From 4e18b8786e175b88e12a1f625620169499626354 Mon Sep 17 00:00:00 2001 From: Elson Correia <13211046+ECorreia45@users.noreply.github.com> Date: Sat, 20 Apr 2024 12:02:37 -0400 Subject: [PATCH] Next (#13) * bump next version * fix format * deprecate executables for dynamic value - deprecate executables for dynamic values - improve logic of handling template - fixe bugs related to state order update * optimize and simplify --- build-scripts/build-browser.ts | 31 ++ docs-src/data/reasons.json | 6 +- package-lock.json | 20 +- package.json | 11 +- src/Doc.spec.ts | 242 +++++++++++ src/Doc.ts | 195 +++++++++ src/Helper.ts | 5 +- .../AttributeDynamicValueResolver.ts | 21 + .../ContentDynamicValueResolver.spec.ts | 397 ++++++++++++++++++ .../ContentDynamicValueResolver.ts | 90 ++++ .../DirectiveDynamicValueResolver.spec.ts | 195 +++++++++ .../DirectiveDynamicValueResolver.ts | 132 ++++++ src/dynamic-value/DynamicValueResolver.ts | 27 ++ .../EventDynamicValueResolver.spec.ts | 74 ++++ .../EventDynamicValueResolver.ts | 37 ++ .../change-current-into-new-nodes.spec.ts} | 56 ++- .../change-current-into-new-nodes.ts} | 9 +- src/dynamic-value/is-dynamic-value.ts | 5 + .../parse-dynamic-raw-value.spec.ts | 68 +++ src/dynamic-value/parse-dynamic-raw-value.ts | 51 +++ ...lve-content-dynamic-value-to-nodes.spec.ts | 132 ++++++ .../resolve-content-dynamic-value-to-nodes.ts | 61 +++ src/dynamic-value/sync-nodes.ts | 23 + src/executable/Doc.ts | 195 --------- ...ct-executable-value-from-raw-value.spec.ts | 45 -- ...extract-executable-value-from-raw-value.ts | 32 -- .../handle-attr-directive-executable.spec.ts | 192 --------- .../handle-attr-directive-executable.ts | 129 ------ src/executable/handle-executable.spec.ts | 232 ---------- src/executable/handle-executable.ts | 212 ---------- src/executable/handle-text-executable.spec.ts | 143 ------- src/executable/handle-text-executable.ts | 30 -- src/helpers/is-not.helper.ts | 5 +- src/helpers/is.helper.ts | 5 +- src/helpers/one-of.helper.ts | 6 +- src/html.spec.ts | 184 +++++--- src/html.ts | 267 +++++------- src/types.ts | 27 +- src/utils/boolean-attributes.ts | 2 +- src/utils/index.ts | 1 + src/utils/is-object-literal.spec.ts | 7 + src/utils/is-object-literal.ts | 12 +- src/utils/is-primitive.spec.ts | 2 +- src/utils/is-primitive.ts | 8 +- src/utils/json-parse.ts | 7 +- src/utils/set-element-attribute.spec.ts | 53 +++ src/utils/set-element-attribute.ts | 18 +- .../shallow-copy-array-or-object-literal.ts | 14 + src/utils/turn-camel-to-kebab-casing.ts | 13 +- src/utils/turn-kebab-to-camel-casing.ts | 2 +- tsconfig.json | 7 +- 51 files changed, 2229 insertions(+), 1509 deletions(-) create mode 100644 build-scripts/build-browser.ts create mode 100644 src/Doc.spec.ts create mode 100644 src/Doc.ts create mode 100644 src/dynamic-value/AttributeDynamicValueResolver.ts create mode 100644 src/dynamic-value/ContentDynamicValueResolver.spec.ts create mode 100644 src/dynamic-value/ContentDynamicValueResolver.ts create mode 100644 src/dynamic-value/DirectiveDynamicValueResolver.spec.ts create mode 100644 src/dynamic-value/DirectiveDynamicValueResolver.ts create mode 100644 src/dynamic-value/DynamicValueResolver.ts create mode 100644 src/dynamic-value/EventDynamicValueResolver.spec.ts create mode 100644 src/dynamic-value/EventDynamicValueResolver.ts rename src/{executable/change-current-into-new-items.spec.ts => dynamic-value/change-current-into-new-nodes.spec.ts} (76%) rename src/{executable/change-current-into-new-items.ts => dynamic-value/change-current-into-new-nodes.ts} (84%) create mode 100644 src/dynamic-value/is-dynamic-value.ts create mode 100644 src/dynamic-value/parse-dynamic-raw-value.spec.ts create mode 100644 src/dynamic-value/parse-dynamic-raw-value.ts create mode 100644 src/dynamic-value/resolve-content-dynamic-value-to-nodes.spec.ts create mode 100644 src/dynamic-value/resolve-content-dynamic-value-to-nodes.ts create mode 100644 src/dynamic-value/sync-nodes.ts delete mode 100644 src/executable/Doc.ts delete mode 100644 src/executable/extract-executable-value-from-raw-value.spec.ts delete mode 100644 src/executable/extract-executable-value-from-raw-value.ts delete mode 100644 src/executable/handle-attr-directive-executable.spec.ts delete mode 100644 src/executable/handle-attr-directive-executable.ts delete mode 100644 src/executable/handle-executable.spec.ts delete mode 100644 src/executable/handle-executable.ts delete mode 100644 src/executable/handle-text-executable.spec.ts delete mode 100644 src/executable/handle-text-executable.ts create mode 100644 src/utils/set-element-attribute.spec.ts create mode 100644 src/utils/shallow-copy-array-or-object-literal.ts diff --git a/build-scripts/build-browser.ts b/build-scripts/build-browser.ts new file mode 100644 index 00000000..5d27ce26 --- /dev/null +++ b/build-scripts/build-browser.ts @@ -0,0 +1,31 @@ +import esbuild, { Plugin } from 'esbuild' + +const emptyParserDoc = { + name: 'empty-parser-Doc-plugin', + setup(build) { + build.onResolve({ filter: /Doc$/ }, (args) => { + if (args.importer.includes('@beforesemicolon/html-parser')) { + return { + path: args.path, + namespace: 'empty-Doc', + } + } + }) + build.onLoad({ filter: /.*/, namespace: 'empty-Doc' }, () => { + return { + contents: '', + } + }) + }, +} as Plugin + +await esbuild.build({ + entryPoints: ['src/client.ts'], + outfile: 'dist/client.js', + bundle: true, + keepNames: true, + sourcemap: true, + target: 'esnext', + minify: true, + plugins: [emptyParserDoc], +}) diff --git a/docs-src/data/reasons.json b/docs-src/data/reasons.json index 97ad740d..00f5c2c5 100644 --- a/docs-src/data/reasons.json +++ b/docs-src/data/reasons.json @@ -23,9 +23,9 @@ "img": "./assets/small.svg", "imgAlt": "small templating system", "breakDown": [ - "< 8kb CDN compressed", - "< 18kb minified", - "< 230kb raw" + "~ 8kb CDN compressed", + "~ 16kb minified", + "~ 175kb raw" ] }, { diff --git a/package-lock.json b/package-lock.json index 35828488..097ed6be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@beforesemicolon/markup", - "version": "0.17.0", + "version": "0.18.5-next", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -2520,6 +2520,15 @@ "@esbuild/win32-x64": "0.19.10" } }, + "esbuild-plugin-text-replace": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/esbuild-plugin-text-replace/-/esbuild-plugin-text-replace-1.3.0.tgz", + "integrity": "sha512-RWB/bbdP0xDHBOtA0st4CAE6UZtky76aCB7Shw5r350JY403lfvrj2UMkInUB346tMtFWXKWXNf4gqNM+WbXag==", + "dev": true, + "requires": { + "ts-replace-all": "^1.0.0" + } + }, "escalade": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", @@ -6694,6 +6703,15 @@ } } }, + "ts-replace-all": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ts-replace-all/-/ts-replace-all-1.0.0.tgz", + "integrity": "sha512-6uBtdkw3jHXkPtx/e9xB/5vcngMm17CyJYsS2YZeQ+9FdRnt6Ev5g931Sg2p+dxbtMGoCm13m3ax/obicTZIkQ==", + "dev": true, + "requires": { + "core-js": "^3.4.1" + } + }, "tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", diff --git a/package.json b/package.json index 9c3bf4f6..727ed782 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@beforesemicolon/markup", - "version": "0.17.0", + "version": "0.18.6-next", "description": "Reactive HTML Templating System", "engines": { "node": ">=18.16.0" @@ -14,17 +14,17 @@ "types": "./dist/types/index.d.ts" }, "scripts": { - "test": "jest", + "test": "jest --coverage", "test:watch": "jest --watch", - "test:coverage": "jest --coverage", "docs:css-watch": "nodemon --watch docs-src -e scss --exec 'npm run docs:css'", "docs:css": "node-sass docs-src/stylesheets -o docs/stylesheets --output-style compressed", "docs:watch": "rm -rf docs && nodemon --watch docs-src -e ts,json --exec 'NODE_ENV=development npm run docs:build'", "docs:build": "tsx docs-src/build.ts", + "local": "nodemon --watch src -e ts --exec 'npm run build:browser'", "build:docs": "rm -rf docs && npm-run-all docs:build docs:css", "build:cjs-min": "esbuild `find src \\( -name '*.ts' ! -name '*.spec.ts' ! -name 'client.ts' \\)` --minify --outdir=dist/cjs --platform=node --format=cjs --keep-names --target=esnext", "build:esm-min": "esbuild `find src \\( -name '*.ts' ! -name '*.spec.ts' ! -name 'client.ts' \\)` --minify --outdir=dist/esm --platform=node --format=esm --keep-names --target=esnext", - "build:browser": "esbuild src/client.ts --bundle --minify --keep-names --sourcemap --target=esnext --outfile=dist/client.js", + "build:browser": "tsx build-scripts/build-browser.ts", "build": "rm -rf dist && npm-run-all lint test && tsc --emitDeclarationOnly && npm-run-all build:cjs-min build:esm-min build:browser", "lint": "eslint ./src && prettier --check .", "format": "eslint ./src --fix && prettier --write ." @@ -55,6 +55,7 @@ "@typescript-eslint/parser": "^6.15.0", "core-js": "^3.34.0", "esbuild": "^0.19.10", + "esbuild-plugin-text-replace": "^1.3.0", "eslint": "^8.56.0", "eslint-config-prettier": "^9.1.0", "eslint-config-standard": "^17.1.0", @@ -76,6 +77,6 @@ "typescript": "^5.3.3" }, "dependencies": { - "@beforesemicolon/html-parser": "*" + "@beforesemicolon/html-parser": "^0.4.0" } } diff --git a/src/Doc.spec.ts b/src/Doc.spec.ts new file mode 100644 index 00000000..f5616aa6 --- /dev/null +++ b/src/Doc.spec.ts @@ -0,0 +1,242 @@ +import { DocumentLike, parse } from "@beforesemicolon/html-parser"; +import { Doc } from "./Doc"; + +describe("Doc", () => { + const dynamicValueCollector = jest.fn(); + let refs: Record> = {}; + + beforeEach(() => { + jest.clearAllMocks(); + refs = {}; + }); + + it("should parse plain html", () => { + const res = parse( + "

simple

", + Doc([], refs, dynamicValueCollector) as unknown as DocumentLike + ); + + expect(res.childNodes.length).toBe(1); + expect(res.childNodes[0]).toBeInstanceOf(HTMLParagraphElement); + expect((res.childNodes[0] as HTMLParagraphElement).outerHTML).toBe("

simple

"); + expect(dynamicValueCollector).not.toHaveBeenCalled(); + }); + + it("should parse comment", () => { + const res = parse( + "", + Doc([], refs, dynamicValueCollector) as unknown as DocumentLike + ); + + expect(res.childNodes.length).toBe(1); + expect(res.childNodes[0]).toBeInstanceOf(Comment); + expect((res.childNodes[0] as Comment).nodeValue).toBe("

simple

"); + expect(dynamicValueCollector).not.toHaveBeenCalled(); + }); + + it("should parse plain html with static injected value", () => { + const res = parse( + "

$val0

", + Doc(["simple"], refs, dynamicValueCollector) as unknown as DocumentLike + ); + + expect(res.childNodes.length).toBe(1); + expect(res.childNodes[0]).toBeInstanceOf(HTMLParagraphElement); + expect((res.childNodes[0] as HTMLParagraphElement).outerHTML).toBe("

simple

"); + expect(dynamicValueCollector).not.toHaveBeenCalled(); + }); + + it("should parse plain html with dynamic injected value", () => { + const fn = () => "simple"; + const res = parse( + "

$val0

", + Doc([fn], refs, dynamicValueCollector) as unknown as DocumentLike + ); + + expect(res.childNodes.length).toBe(1); + expect(res.childNodes[0]).toBeInstanceOf(HTMLParagraphElement); + expect((res.childNodes[0] as HTMLParagraphElement).outerHTML).toBe("

$val0

"); + expect(dynamicValueCollector).toHaveBeenCalledWith({ + data: null, + name: "nodeValue", + value: fn, + rawValue: "$val0", + prop: null, + }); + }); + + it("should parse event attribute", () => { + const handler = () => { + }; + const res = parse( + "", + Doc([handler], {}, dynamicValueCollector) as unknown as DocumentLike + ); + + expect(res.childNodes.length).toBe(1); + expect(res.childNodes[0]).toBeInstanceOf(HTMLButtonElement); + expect((res.childNodes[0] as HTMLButtonElement).outerHTML).toBe(""); + expect(dynamicValueCollector).toHaveBeenCalledTimes(1); + expect(dynamicValueCollector).toHaveBeenCalledWith({ + "data": null, + "name": "onclick", + "prop": "click", + "rawValue": "$val0", + "value": [handler] + }); + }); + + it("should parse event attribute with option", () => { + const handler = () => { + }; + const res = parse( + '', + Doc([handler, {once: true}], {}, dynamicValueCollector) as unknown as DocumentLike + ); + + expect(res.childNodes.length).toBe(1); + expect(res.childNodes[0]).toBeInstanceOf(HTMLButtonElement); + expect((res.childNodes[0] as HTMLButtonElement).outerHTML).toBe(""); + expect(dynamicValueCollector).toHaveBeenCalledTimes(1); + expect(dynamicValueCollector).toHaveBeenCalledWith({ + "data": null, + "name": "onclick", + "prop": "click", + "rawValue": "$val0, $val1", + "value": [handler, ", ", {once: true}] + }); + }); + + it("should parse and ignore unknown event attribute", () => { + const handler = () => { + }; + const res = parse( + "", + Doc([handler], {}, dynamicValueCollector) as unknown as DocumentLike + ); + + expect(res.childNodes.length).toBe(1); + expect(res.childNodes[0]).toBeInstanceOf(HTMLButtonElement); + expect((res.childNodes[0] as HTMLButtonElement).outerHTML).toBe(""); + expect(dynamicValueCollector).toHaveBeenCalledTimes(1); + expect(dynamicValueCollector).toHaveBeenCalledWith({ + "data": null, + "name": "onval", + "prop": null, + "rawValue": "$val0", + "value": [handler] + }); + }); + + it("should parse event attribute for webComponent", () => { + class MyButton extends HTMLElement { + } + + customElements.define("my-button", MyButton); + + const handler = () => { + }; + const res = parse( + "click me", + Doc([handler], {}, dynamicValueCollector) as unknown as DocumentLike + ); + + expect(res.childNodes.length).toBe(1); + expect(res.childNodes[0]).toBeInstanceOf(MyButton); + expect((res.childNodes[0] as MyButton).outerHTML).toBe("click me"); + expect(dynamicValueCollector).toHaveBeenCalledTimes(1); + expect(dynamicValueCollector).toHaveBeenCalledWith({ + "data": null, + "name": "onval", + "prop": "val", + "rawValue": "$val0", + "value": [handler] + }); + }); + + it("should parse ref attribute", () => { + const res = parse( + "", + Doc([], refs, dynamicValueCollector) as unknown as DocumentLike + ); + + expect(res.childNodes.length).toBe(1); + expect(res.childNodes[0]).toBeInstanceOf(HTMLButtonElement); + expect((res.childNodes[0] as HTMLButtonElement).outerHTML).toBe(""); + expect(dynamicValueCollector).not.toHaveBeenCalled(); + expect(refs["btn"].size).toBe(1); + expect(refs["btn"].has(res.childNodes[0] as HTMLButtonElement)).toBeTruthy(); + }); + + it("should handle boolean, class, style and data attributes", () => { + const active = () => true; + const loading = () => false; + const disabled = () => true; + const sample = () => false; + + + const res = parse( + "", + Doc([active, loading, disabled, sample], refs, dynamicValueCollector) as unknown as DocumentLike + ); + + expect(res.childNodes.length).toBe(1); + expect(res.childNodes[0]).toBeInstanceOf(HTMLButtonElement); + expect((res.childNodes[0] as HTMLButtonElement).outerHTML).toBe(''); + expect(dynamicValueCollector).toHaveBeenCalledTimes(4); + + + expect(dynamicValueCollector.mock.calls[0][0]).toEqual({ + "data": null, + "name": "class", + "prop": "active", + "rawValue": "$val0", + "value": [active] + }); + expect(dynamicValueCollector.mock.calls[1][0]).toEqual({ + "data": null, + "name": "style", + "prop": "loading", + "rawValue": "color: blue; | $val1", + "value": ["color: blue; | ", loading] + }); + expect(dynamicValueCollector.mock.calls[2][0]).toEqual({ + "data": null, + "name": "disabled", + "prop": "", + "rawValue": "$val2", + "value": [disabled] + }); + expect(dynamicValueCollector.mock.calls[3][0]).toEqual({ + "data": null, + "name": "data", + "prop": "sample", + "rawValue": "$val3", + "value": [sample] + }); + }); + + it("should parse and ignore boolean attributes with false value", () => { + const res = parse( + "", + Doc([], refs, dynamicValueCollector) as unknown as DocumentLike + ); + + expect(res.childNodes.length).toBe(1); + expect(res.childNodes[0]).toBeInstanceOf(HTMLButtonElement); + expect((res.childNodes[0] as HTMLButtonElement).outerHTML).toBe(""); + expect(dynamicValueCollector).not.toHaveBeenCalled(); + }); + + it("should parse and ignore injected value names", () => { + const res = parse( + '', + Doc(['disabled'], refs, dynamicValueCollector) as unknown as DocumentLike + ); + + expect(res.childNodes.length).toBe(1); + expect(res.childNodes[0]).toBeInstanceOf(HTMLButtonElement); + expect((res.childNodes[0] as HTMLButtonElement).outerHTML).toBe(""); + expect(dynamicValueCollector).not.toHaveBeenCalled(); + }); +}); diff --git a/src/Doc.ts b/src/Doc.ts new file mode 100644 index 00000000..179003bd --- /dev/null +++ b/src/Doc.ts @@ -0,0 +1,195 @@ +import { + booleanAttributes, + element, + ElementOptions, + setElementAttribute, +} from './utils' +import { ContentDynamicValueResolver } from './dynamic-value/ContentDynamicValueResolver' +import { EventDynamicValueResolver } from './dynamic-value/EventDynamicValueResolver' +import { DirectiveDynamicValueResolver } from './dynamic-value/DirectiveDynamicValueResolver' +import { AttributeDynamicValueResolver } from './dynamic-value/AttributeDynamicValueResolver' +import { isDynamicValue } from './dynamic-value/is-dynamic-value' +import { parseDynamicRawValue } from './dynamic-value/parse-dynamic-raw-value' + +type DynamicValueCallback = ( + dynamicVal: + | ContentDynamicValueResolver + | EventDynamicValueResolver + | DirectiveDynamicValueResolver + | AttributeDynamicValueResolver +) => void + +const node = ( + nodeName: string, + ns: ElementOptions['ns'] = '', + values: Array = [], + refs: Record> = {}, + cb: DynamicValueCallback = () => {} +) => { + const node = + nodeName === '#fragment' + ? document.createDocumentFragment() + : element(nodeName, { ns }) + const comp = customElements.get(nodeName.toLowerCase()) + + return { + __self__: node, + namespaceURI: (node as Element).namespaceURI as string, + tagName: nodeName, + childNodes: node.childNodes, + attributes: 'attributes' in node ? node.attributes : null, + textContent: node.textContent, + setAttribute: (name: string, value: string = '') => { + // should ignore dynamically set attribute name + if (/^val[0-9]+$/.test(name)) { + return + } + + const trimmedValue = value.trim() + + if (trimmedValue) { + if (name === 'ref') { + if (!refs[trimmedValue]) { + refs[trimmedValue] = new Set() + } + + refs[trimmedValue].add(node as Element) + return + } + + const attrLessName = name.replace(/^attr\./, '') + const isBollAttr = booleanAttributes[attrLessName.toLowerCase()] + + // boolean attr with false value can just be ignored + if (trimmedValue === 'false' && isBollAttr) { + return + } + + const parts = parseDynamicRawValue(trimmedValue, values) + const dvValue = parts.map((p) => + typeof p === 'string' ? p : p.value + ) + const partsWithDynamicValues = dvValue.some(isDynamicValue) + + if ( + /^on[a-z]+/.test(name) && // @ts-expect-error observedAttributes is property of web component + ((comp && !comp?.observedAttributes?.includes(name)) || + (document.head && + // @ts-expect-error check if know event name + typeof document.head[name] !== 'undefined')) + ) { + return cb( + new EventDynamicValueResolver( + name, + trimmedValue, + dvValue, + [node], + name.slice(2) + ) + ) + } + + if ( + isBollAttr || + /^(attr|class|style|data)/i.test(name) || + trimmedValue.split(/\|/).length > 1 + ) { + let props: string[] = [] + ;[name, ...props] = attrLessName.split('.') + + const dynVal = new DirectiveDynamicValueResolver( + name, + trimmedValue, + dvValue, + [node], + props.join('.') + ) + + if (partsWithDynamicValues) { + cb(dynVal) + } else { + dynVal.resolve() + } + + return + } + + if (partsWithDynamicValues) { + return cb( + new AttributeDynamicValueResolver( + name, + trimmedValue, + dvValue, + [node] + ) + ) + } + + return setElementAttribute(node as Element, name, dvValue[0]) + } + + if ( + // ignore special attributes specific to Markup that did not get handled + !/^(ref|(attr|class|style|data)\.)/.test(name) + ) { + setElementAttribute(node as Element, name, trimmedValue) + } + }, + appendChild: (n: DocumentFragment | Node) => { + // @ts-expect-error __self__ is set by this node + if (n.__self__) { + // @ts-expect-error __self__ is set by this node + n = n.__self__ + } + + if (n.nodeType === 3) { + // text node + + if (n.nodeValue) { + const parts = parseDynamicRawValue(n.nodeValue, values) + + parts.forEach((part) => { + if (part) { + if ( + typeof part !== 'string' && + isDynamicValue(part.value) + ) { + const txtNode = document.createTextNode( + part.text + ) + node.appendChild(txtNode) + + cb( + new ContentDynamicValueResolver( + 'nodeValue', + part.text, + part.value, + [txtNode] + ) + ) + } else { + node.appendChild( + document.createTextNode(String(part)) + ) + } + } + }) + } + } else { + node.appendChild(n as Node) + } + }, + } +} + +export const Doc = ( + values: Array, + refs: Record>, + cb: DynamicValueCallback +) => ({ + createTextNode: (text: string) => document.createTextNode(text), + createComment: (text: string) => document.createComment(text), + createDocumentFragment: () => node('#fragment', '', values, refs, cb), + createElementNS: (ns: ElementOptions['ns'], tagName: string) => + node(tagName, ns, values, refs, cb), +}) diff --git a/src/Helper.ts b/src/Helper.ts index f95f82c5..3d2360c2 100644 --- a/src/Helper.ts +++ b/src/Helper.ts @@ -24,8 +24,7 @@ export class Helper) => unknown> { } } -export const helper = (handler: T) => { - return ((...args: Array) => { +export const helper = (handler: T) => + ((...args: Array) => { return new Helper(handler as (...args: Array) => void, args) }) as typeof handler -} diff --git a/src/dynamic-value/AttributeDynamicValueResolver.ts b/src/dynamic-value/AttributeDynamicValueResolver.ts new file mode 100644 index 00000000..259cc261 --- /dev/null +++ b/src/dynamic-value/AttributeDynamicValueResolver.ts @@ -0,0 +1,21 @@ +import { DynamicValueResolver } from './DynamicValueResolver' +import { setElementAttribute, val } from '../utils' + +export class AttributeDynamicValueResolver extends DynamicValueResolver< + unknown[], + unknown +> { + resolve() { + const newData = val(this.value[0]) + + if (newData !== this.data) { + this.data = newData + + setElementAttribute( + this.renderedNodes[0] as Element, + this.name, + newData + ) + } + } +} diff --git a/src/dynamic-value/ContentDynamicValueResolver.spec.ts b/src/dynamic-value/ContentDynamicValueResolver.spec.ts new file mode 100644 index 00000000..138d6f71 --- /dev/null +++ b/src/dynamic-value/ContentDynamicValueResolver.spec.ts @@ -0,0 +1,397 @@ +import { html, state } from '../html' +import { is, when } from '../helpers' +import { ContentDynamicValueResolver } from './ContentDynamicValueResolver' + +describe('ContentDynamicValueResolver', () => { + const renderContent = (value: any, data = null, renderedNodes = [document.createTextNode('$val0')]) => { + const refs = {} + document.body.innerHTML = ''; + document.body.append(...renderedNodes) + + const dynamicValue = new ContentDynamicValueResolver( + 'nodeValue', + '$val0', + value, + renderedNodes + ) + dynamicValue.data = data; + + dynamicValue.resolve(refs) + + return { + dynamicValue, + refs, + update: () => { + dynamicValue.resolve(refs); + } + }; + } + + it('should render and update a dynamic text', () => { + let txt = 'sample'; + const {dynamicValue, update} = renderContent(() => txt) + + expect(dynamicValue.renderedNodes).toHaveLength(1); + expect(dynamicValue.renderedNodes[0]).toBeInstanceOf(Text); + expect(dynamicValue.renderedNodes[0].nodeValue).toBe('sample'); + expect(dynamicValue.data).toBe('sample'); + expect(document.body.innerHTML).toBe('sample') + + txt = 'changed'; + + update() + + expect(dynamicValue.renderedNodes).toHaveLength(1); + expect(dynamicValue.renderedNodes[0]).toBeInstanceOf(Text); + expect(dynamicValue.renderedNodes[0].nodeValue).toBe('changed'); + expect(dynamicValue.data).toBe('changed'); + expect(document.body.innerHTML).toBe('changed') + }) + + it('should render and update a list of text', () => { + let list = [() => 'one', () => 'two', () => 'three']; + const {dynamicValue, update} = renderContent(list) + + expect(dynamicValue.renderedNodes).toHaveLength(3); + expect(dynamicValue.renderedNodes.every(node => node instanceof Text)).toBe(true); + expect(dynamicValue.renderedNodes[0].nodeValue).toBe('one'); + expect(dynamicValue.renderedNodes[1].nodeValue).toBe('two'); + expect(dynamicValue.renderedNodes[2].nodeValue).toBe('three'); + expect(document.body.innerHTML).toBe('onetwothree') + + list.pop() + + update() + + expect(dynamicValue.renderedNodes).toHaveLength(2); + expect(dynamicValue.renderedNodes.every(node => node instanceof Text)).toBe(true); + expect(dynamicValue.renderedNodes[0].nodeValue).toBe('one'); + expect(dynamicValue.renderedNodes[1].nodeValue).toBe('two'); + expect(document.body.innerHTML).toBe('onetwo') + }) + + it('should render and update a dynamic conditional template', () => { + let x = 15 + const a = html`

more than 10

` + const b = html`

less than 10

` + const {dynamicValue, update} = renderContent( + () => x > 10 ? html`>${a}` : html`<${b}` + ) + + expect(a.nodes).toHaveLength(1); + expect(a.mounted).toBeTruthy(); + expect(b.nodes).toHaveLength(0); + expect(b.mounted).toBeFalsy(); + expect(dynamicValue.renderedNodes).toHaveLength(2); + expect(dynamicValue.renderedNodes[0]).toBeInstanceOf(Text); + expect(dynamicValue.renderedNodes[0].nodeValue).toBe('>'); + expect((dynamicValue.renderedNodes[1] as Element).outerHTML).toBe('

more than 10

'); + expect(document.body.innerHTML).toBe('>

more than 10

') + + x = 5; + + update(); + + expect(a.nodes).toHaveLength(0); + expect(a.mounted).toBeFalsy(); + expect(b.nodes).toHaveLength(1); + expect(b.mounted).toBeTruthy(); + expect(dynamicValue.renderedNodes).toHaveLength(2); + expect(dynamicValue.renderedNodes[0]).toBeInstanceOf(Text); + expect(dynamicValue.renderedNodes[0].nodeValue).toBe('<'); + expect((dynamicValue.renderedNodes[1] as Element).outerHTML).toBe('

less than 10

'); + expect(document.body.innerHTML).toBe('<

less than 10

') + }) + + it('should render and update a template', () => { + let txt = 'sample'; + const temp = html`${() => txt}`; + const {dynamicValue, update} = renderContent(temp) + + expect(dynamicValue.renderedNodes).toHaveLength(1); + expect(dynamicValue.renderedNodes[0]).toBeInstanceOf(Text); + expect(dynamicValue.renderedNodes[0].nodeValue).toBe('sample'); + expect(dynamicValue.data).toBe(temp); + expect(document.body.innerHTML).toBe('sample') + + txt = 'changed'; + + update() + + expect(dynamicValue.renderedNodes).toHaveLength(1); + expect(dynamicValue.renderedNodes[0]).toBeInstanceOf(Text); + expect(dynamicValue.renderedNodes[0].nodeValue).toBe('changed'); + expect(dynamicValue.data).toBe(temp); + expect(document.body.innerHTML).toBe('changed') + }) + + it('should render and update nested templates', () => { + let txt = 'sample'; + const temp = html`${html`${() => txt}`}`; + const {dynamicValue, update} = renderContent(temp) + + expect(dynamicValue.renderedNodes).toHaveLength(1); + expect(dynamicValue.renderedNodes[0]).toBeInstanceOf(Text); + expect(dynamicValue.renderedNodes[0].nodeValue).toBe('sample'); + expect(dynamicValue.data).toBe(temp); + expect(document.body.innerHTML).toBe('sample') + + txt = 'changed'; + + update() + + expect(dynamicValue.renderedNodes).toHaveLength(1); + expect(dynamicValue.renderedNodes[0]).toBeInstanceOf(Text); + expect(dynamicValue.renderedNodes[0].nodeValue).toBe('changed'); + expect(dynamicValue.data).toBe(temp); + expect(document.body.innerHTML).toBe('changed') + }) + + it('should render and update deeply nested templates', () => { + let txt = 'sample'; + const temp = html`${html`${html`${html`${() => txt}`}`}`}`; + const {dynamicValue, update} = renderContent(temp) + + expect(dynamicValue.renderedNodes).toHaveLength(1); + expect(dynamicValue.renderedNodes[0]).toBeInstanceOf(Text); + expect(dynamicValue.renderedNodes[0].nodeValue).toBe('sample'); + expect(dynamicValue.data).toBe(temp); + expect(document.body.innerHTML).toBe('sample') + + txt = 'changed'; + + update() + + expect(dynamicValue.renderedNodes).toHaveLength(1); + expect(dynamicValue.renderedNodes[0]).toBeInstanceOf(Text); + expect(dynamicValue.renderedNodes[0].nodeValue).toBe('changed'); + expect(dynamicValue.data).toBe(temp); + expect(document.body.innerHTML).toBe('changed') + }) + + it('should render and update when helper with templates', () => { + let condition = true; + const temp = html`${when(() => condition, 'truthy', 'falsy')}`; + const {dynamicValue, update} = renderContent(temp) + + expect(dynamicValue.renderedNodes).toHaveLength(1); + expect(dynamicValue.renderedNodes[0]).toBeInstanceOf(Text); + expect(dynamicValue.renderedNodes[0].nodeValue).toBe('truthy'); + expect(dynamicValue.data).toBe(temp); + expect(document.body.innerHTML).toBe('truthy') + + condition = false; + + update() + + expect(dynamicValue.renderedNodes).toHaveLength(1); + expect(dynamicValue.renderedNodes[0]).toBeInstanceOf(Text); + expect(dynamicValue.renderedNodes[0].nodeValue).toBe('falsy'); + expect(dynamicValue.data).toBe(temp); + expect(document.body.innerHTML).toBe('falsy') + + condition = true; + + update() + + expect(dynamicValue.renderedNodes).toHaveLength(1); + expect(dynamicValue.renderedNodes[0]).toBeInstanceOf(Text); + expect(dynamicValue.renderedNodes[0].nodeValue).toBe('truthy'); + expect(dynamicValue.data).toBe(temp); + expect(document.body.innerHTML).toBe('truthy') + }) + + it('should render and update nested when helper with templates', () => { + let condition1 = true; + let condition2 = true; + const temp = html`${ + when(() => condition1, + html`${ + when(() => condition2, + 'deep-truthy', + 'deep-falsy' + )}after` + , + 'falsy') + }`; + const {dynamicValue, update} = renderContent(temp) + + expect(dynamicValue.renderedNodes).toHaveLength(2); + expect(dynamicValue.renderedNodes[0]).toBeInstanceOf(Text); + expect(dynamicValue.renderedNodes[0].nodeValue).toBe('deep-truthy'); + expect(dynamicValue.data).toBe(temp); + expect(document.body.innerHTML).toBe('deep-truthyafter') + + condition1 = false; + + update() + + expect(dynamicValue.renderedNodes).toHaveLength(1); + expect(dynamicValue.renderedNodes[0]).toBeInstanceOf(Text); + expect(dynamicValue.renderedNodes[0].nodeValue).toBe('falsy'); + expect(dynamicValue.data).toBe(temp); + expect(document.body.innerHTML).toBe('falsy') + + condition1 = true; + condition2 = false; + + update() + + expect(dynamicValue.renderedNodes).toHaveLength(2); + expect(dynamicValue.renderedNodes[0]).toBeInstanceOf(Text); + expect(dynamicValue.renderedNodes[0].nodeValue).toBe('deep-falsy'); + expect(dynamicValue.data).toBe(temp); + expect(document.body.innerHTML).toBe('deep-falsyafter') + + condition1 = false; + + update() + + expect(dynamicValue.renderedNodes).toHaveLength(1); + expect(dynamicValue.renderedNodes[0]).toBeInstanceOf(Text); + expect(dynamicValue.renderedNodes[0].nodeValue).toBe('falsy'); + expect(dynamicValue.data).toBe(temp); + expect(document.body.innerHTML).toBe('falsy') + + condition1 = true; + + update() + + expect(dynamicValue.renderedNodes).toHaveLength(2); + expect(dynamicValue.renderedNodes[0]).toBeInstanceOf(Text); + expect(dynamicValue.renderedNodes[0].nodeValue).toBe('deep-falsy'); + expect(dynamicValue.data).toBe(temp); + expect(document.body.innerHTML).toBe('deep-falsyafter') + }) + + it('should render and update deeply nested when helper with templates', () => { + let condition1 = true; + let condition2 = true; + let condition3 = true; + const temp = html`${ + when(() => condition1, + html`${ + when(() => condition2, + 'deep-truthy', + 'deep-falsy' + )}${when(() => condition3, + 'after-truthy', + 'after-falsy' + )}` + , + 'falsy') + }`; + const {dynamicValue, update} = renderContent(temp) + + expect(dynamicValue.renderedNodes).toHaveLength(2); + expect(dynamicValue.renderedNodes[0]).toBeInstanceOf(Text); + expect(dynamicValue.renderedNodes[0].nodeValue).toBe('deep-truthy'); + expect(dynamicValue.data).toBe(temp); + expect(document.body.innerHTML).toBe('deep-truthyafter-truthy') + + condition3 = false; + + update() + + expect(dynamicValue.renderedNodes).toHaveLength(2); + expect(dynamicValue.renderedNodes[0]).toBeInstanceOf(Text); + expect(dynamicValue.renderedNodes[0].nodeValue).toBe('deep-truthy'); + expect(dynamicValue.data).toBe(temp); + expect(document.body.innerHTML).toBe('deep-truthyafter-falsy') + + condition2 = false; + + update() + + expect(dynamicValue.renderedNodes).toHaveLength(2); + expect(dynamicValue.renderedNodes[0]).toBeInstanceOf(Text); + expect(dynamicValue.renderedNodes[0].nodeValue).toBe('deep-falsy'); + expect(dynamicValue.data).toBe(temp); + expect(document.body.innerHTML).toBe('deep-falsyafter-falsy') + + condition1 = false; + + update() + + expect(dynamicValue.renderedNodes).toHaveLength(1); + expect(dynamicValue.renderedNodes[0]).toBeInstanceOf(Text); + expect(dynamicValue.renderedNodes[0].nodeValue).toBe('falsy'); + expect(dynamicValue.data).toBe(temp); + expect(document.body.innerHTML).toBe('falsy') + + condition1 = true; + condition3 = true; + + update() + + expect(dynamicValue.renderedNodes).toHaveLength(2); + expect(dynamicValue.renderedNodes[0]).toBeInstanceOf(Text); + expect(dynamicValue.renderedNodes[0].nodeValue).toBe('deep-falsy'); + expect(dynamicValue.data).toBe(temp); + expect(document.body.innerHTML).toBe('deep-falsyafter-truthy') + + condition2 = true; + + update() + + expect(dynamicValue.renderedNodes).toHaveLength(2); + expect(dynamicValue.renderedNodes[0]).toBeInstanceOf(Text); + expect(dynamicValue.renderedNodes[0].nodeValue).toBe('deep-truthy'); + expect(dynamicValue.data).toBe(temp); + expect(document.body.innerHTML).toBe('deep-truthyafter-truthy') + }) + + it("should render nested 'when' helper with 'is' helper for condition and 'state' for data update", () => { + const [currentPlayer, setCurrentPlayer] = state("x") + const [ended, setEnded] = state(false); + + const temp = html`${when(ended, html` + ${when(is(currentPlayer, 'xy'), + html`tie`, + html`${currentPlayer} won` + )} + `)}`; + const {dynamicValue, update} = renderContent(temp) + + expect(dynamicValue.renderedNodes).toHaveLength(1); + expect(dynamicValue.renderedNodes[0].nodeValue).toBe(''); + expect(dynamicValue.data).toBe(temp); + expect(document.body.innerHTML).toBe('') + + setEnded(true); + + expect(document.body.innerHTML).toBe('x won') + + setCurrentPlayer('xy') + + expect(document.body.innerHTML).toBe('tie') + + setEnded(false) + + expect(document.body.innerHTML).toBe('') + + setCurrentPlayer('x') + + expect(document.body.innerHTML).toBe('') + + setEnded(true) + + expect(dynamicValue.renderedNodes).toHaveLength(3); + expect(dynamicValue.renderedNodes[0].nodeValue).toBe('x'); + expect(dynamicValue.renderedNodes[1].nodeValue).toBe(' won'); + expect((dynamicValue.renderedNodes[2] as Element).outerHTML).toBe(''); + expect(dynamicValue.data).toBe(temp); + expect(document.body.innerHTML).toBe('x won') + }); + + // todo: html.spec.ts has these tests but moving them here makes it easier to debug + it.todo('should render and update repeat helper with primitives') + it.todo('should render and update nested repeat helper with primitives') + it.todo('should render and update deeply nested repeat helper with primitives') + it.todo('should render and update repeat helper with objects') + it.todo('should render and update nested repeat helper with objects') + it.todo('should render and update deeply nested repeat helper with objects') + it.todo('should render and update repeat helper with templates') + it.todo('should render and update nested repeat helper with templates') + it.todo('should render and update deeply nested repeat helper with templates') +}) diff --git a/src/dynamic-value/ContentDynamicValueResolver.ts b/src/dynamic-value/ContentDynamicValueResolver.ts new file mode 100644 index 00000000..43dc4f9a --- /dev/null +++ b/src/dynamic-value/ContentDynamicValueResolver.ts @@ -0,0 +1,90 @@ +import { DynamicValueResolver } from './DynamicValueResolver' +import { shallowCopyArrayOrObjectLiteral, val } from '../utils' +import { HtmlTemplate } from '../html' +import { resolveContentDynamicValueToNodes } from './resolve-content-dynamic-value-to-nodes' +import { syncNodes } from './sync-nodes' + +const getDataAsList = (data: unknown): unknown[] => { + if (!data) return [] + + if (Array.isArray(data)) { + return data.flat(Infinity) + } + + return [data] +} + +export class ContentDynamicValueResolver extends DynamicValueResolver { + get renderedNodes() { + return this.data instanceof HtmlTemplate + ? this.data.nodes + : super.renderedNodes + } + + set renderedNodes(newNodes: Node[]) { + super.renderedNodes = Array.from(new Set(newNodes)) + } + + resolve(refs: Record>) { + const newData = val(this.value) + const isTemplate = newData instanceof HtmlTemplate + + if (isTemplate || newData !== this.data) { + const currentNodes = this.renderedNodes + // if the template is already mounted we just need to resolve it to + // a list of nodes and move on as its internal dynamic value will + // sync the nodes depending on the type otherwise when we try to + // resolve them again, we will end up unmounting them + if (isTemplate && newData.mounted) { + resolveContentDynamicValueToNodes(newData, currentNodes, refs) + } else { + super.renderedNodes = syncNodes( + currentNodes, + resolveContentDynamicValueToNodes( + newData, + currentNodes, + refs + ), + // need to get the parentNode of the renderedNodes before + // resolveContentDynamicValueToNodes because that could unmount a node further down + currentNodes.find((n) => n.parentNode !== null)?.parentNode + ) + } + + // the syncNodes only deals with Nodes but at the data level + // we could have HTMLTemplates which nodes were unmounted but the template + // reference would still be around so therefore we need to find those which + // are not longer being tracked and unmount them + if (this.data) { + const newDataList = new Set(getDataAsList(newData)) + const currentDataList = getDataAsList(this.data) + + for (const v of currentDataList) { + if (v instanceof HtmlTemplate && !newDataList.has(v)) { + v.unmount() + } + } + } + + // make a shallow copy of the data just in case the user updates the object in place + // which means the tracked data would also be updated and cause issues + this.data = shallowCopyArrayOrObjectLiteral(newData) + } + } + + unmount() { + unmount(this.data) + } +} + +function unmount(data: unknown) { + data = val(data) + + if (data instanceof HtmlTemplate) { + return data.unmount() + } + + if (Array.isArray(data)) { + return data.forEach(unmount) + } +} diff --git a/src/dynamic-value/DirectiveDynamicValueResolver.spec.ts b/src/dynamic-value/DirectiveDynamicValueResolver.spec.ts new file mode 100644 index 00000000..4e5939e4 --- /dev/null +++ b/src/dynamic-value/DirectiveDynamicValueResolver.spec.ts @@ -0,0 +1,195 @@ +import { DirectiveDynamicValueResolver } from './DirectiveDynamicValueResolver' + +describe('DirectiveDynamicValueResolver', () => { + let div: HTMLDivElement + + beforeEach(() => { + div = document.createElement('div') + }) + + it('should handle style attribute', () => { + const e1 = new DirectiveDynamicValueResolver( + 'style', + 'bold | true', + ['bold | true'], + [div], + 'font-style' + ) + + const e2 = new DirectiveDynamicValueResolver( + 'style', + 'background-color: #900 | true', + ['background-color: #900 | true'], + [div], + '' + ) + + e1.resolve() + e2.resolve() + + expect(div.outerHTML).toBe( + '
' + ) + expect(e1.data).toBe('bold | true') + expect(e2.data).toBe('background-color: #900 | true') + + e2.value = ['background-color: #900 | false'] + + e2.resolve() + + expect(div.outerHTML).toBe('
') + + expect(e2.data).toBe('background-color: #900 | false') + + e1.value = ['bold | false'] + + e1.resolve() + + expect(div.outerHTML).toBe('
') + + expect(e1.data).toBe('bold | false') + }) + + it('should handle class attribute', () => { + const e1 = new DirectiveDynamicValueResolver( + 'class', + 'false', + ['false'], + [div], + 'simple-cls' + ) + + const e2 = new DirectiveDynamicValueResolver( + 'class', + 'sample | true', + ['sample | true'], + [div], + '' + ) + + e1.resolve() + e2.resolve() + + expect(div.outerHTML).toBe('
') + expect(e1.data).toBe('false') + expect(e2.data).toBe('sample | true') + + e1.value = ['true'] + + e1.resolve() + + expect(div.outerHTML).toBe('
') + + expect(e1.data).toBe('true') + + e2.value = ['sample | false'] + + e2.resolve() + + expect(div.outerHTML).toBe('
') + + expect(e2.data).toBe('sample | false') + }) + + it('should handle data attribute', () => { + const e1 = new DirectiveDynamicValueResolver( + 'data', + 'val | true', + ['val | true'], + [div], + 'simple-val' + ); + + const e2 = new DirectiveDynamicValueResolver( + 'data', + 'simple-val | true', + ['simple-val | true'], + [div], + '' + ); + + e1.resolve() + e2.resolve() + + expect(div.outerHTML).toBe('
') + expect(e1.data).toBe('val | true') + expect(e2.data).toBe('simple-val | true') + + e1.value = ['false'] + + e1.resolve() + + expect(div.outerHTML).toBe('
') + + expect(e1.data).toBe('false') + }) + + it('should handle boolean attribute', () => { + const e1 = new DirectiveDynamicValueResolver( + 'disabled', + 'true', + ['true'], + [div], + '' + ) + + const e2 = new DirectiveDynamicValueResolver( + 'hidden', + 'until-found | true', + ['until-found | true'], + [div], + '' + ); + + e1.resolve() + e2.resolve() + + expect(div.outerHTML).toBe( + '' + ) + expect(e1.data).toBe('true') + expect(e2.data).toBe('until-found | true') + + e1.value = ['false'] + + e1.resolve() + + expect(div.outerHTML).toBe('') + + expect(e1.data).toBe('false') + }) + + it('should handle all other attributes', () => { + const e1 = new DirectiveDynamicValueResolver( + 'id', + 'sample | true', + ['sample | true'], + [div], + '' + ); + + const e2 = new DirectiveDynamicValueResolver( + 'aria-disabled', + 'true', + ['true'], + [div], + '' + ); + + e1.resolve() + e2.resolve() + + expect(div.outerHTML).toBe( + '
' + ) + expect(e1.data).toBe('sample | true') + expect(e2.data).toBe('true') + + e1.value = ['sample | false'] + + e1.resolve() + + expect(div.outerHTML).toBe('
') + expect(e1.data).toBe('sample | false') + }) +}) diff --git a/src/dynamic-value/DirectiveDynamicValueResolver.ts b/src/dynamic-value/DirectiveDynamicValueResolver.ts new file mode 100644 index 00000000..8b687cfd --- /dev/null +++ b/src/dynamic-value/DirectiveDynamicValueResolver.ts @@ -0,0 +1,132 @@ +import { + booleanAttributes, + jsonParse, + jsonStringify, + setElementAttribute, + turnCamelToKebabCasing, + turnKebabToCamelCasing, + val, +} from '../utils' +import { DynamicValueResolver } from './DynamicValueResolver' + +export class DirectiveDynamicValueResolver extends DynamicValueResolver< + unknown[], + string +> { + resolve() { + const newData = this.value.map((v) => jsonStringify(val(v))).join('') + + if (newData !== this.data) { + this.data = newData + const { name: attrName, prop: property } = this + const element = this.renderedNodes[0] as HTMLElement + const valueParts = this.data.split(/\|/) + const [value, condition] = valueParts.map((s) => s.trim()) + const parsedValue = jsonParse(value) + let shouldAdd = Boolean( + valueParts.length > 1 + ? typeof condition === 'string' + ? jsonParse(condition) + : condition + : parsedValue + ) + + switch (attrName) { + case 'style': + if (property) { + element.style.setProperty( + property, + shouldAdd ? value : '' + ) + } else { + value + .match(/([a-z][a-z-]+)(?=:):([^;]+)/g) + ?.forEach((style: string) => { + const [name, styleValue] = style + .split(':') + .map((s) => s.trim()) + + if (shouldAdd) { + element.style.setProperty(name, styleValue) + } else { + element.style.removeProperty(name) + } + }) + } + + if (!element.style.length) { + element.removeAttribute('style') + } + + break + case 'class': + if (property) { + if (shouldAdd) { + element.classList.add(property) + } else { + element.classList.remove(property) + } + } else { + // ''.split(/\s+/g) results in [''] which will fail in classList actions + ;(value ? value.split(/\s+/g) : []).forEach( + (cls: string) => { + if (shouldAdd) { + element.classList.add(cls) + } else { + element.classList.remove(cls) + } + } + ) + } + + if (!element.classList.length) { + element.removeAttribute('class') + } + break + case 'data': + if (property) { + if (shouldAdd) { + element.dataset[turnKebabToCamelCasing(property)] = + value + } else { + element.removeAttribute( + `data-${turnCamelToKebabCasing(property)}` + ) + } + } + break + default: + if (attrName) { + const boolAttr = booleanAttributes[attrName] + const boolAttributeWithValidPossibleValue = + boolAttr?.possibleValues && + boolAttr.possibleValues.includes(value) + // when it's a native boolean attribute, the value itself should be the flag to whether add/remove it + // unless it is a boolean attribute with a valid possible value + shouldAdd = + boolAttr && + !boolAttributeWithValidPossibleValue && + typeof parsedValue === 'boolean' + ? parsedValue + : shouldAdd + + if (shouldAdd) { + if (boolAttr) { + setElementAttribute( + element, + attrName, + boolAttributeWithValidPossibleValue + ? value + : 'true' + ) + } else { + setElementAttribute(element, attrName, value) + } + } else { + element.removeAttribute(attrName) + } + } + } + } + } +} diff --git a/src/dynamic-value/DynamicValueResolver.ts b/src/dynamic-value/DynamicValueResolver.ts new file mode 100644 index 00000000..d61a334c --- /dev/null +++ b/src/dynamic-value/DynamicValueResolver.ts @@ -0,0 +1,27 @@ +export class DynamicValueResolver { + data: D | null = null + #renderedNodes: Node[] = [] + + get renderedNodes() { + return this.#renderedNodes + } + + set renderedNodes(newNodes: Node[]) { + this.#renderedNodes = Array.from(new Set(newNodes)) + } + + constructor( + protected name: string, + protected rawValue: string, + public value: V, + initialNodes: Node[], + public prop: string | null = null + ) { + this.#renderedNodes = initialNodes + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + resolve(refs?: Record>) {} + + unmount() {} +} diff --git a/src/dynamic-value/EventDynamicValueResolver.spec.ts b/src/dynamic-value/EventDynamicValueResolver.spec.ts new file mode 100644 index 00000000..7abdbad7 --- /dev/null +++ b/src/dynamic-value/EventDynamicValueResolver.spec.ts @@ -0,0 +1,74 @@ +import { EventDynamicValueResolver } from './EventDynamicValueResolver' + +describe('EventDynamicValueResolver', () => { + it('should throw error if no function is provided', () => { + const btn = document.createElement('button') + + expect(() => new EventDynamicValueResolver('click', '$val0', [], [btn], '').resolve()).toThrowError('handler for event "click" is not a function. Found "undefined".') + }) + it('should set event listener', () => { + const btn = document.createElement('button') + const fn = jest.fn() + + new EventDynamicValueResolver('onclick', '$val0', [fn], [btn], 'click').resolve() + + btn.click() + btn.click() + btn.click() + + expect(fn).toHaveBeenCalledTimes(3) + }) + + it('should set event listener with boolean option', () => { + const box = document.createElement('div') + const fn = jest.fn() + + new EventDynamicValueResolver('onscroll', '$val0', [fn, true], [box], 'scroll').resolve() + + const scrollEvent = new Event('scroll') + box.dispatchEvent(scrollEvent) + box.dispatchEvent(scrollEvent) + box.dispatchEvent(scrollEvent) + + expect(fn).toHaveBeenCalledTimes(3) + }) + + it('should set event listener with object option', () => { + const btn = document.createElement('button') + const fn = jest.fn() + + new EventDynamicValueResolver('onclick', '$val0', [fn, { once: true }], [btn], 'click').resolve() + + btn.click() + btn.click() + btn.click() + + expect(fn).toHaveBeenCalledTimes(1) + }) + + it('should set event listener with string option', () => { + const btn = document.createElement('button') + const fn = jest.fn() + + new EventDynamicValueResolver('onclick', '$val0,{"once": true}', [fn], [btn], 'click').resolve() + + btn.click() + btn.click() + btn.click() + + expect(fn).toHaveBeenCalledTimes(1) + }) + + it('should set event listener without string option if invalid', () => { + const btn = document.createElement('button') + const fn = jest.fn() + + new EventDynamicValueResolver('onclick', '$val0, sample', [fn], [btn], 'click').resolve() + + btn.click() + btn.click() + btn.click() + + expect(fn).toHaveBeenCalledTimes(3) + }) +}) diff --git a/src/dynamic-value/EventDynamicValueResolver.ts b/src/dynamic-value/EventDynamicValueResolver.ts new file mode 100644 index 00000000..664c9f1a --- /dev/null +++ b/src/dynamic-value/EventDynamicValueResolver.ts @@ -0,0 +1,37 @@ +import { isObjectLiteral, jsonParse } from '../utils' +import { DynamicValueResolver } from './DynamicValueResolver' + +export class EventDynamicValueResolver extends DynamicValueResolver< + unknown[], + EventListener +> { + resolve() { + const eventHandler = this.value.find( + (p) => typeof p === 'function' + ) as EventListenerOrEventListenerObject + + if (typeof eventHandler !== 'function') { + throw new Error( + `handler for event "${this.name}" is not a function. Found "${this.value[0]}".` + ) + } + + if (this.data !== eventHandler) { + const node = this.renderedNodes[0] + const option = + this.value.length > 1 + ? this.value.find( + (p) => typeof p === 'boolean' || isObjectLiteral(p) + ) + : jsonParse(this.rawValue.split(',')[1]) + + node.addEventListener( + this.prop as string, + eventHandler, + option as AddEventListenerOptions + ) + + this.data = eventHandler + } + } +} diff --git a/src/executable/change-current-into-new-items.spec.ts b/src/dynamic-value/change-current-into-new-nodes.spec.ts similarity index 76% rename from src/executable/change-current-into-new-items.spec.ts rename to src/dynamic-value/change-current-into-new-nodes.spec.ts index bf657b99..92f8d15a 100644 --- a/src/executable/change-current-into-new-items.spec.ts +++ b/src/dynamic-value/change-current-into-new-nodes.spec.ts @@ -1,6 +1,6 @@ -import { changeCurrentIntoNewItems } from './change-current-into-new-items' +import { changeCurrentIntoNewNodes } from './change-current-into-new-nodes' -describe('changeCurrentIntoNewItems', () => { +describe('changeCurrentIntoNewNodes', () => { const ul = document.createElement('ul') const nodes = Array.from({ length: 10 }, (_, i) => { @@ -9,18 +9,16 @@ describe('changeCurrentIntoNewItems', () => { return li }) - - const endAnchor = document.createComment('') - + beforeEach(() => { ul.innerHTML = '' - nodes.forEach((n) => ul.appendChild(n)) + ul.append(...nodes) }) it('should add all new items', () => { ul.innerHTML = '' - changeCurrentIntoNewItems([], nodes, ul) + changeCurrentIntoNewNodes([], nodes, ul) expect(ul.children).toHaveLength(nodes.length) }) @@ -29,7 +27,7 @@ describe('changeCurrentIntoNewItems', () => { ul.innerHTML = '' ul.appendChild(nodes[0]) - changeCurrentIntoNewItems([nodes[0]], [nodes[0], nodes[1]], ul) + changeCurrentIntoNewNodes([nodes[0]], [nodes[0], nodes[1]], ul) expect(ul.children).toHaveLength(2) }) @@ -37,7 +35,7 @@ describe('changeCurrentIntoNewItems', () => { it('should remove all new items', () => { expect(ul.children).toHaveLength(nodes.length) - changeCurrentIntoNewItems(Array.from(ul.children), [], ul) + changeCurrentIntoNewNodes(Array.from(ul.children), [], ul) expect(ul.children).toHaveLength(0) }) @@ -53,7 +51,7 @@ describe('changeCurrentIntoNewItems', () => { 'item 4', ]) - changeCurrentIntoNewItems(Array.from(ul.children), nodes.slice(5), ul) + changeCurrentIntoNewNodes(Array.from(ul.children), nodes.slice(5), ul) expect(Array.from(ul.children, (n) => n.textContent)).toEqual([ 'item 6', @@ -67,7 +65,7 @@ describe('changeCurrentIntoNewItems', () => { it('should remove items from the start', () => { expect(ul.children).toHaveLength(nodes.length) - changeCurrentIntoNewItems(Array.from(ul.children), nodes.slice(2), ul) + changeCurrentIntoNewNodes(Array.from(ul.children), nodes.slice(2), ul) expect(ul.children).toHaveLength(8) expect(ul.children[0].textContent).toBe('item 3') @@ -99,7 +97,7 @@ describe('changeCurrentIntoNewItems', () => { 'item 10', ]) - changeCurrentIntoNewItems( + changeCurrentIntoNewNodes( Array.from(ul.children), [...startNodes, ...endNodes], ul @@ -121,7 +119,7 @@ describe('changeCurrentIntoNewItems', () => { it('should remove items from the end', () => { expect(ul.children).toHaveLength(nodes.length) - changeCurrentIntoNewItems( + changeCurrentIntoNewNodes( Array.from(ul.children), nodes.slice(0, -2), ul @@ -167,7 +165,7 @@ describe('changeCurrentIntoNewItems', () => { 'item 1', ]) - changeCurrentIntoNewItems(Array.from(ul.children), reversedNodes, ul) + changeCurrentIntoNewNodes(Array.from(ul.children), reversedNodes, ul) expect(Array.from(ul.children, (n) => n.textContent)).toEqual([ 'item 10', @@ -217,7 +215,7 @@ describe('changeCurrentIntoNewItems', () => { 'item 6', ]) - changeCurrentIntoNewItems(Array.from(ul.children), shuffledNodes, ul) + changeCurrentIntoNewNodes(Array.from(ul.children), shuffledNodes, ul) expect(Array.from(ul.children, (n) => n.textContent)).toEqual([ 'item 9', @@ -236,14 +234,38 @@ describe('changeCurrentIntoNewItems', () => { const edit = document.createElement('edit') const archive = document.createElement('archive') - changeCurrentIntoNewItems([], [complete, edit, archive], ul) + changeCurrentIntoNewNodes([], [complete, edit, archive], ul) expect(ul.innerHTML).toBe( '' ) - changeCurrentIntoNewItems([complete, edit, archive], [archive], ul) + changeCurrentIntoNewNodes([complete, edit, archive], [archive], ul) expect(ul.innerHTML).toBe('') }) + + it('should handle first item moved with end anchor', () => { + const parent = document.createElement('div'); + const span1 = document.createElement('span'); + const span2 = document.createElement('span'); + const span3 = document.createElement('span'); + const span4 = document.createElement('span'); + parent.appendChild(span1) + parent.appendChild(span2) + parent.appendChild(span4) + + expect(parent.children).toHaveLength(3) + expect(parent.children[0]).toEqual(span1) + expect(parent.children[1]).toEqual(span2) + expect(parent.children[2]).toEqual(span4) + + changeCurrentIntoNewNodes([span1, span2], [span3, span2, span1], parent); + + expect(parent.children).toHaveLength(4) + expect(parent.children[0]).toEqual(span3) + expect(parent.children[1]).toEqual(span2) + expect(parent.children[2]).toEqual(span1) + expect(parent.children[3]).toEqual(span4) + }) }) diff --git a/src/executable/change-current-into-new-items.ts b/src/dynamic-value/change-current-into-new-nodes.ts similarity index 84% rename from src/executable/change-current-into-new-items.ts rename to src/dynamic-value/change-current-into-new-nodes.ts index 8ca04b69..7c54737e 100644 --- a/src/executable/change-current-into-new-items.ts +++ b/src/dynamic-value/change-current-into-new-nodes.ts @@ -6,15 +6,14 @@ * @param newChildNodes - list of nodes of how the parent node child nodes should become * @param parent */ -export const changeCurrentIntoNewItems = ( +export const changeCurrentIntoNewNodes = ( currentChildNodes: Node[], newChildNodes: Node[], - parent: ParentNode | null + parent: ParentNode | null = null ) => { const currentChildNodesSet = new Set(currentChildNodes) - const lastNode = currentChildNodes.at(-1) - const endAnchor = lastNode?.nextSibling ?? null - let frag: DocumentFragment = document.createDocumentFragment() + const endAnchor = currentChildNodes.at(-1)?.nextSibling ?? null + let frag = document.createDocumentFragment() newChildNodes.forEach((n, i) => { const moved = currentChildNodes[i] !== n diff --git a/src/dynamic-value/is-dynamic-value.ts b/src/dynamic-value/is-dynamic-value.ts new file mode 100644 index 00000000..576f4757 --- /dev/null +++ b/src/dynamic-value/is-dynamic-value.ts @@ -0,0 +1,5 @@ +import { HtmlTemplate } from '../html' +import { Helper } from '../Helper' + +export const isDynamicValue = (x: unknown) => + typeof x === 'function' || x instanceof HtmlTemplate || x instanceof Helper diff --git a/src/dynamic-value/parse-dynamic-raw-value.spec.ts b/src/dynamic-value/parse-dynamic-raw-value.spec.ts new file mode 100644 index 00000000..35f3e21b --- /dev/null +++ b/src/dynamic-value/parse-dynamic-raw-value.spec.ts @@ -0,0 +1,68 @@ +import { parseDynamicRawValue } from "./parse-dynamic-raw-value"; + +describe("parseDynamicRawValue", () => { + it("should return raw value if no placeholder preset", () => { + expect( + parseDynamicRawValue( + "just a simple straight up text", + [12, 45] + ) + ).toEqual(["just a simple straight up text"]); + }); + + it("should collect single value", () => { + expect( + parseDynamicRawValue("$val0", [12, 45]) + ).toEqual(["12"]); + }); + + it("should collect single value mixed with other content", () => { + expect( + parseDynamicRawValue( + "$val0 in between $val1", + [12, 24, 36] + ) + ).toEqual([ + "12 in between 24" + ]); + }); + + it("should collect multiple values", () => { + expect( + parseDynamicRawValue( + "$val0 $val1", + [12, 24, 36] + ) + ).toEqual(["12 24"]); + }); + + it("should collect multiple values mixed with other content", () => { + expect( + parseDynamicRawValue( + "heading $val0 middle $val1 tail text", + [12, 24, 36] + ) + ).toEqual(["heading 12 middle 24 tail text"]); + }); + + it("should collect multiple dynamic values mixed with other content", () => { + expect( + parseDynamicRawValue( + "heading $val0 middle $val1 tail text $val2", + [() => 12, () => 24, 36] + ) + ).toEqual([ + "heading ", + { + "text": "$val0", + "value": expect.any(Function) + }, + " middle ", + { + "text": "$val1", + "value": expect.any(Function) + }, + " tail text 36" + ]); + }); +}); diff --git a/src/dynamic-value/parse-dynamic-raw-value.ts b/src/dynamic-value/parse-dynamic-raw-value.ts new file mode 100644 index 00000000..c508f170 --- /dev/null +++ b/src/dynamic-value/parse-dynamic-raw-value.ts @@ -0,0 +1,51 @@ +import { isPrimitive } from '../utils/is-primitive' + +const handleLastPart = (parts: unknown[], str: string) => { + if (str) { + if (typeof parts.at(-1) === 'string') { + parts[parts.length - 1] += str + } else { + parts.push(str) + } + } +} + +export const parseDynamicRawValue = (str: string, values: unknown[]) => { + const vals = Array.from(str.matchAll(/\$val([0-9]+)/g)) + + if (!vals.length) { + return [str] + } + + const parts: (string | { value: unknown; text: string })[] = [] + let idx = 0 + + for (const val of vals) { + const prevVal = vals[idx - 1] + const value = values[Number(val[0].match(/\d+/g))] + const newPart = str.slice( + prevVal ? (prevVal.index ?? 0) + prevVal[0].length : 0, + val.index + ) + + if (isPrimitive(value) || value === null) { + handleLastPart(parts, newPart + value) + } else { + newPart && parts.push(newPart) + parts.push({ value, text: val[0] }) + } + + idx += 1 + } + + const lastVal = vals.at(-1) + + if (lastVal) { + handleLastPart( + parts, + str.substring((lastVal.index ?? 0) + lastVal[0].length) + ) + } + + return parts +} diff --git a/src/dynamic-value/resolve-content-dynamic-value-to-nodes.spec.ts b/src/dynamic-value/resolve-content-dynamic-value-to-nodes.spec.ts new file mode 100644 index 00000000..a0111fc3 --- /dev/null +++ b/src/dynamic-value/resolve-content-dynamic-value-to-nodes.spec.ts @@ -0,0 +1,132 @@ +import { resolveContentDynamicValueToNodes } from './resolve-content-dynamic-value-to-nodes' +import { element } from '../utils/element' +import { when } from '../helpers' +import { html } from '../html' + +describe('resolveContentDynamicValueToNodes', () => { + + it('should resolve text value', () => { + const data = 'hello'; + let renderedNodes: Node[] = [document.createTextNode('$val0')] + let res = resolveContentDynamicValueToNodes(data, renderedNodes) + + expect(res).toHaveLength(1) + expect(res[0]).not.toEqual(renderedNodes[0]) + expect(res[0].textContent).toBe('hello') + + const node = res[0] + + renderedNodes = [...res] + + res = resolveContentDynamicValueToNodes('hello', renderedNodes) + + expect(res[0]).toEqual(node) + expect(res[0].textContent).toBe('hello') + }) + + it('should resolve node value', () => { + const data = element('p', { textContent: 'sample' }); + let renderedNodes: Node[] = [document.createTextNode('$val0')] + let res = resolveContentDynamicValueToNodes(data, renderedNodes) + + expect(res).toHaveLength(1) + expect(res[0]).not.toEqual(renderedNodes[0]) + expect(res[0].textContent).toBe('sample') + + const node = res[0] + + renderedNodes = [...res] + + res = resolveContentDynamicValueToNodes(data, renderedNodes) + + expect(res[0]).toEqual(node) + expect(res[0].textContent).toBe('sample') + }) + + it('should resolve function value', () => { + let total = 12 + const data = () => total; + let renderedNodes: Node[] = [document.createTextNode('$val0')] + let res = resolveContentDynamicValueToNodes(data, renderedNodes) + + expect(res).toHaveLength(1) + expect(res[0]).not.toEqual(renderedNodes[0]) + expect(res[0].textContent).toBe('12') + + let node = res[0] + + renderedNodes = [...res] + + res = resolveContentDynamicValueToNodes(data, renderedNodes) + + expect(res[0]).toEqual(node) + expect(res[0].textContent).toBe('12') + + total = 24 + + node = res[0] + + renderedNodes = [...res] + + res = resolveContentDynamicValueToNodes(data, renderedNodes) + + expect(res[0]).not.toEqual(node) + expect(res[0].textContent).toBe('24') + }) + + it('should resolve helper value', () => { + let v = true + const data = when(() => v, 'is truthy', 'is falsy'); + let renderedNodes: Node[] = [document.createTextNode('$val0')] + let res = resolveContentDynamicValueToNodes(data, renderedNodes) + + expect(res).toHaveLength(1) + expect(res[0]).not.toEqual(renderedNodes[0]) + expect(res[0].textContent).toBe('is truthy') + + let node = res[0] + + renderedNodes = [...res] + + res = resolveContentDynamicValueToNodes(data, renderedNodes) + + expect(res[0]).toEqual(node) + expect(res[0].textContent).toBe('is truthy') + + v = false + + node = res[0] + + renderedNodes = [...res] + + res = resolveContentDynamicValueToNodes(data, renderedNodes) + + expect(res[0]).not.toEqual(node) + expect(res[0].textContent).toBe('is falsy') + }) + + it('should resolve array value', () => { + const data = [[['x', 12]]]; + let renderedNodes: Node[] = [document.createTextNode('$val0')] + let res = resolveContentDynamicValueToNodes(data, renderedNodes) + + expect(res).toHaveLength(2) + expect(res).not.toEqual(renderedNodes) + expect(res[0].nodeValue).toBe('x') + expect(res[1].nodeValue).toBe('12') + }) + + it('should resolve HTML template', () => { + const data = html`sample`; + let renderedNodes: Node[] = [document.createTextNode('$val0')] + let res = resolveContentDynamicValueToNodes(data, renderedNodes) + + expect(res).toHaveLength(1) + expect(res).not.toEqual(renderedNodes) + expect(res[0].nodeValue).toBe('sample') + + res = resolveContentDynamicValueToNodes(data, renderedNodes) + + expect(res[0].nodeValue).toBe('sample') + }) +}) diff --git a/src/dynamic-value/resolve-content-dynamic-value-to-nodes.ts b/src/dynamic-value/resolve-content-dynamic-value-to-nodes.ts new file mode 100644 index 00000000..b6dcb69b --- /dev/null +++ b/src/dynamic-value/resolve-content-dynamic-value-to-nodes.ts @@ -0,0 +1,61 @@ +import { HtmlTemplate } from '../html' +import { Helper } from '../Helper' +import { jsonStringify } from '../utils/json-stringify' +import { val } from '../utils/val' + +export const resolveContentDynamicValueToNodes = ( + data: unknown, + renderedNodes: Node[], + refs: Record> = {} +): Node[] => { + return (function getNode(value): Node[] { + if (value instanceof HtmlTemplate) { + if (value.mounted) { + value.update() + } else { + const frag = document.createDocumentFragment() + value.render(frag, true) + } + + // collect dynamic refs that could appear + // after render/update + Object.entries(value.refs).forEach(([name, els]) => { + els.forEach((el) => { + if (!refs[name]) { + refs[name] = new Set() + } + + refs[name].add(el) + }) + }) + + return value.nodes + } + + if (value instanceof Helper || typeof value === 'function') { + return getNode(val(value)) + } + + if (Array.isArray(value)) { + return value.flatMap(getNode) + } + + if (value instanceof Node) { + return [value] + } + + const str = jsonStringify(value) + + const renderedNode = renderedNodes[0] + + if ( + renderedNode && + renderedNode.nodeValue === str && + renderedNode.nodeType === Node.TEXT_NODE + ) { + return [renderedNode] + } + + return [document.createTextNode(str)] + })(data) +} diff --git a/src/dynamic-value/sync-nodes.ts b/src/dynamic-value/sync-nodes.ts new file mode 100644 index 00000000..8b138741 --- /dev/null +++ b/src/dynamic-value/sync-nodes.ts @@ -0,0 +1,23 @@ +import { changeCurrentIntoNewNodes } from './change-current-into-new-nodes' + +export const syncNodes = ( + currentNodes: Array, + newNodes: Array, + parent: ParentNode | null = null +) => { + if (newNodes.length) { + changeCurrentIntoNewNodes(currentNodes, newNodes, parent) + return newNodes + } + + const n = currentNodes[0] + + const emptyNode = document.createTextNode('') + n.parentNode?.replaceChild(emptyNode, n) + + currentNodes.forEach((n: Node) => { + n.parentNode?.removeChild(n) + }) + + return [emptyNode] +} diff --git a/src/executable/Doc.ts b/src/executable/Doc.ts deleted file mode 100644 index efcc111d..00000000 --- a/src/executable/Doc.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { ExecutableValue } from '../types' -import { extractExecutableValueFromRawValue } from './extract-executable-value-from-raw-value' -import { - handleAttrDirectiveExecutableValue, - handleAttrExecutableValue, - handleEventExecutableValue, - handleTextExecutableValue, -} from './handle-executable' -import { - booleanAttributes, - element, - ElementOptions, - setElementAttribute, -} from '../utils' - -const node = ( - nodeName: string, - ns: ElementOptions['ns'] = '', - values: Array = [], - refs: Record> = {}, - cb: (node: Node, e: ExecutableValue, type: string) => void = () => {} -) => { - const node = - nodeName === '#fragment' - ? document.createDocumentFragment() - : element(nodeName, { ns }) - const comp = customElements.get(nodeName.toLowerCase()) - - return { - __self__: node, - namespaceURI: (node as Element).namespaceURI as string, - tagName: nodeName, - childNodes: node.childNodes, - attributes: 'attributes' in node ? node.attributes : null, - textContent: node.textContent, - setAttribute: (name: string, value: string = '') => { - // should ignore dynamically set attribute name - if (/^val[0-9]+$/.test(name)) { - return - } - - const trimmedValue = value.trim() - - if (trimmedValue) { - if (name === 'ref') { - if (!refs[trimmedValue]) { - refs[trimmedValue] = new Set() - } - - refs[trimmedValue].add(node as Element) - return - } - - const attrLessName = name.replace(/^attr\./, '') - const isBollAttr = - booleanAttributes[ - attrLessName.toLowerCase() as keyof typeof booleanAttributes - ] - - // boolean attr with false value can just be ignored - if (trimmedValue === 'false' && isBollAttr) { - return - } - - let e: ExecutableValue = { - name, - value: trimmedValue, - rawValue: trimmedValue, - renderedNodes: [node], - parts: /^(true|false)$/.test(trimmedValue) - ? [Boolean(trimmedValue)] - : extractExecutableValueFromRawValue( - trimmedValue, - values - ), - } - - if (/^on[a-z]+/.test(name)) { - // if the node happen to have - if (comp) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - if (!comp?.observedAttributes?.includes(name)) { - e.prop = name.slice(2) - } - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - } else if ( - document.head && - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - typeof document.head[name] !== 'undefined' - ) { - // ignore unknown events - e.prop = name.slice(2) - } - - if (e.prop) { - handleEventExecutableValue(e) - cb(node, e, 'events') - return - } - } - - if ( - isBollAttr || - /^(attr|class|style|data)/i.test(name) || - trimmedValue.split(/\|/).length > 1 - ) { - let props: string[] = [] - ;[name, ...props] = attrLessName.split('.') - - e = { - ...e, - name, - value: '', - prop: props.join('.'), - } - - handleAttrDirectiveExecutableValue(e) - return cb(node, e, 'directives') - } - - if (/{{val[0-9]+}}/.test(trimmedValue)) { - handleAttrExecutableValue(e, node as Element) - return cb(node, e, 'attributes') - } - } - - if ( - 'setAttribute' in node && - // ignore special attributes specific to Markup that did not get handled - !/^(ref|(attr|class|style|data)\.)/.test(name) - ) { - setElementAttribute(node, name, trimmedValue) - } - }, - appendChild: (n: DocumentFragment | Node) => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - if (n.__self__) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - n = n.__self__ - } - - if (n.nodeType === 3) { - // text node - node.appendChild(n) - - if (n.nodeValue && /{{val([0-9]+)}}/.test(n.nodeValue)) { - const value = String(n.nodeValue) - const e: ExecutableValue = { - name: 'nodeValue', - rawValue: value, - value, - parts: extractExecutableValueFromRawValue( - value, - values - ), - renderedNodes: [n], - } - - cb(n, e, 'content') - handleTextExecutableValue(e, refs, n) - } - } else { - node.appendChild(n as Node) - } - }, - } -} - -export const Doc = ( - values: Array, - refs: Record>, - cb: (node: Node, e: ExecutableValue, type: string) => void -) => { - return { - createTextNode: (text: string) => { - return document.createTextNode(text) - }, - createComment: (text: string) => { - return document.createComment(text) - }, - createDocumentFragment: () => { - return node('#fragment', '', values, refs, cb) - }, - createElementNS: ( - ns: ElementOptions['ns'], - tagName: string - ) => { - return node(tagName, ns, values, refs, cb) - }, - } -} diff --git a/src/executable/extract-executable-value-from-raw-value.spec.ts b/src/executable/extract-executable-value-from-raw-value.spec.ts deleted file mode 100644 index c91a8dca..00000000 --- a/src/executable/extract-executable-value-from-raw-value.spec.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { extractExecutableValueFromRawValue } from './extract-executable-value-from-raw-value' - -describe('extractExecutableValueFromRawValue', () => { - it('should return raw value if no placeholder preset', () => { - expect( - extractExecutableValueFromRawValue( - 'just a simple straight up text', - [12, 45] - ) - ).toEqual(['just a simple straight up text']) - }) - - it('should collect single value', () => { - expect( - extractExecutableValueFromRawValue('{{val0}}', [12, 45]) - ).toEqual([12]) - }) - - it('should collect single value mixed with other content', () => { - expect( - extractExecutableValueFromRawValue( - '{{val0}} in between {{val1}}', - [12, 24, 36] - ) - ).toEqual([12, ' in between ', 24]) - }) - - it('should collect multiple values', () => { - expect( - extractExecutableValueFromRawValue( - '{{val0}} {{val1}}', - [12, 24, 36] - ) - ).toEqual([12, ' ', 24]) - }) - - it('should collect multiple values mixed with other content', () => { - expect( - extractExecutableValueFromRawValue( - 'heading {{val0}} middle {{val1}} tail text', - [12, 24, 36] - ) - ).toEqual(['heading ', 12, ' middle ', 24, ' tail text']) - }) -}) diff --git a/src/executable/extract-executable-value-from-raw-value.ts b/src/executable/extract-executable-value-from-raw-value.ts deleted file mode 100644 index feff628b..00000000 --- a/src/executable/extract-executable-value-from-raw-value.ts +++ /dev/null @@ -1,32 +0,0 @@ -export const extractExecutableValueFromRawValue = ( - rawValue: string, - values: unknown[] -) => { - rawValue = rawValue.trim() - let match: RegExpExecArray | null = null - const parts = [] - - while ((match = /{{val([0-9]+)}}/.exec(rawValue)) !== null) { - const [full] = match - const pre = match.input.slice(0, match.index) - - if (pre.length) { - parts.push(pre) - } - - const numb = full.match(/\d+/g) - - if (numb) { - const val = values[Number(numb[0])] - parts.push(val) - } - - rawValue = match.input.slice(match.index + full.length) - } - - if (rawValue.length) { - parts.push(rawValue) - } - - return parts.length ? parts : [rawValue] -} diff --git a/src/executable/handle-attr-directive-executable.spec.ts b/src/executable/handle-attr-directive-executable.spec.ts deleted file mode 100644 index d3e52238..00000000 --- a/src/executable/handle-attr-directive-executable.spec.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { handleAttrDirectiveExecutable } from './handle-attr-directive-executable' -import { ExecutableValue } from '../types' - -describe('handleAttrDirectiveExecutable', () => { - let div: HTMLDivElement - - beforeEach(() => { - div = document.createElement('div') - }) - - it('should handle style attribute', () => { - const e1: ExecutableValue = { - name: 'style', - rawValue: 'bold', - value: '', - renderedNodes: [div], - prop: 'font-style', - parts: [], - } - - const e2: ExecutableValue = { - name: 'style', - rawValue: 'background-color: #900 | true', - value: '', - renderedNodes: [div], - prop: '', - parts: [], - } - - handleAttrDirectiveExecutable(e1, 'bold') - handleAttrDirectiveExecutable(e2, 'background-color: #900 | true') - - expect(div.outerHTML).toBe( - '
' - ) - expect(e1.value).toBe('bold') - expect(e2.value).toBe('background-color: #900 | true') - - handleAttrDirectiveExecutable(e2, 'background-color: #900 | false') - - expect(div.outerHTML).toBe('
') - - expect(e2.value).toBe('background-color: #900 | false') - - handleAttrDirectiveExecutable(e1, 'bold | false') - - expect(div.outerHTML).toBe('
') - - expect(e1.value).toBe('bold | false') - }) - - it('should handle class attribute', () => { - const e1: ExecutableValue = { - name: 'class', - rawValue: 'false', - value: '', - renderedNodes: [div], - prop: 'simple-cls', - parts: [], - } - - const e2: ExecutableValue = { - name: 'class', - rawValue: 'sample | true', - value: '', - renderedNodes: [div], - prop: '', - parts: [], - } - - handleAttrDirectiveExecutable(e1, 'false') - handleAttrDirectiveExecutable(e2, 'sample | true') - - expect(div.outerHTML).toBe('
') - expect(e1.value).toBe('false') - expect(e2.value).toBe('sample | true') - - handleAttrDirectiveExecutable(e1, 'true') - - expect(div.outerHTML).toBe('
') - - expect(e1.value).toBe('true') - - handleAttrDirectiveExecutable(e2, 'sample | false') - - expect(div.outerHTML).toBe('
') - - expect(e2.value).toBe('sample | false') - }) - - it('should handle data attribute', () => { - const e1: ExecutableValue = { - name: 'data', - rawValue: 'val | true', - value: '', - renderedNodes: [div], - prop: 'simple-val', - parts: [], - } - - const e2: ExecutableValue = { - name: 'data', - rawValue: 'simple-val | true', - value: '', - renderedNodes: [div], - prop: '', - parts: [], - } - - handleAttrDirectiveExecutable(e1, 'val | true') - handleAttrDirectiveExecutable(e2, 'simple-val | true') - - expect(div.outerHTML).toBe('
') - expect(e1.value).toBe('val | true') - expect(e2.value).toBe('simple-val | true') - - handleAttrDirectiveExecutable(e1, 'false') - - expect(div.outerHTML).toBe('
') - - expect(e1.value).toBe('false') - }) - - it('should handle boolean attribute', () => { - const e1: ExecutableValue = { - name: 'disabled', - rawValue: 'true', - value: '', - renderedNodes: [div], - prop: '', - parts: [], - } - - const e2: ExecutableValue = { - name: 'hidden', - rawValue: 'until-found | true', - value: '', - renderedNodes: [div], - prop: '', - parts: [], - } - - handleAttrDirectiveExecutable(e1, 'true') - handleAttrDirectiveExecutable(e2, 'until-found | true') - - expect(div.outerHTML).toBe( - '' - ) - expect(e1.value).toBe('true') - expect(e2.value).toBe('until-found | true') - - handleAttrDirectiveExecutable(e1, 'false') - - expect(div.outerHTML).toBe('') - - expect(e1.value).toBe('false') - }) - - it('should handle all other attributes', () => { - const e1: ExecutableValue = { - name: 'id', - rawValue: 'sample | true', - value: '', - renderedNodes: [div], - prop: '', - parts: [], - } - - const e2: ExecutableValue = { - name: 'aria-disabled', - rawValue: 'true', - value: '', - renderedNodes: [div], - prop: '', - parts: [], - } - - handleAttrDirectiveExecutable(e1, 'sample | true') - handleAttrDirectiveExecutable(e2, 'true') - - expect(div.outerHTML).toBe( - '
' - ) - expect(e1.value).toBe('sample | true') - expect(e2.value).toBe('true') - - handleAttrDirectiveExecutable(e1, 'sample | false') - - expect(div.outerHTML).toBe('
') - expect(e1.value).toBe('sample | false') - }) -}) diff --git a/src/executable/handle-attr-directive-executable.ts b/src/executable/handle-attr-directive-executable.ts deleted file mode 100644 index 7a26c821..00000000 --- a/src/executable/handle-attr-directive-executable.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { ExecutableValue } from '../types' -import { - booleanAttributes, - jsonParse, - turnCamelToKebabCasing, - turnKebabToCamelCasing, -} from '../utils' -import { setElementAttribute } from '../utils/set-element-attribute' - -export const handleAttrDirectiveExecutable = ( - executableValue: ExecutableValue, - rawValue: string -) => { - if (rawValue !== executableValue.value) { - const { name: attrName, prop: property } = executableValue - executableValue.value = rawValue - const element = executableValue.renderedNodes[0] as HTMLElement - // eslint-disable-next-line prefer-const - const valueParts = rawValue.split(/\|/) - const [value, condition] = valueParts.map((s) => s.trim()) - const parsedValue = jsonParse(value) - let shouldAdd = Boolean( - valueParts.length > 1 - ? typeof condition === 'string' - ? jsonParse(condition) - : condition - : parsedValue - ) - - switch (attrName) { - case 'style': - if (property) { - element.style.setProperty(property, shouldAdd ? value : '') - } else { - value - .match(/([a-z][a-z-]+)(?=:):([^;]+)/g) - ?.forEach((style: string) => { - const [name, styleValue] = style - .split(':') - .map((s) => s.trim()) - - if (shouldAdd) { - element.style.setProperty(name, styleValue) - } else { - element.style.removeProperty(name) - } - }) - } - - if (!element.style.length) { - element.removeAttribute('style') - } - - break - case 'class': - if (property) { - if (shouldAdd) { - element.classList.add(property) - } else { - element.classList.remove(property) - } - } else { - // ''.split(/\s+/g) results in [''] which will fail in classList actions - ;(value ? value.split(/\s+/g) : []).forEach( - (cls: string) => { - if (shouldAdd) { - element.classList.add(cls) - } else { - element.classList.remove(cls) - } - } - ) - } - - if (!element.classList.length) { - element.removeAttribute('class') - } - break - case 'data': - if (property) { - if (shouldAdd) { - element.dataset[turnKebabToCamelCasing(property)] = - value - } else { - element.removeAttribute( - `data-${turnCamelToKebabCasing(property)}` - ) - } - } - break - default: - if (attrName) { - const boolAttr = ( - booleanAttributes as Record< - string, - { possibleValues?: string[] } - > - )[attrName] - const boolAttributeWithValidPossibleValue = - boolAttr?.possibleValues && - boolAttr.possibleValues.includes(value) - // when it's a native boolean attribute, the value itself should be the flag to whether add/remove it - // unless it is a boolean attribute with a valid possible value - shouldAdd = - boolAttr && - !boolAttributeWithValidPossibleValue && - typeof parsedValue === 'boolean' - ? parsedValue - : shouldAdd - - if (shouldAdd) { - if (boolAttr) { - setElementAttribute( - element, - attrName, - boolAttributeWithValidPossibleValue - ? value - : 'true' - ) - } else { - setElementAttribute(element, attrName, value) - } - } else { - element.removeAttribute(attrName) - } - } - } - } -} diff --git a/src/executable/handle-executable.spec.ts b/src/executable/handle-executable.spec.ts deleted file mode 100644 index 6d6de20f..00000000 --- a/src/executable/handle-executable.spec.ts +++ /dev/null @@ -1,232 +0,0 @@ -import { handleExecutable } from './handle-executable' -import { Executable } from '../types' -import { html } from '../html' - -describe('handleExecutable', () => { - let div: HTMLDivElement - const refs = {} - const defaultExec = { - directives: [], - content: [], - attributes: [], - events: [], - } - - beforeEach(() => { - div = document.createElement('div') - }) - - it('should handle event executable', () => { - const fnMock1 = jest.fn() - const fnMock2 = jest.fn() - - const e: Executable = { - ...defaultExec, - events: [ - { - name: 'scroll', - rawValue: '{{val0}}, true', - value: '{{val0}}', - renderedNodes: [div], - parts: [fnMock1], - }, - ], - } - - handleExecutable(div, e, refs) - - expect(e).toEqual({ - ...defaultExec, - events: [ - { - name: 'scroll', - rawValue: '{{val0}}, true', - renderedNodes: [div], - value: fnMock1, - parts: [fnMock1], - }, - ], - }) - - e.events[0].parts = [fnMock2] - handleExecutable(div, e, refs) - - expect(e).toEqual({ - ...defaultExec, - events: [ - { - name: 'scroll', - rawValue: '{{val0}}, true', - renderedNodes: [div], - value: fnMock2, - parts: [fnMock2], - }, - ], - }) - - expect(div.outerHTML).toBe('
') - - e.events[0].parts = [null] - expect(() => handleExecutable(div, e, refs)).toThrowError( - 'handler for event "scroll" is not a function. Found "null".' - ) - }) - - it('should handle attribute value executable', () => { - const e: Executable = { - ...defaultExec, - attributes: [ - { - name: 'id', - rawValue: '{{val0}}', - value: '{{val0}}', - renderedNodes: [div], - parts: ['sample'], - }, - ], - } - - handleExecutable(div, e, refs) - - expect(e).toEqual({ - ...defaultExec, - attributes: [ - { - name: 'id', - rawValue: '{{val0}}', - renderedNodes: [div], - value: 'sample', - parts: ['sample'], - }, - ], - }) - - expect(div.outerHTML).toBe('
') - }) - - it('should handle attribute directive executable', () => { - const e: Executable = { - ...defaultExec, - directives: [ - { - name: 'disabled', - rawValue: 'true', - value: '', - renderedNodes: [div], - prop: '', - parts: [true], - }, - ], - } - - handleExecutable(div, e, refs) - - expect(e).toEqual({ - ...defaultExec, - directives: [ - { - name: 'disabled', - prop: '', - rawValue: 'true', - renderedNodes: [div], - value: "true", - parts: [true], - }, - ], - }) - - expect(div.outerHTML).toBe('
') - }) - - it('should handle text executable as text', () => { - const txt = document.createTextNode('{{val0}}') - div.appendChild(txt) - - const e: Executable = { - ...defaultExec, - content: [ - { - name: 'nodeValue', - rawValue: '{{val0}}', - value: '', - renderedNodes: [txt], - parts: ['sample'], - }, - ], - } - - handleExecutable(txt, e, refs) - - expect(div.outerHTML).toBe('
sample
') - }) - - it('should handle text executable as node', () => { - const txt = document.createTextNode('{{val0}}') - div.appendChild(txt) - - const p = document.createElement('p') - - const e: Executable = { - ...defaultExec, - content: [ - { - name: 'nodeValue', - rawValue: '{{val0}}', - value: '', - renderedNodes: [txt], - parts: [p], - }, - ], - } - - handleExecutable(txt, e, refs) - - expect(div.outerHTML).toBe('

') - }) - - it('should handle text executable as node', () => { - const txt = document.createTextNode('{{val0}}') - div.appendChild(txt) - - const p = html`

sample

` - - const e: Executable = { - ...defaultExec, - content: [ - { - name: 'nodeValue', - rawValue: '{{val0}}', - value: '', - renderedNodes: [txt], - parts: [p], - }, - ], - } - - handleExecutable(txt, e, refs) - - expect(div.outerHTML).toBe('

sample

') - }) - - it('should handle html string executable as text and encode entities', () => { - const txt = document.createTextNode('{{val0}}') - div.appendChild(txt) - - const e: Executable = { - ...defaultExec, - content: [ - { - name: 'nodeValue', - rawValue: '{{val0}}', - value: '', - renderedNodes: [txt], - parts: ['

sample

'], - }, - ], - } - - handleExecutable(txt, e, refs) - - expect(div.outerHTML).toBe('
<p>sample</p>
') - }) -}) diff --git a/src/executable/handle-executable.ts b/src/executable/handle-executable.ts deleted file mode 100644 index 384ec29c..00000000 --- a/src/executable/handle-executable.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { Executable, ExecutableValue } from '../types' -import { - jsonParse, - isObjectLiteral, - isPrimitive, - jsonStringify, - turnKebabToCamelCasing, - val, - setElementAttribute, -} from '../utils' -import { handleTextExecutable } from './handle-text-executable' -import { handleAttrDirectiveExecutable } from './handle-attr-directive-executable' -import { HtmlTemplate } from '../html' - -const partsToValue = ( - parts: unknown[], - handler: null | ((p: unknown) => unknown) = null -) => - parts.flatMap((p) => { - if (typeof handler === 'function') { - return handler(val(p)) - } - - return val(p) - }) - -export const handleExecutable = ( - node: Node, - executable: Executable, - refs: Record> -) => { - executable.events.forEach((e) => { - handleEventExecutableValue(e) - }) - executable.directives.forEach((d) => { - handleAttrDirectiveExecutableValue(d) - }) - executable.attributes.forEach((a) => { - handleAttrExecutableValue(a, a.renderedNodes[0] as Element) - }) - executable.content.forEach((t) => { - handleTextExecutableValue(t, refs, node) - }) -} - -export function handleAttrDirectiveExecutableValue(val: ExecutableValue) { - const value = partsToValue(val.parts, jsonStringify).join('') - - if (val.value !== value) { - handleAttrDirectiveExecutable(val, value) - } -} - -export function handleEventExecutableValue(val: ExecutableValue) { - const eventHandler = val.parts[0] as EventListenerOrEventListenerObject - - if (typeof eventHandler !== 'function') { - throw new Error( - `handler for event "${val.name}" is not a function. Found "${eventHandler}".` - ) - } - - if (val.value !== eventHandler) { - val.value = eventHandler - const node = Array.isArray(val.renderedNodes) - ? (val.renderedNodes as Node[])[0] - : (val.renderedNodes as Node) - const eventName = val.prop as string - const option = - val.parts.length > 1 - ? val.parts[2] - : jsonParse(val.rawValue.split(',')[1]) - const validOption = - typeof option === 'boolean' || isObjectLiteral(option) - const eventOption = ( - validOption ? option : undefined - ) as AddEventListenerOptions - - if (typeof val.value === 'function') { - node.removeEventListener(eventName, eventHandler, eventOption) - } - - node.addEventListener(eventName, eventHandler, eventOption) - } -} - -export function handleAttrExecutableValue( - eVal: ExecutableValue, - node: Element -) { - let rawValue = val(eVal.parts[0]) - - if (isPrimitive(rawValue)) { - rawValue = partsToValue(eVal.parts, jsonStringify).join('') - } - - const value = jsonStringify(rawValue) - - if (rawValue !== eVal.value) { - eVal.value = rawValue - - try { - // always update the element attribute - setElementAttribute(node, eVal.name, value) - // for WC we can also use the setter to set the value in case they - // have correspondent camel case property version of the attribute - // we do this only for non-primitive value because they are not handled properly - // by elements and if in case they have such setters, we can use them to set it - if ( - customElements.get(node.nodeName.toLowerCase()) && - !isPrimitive(rawValue) - ) { - const propName = /-/.test(eVal.name) - ? turnKebabToCamelCasing(eVal.name) - : eVal.name - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore check if value is different from the new value - if (node[propName] !== rawValue) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore in case the property is not writable and throws error - node[propName] = rawValue - } - } else { - if ( - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore handle cases like input field which changing attribute does not - // actually change the value of the input field, and we check this by - // verifying that the matching property value remained different from the new value of the attribute - node[eVal.name] !== undefined && - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - node[eVal.name] !== rawValue - ) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - node[eVal.name] = rawValue - } - } - } catch (e) { - /* empty */ - } - } -} - -export function handleTextExecutableValue( - val: ExecutableValue, - refs: Record>, - el: Node -) { - const value = partsToValue(val.parts) as Array - const nodes: Array = [] - - for (let i = 0; i < value.length; i++) { - const v = value[i] - - if (v instanceof HtmlTemplate) { - const renderedBefore = v.renderTarget !== null - - if (renderedBefore) { - // could be that the component was sitting around while data changed - // for that we need to update it, so it has the latest data - v.update() - } else { - v.render(document.createElement('div')) - } - - // collect dynamic refs that could appear - // after render/update - Object.entries(v.refs).forEach(([name, els]) => { - els.forEach((el) => { - if (!refs[name]) { - refs[name] = new Set() - } - - refs[name].add(el) - }) - }) - - nodes.push(...v.nodes) - } else if (v instanceof Node) { - nodes.push(v) - } else { - // need to make sure to grab the same text node that was already rendered - // to avoid unnecessary DOM updates - if ( - Array.isArray(val.value) && - String(val.value[i]) === String(v) - ) { - nodes.push(val.renderedNodes[i]) - } else { - nodes.push(document.createTextNode(String(v))) - } - } - } - - // need to make sure nodes array does not have repeated nodes - // which cannot be rendered in 2 places at once - handleTextExecutable(val, Array.from(new Set(nodes)), el) - - // clean up templates removed by unmounting them - if (Array.isArray(val.value)) { - const valueSet = new Set(value) - - for (const v of val.value as unknown[]) { - if (v instanceof HtmlTemplate && !valueSet.has(v)) { - v.unmount() - } - } - } - - val.value = value -} diff --git a/src/executable/handle-text-executable.spec.ts b/src/executable/handle-text-executable.spec.ts deleted file mode 100644 index b6b41a70..00000000 --- a/src/executable/handle-text-executable.spec.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { handleTextExecutable } from './handle-text-executable' -import { ExecutableValue } from '../types' - -describe('handleTextExecutable', () => { - it('should remove one node', () => { - const div = document.createElement('div') - const txt = document.createTextNode('{{val0}}') - - div.appendChild(txt) - - expect(div.innerHTML).toBe('{{val0}}') - expect(txt.nodeValue).toBe('{{val0}}') - expect(txt.parentNode).toEqual(div) - - const execVal: ExecutableValue = { - name: 'nodeValue', - rawValue: '', - value: '', - renderedNodes: [txt], - parts: [], - } - - expect(execVal.renderedNodes).toEqual([txt]) - - handleTextExecutable(execVal, [], txt) - - expect(div.innerHTML).toBe('') - expect(txt.nodeValue).toBe('{{val0}}') - expect(txt.parentNode).toBeNull() - expect(execVal.renderedNodes).not.toEqual(txt) - }) - - it('should remove all nodes', () => { - const div = document.createElement('div') - div.innerHTML = 'sampletext' - - expect(div.innerHTML).toBe('sampletext') - - const execVal: ExecutableValue = { - name: 'nodeValue', - rawValue: '', - value: '', - renderedNodes: Array.from(div.childNodes), - parts: [], - } - - handleTextExecutable(execVal, [], div) - - expect(div.innerHTML).toBe('') - expect(execVal.renderedNodes[0]).toBeInstanceOf(Text) - }) - - it('should replace all nodes with new ones', () => { - const div = document.createElement('div') - div.innerHTML = 'sampletext' - - expect(div.innerHTML).toBe('sampletext') - - const execVal: ExecutableValue = { - name: 'nodeValue', - rawValue: '', - value: '', - renderedNodes: Array.from(div.childNodes), - parts: [], - } - - const p1 = document.createElement('p') - const p2 = document.createElement('p') - - p1.innerHTML = 'one' - p2.innerHTML = 'two' - - const nodes = [p1, p2] - - handleTextExecutable(execVal, nodes, div) - - expect(div.innerHTML).toBe('

one

two

') - expect(execVal.renderedNodes).toEqual(nodes) - }) - - it('should replace some nodes with new ones', () => { - const div = document.createElement('div') - - const p1 = document.createElement('p') - const p2 = document.createElement('p') - const p3 = document.createElement('p') - const p4 = document.createElement('p') - - p1.innerHTML = 'one' - p2.innerHTML = 'two' - p3.innerHTML = 'three' - p4.innerHTML = 'four' - - const nodes = [p1, p3] - - div.appendChild(p2) - div.appendChild(p4) - - expect(div.innerHTML).toBe('

two

four

') - - const execVal: ExecutableValue = { - name: 'nodeValue', - rawValue: '', - value: '', - renderedNodes: Array.from(div.childNodes), - parts: [], - } - - handleTextExecutable(execVal, nodes, div) - - expect(div.innerHTML).toBe('

one

three

') - expect(execVal.renderedNodes).toEqual(nodes) - }) - - it('should replace one node with new ones', () => { - const div = document.createElement('div') - - const p1 = document.createElement('p') - const p2 = document.createElement('p') - const p3 = document.createElement('p') - - p1.innerHTML = 'one' - p2.innerHTML = 'two' - p3.innerHTML = 'three' - - div.appendChild(p1) - - expect(div.innerHTML).toBe('

one

') - - const execVal: ExecutableValue = { - name: 'nodeValue', - rawValue: '', - value: '', - renderedNodes: [p1], - parts: [], - } - - handleTextExecutable(execVal, [p2, p3], div) - - expect(div.innerHTML).toBe('

two

three

') - expect(execVal.renderedNodes).toEqual([p2, p3]) - }) -}) diff --git a/src/executable/handle-text-executable.ts b/src/executable/handle-text-executable.ts deleted file mode 100644 index 918ff989..00000000 --- a/src/executable/handle-text-executable.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { ExecutableValue } from '../types' -import { changeCurrentIntoNewItems } from './change-current-into-new-items' - -export const handleTextExecutable = ( - executableValue: ExecutableValue, - nodes: Array, - el: Node -) => { - if (nodes.length) { - const parent = - executableValue.renderedNodes[0]?.parentNode ?? el.parentNode - - changeCurrentIntoNewItems(executableValue.renderedNodes, nodes, parent) - - executableValue.renderedNodes = nodes - } else { - const n = executableValue.renderedNodes[0] - - const emptyNode = document.createTextNode('') - n.parentNode?.replaceChild(emptyNode, n) - - if (Array.isArray(executableValue.renderedNodes)) { - executableValue.renderedNodes.forEach((n: Node) => { - n.parentNode?.removeChild(n) - }) - } - - executableValue.renderedNodes = [emptyNode] - } -} diff --git a/src/helpers/is-not.helper.ts b/src/helpers/is-not.helper.ts index 46f8a82b..cb39a5f9 100644 --- a/src/helpers/is-not.helper.ts +++ b/src/helpers/is-not.helper.ts @@ -11,9 +11,8 @@ export const isNot = helper( ( st: T | StateGetter, checker: HelperValueChecker | AnythingButAFunction - ) => { - return typeof checker === 'function' + ) => + typeof checker === 'function' ? !(checker as HelperValueChecker)(val(st)) : val(st) !== checker - } ) diff --git a/src/helpers/is.helper.ts b/src/helpers/is.helper.ts index 8b7107ae..de639e2b 100644 --- a/src/helpers/is.helper.ts +++ b/src/helpers/is.helper.ts @@ -11,9 +11,8 @@ export const is = helper( ( st: T | StateGetter, checker: HelperValueChecker | AnythingButAFunction - ) => { - return typeof checker === 'function' + ) => + typeof checker === 'function' ? Boolean((checker as HelperValueChecker)(val(st))) : val(st) === checker - } ) diff --git a/src/helpers/one-of.helper.ts b/src/helpers/one-of.helper.ts index e437edaf..413e8792 100644 --- a/src/helpers/one-of.helper.ts +++ b/src/helpers/one-of.helper.ts @@ -7,6 +7,6 @@ import { val } from '../utils' * @param st * @param list */ -export const oneOf = helper((st: T | StateGetter, list: unknown[]) => { - return list.includes(val(st)) -}) +export const oneOf = helper((st: T | StateGetter, list: unknown[]) => + list.includes(val(st)) +) diff --git a/src/html.spec.ts b/src/html.spec.ts index 4703efd6..8fb60a2f 100644 --- a/src/html.spec.ts +++ b/src/html.spec.ts @@ -1,5 +1,5 @@ import {html, HtmlTemplate, state} from './html' -import {when, repeat, oneOf} from './helpers' +import {when, repeat, oneOf, is} from './helpers' import {suspense} from './utils' import {helper} from "./Helper"; @@ -65,6 +65,8 @@ describe('html', () => { temp.render(document.body) expect(document.body.innerHTML).toBe('sample') + expect(document.body.childNodes).toHaveLength(1) + expect(Array.from(document.body.childNodes)).toEqual(temp.nodes) }) it('should render html as text', () => { @@ -107,17 +109,37 @@ describe('html', () => { const a = html`

more than 10

` const b = html`

less than 10

` - const temp = html`${() => (x > 10 ? html`${a}` : html`${b}`)}` + const temp = html`total: ${() => (x > 10 ? html`>${a}` : html`<${b}`)}` temp.render(document.body) - expect(document.body.innerHTML).toBe('

more than 10

') + expect(temp.nodes).toHaveLength(3) + expect(temp.nodes.map(n => (n as Element).outerHTML || n.nodeValue)).toEqual([ + "total: ", + ">", + "

more than 10

" + ]) + expect(a.mounted).toBeTruthy() + expect(a.nodes).toHaveLength(1) + expect((a.nodes[0] as Element).outerHTML).toBe('

more than 10

') - x = 5 + expect(b.mounted).toBeFalsy() + expect(b.nodes).toHaveLength(0) - temp.update() + expect(document.body.innerHTML).toBe('total: >

more than 10

') - expect(document.body.innerHTML).toBe('

less than 10

') + x = 5 + + temp.update() + + expect(temp.nodes).toHaveLength(3) + expect(temp.nodes.map(n => (n as Element).outerHTML || n.nodeValue)).toEqual([ + "total: ", + "<", + "

less than 10

" + ]) + + expect(document.body.innerHTML).toBe('total: <

less than 10

') }) it('should handle deeply nested HTML', () => { @@ -306,12 +328,8 @@ describe('html', () => { }) it('should render nested from values', () => { - const button = html` - ` - const app = html` -

App

- ${button} - ` + const button = html`` + const app = html`

App

${button}` app.render(document.body) @@ -331,35 +349,45 @@ describe('html', () => { list.render(document.body) expect(document.body.innerHTML).toBe( - '
  • item-1
  • item-2
  • item-3
' + '
    \n' + + '\t\t\t\t
  • item-1
  • item-2
  • item-3
  • \n' + + '\t\t\t
' ) items = [1] list.update() - expect(document.body.innerHTML).toBe('
  • item-1
') + expect(document.body.innerHTML).toBe('
    \n' + + '\t\t\t\t
  • item-1
  • \n' + + '\t\t\t
') items = [1, 2] list.update() expect(document.body.innerHTML).toBe( - '
  • item-1
  • item-2
' + '
    \n' + + '\t\t\t\t
  • item-1
  • item-2
  • \n' + + '\t\t\t
' ) items = [] list.update() - expect(document.body.innerHTML).toBe('
    ') + expect(document.body.innerHTML).toBe('
      \n' + + '\t\t\t\t\n' + + '\t\t\t
    ') items = [1, 2, 3] list.update() expect(document.body.innerHTML).toBe( - '
    • item-1
    • item-2
    • item-3
    ' + '
      \n' + + '\t\t\t\t
    • item-1
    • item-2
    • item-3
    • \n' + + '\t\t\t
    ' ) }) @@ -373,7 +401,9 @@ describe('html', () => { deep.render(document.body) expect(document.body.innerHTML).toBe( - '
    • sample
    ' + '
      \n' + + '\t\t\t\t
    • sample
    • \n' + + '\t\t\t
    ' ) }) @@ -528,6 +558,7 @@ describe('html', () => { btn.render(document.body) + expect(document.body.innerHTML).toBe('') expect(btn.refs['btn'][0]).toBeInstanceOf(HTMLButtonElement) expect(btn.refs['greater'][0]).toBeInstanceOf(HTMLSpanElement) expect(btn.refs['less']).toBeUndefined() @@ -536,6 +567,7 @@ describe('html', () => { btn.update() + expect(document.body.innerHTML).toBe('') expect(btn.refs['btn'][0]).toBeInstanceOf(HTMLButtonElement) expect(btn.refs['greater'][0]).toBeInstanceOf(HTMLSpanElement) expect(btn.refs['less'][0]).toBeInstanceOf(HTMLSpanElement) @@ -550,7 +582,7 @@ describe('html', () => { btn.render(document.body) expect(document.body.innerHTML).toBe( - '' + '' ) loading = false @@ -570,7 +602,7 @@ describe('html', () => { btn.render(document.body) expect(document.body.innerHTML).toBe( - '' + '' ) loading = false @@ -634,7 +666,7 @@ describe('html', () => { btn.render(document.body) expect(document.body.innerHTML).toBe( - '' + '' ) loading = false @@ -654,7 +686,7 @@ describe('html', () => { btn.render(document.body) expect(document.body.innerHTML).toBe( - '' + '' ) loading = false @@ -674,7 +706,7 @@ describe('html', () => { btn.render(document.body) expect(document.body.innerHTML).toBe( - '' + '' ) loading = false @@ -694,7 +726,7 @@ describe('html', () => { btn.render(document.body) expect(document.body.innerHTML).toBe( - '' + '' ) loading = false @@ -1145,11 +1177,11 @@ describe('html', () => { #sample: any; temp: HtmlTemplate | undefined; - get sample(): any { + get sample() { return this.#sample; } - set sample(val: any) { + set sample(val) { this.#sample = val; this.temp?.update() } @@ -1309,7 +1341,7 @@ describe('html', () => { items.render(document.body) expect(document.body.innerHTML).toBe( - 'item 1item 3item 5' + 'item 1item 5item 3' ) }) @@ -1421,10 +1453,14 @@ describe('html', () => { expect(todo.shadowRoot?.innerHTML).toBe('
    \n' + '\t\t\t\t\t\t\t
    \n' + - '\t\t\t\t\t\t\t\t

    sample

    \n' + - '\t\t\t\t\t\t\t
    \n' + + '\t\t\t\t\t\t\t\t

    sample

    \n' + + '\t\t\t\t\t\t\t\t\n' + + '\t\t\t\t\t\t\t
    \n' + + '\t\t\t\t\t\t\t
    \n' + + '\t\t\t\t\t\t\t\t\n' + '\t\t\t\t\t\t\t\t\n' + - '\t\t\t\t\t\t\t\t
    \n' + + '\t\t\t\t\t\t\t\t\n' + + '\t\t\t\t\t\t\t
    \n' + '\t\t\t\t\t\t') todoList[0].status = 'completed' @@ -1436,9 +1472,14 @@ describe('html', () => { ) expect(todo.shadowRoot?.innerHTML).toBe('
    \n' + '\t\t\t\t\t\t\t
    \n' + - '\t\t\t\t\t\t\t\t

    sample

    \n' + - '\t\t\t\t\t\t\t
    \n' + - '\t\t\t\t\t\t\t\t
    \n' + + '\t\t\t\t\t\t\t\t

    sample

    \n' + + '\t\t\t\t\t\t\t\t\n' + + '\t\t\t\t\t\t\t
    \n' + + '\t\t\t\t\t\t\t
    \n' + + '\t\t\t\t\t\t\t\t\n' + + '\t\t\t\t\t\t\t\t\n' + + '\t\t\t\t\t\t\t\t\n' + + '\t\t\t\t\t\t\t
    \n' + '\t\t\t\t\t\t' ) }) @@ -1483,15 +1524,21 @@ describe('html', () => { Todo.render(document.body) - expect(document.body.innerHTML).toBe('
      ') + expect(document.body.innerHTML).toBe('
        \n' + + '\t\t\t\t\t\n' + + '\t\t\t\t
      ') setTodos((prev) => [...prev, 'first']) - expect(document.body.innerHTML).toBe('
      • first
      ') + expect(document.body.innerHTML).toBe('
        \n' + + '\t\t\t\t\t
      • first
      • \n' + + '\t\t\t\t
      ') setTodos([]) - expect(document.body.innerHTML).toBe('
        ') + expect(document.body.innerHTML).toBe('
          \n' + + '\t\t\t\t\t\n' + + '\t\t\t\t
        ') }) it('when item changes', () => { @@ -1518,19 +1565,19 @@ describe('html', () => { status: 'pending', }, ]) - expect(document.body.innerHTML).toBe('
        action 1pending
        ') + expect(document.body.innerHTML).toBe('
        action 1 pending
        ') setTodos(prev => prev.map(t => t.id === 1 ? {...t, status: 'complete'} : t )) - expect(document.body.innerHTML).toBe('
        action 1complete
        ') + expect(document.body.innerHTML).toBe('
        action 1 complete
        ') setTodos(prev => [...prev, {name: 'action 2', status: 'pending', id: 2}]) expect(document.body.innerHTML).toBe('
        ' + - '
        action 1complete
        ' + - '
        action 2pending
        ' + + '
        action 1 complete
        ' + + '
        action 2 pending
        ' + '
        ') }) @@ -1547,13 +1594,9 @@ describe('html', () => { el.render(document.body) expect(document.body.innerHTML).toBe( - '
          ' + - '
        • 1
        • ' + - '
        ' + - '
          ' + - '
        • 1
        • ' + - '
        • 2
        • ' + - '
        ') + '
        • 1
        • \n' + + '\t\t\t\t\t
        • 1
        • 2
        • \n' + + '\t\t\t\t\t
        ') }) it('with helper list', () => { @@ -1625,7 +1668,7 @@ describe('html', () => { el.update() expect(document.body.innerHTML).toBe('Non Zero: 20') - expect(document.body.children[0]).toEqual(n) // same node should be rendered + expect(document.body.children[0]).not.toEqual(n) // node should be re-created }) it('when nested', () => { @@ -1669,6 +1712,45 @@ describe('html', () => { expect(document.body.innerHTML).toBe('

        no items

        ') }); + + it('when nested with dependent logic', () => { + const [currentPlayer, setCurrentPlayer] = state("x") + const [ended, setEnded] = state(false) + + html` + ${when(ended, html` + ${when(is(currentPlayer, 'xy'), + html`tie`, + html`${currentPlayer} won` + )} + + `)} + `.render(document.body) + + expect(document.body.innerHTML).toBe('') + + setEnded(true) + expect(document.body.innerHTML).toBe('x won\n' + + '\t\t\t\t\t') + + setCurrentPlayer('xy') + expect(document.body.innerHTML).toBe('tie\n' + + '\t\t\t\t\t') + + setEnded(false) + expect(document.body.innerHTML).toBe('') + + setCurrentPlayer('x') + expect(document.body.innerHTML).toBe('') + + setEnded(true) + expect(document.body.innerHTML).toBe('x won\n' + + '\t\t\t\t\t') + + setCurrentPlayer('x') + setEnded(false) + expect(document.body.innerHTML).toBe('') + }) }) it('should work with state helper', () => { @@ -1804,6 +1886,7 @@ describe('html', () => { }); it('unmount and resume on mount', () => { + jest.useFakeTimers() const updateMock = jest.fn(); const [x, setX] = state(5); @@ -1825,9 +1908,13 @@ describe('html', () => { setX(20); + jest.advanceTimersByTime(1000) + expect(updateMock).toHaveBeenCalled() expect(document.body.innerHTML).toBe("item 20") + + jest.useRealTimers() }) }) @@ -1859,4 +1946,5 @@ describe('html', () => { expect(document.body.innerHTML).toBe('<button>click me</button>') }); + }) diff --git a/src/html.ts b/src/html.ts index bb325b40..2bb2bbd8 100644 --- a/src/html.ts +++ b/src/html.ts @@ -1,13 +1,12 @@ import { - Executable, StateGetter, StateSetter, StateSubscriber, StateUnSubscriber, } from './types' -import { Doc } from './executable/Doc' -import { handleExecutable } from './executable/handle-executable' -import { parse } from '@beforesemicolon/html-parser' +import type { DynamicValueResolver } from './dynamic-value/DynamicValueResolver' +import { Doc } from './Doc' +import { parse } from '@beforesemicolon/html-parser/dist/parse' import { Helper } from './Helper' // prevents others from creating functions that can be subscribed to @@ -16,38 +15,28 @@ const id = 'S' + Math.floor(Math.random() * 10000000) export class HtmlTemplate { #htmlTemplate: string - #nodes: Node[] = [] - #renderTarget: ShadowRoot | Element | null = null + #nodes: Array = [] + #renderTarget: ShadowRoot | Element | DocumentFragment | null = null #refs: Record> = {} #updateSubs: Set<() => void> = new Set() #mountSubs: Set<() => void> = new Set() #unmountSubs: Set<() => void> = new Set() #stateUnsubs: Set<() => void> = new Set() - #executablesByNode: Map = new Map() + #dynamicValues: DynamicValueResolver[] = [] #values: Array = [] - #root: DocumentFragment | null = null + #mounted = false /** * list of direct ChildNode from the template that got rendered */ get nodes() { - return this.#nodes.flatMap((node) => { - const e = this.#executablesByNode.get(node) - - if (e?.content.length) { - return Array.from( - new Set( - e.content.flatMap((v) => - Array.isArray(v.renderedNodes) - ? v.renderedNodes - : [v.renderedNodes] - ) - ) + return Array.from( + new Set( + this.#nodes.flatMap((n) => + n instanceof Node ? [n] : n.renderedNodes ) - } - - return [node] - }) + ) + ) } /** @@ -86,11 +75,15 @@ export class HtmlTemplate { }) } + get mounted() { + return this.#mounted + } + constructor(parts: TemplateStringsArray | string[], values: unknown[]) { this.#values = values this.#htmlTemplate = parts .map((s, i) => { - return i === parts.length - 1 ? s : s + `{{val${i}}}` + return i === parts.length - 1 ? s : s + `$val${i}` }) .join('') .trim() @@ -102,7 +95,7 @@ export class HtmlTemplate { * @param force */ render = ( - elementToAttachNodesTo: ShadowRoot | Element | null, + elementToAttachNodesTo: ShadowRoot | Element | DocumentFragment, force = false ) => { if ( @@ -110,30 +103,16 @@ export class HtmlTemplate { elementToAttachNodesTo !== this.renderTarget && (force || !this.renderTarget) && (elementToAttachNodesTo instanceof ShadowRoot || - elementToAttachNodesTo instanceof Element) + elementToAttachNodesTo instanceof Element || + elementToAttachNodesTo instanceof DocumentFragment) ) { - this.#renderTarget = elementToAttachNodesTo - let initialized = false - - if (!this.#root) { - initialized = true - this.#init(elementToAttachNodesTo as Element) - } - - this.#nodes.forEach((node) => { - if (node.parentNode !== elementToAttachNodesTo) { - elementToAttachNodesTo.appendChild(node) - } - }) - - // this needs to happen after the nodes have been added again in order - // to catch any updates done to things like state while the template - // was unmounted - if (!initialized && !this.#stateUnsubs.size) { - this.#subscribeToState() - this.update() + if (force) { + this.unmount() } + this.#renderTarget = elementToAttachNodesTo + elementToAttachNodesTo.appendChild(this.#init()) + this.#mounted = true this.#broadcast(this.#mountSubs) } @@ -144,10 +123,10 @@ export class HtmlTemplate { * replaces the target element with the template nodes. Does not replace HEAD, BODY, HTML, and ShadowRoot elements * @param target */ - replace = (target: Node | Element | HtmlTemplate | null) => { + replace = (target: Node | HtmlTemplate) => { if ( target instanceof HtmlTemplate || - (target instanceof Element && + (target instanceof Node && !( target instanceof ShadowRoot || target instanceof HTMLBodyElement || @@ -167,30 +146,13 @@ export class HtmlTemplate { } this.#renderTarget = element.parentNode as Element - let initialized = false + element.parentNode?.replaceChild(this.#init(), element) - if (!this.#root) { - initialized = true - this.#init(element) - } - - const frag = document.createDocumentFragment() - frag.append(...this.#nodes) - element.parentNode?.replaceChild(frag, element) - - // this needs to happen after the nodes have been added again in order - // to catch any updates done to things like state while the template - // was unmounted - if (!initialized && !this.#stateUnsubs.size) { - this.#subscribeToState() - this.update() - } else { - this.#broadcast(this.#mountSubs) - } + this.#mounted = true + this.#broadcast(this.#mountSubs) - // only need to unmount the template nodes - // if the target is not a template, it will be automatically removed - // when replaceChild is called above. + // since the nodes of the template are being replaced + // we can go ahead an unmount it so it cleans up properly if (target instanceof HtmlTemplate) { target.unmount() } @@ -206,22 +168,26 @@ export class HtmlTemplate { */ update() { // only update if the nodes were already rendered and there are actual values - if (this.renderTarget && this.#executablesByNode.size) { - this.#executablesByNode.forEach((executable, node) => { - handleExecutable(node, executable, this.#refs) - }) + if (this.renderTarget && this.#dynamicValues.length) { + this.#dynamicValues.forEach((dv) => dv.resolve(this.#refs)) this.#broadcast(this.#updateSubs) } } unmount() { - if (this.#renderTarget) { - this.#nodes.forEach((n) => { + if (this.#renderTarget && this.#mounted) { + this.#dynamicValues.forEach((dv) => { + dv.unmount() + }) + this.nodes.forEach((n) => { if (n.parentNode) { n.parentNode.removeChild(n) } }) this.#renderTarget = null + this.#dynamicValues = [] + this.#mounted = false + this.#nodes = [] this.unsubscribeFromStates() this.#broadcast(this.#unmountSubs) } @@ -258,71 +224,53 @@ export class HtmlTemplate { set.forEach((sub) => setTimeout(sub, 0)) } - #init(target: ShadowRoot | Element) { - if (target) { - this.#root = parse( - this.#htmlTemplate, - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - Doc(this.#values, this.#refs, (node, e, type) => { - if (!this.#executablesByNode.has(node)) { - this.#executablesByNode.set(node, { - content: [], - events: [], - directives: [], - attributes: [], - }) - } - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - this.#executablesByNode.get(node)[type].push(e) - }) - ) as DocumentFragment - this.#subscribeToState() - this.#nodes = Array.from(this.#root.childNodes) - } + #init() { + const fragLike = parse( + this.#htmlTemplate, + // @ts-expect-error this is a Document like object + Doc(this.#values, this.#refs, (dv) => this.#dynamicValues.push(dv)) + ) as DocumentFragment + this.#subscribeToState() + const renderedNodeDvMapping = new WeakMap() + this.#dynamicValues.forEach((dv) => { + dv.resolve(this.#refs) + dv.renderedNodes.forEach((n) => renderedNodeDvMapping.set(n, dv)) + }) + this.#nodes = Array.from( + fragLike.childNodes, + (n) => renderedNodeDvMapping.get(n) ?? n + ) + const frag = document.createDocumentFragment() + frag.append(...fragLike.childNodes) + return frag } #subscribeToState() { // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this - Array.from(this.#executablesByNode.entries()).forEach( - ([node, { content, directives, attributes, events }]) => { - ;[...content, ...directives, ...events, ...attributes].forEach( - (e) => { - // subscribe to any state value used in the node - e.parts.forEach(function sub(val: unknown) { - if (val instanceof Helper) { - val.args.forEach(sub) - } else if ( - typeof val === 'function' && - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - typeof val[id] === 'function' - ) { - const nodeExec = - self.#executablesByNode.get(node) - - if (nodeExec) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const unsub = val[id](() => { - handleExecutable( - node, - nodeExec, - self.#refs - ) - self.#updateSubs.forEach((cb) => cb()) - }, id) - self.#stateUnsubs.add(unsub) - } - } - }) + this.#dynamicValues.forEach((dv) => { + // subscribe to any state value used in the node + ;(Array.isArray(dv.value) ? dv.value : [dv.value]).forEach( + function sub(val: unknown) { + if (val instanceof Helper) { + // subscribe to possible state value provided as argument to helpers + val.args.forEach(sub) + } else if ( + typeof val === 'function' && + // @ts-expect-error state value exposes function accessible by this global id + typeof val[id] === 'function' + ) { + // @ts-expect-error state value exposes function accessible by this global id + const unsub = val[id](() => { + dv.resolve(self.#refs) + self.#updateSubs.forEach((cb) => cb()) + }, id) + self.#stateUnsubs.add(unsub) } - ) - } - ) + } + ) + }) } } @@ -334,37 +282,20 @@ export class HtmlTemplate { export const html = ( parts: TemplateStringsArray | string[], ...values: unknown[] -) => { - return new HtmlTemplate(parts, values) -} +) => new HtmlTemplate(parts, values) -export const state = (val: T, sub?: StateSubscriber) => { +export const state = ( + val: T, + sub?: StateSubscriber +): Readonly<[StateGetter, StateSetter, StateUnSubscriber]> => { const subs: Set<() => void> = new Set() - const arr = new Array(3) as [ - StateGetter, - StateSetter, - StateUnSubscriber, - ] + const getter = () => val if (typeof sub === 'function') { subs.add(sub) } - arr[0] = () => val - arr[1] = (newVal: T | ((val: T) => T)) => { - val = - typeof newVal === 'function' - ? (newVal as (val: T) => T)(val) - : newVal - subs.forEach((sub) => { - sub() - }) - } - arr[2] = () => { - sub && subs.delete(sub) - } - - Object.defineProperty(arr[0], id, { + Object.defineProperty(getter, id, { // ensure only HtmlTemplate can subscribe to this value value: (sub: () => void, subId: string) => { if (subId === id && typeof sub === 'function') { @@ -377,7 +308,19 @@ export const state = (val: T, sub?: StateSubscriber) => { }, }) - Object.freeze(arr) - - return arr + return Object.freeze([ + getter, + (newVal: T | ((val: T) => T)) => { + val = + typeof newVal === 'function' + ? (newVal as (val: T) => T)(val) + : newVal + subs.forEach((sub) => { + sub() + }) + }, + () => { + sub && subs.delete(sub) + }, + ]) } diff --git a/src/types.ts b/src/types.ts index d545067d..694e1bb8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,17 +1,20 @@ -export interface ExecutableValue { - name: string - rawValue: string - value: unknown - parts: unknown[] - renderedNodes: Node[] - prop?: string +export enum DynamicValueType { + Content = 'content', + Attribute = 'attribute', + Directive = 'directive', + Event = 'event', } -export interface Executable { - directives: ExecutableValue[] - attributes: ExecutableValue[] - content: ExecutableValue[] - events: ExecutableValue[] +export type ObjectLiteral = Record + +export interface DynamicValue { + name: string // name of the node property or attribute to target when updating + type: DynamicValueType + rawValue: string // raw value collected from the template + value: T // value injected in the template + data: D // last result on the dynamic value + renderedNodes: Node[] // nodes currently rendered + prop: string | null // attribute suffix, e.g in class.active, active is the suffix } export type StateGetter = () => T diff --git a/src/utils/boolean-attributes.ts b/src/utils/boolean-attributes.ts index ff6b4c76..20702e74 100644 --- a/src/utils/boolean-attributes.ts +++ b/src/utils/boolean-attributes.ts @@ -1,4 +1,4 @@ -export const booleanAttributes = { +export const booleanAttributes: Record> = { allowfullscreen: {}, async: {}, autofocus: {}, diff --git a/src/utils/index.ts b/src/utils/index.ts index 0b31520c..3244575a 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -9,3 +9,4 @@ export * from './val' export * from './element' export * from './suspense' export * from './set-element-attribute' +export * from './shallow-copy-array-or-object-literal' diff --git a/src/utils/is-object-literal.spec.ts b/src/utils/is-object-literal.spec.ts index 99d4ea05..b572dde3 100644 --- a/src/utils/is-object-literal.spec.ts +++ b/src/utils/is-object-literal.spec.ts @@ -1,13 +1,19 @@ import { isObjectLiteral } from './is-object-literal' +import { html } from '../html' +import { when } from "../helpers"; describe('isObjectLiteral', () => { it('should be object', () => { expect(isObjectLiteral({})).toBeTruthy() expect(isObjectLiteral(new Object({}))).toBeTruthy() expect(isObjectLiteral(Object.create(null))).toBeTruthy() + expect(isObjectLiteral(Object.create({sample: 12}))).toBeTruthy() }) it('should NOT be object', () => { + expect(isObjectLiteral(null)).toBeFalsy() + expect(isObjectLiteral(html`sample`)).toBeFalsy() + expect(isObjectLiteral(when(true, 12))).toBeFalsy() expect(isObjectLiteral(new Map())).toBeFalsy() expect(isObjectLiteral(new Set())).toBeFalsy() expect(isObjectLiteral(String(''))).toBeFalsy() @@ -15,5 +21,6 @@ describe('isObjectLiteral', () => { expect(isObjectLiteral([])).toBeFalsy() expect(isObjectLiteral(new Function())).toBeFalsy() expect(isObjectLiteral(Symbol())).toBeFalsy() + expect(isObjectLiteral(new (class Sample{}))).toBeFalsy() }) }) diff --git a/src/utils/is-object-literal.ts b/src/utils/is-object-literal.ts index 2a8f5084..5a25c3e4 100644 --- a/src/utils/is-object-literal.ts +++ b/src/utils/is-object-literal.ts @@ -1,2 +1,10 @@ -export const isObjectLiteral = (val: unknown) => - val && Object.prototype.toString.call(val) === '[object Object]' +import { ObjectLiteral } from '../types' + +export const isObjectLiteral = (val: unknown) => { + return ( + val && + Object.prototype.toString.call(val) === '[object Object]' && + ((val as ObjectLiteral).constructor === Object || + Object.getPrototypeOf(val) === null) + ) +} diff --git a/src/utils/is-primitive.spec.ts b/src/utils/is-primitive.spec.ts index f8b5c55e..ff488819 100644 --- a/src/utils/is-primitive.spec.ts +++ b/src/utils/is-primitive.spec.ts @@ -8,10 +8,10 @@ describe('isPrimitive', () => { expect(isPrimitive(BigInt(1))).toBeTruthy() expect(isPrimitive(Symbol('ss'))).toBeTruthy() expect(isPrimitive(undefined)).toBeTruthy() - expect(isPrimitive(null)).toBeTruthy() }) it('should NOT be primitive', () => { + expect(isPrimitive(null)).toBeFalsy() expect(isPrimitive([])).toBeFalsy() expect(isPrimitive({})).toBeFalsy() expect(isPrimitive(new Map())).toBeFalsy() diff --git a/src/utils/is-primitive.ts b/src/utils/is-primitive.ts index 9a73737d..b79c12ea 100644 --- a/src/utils/is-primitive.ts +++ b/src/utils/is-primitive.ts @@ -1,6 +1,2 @@ -export const isPrimitive = (val: unknown) => { - return ( - val === null || - /undefined|number|string|bigint|boolean|symbol/.test(typeof val) - ) -} +export const isPrimitive = (val: unknown) => + /undefined|number|string|bigint|boolean|symbol/.test(typeof val) diff --git a/src/utils/json-parse.ts b/src/utils/json-parse.ts index edea3ac8..4fbc2569 100644 --- a/src/utils/json-parse.ts +++ b/src/utils/json-parse.ts @@ -1,10 +1,9 @@ export function jsonParse(value: T): T { if (value && typeof value === 'string') { try { - value = - value === 'undefined' - ? undefined - : JSON.parse(value.replace(/['`]/g, '"')) + return value === 'undefined' + ? undefined + : JSON.parse(value.replace(/['`]/g, '"')) } catch (e) { /* empty */ } diff --git a/src/utils/set-element-attribute.spec.ts b/src/utils/set-element-attribute.spec.ts new file mode 100644 index 00000000..10ee2fa6 --- /dev/null +++ b/src/utils/set-element-attribute.spec.ts @@ -0,0 +1,53 @@ +import { setElementAttribute } from "./set-element-attribute"; + +describe("setElementAttribute", () => { + it("should set the attribute value and remove it with null", () => { + const el = document.createElement("div"); + + setElementAttribute(el, "id", "test"); + + expect(el.hasAttribute("id")).toEqual(true); + expect(el.getAttribute("id")).toEqual("test"); + + setElementAttribute(el, "id", null); + + expect(el.hasAttribute("id")).toEqual(false); + }); + + it("should set web component non-primitive prop", () => { + class TodoItem extends HTMLElement { + todo: { title: string } | null = null; + #sample: unknown = null; + + set sample(newVal: unknown) { + this.#sample = newVal; + } + + get sample() { + return this.#sample; + } + + get no() { + return null; + } + } + + customElements.define("todo-item", TodoItem); + + const el = document.createElement("todo-item") as TodoItem; + + setElementAttribute(el, "todo", { title: "test" }); + setElementAttribute(el, "sample", {dope: true}); + setElementAttribute(el, "no", {nope: true}); + + expect(el.getAttribute('todo')).toEqual('{"title":"test"}'); + expect(el.todo).toEqual({ "title": "test" }); + + expect(el.getAttribute('sample')).toEqual('{"dope":true}'); + expect(el.sample).toEqual({"dope":true}); + + expect(el.getAttribute('no')).toEqual('{"nope":true}'); + expect(el.no).toEqual(null); + + }); +}); diff --git a/src/utils/set-element-attribute.ts b/src/utils/set-element-attribute.ts index 39ae8f50..cd23456e 100644 --- a/src/utils/set-element-attribute.ts +++ b/src/utils/set-element-attribute.ts @@ -15,18 +15,16 @@ export const setElementAttribute = ( if (value !== undefined && value !== null) { el.setAttribute(key, jsonStringify(value)) + // take care of web component elements with prop setters if (el.nodeName.includes('-') && !isPrimitive(value)) { - const descriptors = Object.getOwnPropertyDescriptors( - Object.getPrototypeOf(el) - ) + const descriptor = + // describe the property directly on the object + Object.getOwnPropertyDescriptor(el, key) ?? + // describe properties defined as setter/getter by checking the prototype + Object.getOwnPropertyDescriptors(Object.getPrototypeOf(el))[key] - // make sure the property can be set - if ( - descriptors.hasOwnProperty(key) && - typeof descriptors[key].set === 'function' - ) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore cant use string key for Element + if (descriptor?.writable || typeof descriptor?.set === 'function') { + // @ts-expect-error Cannot assign to X because it is a read-only property. el[key] = value } } diff --git a/src/utils/shallow-copy-array-or-object-literal.ts b/src/utils/shallow-copy-array-or-object-literal.ts new file mode 100644 index 00000000..5cc77825 --- /dev/null +++ b/src/utils/shallow-copy-array-or-object-literal.ts @@ -0,0 +1,14 @@ +import { isObjectLiteral } from './is-object-literal' +import { ObjectLiteral } from '../types' + +export const shallowCopyArrayOrObjectLiteral = (obj: unknown) => { + if (Array.isArray(obj)) { + return [...obj] + } + + if (isObjectLiteral(obj)) { + return { ...(obj as ObjectLiteral) } + } + + return obj +} diff --git a/src/utils/turn-camel-to-kebab-casing.ts b/src/utils/turn-camel-to-kebab-casing.ts index 8241cf64..5681b8b2 100644 --- a/src/utils/turn-camel-to-kebab-casing.ts +++ b/src/utils/turn-camel-to-kebab-casing.ts @@ -1,8 +1,5 @@ -export function turnCamelToKebabCasing(name: string): string { - return ( - name - .match(/(?:[A-Z]+(?=[A-Z][a-z])|[A-Z]+|[a-zA-Z])[^A-Z]*/g) - ?.map((p) => p.toLowerCase()) - .join('-') ?? name - ) -} +export const turnCamelToKebabCasing = (name: string) => + name + .match(/(?:[A-Z]+(?=[A-Z][a-z])|[A-Z]+|[a-zA-Z])[^A-Z]*/g) + ?.map((p) => p.toLowerCase()) + .join('-') ?? name diff --git a/src/utils/turn-kebab-to-camel-casing.ts b/src/utils/turn-kebab-to-camel-casing.ts index 33406c0b..83cfcc28 100644 --- a/src/utils/turn-kebab-to-camel-casing.ts +++ b/src/utils/turn-kebab-to-camel-casing.ts @@ -1,4 +1,4 @@ -export function turnKebabToCamelCasing(name: string): string { +export const turnKebabToCamelCasing = (name: string) => { if (!/-/.test(name)) { return name.toLowerCase() } diff --git a/tsconfig.json b/tsconfig.json index 93d255d3..f4fbd4ad 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,7 +17,12 @@ "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "allowJs": true + "allowJs": true, + "paths": { + "node_modules/@beforesemicolon/html-parser/dist/Doc.js": [ + "./src/Doc" + ] + } }, "include": ["./src/**/*"], "exclude": ["src/**/*.spec.ts"]