diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 845d6286..bfe2bd6a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,9 +2,9 @@ name: Test on: push: - branches: [next] + branches: [main] pull_request: - branches: [next] + branches: [main] jobs: test: diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs index 8d08abec..a788b099 100644 --- a/docs/.vitepress/config.mjs +++ b/docs/.vitepress/config.mjs @@ -43,8 +43,8 @@ export default defineConfig({ text: "Features", items: [ { - text: "Charming SVG", - link: "/svg", + text: "Charming DOM", + link: "/dom", }, { text: "Charming Canvas", diff --git a/docs/api-index.md b/docs/api-index.md index 2f97d467..081fdc42 100644 --- a/docs/api-index.md +++ b/docs/api-index.md @@ -1,7 +1,6 @@ # API Index -- [_cm_.**svg**](/svg#cm-svg) - creates an SVG element. -- [_cm_.**html**](/svg#cm-html) - creates an HTML element. -- [_cm_.**attr**](/svg#cm-attr) - sets or gets attributes for HTML or SVG elements. -- [_cm_.**tag**](/svg#cm-tag) - creates a new element factory. -- [_cm_.**context2d**](/canvas#cm-context2d) - creates a 2D Canvas drawing context. +- [_cm_.**svg**](/dom#cm-svg) — SVG via tagged template literal. +- [_cm_.**html**](/dom#cm-html) — HTML via tagged template literal. +- [_cm_.**attr**](/dom#cm-attr) — get, set, or remove attributes (including styles and events) on a node. +- [_cm_.**context2d**](/canvas#cm-context2d) — 2D canvas rendering context. diff --git a/docs/dom.md b/docs/dom.md new file mode 100644 index 00000000..9ba1a51d --- /dev/null +++ b/docs/dom.md @@ -0,0 +1,294 @@ +# Charming DOM + +**Charming DOM** helps you create and manipulate DOM elements. Its focus is data-driven tagged template literals for HTML and SVG, so data, markup and mapping stay clearly connected in one place. + +```js +const svg = cm.svg` + d.x, + cy: (d) => d.y, + r: (d) => d.r, + }}/> +`; +``` + +The style is inspired by [D3.js](https://d3js.org/). D3.js excels at dynamic, data-driven DOM updates with a rich API; Charming is aimed at static, data-driven markup with a lighter template syntax. It works well for small UIs, static visualizations and generative art. + +## Creating Elements + +Charming DOM provides two tagged template literals for creating HTML and SVG elements: `cm.html` and `cm.svg`. + +```js +cm.html`

Hello, Charming!

`; +``` + +If multiple top-level nodes are given, the nodes are implicitly wrapped in a [document fragment](https://developer.mozilla.org/en-US/docs/Web/API/DocumentFragment). + +```js +cm.html` +

Hello A

+

Hello B

+

Hello C

+`; +``` + +## Setting Attributes + +You can set attributes (or styles, event listeners) by interpolating an object in place of attributes. + +```js +cm.html`

`; +``` + +Kebab-case attributes are typically specified in snake_case: + +```js +cm.html``; +``` + +But you can also specify them in kebab-case if you prefer: + +```js +cm.html``; +``` + +Styles are typically specified with prefix "style\_" or "style\-": + +```js +cm.html`
`; +``` + +Equivalent to: + +```js +cm.html`
`; +``` + +If an attribute starts with "on", it is treated as an event listener. + +```js +cm.html``; +``` + +The event object and the node are passed as the first and second arguments to the listener. + +```js +cm.html` (node.style.background = "yellow"), + onmousedown: (event, node) => (node.style.background = "red"), + onmouseup: (event, node) => (node.style.background = "yellow"), + onmouseout: (event, node) => (node.style.background = ""), +}}> + hover me! +`; +``` + +## Appending Nodes + +If an interpolated data value is a node, it is inserted into the result at the corresponding location. + +```js +const svg = cm.svg``; + +cm.html`
${svg}
`; +``` + +You can interpolate multiple nodes at once by using an array: + +```js +cm.html``; +``` + +Falsy nodes and nested arrays will be filtered out. + +```js +cm.html``; +``` + +## Binding Data + +If a data attribute is specified, the data is mapped to a list of nodes and appended to the parent node. For other attributes, if the value is a constant, all the nodes are given the same attribute value; otherwise, if the value is a function, it is evaluated for each node, in order, being passed the current datum (_d_), the current index (_i_), the current data (_data_), and the current node (_node_) as positional parameters. The function's return value is then used to set each node's attribute. + +```js +const items = ["red", "green", "blue"]; + +cm.svg` + d, // function value + cx: (d, i) => i * 100, + cy: 50, + }}/> +`; +``` + +This is equivalent to the following but with a more concise structure: + +```js +const items = ["red", "green", "blue"]; + +const svg = cm.svg`${items.map( + (d, i) => + cm.svg``, +)}`; +``` + +The child node can also access its ancestor's data by function attributes. + +```js +const items = ["red", "green", "blue"]; + +cm.svg` + `translate(0, ${i * 100})`, + }}> + d, // Accessing item data. + }}/> + +`; +``` + +Furthermore, the child node can also be a function, which is evaluated for each child node with the same positional parameters as the parent node. + +```js +const items = ["red", "green", "blue"]; + +cm.svg` + `translate(0, ${i * 100})`, + }}> + ${(d, i) => cm.svg``} + +`; +``` + +If the child data is also a function, it is evaluated for each child node with the same positional parameters as the parent node. The function's return value is then used to set the child node's data. This is useful for rendering nested data, such as a table of data. + +```js +const table = [ + [11975, 5871, 8916, 2868], + [1951, 10048, 2060, 6171], + [8010, 16145, 8090, 8045], + [1013, 990, 940, 6907], +]; + +cm.html` + + + + + +
d}}> + ${(d) => d} +
`; +``` + +If data is specified and an attribute starts with "on", it adds a _listener_ for that event. When the event fires, the _listener_ receives the current event (**e**), the current node (**node**), the current datum (**d**), the index (**i**), and the data array (**array**). + +For all events, the listener receives _(event, node, d, i, array)_. For non-data-driven elements, `d`, `i`, and `array` will be `undefined`. + +```js +const items = ["red", "green", "blue"]; + +cm.svg` + d, + onclick: (event, node, d, i) => { + alert(`Item ${i} is ${d}`); + }, + }}/> +`; +``` + +## _cm_.html\`\\` {#cm-html} + +Renders the specified markup as an HTML element and returns it. For more details, please refer to [Creating Elements](#creating-elements). + +```js +const html = cm.html`

Hello, Charming!

`; +``` + +## _cm_.svg\`\\` {#cm-svg} + +Renders the specified markup as an SVG element and returns it. For more details, please refer to [Creating Elements](#creating-elements). + +```js +const svg = cm.svg``; +``` + +## _cm_.attr(_node_, _key_[, _value_]) {#cm-attr} + +If _value_ is not specified, gets the current value of the specified key of the specified node attributes. + +```js +const div = cm.html`
`; + +cm.attr(div, "id"); // "title" +cm.attr(div, "style-background"); // "red" +``` + +If _value_ is specified, sets the specified _attribute_ with the specified _value_ to the specified _node_. + +```js +const svg = cm.svg``; + +// Set attributes +cm.attr(svg, "width", 100); +cm.attr(svg, "height", 100); + +// Set styles +cm.attr(svg, "style-background", "steelblue"); +cm.attr(svg, "style-cursor", "pointer"); + +// Set event listeners +cm.attr(svg, "onclick", (event, node) => { + const current = cm.attr(node, "style-background"); + const next = current === "steelblue" ? "orange" : "steelblue"; + cm.attr(node, "style-background", next); +}); +``` + +If _value_ is specified as null, remove that attribute. + +```js +cm.attr(input, "checked", null); +cm.attr(div, "class", null); +cm.attr(div, "style-color", null); +cm.attr(span, "textContent", null); +``` + +If an event listener is specified as _null_, removes that event listener. Otherwise, removes the older one if exists and adds the new one. + +```js +const svg = cm.svg``; +cm.attr(svg, "onclick", () => alert("hello charming")); +cm.attr(svg, "onclick", null); // Remove the event listener +``` diff --git a/docs/svg.md b/docs/svg.md deleted file mode 100644 index 64ffdc98..00000000 --- a/docs/svg.md +++ /dev/null @@ -1,468 +0,0 @@ -# Charming SVG - -## _cm_.svg(_tag[, options]_) {#cm-svg} - -Creates an SVG element with the specified _tag_. If the _tag_ is not specified, returns null. - -```js eval -cm.svg(); -``` - -### Applying Attributes - -If _options_ is specified, applies the specified attributes to the created SVG element. For kebab-case attributes, specifying them in snake_case is also valid. For styles, specifying them with prefix "style\_" or "style\-". - -```js eval -cm.svg("svg", { - width: 200, - height: 100, - viewBox: "0 0 200 100", - // kebab-case attributes - "stroke-width": 10, - stroke_width: 10, - // styles - "style-background": "steelblue", - style_background: "steelblue", -}); -``` - -For the text content of SVG elements, specifying it as _textContent_: - -```js eval code=false -(() => { - return cm.svg("svg", { - height: 30, - children: [ - cm.svg("text", { - dy: "1em", - textContent: "Hello Charming!", - fill: "steelblue", - }), - ], - }); -})(); -``` - -```js -cm.svg("text", { - dy: "1em", - textContent: "Hello Charming!", - fill: "steelblue", -}); -``` - -If _options.data_ is specified, maps the data to a list of SVG elements and wraps them with a fragment element. If an attribute _value_ is a constant, all the elements are given the same attribute value; otherwise, if an attribute _value_ is a function, it is evaluated for each created element, in order, being passed the current datum (_d_), the current index (_i_), the current data (_data_), and the current node (_node_) as positional parameters. The function's return value is then used to set each element's attribute. - -```js eval code=false -(() => { - return cm.svg("svg", { - width: 200, - height: 60, - children: [ - cm.svg("circle", { - data: [20, 70, 120], - r: 20, - cy: 30, - cx: (d) => d, - fill: (d, i) => `rgb(${i * 100}, ${i * 100}, ${i * 100})`, - }), - ], - }); -})(); -``` - -```js -cm.svg("circle", { - data: [50, 100, 150], - r: 20, - cy: 30, - cx: (d) => d, - fill: (d, i) => { - const b = i * 100; - return `rgb(${b}, ${b}, ${b})`; - }, -}); -``` - -### Appending Nodes - -If _options.children_ is specified as an array of SVG elements, appends the elements to this SVG element. Falsy elements will be filtered out. Nested arrays will be flattened before appending. - -```js eval -cm.svg("svg", { - width: 100, - height: 60, - children: [ - cm.svg("circle", {r: 20, cy: 30, cx: 20}), - [cm.svg("rect", {width: 40, height: 40, y: 10, x: 50})], // Nested array, - null, // Falsy node - false, // Falsy node - ], -}); -``` - -If a _child_ is a string, a text node will be created and appended to the parent node: - -```js eval code=false -(() => { - return cm.svg("svg", { - height: 30, - children: [ - cm.svg("g", { - transform: `translate(0, 20)`, - fill: "steelblue", - children: [cm.svg("text", ["Hello Charming!"])], - }), - ], - }); -})(); -``` - -```js -cm.svg("g", { - fill: "steelblue", - children: [cm.svg("text", ["Hello Charming!"])], -}); -``` - -If _options_ is specified as an array, it's a convenient shorthand for _{children: options}_: - -```js -cm.svg("svg", [cm.svg("circle"), cm.svg("rect")]); -``` - -If _options.data_ is specified, for each _child_ in _options.children_, if it is a function, it is evaluated for each created element, in order, being passed the current datum (_d_), the current index (_i_), the current data (_data_), and the current node (_node_) as positional parameters. The function's return value is then appended to the created element. - -```js eval code=false -(() => { - const g = cm.svg("g", { - data: [0, 1, 2], - transform: (d) => `translate(${d * 50 + 30}, 0)`, - children: [ - (d, i) => { - const a = i * 100; - return cm.svg("circle", { - r: 20, - cy: 30, - fill: `rgb(${a}, ${a}, ${a})`, - }); - }, - ], - }); - return cm.svg("svg", {width: 200, height: 60, children: [g]}); -})(); -``` - -```js -cm.svg("g", { - data: [0, 1, 2], - transform: (d) => `translate(${(d + 1) * 50}, 0)`, - children: [ - (d, i) => { - const a = i * 100; - return cm.svg("circle", { - r: 20, - cy: 30, - fill: `rgb(${a}, ${a}, ${a})`, - }); - }, - ], -}); -``` - -If the _child_ is a constant and the _childOptions.data_ is specified, creates a list of child elements using the parent _options.data_ and appends each child to each parent element. - -```js eval code=false -(() => { - const g = cm.svg("g", { - data: [0, 1, 2], - transform: (d) => `translate(${d * 50 + 30}, 0)`, - children: [ - cm.svg("circle", { - r: 20, - cy: 30, - fill: (d, i) => { - const a = i * 100; - return `rgb(${a}, ${a}, ${a})`; - }, - }), - ], - }); - return cm.svg("svg", {width: 200, height: 60, children: [g]}); -})(); -``` - -```js -cm.svg("g", { - data: [0, 1, 2], - transform: (d) => `translate(${d * 50 + 30}, 0)`, - children: [ - cm.svg("circle", { - r: 20, - cy: 30, - fill: (d, i) => { - const a = i * 100; - return `rgb(${a}, ${a}, ${a})`; - }, - }), - ], -}); -``` - -If the _child_ is a constant and the _childOptions.data_ is specified as a constant, for each parent element, appends a list of child elements using the specified _childOptions.data_. - -```js eval code=false -(() => { - const g = cm.svg("g", { - data: [0, 1], - transform: (d) => `translate(30, ${d * 50})`, - children: [ - cm.svg("circle", { - data: [0, 1, 2], - r: 20, - cy: 30, - cx: (d) => d * 50, - fill: (d, i) => { - const a = i * 100; - return `rgb(${a}, ${a}, ${a})`; - }, - }), - ], - }); - return cm.svg("svg", {width: 180, height: 110, children: [g]}); -})(); -``` - -```js -cm.svg("g", { - data: [0, 1], - transform: (d) => `translate(30, ${d * 50})`, - children: [ - cm.svg("circle", { - data: [0, 1, 2], - r: 20, - cy: 30, - cx: (d) => d * 50, - fill: (d, i) => { - const a = i * 100; - return `rgb(${a}, ${a}, ${a})`; - }, - }), - ], -}); -``` - -If the _child_ is a constant and the _childOptions.data_ is specified as a function, it is evaluated for each parent element, in order, being passed the current datum (_d_), the current index (_i_), the current data (_data_), and the current node (_node_) as positional parameters. The function's return value is then used to create a list of child elements and append to the current parent element. - -```js eval code=false -(() => { - const g = cm.svg("g", { - data: [ - [0, 1, 2], - [3, 4, 5], - ], - transform: (d, i) => `translate(30, ${i * 50})`, - children: [ - cm.svg("circle", { - data: (d) => d, - r: 20, - cy: 30, - cx: (d, i) => i * 50, - fill: (d, i) => { - const a = d * 40; - return `rgb(${a}, ${a}, ${a})`; - }, - }), - ], - }); - return cm.svg("svg", {width: 180, height: 110, children: [g]}); -})(); -``` - -```js -cm.svg("g", { - data: [ - [0, 1, 2], - [3, 4, 5], - ], - transform: (d, i) => `translate(30, ${i * 50})`, - children: [ - cm.svg("circle", { - data: (d) => d, - r: 20, - cy: 30, - cx: (d, i) => i * 50, - fill: (d, i) => { - const a = d * 40; - return `rgb(${a}, ${a}, ${a})`; - }, - }), - ], -}); -``` - -### Handling Events - -If an attribute starts with "on", adds a _listener_ to the created element for the specified event _typename_. When a specified event is dispatched on the created element, the specified _listener_ will be evaluated for the element, being passed the following positional parameters: - -- **event** (or **e**): the current event, -- **node**: the current node, -- **d**: the current data (if the _options.data_ is specified, otherwise `undefined`), -- **i**: the current index (if the _options.data_ is specified, otherwise `undefined`), -- **array**: the current data array (if the _options.data_ is specified, otherwise `undefined`). - -For all events, the listener receives _(event, node, d, i, array)_. For non-data-driven elements, `d`, `i`, and `array` will be `undefined`. - -```js eval code=false -(() => { - const onclick = (event, node) => { - const current = cm.attr(node, "style-background"); - const next = current === "steelblue" ? "orange" : "steelblue"; - cm.attr(node, "style-background", next); - }; - - return cm.svg("svg", { - onclick, - width: 100, - height: 100, - style_background: "steelblue", - style_cursor: "pointer", - }); -})(); -``` - -```js -const onclick = (event, node) => { - const current = cm.attr(node, "style-background"); - const next = current === "steelblue" ? "orange" : "steelblue"; - cm.attr(node, "style-background", next); -}; - -cm.svg("svg", { - onclick, - width: 100, - height: 100, - style_background: "steelblue", - style_cursor: "pointer", -}); -``` - -If the attribute _value_ is specified as an array, the first element of it will be specified as the _listener_, while the second element will specify the characteristics about the event listener, such as whether it is capturing or passive; see [_element.addEventListener_](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener). - -```js -cm.svg("svg", {onclick: [onclick, {capture: true}]}); -``` - -## _cm_.html(_tag[, options]_) {#cm-html} - -Similar to [_cm.svg_](#cm-svg), but creates HTML elements instead. - -```js eval -cm.html("div", { - style_background: "steelblue", - style_width: "100px", - style_height: "100px", -}); -``` - -## _cm_.tag(_namespace_) {#cm-tag} - -Creates an element factory with the specified _namespace_. For example, creates a math factory for MathML: - -```js eval code=false -(() => { - const math = cm.tag("http://www.w3.org/1998/Math/MathML"); - - return math("math", [ - math("mrow", [ - math("mrow", [math("mi", {textContent: "x"}), math("mo", {textContent: "∗"}), math("mn", {textContent: "2"})]), - math("mo", {textContent: "+"}), - math("mi", {textContent: "y"}), - ]), - ]); -})(); -``` - -```js -const math = cm.tag("http://www.w3.org/1998/Math/MathML"); - -const node = math("math", [ - math("mrow", [ - math("mrow", [ - // equivalent to math("mi", ["x"]) - math("mi", {textContent: "x"}), - math("mo", {textContent: "∗"}), - math("mn", {textContent: "2"}), - ]), - math("mo", {textContent: "+"}), - math("mi", {textContent: "y"}), - ]), -]); -``` - -## _cm_.attr(_node_, _key_[, _value_]) {#cm-attr} - -If _value_ is not specified, gets the current value of the specified key of the specified node attributes. - -```js eval inspector=false -const svg = cm.svg("svg", { - height: 100, - style_background: "red", -}); -``` - -```js eval -svg.getAttribute("height"); -``` - -```js eval -cm.attr(svg, "style_background"); -``` - -If _value_ is specified, sets the specified _attribute_ with the specified _value_ to the specified _node_. - -```js eval code=false -(() => { - const svg = cm.svg("svg"); - cm.attr(svg, "width", 100); - cm.attr(svg, "height", 100); - cm.attr(svg, "style-background", "steelblue"); - cm.attr(svg, "style-cursor", "pointer"); - cm.attr(svg, "onclick", (event, node) => { - const current = cm.attr(node, "style-background"); - const next = current === "steelblue" ? "orange" : "steelblue"; - cm.attr(node, "style-background", next); - }); - return svg; -})(); -``` - -```js -const svg = cm.svg("svg"); -cm.attr(svg, "width", 100); -cm.attr(svg, "height", 100); -cm.attr(svg, "style-background", "steelblue"); -cm.attr(svg, "style-cursor", "pointer"); -cm.attr(svg, "onclick", (event, node) => { - const current = cm.attr(node, "style-background"); - const next = current === "steelblue" ? "orange" : "steelblue"; - cm.attr(node, "style-background", next); -}); -``` - -If _value_ is specified as null, remove that attribute. - -```js -cm.attr(input, "checked", null); -cm.attr(div, "class", null); -cm.attr(div, "style-color", null); -cm.attr(span, "textContent", null); -``` - -If an event listener is specified as _null_, removes that event listener. Otherwise, removes the older one if exists and adds the new one. - -```js -const svg = cm.svg("svg"); -cm.attr(svg, "onclick", () => alert("hello charming")); -cm.attr(svg, "onclick", null); -``` diff --git a/docs/why-charming.md b/docs/why-charming.md index 3a4e0944..c341b252 100644 --- a/docs/why-charming.md +++ b/docs/why-charming.md @@ -27,7 +27,12 @@ While numerous tools exist in the ecosystem to simplify these workflows, they of For those who want to access Canvas and SVG directly without writing too much boilerplate code, introducing Charming: ```js -const svg = cm.svg("svg", {width: 300, height: 150, viewBox: "0 0 200 100"}); +const svg = cm.svg``; + const ctx = cm.context2d({width: 300, height: 150}); ``` @@ -47,20 +52,16 @@ Oh, one more thing. Since most SVG creations are data-driven, Charming also has return data; } - const svg = cm.svg("svg", { - width: 480, - height: 480, - children: [ - cm.svg("circle", { - data: circles(240, 240, 200), - cx: (d) => d.x, - cy: (d) => d.y, - r: (d) => d.r, - stroke: "black", - fill: "transparent", - }), - ], - }); + const svg = cm.svg` + d.x, + cy: (d) => d.y, + r: (d) => d.r, + stroke: "black", + fill: "transparent", + }}/> + `; return svg; })(); @@ -77,20 +78,19 @@ function circles(x, y, r, data = []) { return data; } -const svg = cm.svg("svg", { +const svg = cm.svg` d.x, - cy: (d) => d.y, - r: (d) => d.r, - stroke: "black", - fill: "transparent", - }), - ], -}); +}}> + d.x, + cy: (d) => d.y, + r: (d) => d.r, + stroke: "black", + fill: "transparent", + }}/> +`; document.body.appendChild(svg); ``` diff --git a/src/dom/html.js b/src/dom/html.js index c35825d3..43c5e644 100644 --- a/src/dom/html.js +++ b/src/dom/html.js @@ -1,3 +1,7 @@ import {tag} from "./tag.js"; -export const html = tag(null); +export const html = tag((string) => { + const div = document.createElement("div"); + div.innerHTML = string; + return div; +}); diff --git a/src/dom/index.js b/src/dom/index.js index 0962111a..1fddc798 100644 --- a/src/dom/index.js +++ b/src/dom/index.js @@ -1,4 +1,3 @@ -export {tag} from "./tag.js"; export {svg} from "./svg.js"; export {html} from "./html.js"; export {attr} from "./attr.js"; diff --git a/src/dom/svg.js b/src/dom/svg.js index 93668cc3..2f17165c 100644 --- a/src/dom/svg.js +++ b/src/dom/svg.js @@ -1,3 +1,7 @@ import {tag} from "./tag.js"; -export const svg = tag("http://www.w3.org/2000/svg"); +export const svg = tag((string) => { + const g = document.createElementNS("http://www.w3.org/2000/svg", "g"); + g.innerHTML = string; + return g; +}); diff --git a/src/dom/tag.js b/src/dom/tag.js index 4cab981c..9c5f86e9 100644 --- a/src/dom/tag.js +++ b/src/dom/tag.js @@ -1,49 +1,63 @@ import {set} from "./attr.js"; -const isFunc = (x) => typeof x === "function"; +const TYPE_NODE = 1; -const isTruthy = (x) => x != null && x !== false; +const TYPE_TEXT = 3; -const isString = (x) => typeof x === "string"; +const isFunc = (x) => typeof x === "function"; const isNode = (x) => x?.nodeType; -export const tag = (ns) => (name, options) => { - if (!isString(name)) return null; +const isArray = Array.isArray; + +const isPlainObject = (x) => typeof x === "object" && x !== null && !Array.isArray(x); + +const isFalsy = (x) => x === null || x === undefined || x === false; - // Normalize arguments. - if (Array.isArray(options)) options = {children: options}; - if (options === undefined) options = {}; +function postprocess(node) { + if (node.firstChild === node.lastChild) { + return node.firstChild; + } + const root = document.createDocumentFragment(); + const childNodes = Array.from(node.childNodes); + root.append(...childNodes); + return root; +} - // Create node and store parameters. - const node = ns ? document.createElementNS(ns, name) : document.createElement(name); - node.__options__ = options; - node.__name__ = name; +function cloneNode(node) { + const cloned = node.cloneNode(true); + cloned.__context__ = node.__context__; + return cloned; +} - const {data, ...rest} = options; +function contextOf(node) { + let p = node.parentNode; + while (p) { + if (p.__context__) return p.__context__; + p = p.parentNode; + } + return null; +} - // Nested groups. - if (isFunc(data)) return node; +function hydrate(node, value) { + if (!isPlainObject(value)) return; + + const {data, ...attrs} = value; + const context = contextOf(node); // Non data driven node. - if (!data) { - const {children = [], ...attrs} = rest; + if (!data && !context) { for (const [k, v] of Object.entries(attrs)) { - const val = k.startsWith("on") ? v : isFunc(v) ? v(undefined, undefined, undefined, node) : v; + const val = k.startsWith("on") ? v : isFunc(v) ? v(node) : v; set(node, k, val); } - for (const c of children.filter(isTruthy).flat(Infinity)) { - const child = isNode(c) ? c : document.createTextNode("" + c); - node.append(child); - } - return node; + return; } - // Data driven node. - const {children = [], ...attrs} = rest; - const nodes = data.map((d, i, array) => { - const node = ns ? document.createElementNS(ns, name) : document.createElement(name); + // Non data driven node in a data driven parent. + if (!data && context) { for (const [k, v] of Object.entries(attrs)) { + const [d, i, array] = context; if (k.startsWith("on")) { const [l, o] = Array.isArray(v) ? v : [v]; const val = (e) => l(e, node, d, i, array); @@ -53,44 +67,90 @@ export const tag = (ns) => (name, options) => { set(node, k, val); } } - return node; - }); + return; + } - for (const c of children.filter(isTruthy).flat(Infinity)) { - const n = nodes.length; - const __data__ = c?.__options__?.data; - if (isFunc(c)) { - // Data driven children, evaluate on parent data. - for (let i = 0; i < n; i++) { - let child = c(data[i], i, data, nodes[i]); - if (isTruthy(child)) { - child = isNode(child) ? child : document.createTextNode("" + child); - nodes[i].append(child); - } - } - } else if (__data__) { - // Nested groups, evaluate on derived data from parent data. - for (let i = 0; i < n; i++) { - const childData = isFunc(__data__) ? __data__(data[i], i, data, nodes[i]) : __data__; - const childNode = tag(ns)(c.__name__, {...c.__options__, data: childData}); - if (childNode) nodes[i].append(childNode); - } - } else { - // Appended groups, evaluate on parent data. - let childNodes = []; - if (isNode(c)) { - const fragment = tag(ns)(c.__name__, {...c.__options__, data}); - if (fragment) childNodes = Array.from(fragment.childNodes); + // Data driven node. + const nodeData = context && isFunc(data) ? data(context[0], context[1], context[2], node) : data; + const nodes = nodeData.map((d, i, array) => { + const cloned = cloneNode(node); + for (const [k, v] of Object.entries(attrs)) { + if (k.startsWith("on")) { + const [l, o] = Array.isArray(v) ? v : [v]; + const val = (e) => l(e, cloned, d, i, array); + set(cloned, k, [val, o]); } else { - childNodes = data.map(() => document.createTextNode("" + c)); + const val = isFunc(v) ? v(d, i, array, cloned) : v; + set(cloned, k, val); } - for (let i = 0; i < n; i++) nodes[i].append(childNodes[i]); } + cloned.__context__ = [d, i, array, cloned]; + return cloned; + }); + const temp = document.createDocumentFragment(); + temp.append(...nodes); + node.parentNode.insertBefore(temp, node.nextSibling); + + return node; +} + +function append(node, value) { + const children = [isArray(value) ? value : [value]].flat(Infinity); + const temp = document.createDocumentFragment(); + const c = contextOf(node); + for (const child of children) { + const isDataDriven = isFunc(child) && c; + const n = isDataDriven ? child(c[0], c[1], c[2]) : child; + if (isFalsy(n)) continue; + temp.append(isNode(n) ? (isDataDriven ? n : cloneNode(n)) : document.createTextNode(String(n))); } + node.parentNode.insertBefore(temp, node); +} - const root = document.createDocumentFragment(); - root.append(...nodes); - root.__options__ = options; - root.__name__ = name; - return root; -}; +export function tag(render) { + return function ({raw: strings}) { + let string = ""; + for (let j = 0, m = strings.length; j < m; j++) { + const input = strings[j]; + if (j > 0) string += "::" + j; + string += input; + } + const root = render(string); + const walker = document.createTreeWalker(root); // DFS walker. + const removeNodes = []; + while (walker.nextNode()) { + const node = walker.currentNode; + switch (node.nodeType) { + case TYPE_NODE: { + const attributes = node.attributes; + for (let i = attributes.length - 1; i >= 0; i--) { + const {name} = attributes[i]; + if (/^::/.test(name)) { + const value = arguments[+name.slice(2)]; + node.removeAttribute(name); + const removed = hydrate(node, value); + if (removed) { + removeNodes.push(removed); + walker.nextSibling(); // Skip the children of the removed node. + } + } + } + break; + } + case TYPE_TEXT: { + const data = node.data.trim(); + if (/^::/.test(data)) { + const value = arguments[+data.slice(2)]; + append(node, value); + removeNodes.push(node); + } + break; + } + default: + break; + } + } + for (const node of removeNodes) node.remove(); + return postprocess(root); + }; +} diff --git a/test/event.spec.js b/test/event.spec.js index 2e6b4896..53c65dfd 100644 --- a/test/event.spec.js +++ b/test/event.spec.js @@ -1,9 +1,9 @@ import {test, expect, vi} from "vitest"; -import {svg} from "../src/index.js"; +import * as cm from "../src/index.js"; test("svg(tag, options) should set events", () => { const click = vi.fn(); - const root = svg("svg", {onclick: click}); + const root = cm.svg``; const event = new Event("click"); root.dispatchEvent(event); expect(click).toHaveBeenCalledWith(event, root, undefined, undefined, undefined); @@ -11,7 +11,7 @@ test("svg(tag, options) should set events", () => { test("svg(tag, options) should pass datum to event handler", () => { const click = vi.fn(); - const root = svg("svg", {data: [1, 2, 3], onclick: click}); + const root = cm.svg``; const el = root.children[0]; const event = new Event("click"); el.dispatchEvent(event); diff --git a/test/output/interpolateChildren.html b/test/output/interpolateChildren.html new file mode 100644 index 00000000..caa7cb3e --- /dev/null +++ b/test/output/interpolateChildren.html @@ -0,0 +1,5 @@ + + hello + 1 + [object Object] + \ No newline at end of file diff --git a/test/output/interpolateNodeChildren.html b/test/output/interpolateNodeChildren.html new file mode 100644 index 00000000..c513440a --- /dev/null +++ b/test/output/interpolateNodeChildren.html @@ -0,0 +1,17 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/mathXL.html b/test/output/mathXL.html deleted file mode 100644 index ef6db37a..00000000 --- a/test/output/mathXL.html +++ /dev/null @@ -1,21 +0,0 @@ - - - - - x - - - ∗ - - - 2 - - - - + - - - y - - - \ No newline at end of file diff --git a/test/output/multipleDataDrivenAttributes.html b/test/output/multipleDataDrivenAttributes.html new file mode 100644 index 00000000..ea9e29ab --- /dev/null +++ b/test/output/multipleDataDrivenAttributes.html @@ -0,0 +1,17 @@ + + + + + \ No newline at end of file diff --git a/test/output/setNonMarkChildren.html b/test/output/setNonMarkChildren.html index 8bc88c4d..4f22a277 100644 --- a/test/output/setNonMarkChildren.html +++ b/test/output/setNonMarkChildren.html @@ -3,5 +3,5 @@ world - [object Object] + {key: "foo"} \ No newline at end of file diff --git a/test/output/setTable.html b/test/output/setTable.html index 248c93ed..dc157914 100644 --- a/test/output/setTable.html +++ b/test/output/setTable.html @@ -1,58 +1,60 @@ - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + +
- 11975 - - 5871 - - 8916 - - 2868 -
- 1951 - - 10048 - - 2060 - - 6171 -
- 8010 - - 16145 - - 8090 - - 8045 -
- 1013 - - 990 - - 940 - - 6907 -
+ 11975 + + 5871 + + 8916 + + 2868 +
+ 1951 + + 10048 + + 2060 + + 6171 +
+ 8010 + + 16145 + + 8090 + + 8045 +
+ 1013 + + 990 + + 940 + + 6907 +
\ No newline at end of file diff --git a/test/output/strictString.html b/test/output/strictString.html index ec747fa4..56a6051c 100644 --- a/test/output/strictString.html +++ b/test/output/strictString.html @@ -1 +1 @@ -null \ No newline at end of file +1 \ No newline at end of file diff --git a/test/snapshot.spec.js b/test/snapshot.spec.js index c4b0b5da..83d3ff2f 100644 --- a/test/snapshot.spec.js +++ b/test/snapshot.spec.js @@ -4,8 +4,30 @@ import * as snapshots from "./snapshots.js"; const hasOnly = Object.values(snapshots).some((fn) => fn.only); const filtered = hasOnly ? Object.fromEntries(Object.entries(snapshots).filter(([, fn]) => fn.only)) : snapshots; +const TYPE_TEXT = 3; + +function cleanWhitespace(root) { + if (!(root instanceof Node)) return root; + const walker = document.createTreeWalker(root); + const removeNodes = []; + while (walker.nextNode()) { + const node = walker.currentNode; + switch (node.nodeType) { + case TYPE_TEXT: { + const text = node.data.trim(); + node.data = text; + if (text === "") { + removeNodes.push(node); + } + } + } + } + for (const node of removeNodes) node.remove(); + return root; +} + for (const [name, fn] of Object.entries(filtered)) { test(`${name} should match snapshot`, async () => { - await expect(fn()).toMatchFileSnapshot(`./output/${name}.html`); + await expect(cleanWhitespace(fn())).toMatchFileSnapshot(`./output/${name}.html`); }); } diff --git a/test/snapshots.js b/test/snapshots.js index 76b1769f..e94aa3d3 100644 --- a/test/snapshots.js +++ b/test/snapshots.js @@ -1,228 +1,192 @@ -import {html, tag, svg, attr} from "../src/index.js"; +import * as cm from "../src/index.js"; export function strictNull() { - return svg(); + return cm.svg``; } export function strictString() { - return svg(1); + return cm.svg`1`; } export function setAttr() { - const node = svg("svg"); - attr(node, "width", 100); - attr(node, "height", 200); - attr(node, "style-font-size", "100px"); - attr(node, "style-stroke-width", 2); - return node; + return cm.svg``; } export function setAttributes() { - return svg("svg", { + return cm.svg``; } export function setSnakeCaseAttributes() { - return svg("svg", { + return cm.svg``; } export function setKebabCaseAttributes() { - return svg("svg", { + return cm.svg``; } export function setTextContent() { - return svg("svg", { - textContent: "hello", - }); + return cm.svg`hello`; } export function setInnerHTML() { - return svg("svg", { - innerHTML: "hello", - }); + return cm.svg`hello`; } export function setStyle() { - return svg("svg", { + return cm.svg``; } export function setFunctionAttributes() { - return svg("svg", { + return cm.svg` 100, height: () => 200, - }); + }}/>`; } export function setFunctionAttributesWithNode() { - return svg("svg", { - width: (_d, _i, _data, node) => node.clientWidth, - height: (_d, _i, _data, node) => node.clientHeight, - }); + return cm.svg` node.clientWidth, + height: (node) => node.clientHeight, + }}/>`; } export function setDataDrivenFunctionWithNode() { - return svg("circle", { + return cm.svg` node.clientWidth * d, - }); + }}/>`; } export function setChildren() { - return svg("svg", { - children: [ - svg("g"), - svg("text", { - textContent: "hello", - }), - ], - }); + return cm.svg`hello`; } export function setZeroChildren() { - return html("div", { - children: [0], - }); + return cm.html`
0
`; } export function setFalsyChildren() { - return svg("svg", [ - svg("g"), - null, - false, - undefined, - svg("text", { - textContent: "hello", - }), - ]); + return cm.svg`hello`; } export function setNonMarkChildren() { - return html("div", ["hello", html("span", ["world"]), {key: "foo"}]); + return cm.html`
helloworld{key: "foo"}
`; } export function setDataDrivenNonMarkChildren() { - return html("div", [ - svg("span", { - data: [1, 2, 3], - textContent: (d, i) => `${i}-${d}`, - }), - ]); + return cm.html`
+ `${i}-${d}`, + }} + /> +
`; } export function setDataDrivenAttributes() { - return svg("svg", { + return cm.svg` d * 20, - cy: 50, - r: 10, - }), - ], - }); + }}> + d * 20, + cy: 50, + r: 10, + }}/> + `; } export function setListChildren() { - return svg("svg", { + return cm.svg` svg("circle", {r: d})), - }); + }}> + ${[1, 2, 3].map((d) => cm.svg``)} + `; } export function setNestedListChildren() { - return svg("svg", { + return cm.svg` svg("circle", {r: d}))], - }); + }}> + ${[[1, 2, 3].map((d) => cm.svg``)]} + `; } export function setDataDrivenChildren() { - return svg("svg", { + return cm.svg` d * 20, - cy: 50, - r: 10, - }), - ], - }), - ], - }); + }}> + + d * 20, cy: 50, r: 10}}/> + + `; } export function setDataDrivenChildrenWithoutOptions() { - return svg("svg", { + return cm.svg` + + `; } export function setNestedChildren() { - return svg("svg", { + return cm.svg` d * 20, - cy: 50, - r: 10, - }), - ], - }), - ], - }), - ], - }); + }}> + + + d * 20, + cy: 50, + r: 10, + }}/> + + + `; } export function setNestedDataDrivenChildren() { - return svg("svg", { + return cm.svg` d * 5, - cy: 50, - r: 10, - }), - ], - }), - ], - }), - ], - }); + }}> + + + d * 5, + cy: 50, + r: 10, + }}/> + + + `; } export function setTable() { @@ -232,66 +196,53 @@ export function setTable() { [8010, 16145, 8090, 8045], [1013, 990, 940, 6907], ]; - return html("table", [ - html("tr", { - data: table, - children: [ - html("td", { - data: (d) => d, - textContent: (d) => d, - }), - ], - }), - ]); + return cm.html` + + + + +
d, textContent: (d) => d}}/> +
`; } export function setNestedCallbackDataDrivenChildren() { - return svg("svg", { + return cm.svg` Array.from({length: d}, (_, i) => i), - children: [svg("circle", {r: 10, cx: (d) => d * 5, cy: 50})], - }), - ], - }), - ], - }); + }}> + + Array.from({length: d}, (_, i) => i)}}> + d * 5, cy: 50}}/> + + + `; } export function cloneDataDrivenChildren() { - return svg("svg", { + return cm.svg` `translate(${d * 20}, 50)`, - children: [ - svg("circle", { - r: 10, - }), - ], - }), - ], - }); + }}> + `translate(${d * 20}, 50)`, + }}> + + + `; } export function fragmentRoot() { - return svg("circle", { + return cm.svg` d * 20, cy: 50, r: 10, - }); + }}/>`; } export function htmlAttributes() { - return html("div", { + return cm.html`
`; } export function setCallbackChildren() { - return svg("g", { + return cm.svg` `translate(${(d + 1) * 50}, 0)`, - children: [ - (d, i, data) => { - const a = d + i + data.length; - return svg("circle", { - r: 20, - cy: 30, - fill: `rgb(${a}, ${a}, ${a})`, - }); - }, - ], - }); + }}> + ${(d, i, data) => { + const a = d + i + data.length; + return cm.svg``; + }} + `; } export function setDataChildrenStringNodes() { - return svg("svg", [ - svg("g", { - data: [1, 2, 3], - children: ["hello"], - }), - ]); + return cm.svg` + + hello + + `; +} + +export function interpolateChildren() { + return cm.svg` + ${["hello", null, false, 1, {text: "world"}]} + `; +} + +export function interpolateNodeChildren() { + return cm.svg` + + ${cm.svg``} + + `; +} + +export function multipleDataDrivenAttributes() { + return cm.svg` d * 10, + cx: (d) => d * 20, + cy: 50, + }}/>`; }