Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 12 additions & 12 deletions examples/basic/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof text>) {
return box({ layout: { direction: 'ltr', gap: 1 } }, text({ color: gray }, label), value);
}
6 changes: 5 additions & 1 deletion packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
36 changes: 36 additions & 0 deletions packages/ui/src/elements.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<HostElement>('element');
221 changes: 221 additions & 0 deletions packages/ui/src/host.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>('@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<string>('@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();
});
});
Loading
Loading