From f8f1d9bfa5d761415545551906b90261943c7ab2 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Sun, 2 Aug 2026 00:21:38 -0500 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20host:=20add=20framework-neutral=20m?= =?UTF-8?q?utation=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/basic/src/index.ts | 24 ++-- packages/ui/package.json | 6 +- packages/ui/src/elements.ts | 36 ++++++ packages/ui/src/host.test.ts | 221 +++++++++++++++++++++++++++++++++++ packages/ui/src/host.ts | 136 +++++++++++++++++++++ packages/ui/tsconfig.json | 3 + 6 files changed, 413 insertions(+), 13 deletions(-) create mode 100644 packages/ui/src/elements.ts create mode 100644 packages/ui/src/host.test.ts create mode 100644 packages/ui/src/host.ts diff --git a/examples/basic/src/index.ts b/examples/basic/src/index.ts index 1d5d075..3a43d04 100644 --- a/examples/basic/src/index.ts +++ b/examples/basic/src/index.ts @@ -9,22 +9,22 @@ const blue = rgba(0, 0, 238); // Framework-free: build a tree with `box` / `text`, render it once. const root = await createRoot(); -root.render( +const app = box( + { + layout: { direction: 'ttb', gap: 1, padding: { top: 1, bottom: 1, left: 2, right: 2 } }, + border: { color: blue, top: 1, right: 1, bottom: 1, left: 1 }, + }, + text({ color: cyan }, '@clack/ui'), box( - { - layout: { direction: 'ttb', gap: 1, padding: { top: 1, bottom: 1, left: 2, right: 2 } }, - border: { color: blue, top: 1, right: 1, bottom: 1, left: 1 }, - }, - text({ color: cyan }, '@clack/ui'), - box( - { layout: { direction: 'ttb' } }, - row('status ', text({ color: green }, '● ready')), - row('version', text({ color: white }, '0.0.0')), - row('render ', text({ color: white }, '@bomb.sh/tty')), - ), + { layout: { direction: 'ttb' } }, + row('status ', text({ color: green }, '● ready')), + row('version', text({ color: white }, '0.0.0')), + row('render ', text({ color: white }, '@bomb.sh/tty')), ), ); +root.render(app); + function row(label: string, value: ReturnType) { return box({ layout: { direction: 'ltr', gap: 1 } }, text({ color: gray }, label), value); } diff --git a/packages/ui/package.json b/packages/ui/package.json index 4df8256..a58976e 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -19,9 +19,13 @@ "type": "module", "exports": { ".": { - "types": "./dist/index.d.ts", + "types": "./dist/index.d.mts", "import": "./dist/index.mjs" }, + "./elements": { + "types": "./dist/elements.d.mts", + "import": "./dist/elements.mjs" + }, "./package.json": "./package.json" }, "publishConfig": { diff --git a/packages/ui/src/elements.ts b/packages/ui/src/elements.ts new file mode 100644 index 0000000..94228b4 --- /dev/null +++ b/packages/ui/src/elements.ts @@ -0,0 +1,36 @@ +import { createContext, type Node } from './core.ts'; + +/** + * Type container for elements and their properties + */ +export interface HostElements {} + +export interface HostElement { + parent: HostElement | null; + readonly type: 'element'; + readonly name: string; + readonly properties: Record; + readonly children: HostElementChild[]; + node: Node | null; + attach?: (node: Node) => void; + detach?: (node: Node) => void; +} + +export interface HostLiteral { + parent?: HostElement; + type: 'literal'; + content: string; +} + +export type HostElementChild = HostElement | HostLiteral; + +export function getElement(node: Node): HostElement { + return ElementContext.expect(node); +} + +export function setElement(node: Node, element: HostElement): void { + element.node = node; + ElementContext.set(node, element); +} + +const ElementContext = createContext('element'); diff --git a/packages/ui/src/host.test.ts b/packages/ui/src/host.test.ts new file mode 100644 index 0000000..6bf2cdf --- /dev/null +++ b/packages/ui/src/host.test.ts @@ -0,0 +1,221 @@ +import { createContext } from './core.ts'; +import { describe, expect, test, vi } from 'vitest'; +import { createHost, HostApi } from './host.ts'; +import { getElement } from './elements.ts'; + +describe('host', () => { + test('a prebuilt subtree inherits host context when attached', () => { + const host = createHost(); + const marker = createContext('@test/host/attachment'); + marker.set(host.element.node!, 'from host'); + + const box = host.createElement('box'); + const text = host.createElement('text'); + const literal = host.createLiteral('hello'); + + host.insertBefore(text, literal); + host.insertBefore(box, text); + + expect(text.node).toBeNull(); + + host.insertBefore(host.element, box); + + expect(marker.expect(text.node!)).toBe('from host'); + expect(HostApi.at(text.node!).getHost()).toBe(host); + }); + + test('each attached node resolves its own element', () => { + const host = createHost(); + const parent = host.createElement('box'); + const child = host.createElement('text'); + + host.insertBefore(parent, child); + host.insertBefore(host.element, parent); + + expect(getElement(host.root)).toBe(host.element); + expect(getElement(parent.node!)).toBe(parent); + expect(getElement(child.node!)).toBe(child); + }); + + test('detaching a child does not detach its parent', () => { + const host = createHost(); + const parent = host.createElement('box'); + const child = host.createElement('text'); + const parentDetach = vi.fn(); + const childDetach = vi.fn(); + parent.detach = parentDetach; + child.detach = childDetach; + + host.insertBefore(parent, child); + host.insertBefore(host.element, parent); + const parentNode = parent.node; + const childNode = child.node; + + host.removeChild(parent, child); + + expect(parent.node).toBe(parentNode); + expect(parentDetach).not.toHaveBeenCalled(); + expect(child.node).toBeNull(); + expect(childDetach).toHaveBeenCalledOnce(); + expect(childDetach).toHaveBeenCalledWith(childNode); + }); + + test('moving an attached element to a detached parent detaches it', () => { + const host = createHost(); + const attached = host.createElement('box'); + const detached = host.createElement('box'); + const child = host.createElement('text'); + + host.insertBefore(host.element, attached); + host.insertBefore(attached, child); + + expect(child.node).not.toBeNull(); + expect(detached.node).toBeNull(); + + host.insertBefore(detached, child); + + expect(attached.children).toEqual([]); + expect(detached.children).toEqual([child]); + expect(child.node).toBeNull(); + }); + + test('moving an element within the same parent changes its position', () => { + const host = createHost(); + const parent = host.createElement('box'); + const first = host.createElement('text'); + const second = host.createElement('text'); + const third = host.createElement('text'); + + host.insertBefore(host.element, parent); + host.insertBefore(parent, third); + host.insertBefore(parent, second, third); + host.insertBefore(parent, first, second); + + const node = third.node; + host.insertBefore(parent, third, first); + + expect(parent.children).toEqual([third, first, second]); + expect(third.parent).toBe(parent); + expect(third.node).toBe(node); + }); + + test('moving an attached element to another attached parent reattaches it', () => { + const host = createHost(); + const marker = createContext('@test/host/move'); + const first = host.createElement('box'); + const second = host.createElement('box'); + const child = host.createElement('text'); + + host.insertBefore(host.element, first); + host.insertBefore(host.element, second); + marker.set(first.node!, 'first'); + marker.set(second.node!, 'second'); + host.insertBefore(first, child); + + const firstNode = child.node; + expect(marker.expect(firstNode!)).toBe('first'); + + host.insertBefore(second, child); + + expect(first.children).toEqual([]); + expect(second.children).toEqual([child]); + expect(child.node).not.toBe(firstNode); + expect(marker.expect(child.node!)).toBe('second'); + }); + + test('setProperty adds and replaces a property', () => { + const host = createHost(); + const element = host.createElement('box'); + + host.setProperty(element, 'color', 'cyan'); + host.setProperty(element, 'gap', 1); + expect(element.properties).toEqual({ color: 'cyan', gap: 1 }); + + host.setProperty(element, 'color', 'green'); + expect(element.properties).toEqual({ color: 'green', gap: 1 }); + }); + + test('setProperty removes a property when its value is undefined', () => { + const host = createHost(); + const element = host.createElement('box'); + + host.setProperty(element, 'color', 'cyan'); + host.setProperty(element, 'gap', 1); + host.setProperty(element, 'color', undefined); + + expect(element.properties).toEqual({ gap: 1 }); + expect(element.properties).not.toHaveProperty('color'); + }); + + test('setProperty preserves null as an explicit value', () => { + const host = createHost(); + const element = host.createElement('box'); + + host.setProperty(element, 'value', null); + + expect(element.properties).toHaveProperty('value', null); + }); + + test('setText updates a text literal without replacing it', () => { + const host = createHost(); + const parent = host.createElement('text'); + const literal = host.createLiteral('hello'); + + host.insertBefore(parent, literal); + const instance = parent.children[0]; + + host.setText(literal, 'world'); + + expect(literal.content).toBe('world'); + expect(parent.children).toEqual([literal]); + expect(parent.children[0]).toBe(instance); + expect(literal.parent).toBe(parent); + }); + + test('setText preserves an empty text literal', () => { + const host = createHost(); + const parent = host.createElement('text'); + const literal = host.createLiteral('hello'); + + host.insertBefore(parent, literal); + host.setText(literal, ''); + + expect(literal.content).toBe(''); + expect(parent.children).toEqual([literal]); + }); + + test('removeChild removes a text literal', () => { + const host = createHost(); + const parent = host.createElement('text'); + const literal = host.createLiteral('hello'); + + host.insertBefore(parent, literal); + host.removeChild(parent, literal); + + expect(parent.children).toEqual([]); + expect(literal.parent).toBeNull(); + }); + + test('removeChild detaches a subtree', () => { + const host = createHost(); + const parent = host.createElement('box'); + const child = host.createElement('box'); + const grandchild = host.createElement('text'); + + host.insertBefore(child, grandchild); + host.insertBefore(parent, child); + host.insertBefore(host.element, parent); + + expect(child.node).not.toBeNull(); + expect(grandchild.node).not.toBeNull(); + + host.removeChild(parent, child); + + expect(parent.children).toEqual([]); + expect(child.parent).toBeNull(); + expect(child.children).toEqual([grandchild]); + expect(grandchild.parent).toBe(child); + expect(child.node).toBeNull(); + expect(grandchild.node).toBeNull(); + }); +}); diff --git a/packages/ui/src/host.ts b/packages/ui/src/host.ts new file mode 100644 index 0000000..cc75ea8 --- /dev/null +++ b/packages/ui/src/host.ts @@ -0,0 +1,136 @@ +// oxlint-disable max-params +import { type Node, lifecycle, LifecycleApi } from './core.ts'; +import { Api } from './core/api.ts'; +import { + getElement, + setElement, + type HostElementChild, + type HostElement, + type HostLiteral, +} from './elements.ts'; + +export interface Host { + root: Node; + element: HostElement; + createElement(type: string): HostElement; + createLiteral(content: string): HostLiteral; + insertBefore(parent: HostElement, child: HostElementChild, anchor?: HostElementChild): void; + removeChild(parent: HostElement, child: HostElementChild): void; + setProperty(element: HostElement, name: string, value: unknown): void; + setText(text: HostLiteral, content: string): void; +} + +export class HostApi extends Api implements Host { + root: Node; + element: HostElement; + + constructor(root: Node) { + super(root); + this.root = root; + this.element = { + type: 'element', + name: 'root', + node: root, + parent: null, + properties: {}, + children: [], + }; + + setElement(root, this.element); + + LifecycleApi.decorate(root, () => ({ + destroy(node) { + const element = getElement(node); + if (element.detach) { + element.detach(node); + } + element.node = null; + return super.destroy(node); + }, + })); + } + + createElement(name: string): HostElement { + return { + type: 'element', + name, + node: null, + parent: null, + properties: {}, + children: [], + }; + } + + createLiteral(content: string): HostLiteral { + return { type: 'literal', content }; + } + + insertBefore(parent: HostElement, child: HostElementChild, anchor?: HostElementChild): void { + if (child.parent !== parent) { + if (child.type === 'element') { + detach(child); + + if (parent.node) { + attach(parent, child); + } + } + } + if (child.parent) { + const currentIndex = child.parent.children.indexOf(child); + child.parent.children.splice(currentIndex, 1); + } + const index = anchor ? parent.children.indexOf(anchor) : parent.children.length; + parent.children.splice(index, 0, child); + child.parent = parent; + } + + removeChild(parent: HostElement, child: HostElementChild): void { + if (child.parent === parent) { + child.parent = null; + parent.children.splice(parent.children.indexOf(child), 1); + if (child.type === 'element') { + detach(child); + } + } + } + + setProperty(element: HostElement, name: string, value: unknown): void { + if (typeof value === 'undefined') { + delete element.properties[name]; + } else { + element.properties[name] = value; + } + } + + setText(text: HostLiteral, content: string): void { + text.content = content; + } + + getHost(): Host { + return HostApi.at(this.root); + } +} + +export function createHost(): Host { + return HostApi.init(lifecycle.create()); +} + +function attach(parent: HostElement, child: HostElement): void { + child.node = lifecycle.create(parent.node!); + setElement(child.node, child); + + if (child.attach) { + child.attach(child.node); + } + for (const grandchild of child.children) { + if (grandchild.type === 'element') { + attach(child, grandchild); + } + } +} + +function detach(child: HostElement): void { + if (child.node) { + LifecycleApi.at(child.node).destroy(child.node); + } +} diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index a055ad0..39b2a63 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -1,6 +1,9 @@ { "extends": "@bomb.sh/tools/tsconfig.json", "compilerOptions": { + "paths": { + "@clack/ui/elements": ["./src/elements.ts"] + }, "types": ["node"] } }