From 7386a0a7b8b6f7fb3df764babb3ae0152734af3e Mon Sep 17 00:00:00 2001 From: pearmini Date: Sat, 14 Jun 2025 20:56:57 -0400 Subject: [PATCH] Add component api --- src/dom.js | 52 +++++++++--- src/index.js | 2 +- test/output/basicComponent.html | 11 +++ .../basicComponentWithMultipleNodes.html | 17 ++++ .../componentWidthDataDrivenChildren.html | 26 ++++++ test/output/componentWithChildren.html | 10 +++ test/output/dataDrivenComponent.html | 23 ++++++ test/output/nullComponent.html | 4 + test/output/nullsComponent.html | 4 + test/snapshots.js | 80 ++++++++++++++++++- 10 files changed, 216 insertions(+), 13 deletions(-) create mode 100644 test/output/basicComponent.html create mode 100644 test/output/basicComponentWithMultipleNodes.html create mode 100644 test/output/componentWidthDataDrivenChildren.html create mode 100644 test/output/componentWithChildren.html create mode 100644 test/output/dataDrivenComponent.html create mode 100644 test/output/nullComponent.html create mode 100644 test/output/nullsComponent.html diff --git a/src/dom.js b/src/dom.js index 94a9b72..75e3c09 100644 --- a/src/dom.js +++ b/src/dom.js @@ -6,6 +6,8 @@ const isFunc = (x) => typeof x === "function"; const isStr = (x) => typeof x === "string"; +const isNode = (x) => x instanceof Node; + const isObjectLiteral = (x) => Object.prototype.toString.call(x) === "[object Object]"; const isMark = (x) => x instanceof Mark; @@ -32,6 +34,10 @@ function snake2kebab(str) { return str.replace(/_/g, "-"); } +function markify(node) { + return new Mark(null, node); +} + // Ref: https://github.com/vanjs-org/van/blob/d09cfd1e1e3b5ea7cf8d0a9b5deacca4c0946fb4/src/van.js#L99 function set(dom, k, v) { k = snake2kebab(k); @@ -44,6 +50,32 @@ function set(dom, k, v) { setter(v); } +function createComponent(_, tag, attrs, d, i, array) { + const children = []; + const props = {}; + for (const [k, v] of Object.entries(attrs)) props[k] = isFunc(v) ? v(d, i, array) : v; + return { + append: (...nodes) => { + for (const node of nodes) children.push(markify(node)); + }, + render: () => { + const nodes = [tag({...props, children})].flat(); + const fragment = document.createDocumentFragment(); + for (const child of nodes) if (child) fragment.append(renderMark(child)); + return fragment; + }, + }; +} + +function createDOM(ns, tag, attrs, d, i, array) { + const dom = ns ? document.createElementNS(ns, tag) : document.createElement(tag); + for (const [k, v] of Object.entries(attrs)) { + const val = k.startsWith("on") ? (e) => v(e, d, i, array) : isFunc(v) ? v(d, i, array) : v; + set(dom, k, val); + } + return dom; +} + class Mark { constructor(ns, tag, data, options) { if (isObjectLiteral(data)) (options = data), (data = undefined); @@ -63,16 +95,12 @@ class Mark { function renderNodes(mark) { const {_ns: ns, _tag: tag, _data: data = [undefined], _options: options = {}} = mark; - if (!isStr(tag)) return null; + const isComponent = isFunc(tag); + if (isNode(tag)) return [tag]; + if (!isStr(tag) && !isComponent) return null; const {children = [], ...attrs} = options; - const nodes = data.map((d, i, array) => { - const dom = ns ? document.createElementNS(ns, tag) : document.createElement(tag); - for (const [k, v] of Object.entries(attrs)) { - const val = k.startsWith("on") ? (e) => v(e, d, i, array) : isFunc(v) ? v(d, i, array) : v; - set(dom, k, val); - } - return dom; - }); + const creator = (isComponent ? createComponent : createDOM).bind(null, ns, tag, attrs); + const nodes = data.map(creator); for (const child of children.filter(isTruthy).flat(Infinity)) { const n = nodes.length; if (!isMark(child)) { @@ -99,7 +127,7 @@ function renderNodes(mark) { for (let i = 0; i < n; i++) nodes[i].append(childNodes[i]); } } - return nodes; + return isComponent ? nodes.map((d) => d.render()) : nodes; } export const renderMark = (mark) => postprocess(renderNodes(mark)); @@ -108,6 +136,8 @@ export const render = (options) => renderMark(svg("svg", preprocess(options))); export const tag = (ns) => (tag, data, options) => new Mark(ns, tag, data, options); +export const mark = tag(null); + export const svg = tag("http://www.w3.org/2000/svg"); -export const html = tag(null); +export const html = mark; diff --git a/src/index.js b/src/index.js index 8f623a7..7ebebc5 100644 --- a/src/index.js +++ b/src/index.js @@ -1 +1 @@ -export {svg, html, tag, render, renderMark} from "./dom.js"; +export {svg, html, tag, render, renderMark, mark} from "./dom.js"; diff --git a/test/output/basicComponent.html b/test/output/basicComponent.html new file mode 100644 index 0000000..0148412 --- /dev/null +++ b/test/output/basicComponent.html @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/test/output/basicComponentWithMultipleNodes.html b/test/output/basicComponentWithMultipleNodes.html new file mode 100644 index 0000000..0508c59 --- /dev/null +++ b/test/output/basicComponentWithMultipleNodes.html @@ -0,0 +1,17 @@ + + + + \ No newline at end of file diff --git a/test/output/componentWidthDataDrivenChildren.html b/test/output/componentWidthDataDrivenChildren.html new file mode 100644 index 0000000..9dfe7d2 --- /dev/null +++ b/test/output/componentWidthDataDrivenChildren.html @@ -0,0 +1,26 @@ +
+
+

+ Hello 1 +

+

+ World 1 +

+
+
+

+ Hello 2 +

+

+ World 2 +

+
+
+

+ Hello 3 +

+

+ World 3 +

+
+
\ No newline at end of file diff --git a/test/output/componentWithChildren.html b/test/output/componentWithChildren.html new file mode 100644 index 0000000..b40f3e5 --- /dev/null +++ b/test/output/componentWithChildren.html @@ -0,0 +1,10 @@ +
+
+

+ Hello +

+

+ World +

+
+
\ No newline at end of file diff --git a/test/output/dataDrivenComponent.html b/test/output/dataDrivenComponent.html new file mode 100644 index 0000000..3d44679 --- /dev/null +++ b/test/output/dataDrivenComponent.html @@ -0,0 +1,23 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/nullComponent.html b/test/output/nullComponent.html new file mode 100644 index 0000000..7a9d301 --- /dev/null +++ b/test/output/nullComponent.html @@ -0,0 +1,4 @@ + \ No newline at end of file diff --git a/test/output/nullsComponent.html b/test/output/nullsComponent.html new file mode 100644 index 0000000..7a9d301 --- /dev/null +++ b/test/output/nullsComponent.html @@ -0,0 +1,4 @@ + \ No newline at end of file diff --git a/test/snapshots.js b/test/snapshots.js index 7fcee50..307d78f 100644 --- a/test/snapshots.js +++ b/test/snapshots.js @@ -1,4 +1,4 @@ -import {html, tag, svg, render, renderMark} from "../src/index.js"; +import {html, tag, svg, mark, render, renderMark} from "../src/index.js"; export function strictNull() { return renderMark(svg()); @@ -302,3 +302,81 @@ export function mathXL() { ]), ); } + +export function basicComponent() { + const redCircle = (props) => svg("g").with([svg("circle", {fill: "red", ...props})]); + return render({ + width: 100, + height: 100, + marks: [mark(redCircle, {r: 10})], + }); +} + +export function nullComponent() { + const nullComponent = () => null; + return render({ + width: 100, + height: 100, + marks: [mark(nullComponent, {r: 10})], + }); +} + +export function nullsComponent() { + const nullComponent = () => [null, null, null]; + return render({ + width: 100, + height: 100, + marks: [mark(nullComponent, {r: 10})], + }); +} + +export function basicComponentWithMultipleNodes() { + const ring = ({cx, cy}) => [ + svg("circle", {cx, cy, fill: "red", r: 20}), + svg("circle", {cx, cy, fill: "blue", r: 10}), + ]; + return render({ + width: 100, + height: 100, + marks: [mark(ring, {cx: 50, cy: 50})], + }); +} + +export function dataDrivenComponent() { + const redCircle = (props) => svg("g").with([svg("circle", {fill: "red", ...props})]); + return render({ + width: 100, + height: 100, + marks: [mark(redCircle, [1, 2, 3], {r: (d) => d * 10})], + }); +} + +export function componentWithChildren() { + const withTitle = ({title, children}) => html("div").with([html("h1", {textContent: title}), ...children]); + return renderMark( + html("div").with([ + mark(withTitle, { + title: "Hello", + }).with([ + html("p", { + textContent: "World", + }), + ]), + ]), + ); +} + +export function componentWidthDataDrivenChildren() { + const withTitle = ({title, children}) => html("div").with([html("h1", {textContent: title}), ...children]); + return renderMark( + html("div").with([ + mark(withTitle, [1, 2, 3], { + title: (d) => `Hello ${d}`, + }).with([ + html("p", { + textContent: (d) => `World ${d}`, + }), + ]), + ]), + ); +}