Skip to content
Open
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
12 changes: 12 additions & 0 deletions .changeset/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"privatePackages": false,
"ignore": []
}
5 changes: 5 additions & 0 deletions .changeset/dom-mutations-and-events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clack/ui': minor
---

Add DOM tree navigation and mutation APIs, text and attribute updates, and event dispatch with capture, target, and bubbling phases. Support cancellation, propagation control, listener updates during dispatch, and nested events for framework integrations.
6 changes: 6 additions & 0 deletions .changeset/preact-integration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clack/ui': minor
'@clack/ui-preact': minor
---

Add Preact 11 bindings for mounting into Clack elements, with Clack-specific JSX types and disposable roots. Export the DOM classes and UI types needed by framework integrations from `@clack/ui`.
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Unified diffs deliberately contain space-prefixed context lines and blank lines.
*.patch -whitespace
1 change: 1 addition & 0 deletions .github/workflows/preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ jobs:
secrets: inherit
with:
publish: './packages/*'
template: './examples/*'
16 changes: 16 additions & 0 deletions examples/hello-world-preact/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Hello Preact

A Preact component mounted on a nested Clack element, without styles or a global
`document`. It keeps `Hello ` on screen while erasing and typing `World!`, `DOM!`,
`Preact!`, and `Clack!` in a repeating loop.

From the workspace root:

```sh
pnpm install
pnpm build
pnpm --filter @examples/hello-world-preact start
```

Press Ctrl+C to exit. Unmounting cancels the animation timer, and disposing the UI
restores the terminal buffer and cursor.
20 changes: 20 additions & 0 deletions examples/hello-world-preact/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "@examples/hello-world-preact",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node --import tsx src/main.tsx",
"dev": "tsx watch src/main.tsx",
"lint": "bsh lint"
},
"dependencies": {
"@clack/ui": "workspace:*",
"@clack/ui-preact": "workspace:*",
"preact": "11.0.1"
},
"devDependencies": {
"@types/node": "^22.20.0",
"tsx": "^4.19.0"
}
}
34 changes: 34 additions & 0 deletions examples/hello-world-preact/src/animation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
export interface GreetingFrame {
text: string;
delay: number;
}

const prefix = 'Hello ';
const greetings = ['World!', 'DOM!', 'Preact!', 'Clack!'];
const pause = 900;
const erase = 60;
const type = 100;

/** Each frame says what to show and how long to leave it on screen. */
export function* greetingFrames(): Generator<GreetingFrame, never, void> {
let index = 0;
const suffix = [...greetings[index]!];
yield { text: prefix + suffix.join(''), delay: pause };

while (true) {
while (suffix.length > 0) {
suffix.pop();
yield { text: prefix + suffix.join(''), delay: erase };
}

index = (index + 1) % greetings.length;
const next = [...greetings[index]!];
for (const character of next) {
suffix.push(character);
yield {
text: prefix + suffix.join(''),
delay: suffix.length === next.length ? pause : type,
};
}
}
}
22 changes: 22 additions & 0 deletions examples/hello-world-preact/src/greeting.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { useEffect, useState } from 'preact/hooks';
import { greetingFrames } from './animation.ts';

export function Greeting() {
const [text, setText] = useState('Hello World!');

useEffect(() => {
const animation = greetingFrames();
let timer: ReturnType<typeof setTimeout>;

function advance() {
const frame = animation.next().value;
setText(frame.text);
timer = setTimeout(advance, frame.delay);
}

advance();
return () => clearTimeout(timer);
}, []);

return <span>{text}</span>;
}
28 changes: 28 additions & 0 deletions examples/hello-world-preact/src/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { createUI } from '@clack/ui';
import { createRoot } from '@clack/ui-preact';
import { Greeting } from './greeting.tsx';

const shutdown = Promise.withResolvers<void>();
const stop = () => shutdown.resolve();
process.once('SIGINT', stop);
process.once('SIGTERM', stop);

try {
await using ui = await createUI({
input: process.stdin,
output: process.stdout,
width: process.stdout.columns ?? 80,
height: process.stdout.rows ?? 24,
});

// A root can be mounted on any Clack element, not just the document root.
const mount = ui.document.createElement('div');
ui.document.documentElement.appendChild(mount);
using root = createRoot(mount);
root.render(<Greeting />);

await Promise.race([ui.main(), shutdown.promise]);
} finally {
process.off('SIGINT', stop);
process.off('SIGTERM', stop);
}
9 changes: 9 additions & 0 deletions examples/hello-world-preact/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"types": ["node"],
"jsx": "react-jsx",
"jsxImportSource": "@clack/ui-preact"
},
"include": ["src"]
}
9 changes: 5 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,17 @@
"build": "pnpm --filter \"@clack/*\" run build",
"format": "bsh format",
"format:check": "bsh format --check",
"lint": "bsh lint .",
"test": "bsh test"
"lint": "bsh lint && pnpm -r lint",
"test": "vitest run"
},
"devDependencies": {
"@bomb.sh/tools": "^0.6.1",
"@bomb.sh/tools": "^0.8.0",
"@changesets/cli": "^2.30.0",
"@termless/core": "^0.10.2",
"@termless/test": "^0.10.2",
"@types/node": "^22.20.0",
"node-pty": "1.2.0-beta.15",
"vitest": "^4.1.9"
"vitest": "^5.0.5"
},
"devEngines": {
"packageManager": {
Expand Down
39 changes: 39 additions & 0 deletions packages/ui-preact/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# `@clack/ui-preact`

Mount Preact 11 into any Clack element. No browser DOM, global `document`, or DOM adapter is needed.

```tsx
import { createUI } from '@clack/ui';
import { createRoot } from '@clack/ui-preact';

await using ui = await createUI({ input: process.stdin, output: process.stdout });
const container = ui.document.createElement('div');
ui.document.documentElement.appendChild(container);

using root = createRoot(container);
root.render(<span>Hello World!</span>);

// Run until your application exits its scope and disposes the UI.
await ui.main();
```

Configure TypeScript with `"jsx": "react-jsx"` and `"jsxImportSource": "@clack/ui-preact"`.
Use components and hooks from `preact` and `preact/hooks` as usual. JSX refs refer to Clack
elements, and event handlers receive Clack events, not browser events.

`root.render(node)` updates the element's children. `root.unmount()` removes that tree and
allows a later render. Disposing the root with `using` calls `unmount()`. Preact owns effect
cleanup; passive effect cleanup follows Preact's asynchronous scheduling.

Roots may be nested in an existing DOM tree or attached after rendering while detached.
Keep one root per container. Its contents belong to Preact; siblings outside the container
are unaffected. Separate roots can use separate Clack documents. Preact 11's `createPortal`
also accepts Clack elements directly.

This first integration intentionally has no style support. Tags do not imply HTML semantics,
and `style`, HTML parsing, browser APIs, hydration, and `preact/compat` are not part of this
surface. Preact's reserved `svg`, `math`, and `template` tags are also unsupported.
Native keyboard and mouse input will be added separately; dispatched Clack events
already work with Preact handlers.

See [the animated greeting example](../../examples/hello-world-preact/README.md).
75 changes: 75 additions & 0 deletions packages/ui-preact/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
{
"name": "@clack/ui-preact",
"version": "0.0.0",
"description": "Preact bindings for Clack's terminal DOM",
"keywords": [
"bombshell",
"cli",
"preact"
],
"homepage": "https://bomb.sh/docs/ui",
"license": "MIT",
"author": {
"name": "Bombshell",
"email": "oss@bomb.sh",
"url": "https://bomb.sh"
},
"repository": {
"type": "git",
"url": "git+https://github.com/bombshell-dev/ui.git"
},
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs"
},
"./jsx-runtime": {
"types": "./dist/jsx-runtime.d.mts",
"import": "./dist/jsx-runtime.mjs"
},
"./jsx-dev-runtime": {
"types": "./dist/jsx-dev-runtime.d.mts",
"import": "./dist/jsx-dev-runtime.mjs"
},
"./package.json": "./package.json"
},
"publishConfig": {
"access": "public"
},
"scripts": {
"dev": "bsh dev",
"build": "bsh build --dts",
"format": "bsh format",
"lint": "bsh lint",
"test": "bsh test"
},
"dependencies": {
"@clack/ui": "workspace:*"
},
"devDependencies": {
"preact": "11.0.1"
},
"peerDependencies": {
"preact": "^11.0.1"
},
"devEngines": {
"packageManager": {
"name": "pnpm",
"version": "^10.7.0",
"onFail": "error"
},
"runtime": {
"name": "node",
"version": "^22.14.0",
"onFail": "error"
}
},
"knip": {
"entry": [
"src/index.ts",
"src/jsx-runtime.ts",
"src/jsx-dev-runtime.ts"
]
}
}
15 changes: 15 additions & 0 deletions packages/ui-preact/src/elements.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { Element, Event } from '@clack/ui';
import type { Ref } from 'preact';
import { expectTypeOf, it } from 'vitest';
import type { JSX } from './jsx-runtime.ts';

it('types JSX against Clack without advertising browser-only APIs', () => {
type Props = JSX.IntrinsicElements['span'];
expectTypeOf<Props['ref']>().toEqualTypeOf<Ref<Element> | undefined>();
expectTypeOf<NonNullable<Props['onKeyDown']>>().parameter(0).toEqualTypeOf<Event>();
expectTypeOf<Props['style']>().toEqualTypeOf<undefined>();
expectTypeOf<Props['dangerouslySetInnerHTML']>().toEqualTypeOf<undefined>();
expectTypeOf<JSX.IntrinsicElements['svg']>().toBeNever();
expectTypeOf<JSX.IntrinsicElements['math']>().toBeNever();
expectTypeOf<JSX.IntrinsicElements['template']>().toBeNever();
});
30 changes: 30 additions & 0 deletions packages/ui-preact/src/elements.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { Element, Event } from '@clack/ui';
import type { ComponentChildren, Key, Ref } from 'preact';

export type EventHandler = (event: Event) => void;
export type AttributeValue = string | number | boolean | null | undefined;

/** Attributes supported by the unstyled Clack DOM, not browser HTML elements. */
export interface ElementProps {
children?: ComponentChildren;
key?: Key;
ref?: Ref<Element>;
id?: string;
class?: string;
title?: string;
[attribute: `data-${string}`]: AttributeValue;
[attribute: `aria-${string}`]: AttributeValue;
[event: `on${string}`]: EventHandler | undefined;
// These need APIs which are deliberately outside this initial integration.
style?: never;
dangerouslySetInnerHTML?: never;
}

/** Ordinary tags are generic Clack elements, without browser behavior or styling. */
export interface HostElements {
[tagName: string]: ElementProps;
// Preact reserves these names for browser APIs that Clack does not implement.
svg: never;
math: never;
template: never;
}
2 changes: 2 additions & 0 deletions packages/ui-preact/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { createRoot, type Root } from './root.ts';
export type { AttributeValue, ElementProps, EventHandler, HostElements } from './elements.ts';
2 changes: 2 additions & 0 deletions packages/ui-preact/src/jsx-dev-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { Fragment, jsxDEV } from 'preact/jsx-dev-runtime';
export type { JSX } from './jsx-runtime.ts';
18 changes: 18 additions & 0 deletions packages/ui-preact/src/jsx-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { JSX as PreactJSX } from 'preact/jsx-runtime';
import type { HostElements } from './elements.ts';

export { Fragment, jsx, jsxs } from 'preact/jsx-runtime';

export declare namespace JSX {
type Element = PreactJSX.Element;
type ElementType = string | Exclude<PreactJSX.ElementType, string>;
type ElementClass = PreactJSX.ElementClass;
type ElementAttributesProperty = PreactJSX.ElementAttributesProperty;
type ElementChildrenAttribute = PreactJSX.ElementChildrenAttribute;
type IntrinsicAttributes = PreactJSX.IntrinsicAttributes;
type LibraryManagedAttributes<Component, Props> = PreactJSX.LibraryManagedAttributes<
Component,
Props
>;
interface IntrinsicElements extends HostElements {}
}
Loading
Loading