# Domphy — Full LLM Context > One-shot dump for code generation. Contains: critical rules, quickstart, core runtime docs, theme docs, and every `@domphy/ui` patch source. Prefer the curated `llms.txt` index for targeted lookups. --- ## Critical rules - Build UIs as plain objects keyed by HTML tag. Apply patches via `$`. Never wrap in components. - Never inline typography styles. Use typography patches: `small()`, `paragraph()`, `heading()`, `link()`, `strong()`, `emphasis()`, `code()`, `keyboard()`. - Forms use `@domphy/form` (`createForm` from `@domphy/form/domphy`): bind inputs with `value: (l) => field.value(l)` + `onInput: (e) => field.handleChange(e.target.value)`. The old ui `form()`/`field()` patches and `FormState`/`FieldState` were removed; only `formGroup()` (layout) remains in `@domphy/ui`. - Reactive content uses `(listener) => state.get(listener)`. Controlled inputs (value bound to a state you also `.set()` in `onInput`) are safe. - Data/logic = 1-1 TanStack core ports + a Domphy adapter at the `/domphy` subpath (`@domphy/core` peer dep): query, table, router, virtual, form. Drag & drop: `@domphy/dnd`. Animation: the `motion()` patch. App framework (Next-style): `@domphy/app` (incl. lazy code-split routes). Markdown→Domphy: `@domphy/markdown`. Mermaid: `@domphy/mermaid`. - Derived reactivity: `computed`/`effect`/`effectScope`/`batch`/`untrack` in `@domphy/core`; `flushSync()` drains reactivity synchronously (tests/imperative). Self-check with `@domphy/doctor` `diagnose`/`validate`/`fix` (rules incl. raw-theme-value, unknown-tone). - Build tool: tsup. Docs: DomphyPress (built on `@domphy/app` + `@domphy/markdown`). --- ## Landing # Domphy **The AI-friendly UI framework. Patch-based, native elements, no components.** Framework-agnostic, no JSX, no virtual DOM, no build step required — and the most **AI-friendly** UI framework (learnable from one spec file, self-correcting via [`@domphy/doctor`](/docs/doctor/)). **Runtime + design system** — tiny, tree-shakeable: - `@domphy/core` — runtime: rendering, reactivity, lifecycle, SSR, CSS-in-JS (≈ `react-dom` + SSR + CSS-in-JS in one) - `@domphy/theme` — context-aware color/size/spacing tokens - `@domphy/ui` — 86 patches for native HTML (≈ MUI) **Data & logic** — 1-1 ports of the TanStack cores (identical API) + a Domphy adapter at the `/domphy` subpath: - `@domphy/query` — async state (TanStack Query core) - `@domphy/table` — headless tables (TanStack Table core) - `@domphy/router` — type-safe routing (TanStack Router core) - `@domphy/virtual` — virtualization (TanStack Virtual core) - `@domphy/form` — forms (TanStack Form core) **App layer & tools:** - `@domphy/dnd` — drag & drop / sortable lists - `@domphy/palette` — color-palette engine (generate accessible ramps + measure quality); design-time companion to theme - `@domphy/app` — Next.js App Router-style framework (routes, layouts, loaders+SWR, metadata, middleware, parallel/intercepting routes, lazy code-split routes, SSR + streaming, API routes) - `@domphy/markdown` — Markdown → Domphy element trees for SSR/SSG (this docs site runs on it) - `@domphy/mermaid` — render Mermaid diagrams (build-time SVG + client patch) - `@domphy/doctor` — static analyzer that flags non-idiomatic code (`diagnose`/`validate`; powers AI self-correction) - `@domphy/mcp` — MCP server exposing patches/packages/rules + doctor + app-block registry to agents Domphy removes component boundaries, unifies SSR and CSR under one model, automates context-aware styling, and works with any JavaScript library without adapters or plugins. For anything outside these packages (charts, rich text, i18n…), use the vanilla library directly — see [Integrations](/docs/integrations/). ## Why Domphy From the author: > I published Domphy in February 2026, at 41 years old. I spent 10 years as a structural architect and 6 years teaching myself to code (2 years with js/ts). Every time I tried to learn React or Vue, something felt wrong: logic scattered between data and UI, too many abstractions, too many plugins just to ship a feature. So I built what I wished existed. > I introduce Patch-based UI Architecture, a paradigm for composing web interfaces distinct from component-based, directive-based, and mixin-based approaches. A Patch is formally defined as a function returning a PartialElement: a composable, stateless descriptor that augments a host element's behavior without wrapping, replacing, or owning it. Unlike existing composition models, a Patch carries no rendering lifecycle, holds no state, and creates no DOM boundary. ## Installation npm install @domphy/ui ``` ```html [CDN] ``` ## Quick Start // source: /docs/demos/core/counting.ts --- ## Quickstart # 5-Minute Quickstart ## Install npm create domphy@latest my-app cd my-app npm install npm run dev ``` ```bash [Add to existing project] npm install @domphy/ui @domphy/core @domphy/theme ``` ```html [CDN] ``` `npm create domphy@latest` scaffolds a Vite + TypeScript starter with everything wired up. For an existing project, `npm install @domphy/ui @domphy/core @domphy/theme` is enough — `@domphy/ui` depends on both as peer packages. ## 1. Hello World A Domphy element is a plain object. The key is the HTML tag, the value is the content. // source: /docs/demos/quickstart/01-hello.ts No classes, no components, no JSX. Just objects. ## 2. Add Patches A **patch** is a function that adds styling and behavior to an element. Apply it with the `$` property. // source: /docs/demos/quickstart/02-patches.ts Every patch handles its own sizing, spacing, colors, and accessibility. You write the structure — patches do the rest. ## 3. Reactive State Use `toState()` for reactive values. Read with `state.get(listener)` inside a reactive function to auto-subscribe. // source: /docs/demos/quickstart/03-state.ts No virtual DOM, no diffing. Changing state updates only the properties that read it. ## 4. Forms Form state, validation, and submission live in [`@domphy/form`](/docs/form/) (`createForm`) — a 1-1 port of `@tanstack/form-core`. `@domphy/ui` provides the presentation: native inputs with the input patches, `label`, and `formGroup` for layout. Bind each field with `value: (l) => field.value(l)` and forward events to `field.handleChange(...)`. // source: /docs/demos/quickstart/04-form.ts ## What's Next - [Core concepts](/docs/core/) — Syntax, reactivity, lifecycle - [Theme](/docs/theme/) — Tone, size, density - [All 86 patches](/docs/ui/) — Buttons, inputs, cards, dialogs, and more - [Research](/docs/research/) — The two papers behind the design system --- ## Core docs ### api/attribute-list.md # AttributeList Manages HTML attributes on an `ElementNode`. Accessed via `node.attributes`. Attributes set via element definition are managed automatically. Use `AttributeList` directly when you need to read or mutate attributes imperatively inside lifecycle hooks. ## Methods ### `get(name)` Returns the current value of an attribute. ```ts node.attributes.get("class") // "btn active" node.attributes.get("disabled") // true | undefined ``` --- ### `set(name, value)` Sets or updates an attribute value. ```ts node.attributes.set("aria-expanded", "true") node.attributes.set("tabindex", 0) node.attributes.set("disabled", true) ``` --- ### `has(name)` Returns `true` if the attribute exists. ```ts node.attributes.has("disabled") // boolean ``` --- ### `remove(name)` Removes an attribute from the element. ```ts node.attributes.remove("disabled") ``` --- ### `toggle(name, force?)` Toggles a boolean attribute (`disabled`, `hidden`, `checked`, etc.). ```ts node.attributes.toggle("disabled") // flip node.attributes.toggle("disabled", true) // force on node.attributes.toggle("disabled", false) // force off ``` --- ### `addListener(name, callback)` Subscribes to changes on a specific attribute after the node is mounted and the attribute already exists. The listener auto-releases when the node is removed. ```ts node.attributes.addListener("aria-expanded", (value) => { console.log("expanded:", value) }) ``` If you need to observe a value immediately, set the attribute first, then subscribe inside `_onMount` or another mounted lifecycle hook. --- ### `addClass(className)` Adds a class to the element's class list. ```ts node.attributes.addClass("active") ``` --- ### `removeClass(className)` Removes a class from the element's class list. ```ts node.attributes.removeClass("active") ``` --- ### `hasClass(className)` Returns `true` if the class exists in the element's class list. ```ts node.attributes.hasClass("active") // boolean ``` --- ### `toggleClass(className)` Toggles a class in the element's class list. ```ts node.attributes.toggleClass("active") ``` --- ### `replaceClass(oldClass, newClass)` Replaces an existing class with a new one. ```ts node.attributes.replaceClass("old", "new") ``` ### api/element-list.md # ElementList Manages the ordered list of child nodes under an `ElementNode`. Accessed via `node.children`. ```ts node.children // ElementList node.children.items // NodeItem[] ``` ## Properties | Property | Type | Description | |---|---|---| | `items` | `NodeItem[]` | Ordered array of child nodes (`ElementNode` or `TextNode`) | | `owner` | `ElementNode` | The `ElementNode` that this list belongs to | ## Methods ### `insert(input, index?, updateDom?, silent?)` Creates a new child node and inserts it at the given index. Returns the new node. ```ts // Append at end const created = node.children.insert({ div: "Hello" }) // Insert at specific index const created = node.children.insert({ div: "Hello" }, 2) // Insert without touching DOM (DOM already updated externally) const created = node.children.insert({ div: "Hello" }, 2, false) ``` | Parameter | Type | Default | Description | |---|---|---|---| | `input` | `DomphyElement \| string \| number \| null` | required | Element or scalar child content to insert | | `index` | `number` | end | Position to insert at | | `updateDom` | `boolean` | `true` | Whether to update the DOM | | `silent` | `boolean` | `false` | Whether to suppress the `Update` hook | Returns the created `ElementNode` or `TextNode`. Cast to `ElementNode` when you need methods like `.remove()`: ```ts const toastNode = node.parent!.children.insert(Toast) as ElementNode setTimeout(() => toastNode.remove(), 3000) ``` --- ### `remove(item, updateDom?, silent?)` Removes a specific node from the list. ```ts node.children.remove(targetNode) // Remove without touching DOM node.children.remove(targetNode, false) ``` | Parameter | Type | Default | Description | |---|---|---|---| | `item` | `NodeItem` | required | The node to remove | | `updateDom` | `boolean` | `true` | Whether to remove from DOM | | `silent` | `boolean` | `false` | Whether to suppress the `Update` hook | Triggers `BeforeRemove` hook if present — removal waits for `done()` to be called. --- ### `update(inputs, updateDom?, silent?)` Reconciles the child list against a new array of inputs. Reuses keyed nodes, inserts new ones, removes stale ones — in order. ```ts node.children.update(newItemsArray) // Sync logical list without touching DOM (DOM already updated externally) node.children.update(newItemsArray, false) ``` | Parameter | Type | Default | Description | |---|---|---|---| | `inputs` | `ElementInput[]` | required | New desired child list | | `updateDom` | `boolean` | `true` | Whether to update the DOM | | `silent` | `boolean` | `false` | Whether to suppress hooks | Pass `false` for `updateDom` when an external library (e.g. SortableJS) has already updated the DOM — prevents double update. --- ### `move(fromIndex, toIndex, updateDom?, silent?)` Moves a node from one index to another within the list. ```ts node.children.move(0, 2) // Move logical position only — DOM already moved externally node.children.move(oldIndex, newIndex, false) ``` | Parameter | Type | Default | Description | |---|---|---|---| | `fromIndex` | `number` | required | Current position | | `toIndex` | `number` | required | Target position | | `updateDom` | `boolean` | `true` | Whether to move in DOM | | `silent` | `boolean` | `false` | Whether to suppress the `Update` hook | --- ### `swap(aIndex, bIndex, updateDom?, silent?)` Swaps two nodes at the given indices. ```ts node.children.swap(0, 1) ``` | Parameter | Type | Default | Description | |---|---|---|---| | `aIndex` | `number` | required | Index of first node | | `bIndex` | `number` | required | Index of second node | | `updateDom` | `boolean` | `true` | Whether to swap in DOM | | `silent` | `boolean` | `false` | Whether to suppress the `Update` hook | --- ### `clear(updateDom?, silent?)` Removes all children. ```ts node.children.clear() ``` --- ### `generateHTML()` Generates the HTML string for all child nodes. Used for Server-Side Rendering (SSR). ```ts const html = node.children.generateHTML() ``` --- ## The `updateDom` flag All mutating methods accept `updateDom` (default `true`). Pass `false` when the DOM has already been updated by an external source — prevents double mutation. Common case: SortableJS drag-and-drop. ```ts Sortable.create(el, { onEnd(evt) { // SortableJS already moved the DOM node — sync logical tree only node.children.move(evt.oldIndex!, evt.newIndex!, false) } }) ``` ## Common patterns **Insert and auto-remove (toast):** ```ts onClick: (_, node) => { const toastNode = node.parent!.children.insert(Toast) as ElementNode setTimeout(() => toastNode.remove(), 3000) } ``` **Reactive list (state-driven):** ```ts const items = toState([]) const List: DomphyElement<"ul"> = { ul: (listener) => items.get(listener).map(item => ({ li: item.name, _key: item.id, })) } ``` **Imperative reorder:** ```ts node.children.move(from, to) node.children.swap(a, b) ``` ### api/element-node.md # ElementNode Core node representing a single HTML element in the Domphy tree. ```ts import { ElementNode } from "@domphy/core" const node = new ElementNode({ div: "Hello World" }) node.render(document.body) ``` ## Constructor ```ts new ElementNode(domphyElement: DomphyElement, parent?: ElementNode | null) ``` ## Properties | Property | Type | Description | |---|---|---| | `type` | `string` | Always `"ElementNode"` | | `parent` | `ElementNode \| null` | Parent node. `null` if root | | `tagName` | `TagName` | HTML tag name e.g. `"div"` | | `children` | `ElementList` | Child nodes | | `styles` | `StyleList` | Scoped CSS styles | | `attributes` | `AttributeList` | HTML attributes | | `domElement` | `HTMLElement \| null` | Mounted DOM element | | `key` | `string \| number \| null` | Identity key for diffing | | `nodeId` | `string` | Hash used for scoped CSS class generation | | `_portal` | `((root) => Element) \| undefined` | Redirects DOM mount target when present | The scoped CSS class is attached through `node.attributes` using the pattern ``${tagName}_${nodeId}``. ## Methods ### `render(domElement)` Creates a DOM node and appends it to the target. ```ts node.render(document.body) node.render(document.getElementById("app")!) ``` ### `mount(domElement, domStyle?)` Hydrates onto an existing DOM element. Used for SSR. ```ts const html = node.generateHTML() const css = node.generateCSS() // ... send to client ... const domStyle = document.getElementById("domphy-style") as HTMLStyleElement node.mount(document.getElementById("app")!, domStyle) ``` When doing SSR, render CSS into `` on the server, then pass that same style element to `mount()` on the client. ### `remove()` Removes this node from its parent. ```ts node.remove() ``` ### `merge(partial)` Updates this node from a partial element descriptor. ```ts node.merge({ style: { color: "red" }, class: "active" }) ``` ### `addEvent(name, callback)` Registers a DOM event listener. Multiple callbacks are chained. ```ts node.addEvent("click", (e, node) => console.log(node.tagName)) ``` ### `addHook(name, callback)` Registers a lifecycle hook. Multiple callbacks are chained. ```ts node.addHook("Mount", (node) => console.log("mounted")) node.addHook("BeforeRemove", (node, done) => { animate(node.domElement).then(done) }) ``` | Hook | Trigger | |---|---| | `Schedule` | `(node, rawElement) => void` — fired before parsing; use to apply context-aware patches via `merge(rawElement, ...)` | | `Init` | `(node) => void` — fired after parsing, before insertion into the tree | | `Insert` | Node added to children list | | `Mount` | DOM element created | | `BeforeUpdate` | Before children diff | | `Update` | After children diff | | `BeforeRemove` | Before DOM removal — call `done()` to proceed | | `Remove` | After DOM removal | | `Error` | Caught error from a reactive child (`(node, error, reset) => void`) — call `reset()` to clear children and render fallback | ### `getRoot()` Returns the root node of the tree. ```ts const root = node.getRoot() ``` ### `getContext(name)` / `setContext(name, value)` Inherited context — walks up the tree to find the nearest value. ```ts // Parent node.setContext("theme", "dark") // Any descendant const theme = node.getContext("theme") // "dark" ``` ### `getMetadata(name)` / `setMetadata(key, value)` Local metadata — not inherited by children. ```ts node.setMetadata("id", "user-123") node.getMetadata("id") // "user-123" ``` ### `generateHTML()` Generates HTML string. Used for SSR. ```ts const html = node.generateHTML() // "
Hello
" ``` ### `generateCSS()` Generates CSS string for this node and all descendants. Used for SSR. ```ts const css = node.generateCSS() ``` ### api/notifier.md # Notifier Subscription utility used internally by `State` and `AttributeList`. ## Methods ### `addListener(event, listener)` Registers a listener for an event. Returns a `release` function to unsubscribe. ```ts const release = notifier.addListener("change", (value) => { console.log(value) }) // Unsubscribe release() ``` If the listener has an `onSubscribe` callback, it is called immediately with the `release` function — useful for auto-cleanup: ```ts const listener = (value: string) => console.log(value) listener.onSubscribe = (release) => { node.addHook("BeforeRemove", release) // auto-cleanup on node remove } notifier.addListener("change", listener) ``` ### `removeListener(event, listener)` Removes a specific listener from an event. ```ts notifier.removeListener("change", listener) ``` ### `notify(event, ...args)` Calls all listeners registered for an event. ```ts notifier.notify("change", newValue) ``` ## `Handler` type ```ts type Handler = ((...args: any[]) => any) & { onSubscribe?: (release: () => void) => void } ``` `onSubscribe` is called once when the listener is registered. Use it to tie the listener's lifetime to another object. ### api/state.md # State Reactive value container. When the value changes, all listeners are notified. ## `ReadableState` A read-only view of a `State`. Exposes only `get(listener?)` — no `set`, `reset`, or `addListener`. Use it when you want to pass a state to a consumer that should read but not mutate it. ```ts export type ReadableState = { readonly _isState: true; get(listener?: ValueListener): T; }; ``` The `_isState: true` discriminant lets runtime code and type guards distinguish a `ReadableState` from a plain value. ```ts import type { ReadableState } from "@domphy/core" function display(count: ReadableState) { return { p: (l) => `Count: ${count.get(l)}` } } ``` `ReadableState` is exported as a named type from `@domphy/core`. `State` satisfies `ReadableState` — any `State` can be passed where a `ReadableState` is expected. `toState()` also accepts `ReadableState` as input (returns it as-is). ```ts import { toState } from "@domphy/core" const count = toState(0) count.get() // 0 count.set(1) // notify all listeners count.get() // 1 count.reset() // back to 0 ``` Create a `State` with `toState()` from `@domphy/core`. The `toState()` function is documented in the Utilities page. ## Methods ### `get(listener?)` Returns the current value. If a listener is provided, subscribes it to future changes. ```ts const value = count.get() // With listener — auto-subscribe const value = count.get(listener) ``` ### `set(newValue)` Updates the value and notifies all listeners. ```ts count.set(5) ``` ### `reset()` Resets the value to `initialValue`. ```ts const filter = toState("all") filter.set("active") filter.reset() filter.get() // "all" ``` ### `addListener(listener)` Subscribes a listener to value changes. Returns a release function. ```ts const release = count.addListener((value) => { console.log(value) }) // Unsubscribe release() ``` ## Reactive children Pass a function as children to make an element reactive: ```ts const count = toState(0) const node: DomphyElement = { p: (listener) => `Count: ${count.get(listener)}` // ↑ subscribes automatically } ``` When `count.set()` is called, the element re-renders automatically. ## `initialValue` The value passed to the constructor. Used by `reset()`. ```ts const count = toState(0) count.initialValue // 0 ``` ## `ValueOrState` A union type accepted by patch props and element attributes that can be either a plain value, a reactive `State`, or a read-only `ReadableState`. ```ts export type ValueOrState = T | State | ReadableState; ``` Use it in function signatures when a prop should accept both static values and reactive states: ```ts import type { ValueOrState } from "@domphy/core" function myPatch(open: ValueOrState): PartialElement { return { ariaExpanded: typeof open === "object" && open._isState ? (l) => (open as ReadableState).get(l) : open, } } ``` In practice most patch props accept `ValueOrState` so callers can pass `true` / `false` or a `toState(false)` interchangeably. ### api/text-node.md # TextNode Represents a text or inline HTML node in the Domphy tree. `TextNode` is created automatically when children contain strings or numbers. You usually do not instantiate it directly. ```ts const node: DomphyElement = { div: "Hello World" // -> TextNode internally } const node2: DomphyElement = { div: 42 // -> TextNode internally } const node3: DomphyElement = { div: "Bold" // -> TextNode with inline HTML } ``` ## Properties | Property | Type | Description | |---|---|---| | `type` | `string` | Always `"TextNode"` | | `parent` | `ElementNode` | Parent node | | `text` | `string` | Current text content | | `domText` | `ChildNode` | Mounted DOM node | ## Inline HTML `TextNode` accepts a single-root HTML string. Multiple root elements are not supported. ```ts "Hello" // valid "" // valid "Hello World" // invalid: multiple roots ``` Single-root HTML is required so DOM operations like `move()` and `swap()` can keep node identity stable. ## Empty string An empty string `""` is stored as a zero-width space (`\u200B`) so the DOM node still exists. ```ts { div: "" } // renders as ​ ``` ## `generateHTML()` Returns the text content as an HTML string. Used for SSR. ```ts node.generateHTML() // "Hello World" or "​" for empty string ``` ### api/utilities.md # Utilities Top-level helper functions exported by `@domphy/core`. ```ts import { toState, merge, hashString } from "@domphy/core" ``` Use `Utilities` here rather than `Functions`: these are reusable helper APIs, not the main object model like `ElementNode`, `ElementList`, or `State`. ## `toState(value, name?)` Creates a `State` from a raw value. If the input is already a `State` or `ReadableState`, returns it as-is. ```ts const a = toState(0) // State const b = toState(a) // same State, no wrapping const c = toState(0, "count") // State with debug name "count" ``` | Parameter | Type | Description | |---|---|---| | `value` | `T \| State \| ReadableState` | Raw value, existing `State`, or `ReadableState` | | `name` | `string` (optional) | Debug name for the state, used in devtools and error messages | Returns `State`. Common use case: normalize patch props so callers can pass either a plain value or a reactive state. ```ts const openState = toState(props.open ?? false) ``` --- ## `merge(source, target)` Deep-merges `target` into `source` using Domphy's composition rules. ```ts const base = { class: "card", style: { padding: "1rem" } } merge(base, { class: "active", style: { color: "red" } }) // base is now: // { class: "card active", style: { padding: "1rem", color: "red" } } ``` | Parameter | Type | Description | |---|---|---| | `source` | `Record` | Object to mutate | | `target` | `Record` | Values to merge into `source` | Returns the same `source` object after merge. Key merge behaviors: - Plain objects are merged deeply. - `class`, `transform`, `rel` and similar fields are space-joined. - `animation`, `transition`, `boxShadow` and similar fields are comma-joined. - Event handlers like `onClick` are chained. - Hooks like `_onMount` are chained. - Most other keys are overwritten by `target`. Use `merge()` when composing patches or mutating a raw element in `_onSchedule`. --- ## `hashString(str?)` Generates a deterministic string hash. The result always starts with a lowercase letter, so it is safe to use as a CSS identifier. ```ts hashString("hello") // e.g. "b4a2f1c3" hashString("hello") // same input, same output ``` | Parameter | Type | Default | Description | |---|---|---|---| | `str` | `string` | `""` | Input string to hash | Returns a `string`. Primary use case: generate a stable animation name from keyframes. ```ts const keyframes = { to: { transform: "rotate(360deg)" } } const animationName = hashString(JSON.stringify(keyframes)) const style = { animation: `${animationName} 0.7s linear infinite`, [`@keyframes ${animationName}`]: keyframes, } ``` Do not use `hashString()` to generate ids for Domphy nodes. `ElementNode` already exposes `node.nodeId`, which is the runtime-scoped unique id used by the framework. Notes: - Deterministic: identical input always produces identical output. - CSS-safe: output always starts with a letter. - Not cryptographic: use it for IDs and CSS names, not security. --- ## `configure(options)` Set global runtime options. Call once before mounting your app. ```ts import { configure } from "@domphy/core" configure({ cspNonce: "abc123" }) ``` | Option | Type | Description | |---|---|---| | `cspNonce` | `string` | Nonce stamped on every `
${node.generateHTML()}
` ``` ```ts [client.js] import { ElementNode } from "@domphy/core" import App from "./app.js" const domStyle = document.getElementById("domphy-style") as HTMLStyleElement new ElementNode(App).mount(document.getElementById("app")!, domStyle) ``` For SSR, render CSS into `
...
` ``` If the CSS is already in the HTML, the client usually does not need to call `themeApply()` again unless you later change registered themes. For the full API surface, see [API](./api). ### size.md # Size Domphy keeps `size`, `density`, and `spacing` separate, but they work together in one sizing model. The base unit is: `U = fontSize / 4` At `fontSize: 16px`, `U = 4px`. Use: - `themeSize(listener, key)` to resolve font size from `dataSize` - `themeDensity(listener)` to resolve the current density factor from `dataDensity` - `themeSpacing(n)` to convert the final numeric result into CSS units ## Overview Think of the sizing pipeline like this: 1. `themeSize()` sets the local text scale 2. that font size defines `U` 3. `themeDensity()` changes how compact or loose the geometry feels 4. formulas produce numeric spacing values in units of `U` 5. `themeSpacing()` converts the final number into a CSS length ## Size `size` controls typography scale through `dataSize` and `themeSize()`. Use it when the local subtree should inherit a larger or smaller text scale. ```ts fontSize: (listener) => themeSize(listener, "inherit") ``` This is the part that defines the local `fontSize`, and therefore defines the local unit: `U = fontSize / 4` If the subtree font size changes, every formula built on `U` changes with it. ## Density `density` controls compactness through `dataDensity` and `themeDensity()`. Use it when the component should feel tighter or looser without changing the type scale. Core variable: - `d` = current density factor Density factors come from the current theme: `[0.75, 1, 1.5, 2, 2.5]` Default density: `d = 1.5` Typical read: ```ts const d = themeDensity(listener) ``` `themeDensity()` returns a number, not a CSS value. It is a multiplier used inside sizing formulas. ## Spacing `spacing` is the final CSS length produced from the numeric result. Use `themeSpacing(n)` after the geometry has already been decided. ```ts gap: themeSpacing(3) paddingInline: themeSpacing(themeDensity(listener) * 3) ``` So the role split is: - `themeSize()` sets the scale - `themeDensity()` sets the multiplier - `themeSpacing()` emits the CSS value ## Geometry Variables - `n` = intrinsic text lines - `w` = wrapping level - `d` = current density factor ## Wrapping Level ```txt w = 0 inline / no boundary w = 1 single-line bounded control w = 2 multi-line bounded block w = 3 structural section / large overlay ``` Examples: | w | Class | Example | | --- | --- | --- | | 0 | inline / no boundary | text, icon, inline label | | 1 | single-line bounded | button, input, select, tooltip | | 2 | multi-line bounded | textarea, blockquote, card | | 3 | structural section | dialog, drawer, fieldset | ## Geometry Formulas Internal component geometry is formula-driven: ```txt paddingBlock = d * w * U paddingInline = ceil(3 / w) * d * w * U for w >= 1 paddingInline = 2dU for bounded inline w = 0 radius = paddingBlock height = (n * 6 + 2 * d * w) * U ``` For single-line bounded controls (`n = 1`, `w = 1`): ```txt height = (6 + 2d) * U ``` At default density `d = 1.5`, that becomes: ```txt height = 9U paddingBlock = 1.5U paddingInline = 4.5U radius = 1.5U ``` At `fontSize: 16px`: ```txt height = 36px paddingBlock = 6px paddingInline = 18px radius = 6px ``` ## Industry Validation The height formula produces the canonical button sizes used across major design systems — not by coincidence, but because those systems converged on the same proportions through practice. At `fontSize: 16px` (`U = 4px`), `n = 1`, `w = 1`: | Density `d` | Formula `(6 + 2d) * U` | Height | Matches | | --- | --- | --- | --- | | 0.75 | `(6 + 1.5) * 4` | **30px** | MUI small | | 1 | `(6 + 2) * 4` | **32px** | Ant Design medium · Chakra small · GitHub medium | | 1.5 | `(6 + 3) * 4` | **36px** | MUI medium | | 2 | `(6 + 4) * 4` | **40px** | Ant Design large · Chakra medium · GitHub large | | 2.5 | `(6 + 5) * 4` | **44px** | MUI large range | These are not hardcoded sizes. They emerge from one formula across five density levels. The formula does not prescribe what height a button must be. It reveals the underlying structure that the industry already arrived at through intuition and iteration. ## Putting Them Together ```ts import { themeColor, themeDensity, themeSize, themeSpacing } from "@domphy/theme" const button = { button: "Buy", dataDensity: "inherit", style: { fontSize: (listener) => themeSize(listener, "inherit"), paddingBlock: (listener) => themeSpacing(themeDensity(listener) * 1), paddingInline: (listener) => themeSpacing(themeDensity(listener) * 3), borderRadius: (listener) => themeSpacing(themeDensity(listener) * 1), backgroundColor: (listener) => themeColor(listener, "inherit", "primary"), color: (listener) => themeColor(listener, "shift-9", "primary"), }, } ``` This reads as: - `themeSize(listener, "inherit")` -> local font size - `themeDensity(listener)` -> current `d` - `* 1` / `* 3` -> geometry factor for that edge - `themeSpacing(...)` -> final CSS unit ## Reference Table Base density `d = 1.5`: | Level | w=0 | w=1 | w=2 | w=3 | | --- | --- | --- | --- | --- | | height (`n = 1`) | 6U | 9U | 12U | 15U | | paddingBlock | 0 | 1.5U | 3U | 4.5U | | paddingInline | 3U* | 4.5U | 6U | 4.5U | | radius | 0 | 1.5U | 3U | 4.5U | At `fontSize: 16px`: | Level | w=0 | w=1 | w=2 | w=3 | | --- | --- | --- | --- | --- | | height (`n = 1`) | 24px | 36px | 48px | 60px | | paddingBlock | 0 | 6px | 12px | 18px | | paddingInline | 12px* | 18px | 24px | 18px | | radius | 0 | 6px | 12px | 18px | \* For `w = 0`, inline padding only applies to bounded inline surfaces such as `tag`, `badge`, or `code`. Pure text or icon inline content has no outer padding. ## Sub-Baseline Scale Elements intentionally below the `6U` text baseline use the fixed proportional sub-scale: `2U / 4U / 6U` These stay fixed unless the patch explicitly defines another rule. ## Layout Spacing Internal geometry is formula-driven. Layout spacing between separate regions is not. Practical rule: - horizontal `gap` / `margin-inline` should usually be at least the related `paddingInline` - vertical `gap` / `margin-block` should usually be at least the related `paddingBlock` Example at base density: ```ts gap: themeSpacing(4.5) // >= w=1 paddingInline gap: themeSpacing(3) // >= w=2 paddingBlock ``` ## Recommendation Use `outline` or `box-shadow` instead of `border` when the sizing formula matters. At `w = 1`, `d = 1.5`: - formula height = `9U = 36px` - a `1px` border on both sides adds `2px` - total rendered height becomes `38px` That is a `5.56%` deviation from the sizing model. For the underlying tone model, see [Tone](./tone). ### tone.md # Tone Use `themeColor(listener, tone, color?)` from `@domphy/theme` to resolve colors from theme context. Tone Model ## Tone Span `Tone Span` is the contrast-span model derived in the Chromametry paper for sequential monochromatic ramps. - For a color family with `N` ordered lightness steps, `K` is the minimum index distance that guarantees WCAG `4.5:1` contrast for all valid pairs in that family. - This turns contrast selection into a fixed index rule instead of repeated runtime checking. - In the current Domphy light ramp, `N = 18`, so the working span is `K = 9`. Formal definition: ```txt K = min { k : CR(c_i, c_{i+k}) >= 4.5 for all valid i } ``` For the formal definition, benchmark method, and cross-system results, see: - Repo: https://github.com/chromametry/chromametry - Paper: https://github.com/chromametry/chromametry/blob/main/paper/paper.pdf ## Tone System Hierarchy Domphy's tone system is built on three independent logical layers. This is the abstract model, before any concrete step count or ramp mapping is applied. ### 1. Layer 1: Context Surface This is **The Floor**. It is not the state of the object itself, but the environment that contains it. - **Role:** Defines the local tone field for a subtree. - **Meaning:** Establishes the anchor from which child elements are measured. - **Behavior:** Gives the system a stable surface reference so descendant tones can be interpreted relative to the same anchor. ### 2. Layer 2: Semantic Zone This is **The Seat**. It describes the object's stable semantic position before any interaction happens. - **Role:** Encodes meaning, not interaction. - **Meaning:** Distinguishes resting, positional, and emphasized states. - **Behavior:** Creates persistent semantic separation between elements that share the same context surface. ### 3. Layer 3: Interactive Delta This is **The Action**. It is a temporary modifier applied on top of the semantic zone during interaction. - **Role:** Expresses live response such as hover or press. - **Meaning:** It is transient and should never redefine the semantic identity of the element. - **Behavior:** Adds a small offset so interaction remains visible without collapsing into another semantic zone. ### General Formula At the abstract level, the final tone is always resolved from the same three-layer composition: ```txt T = C_surface + S_zone + I_delta ``` Where: - `T` means the final tone - `C_surface` means the context surface anchor - `S_zone` means the semantic zone offset - `I_delta` means the interactive offset This formula is the core rule of the hierarchy: context defines the anchor, semantics define the stable zone, and interaction adds a temporary local delta. --- ## Tone Mapping This section applies the abstract hierarchy to the current Domphy tone ramp. For the current Domphy light ramp: ```txt N = 18 K = 9 ``` `K = 9` is the contrast span reserved by the system between background and text. In practice, this means the first 9 steps can be used for surfaces and state layers, while the contrast target for text begins at step 9 relative to the same anchor. ### 1. Surface Anchors To keep tone progression predictable, the context surface should usually start near one edge of the 18-step ramp. - **Normal surface anchors:** `0`, `1`, `2`, `3` - **Inverted surface anchors:** `17`, `16`, `15`, `14` - The purpose of choosing edge anchors is to keep tone progression moving in one direction inside a single context. - If a surface starts in the middle of the ramp, child tones can hit a clamp before the progression finishes, then appear to bend back toward the opposite side. That produces unstable and visually ugly mapping. - No matter whether the local context is interpreted as increasing or decreasing, the final resolved surface band should still land in one of these two edge ranges. - `0, 1, 2, 3` keep the surface on the low edge so child tones can expand upward in a single clear sequence. - `17, 16, 15, 14` keep the surface on the high edge so child tones can still be mapped consistently in the inverted case. AI should prefer these surface anchors and avoid arbitrary middle anchors unless there is a specific reason. ### 2. Semantic Mapping To keep the system structured, Domphy maps the semantic layer into three equal regions inside the available `K = 9` surface span: - **Default zone:** `0` - **Indicator zone:** `K / 3 = 3` - **Accent zone:** `2K / 3 = 6` This is why `K = 9` is a strong fit. It divides cleanly into three semantic anchors: - `0` for rest - `3` for indicator - `6` for accent These anchors are far enough apart to be perceptually distinct while still remaining below the text threshold at `9`. ### 3. Interaction Mapping Interactive deltas stay intentionally small: - **Hover:** `+1` or `-1` - **Active:** `+2` or `-2` That gives each semantic anchor its own local interaction range without collisions: - `0` -> `1` -> `2` - `3` -> `4` -> `5` - `6` -> `7` -> `8` Because the three semantic anchors are spaced by `3`, and the largest interaction delta is `2`, every resulting tone remains unique. The proof below applies the general formula from the hierarchy section on top of those surface anchors. **Proof matrix (example with `Context Surface = 0` and `K = 9`):** | Actual state | Logical formula | Result (Final Tone) | | :--- | :--- | :--- | | **Resting component** | `0 + 0 + 0` | **Step 0** | | **Hovered component** | `0 + 0 + 1` | **Step 1** | | **Pressed component** | `0 + 0 + 2` | **Step 2** | | **Static indicator (Menu)** | `0 + K/3 + 0` | **Step 3** | | **Indicator + Hover** | `0 + 3 + 1` | **Step 4** | | **Indicator + Press** | `0 + 3 + 2` | **Step 5** | | **Strong state (Toggle)** | `0 + 2K/3 + 0` | **Step 6** | | **Strong state + Hover** | `0 + 6 + 1` | **Step 7** | | **Strong state + Press** | `0 + 6 + 2` | **Step 8** | **Proof matrix (example with inverted `Context Surface = 17` and `K = 9`):** | Actual state | Logical formula | Result (Final Tone) | | :--- | :--- | :--- | | **Resting component** | `17 + 0 + 0` | **Step 17** | | **Hovered component** | `17 - 0 - 1` | **Step 16** | | **Pressed component** | `17 - 0 - 2` | **Step 15** | | **Static indicator (Menu)** | `17 - K/3 - 0` | **Step 14** | | **Indicator + Hover** | `17 - 3 - 1` | **Step 13** | | **Indicator + Press** | `17 - 3 - 2` | **Step 12** | | **Strong state (Toggle)** | `17 - 2K/3 - 0` | **Step 11** | | **Strong state + Hover** | `17 - 6 - 1` | **Step 10** | | **Strong state + Press** | `17 - 6 - 2` | **Step 9** | **Invariant rule:** The total variation (`Semantic Zone + Interactive Delta`) must stay below `K`. With `K = 9`, the sequence `0, 1, 2, 3, 4, 5, 6, 7, 8` forms three clean semantic bands, and `Step 9` remains the start of the text-contrast region. That is why `9` works well: it divides into three stable zones and still leaves hover and active states unique without overlap. ## Tone Roles When Domphy says `tone` without another qualifier, it usually means the resolved surface or background tone of the element itself. From that base tone, the common visual roles are derived as follows: - **Background / Surface:** the tone itself - **Text:** the tone plus or minus `K` - **Stroke:** the tone plus or minus `K / 3` Here, `stroke` means the structural edge role, such as `outline`, `border`, or a separator line. With the current Domphy light ramp: ```txt K = 9 K / 3 = 3 ``` So the concrete role mapping is: - normal side: `background = tone`, `stroke = tone + 3`, `text = tone + 9` - inverted side: `background = tone`, `stroke = tone - 3`, `text = tone - 9` This is the practical reason tone selection stays anchored near the edges: the derived roles remain ordered, predictable, and do not collapse back into the wrong side of the ramp. ## Shift System Valid tone keys: - `"shift-N"` where `N` is `0` to `17` - `"increase-N"` - `"decrease-N"` - `"inherit"` - `"base"` `dataTone` accepts the same keys. Use them like this: - `inherit` = keep the current local surface - `shift-N` = go to a fixed semantic slot on the current branch - `increase-N` = move further along the current branch - `decrease-N` = move back along the current branch - `base` = jump to the registered base tone of that color family Basic example: ```ts backgroundColor: (l) => themeColor(l, "shift-0", "primary") color: (l) => themeColor(l, "shift-9", "primary") outline: (l) => `1px solid ${themeColor(l, "shift-3", "primary")}` ``` ## Full Example ```ts const button = { button: "Buy", style: { fontSize: (l) => themeSize(l, "inherit"), paddingBlock: (l) => themeSpacing(themeDensity(l) * 1), paddingInline: (l) => themeSpacing(themeDensity(l) * 3), borderRadius: (l) => themeSpacing(themeDensity(l) * 1), backgroundColor: (l) => themeColor(l, "inherit", "primary"), color: (l) => themeColor(l, "shift-9", "primary"), outline: (l) => `1px solid ${themeColor(l, "shift-3", "primary")}`, "&:hover": { backgroundColor: (l) => themeColor(l, "increase-1", "primary"), }, "&:focus-visible": { boxShadow: (l) => `0 0 0 2px ${themeColor(l, "shift-6", "primary")}`, }, }, } ``` ## Context Tone `dataTone` propagates down the tree. Descendants resolve their own tone automatically. ```ts { div: [...], dataTone: "shift-1" } { span: "Error", style: { color: (l) => themeColor(l, "shift-9", "error") } } ``` ## Recommendation Prefer `dataTone` over changing container colors manually. ```ts // better { div: [Button, Text], dataTone: "shift-1", style: { backgroundColor: (l) => themeColor(l, "inherit", "danger"), }, } ``` Use `dataTheme` only when you truly want a different theme, not just a darker or lighter local surface. --- ## Query docs (`@domphy/query`) ### adapter.md # Domphy Adapter The [bridge pattern](./) — `observer.subscribe(...)` pushing into `toState` — works everywhere, but it is the same boilerplate in every component. `@domphy/query/domphy` packages it once: a thin adapter that binds the TanStack observers to Domphy reactivity and hands you reactive accessors. ```bash npm install @domphy/query @domphy/core ``` `@domphy/core` is a **peer dependency** of the adapter — it is the only part of `@domphy/query` that touches Domphy, so the main `@domphy/query` entry stays a dependency-free, byte-identical port. Import the adapter from the `/domphy` subpath: ```ts import { createQuery, createInfiniteQuery, createMutation } from "@domphy/query/domphy" ``` ## createQuery `createQuery(client, options)` constructs a `QueryObserver`, subscribes it, and returns a handle whose accessors are reactive — pass the listener `l` and the element re-renders when that field changes. ```ts import { QueryClient } from "@domphy/query" import { createQuery } from "@domphy/query/domphy" const queryClient = new QueryClient() queryClient.mount() const users = createQuery(queryClient, { queryKey: ["users"], queryFn: () => fetch("/api/users").then((res) => res.json()), }) const App: DomphyElement<"ul"> = { ul: (l) => (users.data(l) ?? []).map((user) => ({ li: user.name, _key: user.id, })), hidden: (l) => users.isPending(l), } ``` ### Accessors Every accessor takes an optional listener and returns the live value: | Accessor | Returns | | --- | --- | | `data(l)` | `TData \| undefined` | | `error(l)` | `TError \| null` | | `status(l)` | `"pending" \| "error" \| "success"` | | `fetchStatus(l)` | `"fetching" \| "paused" \| "idle"` | | `isPending(l)` / `isLoading(l)` | `boolean` | | `isFetching(l)` / `isRefetching(l)` | `boolean` | | `isSuccess(l)` / `isError(l)` | `boolean` | | `isStale(l)` | `boolean` | Plus the imperative members: `refetch(options?)`, `setOptions(options)`, the raw `observer`, the underlying `state` (a `RecordState`), and `destroy()`. ### Per-field reactivity Each result field is an independent key in a `RecordState`, and updates are diffed by reference before they notify. A component that reads only `data` does **not** re-render when `isFetching` toggles, and refetching a query that returns the same value re-renders nothing: ```ts { ul: (l) => users.data(l) } // re-renders only when data changes { span: (l) => users.isFetching(l) ? "↻" : "" } // re-renders only on fetch toggles ``` ### Cleanup The observer subscribes for the life of the handle. When the owning subtree unmounts, release it from a lifecycle hook: ```ts { ul: (l) => (users.data(l) ?? []).map(...), _onRemove: () => users.destroy(), } ``` Top-level queries that live as long as the page need no cleanup. ## createMutation `createMutation(client, options)` returns a handle with the same reactive accessors plus `mutate` / `mutateAsync`. ```ts import { createMutation } from "@domphy/query/domphy" const save = createMutation(queryClient, { mutationFn: (input) => fetch("/api/posts", { method: "POST", body: JSON.stringify(input) }).then((r) => r.json()), onSuccess: () => queryClient.invalidateQueries({ queryKey: ["posts"] }), }) const SaveButton: DomphyElement<"button"> = { button: (l) => (save.isPending(l) ? "Saving…" : "Save"), ariaDisabled: (l) => save.isPending(l), onClick: () => save.mutate({ title: "Hello" }), } ``` - `mutate(variables, options?)` is fire-and-forget — rejections are swallowed, read them via `save.error(l)`. - `mutateAsync(variables, options?)` returns the promise so you can `await`/`catch`. - Accessors: `data`, `error`, `variables`, `status`, `isPending`, `isSuccess`, `isError`, `isIdle`. Plus `reset()` and `destroy()`. ## createInfiniteQuery `createInfiniteQuery(client, options)` wraps `InfiniteQueryObserver` and adds the page accessors: ```ts import { createInfiniteQuery } from "@domphy/query/domphy" const feed = createInfiniteQuery(queryClient, { queryKey: ["feed"], queryFn: ({ pageParam }) => fetch(`/api/feed?cursor=${pageParam}`).then((r) => r.json()), initialPageParam: 0, getNextPageParam: (lastPage) => lastPage.nextCursor, }) const Feed: DomphyElement<"div"> = { div: [ { ul: (l) => (feed.data(l)?.pages ?? []).flatMap((page) => page.items.map((item) => ({ li: item.title, _key: item.id })), ), }, { button: "Load more", ariaDisabled: (l) => !feed.hasNextPage(l) || feed.isFetchingNextPage(l), onClick: () => feed.fetchNextPage(), }, ], } ``` InfiniteQueryHandle accessors: `data`, `error`, `status`, `isPending`, `isFetching`, `isSuccess`, `isError`, `hasNextPage`, `hasPreviousPage`, `isFetchingNextPage`, `isFetchingPreviousPage`; methods: `fetchNextPage`, `fetchPreviousPage`, `refetch`, `destroy`. Note: `fetchStatus`, `isLoading`, `isRefetching`, `isStale`, and `setOptions` are not available on the infinite variant. ## When to use the bridge directly The adapter covers the common cases. Reach for the raw [bridge pattern](./) when you need full control over which result fields drive which states, or when wiring a query into an existing custom state model. The adapter is built on exactly that bridge — there is no hidden machinery. ### api.md # API Reference `@domphy/query` is a 1-1 port of `@tanstack/query-core` v5.90.20 — every export below has identical behavior to upstream, so the [TanStack Query reference](https://tanstack.com/query/latest/docs/reference/QueryClient) documents each item in full detail. ## Client And Caches - `QueryClient` — the entry point. `fetchQuery`, `prefetchQuery`, `getQueryData`, `setQueryData`, `ensureQueryData`, `invalidateQueries`, `refetchQueries`, `cancelQueries`, `removeQueries`, `resetQueries`, `setQueryDefaults`, `setMutationDefaults`, `getQueryCache`, `getMutationCache`, `mount`, `unmount`, `clear` - `QueryCache` — holds all `Query` instances. `find`, `findAll`, `subscribe`, `clear` - `MutationCache` — holds all `Mutation` instances. `find`, `findAll`, `subscribe`, `clear` - `Query` — one cache entry (advanced; usually accessed via `QueryCache`) - `Mutation` — one mutation instance (advanced) ## Observers - `QueryObserver` — subscribe to one query. `subscribe`, `setOptions`, `getCurrentResult`, `refetch` - `QueriesObserver` — subscribe to a dynamic array of queries - `InfiniteQueryObserver` — paginated queries. Adds `fetchNextPage`, `fetchPreviousPage` - `MutationObserver` — run mutations. `mutate`, `reset`, `subscribe`, `getCurrentResult` ## Managers - `focusManager` — window focus tracking; `setFocused`, `setEventListener`, `subscribe` - `onlineManager` — online/offline tracking; `setOnline`, `setEventListener`, `subscribe` - `notifyManager` — update batching; `batch`, `schedule`, `setScheduler`, plus `defaultScheduler` - `timeoutManager` — pluggable timer provider (`TimeoutProvider`, `TimeoutCallback`, `ManagedTimerId`) ## Hydration - `dehydrate(client, options?)` — serialize the cache to `DehydratedState` - `hydrate(client, state, options?)` — restore a serialized cache - `defaultShouldDehydrateQuery`, `defaultShouldDehydrateMutation` — the default include rules ## Utilities - `hashKey(queryKey)` — structural hash, stable across object key order - `matchQuery(filters, query)` / `matchMutation(filters, mutation)` — filter predicates - `partialMatchKey(a, b)` — prefix key matching - `replaceEqualDeep(a, b)` — structural sharing helper (keeps referential identity for unchanged parts) - `keepPreviousData` — pass as `placeholderData` to keep data across key changes - `skipToken` — pass as `queryFn` to disable a query type-safely - `isCancelledError(error)` / `CancelledError` — cancellation detection - `shouldThrowError`, `isServer`, `noop` - `experimental_streamedQuery` — build a query from an `AsyncIterable` (streamed chunks) ## Types All public types are re-exported, including: - options: `QueryObserverOptions`, `InfiniteQueryObserverOptions`, `MutationObserverOptions`, `QueryOptions`, `FetchQueryOptions`, `DefaultOptions` - results: `QueryObserverResult`, `InfiniteQueryObserverResult`, `MutationObserverResult` - keys & functions: `QueryKey`, `QueryFunction`, `QueryFunctionContext`, `MutationFunction` - state: `QueryState`, `MutationState`, `QueryStatus`, `FetchStatus`, `MutationStatus` - data shapes: `InfiniteData`, `GetNextPageParamFunction`, `DehydratedState` - filters: `QueryFilters`, `MutationFilters`, `SkipToken`, `Updater` - cache events: `QueryCacheNotifyEvent`, `MutationCacheNotifyEvent`, `QueriesObserverOptions` ## CDN Global The IIFE bundle exposes everything under `Domphy.query`: ```html ``` ### caching.md # Caching Every query result lives in the `QueryCache` under its hashed `queryKey`. Understanding two timers explains almost all cache behavior: - **`staleTime`** (default `0`) — how long data is *fresh*. Fresh data is served from cache with no network request. Stale data is still served instantly, but a background refetch fires. - **`gcTime`** (default 5 minutes) — how long *inactive* data (no subscribers) stays in memory before being garbage-collected. ``` fetch ──► fresh ──staleTime──► stale ──last subscriber leaves──► inactive ──gcTime──► gone ``` The default `staleTime: 0` means "always refetch in the background when a query is used again" — data on screen is never blocked, just silently updated. Raise `staleTime` for data that rarely changes. ## Reading And Writing The Cache ```ts queryClient.getQueryData(["todos"]) // read, undefined if absent queryClient.setQueryData(["todos"], todos) // write queryClient.setQueryData(["todos"], (old) => [...(old ?? []), newTodo]) ``` `setQueryData` notifies every observer on that key — bridged states update immediately. ## Invalidation `invalidateQueries` marks matching queries stale and refetches the active ones: ```ts // everything under ["todos"] — ["todos"], ["todos", 5], ["todos", {page: 2}] ... queryClient.invalidateQueries({ queryKey: ["todos"] }) // exactly ["todos"] queryClient.invalidateQueries({ queryKey: ["todos"], exact: true }) // everything queryClient.invalidateQueries() ``` This is the standard pattern after a mutation: the server changed, so ask again. ## Prefetching Warm the cache before the user needs it — on hover, on route intent, at boot: ```ts queryClient.prefetchQuery({ queryKey: ["todo", id], queryFn: () => fetchTodo(id), staleTime: 10_000, }) ``` `prefetchQuery` never throws; errors are cached like any query error. Use `fetchQuery` instead when you need the data (it returns the data and throws on failure): ```ts const todos = await queryClient.fetchQuery({ queryKey: ["todos"], queryFn: fetchTodos }) ``` If fresh data already exists in cache, both return it without a request. ## Other Cache Operations ```ts await queryClient.cancelQueries({ queryKey: ["todos"] }) // abort in-flight fetches queryClient.removeQueries({ queryKey: ["todos"] }) // drop entries entirely await queryClient.resetQueries({ queryKey: ["todos"] }) // back to initial state, refetch active await queryClient.refetchQueries({ queryKey: ["todos"] }) // force refetch, ignores staleTime queryClient.clear() // empty both caches ``` In-flight `queryFn`s receive an `AbortSignal` — pass it to `fetch` so `cancelQueries` aborts the request itself: ```ts queryFn: ({ signal }) => fetch("/api/todos", { signal }).then((res) => res.json()) ``` ## Query Filters Most `QueryClient` methods accept a filter object: | Filter | Meaning | |---|---| | `queryKey` | Prefix match by default | | `exact` | Match the key exactly | | `type` | `"active"`, `"inactive"`, or `"all"` | | `stale` | Only stale (or only fresh) queries | | `fetchStatus` | `"fetching"`, `"paused"`, `"idle"` | | `predicate` | `(query) => boolean` for anything else | ```ts queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0] === "todos" && (query.queryKey[1] as number) > 10, }) ``` ## Default Options Set app-wide defaults once on the client: ```ts const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 60_000, retry: 1, refetchOnWindowFocus: false, }, }, }) ``` Per-key defaults are also supported with `queryClient.setQueryDefaults(["todos"], { staleTime: 10_000 })`. ## Focus And Online Refetching `queryClient.mount()` subscribes the cache to two singletons: - `focusManager` — refetches stale active queries when the window regains focus - `onlineManager` — pauses fetches offline and resumes/refetches on reconnect Both are exported and overridable (useful in tests or non-browser environments): ```ts import { focusManager, onlineManager } from "@domphy/query" focusManager.setFocused(true) // force onlineManager.setOnline(false) // simulate offline ``` ### index.md # Query `@domphy/query` provides async state management for Domphy apps: fetching, caching, deduplication, background refetching, mutations, infinite queries, and SSR hydration. It is a **1-1 port of [`@tanstack/query-core`](https://github.com/TanStack/query/tree/main/packages/query-core) v5.90.20** (MIT, © Tanner Linsley and the TanStack team). The source is kept byte-identical to upstream, so the entire [TanStack Query core reference](https://tanstack.com/query/latest/docs/reference/QueryClient) applies as-is, and future upstream versions can be diffed and merged directly. Like the rest of Domphy, it is framework-agnostic and has zero dependencies — the bridge to the UI is plain `toState`. ## Install npm install @domphy/query ``` ```html [CDN] ``` The CDN bundle exposes `Domphy.query` with all exports. ## Live Example ## Core Concepts - **`QueryClient`** — owns the `QueryCache` and `MutationCache`. Create one per app, call `queryClient.mount()` once so window focus and reconnect refetching work. - **`QueryObserver`** — subscribes to one query: runs the `queryFn`, caches the result under `queryKey`, and emits a result object on every state change. - **`MutationObserver`** — runs create/update/delete operations with retry and lifecycle callbacks. - **Query keys** — arrays like `["users", userId]`. Hashed structurally, so object key order does not matter. Keys are also the handle for invalidation and prefetching. ## The Bridge Pattern Domphy has no async primitive by design — async is a state problem, and state lives outside the UI. `@domphy/query` manages the async state; `toState` pushes results into the UI: ```ts import { QueryClient, QueryObserver } from "@domphy/query" import { toState } from "@domphy/core" const queryClient = new QueryClient() queryClient.mount() const data = toState(undefined) const loading = toState(true) const observer = new QueryObserver(queryClient, { queryKey: ["users"], queryFn: () => fetch("/api/users").then((res) => res.json()), }) observer.subscribe((result) => { data.set(result.data) loading.set(result.isPending) }) ``` The UI reads the states reactively — nothing query-specific leaks into elements: ```ts const App: DomphyElement<"ul"> = { ul: (l) => (data.get(l) ?? []).map((user) => ({ li: user.name, _key: user.id, })), hidden: (l) => loading.get(l), } ``` ## What To Read Next 1. [Domphy Adapter](./adapter) for `createQuery` / `createMutation` / `createInfiniteQuery` — the recommended way to consume queries 1. [Queries](./queries) for `QueryObserver`, options, and a reusable `createQuery` helper 2. [Mutations](./mutations) for writes, callbacks, and optimistic updates 3. [Caching](./caching) for invalidation, prefetching, and the `staleTime` / `gcTime` model 4. [Infinite Queries](./infinite-queries) for pagination 5. [SSR & Hydration](./ssr) for server rendering with Domphy SSR 6. [API Reference](./api) for the full export list ### infinite-queries.md # Infinite Queries `InfiniteQueryObserver` handles "load more" and infinite scroll. Instead of one value, the cached data is `{ pages: [...], pageParams: [...] }`, and each fetch appends (or prepends) a page. ## Basic Usage ```ts import { QueryClient, InfiniteQueryObserver } from "@domphy/query" import { toState } from "@domphy/core" const queryClient = new QueryClient() queryClient.mount() const items = toState([]) const hasMore = toState(false) const loadingMore = toState(false) const observer = new InfiniteQueryObserver<{ items: Item[]; nextCursor: number | null }>(queryClient, { queryKey: ["feed"], queryFn: ({ pageParam }) => fetch(`/api/feed?cursor=${pageParam}`).then((res) => res.json()), initialPageParam: 0, getNextPageParam: (lastPage) => lastPage.nextCursor, // null/undefined = no more pages }) observer.subscribe((result) => { items.set(result.data?.pages.flatMap((page) => page.items) ?? []) hasMore.set(result.hasNextPage) loadingMore.set(result.isFetchingNextPage) }) ``` Flattening `pages` into one `toState` list keeps the UI a plain keyed list: ```ts const App: DomphyElement<"div"> = { div: [ { ul: (l) => items.get(l).map((item) => ({ li: item.title, _key: item.id, })), }, { button: (l) => (loadingMore.get(l) ? "Loading..." : "Load more"), $: [button()], hidden: (l) => !hasMore.get(l), onClick: () => observer.fetchNextPage(), }, ], } ``` ## Page Parameters - `initialPageParam` — the param for the first page (required) - `getNextPageParam(lastPage, allPages, lastPageParam, allPageParams)` — returns the next param, or `null`/`undefined` when there are no more pages - `getPreviousPageParam` — same, for bidirectional lists (`fetchPreviousPage()`) ## Result Additions On top of the normal query result: - `data.pages`, `data.pageParams` - `hasNextPage`, `hasPreviousPage` - `fetchNextPage()`, `fetchPreviousPage()` - `isFetchingNextPage`, `isFetchingPreviousPage` ## Refetching Behavior When an infinite query refetches (invalidation, window focus), every fetched page is refetched **sequentially** from the first, so cursors stay consistent. Use `maxPages` to cap how many pages stay in cache: ```ts new InfiniteQueryObserver(queryClient, { queryKey: ["feed"], queryFn: fetchPage, initialPageParam: 0, getNextPageParam: (lastPage) => lastPage.nextCursor, maxPages: 5, // keeps memory and refetch cost bounded }) ``` ### mutations.md # Mutations Mutations are for writes — create, update, delete. Unlike queries they run on demand via `mutate()`, are never cached by key, and never refetch on their own. ## Live Example ## Basic Mutation ```ts import { MutationObserver } from "@domphy/query" import { toState } from "@domphy/core" const saving = toState(false) const mutation = new MutationObserver(queryClient, { mutationFn: (todo: { title: string }) => fetch("/api/todos", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(todo), }).then((res) => res.json()), onSuccess: () => queryClient.invalidateQueries({ queryKey: ["todos"] }), }) mutation.subscribe((result) => saving.set(result.isPending)) ``` Trigger it from an event handler: ```ts { button: (l) => (saving.get(l) ? "Saving..." : "Save"), $: [button({ color: "primary" })], ariaDisabled: (l) => saving.get(l), onClick: () => mutation.mutate({ title: "New todo" }).catch(() => undefined), } ``` `mutate()` returns a promise that rejects on failure — handle it (or read `result.error` from the subscription instead). ## Lifecycle Callbacks ```ts new MutationObserver(queryClient, { mutationFn: updateTodo, onMutate: (variables) => { // runs before mutationFn; the return value becomes `context` return { startedAt: performance.now() } }, onSuccess: (data, variables, context) => {}, onError: (error, variables, context) => {}, onSettled: (data, error, variables, context) => { // success or error — the usual place to invalidate queryClient.invalidateQueries({ queryKey: ["todos"] }) }, }) ``` ## Optimistic Updates Update the cache immediately in `onMutate`, roll back from `context` on error: ```ts new MutationObserver(queryClient, { mutationFn: updateTodo, onMutate: async (newTodo) => { // stop in-flight refetches from overwriting the optimistic value await queryClient.cancelQueries({ queryKey: ["todos"] }) const previous = queryClient.getQueryData(["todos"]) queryClient.setQueryData(["todos"], (old) => (old ?? []).map((todo) => (todo.id === newTodo.id ? newTodo : todo)), ) return { previous } }, onError: (error, newTodo, context) => { queryClient.setQueryData(["todos"], context?.previous) }, onSettled: () => { queryClient.invalidateQueries({ queryKey: ["todos"] }) }, }) ``` Because the cache update notifies every `QueryObserver` on `["todos"]`, the optimistic value flows through the normal bridge into `toState` — the UI updates instantly with no extra wiring. ## Mutation Result The subscription result mirrors queries: - `status`: `"idle" | "pending" | "error" | "success"` - `isIdle`, `isPending`, `isError`, `isSuccess` - `data`, `error`, `variables`, `failureCount` - `mutate(variables)`, `reset()` ## Retry Mutations do not retry by default (writes are not safely repeatable in general). Opt in per mutation: ```ts new MutationObserver(queryClient, { mutationFn: sendTelemetry, retry: 3, retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30_000), }) ``` ### queries.md # Queries A query is a declarative dependency on async data, identified by a `queryKey`. `QueryObserver` runs the `queryFn`, caches the result, deduplicates concurrent requests for the same key, and notifies subscribers on every state change. ## Basic Query ```ts import { QueryClient, QueryObserver } from "@domphy/query" const queryClient = new QueryClient() queryClient.mount() const observer = new QueryObserver(queryClient, { queryKey: ["todos"], queryFn: () => fetch("/api/todos").then((res) => res.json()), }) const unsubscribe = observer.subscribe((result) => { console.log(result.status, result.data) }) ``` `subscribe` returns an unsubscribe function. When the last subscriber leaves, the query becomes inactive and is garbage-collected after `gcTime`. ## A Reusable Query State Helper Most Domphy apps wrap the observer bridge once and reuse it. Note: this manual pattern is an alternative to the adapter's `createQuery` from `@domphy/query/domphy` (which takes a `QueryClient` + options directly and is the recommended approach for most use cases). ```ts import { QueryClient, QueryObserver } from "@domphy/query" import { toState } from "@domphy/core" const queryClient = new QueryClient() queryClient.mount() function makeQueryStates( client: QueryClient, options: { queryKey: unknown[]; queryFn: () => Promise }, ) { const data = toState(undefined) const loading = toState(true) const fetching = toState(false) const error = toState(null) const observer = new QueryObserver(client, options) observer.subscribe((result) => { data.set(result.data) loading.set(result.isPending) fetching.set(result.isFetching) error.set(result.error as Error | null) }) return { data, loading, fetching, error, observer } } const todos = makeQueryStates(queryClient, { queryKey: ["todos"], queryFn: () => fetch("/api/todos").then((res) => res.json()), }) ``` Each `toState` is independent — the UI re-renders only the parts that read the changed state. ## Query Keys Keys are arrays, hashed structurally: ```ts ["todos"] // all todos ["todos", 5] // one todo ["todos", { status: "done" }] // filtered list — object key order does not matter ``` Anything serializable works. The key should contain every variable the `queryFn` depends on — changing the key creates a new cache entry and triggers a fetch. ## Dynamic Keys Change the key with `setOptions` — the observer switches to the new cache entry: ```ts const page = toState(1) page.addListener(() => { observer.setOptions({ queryKey: ["todos", { page: page.get() }], queryFn: () => fetch(`/api/todos?page=${page.get()}`).then((res) => res.json()), placeholderData: keepPreviousData, }) }) ``` With `placeholderData: keepPreviousData`, the previous page's data stays visible while the next page loads (`result.isPlaceholderData` is `true` during that window). ## Important Options | Option | Default | Meaning | |---|---|---| | `staleTime` | `0` | How long data is considered fresh. Fresh data is served from cache without refetching. | | `gcTime` | `5 * 60 * 1000` | How long inactive data stays in cache before garbage collection. | | `enabled` | `true` | Set `false` to prevent fetching (dependent queries). | | `retry` | `3` | Retry count on failure (`false` to disable, or a function). | | `refetchOnWindowFocus` | `true` | Refetch stale queries when the window regains focus. | | `refetchOnReconnect` | `true` | Refetch stale queries when the network reconnects. | | `refetchInterval` | `false` | Poll on an interval (ms). | | `select` | — | Transform/derive data without touching the cache. | | `placeholderData` | — | Data shown while pending; `keepPreviousData` keeps the last result across key changes. | | `initialData` | — | Seed the cache entry itself. | ## Disabling With `skipToken` For type-safe conditional fetching, pass `skipToken` as the `queryFn`: ```ts import { skipToken } from "@domphy/query" const userId = toState(null) observer.setOptions({ queryKey: ["user", userId.get()], queryFn: userId.get() === null ? skipToken : () => fetch(`/api/users/${userId.get()}`).then((res) => res.json()), }) ``` ## The Result Object Every notification carries a full snapshot: - `data`, `error` - `status`: `"pending" | "error" | "success"` - `fetchStatus`: `"fetching" | "paused" | "idle"` - `isPending`, `isSuccess`, `isError` — derived from `status` - `isFetching` — any fetch in flight, including background refetch - `isLoading` — first fetch only (`isPending && isFetching`) - `isStale`, `isPlaceholderData`, `isRefetching` - `dataUpdatedAt`, `errorUpdatedAt`, `failureCount` - `refetch()` — imperative refetch Typical mapping: drive a `spinner()` from `isPending`, a subtle inline indicator from `isFetching`, and an `alert({ color: "error" })` from `error`. ## Multiple Queries At Once `QueriesObserver` subscribes to a dynamic list of queries in one subscription: ```ts import { QueriesObserver } from "@domphy/query" const observer = new QueriesObserver(queryClient, [ { queryKey: ["user", 1], queryFn: fetchUser(1) }, { queryKey: ["user", 2], queryFn: fetchUser(2) }, ]) observer.subscribe((results) => { // results is an array of result objects, same order as options }) ``` ### ssr.md # SSR & Hydration Queries fit Domphy's SSR model directly: fetch on the server, serialize the cache, render HTML with the data already in state, then hydrate the cache on the client so the first `QueryObserver` subscription finds fresh data instead of refetching. ## Server Prefetch into a request-scoped `QueryClient`, then dehydrate: ```ts import { QueryClient, dehydrate } from "@domphy/query" import { ElementNode } from "@domphy/core" async function renderPage() { const queryClient = new QueryClient() await queryClient.prefetchQuery({ queryKey: ["users"], queryFn: fetchUsers, }) const dehydratedState = dehydrate(queryClient) // Seed the bridge states from the cache so generateHTML sees the data users.set(queryClient.getQueryData(["users"]) ?? []) loading.set(false) const node = new ElementNode(App) const html = node.generateHTML() const css = node.generateCSS() queryClient.clear() // request-scoped — do not share between requests return `
${html}
` } ``` Create one `QueryClient` **per request**. A module-level client on the server would leak data between users. ## Client Hydrate before mounting: ```ts import { QueryClient, hydrate, QueryObserver } from "@domphy/query" import { ElementNode } from "@domphy/core" const queryClient = new QueryClient() hydrate(queryClient, window.__QUERY_STATE__) queryClient.mount() const observer = new QueryObserver(queryClient, { queryKey: ["users"], queryFn: fetchUsers, staleTime: 60_000, // treat server data as fresh — no immediate refetch }) observer.subscribe((result) => { users.set(result.data ?? []) loading.set(result.isPending) }) const domStyle = document.getElementById("domphy-style") as HTMLStyleElement new ElementNode(App).mount(document.getElementById("app")!, domStyle) ``` Because the cache already holds the server data, the observer's first result is `success` with data — no flash, no duplicate request. Set `staleTime` high enough that the just-fetched server data is not immediately refetched. ## What Gets Dehydrated By default `dehydrate` includes successful and pending queries, and skips mutations. Both are configurable: ```ts dehydrate(queryClient, { shouldDehydrateQuery: (query) => query.state.status === "success", shouldDehydrateMutation: () => false, }) ``` The dehydrated state is plain JSON-serializable data (`DehydratedState`) — embed it in HTML, send it over the wire, or store it. --- ## Router docs (`@domphy/router`) ### api.md # API Reference `@domphy/router` is a 1-1 port of `@tanstack/router-core` v1.171.13 — every export below has identical behavior to upstream, so the [TanStack Router reference](https://tanstack.com/router/latest) documents each item in full detail. The only additions are the `create*` adapter functions and the re-exported `@tanstack/history`. ## Creating Routers And Routes | Export | Purpose | |---|---| | `createRouter(options)` | Create the router. Key options: `routeTree`, `history`, `context`, `defaultStaleTime`, `defaultPreloadStaleTime`, `defaultGcTime`, `parseSearch` / `stringifySearch`, `scrollRestoration` | | `createRoute(options)` | Create a route: `getParentRoute`, `path` (or `id` for pathless layouts), `validateSearch`, `beforeLoad`, `loader`, `loaderDeps`, `staleTime`, `gcTime`, `shouldReload` | | `createRootRoute(options?)` | Create the root of the tree | | `createRootRouteWithContext()` | Root route factory with a required typed router context | | `rootRouteWithContext()` | Alias for `createRootRouteWithContext` | | `createRouteMask(options)` | Create a route mask (renders a different route at a given URL) | | `getRouteApi(id)` | Get a type-safe route API handle by route id | | `NotFoundRoute` | A pre-built route that renders a not-found boundary at any unmatched path | | `RouterCore`, `BaseRoute`, `BaseRootRoute`, `BaseRouteApi` | The underlying classes (advanced — the `create*` functions wrap them) | | `getStoreFactory` | Low-level store factory used by the router internals | ## Router Instance The main members of the router returned by `createRouter`: - `state` — `RouterState`: `matches`, `location`, `resolvedLocation`, `status` (`"pending" | "idle"`), `isLoading`, `statusCode`, `redirect` - `navigate(options)` — navigate; resolves when loaders settle - `buildLocation(options)` — resolve navigate options to a `ParsedLocation` without navigating - `load()` — match and load the current location (call once at startup, and on the server) - `subscribe(event, fn)` — lifecycle events: `onBeforeNavigate`, `onBeforeLoad`, `onLoad`, `onResolved`, `onBeforeRouteMount`, `onRendered` - `invalidate(options?)` — mark cached loader data stale and re-run active loaders - `preloadRoute(options)` — run matching + loaders for a destination ahead of navigation - `matchRoute(location, options?)` — test a location against the tree (`{ fuzzy, includeSearch }`) - `getMatch(matchId)` / `clearCache(options?)` — cache access (advanced) - `history` — the underlying `RouterHistory` (`back`, `forward`, `go`, `block`) - `update(options)` — update router options after creation ## History Re-exported from `@tanstack/history`: - `createBrowserHistory()`, `createHashHistory()`, `createMemoryHistory({ initialEntries })`, `createHistory()` - `parseHref(href, state)` - types: `RouterHistory`, `HistoryLocation`, `ParsedHistoryState`, `NavigationBlocker`, `BlockerFn` ## Control Flow - `redirect(options)` / `isRedirect` / `isResolvedRedirect` / `parseRedirect` — throwable redirect Responses for `beforeLoad` and loaders - `notFound(options?)` / `isNotFound` — throwable not-found errors - `defer(promise)` / `TSR_DEFERRED_PROMISE` — deferred loader data with synchronous status ## Search Params - `retainSearchParams(keys | true)` / `stripSearchParams(input)` — search middlewares - `defaultParseSearch` / `defaultStringifySearch` — the JSON-aware default codec - `parseSearchWith(parser)` / `stringifySearchWith(stringifier)` — build custom codecs - `SearchParamError`, `PathParamError` — validation error classes ## Scroll Restoration - `setupScrollRestoration(router)` — wire scroll save/restore (client) - `getScrollRestorationScriptForRouter(router)` — inline pre-hydration script, from `@domphy/router/scroll-restoration-script` - `defaultGetScrollRestorationKey`, `storageKey` ## Path Utilities - `interpolatePath`, `resolvePath`, `joinPaths`, `cleanPath`, `trimPath` / `trimPathLeft` / `trimPathRight`, `removeTrailingSlash`, `exactPathTest` - `encode` / `decode` — query-string primitives (`qss`) - `rootRouteId` — the id of the root route (`"__root__"`) ## Utilities - `functionalUpdate`, `replaceEqualDeep`, `deepEqual`, `isPlainObject`, `isPlainArray`, `hasKeys` - `createControlledPromise`, `isModuleNotFoundError`, `escapeHtml`, `isDangerousProtocol` - `defaultSerializeError`, `getLocationChangeInfo`, `lazyFn`, `isMatch` ## SSR Entry Points | Entry | Main exports | |---|---| | `@domphy/router/ssr/server` | `createRequestHandler`, `attachRouterServerSsrUtils`, `transformStreamWithRouter`, `transformReadableStreamWithRouter`, `transformPipeableStreamWithRouter`, `createSsrStreamResponse`, `defineHandlerCallback`, `getOrigin`, `getNormalizedURL`; types: `RequestHandler` | | `@domphy/router/ssr/client` | `hydrate`, `json`, `mergeHeaders`; types: `DehydratedRouter`, `DehydratedMatch` | | `@domphy/router/isServer` | `isServer` — `boolean | undefined`, resolved per build condition; `undefined` in development/test, `true` on server runtimes, `false` in browsers | See [SSR](./ssr) for how these fit Domphy. ## Types All public types are re-exported, including: - routes: `AnyRoute`, `Route`, `RouteOptions`, `RootRoute`, `RouteIds`, `RouteById`, `RoutePaths` - router: `AnyRouter`, `RouterOptions`, `RouterState`, `RouterEvents`, `Register`, `RegisteredRouter` - matches: `AnyRouteMatch`, `RouteMatch`, `MakeRouteMatch` - navigation: `NavigateOptions`, `ToOptions`, `LinkOptions`, `ParsedLocation`, `ViewTransitionOptions` - loading: `LoaderFnContext`, `RouteLoaderFn`, `DeferredPromise` - search: `SearchMiddleware`, `SearchValidator`, `SearchParser`, `SearchSerializer` - control flow: `Redirect`, `RedirectOptions`, `NotFoundError` - ssr: `Manifest` ### data-loading.md # Data Loading Every route can declare a `loader`. The router runs loaders for all matched routes in parallel during navigation, caches the results with a stale-while-revalidate model, and exposes them on each match as `loaderData`. ## Loaders ```ts const postRoute = createRoute({ getParentRoute: () => rootRoute, path: "/posts/$postId", loader: ({ params }) => fetchPost(params.postId), }) ``` The loader context provides everything a fetch needs: | Field | Meaning | |---|---| | `params` | Typed path params | | `deps` | The result of `loaderDeps` (see below) | | `context` | Router + route context | | `location` | The destination `ParsedLocation` | | `abortController` | Aborted when the navigation is superseded — pass `.signal` to `fetch` | | `preload` | `true` when running as a preload | | `cause` | `"enter"`, `"stay"`, or `"preload"` | | `route` | The route instance | In the UI, read the result from the match — and drive pending UI from `match.status`: ```ts const match = matches.get(l).find((m) => m.routeId === postRoute.id) if (!match || match.status === "pending") return [{ p: "Loading..." }] if (match.status === "error") return [{ p: `Failed: ${match.error}` }] return [{ h1: match.loaderData.title }] ``` ## `loaderDeps` Loaders are cached per path — search params are deliberately not part of the key unless you opt them in. `loaderDeps` picks which search params (or anything else) the loader depends on; changing deps re-runs the loader and creates a separate cache entry: ```ts const postsRoute = createRoute({ getParentRoute: () => rootRoute, path: "/posts", validateSearch: (search: Record) => ({ page: Number(search.page ?? 1), }), loaderDeps: ({ search }) => ({ page: search.page }), loader: ({ deps, abortController }) => fetch(`/api/posts?page=${deps.page}`, { signal: abortController.signal }) .then((response) => response.json()), }) ``` ## Caching: `staleTime` And Preloading Loader results follow the same two-timer model as [`@domphy/query`](../query/caching): - **`staleTime`** (default `0`) — how long loader data is fresh. Navigating back to a route with fresh data uses the cache and skips the loader entirely; stale data is shown immediately while the loader re-runs. - **`gcTime`** (default 30 minutes) — how long unmatched route data stays cached. Set them per route or router-wide: ```ts const router = createRouter({ routeTree, history, defaultStaleTime: 10_000, defaultPreloadStaleTime: 30_000, }) const postRoute = createRoute({ // ... staleTime: 60_000, shouldReload: false, // never reload after first successful load (until invalidated) }) ``` Preload a route before the user commits — the classic hover pattern: ```ts const link = (to: string, label: string): DomphyElement<"a"> => ({ a: label, href: router.buildLocation({ to }).href, onClick: (e) => { e.preventDefault() router.navigate({ to }) }, onMouseEnter: () => router.preloadRoute({ to }), }) ``` Preloaded data lands in the same cache, so the subsequent navigation is instant. After a mutation, mark everything stale and re-run active loaders: ```ts await router.invalidate() ``` ## `redirect()` Throw a redirect from `beforeLoad` or `loader` to send the user elsewhere — the canonical auth guard: ```ts import { redirect } from "@domphy/router" const dashboardRoute = createRoute({ getParentRoute: () => rootRoute, path: "/dashboard", beforeLoad: ({ context, location }) => { if (!context.user) { throw redirect({ to: "/login", search: { redirect: location.href }, }) } }, }) ``` `redirect` accepts all navigate options plus `href` (absolute URLs trigger a full document navigation) and `statusCode` (default `307`, used by SSR). `beforeLoad` runs root → leaf *before* loaders, making it the right place for guards. ## `notFound()` Throw from a loader when the data does not exist: ```ts import { notFound } from "@domphy/router" loader: async ({ params }) => { const post = await fetchPost(params.postId) if (!post) throw notFound() return post }, ``` The match ends with `status: "notFound"` — render your 404 UI from that: ```ts if (match.status === "notFound") return [{ h1: "Post not found" }] ``` ## Deferred Data Return critical data immediately and let slow data stream in afterwards. Wrap the slow promise with `defer()` and return it *unawaited* — the navigation resolves as soon as the fast data is ready: ```ts import { defer } from "@domphy/router" const postRoute = createRoute({ getParentRoute: () => rootRoute, path: "/posts/$postId", loader: async ({ params }) => ({ post: await fetchPost(params.postId), // awaited — blocks navigation comments: defer(fetchComments(params.postId)), // not awaited — streams in }), }) ``` There is no `` component in Domphy — async is a state problem, so bridge the deferred promise into a state: ```ts const comments = toState | null>(null) router.subscribe("onResolved", () => { const match = router.state.matches.find((m) => m.routeId === postRoute.id) comments.set(null) match?.loaderData?.comments.then((data: Array) => comments.set(data)) }) ``` ```ts const CommentList: DomphyElement<"section"> = { section: (l) => { const list = comments.get(l) if (!list) return [{ p: "Loading comments..." }] return list.map((comment) => ({ p: comment.text, _key: comment.id })) }, } ``` ### index.md # Router `@domphy/router` provides type-safe routing for Domphy apps: nested route trees, path params, validated search params, loaders with caching, redirects, navigation blocking, scroll restoration, and SSR. It is a **1-1 port of [`@tanstack/router-core`](https://github.com/TanStack/router/tree/main/packages/router-core) v1.171.13** (MIT, © Tanner Linsley and the TanStack team). The source is kept byte-identical to upstream, so the entire [TanStack Router reference](https://tanstack.com/router/latest) applies as-is, and future upstream versions can be diffed and merged directly. A thin adapter adds `createRouter`, `createRoute`, `createRootRoute`, and `createRootRouteWithContext`, and the `@tanstack/history` layer is re-exported so no separate install is needed. Like the rest of Domphy, it is framework-agnostic — the bridge to the UI is plain `toState`. ## Install ```bash npm install @domphy/router ``` ## Live Examples Basic navigation: Route loaders: ## Core Concepts - **Route tree** — built from `createRootRoute()` and `createRoute()`, composed with `addChildren`. Every route knows its parent via `getParentRoute`, which is what makes params, search, and loader data fully typed. - **Router** — `createRouter({ routeTree, history })` owns matching, navigation, loading, and caching. `router.state` is the single source of truth. - **Matches** — `router.state.matches` is the array of matched routes for the current location, ordered root → leaf. Each match carries `params`, `search`, `loaderData`, and `status`. - **History** — `createBrowserHistory()`, `createHashHistory()`, or `createMemoryHistory()` decide how locations map to the URL (or to memory, for tests and SSR). ## The Bridge Pattern Domphy has no router primitive by design — routing is a state problem, and state lives outside the UI. The router manages location and match state; `toState` pushes it into the UI: ```ts import { type DomphyElement, toState } from "@domphy/core" import { createRouter, createRoute, createRootRoute, createMemoryHistory, type AnyRouteMatch, } from "@domphy/router" const rootRoute = createRootRoute() const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: "/" }) const postRoute = createRoute({ getParentRoute: () => rootRoute, path: "/posts/$postId", loader: ({ params }) => fetchPost(params.postId), }) const routeTree = rootRoute.addChildren([indexRoute, postRoute]) const router = createRouter({ routeTree, history: createMemoryHistory({ initialEntries: ["/"] }) }) // Bridge: router state -> Domphy states const matches = toState>([]) const pathname = toState("/") function syncRouterState() { matches.set(router.state.matches) pathname.set(router.state.location.pathname) } router.subscribe("onResolved", syncRouterState) await router.load() syncRouterState() ``` The UI reads the states reactively — nothing router-specific leaks into elements: ```ts const App: DomphyElement<"main"> = { main: (l) => { const match = matches.get(l).find((m) => m.routeId === postRoute.id) if (!match) return [{ p: "Welcome" }] return [{ h1: match.loaderData.title }, { p: match.loaderData.body }] }, } ``` ## Links Render real `` elements with real hrefs, but intercept the click so navigation stays client-side: ```ts const link = (to: string, label: string): DomphyElement<"a"> => ({ a: label, href: router.buildLocation({ to }).href, onClick: (e) => { e.preventDefault() router.navigate({ to }) }, }) ``` See [Navigation](./navigation) for active links, history types, and blocking. ## What To Read Next 1. [Route Trees](./routes) for `createRoute`, path params, wildcards, and nested layouts 2. [Navigation](./navigation) for `navigate`, `buildLocation`, link patterns, and blocking 3. [Search Params](./search-params) for `validateSearch` and search middleware 4. [Data Loading](./data-loading) for loaders, caching, `redirect()`, and `notFound()` 5. [SSR](./ssr) for the server and client SSR entries 6. [API Reference](./api) for the full export list ### navigation.md # Navigation All navigation goes through the router — it matches the destination, runs loaders, and commits the new location to history. The UI never touches `window.location`. ## `router.navigate` ```ts await router.navigate({ to: "/posts" }) await router.navigate({ to: "/posts/$postId", params: { postId: "42" } }) await router.navigate({ to: "/posts", search: { page: 2 } }) await router.navigate({ to: "/login", replace: true }) // no history entry await router.navigate({ to: ".", search: (prev) => ({ ...prev, page: 2 }) }) // stay, update search ``` Common options: | Option | Meaning | |---|---| | `to` | Destination path. Param segments stay literal (`"/posts/$postId"`); values go in `params`. | | `params` | Path param values, or an updater `(prev) => next`. | | `search` | Search params object, updater function, or `true` to keep current. | | `hash` | Hash string or updater. | | `state` | Custom history state (survives back/forward). | | `from` | Resolve `to` relative to this path — enables `to: ".."` and `to: "./details"`. | | `replace` | Replace instead of push. | | `reloadDocument` | Full document navigation instead of client-side. | | `ignoreBlocker` | Skip navigation blockers. | `navigate` returns a promise that resolves when the navigation (including loaders) settles. ## Building Hrefs `router.buildLocation` resolves any navigate options into a `ParsedLocation` without navigating — the way to get real `href`s for `` elements: ```ts const location = router.buildLocation({ to: "/posts/$postId", params: { postId: "42" } }) location.href // "/posts/42" location.pathname // "/posts/42" ``` ## The Domphy Link Pattern A real anchor with a real href, click intercepted for client-side navigation: ```ts import type { DomphyElement } from "@domphy/core" const link = (to: string, label: string): DomphyElement<"a"> => ({ a: label, href: router.buildLocation({ to }).href, onClick: (e) => { e.preventDefault() router.navigate({ to }) }, }) ``` This keeps middle-click, copy-link, and crawlers working, because the `href` is genuine. ## Active Links Bridge the current pathname into a state, then mark the active link with a data attribute and style it via a nested selector: ```ts import { toState } from "@domphy/core" const pathname = toState(router.state.location.pathname) router.subscribe("onResolved", () => pathname.set(router.state.location.pathname)) const navLink = (to: string, label: string): DomphyElement<"a"> => ({ a: label, href: router.buildLocation({ to }).href, dataActive: (l) => (pathname.get(l) === to ? "true" : "false"), onClick: (e) => { e.preventDefault() router.navigate({ to }) }, style: { '&[data-active="true"]': { textDecoration: "underline" }, }, }) ``` For prefix matching (e.g. `/posts` active on `/posts/42`), use `pathname.get(l).startsWith(to)`, or ask the router: `router.matchRoute({ to }, { fuzzy: true })`. ## History Types The history decides how locations map to the address bar. All three are re-exported from `@tanstack/history`: ```ts import { createBrowserHistory, createHashHistory, createMemoryHistory } from "@domphy/router" createBrowserHistory() // normal URLs — needs server rewrites to index.html createHashHistory() // /#/posts/42 — static hosts, no server config createMemoryHistory({ initialEntries: ["/posts/42"] }) // no URL at all — SSR, tests, embedded demos ``` The history object is also the imperative back/forward API: ```ts router.history.back() router.history.forward() router.history.go(-2) ``` ## Blocking Navigation Block navigation away from unsaved work with `history.block`. The blocker function decides per navigation; `enableBeforeUnload` extends the guard to tab close: ```ts const unblock = router.history.block({ blockerFn: async ({ nextLocation }) => { if (!formIsDirty.get()) return true return window.confirm(`Discard changes and go to ${nextLocation.pathname}?`) }, enableBeforeUnload: () => formIsDirty.get(), }) // later, when the form is saved unblock() ``` A navigation called with `ignoreBlocker: true` bypasses blockers. ## Router Events `router.subscribe` covers the full navigation lifecycle — each returns an unsubscribe function: | Event | When | |---|---| | `onBeforeNavigate` | Navigation accepted, before anything loads | | `onBeforeLoad` | Location committed, loaders about to run | | `onLoad` | Loaders running for the new matches | | `onResolved` | Navigation fully settled — **the one to bridge UI state from** | Each event carries `fromLocation`, `toLocation`, and `pathChanged` / `hrefChanged` flags. Two more events exist for framework adapters (`onBeforeRouteMount`, `onRendered`) — plain Domphy apps rarely need them. ### routes.md # Route Trees A route tree is built from plain function calls — no file conventions, no JSX. Every route declares its parent with `getParentRoute`, and the tree is assembled once with `addChildren`. This is what gives params, search params, context, and loader data their types. ## Building A Tree ```ts import { createRootRoute, createRoute } from "@domphy/router" const rootRoute = createRootRoute() const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: "/", }) const postsRoute = createRoute({ getParentRoute: () => rootRoute, path: "/posts", }) const postRoute = createRoute({ getParentRoute: () => postsRoute, path: "$postId", }) const routeTree = rootRoute.addChildren([ indexRoute, postsRoute.addChildren([postRoute]), ]) ``` Child paths compose: `postRoute` matches `/posts/$postId`. Leading and trailing slashes are normalized, so `path: "$postId"` and `path: "/$postId"` are equivalent. Every route gets a stable `id` (its full path, e.g. `"/posts/$postId"`) and a `fullPath`. The `id` is how you find a route's match in `router.state.matches`: ```ts const match = router.state.matches.find((m) => m.routeId === postRoute.id) ``` ## Path Params Segments starting with `$` are params. They are available, typed, on the match and in every loader: ```ts const postRoute = createRoute({ getParentRoute: () => postsRoute, path: "$postId", loader: ({ params }) => fetchPost(params.postId), // params.postId: string }) router.navigate({ to: "/posts/$postId", params: { postId: "42" } }) ``` Params from parent routes are inherited — a route at `/users/$userId/posts/$postId` sees both `userId` and `postId`. Optional params use braces with a dash, and prefix/suffix segments are supported: ```ts path: "/posts/{-$category}" // matches /posts and /posts/news path: "/files/prefix{$name}.txt" // matches /files/prefixreport.txt ``` ## Wildcards A trailing `$` matches everything after it. The remainder is exposed as `params._splat`: ```ts const fileRoute = createRoute({ getParentRoute: () => rootRoute, path: "/files/$", loader: ({ params }) => readFile(params._splat), // "docs/readme.md" for /files/docs/readme.md }) router.navigate({ to: "/files/$", params: { _splat: "docs/readme.md" } }) ``` ## Layout Routes A route with an `id` instead of a `path` is *pathless*: it adds no URL segment but still participates in matching — useful for shared layouts, shared `beforeLoad` guards, or shared context: ```ts const authLayout = createRoute({ getParentRoute: () => rootRoute, id: "auth", beforeLoad: () => { if (!isLoggedIn()) throw redirect({ to: "/login" }) }, }) const dashboardRoute = createRoute({ getParentRoute: () => authLayout, path: "/dashboard", }) const routeTree = rootRoute.addChildren([ authLayout.addChildren([dashboardRoute]), ]) ``` `/dashboard` now matches three routes: root → `auth` → dashboard. ## Rendering Nested Matches `router.state.matches` is ordered root → leaf, which maps directly to nested layouts. The Domphy pattern is a render map keyed by route id — each entry renders its own chrome and recurses into the rest of the matches: ```ts import { type DomphyElement, toState } from "@domphy/core" import type { AnyRouteMatch } from "@domphy/router" const matches = toState>([]) router.subscribe("onResolved", () => matches.set(router.state.matches)) const renderers: Record) => DomphyElement> = { [postsRoute.id]: (match, rest) => ({ section: [{ h2: "Posts" }, ...rest.map((m) => renderMatch(m, rest.slice(1)))], }), [postRoute.id]: (match) => ({ article: match.status === "pending" ? "Loading..." : match.loaderData.title, }), } function renderMatch(match: AnyRouteMatch, rest: Array): DomphyElement { const render = renderers[match.routeId] return render ? render(match, rest) : { div: rest.map((m) => renderMatch(m, rest.slice(1))) } } const App: DomphyElement<"main"> = { main: (l) => { const [, ...rest] = matches.get(l) // skip the root match return rest.length ? [renderMatch(rest[0], rest.slice(1))] : [] }, } ``` For flat apps without shared layouts, finding the leaf match by `routeId` (as in the [Overview](./)) is all you need. ## Route Context The root route can require a typed context, provided once at router creation and merged down the tree by `beforeLoad`: ```ts import { createRootRouteWithContext, createRouter } from "@domphy/router" type RouterContext = { user: User | null } const rootRoute = createRootRouteWithContext()() const router = createRouter({ routeTree, history, context: { user: null }, }) ``` Every loader and `beforeLoad` then receives `context` — the standard place to pass API clients or session data without globals. Call `router.update({ context: newContext })` when context values change at runtime (e.g. after login), then navigate to re-run `beforeLoad` guards. ### search-params.md # Search Params The router treats the query string as structured, validated, typed state — not a bag of strings. Search params are parsed (with JSON support for nested values), validated per route, and flow into matches and loaders fully typed. ## `validateSearch` Declare a route's search schema with `validateSearch`. The raw parsed search comes in, your typed schema comes out: ```ts type PostsSearch = { page: number filter: string } const postsRoute = createRoute({ getParentRoute: () => rootRoute, path: "/posts", validateSearch: (search: Record): PostsSearch => ({ page: Number(search.page ?? 1), filter: (search.filter as string) ?? "", }), }) ``` Any [Standard Schema](https://github.com/standard-schema/standard-schema) validator (Zod, Valibot, ArkType...) can be passed directly instead of a function: ```ts validateSearch: z.object({ page: z.number().catch(1), filter: z.string().catch(""), }) ``` If validation throws, the match errors with a `SearchParamError` — surface it from `match.status === "error"` / `match.error`. ## Reading Search The validated result lives on the match (parent schemas are merged in): ```ts const match = matches.get(l).find((m) => m.routeId === postsRoute.id) match?.search.page // number — typed by validateSearch ``` And in loaders — but go through `loaderDeps`, not `search` directly (see [Data Loading](./data-loading)): ```ts loaderDeps: ({ search }) => ({ page: search.page }), loader: ({ deps }) => fetchPosts(deps.page), ``` ## Navigating With Search `search` accepts an object, `true` to keep the current params, or an updater function — the functional form is the idiomatic one for "change one param, keep the rest": ```ts router.navigate({ to: "/posts", search: { page: 2, filter: "" } }) // update relative to the current search router.navigate({ to: ".", search: (prev) => ({ ...prev, page: prev.page + 1 }), }) // different route, keep whatever search is currently in the URL router.navigate({ to: "/posts", search: true }) ``` The same `search` option works in `buildLocation` for hrefs and in `redirect()`. ## Search Middleware Middlewares run on every *link build and navigation* for a route, transforming the outgoing search before it hits the URL. They are declared on the route's `search.middlewares`: ```ts import { retainSearchParams, stripSearchParams } from "@domphy/router" ``` ### `retainSearchParams` Keeps search params alive across navigations that would otherwise drop them — for app-wide params like feature flags or an active workspace: ```ts const rootRoute = createRootRoute({ validateSearch: (search: Record) => ({ workspace: (search.workspace as string) ?? undefined, }), search: { middlewares: [retainSearchParams(["workspace"])], }, }) ``` `retainSearchParams(true)` retains everything currently in the URL. ### `stripSearchParams` Removes noise from URLs — params equal to their defaults disappear: ```ts const defaults = { page: 1, filter: "" } const postsRoute = createRoute({ getParentRoute: () => rootRoute, path: "/posts", validateSearch: (search: Record) => ({ page: Number(search.page ?? defaults.page), filter: (search.filter as string) ?? defaults.filter, }), search: { middlewares: [stripSearchParams(defaults)], }, }) ``` `/posts?page=1&filter=` becomes `/posts`. Variants: pass an array of keys to always strip them, or `true` to strip all (only when no search params are required). ## Custom Serialization By default search is serialized with `defaultStringifySearch` (JSON-aware: `?ids=[1,2]` round-trips as an array). Swap the codec router-wide: ```ts import { createRouter, parseSearchWith, stringifySearchWith } from "@domphy/router" const router = createRouter({ routeTree, history, parseSearch: parseSearchWith(JSON.parse), stringifySearch: stringifySearchWith(JSON.stringify), }) ``` ### ssr.md # SSR The router is isomorphic: route matching, loaders, and redirects run identically on the server. The SSR layer is ported 1-1 from upstream and works at the router level — but the upstream streaming pipeline is designed around framework render integrations, so **wiring it to Domphy is manual**. This page shows the honest, minimal path. ## Entry Points ```ts import { createRequestHandler, attachRouterServerSsrUtils } from "@domphy/router/ssr/server" import { hydrate, json, mergeHeaders } from "@domphy/router/ssr/client" ``` - `@domphy/router/ssr/server` — `createRequestHandler`, `attachRouterServerSsrUtils` (dehydration + streaming utilities), `transformStreamWithRouter` and friends - `@domphy/router/ssr/client` — `hydrate` for restoring dehydrated router state, plus `json` / `mergeHeaders` helpers ## The Minimal Pattern The simplest reliable approach skips the streaming pipeline: run the router per request, render with Domphy SSR, and serialize what you need yourself. Server — one router per request, memory history at the request URL: ```ts import { createRouter, createMemoryHistory } from "@domphy/router" import { ElementNode } from "@domphy/core" async function renderPage(requestUrl: string) { const router = createRouter({ routeTree, history: createMemoryHistory({ initialEntries: [requestUrl] }), }) await router.load() // matches + loaders, server-side if (router.state.redirect) { return { redirect: router.state.redirect.options.href, status: 307 } } syncRouterState() // seed the bridge states so generateHTML sees the data const node = new ElementNode(App) const html = node.generateHTML() const css = node.generateCSS() const loaderData = router.state.matches.map((match) => ({ id: match.id, loaderData: match.loaderData, })) return { status: router.state.statusCode, // 200, or 404 when a match was notFound body: `
${html}
`, } } ``` Client — same route tree, browser history, mount instead of render: ```ts import { createRouter, createBrowserHistory } from "@domphy/router" import { ElementNode } from "@domphy/core" const router = createRouter({ routeTree, history: createBrowserHistory() }) // Optional: seed match data from the server so loaders with staleTime skip refetching hydrateLoaderData(window.__ROUTER_STATE__) await router.load() syncRouterState() const domStyle = document.getElementById("domphy-style") as HTMLStyleElement new ElementNode(App).mount(document.getElementById("app")!, domStyle) ``` Create the router **per request** on the server — a module-level router would leak state between users. ## Dehydrate / Hydrate Concept The ported upstream layer can do this serialization for you: on the server, `attachRouterServerSsrUtils(router, ...)` collects matches, loader data, and deferred promises into a dehydrated payload (serialized with `seroval`, so it handles promises and streaming); on the client, `hydrate(router)` from `@domphy/router/ssr/client` restores it before the first `router.load()`, so loaders do not re-run for data the server already fetched. `createRequestHandler` wraps the whole request lifecycle (including redirect responses). These utilities work, but they assume a streaming HTML render to interleave with — Domphy's `generateHTML` is synchronous, so until a dedicated integration exists, the manual pattern above is the recommended route. The full upstream behavior is documented in the [TanStack Router SSR guide](https://tanstack.com/router/latest/docs/framework/react/guide/ssr). ## Scroll Restoration On the client, enable scroll restoration once after creating the router: ```ts import { setupScrollRestoration } from "@domphy/router" const router = createRouter({ routeTree, history: createBrowserHistory(), scrollRestoration: true }) setupScrollRestoration(router) ``` For SSR pages, `@domphy/router/scroll-restoration-script` exports `getScrollRestorationScriptForRouter(router)` — an inline script to embed in the server HTML `` so the scroll position is restored *before* hydration, avoiding a visible jump. (The export resolves to a no-op stub in browser builds.) --- ## Table docs (`@domphy/table`) ### adapter.md # Domphy Adapter The [bridge pattern](./) — a `tableVersion` counter bumped from `onStateChange` — is the same wiring in every table. `@domphy/table/domphy` packages it once: `createDomphyTable` owns the controlled-state loop and exposes a reactive `version` you read to re-render. ```bash npm install @domphy/table @domphy/core ``` `@domphy/core` is a **peer dependency** of the adapter, so the main `@domphy/table` entry stays a dependency-free, byte-identical port. Import from the `/domphy` subpath: ```ts import { createDomphyTable } from "@domphy/table/domphy" ``` ## createDomphyTable `createDomphyTable(options)` takes the same options as `createTable`, but fills in `state`, `onStateChange`, and `renderFallbackValue` for you and wires them to Domphy reactivity. It returns the full table instance plus a reactive `version`. ```ts import { createColumnHelper, getCoreRowModel, getSortedRowModel, getPaginationRowModel } from "@domphy/table" import { createDomphyTable } from "@domphy/table/domphy" import { table as tableUI } from "@domphy/ui" const { table, version, destroy } = createDomphyTable({ data, columns, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), getPaginationRowModel: getPaginationRowModel(), initialState: { pagination: { pageIndex: 0, pageSize: 5 } }, }) const App: DomphyElement<"table"> = { table: (l) => { version(l) // re-render on any sort / filter / pagination / selection change return [ { thead: table.getHeaderGroups().map((group) => ({ tr: group.headers.map((header) => ({ th: String(header.column.columnDef.header), onClick: header.column.getToggleSortingHandler(), _key: header.id, })), _key: group.id, })), }, { tbody: table.getRowModel().rows.map((row) => ({ tr: row.getVisibleCells().map((cell) => ({ td: String(cell.getValue() ?? ""), _key: cell.id, })), _key: row.id, })), }, ] }, $: [tableUI()], } ``` ## Return value | Member | Description | | --- | --- | | `table` | The full table-core `Table` instance — every header group, row model, and feature method. | | `version(l)` | Reactive change counter. Read it (with the listener) anywhere you render from the instance; it bumps on every state change. | | `state(l)` | The current `TableState`, reactive when a listener is passed. | | `setState(updater)` | Forwards to `table.setState`. | | `destroy()` | Releases the version state's listeners. | ## Why a version counter A table's rendered output — `getRowModel().rows`, header sort markers, the page indicator — depends on essentially all of the table state. One coarse `version` signal that bumps on any change is the right granularity: read `version(l)` once at the top of the region that renders from the instance, and the whole region reconciles. Domphy patches the DOM in place, so re-deriving rows is cheap and keyed rows keep their nodes. For controls that depend on a single slice (e.g. a page-size selector), you can read `state(l).pagination` instead to avoid re-rendering on unrelated changes. ## Cleanup `version` is a Domphy state; release it when the table's subtree unmounts: ```ts { table: (l) => { version(l); return [...] }, $: [tableUI()], _onRemove: () => destroy(), } ``` ## When to use the bridge directly Use the raw [bridge pattern](./) when you need fully-controlled state living in your own model, or per-slice signals instead of one counter. The adapter is built on that exact pattern. ### advanced.md # Advanced Features Each feature below follows the same pattern as [sorting and filtering](./sorting-filtering): pass the row model factory if one exists, drive state through instance methods, and let the `tableVersion` bridge re-render the UI. ## Grouping & Aggregation Pass `getGroupedRowModel()` (plus `getExpandedRowModel()` if group rows should expand) and set which columns to group by: ```ts import { getGroupedRowModel, getExpandedRowModel } from "@domphy/table" // in createTable options: getGroupedRowModel: getGroupedRowModel(), getExpandedRowModel: getExpandedRowModel(), ``` ```ts table.setGrouping(["status"]) table.resetGrouping() // or per column: column.toggleGrouping(), column.getIsGrouped() ``` Grouped rows aggregate their leaf rows. Pick the function per column with `aggregationFn`: | `aggregationFn` | Result | |---|---| | `sum`, `min`, `max`, `mean`, `median` | Numeric aggregates. | | `extent` | `[min, max]` tuple. | | `unique` | Array of distinct values. | | `uniqueCount` | Number of distinct values. | | `count` | Leaf row count (default for group rows). | ```ts helper.accessor("visits", { aggregationFn: "sum" }) ``` When rendering, group rows report their kind per cell: `cell.getIsGrouped()` (the grouped value plus `row.subRows.length`), `cell.getIsAggregated()` (render the aggregate), `cell.getIsPlaceholder()` (render nothing). All built-ins are exported as the `aggregationFns` object; a custom one is `(columnId, leafRows, childRows) => value`. ## Expanding & Sub-Rows For hierarchical data, tell the core row model where children live and pass `getExpandedRowModel()`: ```ts const table = createTable({ data, columns, getSubRows: (row) => row.children, getCoreRowModel: getCoreRowModel(), getExpandedRowModel: getExpandedRowModel(), // ... }) ``` `table.getRowModel().rows` then flattens expanded children in place. Per row: - `row.getCanExpand()` — has sub-rows (or `getRowCanExpand` says so) - `row.getIsExpanded()` / `row.toggleExpanded(value?)` - `row.getToggleExpandedHandler()` — ready-made click handler - `row.depth` — indent level ```ts td: cell.column.id === "name" ? { div: [ row.getCanExpand() ? { button: row.getIsExpanded() ? "▼" : "▶", onclick: row.getToggleExpandedHandler(), $: [button()] } : null, String(cell.getValue()), ], style: { paddingLeft: `${row.depth * 1.5}rem` }, } : String(cell.getValue() ?? ""), ``` `table.toggleAllRowsExpanded()` and `table.setExpanded(true)` work on the whole tree (`expanded: true` means "everything expanded"). ## Column Visibility No row model needed — visibility filters which leaf columns appear: ```ts table.getColumn("age")!.toggleVisibility() // or .toggleVisibility(false) table.getColumn("age")!.getIsVisible() table.toggleAllColumnsVisible(true) ``` Render from the visibility-aware getters and columns hide everywhere automatically: `table.getVisibleLeafColumns()`, `row.getVisibleCells()`, and header groups already respect it. A column with `enableHiding: false` is exempt. A visibility menu is one loop over `table.getAllLeafColumns()` with an `inputCheckbox()` per column bound to `column.getIsVisible()` / `column.toggleVisibility()`. ## Column Ordering ```ts table.setColumnOrder(["select", "fullName", "age", "visits"]) table.resetColumnOrder() ``` The state is an array of column ids; unlisted columns append in definition order. Pinning (below) takes precedence over ordering for the pinned sections. ## Column Pinning Pin columns to either edge: ```ts table.setColumnPinning({ left: ["select"], right: ["actions"] }) // or per column: column.pin("left"), column.pin(false), column.getIsPinned() ``` Read the three sections separately when you need split rendering (e.g. sticky columns): - `table.getLeftLeafColumns()` / `table.getCenterLeafColumns()` / `table.getRightLeafColumns()` - `table.getLeftHeaderGroups()` / center / right variants - `row.getLeftVisibleCells()` / `row.getCenterVisibleCells()` / `row.getRightVisibleCells()` For CSS-sticky pinning in one ``, keep rendering `row.getVisibleCells()` (pinned cells are ordered left → center → right) and use `column.getIsPinned()` with `column.getStart("left")` / `column.getAfter("right")` to compute the sticky offsets. ## Column Sizing Sizes are plain numbers in state — `column.getSize()` returns the current size (default 150, bounded by `minSize` / `maxSize` from the column def): ```ts helper.accessor("firstName", { size: 240, minSize: 80 }) ``` ```ts th: { ..., style: { width: `${header.getSize()}px` } }, ``` Set sizes directly with `table.setColumnSizing({ firstName: 300 })`, or build a resize handle: `header.getResizeHandler()` returns a `mousedown`/`touchstart` handler that tracks the drag and writes `columnSizing` state for you. During a drag, `column.getIsResizing()` is `true`; the `columnResizeMode` option picks whether sizes apply live (`"onChange"`) or on release (`"onEnd"`). ```ts { div: null, onmousedown: header.getResizeHandler(), style: { cursor: "col-resize", ... } } ``` ## Faceting Faceting computes value statistics per column for building filter UIs — pass the row models and read, never set: ```ts import { getFacetedRowModel, getFacetedUniqueValues, getFacetedMinMaxValues } from "@domphy/table" // in createTable options: getFacetedRowModel: getFacetedRowModel(), getFacetedUniqueValues: getFacetedUniqueValues(), getFacetedMinMaxValues: getFacetedMinMaxValues(), ``` ```ts const statusColumn = table.getColumn("status")! // Map — feed a select() or checkbox list of available filter options const options = [...statusColumn.getFacetedUniqueValues().keys()] // [min, max] — feed an inputRange() for a number range filter const range = table.getColumn("age")!.getFacetedMinMaxValues() ``` Facets are computed from rows filtered by *every other* column, so the option list always reflects what selecting it would actually match. ### api.md # API Reference `@domphy/table` is a 1-1 port of `@tanstack/table-core` v8.21.3 — every export below has identical behavior to upstream, so the [TanStack Table v8 reference](https://tanstack.com/table/v8/docs) documents each item in full detail. ## Core - `createTable(options)` — builds the table instance. Key options: `data`, `columns`, `state`, `onStateChange`, `initialState`, the `get*RowModel` factories, `getRowId`, `getSubRows`, `renderFallbackValue`, plus per-feature `enable*` / `manual*` flags - `createColumnHelper()` — typed column def builder: `accessor` (by key or function), `display`, `group` ## Row Models All opt-in factories passed into `createTable` options; only the first is required: - `getCoreRowModel()` — base rows from `data` - `getSortedRowModel()` — sorting - `getFilteredRowModel()` — column filters + global filter - `getPaginationRowModel()` — page slicing - `getGroupedRowModel()` — grouping and aggregation - `getExpandedRowModel()` — expanding and sub-rows - `getFacetedRowModel()` — per-column faceting base - `getFacetedUniqueValues()` — `Map` per column - `getFacetedMinMaxValues()` — `[min, max]` per column ## Built-In Functions - `sortingFns` — `alphanumeric`, `alphanumericCaseSensitive`, `text`, `textCaseSensitive`, `datetime`, `basic` - `filterFns` — `includesString`, `includesStringSensitive`, `equalsString`, `equals`, `weakEquals`, `arrIncludes`, `arrIncludesAll`, `arrIncludesSome`, `inNumberRange` - `aggregationFns` — `sum`, `min`, `max`, `extent`, `mean`, `median`, `unique`, `uniqueCount`, `count` ## Instance APIs Per Feature Each feature contributes methods to the table, column, row, and header objects. Summarized; see the feature pages for usage: - **Sorting** — `table.setSorting`/`resetSorting`; `column.toggleSorting`, `getIsSorted`, `getCanSort`, `clearSorting`, `getToggleSortingHandler` - **Column filtering** — `table.setColumnFilters`; `column.setFilterValue`, `getFilterValue`, `getIsFiltered`, `getCanFilter` - **Global filtering** — `table.setGlobalFilter`, `resetGlobalFilter`, `getGlobalFilterFn` (rows come out of `getFilteredRowModel`) - **Pagination** — `table.setPageIndex`, `setPageSize`, `nextPage`, `previousPage`, `firstPage`, `lastPage`, `getPageCount`, `getCanNextPage`, `getCanPreviousPage` - **Row selection** — `table.toggleAllRowsSelected`, `toggleAllPageRowsSelected`, `getIsAllRowsSelected`, `getSelectedRowModel`, `resetRowSelection`; `row.toggleSelected`, `getIsSelected`, `getCanSelect` - **Grouping** — `table.setGrouping`, `resetGrouping`; `column.toggleGrouping`, `getIsGrouped`; `cell.getIsGrouped`, `getIsAggregated`, `getIsPlaceholder` - **Expanding** — `table.setExpanded`, `toggleAllRowsExpanded`; `row.toggleExpanded`, `getIsExpanded`, `getCanExpand`, `getToggleExpandedHandler` - **Visibility** — `table.setColumnVisibility`, `toggleAllColumnsVisible`, `getVisibleLeafColumns`; `column.toggleVisibility`, `getIsVisible`, `getCanHide` - **Ordering** — `table.setColumnOrder`, `resetColumnOrder` - **Column pinning** — `table.setColumnPinning`, `getLeftLeafColumns`/center/right (plus header-group and visible-cell variants); `column.pin`, `getIsPinned`, `getStart`, `getAfter` - **Row pinning** — `table.setRowPinning`, `getTopRows`, `getBottomRows`; `row.pin`, `getIsPinned` - **Sizing** — `table.setColumnSizing`, `resetColumnSizing`; `column.getSize`, `getIsResizing`; `header.getSize`, `getResizeHandler` - **Faceting** — `column.getFacetedRowModel`, `getFacetedUniqueValues`, `getFacetedMinMaxValues`; global variants on `table` ## Utilities - `functionalUpdate(updater, input)` — resolve an `Updater` (value or function) against the current value - `makeStateUpdater(key, instance)` — build a per-key `onChange` handler that writes through `onStateChange` - `memo(getDeps, fn, options)` — the dependency-memoization primitive all row models use - `flattenBy(array, getChildren)`, `isFunction`, `isNumberArray`, `noop` - `reSplitAlphaNumeric` — the regex behind `alphanumeric` sorting ## Types All public types are re-exported, including: - instance shapes: `Table`, `Column`, `Row`, `Cell`, `Header`, `HeaderGroup`, `RowModel`, `RowData` - options & state: `TableOptions`, `TableState`, `InitialTableState`, `Updater` - column defs: `ColumnDef`, `AccessorKeyColumnDef`, `AccessorFnColumnDef`, `DisplayColumnDef`, `GroupColumnDef`, `IdentifiedColumnDef`, `ColumnHelper`, `CellContext`, `HeaderContext` - feature state: `SortingState`, `ColumnFiltersState`, `GlobalFilterTableState` (via `TableState`), `PaginationState`, `RowSelectionState`, `GroupingState`, `ExpandedState`, `VisibilityState`, `ColumnOrderState`, `ColumnPinningState`, `RowPinningState`, `ColumnSizingState`, `ColumnSizingInfoState` - functions: `SortingFn`, `FilterFn`, `AggregationFn`, `AccessorFn`, plus `BuiltInSortingFn`, `BuiltInFilterFn`, `BuiltInAggregationFn` ## CDN Global The IIFE bundle exposes everything under `Domphy.table`: ```html ``` ### columns.md # Columns & Row Models Column defs tell the table how to extract values from your data and what to show in headers, cells, and footers. Row models compute the rows the table displays — each one is opt-in and tree-shakeable. ## Column Defs With `createColumnHelper` `createColumnHelper()` returns a typed builder with three methods: ```ts import { createColumnHelper } from "@domphy/table" type Person = { firstName: string lastName: string age: number visits: number } const helper = createColumnHelper() const columns = [ // accessor by key — value type is inferred from the data shape helper.accessor("firstName", { header: "First Name", }), // accessor by function — derive a value; an explicit id is required helper.accessor((row) => `${row.firstName} ${row.lastName}`, { id: "fullName", header: "Full Name", }), // display column — no data value (actions, checkboxes, row numbers) helper.display({ id: "actions", header: "Actions", }), // group column — a header spanning child columns helper.group({ header: "Stats", columns: [ helper.accessor("age", { header: "Age" }), helper.accessor("visits", { header: "Visits" }), ], }), ] ``` The helper is purely a typing convenience — plain objects with `accessorKey` / `accessorFn` work identically. ## Header, Cell, And Footer Defs Each column def can carry `header`, `cell`, and `footer`. Each accepts a string or a function receiving a context object: ```ts helper.accessor("age", { header: "Age", cell: (info) => `${info.getValue()} years`, footer: (info) => info.column.id, }) ``` The cell context exposes `getValue()`, `row`, `column`, and `table`. Because Domphy is headless-native, there is no `flexRender` step — a cell def can simply return a string, or you can skip cell defs entirely and read `cell.getValue()` yourself when building elements. ## Row Models Are Opt-In `getCoreRowModel()` is the only required row model — it maps raw `data` to rows. Everything else is a separate factory you pass only when you use the feature: | Row model | Enables | |---|---| | `getCoreRowModel()` | **Required.** Base rows from `data`. | | `getSortedRowModel()` | Sorting. | | `getFilteredRowModel()` | Column filters and global filtering. | | `getPaginationRowModel()` | Page slicing. | | `getGroupedRowModel()` | Grouping and aggregation. | | `getExpandedRowModel()` | Expanding and sub-rows. | | `getFacetedRowModel()` | Per-column faceting base. | | `getFacetedUniqueValues()` | Unique value counts for filter UIs. | | `getFacetedMinMaxValues()` | Min/max range for range filter UIs. | ```ts import { createTable, getCoreRowModel, getSortedRowModel, getFilteredRowModel, } from "@domphy/table" const table = createTable({ data, columns, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), // ...bridge options from the overview }) ``` If a row model is missing, the feature's state is still tracked but rows pass through unprocessed — sorting state changes, but rows do not reorder. ## Accessing Data The instance computes everything; you read it in a render function. The traversal is always the same: - `table.getHeaderGroups()` → header rows; each group has `headers`, each header has `column.columnDef.header`, `colSpan`, `isPlaceholder` - `table.getRowModel().rows` → final rows after all enabled row models - `row.getVisibleCells()` → cells in visible-column order; `cell.getValue()` returns the accessor value ```ts const App: DomphyElement<"table"> = { table: (l) => { tableVersion.get(l) return [ { thead: table.getHeaderGroups().map((headerGroup) => ({ tr: headerGroup.headers.map((header) => ({ th: header.isPlaceholder ? "" : String(header.column.columnDef.header), colSpan: header.colSpan, _key: header.id, })), _key: headerGroup.id, })), }, { tbody: table.getRowModel().rows.map((row) => ({ tr: row.getVisibleCells().map((cell) => ({ td: String(cell.getValue() ?? ""), _key: cell.id, })), _key: row.id, })), }, ] }, $: [tableUI()], } ``` In Domphy these map to plain element objects — no render-prop indirection. If a column has a `cell` function returning a string, call it with `cell.getContext()`; otherwise `cell.getValue()` is all you need. Use `header.id`, `row.id`, and `cell.id` as `_key` so reconciliation stays stable across sorts and filters. ### index.md # Table `@domphy/table` provides headless table logic for Domphy apps: sorting, filtering, pagination, row selection, grouping, expanding, pinning, column sizing, and faceting. It is a **1-1 port of [`@tanstack/table-core`](https://github.com/TanStack/table/tree/main/packages/table-core) v8.21.3** (MIT, © Tanner Linsley and the TanStack team). The source is kept byte-identical to upstream, so the entire [TanStack Table v8 reference](https://tanstack.com/table/v8/docs) applies as-is, and future upstream versions can be diffed and merged directly. Like the rest of Domphy, it is framework-agnostic and has zero dependencies — the bridge to the UI is plain `toState`. ## Install npm install @domphy/table ``` ```html [CDN] ``` The CDN bundle exposes `Domphy.table` with all exports. ## Live Example ## Core Concepts - **`createTable`** — builds the table instance from your `data`, `columns`, row models, and state. The instance is the single API surface: header groups, row models, and every feature method hang off it. - **Column defs** — plain objects describing how to extract values (`accessorKey` / `accessorFn`), what to render in headers/cells/footers, and per-column feature options. `createColumnHelper` gives you a typed builder. - **Row models are opt-in** — only `getCoreRowModel()` is required. Sorting, filtering, pagination, grouping, expanding, and faceting each ship as a separate `get*RowModel()` factory you pass in explicitly, so unused features tree-shake away. - **Headless** — `@domphy/table` computes *what* to display; Domphy owns *how*. You map header groups and rows straight into plain element objects. ## The Bridge Pattern `@domphy/table` is state-driven: every interaction (sort click, filter input, page change) funnels through `onStateChange`. The bridge is one `toState` counter that bumps whenever table state changes — the UI reads it and re-renders from the instance: ```ts import { createTable, getCoreRowModel, getSortedRowModel } from "@domphy/table" import { toState } from "@domphy/core" const tableVersion = toState(0) const table = createTable({ data, columns, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), state: {}, onStateChange: (updater) => { const next = typeof updater === "function" ? updater(table.getState()) : updater table.setOptions((prev) => ({ ...prev, state: next })) tableVersion.set(tableVersion.get() + 1) }, renderFallbackValue: null, }) table.setOptions((prev) => ({ ...prev, state: table.initialState })) ``` The UI touches `tableVersion` reactively, then reads everything else directly from the instance: ```ts import { table as tableUI } from "@domphy/ui" const App: DomphyElement<"table"> = { table: (l) => { tableVersion.get(l) return [ { thead: table.getHeaderGroups().map((headerGroup) => ({ tr: ..., _key: headerGroup.id })) }, { tbody: table.getRowModel().rows.map((row) => ({ tr: ..., _key: row.id })) }, ] }, $: [tableUI()], } ``` Note the import alias: the `table()` patch from `@domphy/ui` is imported as `tableUI` because `table` is taken by the instance. The docs use this aliasing throughout. ## What To Read Next 1. [Domphy Adapter](./adapter) for `createDomphyTable` — the recommended way to consume tables 1. [Columns & Row Models](./columns) for column defs, `createColumnHelper`, and reading data out of the instance 2. [Sorting & Filtering](./sorting-filtering) for sort toggling, column filters, and global filtering 3. [Pagination & Selection](./pagination-selection) for page controls and row selection with checkboxes 4. [Advanced Features](./advanced) for grouping, expanding, visibility, ordering, pinning, sizing, and faceting 5. [API Reference](./api) for the full export list ### pagination-selection.md # Pagination & Selection ## Pagination Pass `getPaginationRowModel()` — `table.getRowModel()` then returns only the current page: ```ts import { createTable, getCoreRowModel, getPaginationRowModel } from "@domphy/table" const table = createTable({ data, columns, getCoreRowModel: getCoreRowModel(), getPaginationRowModel: getPaginationRowModel(), // ...bridge options from the overview }) ``` The default page size is 10. Seed a different one through `initialState`: ```ts initialState: { pagination: { pageIndex: 0, pageSize: 25 } }, ``` ### Page Controls | Method | Does | |---|---| | `table.setPageIndex(index)` | Jump to a page (0-based). | | `table.setPageSize(size)` | Change page size; the index is clamped to keep the first row visible. | | `table.nextPage()` / `table.previousPage()` | Step one page. | | `table.firstPage()` / `table.lastPage()` | Jump to either end. | | `table.getPageCount()` | Total pages after filtering. | | `table.getCanNextPage()` / `table.getCanPreviousPage()` | Boundary checks for disabling buttons. | ```ts const Pager: DomphyElement<"div"> = { div: (l) => { tableVersion.get(l) const { pageIndex } = table.getState().pagination return [ { button: "Previous", onclick: () => table.previousPage(), disabled: !table.getCanPreviousPage(), $: [button()], }, { span: `Page ${pageIndex + 1} of ${table.getPageCount()}` }, { button: "Next", onclick: () => table.nextPage(), disabled: !table.getCanNextPage(), $: [button()], }, ] }, } ``` Pagination runs after sorting and filtering, so changing a filter automatically recomputes `getPageCount()`. For server-side data, set `manualPagination: true` with `pageCount` (or `rowCount`) and fetch per page yourself. ## Row Selection Selection needs no extra row model — enable it and drive it per row: ```ts const table = createTable({ // ... enableRowSelection: true, // or a predicate: (row) => row.original.age >= 18 }) ``` Selection state is a map of row ids in `state.rowSelection`. The API: - `row.toggleSelected(value?)` / `row.getIsSelected()` / `row.getCanSelect()` - `table.toggleAllRowsSelected(value?)` / `table.getIsAllRowsSelected()` / `table.getIsSomeRowsSelected()` - `table.toggleAllPageRowsSelected(value?)` — current page only - `table.getSelectedRowModel()` — the selected rows as a row model (`.rows`, `row.original` for your data) - `table.resetRowSelection()` ```ts const selected = table.getSelectedRowModel().rows.map((row) => row.original) ``` ## Checkbox Column Recipe A `display` column with Domphy's `inputCheckbox()` patch — header checkbox selects all, row checkboxes select one: ```ts import { inputCheckbox } from "@domphy/ui" const selectColumn = helper.display({ id: "select" }) ``` Render it specially when building cells (the column has no value, so branch on `column.id`): ```ts // header cell th: header.column.id === "select" ? { input: null, type: "checkbox", checked: table.getIsAllRowsSelected(), indeterminate: table.getIsSomeRowsSelected(), onchange: () => table.toggleAllRowsSelected(), $: [inputCheckbox()], } : String(header.column.columnDef.header), // body cell td: cell.column.id === "select" ? { input: null, type: "checkbox", checked: row.getIsSelected(), disabled: !row.getCanSelect(), onchange: () => row.toggleSelected(), $: [inputCheckbox()], } : String(cell.getValue() ?? ""), ``` Because the whole `table:` render function re-runs on every `tableVersion` bump, `checked` stays in sync with `state.rowSelection` without any extra wiring. Selection survives sorting and filtering (it is keyed by `row.id`); whether it survives pagination of filtered-out rows depends on `enableSubRowSelection` and your row id strategy — pass `getRowId: (row) => row.id` to key selection by your own ids instead of row index. ### sorting-filtering.md # Sorting & Filtering Both features follow the same shape: pass the row model factory, then drive state through column or table methods. Every state change flows through `onStateChange`, bumps `tableVersion`, and the UI re-reads the instance. ## Sorting Pass `getSortedRowModel()` and toggle from a header click: ```ts import { createTable, getCoreRowModel, getSortedRowModel } from "@domphy/table" const table = createTable({ data, columns, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), // ...bridge options from the overview }) ``` ```ts thead: table.getHeaderGroups().map((headerGroup) => ({ tr: headerGroup.headers.map((header) => ({ th: [ String(header.column.columnDef.header), { asc: " ▲", desc: " ▼", false: "" }[String(header.column.getIsSorted())], ], onclick: () => header.column.toggleSorting(), _key: header.id, })), _key: headerGroup.id, })), ``` The per-column API: - `column.toggleSorting(desc?, multi?)` — cycle `asc` → `desc` → unsorted (or force a direction) - `column.getIsSorted()` — `"asc" | "desc" | false` - `column.getCanSort()`, `column.clearSorting()`, `column.getToggleSortingHandler()` Or set the whole state imperatively: ```ts table.setSorting([{ id: "age", desc: true }]) table.resetSorting() ``` ## Multi-Sort Sorting state is an array, so multi-sort works out of the box — hold a modifier and pass `multi: true` (the default `getToggleSortingHandler()` reads `event.shiftKey` for you): ```ts onclick: (e: MouseEvent) => header.column.toggleSorting(undefined, e.shiftKey), ``` Control it with the `enableMultiSort`, `maxMultiSortColCount`, and `isMultiSortEvent` table options. ## Built-In Sorting Functions Pick per column with `sortingFn`, or rely on auto-detection: | `sortingFn` | Behavior | |---|---| | `alphanumeric` | Mixed strings/numbers, case-insensitive (default for mixed values). | | `alphanumericCaseSensitive` | Same, case-sensitive. | | `text` | Plain string compare, case-insensitive. | | `textCaseSensitive` | Plain string compare, case-sensitive. | | `datetime` | `Date` values. | | `basic` | Fast `a > b` compare (default for numbers). | ```ts helper.accessor("createdAt", { sortingFn: "datetime" }) ``` A custom `sortingFn` is `(rowA, rowB, columnId) => number`. All built-ins are also exported as the `sortingFns` object. ## Column Filters Pass `getFilteredRowModel()` and set values per column: ```ts import { getFilteredRowModel } from "@domphy/table" // in createTable options: getFilteredRowModel: getFilteredRowModel(), ``` ```ts const nameColumn = table.getColumn("firstName")! const FilterInput: DomphyElement<"input"> = { input: null, placeholder: "Filter names...", oninput: (e, node) => nameColumn.setFilterValue(node.element.value), $: [inputText()], } ``` The per-column API: `column.setFilterValue(value)`, `column.getFilterValue()`, `column.getIsFiltered()`, `column.getCanFilter()`. Setting a filter value to `undefined` (or an empty string for string filters) removes it automatically. ## Built-In Filter Functions Pick per column with `filterFn`: | `filterFn` | Matches when | |---|---| | `includesString` | Value contains the filter string, case-insensitive (default for strings). | | `includesStringSensitive` | Same, case-sensitive. | | `equalsString` | Value equals the filter string, case-insensitive. | | `equals` | Strict `===`. | | `weakEquals` | Loose `==`. | | `arrIncludes` | Array value includes the filter value. | | `arrIncludesAll` | Array value includes all filter values. | | `arrIncludesSome` | Array value includes at least one filter value. | | `inNumberRange` | Value is within `[min, max]`. | ```ts helper.accessor("age", { filterFn: "inNumberRange" }) table.getColumn("age")!.setFilterValue([18, 65]) ``` All built-ins are exported as the `filterFns` object. ## Custom Filter Functions A filter function is `(row, columnId, filterValue) => boolean`: ```ts helper.accessor("status", { filterFn: (row, columnId, filterValue: string[]) => filterValue.length === 0 || filterValue.includes(row.getValue(columnId)), }) ``` Optional statics refine behavior: `myFilterFn.autoRemove = (value) => ...` removes the filter when the value is "empty", and `myFilterFn.resolveFilterValue` pre-transforms the value once before filtering. ## Global Filtering One filter value applied across all filterable columns — same `getFilteredRowModel()` powers it: ```ts const SearchInput: DomphyElement<"input"> = { input: null, placeholder: "Search all columns...", oninput: (e, node) => table.setGlobalFilter(node.element.value), $: [inputText()], } ``` - `table.setGlobalFilter(value)` / `table.resetGlobalFilter()` — state lives in `state.globalFilter` - `globalFilterFn` table option picks the function (auto-detected otherwise; inspect with `table.getGlobalFilterFn()`) - the same `getFilteredRowModel()` applies it — `table.getFilteredRowModel()` returns rows after both column and global filters, `table.getPreFilteredRowModel()` returns rows before either Column filters and the global filter compose — a row must pass both to appear in `table.getRowModel()`. --- ## Virtual docs (`@domphy/virtual`) ### index.md # Virtual `@domphy/virtual` renders only the rows/columns currently in view — essential for long lists, grids, and tables — with dynamic measurement, overscan, sticky ranges, and smooth scroll-to. It is **based on [`@tanstack/virtual-core`](https://github.com/TanStack/virtual/tree/main/packages/virtual-core) v3.17.0** (MIT, © Tanner Linsley and the TanStack team) with additional features: iOS WebKit scroll deferral, `scrollBy`/`scrollToEnd`/`takeSnapshot` methods, lazy typed-array fast-path, and `laneAssignmentMode`/`useCachedMeasurements` options. The entire [TanStack Virtual reference](https://tanstack.com/virtual/latest) applies for the base API; Domphy-specific additions are documented here. The Domphy adapter lives in `src/domphy/`. The core is framework-agnostic with zero dependencies. ## Install npm install @domphy/virtual @domphy/core ``` ```html [CDN] ``` `@domphy/core` is a peer dependency of the adapter only. ## Live Example 10,000 rows; only the visible ones are mounted. ## Adapter `createVirtualizer(options)` (from `@domphy/virtual/domphy`) owns the scroll element and binds the virtualizer to Domphy reactivity. ```ts import { createVirtualizer } from "@domphy/virtual/domphy" const list = createVirtualizer({ count: rows.length, estimateSize: () => 32, overscan: 10, }) ``` | Member | Description | | --- | --- | | `getVirtualItems(l)` | Reactive list of visible `VirtualItem`s — read with the listener inside the items function. | | `getTotalSize(l)` | Reactive total scroll size, for the spacer height/width. | | `setScrollElement(el)` | Wire the scroll container DOM node; call from its `_onMount`. | | `measureElement(el)` | Dynamic measurement ref; call from each item's `_onMount` for variable sizes. | | `scrollToIndex(i, opts?)` / `scrollToOffset(px, opts?)` | Imperative scrolling. | | `setOptions(opts)` | Update `count`/options, then re-measure. | | `virtualizer` | The underlying `Virtualizer` — the full virtual-core API. Includes Domphy additions not in upstream: `scrollBy(delta, opts?)`, `scrollToEnd(opts?)`, `takeSnapshot()`, `getDistanceFromEnd()`, `isAtEnd(threshold?)`. | | `version(l)` | Raw reactive change counter. | | `destroy()` | Detach observers; call from `_onRemove`. | ## Wiring 1. Make the outer element a fixed-height scroll container and wire it: `_onMount: (node) => list.setScrollElement(node.domElement)`. Add `_onRemove: () => list.destroy()` to detach observers when the container is removed. 2. Inside, render a relative spacer whose height is `list.getTotalSize(l)`. 3. Map `list.getVirtualItems(l)` into absolutely-positioned children using each item's `start`/`size`, keyed by `item.key`. 4. For variable-height rows, call `list.measureElement(node.domElement)` from each row's `_onMount` and drop the fixed `height`. Pass your own `observeElementRect` / `observeElementOffset` / `scrollToFn` to virtualize against the window instead of an element. --- ## Form docs (`@domphy/form`) ### index.md # Form `@domphy/form` provides headless form state for Domphy apps: typed values, per-field and form-level validators (sync + async), touched/blurred/dirty tracking, arrays, and Standard Schema support. It is a **1-1 port of [`@tanstack/form-core`](https://github.com/TanStack/form/tree/main/packages/form-core) v1.33.0** (MIT, © Tanner Linsley and the TanStack team). The `src/` is byte-identical to upstream, so the entire [TanStack Form reference](https://tanstack.com/form/latest) applies as-is. The only addition is the Domphy adapter in `src/domphy/`. It replaces the ad-hoc `FormState` / `FieldState` that used to live in `@domphy/ui`, so form logic lives in exactly one place. ## Install npm install @domphy/form @domphy/core ``` ```html [CDN] ``` `@domphy/core` is a peer dependency of the adapter only. ## Live Example ## Adapter `createForm(options)` (from `@domphy/form/domphy`) owns the form; `form.field(name, options?)` binds one input. ```ts import { createForm } from "@domphy/form/domphy" const form = createForm<{ email: string }>({ defaultValues: { email: "" }, onSubmit: ({ value }) => save(value), }) const email = form.field("email", { validators: { onChange: ({ value }) => (value.includes("@") ? undefined : "Invalid email") }, }) ``` Bind a field to a native input — read `value`/`errors` reactively, forward DOM events to the handle: ```ts import { inputText, label, formGroup } from "@domphy/ui" const Field = { div: [ { label: "Email", $: [label()] }, { input: null, $: [inputText()], value: (l) => email.value(l), onInput: (e) => email.handleChange((e.target as HTMLInputElement).value), onBlur: () => email.handleBlur(), }, { div: (l) => String(email.errors(l)[0] ?? ""), hidden: (l) => email.errors(l).length === 0, }, ], $: [formGroup()], } ``` ## Form handle | Member | Description | | --- | --- | | `values(l)` / `state(l)` | Reactive form values / full form state. | | `canSubmit(l)` / `isSubmitting(l)` / `isValid(l)` / `isSubmitted(l)` | Reactive flags. | | `field(name, options?)` | Create and mount a reactive field handle. | | `handleSubmit()` | Run validation and submission. Returns `Promise`. | | `reset(values?)` | Reset to defaults (or given values). | | `version(l)` | Reactive change counter — increments on every store flush. | | `form` | The underlying `FormApi`. | | `destroy()` | Unmount the form and all fields; call from `_onRemove`. | ## Field handle | Member | Description | | --- | --- | | `value(l)` | Reactive value — bind to the input's `value`/`checked`. | | `errors(l)` / `meta(l)` | Reactive validation errors / full field meta. | | `handleChange(value \| updater)` | Update the value (from `onInput`/`onChange`). Accepts a direct value or an `(prev) => next` updater function. | | `handleBlur()` | Mark blurred and run blur validators. | | `setValue(value \| updater)` | Set the value programmatically. Accepts a direct value or an `(prev) => next` updater function. | | `api` | The underlying `FieldApi`. | Field and form `options` (validators, async debouncing, listeners, arrays, Standard Schema) are the upstream form-core options — see the [TanStack Form docs](https://tanstack.com/form/latest). --- ## DnD docs (`@domphy/dnd`) ### index.md # Drag & Drop `@domphy/dnd` adds sortable lists and drag & drop to Domphy: reorder, transfer between lists, multi-drag, keyboard accessibility, and drop animations. Unlike the TanStack ports (`query`/`table`/`router`/`virtual`/`form`), drag-and-drop has no portable framework-agnostic core to copy byte-for-byte. So this package **depends on** [`@formkit/drag-and-drop`](https://drag-and-drop.formkit.com) (MIT, zero-dependency, framework-agnostic) and adds a thin Domphy adapter — the same way FormKit's own React/Vue/Solid adapters wrap the engine. The full FormKit API is re-exported. ## Install npm install @domphy/dnd @domphy/core ``` ```html [CDN] ``` `@domphy/core` is a peer dependency. ## Live Example ## Usage Apply `dragDrop(state, config?)` to the list container with `$`, and render the children reactively from the **same** state with a stable `_key`: ```ts import { toState } from "@domphy/core" import { dragDrop } from "@domphy/dnd" const items = toState([ { id: 1, label: "Write docs" }, { id: 2, label: "Ship it" }, ]) const App = { ul: (l) => items.get(l).map((item) => ({ li: item.label, _key: item.id })), $: [dragDrop(items)], } ``` Dragging reorders the DOM, FormKit calls `setValues` → the `items` state updates → Domphy re-renders the keyed children in the new order. The `_key` is required so the reorder maps to the right nodes. ## Config & plugins `dragDrop(state, config)` forwards `config` to FormKit's `ParentConfig`, and the whole FormKit API (plugins, sensors, group transfer) is re-exported: ```ts import { dragDrop, animations } from "@domphy/dnd" // drop animations + transfer items between any lists sharing a group { ul: (l) => ..., $: [dragDrop(items, { plugins: [animations()], group: "todos" })] } ``` Give two lists the same `group` to transfer items between them. Accessibility, touch and synthetic-drag handling come from FormKit — see the [FormKit DnD docs](https://drag-and-drop.formkit.com) for the full config. ## Cleanup The adapter tears down FormKit's listeners automatically on removal (`_onRemove`). --- ## Palette docs (`@domphy/palette`) ### index.md # Palette `@domphy/palette` is Domphy's color-palette quality engine: **measure and validate** sequential color ramps using five perceptual metrics in CIELAB. It is the design-time companion to `@domphy/theme` — theme ships the runtime tokens, palette validates the ramps behind them. Framework-agnostic, zero dependencies, pure color science. (Ported from the *Chromametry* research project, same author.) ## Install ```bash npm install @domphy/palette ``` ## Measure `Ramp` / `Palette` score a palette against five metrics (all in CIELAB): ```ts import { Ramp } from "@domphy/palette" const ramp = new Ramp(blueHexes, "blue") ramp.metrics // { contrastEfficiency, lightnessLinearity, chromaSmoothness, hueStability, spacingUniformity } ramp.score // 0–100 (geometric mean of the normalized metrics) ramp.wcag[45].span // how many steps clear WCAG 4.5:1 ``` ```ts import { Palette } from "@domphy/palette" const palette = new Palette({ blue, red, green }) palette.score // aggregate score across all ramps ``` ## Why this matters Most design systems hand-pick color steps; few can *prove* their palettes are perceptually even and accessible. `@domphy/palette` makes palette quality a measurable property — and `@domphy/theme` is built on top of it. ## Paper [**Measuring palette quality**](./measuring) — the five metrics, how they're computed, and a benchmark of popular design systems. ### measuring.md # Paper I — Measuring Palette Quality A sequential color ramp (e.g. `blue-50` … `blue-900`) is good when it is **perceptually even**, **accessible**, and **artifact-free**. `@domphy/palette` makes that measurable with five metrics, all computed in the **CIELAB** color space (lightness, chroma, hue, and ΔE2000 all derived there). ## The five metrics Each metric normalizes to `[0, 1]`; `Ramp.score` is the geometric mean, scaled to `0–100`. 1. **Contrast Efficiency** — how efficiently the ramp uses its lightness range to reach a WCAG **4.5:1** contrast pair. A ramp that wastes lightness span (or never reaches 4.5:1) scores low. 2. **Lightness Linearity** — how linear the lightness progression is across the steps, using the **High et al. (2023) L_EAL** (Equivalent Achromatic Lightness) model to account for the Helmholtz–Kohlrausch effect (highly-chromatic steps that *look* lighter are accounted for). Even visual steps → high score. 3. **Chroma Smoothness** — detects kinks and artifacts in the saturation curve using **monotone cubic splines**. A smooth chroma arc scores high; a jagged one (a step that suddenly desaturates) scores low. 4. **Hue Stability** — quantifies hue drift across the lightness ramp. A ramp that stays "the same color" from light to dark scores high; one that shifts hue (blue → purple at the dark end) scores low. 5. **Spacing Uniformity** — consistency of perceptual spacing between adjacent steps, measured with **ΔE2000**. Evenly-spaced steps score high. ```ts import { Ramp } from "@domphy/palette" const ramp = new Ramp(blueHexes, "blue") ramp.metrics // { contrastEfficiency, lightnessLinearity, chromaSmoothness, hueStability, spacingUniformity } ramp.score // 0–100 ``` ## Benchmark of design systems Popular design systems, scored on these metrics (algorithmic sequential ramps only — systems that ship discrete semantic tokens rather than ramps, like Bootstrap or Material 3, are excluded). **Overall Score** is the geometric mean of the five normalized metrics. | Design System | Steps | Span (K) | Contrast Eff. | Lightness Lin. | Chroma Smooth. | Hue Stab. | Spacing Unif. | **Score** | | :--- | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | | Adobe Spectrum | 18 | 9 | 0.943 | 0.933 | 0.879 | 0.914 | 0.772 | **88.6** | | IBM Carbon | 12 | 6 | 0.911 | 0.930 | 0.869 | 0.929 | 0.792 | **88.5** | | U.S. Web Design System | 12 | 6 | 0.911 | 0.936 | 0.810 | 0.938 | 0.800 | **87.7** | | Salesforce Lightning 2 | 14 | 7 | 0.925 | 0.919 | 0.846 | 0.937 | 0.711 | **86.3** | | GitHub Primer Brand | 12 | 6 | 0.911 | 0.924 | 0.841 | 0.941 | 0.684 | **85.5** | | Atlassian | 14 | 8 | 0.771 | 0.896 | 0.909 | 0.947 | 0.713 | **84.2** | | Tailwind CSS | 13 | 8 | 0.756 | 0.871 | 0.857 | 0.915 | 0.678 | **81.0** | | Ant Design | 12 | 9 | 0.665 | 0.859 | 0.873 | 0.928 | 0.655 | **78.8** | | Material UI | 12 | 11 | 0.507 | 0.797 | 0.786 | 0.924 | 0.550 | **69.4** | | Radix UI | 13 | 10 | 0.474 | 0.798 | 0.768 | 0.947 | 0.521 | **67.8** | | Shopify Polaris | 17 | 15 | 0.282 | 0.728 | 0.689 | 0.922 | 0.467 | **57.2** | ## Where Domphy sits `@domphy/theme` builds on **Adobe Spectrum-derived 18-step ramps** — the top-scoring family in the benchmark — and `@domphy/palette` lets you *verify* that, or any palette you generate, before shipping: ```ts import { Palette } from "@domphy/palette" const score = new Palette({ blue, red, green }).score if (score < 80) console.warn("palette quality below target") ``` Run it in CI to keep a design system's palettes measurably accessible over time. --- ## App docs (`@domphy/app`) ### api-routes.md # API Routes `createApiHandler()` ports `route.ts` handlers: the same path syntax as page routes, one handler per HTTP method, on web-standard `Request`/`Response`. It runs anywhere those classes exist — Node 18+, Bun, Deno, edge runtimes. ## Defining Handlers ```ts import { createApiHandler, json, notFound } from "@domphy/app" const handler = createApiHandler([ { path: "/api/users", GET: () => json(listUsers()), POST: async (request) => { const body = await request.json() return json(createUser(body), { status: 201 }) }, }, { path: "/api/users/[id]", GET: (_request, { params }) => { const user = findUser(params.id as string) if (!user) notFound() return json(user) }, DELETE: (_request, { params }) => { removeUser(params.id as string) return new Response(null, { status: 204 }) }, }, ]) // handler: (request: Request) => Promise ``` ## Built-In Behavior - **404** for unmatched paths, **405** with an `Allow` header for unsupported methods - **HEAD** falls back to `GET` with the body stripped - **OPTIONS** answers automatically with the allowed methods - `redirect()` thrown inside a handler becomes a `307`/`308` response, `notFound()` a `404`, other errors a `500` - `json(data, init?)` is the `NextResponse.json()` equivalent ## Serving from Node ```ts import http from "node:http" http.createServer(async (request, response) => { const webRequest = new Request(`http://localhost${request.url}`, { method: request.method, headers: request.headers as HeadersInit, body: ["GET", "HEAD"].includes(request.method!) ? undefined : request, duplex: "half", } as RequestInit) const webResponse = await handler(webRequest) response.writeHead(webResponse.status, Object.fromEntries(webResponse.headers)) response.end(Buffer.from(await webResponse.arrayBuffer())) }).listen(3000) ``` Combine with [`renderToString`](./ssr) in one server: route `/api/*` to the API handler, everything else to page rendering. ### api.md # API Reference ## App ### `createApp(routes, options?)` / `DomphyApp` | Member | Meaning | | --- | --- | | `app.router` | the `AppRouter` instance | | `app.element()` | root `DomphyElement`; the route tree renders through one reactive child | | `app.render(target)` | client render: starts the router, renders into `target` | | `app.hydrate(target, style?)` | SSR hydration: seeds loader data from `bootstrapScript`, mounts onto existing DOM | | `app.renderToString(url, { headers? })` | server render, returns `SSRResult` | | `app.renderToStream(url, options?)` | streaming SSR; flushes the shell immediately, streams resolved content; returns `StreamResult` | | `app.destroy()` | removes the tree and releases history listeners | `AppOptions`: `history` (`HistoryAdapter | null`, default browser), `middleware`, `notFound`, `error`. `SSRResult`: `html`, `css`, `head`, `status`, `redirect?`, `data`, `bootstrapScript`. `StreamResult`: `stream` (`ReadableStream`), `status` (`number`), `redirect?` (`string`). `RenderToStreamOptions` extends `RenderToStringOptions` with `head?` (markup for ``, sent in the first flush) and `bootstrap?` (markup before ``, typically the client bundle ` ``` The CDN bundle exposes `Domphy.app` with all exports. ## Live Example ## The Mental Model One `Route` object equals one folder in a Next.js `app/` directory: | `app/` directory | `Route` field | | --- | --- | | folder name (`blog`, `[slug]`, `(group)`) | `path` | | `page.tsx` | `page` | | `layout.tsx` | `layout` | | `loading.tsx` | `loading` | | `error.tsx` | `error` | | `not-found.tsx` | `notFound` | | server data fetching | `loader` (+ `revalidate`) | | `metadata` / `generateMetadata` | `metadata` | | nested folders | `children` | ```ts import { createApp, defineRoutes } from "@domphy/app" const routes = defineRoutes([ { path: "/", layout: (children) => ({ div: [Header(), children] }), page: () => ({ h1: "Home" }), children: [ { path: "about", page: () => ({ h1: "About" }) }, { path: "blog/[slug]", loader: async ({ params }) => fetchPost(params.slug as string), page: (context) => ({ h1: (context.data as Post).title }), }, ], }, ]) const app = createApp(routes) await app.render(document.getElementById("app")!) ``` ## What Is Ported - **Routing** — static, dynamic `[slug]`, catch-all `[...parts]`, optional catch-all `[[...parts]]`, route groups `(group)`, route-level redirects. [Routing](./routing) - **Layouts and boundaries** — nested layouts that persist across navigation, `loading`, `error` and `notFound` boundaries per segment. [Layouts](./layouts) - **Navigation** — `navLink()` patch (the `next/link` equivalent with prefetching and active state), `router.push/replace/back/forward/refresh/prefetch`, navigation events. [Navigation](./navigation) - **Data loading** — per-segment `loader` with `revalidate` caching, `redirect()` and `notFound()` from loaders. [Data Fetching](./data-fetching) - **Metadata** — title templates, Open Graph, Twitter, icons, robots, canonical. [Metadata](./metadata) - **Middleware** — global and per-route, with `redirect()` and `rewrite()`. [Middleware](./middleware) - **SSR** — `renderToString()` plus `hydrate()` with embedded loader data. [SSR](./ssr) - **API routes** — `createApiHandler()` on web-standard Request/Response. [API Routes](./api-routes) - **Image and Script** — `optimizedImage()` and `script()` helpers. [Image & Script](./assets) ## What Is Not Ported Build-time concerns stay with your bundler or host server: the compiler and dev server, React Server Components, static export, font optimization, and the image optimization server (`optimizedImage` delegates URL generation to any image CDN through its `loader` prop). ### layouts.md # Layouts & Boundaries ## Nested Layouts A `layout` wraps everything below its segment, exactly like `layout.tsx` wraps `page.tsx` and child segments. Layouts receive the wrapped subtree and the route context: ```ts const routes = defineRoutes([ { path: "/", layout: (children) => ({ div: [Header(), children, Footer()] }), page: HomePage, children: [ { path: "docs", layout: (children) => ({ div: [Sidebar(), children] }), page: DocsIndexPage, children: [{ path: "[...parts]", page: DocsPage }], }, ], }, ]) ``` Navigating from `/docs/a` to `/docs/b` re-renders only the page; both layouts keep their DOM. The router keys every layout with its segment id, so Domphy's child diffing reuses the existing nodes. ## Loading UI `loading` is the `loading.tsx` equivalent. While the segment's loader (or any descendant's) is pending, the nearest loading block renders in place of the subtree; ancestor layouts stay on screen: ```ts { path: "blog/[slug]", loader: ({ params }) => fetchPost(params.slug as string), loading: () => ({ p: "Loading post..." }), page: PostPage, } ``` Without a loading block anywhere in the matched chain, the previous page stays visible until data resolves — the same default as Next.js. ## Error Boundaries `error` is the `error.tsx` equivalent. When a segment's loader throws, the nearest error block at or above that segment renders, wrapped in the ancestor layouts. It receives the error and a `retry` function (which calls `router.refresh()`): ```ts { path: "/", error: (error, retry) => ({ div: [ { h2: "Something went wrong" }, { p: error.message }, { button: "Try again", onClick: () => retry() }, ], }), ... } ``` An app-level fallback can be passed to `createApp(routes, { error })` — the `global-error.tsx` equivalent. ## Not Found `notFound` is the `not-found.tsx` equivalent. It renders when: - no route matches the URL (the app-level `createApp(routes, { notFound })` block, falling back to a built-in 404), or - a loader, metadata function or middleware calls `notFound()` — then the nearest segment-level block renders inside the ancestor layouts. ```ts import { notFound } from "@domphy/app" { path: "blog/[slug]", loader: async ({ params }) => { const post = await fetchPost(params.slug as string) if (!post) notFound() return post }, notFound: () => ({ h2: "Post not found" }), page: PostPage, } ``` `router.state.get("status")` reports `"notfound"`, and `renderToString` returns HTTP status `404`. ### metadata.md # Metadata The Metadata API is ported: each segment declares a `metadata` object (static) or function (the `generateMetadata` equivalent). Matched segments merge from root to leaf; the result drives `document.title` and ``/`` tags on every navigation, and `result.head` during SSR. ## Static Metadata ```ts { path: "/", metadata: { title: { default: "My Site", template: "%s | My Site" }, description: "A Domphy application.", openGraph: { siteName: "My Site", type: "website" }, icons: "/favicon.svg", }, ... children: [ { path: "about", metadata: { title: "About" }, page: AboutPage }, // document.title becomes "About | My Site" ], } ``` ## Dynamic Metadata ```ts { path: "blog/[slug]", metadata: async (context) => { const post = await fetchPost(context.params.slug as string) return { title: post.title, description: post.summary, openGraph: { images: [post.cover] }, } }, ... } ``` ## Title Resolution Identical to Next.js: - a string title gets the nearest ancestor `template` applied (`"%s | My Site"`) - `title.default` is used when no descendant sets a title - `title.absolute` escapes the template entirely ## Merge Rules Top-level keys from deeper segments override shallower ones. Object keys (`openGraph`, `twitter`, `robots`, `icons`, `alternates`) are replaced wholesale, not deep-merged — the same rule as Next.js. ## Supported Fields `title`, `description`, `applicationName`, `generator`, `keywords`, `authors`, `referrer`, `themeColor`, `colorScheme`, `viewport`, `robots`, `icons`, `openGraph`, `twitter`, `alternates` (canonical + languages), `metadataBase` (resolves relative URLs in openGraph/twitter/canonical), `other` (arbitrary name/content pairs). `og:title`, `og:description`, `twitter:title` and `twitter:description` fall back to the page title and description when not set explicitly. ## Lower-Level API ```ts import { resolveMetadata, metadataToHeadTags, renderHeadTags, applyHeadTags } from "@domphy/app" const resolved = await resolveMetadata([rootMetadata, pageMetadata], loaderContext) const tags = metadataToHeadTags(resolved) renderHeadTags(tags) // -> HTML string for SSR applyHeadTags(tags) // -> writes document.head, replacing previous tags ``` ### middleware.md # Middleware Middleware runs before every navigation and server render — the `middleware.ts` equivalent. It can redirect, rewrite, raise a 404, or simply observe. ## Global Middleware ```ts import { createApp, redirect, rewrite } from "@domphy/app" const app = createApp(routes, { middleware: [ (context) => { // Auth gate: redirect interrupts the navigation if (context.pathname.startsWith("/admin") && !isLoggedIn()) { redirect("/login") } }, (context) => { // Rewrite: render another route, keep the URL if (context.pathname === "/home") return rewrite("/") }, ], }) ``` The `MiddlewareContext` carries `url`, `pathname`, `searchParams` and (on the server) `headers`. Middleware may be async. ## Results | Action | Effect | | --- | --- | | return nothing | continue to the next middleware | | `return rewrite(path)` | match and render `path`, address bar keeps the original URL | | `redirect(path)` / `permanentRedirect(path)` (throws) | restart navigation at `path`; SSR reports 307/308 + `result.redirect` | | `notFound()` (throws) | render the not-found boundary; SSR reports 404 | ## Per-Route Middleware Routes can attach middleware that runs only when their subtree matches — runs for the whole chain, root first: ```ts { path: "(admin)", middleware: [requireAdminSession], children: [ { path: "dashboard", page: DashboardPage }, { path: "settings", page: SettingsPage }, ], } ``` Global middleware runs before matching (so it can rewrite the path); per-route middleware runs after matching, before loaders. ### navigation.md # Navigation ## navLink `navLink()` is the `next/link` equivalent — a patch for native `a` elements: ```ts import { navLink } from "@domphy/app" import { link } from "@domphy/ui" { a: "Blog", $: [link(), navLink({ href: "/blog" })], } ``` It intercepts plain left-clicks for client navigation (modified clicks, `target="_blank"`, downloads and external origins fall through to the browser), prefetches, and exposes active state: - `aria-current="page"` and `data-active` are set reactively while the link matches the current pathname (descendant paths count unless `exact: true`) - style the active state with a selector: `style: { "&[data-active]": { ... } }` Props: | Prop | Default | Meaning | | --- | --- | --- | | `href` | required | target path | | `prefetch` | `"hover"` | `"hover"`, `"visible"` (IntersectionObserver) or `false` | | `replace` | `false` | replace the history entry | | `scroll` | `true` | scroll to top (or `#hash`) after navigating | | `exact` | `false` | active only on exact pathname match | | `router` | app router | explicit router instance | ## The Router `app.router` is the `useRouter()` equivalent: ```ts const router = app.router router.push("/blog/hello") // navigate, push history router.replace("/login") // navigate, replace history router.back() // history back router.forward() // history forward router.refresh() // clear loader cache, re-render current URL router.prefetch("/blog/hello") // run loaders ahead of navigation ``` `push`/`replace` resolve relative hrefs against the current URL and hand off external origins to the browser. ## Navigation Events The `next/router` events equivalent: ```ts const release = router.addEventListener("routeChangeStart", (href) => { console.log("navigating to", href) }) router.addEventListener("routeChangeComplete", (href) => { ... }) router.addEventListener("routeChangeError", (error, href) => { ... }) release() // unsubscribe ``` ## Scroll Behavior The router manages scrolling like Next.js: scroll to top after navigation, scroll to the `#hash` element when present, and restore the saved position on back/forward. Pass `scroll: false` to `navigate`/`push`/`navLink` to opt out. ## History Modes By default the router binds to the browser history. For tests, embedded demos or custom hosts, pass a memory history: ```ts import { createApp, createMemoryHistory } from "@domphy/app" const app = createApp(routes, { history: createMemoryHistory("/start") }) ``` `history: null` disables history entirely (used internally for SSR). ### routing.md # Routing Routes form a tree of `Route` objects. Each node is one URL segment — the equivalent of one folder in a Next.js `app/` directory. A node is routable when it declares `page` (or `redirect`). ## Segment Syntax `path` uses the exact Next.js folder conventions: | Pattern | Kind | Example match | `params` | | --- | --- | --- | --- | | `about` | static | `/about` | — | | `docs/install` | static, multi-part | `/docs/install` | — | | `[slug]` | dynamic | `/blog/hello` | `{ slug: "hello" }` | | `[...parts]` | catch-all | `/docs/a/b` | `{ parts: ["a", "b"] }` | | `[[...parts]]` | optional catch-all | `/gallery`, `/gallery/a` | `{ parts: [] }`, `{ parts: ["a"] }` | | `(marketing)` | route group | no URL contribution | — | Static segments win over dynamic, dynamic over catch-all — the same priority order as Next.js, so `/blog/featured` beats `/blog/[slug]`. ```ts const routes = defineRoutes([ { path: "/", page: HomePage, children: [ { path: "blog", page: BlogIndexPage, children: [ { path: "featured", page: FeaturedPage }, { path: "[slug]", page: PostPage }, ]}, { path: "docs/[...parts]", page: DocsPage }, { path: "(marketing)", children: [ { path: "pricing", page: PricingPage }, // URL is /pricing ]}, ], }, ]) ``` ## Pages A page is a block that receives the route context: ```ts import type { RouteContext } from "@domphy/app" function PostPage(context: RouteContext) { return { article: [ { h1: context.data.title }, { p: `slug: ${String(context.params.slug)}` }, { p: `query: ${context.searchParams.get("ref") ?? "none"}` }, ], } } ``` `RouteContext` carries `pathname` (the rendered path, after any middleware rewrite), `url` (the address-bar path, before rewrites), `params`, `searchParams`, `hash`, `data` (the segment's loader result), and `segmentData` (every matched segment's loader result, keyed by segment id). ## Route Groups Groups organize the tree — shared layouts, shared middleware — without affecting the URL, exactly like `(group)` folders: ```ts { path: "(shop)", layout: ShopLayout, children: [ { path: "products", page: ProductsPage }, // URL: /products { path: "cart", page: CartPage }, // URL: /cart ], } ``` ## Parallel Routes A segment can render several independent route trees at once through `slots` — the equivalent of Next.js `@slot` folders. Each slot is matched against the path **below** the segment and rendered independently; the matched elements are passed to the layout's third argument: ```ts { path: "dashboard", layout: (children, context, slots) => ({ div: [ { aside: [slots.nav ?? { span: "" }] }, { section: [slots.analytics ?? { span: "" }] }, children, ], }), slots: { nav: [ { path: "", page: () => ({ nav: "Overview" }) }, { path: "team", page: () => ({ nav: "Team nav" }) }, ], analytics: [{ path: "", page: () => AnalyticsPanel() }], }, children: [ { path: "", page: DashboardHome }, { path: "team", page: TeamPage }, ], } ``` At `/dashboard` the `nav` and `analytics` slots both match their `""` route; at `/dashboard/team` `nav` follows to its `team` route while `analytics` (no match for that sub-path) is simply omitted. Slots may declare their own `layout`, `loading`, `loader`, and even nested `slots` — they go through the same render and `DataCache` as the main tree. ## Intercepting Routes A slot route marked `intercept: true` matches **only during client-side (soft) navigation** — a hard load or refresh of the same URL renders the real route instead. This is how Next.js intercepting routes (`(.)`, `(..)`, `(...)`) power "modal over the current page" patterns: ```ts { path: "feed", layout: (children, _context, slots) => ({ div: [children, slots.modal ?? { span: "" }], }), slots: { // soft-nav to /feed/photo/[id] -> renders the modal over the feed modal: [{ path: "photo/[id]", intercept: true, page: PhotoModal }], }, children: [ { path: "", page: Feed }, // hard load of /feed/photo/[id] -> renders the full page { path: "photo/[id]", page: PhotoPage }, ], } ``` Style the intercepting slot as an overlay (a `dialog`, a portalled panel) and it appears above the previous content on in-app navigation, while a shared link to the same URL opens the standalone page. ## Redirect Routes The equivalent of `redirects` in `next.config.js`: ```ts { path: "old-blog", redirect: "/blog", permanent: true } ``` On the client the router follows the redirect; on the server `renderToString` reports status `308` (or `307` when `permanent` is not set) plus the target in `result.redirect`. ## Lazy / Code-Split Routes A route may declare `lazy: () => import("./page.js")` — any function returning a `Promise`. The heavy parts of the route then live in a separately bundled module fetched on demand the first time the route is matched, rendered, navigated to, or prefetched. This is the equivalent of a dynamically imported route module in Next.js. ```ts { path: "dashboard", metadata: { title: "Dashboard" }, // cheap, stays eager lazy: () => import("./dashboard.js"), } ``` The lazy module may supply any module-level field: `page`, `layout`, `loading`, `error`, `notFound`, `metadata`, `loader`, and `middleware`. ```ts // dashboard.js export const page = DashboardPage export const layout = DashboardLayout export const loading = DashboardSkeleton export const error = DashboardError export const loader = async (context) => fetchDashboard(context) ``` How it behaves: - **Resolved once, then cached.** The import is memoized per `Route` object — it runs at most once for the whole application, no matter how many renders, prefetches, or server routers touch the route. - **Works with prefetch.** `navLink` prefetching (hover or visible) resolves the lazy module ahead of navigation, so the chunk is already loaded by the time you click. - **Works with SSR and streaming.** The server awaits the import before rendering; while it resolves on the client, the route's `loading` block (eager or lazy) shows. - **Errors route to the nearest boundary.** A rejected import is **not** cached — a later navigation retries — and the rejection is routed to the nearest `error` block, exactly like a thrown loader. - **Eager fields win.** If a route declares a field both eagerly and in the lazy module, the eager one wins on conflict. The recommended split is therefore: keep cheap, statically inspectable config (`path`, `metadata`, `revalidate`, `redirect`) eager, and put the heavy `page` (and optionally `layout` / `loading`) in the lazy module. A route can also override a single block from a shared module this way. ```ts { // Eager `loading` wins over the module's, so the shell renders instantly // while ./profile.js (with the heavy page) loads. path: "profile", loading: ProfileSkeleton, lazy: () => import("./profile.js"), } ``` ## Reading the Current Route `router.state` is a `RecordState` — every key is reactive: ```ts const app = createApp(routes) const Breadcrumb = { p: (listener) => `You are at ${app.router.state.get("pathname", listener)}`, } ``` Available keys: `pathname`, `search`, `hash`, `params`, `status` (`"idle" | "loading" | "error" | "notfound"`), `error`. `router.searchParams(listener)` returns the current `URLSearchParams`. ### ssr.md # Server Rendering `@domphy/app` server-renders through Domphy core's `generateHTML()`/`generateCSS()` and hydrates with `mount()` — with routing, loaders, middleware and metadata handled for you. ## renderToString ```ts const app = createApp(routes) const result = await app.renderToString(request.url, { headers: request.headers }) ``` `SSRResult`: | Field | Meaning | | --- | --- | | `html` | markup of the app root | | `css` | scoped CSS of the rendered tree | | `head` | serialized `` / `<meta>` / `<link>` tags | | `status` | `200`, `404`, or `307`/`308` for redirects | | `redirect` | redirect target, when a loader/middleware redirected | | `data` | loader results, keyed for hydration | | `bootstrapScript` | inline `<script>` exposing `data` to the client | ## A Node Server ```ts import http from "node:http" import { createApp } from "@domphy/app" import { themeCSS } from "@domphy/theme" import { routes } from "./routes.js" http.createServer(async (request, response) => { const app = createApp(routes) const result = await app.renderToString(`http://localhost${request.url}`) if (result.redirect) { response.writeHead(result.status, { location: result.redirect }) response.end() return } response.writeHead(result.status, { "content-type": "text/html; charset=utf-8" }) response.end(`<!doctype html> <html> <head> ${result.head} <style>${themeCSS()}</style> <style id="domphy-style">${result.css}</style> </head> <body> <div id="app">${result.html}</div> ${result.bootstrapScript} <script type="module" src="/client.js"></script> </body> </html>`) }).listen(3000) ``` ## Hydration `client.js` builds the same app and mounts onto the server markup. `hydrate()` reads the data embedded by `bootstrapScript`, so loaders are **not** re-run and the client tree matches the HTML byte for byte: ```ts import { createApp } from "@domphy/app" import { themeApply } from "@domphy/theme" import { routes } from "./routes.js" themeApply() const app = createApp(routes) const mountTarget = document.getElementById("app")!.firstElementChild as HTMLElement const style = document.getElementById("domphy-style") as HTMLStyleElement await app.hydrate(mountTarget, style) ``` After hydration the router takes over: clicks on `navLink` anchors navigate client-side, loaders run on demand, metadata updates `document.head`. ## Streaming `renderToStream` trades the single `renderToString` string for a web `ReadableStream` that flushes in two phases: the **shell** (layouts wrapping each segment's `loading` fallback) goes out immediately for a fast first byte, then the resolved **content**, head and hydration data stream in once the loaders settle. ```ts // `redirect` is optional (undefined when no redirect occurred) const { stream, status, redirect } = await app.renderToStream(request.url, { head: `<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">`, bootstrap: `<script type="module" src="/client.js"></script>`, headers: request.headers, }) response.writeHead(status, { "content-type": "text/html" }) // Pipe the web stream to the Node response (or return it directly on edge runtimes). for await (const chunk of stream as unknown as AsyncIterable<Uint8Array>) { response.write(chunk) } response.end() ``` `renderToStream` emits a full HTML document: - **First flush** — `<!DOCTYPE html>` + `<head>` (your `head` option + shell CSS) + `<body><div id="domphy-app">` wrapping the shell. The browser paints the loading UI right away. - **Second flush** — the content and head arrive as `<template>` elements followed by an inline script that swaps them into place, then the hydration data and your `bootstrap` markup. `RenderToStreamOptions` adds `head` (markup for `<head>`, sent first) and `bootstrap` (markup before `</body>`, usually the client bundle `<script>`) to the `headers` option. Because the shell is committed before loaders run, `status` is `200` for any matched route; loader-level `notFound()`/`error` render their boundaries inline. On the client, hydrate the swapped root exactly as with `renderToString`: ```ts await app.hydrate(document.getElementById("domphy-app")!.firstElementChild as HTMLElement) ``` ## Static Generation `renderToString` is a pure function of URL + loaders, so SSG is a loop: ```ts for (const url of ["/", "/about", "/blog/hello"]) { const result = await app.renderToString(url) await writePage(url, result) } ``` --- ## Markdown docs (`@domphy/markdown`) ### index.md # Markdown `@domphy/markdown` parses Markdown into **Domphy element trees** — plain objects like `{ h1: ... }`, `{ ul: [...] }`, `{ pre: [{ code: ... }] }` — so the result can be server-rendered by `@domphy/core` / `@domphy/app` with no client runtime. It walks [markdown-it](https://github.com/markdown-it/markdown-it)'s token stream and emits **semantic tags only** — no inline typography styles. Styling stays the consumer's job, applied through patches and theme, exactly like hand-written Domphy. ## What you get - Headings with a slug `id` for anchors, plus a collected table of contents - Paragraphs, bold / italic / strikethrough, inline code - Fenced code blocks (language preserved as `class="language-..."` and `data-language`, with a pluggable highlighter) - Links, images, blockquotes, ordered / unordered / nested lists (`_key` on list items) - GFM tables, horizontal rules, raw inline / block HTML pass-through - YAML frontmatter splitting - `markdown-it-anchor` wired for heading anchors ## Install npm install @domphy/markdown @domphy/core ``` ```bash [pnpm] pnpm add @domphy/markdown @domphy/core ``` `@domphy/core` is a peer dependency. ## parseMarkdown `parseMarkdown(md, options?)` returns `{ frontmatter, body, toc }`: ```ts import { ElementNode } from "@domphy/core" import { parseMarkdown } from "@domphy/markdown" const source = `--- title: Hello --- # Hello World A paragraph with **bold** and a [link](https://domphy.com). - one - two ` const { frontmatter, body, toc } = parseMarkdown(source) frontmatter // { title: "Hello" } toc // [{ level: 1, text: "Hello World", slug: "hello-world" }] // Render the body to HTML with @domphy/core const html = new ElementNode({ div: body }).generateHTML() ``` | Field | Type | Description | | --- | --- | --- | | `frontmatter` | `Record<string, unknown>` | Parsed YAML frontmatter, or `{}` when none is present. | | `body` | `DomphyElement[]` | The document as an array of Domphy elements. | | `toc` | `TocEntry[]` | Flat list of headings: `{ level, text, slug }`. | ## markdownToDomphy A convenience wrapper when you only need the body element array: ```ts import { markdownToDomphy } from "@domphy/markdown" const body = markdownToDomphy("# Title\n\nText.") ``` It is exactly `parseMarkdown(md, options).body`. ## Options ```ts parseMarkdown(md, { // Highlight fenced code. Return inner HTML for the <code>, or a DomphyElement. // A falsy return falls back to plain escaped text. highlight: (code, language) => `<span class="tok">${code}</span>`, // Custom heading slug function (used for anchors and the toc). anchorSlugify: (text) => text.toLowerCase().replace(/\s+/g, "-"), // Forwarded to the markdown-it constructor. mdOptions: { breaks: true }, }) ``` | Option | Type | Description | | --- | --- | --- | | `highlight` | `(code, language) => string \| DomphyElement \| null \| undefined` | Highlighter for fenced code blocks. A returned string is used as the `<code>` inner HTML; a `DomphyElement` becomes its single child. | | `anchorSlugify` | `(text) => string` | Slug function for heading `id`s and toc entries. | | `mdOptions` | `markdown-it` options | Merged into the markdown-it constructor (e.g. `{ breaks: true }`). | ## Custom pipelines For a documentation generator that needs **extra** markdown-it plugins — containers (``), file includes, custom inline rules — run your own markdown-it instance and feed its token stream to the package's canonical walker. You get the same `body` / `toc` without reimplementing the token-to-Domphy conversion. ```ts import MarkdownIt from "markdown-it" import container from "markdown-it-container" import { splitFrontmatter, tokensToDomphy } from "@domphy/markdown" const md = new MarkdownIt({ html: true, linkify: true }) md.use(container, "tip") const source = "\nUse the walker directly.\n:::" const { frontmatter, content } = splitFrontmatter(source) const tokens = md.parse(content, {}) const { body, toc } = tokensToDomphy(tokens, { // Same highlight / anchorSlugify options as parseMarkdown. highlight: (code) => code, }) ``` The lower-level building blocks are all exported: | Export | Description | | --- | --- | | `tokensToDomphy(tokens, options?)` | Convert a pre-parsed markdown-it token stream into `{ body, toc }`. Use with your own markdown-it instance. | | `walkTokens(tokens, context)` | The raw walker `tokensToDomphy` is built on, for the most control. | | `splitFrontmatter(md)` | Split a document into `{ frontmatter, content }` before parsing. | | `createUniqueSlugger(slugify)` | A stateful slugger that de-duplicates repeated heading slugs. | | `defaultSlugify(text)` | The built-in slug function. | This is exactly how DomphyPress (this site) works: its markdown-it instance adds containers and includes, then hands the tokens to `tokensToDomphy`. --- ## Mermaid docs (`@domphy/mermaid`) ### index.md # Mermaid `@domphy/mermaid` renders [Mermaid](https://mermaid.js.org/) diagrams for Domphy. It has two complementary paths: - **Build-time / SSG** — render each diagram to inline SVG with a headless browser, with an on-disk cache and a tree integration for [`@domphy/markdown`](/docs/markdown/). The browser ships **no Mermaid runtime** — diagrams are just SVG in your HTML. - **Client-side** — the `mermaidClient()` patch renders a diagram in the browser at mount time, using the `mermaid` library (an optional peer dependency). This page is built by DomphyPress, so the diagram below was rendered to SVG at build time by the markdown integration: ```mermaid graph TD MD["Markdown source"] --> P["@domphy/markdown<br/>parseMarkdown"] P --> T["Domphy tree<br/>(pre > code.language-mermaid)"] T --> M["@domphy/mermaid<br/>renderMermaidInTree"] M --> SVG["Inline SVG in HTML"] ``` ## Install pnpm add @domphy/mermaid ``` ```bash [NPM] npm install @domphy/mermaid ``` `@domphy/core` is a peer dependency. `@mermaid-js/mermaid-cli` is a **direct** dependency that powers the build-time path; it manages its own headless browser (Puppeteer / Chrome) internally, so you do not install or configure Puppeteer yourself. `mermaid` is an **optional** peer dependency, needed only for the client-side patch. ## Build-time rendering `renderMermaidToSvg(code, options?)` renders a single diagram to an SVG string: ```ts import { renderMermaidToSvg } from "@domphy/mermaid" const svg = await renderMermaidToSvg("graph TD; A-->B;", { theme: "dark", background: "transparent", }) // svg === "<svg ...>...</svg>" ``` Mermaid syntax errors are thrown as an `Error` that includes the diagram source — they are never silently swallowed. ### Options | Option | Type | Default | | --- | --- | --- | | `theme` | `"default" \| "dark" \| "neutral" \| "forest"` | `"default"` | | `background` | `string` (`"transparent"` for none) | `"transparent"` | | `mermaidConfig` | `Record<string, unknown>` | — | | `css` | `string` (injected into the render page) | — | | `puppeteer` | `Record<string, unknown>` (launch options) | — | ## Render cache `renderMermaidCached(code, options?)` renders once and reads the SVG back from disk on later calls with the same source and options: ```ts import { renderMermaidCached } from "@domphy/mermaid" const svg = await renderMermaidCached("graph TD; A-->B;", { cacheDir: "node_modules/.cache/domphy-mermaid", // default }) ``` The cache key is a stable SHA-256 hash of the normalized source plus the output-affecting options (no time or randomness), so repeated builds are fast. Pass `cache: false` to bypass it. ## Markdown integration `@domphy/markdown` emits a fenced ` ```mermaid ` block as a regular code node: ```js { pre: [{ code: "<escaped source>", dataLanguage: "mermaid", class: "language-mermaid" }] } ``` `renderMermaidInTree(elements, options?)` finds those blocks anywhere in the tree, renders each to SVG (through the cache), and replaces the node with an SVG-wrapping element: ```js { div: "<svg ...>...</svg>", class: "mermaid", ariaLabel: "diagram" } ``` ```ts import { parseMarkdown } from "@domphy/markdown" import { renderMermaidInTree } from "@domphy/mermaid" const { body } = parseMarkdown(markdownSource) const rendered = await renderMermaidInTree(body, { theme: "neutral" }) ``` All other nodes — siblings, nesting, attributes — are left untouched. Identical diagram sources are rendered only once, and distinct diagrams render concurrently. Inject a custom `renderer` to test the tree walk without a browser: ```ts await renderMermaidInTree(body, { renderer: async (code) => `<svg data-src="${code}"></svg>`, }) ``` `TreeOptions` extends the cache options, plus `renderer` and a `className` for the wrapping element (default `"mermaid"`). ## Client-side patch To render in the browser at mount time instead of at build time, attach `mermaidClient()` with `$`: ```ts import { mermaidClient } from "@domphy/mermaid" const App = { pre: [{ code: "graph TD; A-->B;" }], $: [mermaidClient({ theme: "dark" })], } ``` On mount the patch reads the source from the element (preferring an inner `<code>`), renders it with the `mermaid` library, and swaps in the SVG. Install `mermaid` to use this path: ```bash pnpm add mermaid ``` --- ## Doctor docs (`@domphy/doctor`) ### index.md # Doctor `@domphy/doctor` is a static analyzer for Domphy element trees. It walks the plain-object tree — including the output of reactive `(listener) => …` functions — and flags non-idiomatic patterns. Its main job is to give **AI agents** (and humans) a feedback loop: generate code → `diagnose()` → fix what it reports. Because Domphy UIs are plain objects, the doctor needs no parser and no build step. ## Install ```bash npm install -D @domphy/doctor @domphy/core ``` `@domphy/core` is a peer dependency (the doctor reads its tag tables). ## Usage ```ts import { diagnose, format } from "@domphy/doctor" const App = { div: [ { p: "Hello", style: { fontSize: "20px" } }, // inline typography { input: "oops" }, // void tag with content { dvi: "typo" }, // unknown tag ], } console.log(format(diagnose(App))) ``` ``` ⚠ [inline-typography] div > p Inline `fontSize` — avoid inline typography styles. → Use a typography patch (paragraph()/heading()/…) via $. ✗ [void-content] div > input Void tag "input" must have null content (got string). ⚠ [unknown-tag] div "dvi" is not a known HTML/SVG tag — likely a typo. ``` `diagnose(element, options?)` returns `Diagnostic[]`: ```ts type Severity = "error" | "warning" | "info" interface Diagnostic { rule: string // "inline-typography" | "void-content" | "missing-key" | … severity: Severity path: string // "div > ul > li" message: string hint?: string } ``` `Severity` is exported from `@domphy/doctor` as its own named type. ## Rules | Rule | Severity | Catches | | --- | --- | --- | | `inline-typography` | warning | `fontSize` / `lineHeight` / `fontWeight` / `letterSpacing` / `fontFamily` / `textDecoration` literals in `style` — use a typography patch | | `raw-theme-value` | info | a literal hex/rgb/hsl color in a color style prop (`color`, `background`, `border`, `fill`, …). The hint uses **`@domphy/palette` chromametry** (CIELAB→LCH) to suggest the nearest `themeColor()` call with perceptual coordinates | | `raw-spacing-value` | info | a literal `rem`/`em`/`px` value in a layout spacing prop (`padding`, `paddingBlock`, `paddingInline`, `margin`, `marginBlock`, `marginInline`, `gap`, …) — suggests `themeSpacing(n)` for consistent theme density | | `unknown-tone` | warning | a `dataTone` value that isn't valid tone grammar (`inherit` / `base` / a number / `shift-N` / `increase-N` / `decrease-N` with N ≤ 17) — catches invented words like `surface` / `text`, and out-of-range offsets like `shift-25` | | `middle-surface-anchor` | warning | a `dataTone: "shift-N"` where N is 4–13 — a mid-ramp surface anchor causes child tones to clamp and collapse contrast; prefer edge anchors (0–3 light, 14–17 dark) | | `unknown-density` | warning / error | a `dataDensity` value that isn't `"inherit"` / `"increase-N"` / `"decrease-N"` (N ≤ 4), or uses `shift-` (invalid for density). Error when N > 4 (out of the 5-step scale). | | `unknown-size` | warning / error | a `dataSize` value that isn't `"inherit"` / `"increase-N"` / `"decrease-N"` (N ≤ 7), or uses `shift-` (invalid for size). Error when N > 7 (out of the 8-step scale). | | `void-content` | error | a void tag (`input`, `img`, `br`, …) with non-null content | | `missing-key` | warning | a **dynamic** list (returned by a reactive function) of element children missing `_key` | | `unknown-tag` | warning | an element whose first key isn't a valid HTML/SVG tag (typo) | | `duplicate-key` | error | two siblings sharing the same `_key` value — the reconciler can't tell them apart | | `unstable-key` | warning | a dynamic list whose `_key`s are the array index (`0, 1, 2, …`) — index keys shift on reorder/insert | By default the doctor invokes reactive content functions with a no-op listener to inspect their output (this is how the dynamic-list rules are found). Pass `{ runReactive: false }` if your reactive functions have side effects. `duplicate-key` is decidable on any sibling array — static or dynamic — so it is checked everywhere. `missing-key` and `unstable-key` are specific to **dynamic** lists, since only those go through keyed reconciliation. ## validate `validate(element, options?)` is the aggregate entry point. It runs every rule and returns a structured report instead of a raw array: ```ts import { validate } from "@domphy/doctor" const report = validate(App) report.ok // false — there is at least one error-severity issue report.issues // Diagnostic[] — same as diagnose(App) report.summary // { error: 1, warning: 2, info: 0, total: 3 } ``` ```ts interface ValidationReport { ok: boolean // true when there are no error-severity diagnostics issues: Diagnostic[] summary: { error: number; warning: number; info: number; total: number } } ``` `ok` is false when any `error` diagnostic is present; warnings and info do not flip it. Use this as the single programmatic gate — for example fail CI when `!report.ok` — while `diagnose` / `format` remain available for raw access. ## fix `fix(element, options?)` applies the **lossless** fixes automatically and reports the rest: ```ts import { fix } from "@domphy/doctor" const { tree, applied, report } = fix(App) // tree — a copy with lossless fixes applied (reactive functions preserved) // applied — [{ rule, path, message }] describing what changed // report — validate(tree): the issues that still need a human/model decision ``` Only provably-lossless transforms run (currently `void-content`: a void tag cannot render children, so its content is cleared to `null`). Anything that needs intent — which key, tone, color token, or typography patch — is never guessed; it stays in `report` for the model or you to resolve. This keeps autofix safe to apply blindly in an agent loop. ## In an AI loop This is the point of the package. After the model generates a Domphy tree, run the doctor and return the report: ```ts const report = format(diagnose(generatedApp)) if (report !== "✓ No issues found.") { // hand `report` back to the model and ask it to fix the listed issues } ``` Most LLMs have little Domphy training data, so they learn it in-context from [`llms.txt`](/llms.txt) / [`AGENTS.md`](https://github.com/domphy/domphy/blob/main/AGENTS.md). The doctor enforces those same rules mechanically — turning "the model might get it wrong" into "the model gets told exactly what's wrong and fixes it." Wire it into your agent's task loop or CI. ## Large codebases In a real app the model also needs to find and reuse the app's **own** building blocks, not just the framework surface. The repo ships an app-block registry generator, `apps/web/scripts/app-manifest.mjs`, which parses your app source with the TypeScript compiler API and emits one entry per exported Domphy block (function/const that returns an element tree): ```bash node apps/web/scripts/app-manifest.mjs [srcDir] [outFile] # defaults: srcDir = apps/web/docs/demos, outFile = apps/web/public/app-manifest.json ``` Each entry carries `{ name, kind, file, signature, jsdoc, exportKind }` — a machine-readable index an agent can browse the way `manifest.json` exposes the framework packages and `@domphy/ui` patches. [`@domphy/mcp`](/docs/ai) wraps both halves as MCP tools so an agent gets validation **and** discovery over the wire: | Tool | Does | | --- | --- | | `domphy_list_patches` | Lists all `@domphy/ui` patches with their host tag and signature. | | `domphy_get_patch` | Gets one patch's full contract (host tag, signature, props, example, doc, source) by name. | | `domphy_list_packages` | Lists all `@domphy/*` packages with versions and descriptions. | | `domphy_rules` | Gets the Domphy code-generation rules (`llms.txt`) for AI agents to follow. | | `domphy_tones` | Gets valid tone and theme color names for `themeColor()`/`dataTone` — avoids invented tones. | | `domphy_diagnose` | Runs `diagnose()` on a JSON element tree and returns the raw `Diagnostic[]`, without the structured report wrapper that `validate()` adds. | | `domphy_validate` | Runs the aggregate `validate()` on a JSON element tree, returning `{ ok, issues, summary }`. | | `domphy_fix` | Applies the lossless autofix to a JSON element tree, returning `{ tree, applied, report }`. | | `domphy_list_app_blocks` | Lists the app's own blocks (name, kind, signature, file) from `app-manifest.json`. | | `domphy_get_app_block` | Returns one block's full source plus signature and jsdoc, by name. | The loop becomes: list the app's blocks → reuse them → generate → `domphy_validate` → fix what it reports. The doctor is the validation half; the manifest is the discovery half. --- ## UI patch source (`@domphy/ui`) Each block is the authoritative source for a patch — signature, props, style object. These are the contracts to follow. ### abbreviation ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeSize, themeSpacing, } from "@domphy/theme"; /** * Styles an abbreviation/acronym with a dotted underline and a "help" cursor, * shifting to the accent color on hover. Apply to an `<abbr>` element. * * @hostTag abbr * @param props.color - Base text/decoration color tone. Optional `ValueOrState<ThemeColor>`, default "neutral". * @param props.accentColor - Hover color tone. Optional `ValueOrState<ThemeColor>`, default "primary". * @example { abbr: "HTML", title: "HyperText Markup Language", $: [abbreviation({ accentColor: "primary" })] } */ function abbreviation( props: { color?: ValueOrState<ThemeColor>; accentColor?: ValueOrState<ThemeColor>; } = {}, ): PartialElement { const color = toState(props.color ?? "neutral", "color"); const accentColor = toState(props.accentColor ?? "primary", "accentColor"); return { _onInsert: (node) => { if (node.tagName !== "abbr") { console.warn(`"abbreviation" primitive patch must use abbr tag`); } }, style: { fontSize: (listener) => themeSize(listener), backgroundColor: (listener) => themeColor(listener), color: (listener) => themeColor(listener, "shift-10", color.get(listener)), textDecorationLine: "underline", textDecorationStyle: "dotted", textDecorationColor: (listener) => themeColor(listener, "shift-7", color.get(listener)), textUnderlineOffset: themeSpacing(0.72), cursor: "help", "&:hover": { color: (listener) => themeColor(listener, "shift-11", accentColor.get(listener)), textDecorationColor: (listener) => themeColor(listener, "shift-9", accentColor.get(listener)), }, }, }; } export { abbreviation }; ``` ### accordion ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeDensity, themeSpacing, } from "@domphy/theme"; /** * Container patch that groups `<details>` elements into a bordered accordion. * In `type: "single"` mode (default), opening one item closes all siblings. * * @param props.type - `"single"` (default) or `"multiple"`. Single mode auto-closes siblings. * @param props.color - Theme color tone for borders and backgrounds. Defaults to `"neutral"`. * @param props.accentColor - Accent color for focus outlines on summary. Defaults to `"primary"`. * @example * { div: [ * { details: [{ summary: "Section A" }, { p: "Content A" }], $: [details()] }, * { details: [{ summary: "Section B" }, { p: "Content B" }], $: [details()] }, * ], $: [accordion()] } */ function accordion( props: { type?: "single" | "multiple"; color?: ValueOrState<ThemeColor>; accentColor?: ValueOrState<ThemeColor>; } = {}, ): PartialElement { const { type = "single" } = props; const color = toState(props.color ?? "neutral", "color"); const accentColor = toState(props.accentColor ?? "primary", "accentColor"); return { _onMount: (node) => { if (type !== "single") return; const el = node.domElement; if (!el) return; const handler = (event: Event) => { const summary = (event.target as Element).closest("summary"); if (!summary) return; const item = summary.closest("details") as HTMLDetailsElement | null; if (!item || item.parentElement !== el) return; if (!item.open) { Array.from(el.querySelectorAll(":scope > details")).forEach( (detail) => { if (detail !== item) (detail as HTMLDetailsElement).open = false; }, ); } }; el.addEventListener("click", handler); node.addHook("Remove", () => el.removeEventListener("click", handler)); }, style: { display: "flex", flexDirection: "column", borderRadius: (listener) => themeSpacing(themeDensity(listener) * 1), outline: (listener) => `1px solid ${themeColor(listener, "shift-4", color.get(listener))}`, outlineOffset: "-1px", overflow: "hidden", "& > details": { borderBottom: (listener) => `1px solid ${themeColor(listener, "shift-4", color.get(listener))}`, }, "& > details:last-child": { borderBottom: "none", }, "& > details > summary:focus-visible": { outline: (listener) => `${themeSpacing(0.5)} solid ${themeColor(listener, "shift-6", accentColor.get(listener))}`, }, }, }; } export { accordion }; ``` ### alert ```ts import type { PartialElement } from "@domphy/core"; import { toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeDensity, themeSize, themeSpacing, } from "@domphy/theme"; /** * A semantic alert surface block with a colored inset bar, padding, and * `role="alert"`. Typically applied to a `<div>` (any block container). * * @param props.color - Surface/accent color tone. Optional `ValueOrState<ThemeColor>`, default "primary". * @example { div: "Saved successfully", $: [alert({ color: "success" })] } */ function alert( props: { color?: ValueOrState<ThemeColor> } = {}, ): PartialElement { const color = toState(props.color ?? "primary", "color"); return { role: "alert", // Alert is a semantic surface block, so it should shift the local surface context. dataTone: "shift-2", style: { display: "flex", alignItems: "flex-start", gap: themeSpacing(3), paddingBlock: (listener) => themeSpacing(themeDensity(listener) * 2), paddingInline: (listener) => themeSpacing(themeDensity(listener) * 4), boxShadow: (listener) => `inset ${themeSpacing(1)} 0 0 0 ${themeColor(listener, "shift-8", color.get(listener))}`, backgroundColor: (listener) => themeColor(listener, "inherit", color.get(listener)), color: (listener) => themeColor(listener, "shift-10", color.get(listener)), fontSize: (listener) => themeSize(listener, "inherit"), }, }; } export { alert }; ``` ### avatar ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeSize, themeSpacing, } from "@domphy/theme"; /** * A circular avatar container that centers initials/text and cover-fits any * child `<img>`. Typically applied to an inline-flex container such as a `<span>`. * * @param props.color - Background/foreground color tone. Optional `ValueOrState<ThemeColor>`, default "primary". * @example { span: "JD", $: [avatar({ color: "primary" })] } */ function avatar( props: { color?: ValueOrState<ThemeColor> } = {}, ): PartialElement { const color = toState(props.color ?? "primary", "color"); return { dataTone: "shift-2", style: { position: "relative", display: "inline-flex", alignItems: "center", justifyContent: "center", overflow: "hidden", borderRadius: "50%", flexShrink: 0, width: themeSpacing(9), height: themeSpacing(9), fontSize: (listener) => themeSize(listener, "inherit"), fontWeight: "600", userSelect: "none", backgroundColor: (listener) => themeColor(listener, "inherit", color.get(listener)), color: (listener) => themeColor(listener, "shift-11", color.get(listener)), "& img": { position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover", }, }, }; } export { avatar }; ``` ### badge ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeSize, themeSpacing, } from "@domphy/theme"; /** * Renders a small count/label bubble pinned to the top-right corner of its host * (via a `::after` pseudo-element). Typically applied to an inline container such * as a `<span>` wrapping an icon or element. * * @param props.color - Badge color tone. Optional `ValueOrState<ThemeColor>`, default "danger". * @param props.label - Text/number shown in the badge. Optional `ValueOrState<string | number>`, default 999. * @example { span: "🔔", $: [badge({ label: 3, color: "danger" })] } */ function badge( props: { color?: ValueOrState<ThemeColor>; label?: ValueOrState<string | number>; } = {}, ): PartialElement { const { label = 999 } = props; const state = toState(label); const color = toState(props.color ?? "danger", "color"); return { style: { position: "relative", "&::after": { content: (l) => `"${state.get(l)}"`, position: "absolute", top: 0, right: 0, transform: "translate(50%,-50%)", paddingInline: themeSpacing(1), minWidth: themeSpacing(6), display: "inline-flex", alignItems: "center", justifyContent: "center", fontSize: (l) => themeSize(l, "decrease-1"), borderRadius: themeSpacing(999), backgroundColor: (l) => themeColor(l, "shift-9", color.get(l)), color: (l) => themeColor(l, "shift-0", color.get(l)), }, }, }; } export { badge }; ``` ### blockquote ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeDensity, themeSize, themeSpacing, } from "@domphy/theme"; /** * Styles a quotation block with a colored inset side bar, padded surface, and * shifted tone. Apply to a `<blockquote>` element. * * @hostTag blockquote * @param props.color - Surface/bar color tone. Optional `ValueOrState<ThemeColor>`, default "neutral". * @example { blockquote: "Design is how it works.", $: [blockquote({ color: "primary" })] } */ function blockquote( props: { color?: ValueOrState<ThemeColor> } = {}, ): PartialElement { const color = toState(props.color ?? "neutral", "color"); return { _onInsert: (node) => { if (node.tagName !== "blockquote") { console.warn(`"blockquote" primitive patch must use blockquote tag`); } }, dataTone: "shift-2", style: { fontSize: (listener) => themeSize(listener, "inherit"), backgroundColor: (listener) => themeColor(listener, "inherit", color.get(listener)), boxShadow: (listener) => `inset ${themeSpacing(1)} 0 0 0 ${themeColor(listener, "shift-4", color.get(listener))}`, border: "none", paddingBlock: (listener) => themeSpacing(themeDensity(listener) * 2), paddingInline: (listener) => themeSpacing(themeDensity(listener) * 4), margin: 0, }, }; } export { blockquote }; ``` ### breadcrumb ```ts import type { PartialElement } from "@domphy/core"; import { toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeSize, themeSpacing, } from "@domphy/theme"; /** * A horizontal breadcrumb navigation that lays out its children with a * separator between items and highlights the `[aria-current=page]` item. * Apply to a `<nav>` element. * * @hostTag nav * @param props.color - Color tone for links/separators. Optional `ValueOrState<ThemeColor>`, default "neutral". * @param props.separator - String inserted between items via `::after`. Optional `string`, default "/". * @example { nav: null, $: [breadcrumb({ separator: "›" })] } */ function breadcrumb( props: { color?: ValueOrState<ThemeColor>; separator?: string } = {}, ): PartialElement { const { separator = "/" } = props; const color = toState(props.color ?? "neutral", "color"); return { _onInsert: (node) => { if (node.tagName !== "nav") console.warn('"breadcrumb" patch must use nav tag'); }, ariaLabel: "breadcrumb", style: { display: "flex", alignItems: "center", flexWrap: "wrap", fontSize: (listener) => themeSize(listener, "inherit"), gap: themeSpacing(1), color: (listener) => themeColor(listener, "shift-9", color.get(listener)), backgroundColor: (listener) => themeColor(listener, "inherit", color.get(listener)), "& > *": { display: "inline-flex", alignItems: "center", color: (listener) => themeColor(listener, "shift-8", color.get(listener)), }, "& > *:not(:last-child)::after": { content: `"${separator}"`, color: (listener) => themeColor(listener, "shift-4", color.get(listener)), paddingInlineStart: themeSpacing(1), }, "& > [aria-current=page]": { color: (listener) => themeColor(listener, "shift-10", color.get(listener)), pointerEvents: "none", }, }, }; } export { breadcrumb }; ``` ### breadcrumbEllipsis ```ts import type { PartialElement } from "@domphy/core"; import { toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeSize, themeSpacing, } from "@domphy/theme"; /** * An ellipsis trigger button for collapsed breadcrumb items, with hover and * focus-visible states. Apply to a `<button>` element. * * @hostTag button * @param props.color - Color tone for the trigger. Optional `ValueOrState<ThemeColor>`, default "neutral". * @example { button: "…", $: [breadcrumbEllipsis({ color: "neutral" })] } */ function breadcrumbEllipsis( props: { color?: ValueOrState<ThemeColor> } = {}, ): PartialElement { const color = toState(props.color ?? "neutral", "color"); return { _onInsert: (node) => { if (node.tagName !== "button") { console.warn('"breadcrumbEllipsis" patch must use button tag'); } }, ariaLabel: "More breadcrumb items", style: { display: "inline-flex", alignItems: "center", justifyContent: "center", fontSize: (listener) => themeSize(listener, "inherit"), paddingInline: themeSpacing(1), border: "none", background: "none", cursor: "pointer", color: (listener) => themeColor(listener, "shift-8", color.get(listener)), borderRadius: themeSpacing(1), "&:hover": { color: (listener) => themeColor(listener, "shift-10", color.get(listener)), backgroundColor: (listener) => themeColor(listener, "shift-2", color.get(listener)), }, "&:focus-visible": { outline: (listener) => `${themeSpacing(0.5)} solid ${themeColor(listener, "shift-6", color.get(listener))}`, outlineOffset: themeSpacing(0.5), }, }, }; } export { breadcrumbEllipsis }; ``` ### button ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeDensity, themeSize, themeSpacing, } from "@domphy/theme"; /** * A themed button control with density-aware padding/radius and hover, focus-visible, * `[disabled]`, and `[aria-busy=true]` states. Apply to a `<button>` element. * * @hostTag button * @param props.color - Button color tone. Optional `ValueOrState<ThemeColor>`, default "primary". * @example { button: "Save", $: [button({ color: "primary" })] } */ function button( props: { color?: ValueOrState<ThemeColor> } = {}, ): PartialElement { const color = toState(props.color ?? "primary", "color"); return { _onInsert: (node) => { if (node.tagName !== "button") { console.warn(`"button" primitive patch must use button tag`); } }, style: { appearance: "none", fontSize: (listener) => themeSize(listener, "inherit"), // Single-line bounded control: block/radius = 1D, inline = 3D. paddingBlock: (listener) => themeSpacing(themeDensity(listener) * 1), paddingInline: (listener) => themeSpacing(themeDensity(listener) * 3), borderRadius: (listener) => themeSpacing(themeDensity(listener) * 1), width: "fit-content", display: "flex", justifyContent: "center", alignItems: "center", gap: (listener) => themeSpacing(themeDensity(listener) * 1), userSelect: "none", fontFamily: "inherit", lineHeight: "inherit", border: "none", outlineOffset: "-1px", outlineWidth: "1px", outline: (listener) => `1px solid ${themeColor(listener, "shift-4", color.get(listener))}`, color: (listener) => themeColor(listener, "shift-9", color.get(listener)), backgroundColor: (listener) => themeColor(listener, "inherit", color.get(listener)), "&:hover:not([disabled]):not([aria-busy=true])": { color: (listener) => themeColor(listener, "shift-10", color.get(listener)), backgroundColor: (listener) => themeColor(listener, "shift-2", color.get(listener)), }, "&:focus-visible": { boxShadow: (listener) => `inset 0 0 0 ${themeSpacing(0.5)} ${themeColor(listener, "shift-6", color.get(listener))}`, }, "&[disabled]": { opacity: 0.7, cursor: "not-allowed", backgroundColor: (listener) => themeColor(listener, "shift-2", "neutral"), outline: (listener) => `1px solid ${themeColor(listener, "shift-4", "neutral")}`, color: (listener) => themeColor(listener, "shift-8", "neutral"), }, "&[aria-busy=true]": { opacity: 0.7, cursor: "wait", pointerEvents: "none", }, }, }; } export { button }; ``` ### buttonSwitch ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeSize, themeSpacing, } from "@domphy/theme"; /** * A pill-shaped toggle switch with `role="switch"`; clicking flips the bound * `checked` state and slides the thumb. Apply to a `<button>` element. * * @hostTag button * @param props.checked - Toggle state. Optional `ValueOrState<boolean>`, default false. * @param props.accentColor - Color tone when checked (on). Optional `ValueOrState<ThemeColor>`, default "primary". * @param props.color - Color tone when unchecked (off track). Optional `ValueOrState<ThemeColor>`, default "neutral". * @example { button: { span: null }, $: [buttonSwitch({ checked: true })] } */ function buttonSwitch( props: { checked?: ValueOrState<boolean>; accentColor?: ValueOrState<ThemeColor>; color?: ValueOrState<ThemeColor>; } = {}, ): PartialElement { const { checked = false } = props; const check = toState(checked); const color = toState(props.color ?? "neutral", "color"); const accentColor = toState(props.accentColor ?? "primary", "accentColor"); return { _onSchedule: (node) => { if (node.tagName !== "button") { console.warn(`"buttonSwitch" primitive patch must use button tag`); } }, role: "switch", ariaChecked: (listener) => check.get(listener), dataTone: "shift-2", onClick: () => check.set(!check.get()), style: { position: "relative", display: "inline-flex", alignItems: "center", fontSize: (listener) => themeSize(listener), border: "none", outlineWidth: "1px", outline: (listener) => `1px solid ${themeColor(listener, "shift-3", color.get(listener))}`, minWidth: themeSpacing(12), minHeight: themeSpacing(6), borderRadius: themeSpacing(999), paddingLeft: themeSpacing(7), paddingRight: themeSpacing(2), transition: "padding-left 0.3s, padding-right 0.3s", backgroundColor: (listener) => themeColor(listener, "inherit", color.get(listener)), "& > :first-child": { content: '""', position: "absolute", display: "inline-flex", alignItems: "center", left: themeSpacing(0.5), top: "50%", transform: "translateY(-50%)", transition: "left 0.3s", width: themeSpacing(5), height: themeSpacing(5), borderRadius: themeSpacing(999), color: (listener) => themeColor(listener, "shift-9"), backgroundColor: (listener) => themeColor(listener, "decrease-2", color.get(listener)), }, "&[aria-checked=true]": { backgroundColor: (listener) => themeColor(listener, "increase-3", accentColor.get(listener)), outline: "none", color: (listener) => themeColor(listener, "decrease-2"), paddingLeft: themeSpacing(2), paddingRight: themeSpacing(7), }, "&[aria-checked=true] > :first-child": { left: `calc(100% - ${themeSpacing(5.5)})`, }, "&[disabled]": { opacity: 0.7, cursor: "not-allowed", }, }, }; } export { buttonSwitch }; ``` ### card ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeDensity, themeSpacing, } from "@domphy/theme"; /** * A grid-based card surface that auto-places known child elements into named * regions: `<img>` (image), headings (title), `<p>` (description), `<aside>` * (aside), `<div>` (content), and `<footer>` (footer). Typically applied to a * `<div>` (any block container). * * @param props.color - Surface/border color tone. Optional `ValueOrState<ThemeColor>`, default "neutral". * @example { div: { h3: "Title", p: "Body" }, $: [card({ color: "neutral" })] } */ function card( props: { color?: ValueOrState<ThemeColor> } = {}, ): PartialElement { const color = toState(props.color ?? "neutral", "color"); return { style: { display: "grid", gridTemplateColumns: "1fr auto", gridTemplateAreas: '"image image" "title aside" "desc aside" "content content" "footer footer"', borderRadius: (listener) => themeSpacing(themeDensity(listener) * 2), backgroundColor: (listener) => themeColor(listener, "inherit", color.get(listener)), color: (listener) => themeColor(listener, "shift-10", color.get(listener)), outline: (listener) => `1px solid ${themeColor(listener, "shift-4", color.get(listener))}`, outlineOffset: "-1px", overflow: "hidden", "& > img": { gridArea: "image", width: "100%", height: "auto", display: "block", }, "& > :is(h1,h2,h3,h4,h5,h6)": { gridArea: "title", paddingBlock: (listener) => themeSpacing(themeDensity(listener) * 2), paddingInline: (listener) => themeSpacing(themeDensity(listener) * 4), fontWeight: "600", margin: 0, }, "& > p": { gridArea: "desc", paddingInline: (listener) => themeSpacing(themeDensity(listener) * 4), color: (listener) => themeColor(listener, "shift-9", color.get(listener)), margin: 0, }, "& > aside": { gridArea: "aside", alignSelf: "center", padding: (listener) => themeSpacing(themeDensity(listener) * 2), height: "auto", }, "& > div": { gridArea: "content", padding: (listener) => themeSpacing(themeDensity(listener) * 4), color: (listener) => themeColor(listener, "shift-10", color.get(listener)), }, "& > footer": { gridArea: "footer", display: "flex", gap: themeSpacing(2), paddingBlock: (listener) => themeSpacing(themeDensity(listener) * 2), paddingInline: (listener) => themeSpacing(themeDensity(listener) * 4), borderTop: (listener) => `1px solid ${themeColor(listener, "shift-3", color.get(listener))}`, }, }, }; } export { card }; ``` ### code ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeSize, themeSpacing, } from "@domphy/theme"; /** * Styles an inline code snippet with a subtle surface background, rounded corners, * and shifted tone. Apply to a `<code>` element. * * @hostTag code * @param props.color - Surface/text color tone. Optional `ValueOrState<ThemeColor>`, default "neutral". * @example { code: "npm install", $: [code({ color: "neutral" })] } */ function code( props: { color?: ValueOrState<ThemeColor> } = {}, ): PartialElement { const color = toState(props.color ?? "neutral", "color"); return { dataTone: "shift-2", _onInsert: (node) => { if (node.tagName !== "code") { console.warn(`"code" primitive patch must use code tag`); } }, style: { display: "inline-flex", alignItems: "center", fontSize: (listener) => themeSize(listener, "inherit"), color: (listener) => themeColor(listener, "shift-9", color.get(listener)), backgroundColor: (listener) => themeColor(listener, "inherit", color.get(listener)), height: themeSpacing(6), paddingInline: themeSpacing(1.5), borderRadius: themeSpacing(1), }, }; } export { code }; ``` ### combobox ```ts import { type DomphyElement, merge, type PartialElement, type StyleObject, toState, type ValueOrState, } from "@domphy/core"; import type { Placement } from "@domphy/floating"; import { type ThemeColor, themeColor, themeDensity, themeSize, themeSpacing, } from "@domphy/theme"; import { creatFloating } from "../utils/floating.js"; import { tag } from "./tag.js"; /** * A combobox/multi-select control: renders selected options as removable tags * plus an input, and shows a floating popover (`content`) anchored to the host. * Apply to a `<div>` element. * * @hostTag div * @param props.multiple - Allow selecting multiple values (popover stays open on click). Optional `boolean`, default false. * @param props.value - Selected value(s). Optional `ValueOrState<Array<number | string | null | undefined> | number | string | null | undefined>`, no default. * @param props.options - Available `{ label, value }` options used to render selected tags. Optional `Array<{ label: string; value: string }>`, default `[]`. * @param props.placement - Floating popover placement. Optional `ValueOrState<Placement>`, default "bottom". * @param props.content - The floating popover content element. Required `DomphyElement`. * @param props.color - Color tone for the control. Optional `ThemeColor`, default "neutral". * @param props.open - Whether the popover is open. Optional `ValueOrState<boolean>`, default false. * @param props.input - Custom input element; when omitted a default `<input>` is created. Optional `DomphyElement`. * @example { div: null, $: [combobox({ options: [{ label: "A", value: "a" }], content: { div: null } })] } */ function combobox(props: { multiple?: boolean; value?: ValueOrState< | Array<number | string | null | undefined> | number | string | null | undefined >; options?: Array<{ label: string; value: string }>; placement?: ValueOrState<Placement>; content: DomphyElement; color?: ThemeColor; open?: ValueOrState<boolean>; input?: DomphyElement; }): PartialElement { const { options = [], placement = "bottom", color = "neutral", open = false, multiple = false, } = props; const state = toState(props.value); const openState = toState(open); const { show, hide, anchorPartial } = creatFloating({ open: openState, placement: toState(placement), content: props.content, }); const popoverPartial: PartialElement = { onClick: () => !multiple && hide(), }; merge(props.content, popoverPartial); const inputStyle: StyleObject = { border: "none", outline: "none", padding: 0, margin: 0, flex: 1, height: themeSpacing(6), marginInlineStart: themeSpacing(2), fontSize: (listener: any) => themeSize(listener, "inherit"), color: (listener: any) => themeColor(listener, "shift-9", color), backgroundColor: (listener: any) => themeColor(listener, "inherit", color), }; let inputElement: DomphyElement; if (props.input) { merge(props.input, { onFocus: () => show(), style: inputStyle, _key: "combobox-input", }); inputElement = props.input; } else { inputElement = { input: null, onFocus: () => show(), value: (listener: any) => { state.get(listener); return ""; }, style: inputStyle, _key: "combobox-input", }; } const wrap: DomphyElement<"div"> = { div: (listener) => { const val = state.get(listener); const vals = Array.isArray(val) ? val : [val]; const opts = options.filter((opt) => vals.includes(opt.value)); const items: DomphyElement[] = opts.map((opt) => { return { span: opt.label, $: [tag({ color, removable: true })], _key: opt.value, _onRemove: (_node) => { const cur = state.get(); const curVals = Array.isArray(cur) ? cur : [cur]; const filter = curVals.filter((v) => v !== opt.value); multiple ? state.set(filter as any) : state.set(filter[0] as any); }, }; }); items.push(inputElement); return items; }, style: { display: "flex", flexWrap: "wrap", gap: themeSpacing(1), }, }; const partial: PartialElement = { _onInsert: (node) => { if (node.tagName !== "div") { console.warn(`"combobox" primitive patch must use div tag`); } }, _onInit: (node) => node.children.insert(wrap), style: { minWidth: themeSpacing(32), outlineOffset: "-1px", outline: (listener) => `1px solid ${themeColor(listener, "shift-4", "neutral")}`, paddingBlock: (listener) => themeSpacing(themeDensity(listener) * 1), paddingInline: (listener) => themeSpacing(themeDensity(listener) * 1), borderRadius: (listener) => themeSpacing(themeDensity(listener) * 1), fontSize: (listener) => themeSize(listener, "inherit"), color: (listener) => themeColor(listener, "shift-9", color), backgroundColor: (listener) => themeColor(listener, "inherit", color), }, }; merge(anchorPartial, partial); return anchorPartial; } export { combobox }; ``` ### command ```ts import { merge, type PartialElement, toState } from "@domphy/core"; import { type ThemeColor, themeColor, themeDensity, themeSize, themeSpacing, } from "@domphy/theme"; /** * Command-palette container patch. Sets up a vertical flex column and provides a * shared `command` context (a query State) consumed by `commandSearch` and * `commandItem` descendants to filter the list. Typically applied to a `<div>`. * * @example { div: [...], $: [command()] } */ function command(): PartialElement { return { _onSchedule: (_node, element) => { merge(element, { _context: { command: { query: toState(""), }, }, }); }, style: { display: "flex", flexDirection: "column", overflow: "hidden", }, }; } /** * Search input for a command palette. Wires the input's value into the parent * `command` context's query State so descendant `commandItem`s filter live. * Apply to an `<input>` element used inside a `command()`. * * @hostTag input * @param props.color - Base theme color tone. Defaults to "neutral". * @param props.accentColor - Accent color used for the focus border. Defaults to "primary". * @example { input: "", $: [commandSearch({ accentColor: "primary" })] } */ function commandSearch( props: { color?: ThemeColor; accentColor?: ThemeColor } = {}, ): PartialElement { const { color = "neutral", accentColor = "primary" } = props; return { _onInsert: (node) => { if (node.tagName !== "input") { console.warn(`"commandSearch" patch must use input tag`); } }, _onMount: (node) => { const ctx = node.getContext("command"); if (!ctx) { console.warn(`"commandSearch" patch must be used inside a "command"`); return; } const input = node.domElement as HTMLInputElement; const onInput = () => ctx.query.set(input.value); input.addEventListener("input", onInput); node.addHook("Remove", () => input.removeEventListener("input", onInput)); }, style: { fontFamily: "inherit", fontSize: (listener) => themeSize(listener, "inherit"), paddingInline: (listener) => themeSpacing(themeDensity(listener) * 3), paddingBlock: (listener) => themeSpacing(themeDensity(listener) * 2), border: "none", borderBottom: (listener) => `1px solid ${themeColor(listener, "shift-3", color)}`, outline: "none", color: (listener) => themeColor(listener, "shift-10", color), backgroundColor: (listener) => themeColor(listener, "inherit", color), "&::placeholder": { color: (listener) => themeColor(listener, "shift-7"), }, "&:focus-visible": { borderBottomColor: (listener) => themeColor(listener, "shift-6", accentColor), }, }, }; } /** * Selectable item (`role="option"`) in a command palette. On mount, immediately * hides itself if the current query doesn't match its text content, and subscribes * to future query changes — so items added dynamically after a search is typed are * correctly filtered. Typically applied to a `<button>` (or any clickable element) * used inside a `command()`. * * @param props.color - Base theme color tone. Defaults to "neutral". * @param props.accentColor - Accent color used for the focus outline. Defaults to "primary". * @example { button: "Open file", $: [commandItem({ color: "neutral" })] } */ function commandItem( props: { color?: ThemeColor; accentColor?: ThemeColor } = {}, ): PartialElement { const { color = "neutral", accentColor = "primary" } = props; return { role: "option", _onMount: (node) => { const ctx = node.getContext("command"); if (!ctx) { console.warn(`"commandItem" patch must be used inside a "command"`); return; } const el = node.domElement as HTMLElement; const text = el.textContent?.toLowerCase() ?? ""; const applyFilter = (q: string) => { el.hidden = q.length > 0 && !text.includes(q.toLowerCase()); }; applyFilter(ctx.query.get()); const release = ctx.query.addListener(applyFilter); node.addHook("Remove", release); }, style: { cursor: "pointer", display: "flex", alignItems: "center", width: "100%", fontSize: (listener) => themeSize(listener, "inherit"), height: (listener) => themeSpacing(6 + themeDensity(listener) * 2), paddingInline: (listener) => themeSpacing(themeDensity(listener) * 3), border: "none", outline: "none", color: (listener) => themeColor(listener, "shift-9", color), backgroundColor: (listener) => themeColor(listener, "inherit", color), "&:hover:not([disabled])": { backgroundColor: (listener) => themeColor(listener, "shift-2", color), }, "&:focus-visible": { outline: (listener) => `${themeSpacing(0.5)} solid ${themeColor(listener, "shift-6", accentColor)}`, outlineOffset: `-${themeSpacing(0.5)}`, }, }, }; } export { command, commandSearch, commandItem }; ``` ### datePicker ```ts import { type DomphyElement, type Listener, merge, type PartialElement, toState, type ValueOrState, } from "@domphy/core"; import type { Placement } from "@domphy/floating"; import { type ThemeColor, themeColor, themeSize, themeSpacing, } from "@domphy/theme"; import { creatFloating } from "../utils/floating.js"; /** A single date selection, or a `[start, end]` tuple in range mode. */ export type DatePickerValue = Date | null | [Date | null, Date | null]; export interface DatePickerProps { /** Controlled value: a `Date` in single mode, a `[start, end]` tuple in range mode. */ value?: ValueOrState<DatePickerValue>; /** Selection mode. */ mode?: "single" | "range"; /** Also pick hour + minute. The chosen time applies to the selected date(s). */ time?: boolean; /** Earliest selectable day (inclusive). */ min?: Date; /** Latest selectable day (inclusive). */ max?: Date; /** Disable arbitrary days. */ disabledDate?: (date: Date) => boolean; /** BCP-47 locale for month/weekday names, first-day-of-week, and formatting. */ locale?: string; /** Override the first day of the week (0 = Sunday … 6 = Saturday). */ weekStartsOn?: number; /** Override the input display string. */ format?: (value: DatePickerValue) => string; /** Called whenever the selection changes. */ onChange?: (value: DatePickerValue) => void; /** Accent color for the selected/active days. */ accentColor?: ValueOrState<ThemeColor>; /** Popover placement relative to the input. */ placement?: ValueOrState<Placement>; } // --- date helpers (no third-party library) ----------------------------------- const atMidnight = (date: Date): Date => new Date(date.getFullYear(), date.getMonth(), date.getDate()); const addDays = (date: Date, count: number): Date => new Date(date.getFullYear(), date.getMonth(), date.getDate() + count); const addMonths = (date: Date, count: number): Date => new Date(date.getFullYear(), date.getMonth() + count, date.getDate()); const sameDay = (a: Date | null, b: Date | null): boolean => !!a && !!b && a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); const isoOf = (date: Date): string => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String( date.getDate(), ).padStart(2, "0")}`; const startOfWeek = (date: Date, weekStart: number): Date => addDays(date, -((date.getDay() - weekStart + 7) % 7)); /** Resolves the locale's first day of week, falling back to Sunday. */ function localeWeekStart(locale: string): number { try { const localeObject = new Intl.Locale(locale) as Intl.Locale & { weekInfo?: { firstDay: number }; getWeekInfo?: () => { firstDay: number }; }; const info = localeObject.getWeekInfo?.() ?? localeObject.weekInfo; if (info?.firstDay) return info.firstDay % 7; // Intl uses 1=Mon … 7=Sun } catch { // unsupported locale or engine — fall through to Sunday } return 0; } /** * A native, themeable date picker patch for an `<input>`. Opens a calendar * popover (rendered with Domphy elements, positioned via `@domphy/floating`) * supporting single/range selection, optional time, min/max + disabled days, * localized names, and keyboard navigation. The input is read-only and shows the * formatted selection; compose with `inputText()` for the input's look. * * @hostTag input * @param props.value - Controlled value (`ValueOrState<DatePickerValue>`): a `Date`/`null` in single mode, a `[start, end]` tuple in range mode. * @param props.mode - Selection mode, "single" | "range". Defaults to "single". * @param props.time - When true, also pick hour + minute (applied to the selected date(s)). Defaults to false. * @param props.min - Earliest selectable day (inclusive), a `Date`. * @param props.max - Latest selectable day (inclusive), a `Date`. * @param props.disabledDate - Predicate `(date: Date) => boolean` to disable arbitrary days. * @param props.locale - BCP-47 locale for names/first-day-of-week/formatting. Defaults to `navigator.language` (or "en-US" in non-browser). * @param props.weekStartsOn - Override first day of week (0 = Sunday … 6 = Saturday). Defaults to the locale's first day. * @param props.format - Override the input display string, `(value: DatePickerValue) => string`. * @param props.onChange - Called with the new value whenever the selection changes, `(value: DatePickerValue) => void`. * @param props.accentColor - Accent color (`ValueOrState<ThemeColor>`) for selected/active days. Defaults to "primary". * @param props.placement - Popover placement (`ValueOrState<Placement>`) relative to the input. Defaults to "bottom-start". * @example { input: "", $: [inputText(), datePicker({ mode: "range" })] } */ function datePicker(props: DatePickerProps = {}): PartialElement { const { mode = "single", time = false, min, max, disabledDate, locale = typeof navigator !== "undefined" ? navigator.language : "en-US", format, onChange, } = props; const weekStart = props.weekStartsOn ?? localeWeekStart(locale); const accentColor = toState(props.accentColor ?? "primary", "accentColor"); const placeState = toState(props.placement ?? "bottom-start"); const selection = toState<DatePickerValue>( props.value ?? (mode === "range" ? [null, null] : null), ); const releaseOnChange = onChange ? selection.addListener((value) => onChange(value)) : null; const primaryDate = ((): Date => { const current = selection.get(); const base = mode === "range" ? (current as [Date | null, Date | null])?.[0] : current; return base instanceof Date ? base : new Date(); })(); const viewYear = toState(primaryDate.getFullYear(), "viewYear"); const viewMonth = toState(primaryDate.getMonth(), "viewMonth"); const focused = toState<Date>(atMidnight(primaryDate), "focused"); const hovered = toState<Date | null>(null, "hovered"); const hour = toState(primaryDate.getHours(), "hour"); const minute = toState(primaryDate.getMinutes(), "minute"); let contentElement: HTMLElement | null = null; // --- formatting ----------------------------------------------------------- const dateFormatter = new Intl.DateTimeFormat(locale, { dateStyle: "medium", ...(time ? { timeStyle: "short" as const } : {}), }); const monthFormatter = new Intl.DateTimeFormat(locale, { month: "long", year: "numeric", }); const weekdayFormatter = new Intl.DateTimeFormat(locale, { weekday: "short", }); const fullDateFormatter = new Intl.DateTimeFormat(locale, { dateStyle: "full", }); const formatOne = (date: Date | null): string => date ? dateFormatter.format(date) : ""; const formatDisplay = (value: DatePickerValue): string => { if (format) return format(value); if (mode === "range") { const [start, end] = (value as [Date | null, Date | null]) ?? [ null, null, ]; return start ? `${formatOne(start)} – ${formatOne(end)}` : ""; } return formatOne(value as Date | null); }; // 2023-01-01 is a Sunday, so index 0 maps to Sunday before the week-start shift. const weekdayNames = Array.from({ length: 7 }, (_unused, index) => weekdayFormatter.format(new Date(2023, 0, 1 + ((index + weekStart) % 7))), ); // --- selection logic ------------------------------------------------------ const isDisabled = (date: Date): boolean => (!!min && atMidnight(date) < atMidnight(min)) || (!!max && atMidnight(date) > atMidnight(max)) || (!!disabledDate && disabledDate(date)); const withTime = (date: Date): Date => { if (!time) return atMidnight(date); const result = atMidnight(date); result.setHours(hour.get(), minute.get(), 0, 0); return result; }; const selectDate = (date: Date): void => { if (isDisabled(date)) return; if (mode === "single") { selection.set(withTime(date)); if (!time) instantHideRef(); return; } const [start, end] = (selection.get() as [Date | null, Date | null]) ?? [ null, null, ]; if (!start || (start && end)) { selection.set([withTime(date), null]); } else if (atMidnight(date) < atMidnight(start)) { selection.set([withTime(date), withTime(start)]); if (!time) instantHideRef(); } else { selection.set([start, withTime(date)]); if (!time) instantHideRef(); } }; const reapplyTime = (): void => { if (!time) return; if (mode === "single") { const current = selection.get() as Date | null; if (current) selection.set(withTime(current)); } else { const [start, end] = (selection.get() as [Date | null, Date | null]) ?? [ null, null, ]; selection.set([ start ? withTime(start) : start, end ? withTime(end) : end, ]); } }; const inSelectedRange = (date: Date, listener?: Listener): boolean => { if (mode !== "range") return false; const [start, end] = (selection.get(listener) as [ Date | null, Date | null, ]) ?? [null, null]; const tail = end ?? hovered.get(listener); if (!start || !tail) return false; const low = atMidnight(start) <= atMidnight(tail) ? start : tail; const high = atMidnight(start) <= atMidnight(tail) ? tail : start; const day = atMidnight(date); return day >= atMidnight(low) && day <= atMidnight(high); }; const isSelectedEnd = (date: Date, listener?: Listener): boolean => { const current = selection.get(listener); if (mode === "range") { const [start, end] = (current as [Date | null, Date | null]) ?? [ null, null, ]; return sameDay(date, start) || sameDay(date, end); } return sameDay(date, current as Date | null); }; // --- view navigation ------------------------------------------------------ const goToDate = (date: Date): void => { viewYear.set(date.getFullYear()); viewMonth.set(date.getMonth()); }; const shiftMonth = (delta: number): void => { const next = addMonths(new Date(viewYear.get(), viewMonth.get(), 1), delta); goToDate(next); }; const shiftYear = (delta: number): void => viewYear.set(viewYear.get() + delta); const focusActiveCell = (): void => { setTimeout(() => { contentElement ?.querySelector<HTMLElement>(`[data-date="${isoOf(focused.get())}"]`) ?.focus(); }, 0); }; // --- floating popover ------------------------------------------------------ const calendar = buildCalendar(); const { show, hide, anchorPartial } = creatFloating({ open: false, placement: placeState, content: calendar, }); // selectDate calls this before `creatFloating` returns `hide`, so route through a ref. function instantHideRef(): void { hide(); } // Move focus into the grid when the popover opens. anchorPartial.onClick = () => {}; const triggerPartial: PartialElement = { type: "text", readonly: true, value: (listener) => formatDisplay(selection.get(listener)), ariaHaspopup: "dialog", ariaLabel: "Choose date", style: { cursor: "pointer" }, onClick: () => { openAndFocus(); }, onFocus: () => { openAndFocus(); }, onKeyDown: (event) => { const key = (event as KeyboardEvent).key; if (key === "ArrowDown" || key === "Enter") { event.preventDefault(); openAndFocus(); } }, _onMount: (node) => releaseOnChange && node.addHook("Remove", () => { releaseOnChange(); }), }; function openAndFocus(): void { const current = isSelectedPrimary() ?? new Date(); focused.set(atMidnight(current)); goToDate(current); show(); focusActiveCell(); } function isSelectedPrimary(): Date | null { const current = selection.get(); if (mode === "range") return (current as [Date | null, Date | null])?.[0] ?? null; return (current as Date | null) ?? null; } merge(anchorPartial, triggerPartial); return anchorPartial; // --- calendar builder ----------------------------------------------------- function buildCalendar(): DomphyElement<"div"> { const navButton = (label: string, ariaLabel: string, onClick: () => void) => ({ button: label, type: "button", ariaLabel, onClick, style: navButtonStyle(), }) as DomphyElement; const header: DomphyElement<"div"> = { div: [ navButton("«", "Previous year", () => shiftYear(-1)), navButton("‹", "Previous month", () => shiftMonth(-1)), { div: (listener) => monthFormatter.format( new Date(viewYear.get(listener), viewMonth.get(listener), 1), ), ariaLive: "polite", style: { flex: "1", textAlign: "center", fontWeight: "600", fontSize: (listener) => themeSize(listener), }, }, navButton("›", "Next month", () => shiftMonth(1)), navButton("»", "Next year", () => shiftYear(1)), ], style: { display: "flex", alignItems: "center", gap: themeSpacing(1), marginBottom: themeSpacing(2), }, }; const weekdayHeader: DomphyElement<"div"> = { div: weekdayNames.map((name, index) => ({ div: name, style: { textAlign: "center", fontSize: (listener) => themeSize(listener, "decrease-1"), fontWeight: "600", color: (listener) => themeColor(listener, "shift-7"), paddingBlock: themeSpacing(1), }, _key: index, })), role: "row", style: gridRowStyle(), }; const grid: DomphyElement<"div"> = { div: (listener) => buildWeeks(listener), role: "grid", ariaLabel: "Calendar", onKeyDown: onGridKey, onMouseLeave: () => mode === "range" && hovered.set(null), style: { display: "flex", flexDirection: "column", gap: themeSpacing(0.5), }, }; const children: DomphyElement[] = [header, weekdayHeader, grid]; if (time) children.push(buildTimeRow()); children.push(buildFooter()); return { div: children, role: "dialog", ariaModal: "false", _onMount: (node) => { contentElement = node.domElement as HTMLElement; }, style: { minWidth: themeSpacing(70), padding: themeSpacing(3), borderRadius: themeSpacing(2), backgroundColor: (listener) => themeColor(listener, "base"), color: (listener) => themeColor(listener, "shift-10"), border: (listener) => `1px solid ${themeColor(listener, "shift-4")}`, boxShadow: "0 8px 24px rgba(0,0,0,0.18)", }, }; } function buildWeeks(listener: Listener): DomphyElement[] { const first = new Date(viewYear.get(listener), viewMonth.get(listener), 1); const month = viewMonth.get(listener); const start = startOfWeek(first, weekStart); const weeks: DomphyElement[] = []; for (let week = 0; week < 6; week++) { const cells: DomphyElement[] = []; for (let day = 0; day < 7; day++) { const date = addDays(start, week * 7 + day); cells.push(buildDayCell(date, month, listener)); } weeks.push({ div: cells, role: "row", style: gridRowStyle(), _key: week, }); } return weeks; } function buildDayCell( date: Date, month: number, listener: Listener, ): DomphyElement { const disabled = isDisabled(date); const selected = isSelectedEnd(date, listener); const within = inSelectedRange(date, listener); const outside = date.getMonth() !== month; const isFocused = sameDay(date, focused.get(listener)); const isToday = sameDay(date, new Date()); return { button: String(date.getDate()), type: "button", role: "gridcell", tabIndex: isFocused ? 0 : -1, ariaSelected: selected, ariaDisabled: disabled, disabled, ariaLabel: fullDateFormatter.format(date), dataDate: isoOf(date), onClick: () => selectDate(date), onMouseEnter: () => mode === "range" && hovered.set(date), style: { appearance: "none", border: "none", cursor: disabled ? "not-allowed" : "pointer", aspectRatio: "1", borderRadius: themeSpacing(1), fontSize: (l: Listener) => themeSize(l), fontFamily: "inherit", opacity: disabled ? 0.35 : outside ? 0.5 : 1, backgroundColor: (l: Listener) => selected ? themeColor(l, "shift-7", accentColor.get(l)) : within ? themeColor(l, "shift-2", accentColor.get(l)) : "transparent", color: (l: Listener) => selected ? themeColor(l, "shift-0", accentColor.get(l)) : themeColor(l, "shift-9"), outline: isToday ? (l: Listener) => `1px solid ${themeColor(l, "shift-6", accentColor.get(l))}` : "none", outlineOffset: "-2px", "&:hover:not([disabled])": { backgroundColor: (l: Listener) => selected ? themeColor(l, "shift-7", accentColor.get(l)) : themeColor(l, "shift-3", accentColor.get(l)), }, "&:focus-visible": { outline: (l: Listener) => `2px solid ${themeColor(l, "shift-6", accentColor.get(l))}`, }, }, _key: isoOf(date), } as DomphyElement; } function buildTimeRow(): DomphyElement<"div"> { const numberSelect = ( count: number, state: ReturnType<typeof toState<number>>, ariaLabel: string, ): DomphyElement => ({ select: Array.from({ length: count }, (_unused, value) => ({ option: String(value).padStart(2, "0"), value: String(value), selected: (listener) => state.get(listener) === value, _key: value, })) as DomphyElement[], ariaLabel, onChange: (event) => { state.set(Number((event.target as HTMLSelectElement).value)); reapplyTime(); }, style: timeSelectStyle(), }); return { div: [ numberSelect(24, hour, "Hour"), { span: ":", style: { fontWeight: "600" } }, numberSelect(60, minute, "Minute"), ], style: { display: "flex", alignItems: "center", justifyContent: "center", gap: themeSpacing(1), marginTop: themeSpacing(3), }, }; } function buildFooter(): DomphyElement<"div"> { const action = (label: string, onClick: () => void): DomphyElement => ({ button: label, type: "button", onClick, style: { appearance: "none", border: "none", background: "transparent", cursor: "pointer", fontFamily: "inherit", fontSize: (l: Listener) => themeSize(l, "decrease-1"), color: (l: Listener) => themeColor(l, "shift-8", accentColor.get(l)), padding: themeSpacing(1), }, }); return { div: [ action("Today", () => { const today = new Date(); focused.set(atMidnight(today)); goToDate(today); focusActiveCell(); }), action("Clear", () => { selection.set(mode === "range" ? [null, null] : null); hovered.set(null); }), ], style: { display: "flex", justifyContent: "space-between", marginTop: themeSpacing(2), paddingTop: themeSpacing(2), borderTop: (l: Listener) => `1px solid ${themeColor(l, "shift-3")}`, }, }; } function onGridKey(event: Event): void { const keyboard = event as KeyboardEvent; const current = focused.get(); let next: Date | null = null; switch (keyboard.key) { case "ArrowLeft": next = addDays(current, -1); break; case "ArrowRight": next = addDays(current, 1); break; case "ArrowUp": next = addDays(current, -7); break; case "ArrowDown": next = addDays(current, 7); break; case "Home": next = startOfWeek(current, weekStart); break; case "End": next = addDays(startOfWeek(current, weekStart), 6); break; case "PageUp": next = addMonths(current, keyboard.shiftKey ? -12 : -1); break; case "PageDown": next = addMonths(current, keyboard.shiftKey ? 12 : 1); break; case "Enter": case " ": keyboard.preventDefault(); selectDate(current); return; default: return; } keyboard.preventDefault(); focused.set(next); goToDate(next); focusActiveCell(); } } // --- shared style fragments -------------------------------------------------- function gridRowStyle() { return { display: "grid", gridTemplateColumns: "repeat(7, 1fr)", gap: themeSpacing(0.5), }; } function navButtonStyle() { return { appearance: "none" as const, border: "none", background: "transparent", cursor: "pointer", fontFamily: "inherit", fontSize: (l: Listener) => themeSize(l), color: (l: Listener) => themeColor(l, "shift-8"), width: themeSpacing(7), height: themeSpacing(7), borderRadius: themeSpacing(1), "&:hover": { backgroundColor: (l: Listener) => themeColor(l, "shift-3"), }, }; } function timeSelectStyle() { return { fontFamily: "inherit", fontSize: (l: Listener) => themeSize(l), padding: themeSpacing(1), borderRadius: themeSpacing(1), border: (l: Listener) => `1px solid ${themeColor(l, "shift-4")}`, backgroundColor: (l: Listener) => themeColor(l, "base"), color: (l: Listener) => themeColor(l, "shift-9"), }; } export { datePicker }; ``` ### descriptionList ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeSize, themeSpacing, } from "@domphy/theme"; /** * Styles a description list as a two-column grid (terms in the first column, * descriptions in the second), theming the nested `<dt>`/`<dd>` elements. * Apply to a `<dl>` element. * * @hostTag dl * @param props.color - Theme color tone (`ValueOrState<ThemeColor>`) for the term/description text. Defaults to "neutral". * @example { dl: [{ dt: "Name" }, { dd: "Domphy" }], $: [descriptionList()] } */ function descriptionList( props: { color?: ValueOrState<ThemeColor> } = {}, ): PartialElement { const color = toState(props.color ?? "neutral", "color"); return { _onInsert: (node) => { if (node.tagName !== "dl") { console.warn(`"descriptionList" primitive patch must use dl tag`); } }, style: { display: "grid", gridTemplateColumns: `minmax(${themeSpacing(24)}, max-content) 1fr`, columnGap: themeSpacing(4), margin: 0, "& dt": { margin: 0, fontWeight: 600, fontSize: (listener) => themeSize(listener, "inherit"), color: (listener) => themeColor(listener, "shift-10", color.get(listener)), }, "& dd": { margin: 0, fontSize: (listener) => themeSize(listener, "inherit"), color: (listener) => themeColor(listener, "shift-9", color.get(listener)), }, }, }; } export { descriptionList }; ``` ### details ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeDensity, themeSize, themeSpacing, } from "@domphy/theme"; /** * Styles a native disclosure widget: a themed `<summary>` header with an * animated rotating chevron and an expand/collapse transition on the body * content. Apply to a `<details>` element. * * @hostTag details * @param props.color - Theme color tone (`ValueOrState<ThemeColor>`) for the body/summary. Defaults to "neutral". * @param props.accentColor - Accent color (`ValueOrState<ThemeColor>`) for the summary's focus outline. Defaults to "primary". * @param props.duration - Open/close transition duration in milliseconds. Defaults to 240. * @example { details: [{ summary: "More" }, { div: "Body" }], $: [details()] } */ function details( props: { color?: ValueOrState<ThemeColor>; accentColor?: ValueOrState<ThemeColor>; duration?: number; } = {}, ): PartialElement { const { duration = 240 } = props; const color = toState(props.color ?? "neutral", "color"); const accentColor = toState(props.accentColor ?? "primary", "accentColor"); return { _onInsert: (node) => { if (node.tagName !== "details") { console.warn(`"details" primitive patch must use details tag`); } }, style: { fontSize: (listener) => themeSize(listener, "inherit"), color: (listener) => themeColor(listener, "shift-9", color.get(listener)), backgroundColor: (listener) => themeColor(listener, "inherit", color.get(listener)), overflow: "hidden", "& > summary": { backgroundColor: (listener) => themeColor(listener, "shift-2", color.get(listener)), color: (listener) => themeColor(listener, "shift-10", color.get(listener)), fontSize: (listener) => themeSize(listener, "inherit"), listStyle: "none", display: "flex", justifyContent: "space-between", alignItems: "center", gap: themeSpacing(2), cursor: "pointer", userSelect: "none", fontWeight: 500, paddingInline: (listener) => themeSpacing(themeDensity(listener) * 4), height: themeSpacing(10), }, "& > summary::-webkit-details-marker": { display: "none", }, "& > summary::marker": { content: `""`, }, "& > summary::after": { content: `""`, width: themeSpacing(2), height: themeSpacing(2), flexShrink: 0, marginTop: `-${themeSpacing(0.5)}`, borderInlineEnd: (listener) => `${themeSpacing(0.5)} solid ${themeColor(listener, "shift-9", color.get(listener))}`, borderBottom: (listener) => `${themeSpacing(0.5)} solid ${themeColor(listener, "shift-9", color.get(listener))}`, transform: "rotate(45deg)", transition: `transform ${duration}ms ease`, }, "&[open] > summary::after": { transform: "rotate(-135deg)", }, "& > summary:hover": { backgroundColor: (listener) => themeColor(listener, "shift-3", color.get(listener)), }, "& > summary:focus-visible": { borderRadius: (listener) => themeSpacing(themeDensity(listener) * 2), outlineOffset: `-${themeSpacing(0.5)}`, outline: (listener) => `${themeSpacing(0.5)} solid ${themeColor(listener, "shift-6", accentColor.get(listener))}`, }, "& > :not(summary)": { maxHeight: "0px", opacity: 0, overflow: "hidden", paddingInline: (listener) => themeSpacing(themeDensity(listener) * 3), paddingTop: 0, paddingBottom: 0, transition: `max-height ${duration}ms ease, opacity ${duration}ms ease, padding ${duration}ms ease`, }, "&[open] > :not(summary)": { maxHeight: themeSpacing(250), opacity: 1, paddingTop: (listener) => themeSpacing(themeDensity(listener) * 1), paddingBottom: (listener) => themeSpacing(themeDensity(listener) * 3), }, }, }; } export { details }; ``` ### dialog ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeDensity, themeSize, themeSpacing, } from "@domphy/theme"; const FOCUSABLE = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), details, [tabindex]:not([tabindex="-1"])'; /** * Modal dialog patch driven by an `open` State. Calls `showModal()`/`close()`, * fades via opacity, locks page scroll while open, traps Tab focus within the * dialog, restores focus to the previously focused element on close, sets * `aria-modal`, and closes on outside (backdrop) click. Apply to a `<dialog>`. * * @hostTag dialog * @param props.color - Theme color tone for the dialog surface. Defaults to "neutral". * @param props.open - Open state (`ValueOrState<boolean>`); set it to true/false to show/hide. Defaults to false. * @example { dialog: [...], $: [dialog({ open })] } */ function dialog( props: { color?: ThemeColor; open?: ValueOrState<boolean> } = {}, ): PartialElement { const { color = "neutral", open = false } = props; const state = toState(open); let previousFocus: HTMLElement | null = null; let closing = false; return { _onInsert: (node) => { if (node.tagName !== "dialog") { console.warn(`"dialog" primitive patch must use dialog tag`); } }, onClick: (e: MouseEvent, node) => { if (e.target !== node.domElement) return; const r = node.domElement!.getBoundingClientRect(); const inside = e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom; if (!inside) state.set(false); }, onTransitionEnd: (_e, node) => { if (!closing) return; closing = false; const dlg = node.domElement as HTMLDialogElement; dlg.close(); document.body.style.overflow = ""; previousFocus?.focus(); previousFocus = null; }, _onMount: (node) => { const dlg = node.domElement as HTMLDialogElement; dlg.setAttribute("aria-modal", "true"); const trapFocus = (e: KeyboardEvent) => { if (e.key !== "Tab") return; const focusables = Array.from( dlg.querySelectorAll<HTMLElement>(FOCUSABLE), ).filter( (el) => !el.closest("[aria-hidden='true']") && el.offsetParent !== null, ); if (!focusables.length) { e.preventDefault(); return; } const first = focusables[0]; const last = focusables[focusables.length - 1]; if (e.shiftKey) { if ( document.activeElement === first || document.activeElement === dlg ) { e.preventDefault(); last.focus(); } } else { if (document.activeElement === last) { e.preventDefault(); first.focus(); } } }; const onCancel = (e: Event) => { e.preventDefault(); state.set(false); }; dlg.addEventListener("cancel", onCancel); const update = (val: boolean) => { if (val) { previousFocus = document.activeElement as HTMLElement; dlg.showModal(); document.body.style.overflow = "hidden"; dlg.addEventListener("keydown", trapFocus); requestAnimationFrame(() => { dlg.style.opacity = "1"; const focusable = dlg.querySelector<HTMLElement>(FOCUSABLE); focusable?.focus(); }); } else { closing = true; dlg.style.opacity = "0"; dlg.removeEventListener("keydown", trapFocus); // Fallback: if transitionend never fires (reduced-motion, display:none), // unblock close after the transition duration + buffer. setTimeout(() => { if (!closing) return; closing = false; dlg.close(); document.body.style.overflow = ""; previousFocus?.focus(); previousFocus = null; }, 350); } }; update(state.get()); const release = state.addListener(update); node.addHook("Remove", () => { release(); dlg.removeEventListener("cancel", onCancel); document.body.style.overflow = ""; dlg.removeEventListener("keydown", trapFocus); previousFocus?.focus(); previousFocus = null; }); }, style: { opacity: "0", transition: "opacity 200ms ease", fontSize: (listener) => themeSize(listener, "inherit"), color: (listener) => themeColor(listener, "shift-10", color), backgroundColor: (listener) => themeColor(listener, "inherit", color), border: "none", padding: (listener) => themeSpacing(themeDensity(listener) * 3), boxShadow: (listener) => `0 ${themeSpacing(9)} ${themeSpacing(16)} ${themeColor(listener, "shift-4", "neutral")}`, "&::backdrop": { backgroundColor: (listener) => themeColor(listener, "shift-2", "neutral"), opacity: 0.75, }, }, }; } export { dialog }; ``` ### divider ```ts import type { PartialElement } from "@domphy/core"; import { toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeSize, themeSpacing, } from "@domphy/theme"; /** * A horizontal separator (`role="separator"`) with a line on each side of its * content, suitable for labelled dividers ("or"). Apply to a `<div>` element. * * @hostTag div * @param props.color - Theme color tone (`ValueOrState<ThemeColor>`) for the label text and rules. Defaults to "neutral". * @example { div: "or", $: [divider()] } */ function divider( props: { color?: ValueOrState<ThemeColor> } = {}, ): PartialElement { const color = toState(props.color ?? "neutral", "color"); return { role: "separator", _onInsert: (node) => { if (node.tagName !== "div") { console.warn(`"divider" patch should be used with <div>`); } }, style: { display: "flex", justifyContent: "center", alignItems: "baseline", gap: themeSpacing(2), fontSize: (listener) => themeSize(listener, "inherit"), backgroundColor: (listener) => themeColor(listener), color: (listener) => themeColor(listener, "shift-9", color.get(listener)), minHeight: "1lh", "&::before": { content: `""`, flex: 1, borderColor: (listener) => themeColor(listener, "shift-4", color.get(listener)), borderWidth: "1px", borderBottomStyle: "solid", }, "&::after": { content: `""`, flex: 1, borderColor: (listener) => themeColor(listener, "shift-4", color.get(listener)), borderWidth: "1px", borderBottomStyle: "solid", }, }, }; } export { divider }; ``` ### drawer ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeDensity, themeSize, themeSpacing, } from "@domphy/theme"; type PhysicalPlacement = "left" | "right" | "top" | "bottom"; type Placement = PhysicalPlacement | "start" | "end"; const translateOut: Record<PhysicalPlacement, string> = { left: "translateX(-100%)", right: "translateX(100%)", top: "translateY(-100%)", bottom: "translateY(100%)", }; const marginMap: Record<PhysicalPlacement, string> = { left: "0 auto 0 0", right: "0 0 0 auto", top: "0 0 auto 0", bottom: "auto 0 0 0", }; const isVertical = (p: PhysicalPlacement) => p === "left" || p === "right"; function resolvePhysical( placement: Placement, isRTL: boolean, ): PhysicalPlacement { if (placement === "start") return isRTL ? "right" : "left"; if (placement === "end") return isRTL ? "left" : "right"; return placement; } /** * Edge-anchored modal drawer driven by an `open` State. Slides in/out from a * chosen edge via a 250 ms transform transition, calls `showModal()`/`close()`, * locks page scroll while open, and closes on backdrop click. A 350 ms fallback * ensures `close()` is always called even when `transitionend` doesn't fire * (reduced-motion, `display:none`, detached element). Apply to a `<dialog>`. * * Because the patch uses the native `<dialog>` `showModal()` API, the browser * traps focus inside the drawer while it is open and restores focus to the * previously focused element when `close()` is called. Sets `aria-modal="true"`. * Escape key closes the drawer via the animated state path (not immediate close). * * `"start"` and `"end"` placements resolve to left/right based on the * document's `dir` attribute at mount time, enabling RTL-aware drawers: * `"start"` → left (LTR) / right (RTL); `"end"` → right (LTR) / left (RTL). * * @hostTag dialog * @param props.color - Theme color tone for the drawer surface. Defaults to "neutral". * @param props.open - Open state (`ValueOrState<boolean>`); set true/false to show/hide. Defaults to false. * @param props.placement - Edge to anchor to. "left" | "right" | "top" | "bottom" | "start" | "end". Defaults to "end". * @param props.size - CSS length for the drawer's width (left/right/start/end) or height (top/bottom). Defaults to themeSpacing(80) for left/right, themeSpacing(64) for top/bottom. * @example { dialog: [...], $: [drawer({ open, placement: "start" })] } */ function drawer( props: { color?: ThemeColor; open?: ValueOrState<boolean>; placement?: Placement; size?: string; } = {}, ): PartialElement { const { color = "neutral", open = false, placement = "end", size } = props; const state = toState(open); const isLogical = placement === "start" || placement === "end"; // For static rendering / SSR assume LTR as fallback; corrected at mount time. const physicalFallback = resolvePhysical(placement, false); const defaultSize = isVertical(physicalFallback) ? themeSpacing(80) : themeSpacing(64); const drawerSize = size ?? defaultSize; return { _onInsert: (node) => { if (node.tagName !== "dialog") { console.warn(`"drawer" patch must use dialog tag`); } }, onClick: (e: MouseEvent, node) => { if (e.target !== node.domElement) return; state.set(false); }, _onMount: (node) => { const dlg = node.domElement as HTMLDialogElement; dlg.setAttribute("aria-modal", "true"); const onCancel = (e: Event) => { e.preventDefault(); state.set(false); }; dlg.addEventListener("cancel", onCancel); // Resolve logical placements at mount time using document direction. const isRTL = isLogical && (dlg.ownerDocument.documentElement.dir === "rtl" || dlg.ownerDocument.dir === "rtl"); const physical = resolvePhysical(placement, isRTL); // Correct initial styles for logical placements whose physical direction // may differ from the LTR fallback used in the static style block. if (isLogical) { dlg.style.transform = translateOut[physical]; dlg.style.margin = marginMap[physical]; dlg.style.width = isVertical(physical) ? drawerSize : "100dvw"; dlg.style.height = isVertical(physical) ? "100dvh" : drawerSize; } let closing = false; const finishClose = () => { if (!closing) return; closing = false; dlg.close(); document.body.style.overflow = ""; }; const onTransitionEnd = (e: Event) => { if (e.target !== dlg) return; if ((e as TransitionEvent).propertyName !== "transform") return; finishClose(); }; dlg.addEventListener("transitionend", onTransitionEnd); const update = (val: boolean) => { if (val) { closing = false; dlg.showModal(); document.body.style.overflow = "hidden"; requestAnimationFrame(() => { dlg.style.transform = "translate(0, 0)"; }); } else { closing = true; dlg.style.transform = translateOut[physical]; setTimeout(finishClose, 350); } }; update(state.get()); const release = state.addListener(update); node.addHook("Remove", () => { release(); dlg.removeEventListener("cancel", onCancel); dlg.removeEventListener("transitionend", onTransitionEnd); document.body.style.overflow = ""; }); }, style: { transform: translateOut[physicalFallback], transition: "transform 0.25s ease", fontSize: (listener) => themeSize(listener, "inherit"), color: (listener) => themeColor(listener, "shift-10", color), backgroundColor: (listener) => themeColor(listener, "inherit", color), border: "none", padding: (listener) => themeSpacing(themeDensity(listener) * 3), margin: marginMap[physicalFallback], width: isVertical(physicalFallback) ? drawerSize : "100dvw", height: isVertical(physicalFallback) ? "100dvh" : drawerSize, maxWidth: "100dvw", maxHeight: "100dvh", boxShadow: (listener) => `0 ${themeSpacing(4)} ${themeSpacing(12)} ${themeColor(listener, "shift-4", "neutral")}`, "&::backdrop": { backgroundColor: (listener) => themeColor(listener, "shift-2", "neutral"), opacity: 0.75, }, }, }; } export { drawer }; ``` ### emphasis ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeSize } from "@domphy/theme"; /** * Italic emphasized inline text. Apply to an `<em>` element. * * @hostTag em * @param props.color - Theme color tone (`ValueOrState<ThemeColor>`) for the text. Defaults to "neutral". * @example { em: "important", $: [emphasis()] } */ function emphasis( props: { color?: ValueOrState<ThemeColor> } = {}, ): PartialElement { const color = toState(props.color ?? "neutral", "color"); return { _onInsert: (node) => { if (node.tagName !== "em") { console.warn(`"emphasis" primitive patch must use em tag`); } }, style: { fontSize: (listener) => themeSize(listener, "inherit"), fontStyle: "italic", color: (listener) => themeColor(listener, "shift-10", color.get(listener)), }, }; } export { emphasis }; ``` ### empty ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeSpacing } from "@domphy/theme"; /** * Styles a container as an empty-state placeholder: centered flex column with * muted coloring and comfortable padding. Provide the icon, title, and * description as child elements. * * @param props.color - Theme color tone for the muted text/icon. Defaults to `"neutral"`. * @example * { div: [ * { span: "📭" }, * { p: "No items yet", $: [paragraph()] }, * { span: "Add your first item to get started", $: [small()] }, * ], $: [empty()] } */ function empty( props: { color?: ValueOrState<ThemeColor> } = {}, ): PartialElement { const color = toState(props.color ?? "neutral", "color"); return { style: { display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: themeSpacing(3), paddingBlock: themeSpacing(12), paddingInline: themeSpacing(6), textAlign: "center", color: (listener) => themeColor(listener, "shift-6", color.get(listener)), // First child (icon area): more muted color to visually recede behind the text "& > :first-child": { color: (listener) => themeColor(listener, "shift-5", color.get(listener)), }, }, }; } export { empty }; ``` ### errorBoundary ```ts import type { DomphyElement, PartialElement } from "@domphy/core"; /** * Catches errors thrown inside reactive child expressions and renders a * fallback element instead of crashing the whole tree. Apply to any container. * * Only errors in *reactive* children (functions returning element arrays) are * caught. Errors during static construction propagate normally — those are * programming errors, not runtime data errors. * * @hostTag any * @param props.fallback - Fallback element or factory `(error, reset) => element`. Defaults to a plain error message div. * @param props.onError - Optional callback for logging/telemetry. * @example { div: (l) => renderUserContent(l), $: [errorBoundary({ fallback: { p: "Something went wrong." } })] } */ function errorBoundary( props: { fallback?: | DomphyElement | ((error: unknown, reset: () => void) => DomphyElement); onError?: (error: unknown) => void; } = {}, ): PartialElement { return { _onError: (node, error, reset) => { props.onError?.(error); const fallbackEl = typeof props.fallback === "function" ? props.fallback(error, reset) : (props.fallback ?? ({ div: "An error occurred." } as DomphyElement)); node.children.update([fallbackEl]); }, }; } export { errorBoundary }; ``` ### fab ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeSize, themeSpacing, } from "@domphy/theme"; const SIZE_MAP = { small: 8, medium: 10, large: 14 } as const; /** * Floating Action Button — a circular elevated button typically used for the * primary action on a screen. Apply to a `<button>` element. * * @hostTag button * @param props.color - Button color tone. Optional `ValueOrState<ThemeColor>`, defaults to `"primary"`. * @param props.size - Button size preset. Optional `"small" | "medium" | "large"`, defaults to `"medium"`. * @example { button: "+", $: [fab()] } * @example { button: "+", $: [fab({ size: "small", color: "neutral" })] } */ function fab( props: { color?: ValueOrState<ThemeColor>; size?: "small" | "medium" | "large"; } = {}, ): PartialElement { const color = toState(props.color ?? "primary", "color"); const dim = themeSpacing(SIZE_MAP[props.size ?? "medium"]); return { _onInsert: (node) => { if (node.tagName !== "button") { console.warn('"fab" patch must use button tag'); } }, style: { appearance: "none", border: "none", cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center", flexShrink: 0, width: dim, height: dim, borderRadius: "50%", fontSize: (listener) => themeSize(listener, "inherit"), backgroundColor: (listener) => themeColor(listener, "shift-9", color.get(listener)), color: (listener) => themeColor(listener, "shift-0", color.get(listener)), boxShadow: (listener) => `0 ${themeSpacing(1)} ${themeSpacing(4)} ${themeColor(listener, "shift-4", "neutral")}`, transition: "background-color 200ms ease, box-shadow 200ms ease", "&:hover:not([disabled])": { backgroundColor: (listener) => themeColor(listener, "shift-10", color.get(listener)), boxShadow: (listener) => `0 ${themeSpacing(2)} ${themeSpacing(6)} ${themeColor(listener, "shift-5", "neutral")}`, }, "&:focus-visible": { outline: (listener) => `2px solid ${themeColor(listener, "shift-6", color.get(listener))}`, outlineOffset: "2px", }, "&[disabled]": { opacity: 0.5, cursor: "not-allowed" }, }, }; } export { fab }; ``` ### figure ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeSize, themeSpacing, } from "@domphy/theme"; /** * Lays out a figure as a column with block-level media (img/svg/video/canvas) * and a themed `<figcaption>`. Apply to a `<figure>` element. * * @hostTag figure * @param props.color - Theme color tone (`ValueOrState<ThemeColor>`) for the figure/caption text. Defaults to "neutral". * @example { figure: [{ img: "", src }, { figcaption: "A caption" }], $: [figure()] } */ function figure( props: { color?: ValueOrState<ThemeColor> } = {}, ): PartialElement { const color = toState(props.color ?? "neutral", "color"); return { _onInsert: (node) => { if (node.tagName !== "figure") { console.warn(`"figure" primitive patch must use figure tag`); } }, style: { display: "flex", flexDirection: "column", gap: themeSpacing(2), marginInline: 0, marginTop: themeSpacing(3), marginBottom: themeSpacing(3), color: (listener) => themeColor(listener, "shift-9", color.get(listener)), "& img, & svg, & video, & canvas": { display: "block", maxWidth: "100%", borderRadius: themeSpacing(2), }, "& figcaption": { fontSize: (listener) => themeSize(listener, "decrease-1"), color: (listener) => themeColor(listener, "shift-8", color.get(listener)), lineHeight: 1.45, }, }, }; } export { figure }; ``` ### formGroup ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeDensity, themeSize, themeSpacing, } from "@domphy/theme"; /** * Layout patch for a group of form fields. Arranges a `<legend>`, `<label>`s, * controls, and helper `<p>`s in a grid — labels beside controls (horizontal) * or stacked above them (vertical). Apply to a `<fieldset>` element. * * @hostTag fieldset * @param props.color - Theme color tone (`ValueOrState<ThemeColor>`) for legend/text/surface. Defaults to "neutral". * @param props.layout - Field arrangement, "horizontal" (label beside control) | "vertical" (label above). Defaults to "horizontal". * @example { fieldset: [{ legend: "Profile" }, { label: "Name" }, { input: "" }], $: [formGroup({ layout: "vertical" })] } */ function formGroup( props: { color?: ValueOrState<ThemeColor>; layout?: "horizontal" | "vertical"; } = {}, ): PartialElement { const { layout = "horizontal" } = props; const color = toState(props.color ?? "neutral", "color"); const isVertical = layout === "vertical"; return { _onInsert: (node) => { if (node.tagName !== "fieldset") { console.warn(`"formGroup" patch must use fieldset tag`); } }, style: { margin: 0, paddingInline: (listener) => themeSpacing(themeDensity(listener) * 3), paddingBlock: (listener) => themeSpacing(themeDensity(listener) * 3), border: "none", borderRadius: (listener) => themeSpacing(themeDensity(listener) * 2), fontSize: (listener) => themeSize(listener, "inherit"), backgroundColor: (listener) => themeColor(listener, "inherit", color.get(listener)), display: "grid", gridTemplateColumns: isVertical ? `minmax(0, 1fr)` : `max-content minmax(0, 1fr)`, columnGap: themeSpacing(4), rowGap: themeSpacing(3), alignItems: "start", "& > legend": { gridColumn: "1 / -1", margin: 0, fontSize: (listener) => themeSize(listener, "inherit"), fontWeight: 600, paddingBlock: (listener) => themeSpacing(themeDensity(listener) * 1), borderRadius: (listener) => themeSpacing(themeDensity(listener) * 2), color: (listener) => themeColor(listener, "shift-9", color.get(listener)), backgroundColor: (listener) => themeColor(listener, "inherit", color.get(listener)), }, "& > label": { gridColumn: "1", alignSelf: "start", margin: 0, paddingBlock: (listener) => isVertical ? "0px" : themeSpacing(themeDensity(listener) * 1), }, "& > label:has(+ :not(legend, label, p) + p)": { gridRow: isVertical ? "auto" : "span 2", }, "& > :not(legend, label, p)": { gridColumn: isVertical ? "1" : "2", minWidth: 0, width: "100%", boxSizing: "border-box", }, "& > p": { gridColumn: isVertical ? "1" : "2", minWidth: 0, margin: 0, marginBlockStart: `calc(${themeSpacing(2)} * -1)`, fontSize: (listener) => themeSize(listener, "decrease-1"), color: (listener) => themeColor(listener, "shift-9", color.get(listener)), }, }, }; } export { formGroup }; ``` ### heading ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeSize, themeSpacing, } from "@domphy/theme"; const Headinghift: Record<string, string> = { h6: "decrease-1", h5: "inherit", h4: "increase-1", h3: "increase-2", h2: "increase-3", h1: "increase-4", }; /** * Styles a heading, scaling its font size by level (h1 largest … h6 smallest) * relative to the theme base size. Apply to a heading element `<h1>`–`<h6>`. * * @hostTag h1 * @param props.color - Theme color tone (`ValueOrState<ThemeColor>`) for the heading text. Defaults to "neutral". * @example { h2: "Section title", $: [heading()] } */ function heading( props: { color?: ValueOrState<ThemeColor> } = {}, ): PartialElement { const color = toState(props.color ?? "neutral", "color"); return { _onInsert: (node) => { if (!["h1", "h2", "h3", "h4", "h5", "h6"].includes(node.tagName)) { console.warn( `"heading" primitive patch must use heading tags [h1...h6]`, ); } }, style: { color: (listener) => themeColor(listener, "shift-11", color.get(listener)), marginTop: 0, marginBottom: themeSpacing(2), fontSize: (listener) => { const offset = Headinghift[listener.elementNode.tagName] || "inherit"; return themeSize(listener, offset); }, }, }; } export { heading }; ``` ### horizontalRule ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeSpacing } from "@domphy/theme"; /** * A thematic break rendered as a thin 1px themed line with vertical margin. * Apply to an `<hr>` element. * * @hostTag hr * @param props.color - Theme color tone (`ValueOrState<ThemeColor>`) for the rule. Defaults to "neutral". * @example { hr: "", $: [horizontalRule()] } */ function horizontalRule( props: { color?: ValueOrState<ThemeColor> } = {}, ): PartialElement { const color = toState(props.color ?? "neutral", "color"); return { _onInsert: (node) => { if (node.tagName !== "hr") { console.warn(`"horizontalRule" primitive patch must use hr tag`); } }, style: { border: 0, height: "1px", marginInline: 0, marginTop: themeSpacing(3), marginBottom: themeSpacing(3), backgroundColor: (listener) => themeColor(listener, "shift-4", color.get(listener)), }, }; } export { horizontalRule }; ``` ### icon ```ts import type { PartialElement } from "@domphy/core"; import { toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeSize, themeSpacing, } from "@domphy/theme"; /** * Styles an inline icon container: square box that centers its content and * applies the themed icon color. Apply to a `<span>` element. * * @hostTag span * @param props.color - Optional theme color tone for the icon (`ValueOrState<ThemeColor>`). Defaults to `"neutral"`. * @example { span: null, $: [icon()] } */ function icon( props: { color?: ValueOrState<ThemeColor> } = {}, ): PartialElement { const color = toState(props.color ?? "neutral", "color"); return { _onInsert: (node) => { if (node.tagName !== "span") { console.warn(`"icon" primitive patch should use span tag`); } }, style: { display: "inline-flex", alignItems: "center", justifyContent: "center", alignSelf: "center", justifySelf: "center", verticalAlign: "middle", width: themeSpacing(4), height: themeSpacing(4), flexShrink: "0", fontSize: (listener) => themeSize(listener), backgroundColor: "transparent", color: (listener) => themeColor(listener, "shift-9", color.get(listener)), }, }; } export { icon }; ``` ### image ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeSpacing } from "@domphy/theme"; /** * Styles a responsive image: full-width, cover-fit, rounded corners with a * themed placeholder background. Apply to an `<img>` element. * * @hostTag img * @param props.color - Optional theme color tone for the placeholder background (`ValueOrState<ThemeColor>`). Defaults to `"neutral"`. * @example { img: null, src: "photo.jpg", alt: "Photo", $: [image()] } */ function image( props: { color?: ValueOrState<ThemeColor> } = {}, ): PartialElement { const color = toState(props.color ?? "neutral", "color"); return { dataTone: "shift-2", _onInsert: (node) => { if (node.tagName !== "img") { console.warn(`"image" primitive patch must use img tag`); } }, style: { display: "block", width: "100%", maxWidth: "100%", height: "auto", objectFit: "cover", borderRadius: themeSpacing(2), backgroundColor: (listener) => themeColor(listener, "inherit", color.get(listener)), }, }; } export { image }; ``` ### inputCheckbox ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeSize, themeSpacing, } from "@domphy/theme"; /** * Styles a custom checkbox with themed box, check mark, indeterminate state, * hover, focus and disabled styling. Apply to an `<input>` element of * type `checkbox` (the patch sets `type: "checkbox"`). * * @hostTag input * @param props.color - Optional theme color tone for the box/border (`ValueOrState<ThemeColor>`). Defaults to `"neutral"`. * @param props.accentColor - Optional theme color tone for the checked/indeterminate fill and focus ring (`ValueOrState<ThemeColor>`). Defaults to `"primary"`. * @example { input: null, type: "checkbox", $: [inputCheckbox()] } */ function inputCheckbox( props: { color?: ValueOrState<ThemeColor>; accentColor?: ValueOrState<ThemeColor>; } = {}, ): PartialElement { const color = toState(props.color ?? "neutral", "color"); const accentColor = toState(props.accentColor ?? "primary", "accentColor"); return { type: "checkbox", _onInsert: (node) => { if (node.tagName !== "input") { console.warn(`"inputCheckbox" primitive patch must use input tag`); } }, style: { appearance: "none", fontSize: (listener) => themeSize(listener, "inherit"), display: "inline-flex", position: "relative", width: themeSpacing(6), height: themeSpacing(6), justifyContent: "center", alignItems: "center", transition: "background-color 300ms, outline-color 300ms", margin: 0, padding: 0, "&::before": { content: `""`, display: "block", borderRadius: themeSpacing(1), lineHeight: 1, cursor: "pointer", border: "none", outlineOffset: "-1px", outline: (listener) => `1px solid ${themeColor(listener, "shift-4", color.get(listener))}`, color: (listener) => themeColor(listener, "shift-9", color.get(listener)), width: themeSpacing(4), height: themeSpacing(4), }, "&:hover::before": { backgroundColor: (listener) => themeColor(listener, "shift-2", color.get(listener)), }, "&:checked::before": { outline: (listener) => `1px solid ${themeColor(listener, "shift-6", accentColor.get(listener))}`, backgroundColor: (listener) => themeColor(listener, "shift-8", accentColor.get(listener)), }, "&:checked:hover:not([disabled])::before": { backgroundColor: (listener) => themeColor(listener, "shift-7", accentColor.get(listener)), }, "&:checked::after": { content: `""`, display: "block", position: "absolute", top: "25%", insetInlineStart: "37%", width: "20%", height: "30%", border: (listener) => `${themeSpacing(0.5)} solid ${themeColor(listener, "inherit", accentColor.get(listener))}`, borderTop: 0, borderInlineStart: 0, transform: "rotate(45deg)", }, "&:indeterminate::before": { outline: (listener) => `1px solid ${themeColor(listener, "shift-6", accentColor.get(listener))}`, backgroundColor: (listener) => themeColor(listener, "shift-3", accentColor.get(listener)), }, "&:indeterminate::after": { content: `""`, position: "absolute", inset: "30%", backgroundColor: (listener) => themeColor(listener, "shift-8", accentColor.get(listener)), }, "&:indeterminate:hover:not([disabled])::after": { backgroundColor: (listener) => themeColor(listener, "shift-7", accentColor.get(listener)), }, "&:focus-visible": { borderRadius: themeSpacing(1.5), outline: (listener) => `${themeSpacing(0.5)} solid ${themeColor(listener, "shift-6", accentColor.get(listener))}`, }, "&[disabled]": { cursor: "not-allowed", }, "&[disabled]::before, &[disabled]::after": { outline: "none", backgroundColor: (listener) => themeColor(listener, "shift-4", "neutral"), pointerEvents: "none", }, }, }; } export { inputCheckbox }; ``` ### inputColor ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeDensity, themeSize, themeSpacing, } from "@domphy/theme"; /** * Styles a native color picker swatch with themed padding, rounded swatch and * disabled styling. Apply to an `<input>` element of type `color` (the patch * sets `type: "color"`). * * @hostTag input * @param props.color - Optional theme color tone used for the disabled state (`ValueOrState<ThemeColor>`). Defaults to `"neutral"`. * @param props.accentColor - Optional theme color tone (`ValueOrState<ThemeColor>`). Defaults to `"primary"`. * @example { input: null, type: "color", $: [inputColor()] } */ function inputColor( props: { color?: ValueOrState<ThemeColor>; accentColor?: ValueOrState<ThemeColor>; } = {}, ): PartialElement { const _color = toState(props.color ?? "neutral", "color"); const _accentColor = toState(props.accentColor ?? "primary", "accentColor"); return { type: "color", _onSchedule: (node, element) => { if (node.tagName !== "input") { console.warn(`"inputColor" primitive patch must use input tag`); } (element as any).type = "color"; }, style: { appearance: "none", border: "none", cursor: "pointer", fontSize: (listener) => themeSize(listener, "inherit"), paddingBlock: (listener) => themeSpacing(themeDensity(listener) * 1), paddingInline: (listener) => themeSpacing(themeDensity(listener) * 1), blockSize: (listener) => themeSpacing(6 + themeDensity(listener) * 2), inlineSize: (listener) => themeSpacing(6 + themeDensity(listener) * 2), backgroundColor: "transparent", "&::-webkit-color-swatch-wrapper": { margin: 0, padding: 0, }, "&::-webkit-color-swatch": { borderRadius: (listener) => themeSpacing(themeDensity(listener) * 1), }, "&:hover:not([disabled]), &:focus-visible": {}, "&[disabled]": { opacity: 0.7, cursor: "not-allowed", backgroundColor: (listener) => themeColor(listener, "shift-2", "neutral"), outline: (listener) => `1px solid ${themeColor(listener, "shift-4", "neutral")}`, }, }, }; } export { inputColor }; ``` ### inputDateTime ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeDensity, themeSize, themeSpacing, } from "@domphy/theme"; type InputDateTimeMode = "date" | "time" | "week" | "month" | "datetime-local"; /** * Styles a native date/time input with themed border, padding, hover, focus, * invalid and disabled states. The `mode` selects the input `type`. Apply to * an `<input>` element (the patch sets `type` to the chosen `mode`). * * @hostTag input * @param props.mode - Input mode selecting the host `type`: `"date" | "time" | "week" | "month" | "datetime-local"`. Defaults to `"datetime-local"`. * @param props.color - Optional theme color tone for text/border (`ValueOrState<ThemeColor>`). Defaults to `"neutral"`. * @param props.accentColor - Optional theme color tone for the hover/focus ring (`ValueOrState<ThemeColor>`). Defaults to `"primary"`. * @example { input: null, type: "datetime-local", $: [inputDateTime()] } */ function inputDateTime( props: { mode?: InputDateTimeMode; color?: ValueOrState<ThemeColor>; accentColor?: ValueOrState<ThemeColor>; } = {}, ): PartialElement { const { mode = "datetime-local" } = props; const color = toState(props.color ?? "neutral", "color"); const accentColor = toState(props.accentColor ?? "primary", "accentColor"); return { type: mode, _onSchedule: (node, element) => { if (node.tagName !== "input") { console.warn(`"inputDateTime" primitive patch must use input tag`); } (element as any).type = mode; }, style: { fontFamily: "inherit", fontSize: (listener) => themeSize(listener, "inherit"), lineHeight: "inherit", color: (listener) => themeColor(listener, "shift-9", color.get(listener)), backgroundColor: (listener) => themeColor(listener, "inherit", color.get(listener)), border: "none", outlineOffset: "-1px", outline: (listener) => `1px solid ${themeColor(listener, "shift-4", color.get(listener))}`, borderRadius: (listener) => themeSpacing(themeDensity(listener) * 1), paddingInline: (listener) => themeSpacing(themeDensity(listener) * 3), height: (listener) => themeSpacing(6 + themeDensity(listener) * 2), "&::-webkit-calendar-picker-indicator": { cursor: "pointer", opacity: 0.85, }, "&:hover:not([disabled]):not([aria-busy=true]), &:focus-visible": { outline: (listener) => `${themeSpacing(0.5)} solid ${themeColor(listener, "shift-6", accentColor.get(listener))}`, }, "&[disabled]": { opacity: 0.7, cursor: "not-allowed", color: (listener) => themeColor(listener, "shift-8", "neutral"), backgroundColor: (listener) => themeColor(listener, "shift-2", "neutral"), outline: (listener) => `1px solid ${themeColor(listener, "shift-4", "neutral")}`, }, "&:invalid": { outline: (listener) => `${themeSpacing(0.5)} solid ${themeColor(listener, "shift-6", "error")}`, }, }, }; } export { inputDateTime }; ``` ### inputFile ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeDensity, themeSize, themeSpacing, } from "@domphy/theme"; /** * Styles a native file input with a themed upload button, border, hover, focus * and disabled states. Apply to an `<input>` element of type `file` (the patch * sets `type: "file"`). * * @hostTag input * @param props.color - Optional theme color tone for text/border and the upload button (`ValueOrState<ThemeColor>`). Defaults to `"neutral"`. * @param props.accentColor - Optional theme color tone for the hover/focus ring (`ValueOrState<ThemeColor>`). Defaults to `"primary"`. * @example { input: null, type: "file", $: [inputFile()] } */ function inputFile( props: { color?: ValueOrState<ThemeColor>; accentColor?: ValueOrState<ThemeColor>; } = {}, ): PartialElement { const color = toState(props.color ?? "neutral", "color"); const accentColor = toState(props.accentColor ?? "primary", "accentColor"); return { type: "file", _onSchedule: (node, element) => { if (node.tagName !== "input") { console.warn(`"inputFile" primitive patch must use input tag`); } (element as any).type = "file"; }, style: { display: "inline-flex", alignItems: "center", fontFamily: "inherit", fontSize: (listener) => themeSize(listener, "inherit"), lineHeight: "inherit", color: (listener) => themeColor(listener, "shift-9", color.get(listener)), backgroundColor: (listener) => themeColor(listener, "inherit", color.get(listener)), border: "none", outlineOffset: "-1px", outline: (listener) => `1px solid ${themeColor(listener, "shift-4", color.get(listener))}`, borderRadius: (listener) => themeSpacing(themeDensity(listener) * 1), height: (listener) => themeSpacing(6 + themeDensity(listener) * 2), paddingInline: (listener) => themeSpacing(themeDensity(listener) * 1), "&::-webkit-file-upload-button": { marginTop: (listener) => themeSpacing(themeDensity(listener)), fontFamily: "inherit", fontSize: "inherit", border: "none", borderRadius: themeSpacing(1), height: themeSpacing(6), paddingInline: themeSpacing(2), cursor: "pointer", color: (listener) => themeColor(listener, "shift-11", color.get(listener)), backgroundColor: (listener) => themeColor(listener, "shift-1", color.get(listener)), }, "&:hover:not([disabled]):not([aria-busy=true]), &:focus-visible": { outline: (listener) => `${themeSpacing(0.5)} solid ${themeColor(listener, "shift-6", accentColor.get(listener))}`, }, "&[disabled]": { opacity: 0.8, cursor: "not-allowed", color: (listener) => themeColor(listener, "shift-8", "neutral"), outline: (listener) => `1px solid ${themeColor(listener, "shift-4", "neutral")}`, backgroundColor: (listener) => themeColor(listener, "shift-1", "neutral"), }, "&[disabled]::-webkit-file-upload-button": { cursor: "not-allowed", color: (listener) => themeColor(listener, "shift-8", "neutral"), backgroundColor: (listener) => themeColor(listener, "shift-3", "neutral"), }, }, }; } export { inputFile }; ``` ### inputNumber ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeDensity, themeSize, themeSpacing, } from "@domphy/theme"; /** * Styles a native number input with themed border, padding, visible spin * buttons, hover, focus and disabled states. Apply to an `<input>` element of * type `number` (the patch sets `type: "number"`). * * @hostTag input * @param props.color - Optional theme color tone for text/border (`ValueOrState<ThemeColor>`). Defaults to `"neutral"`. * @param props.accentColor - Optional theme color tone for the hover/focus ring (`ValueOrState<ThemeColor>`). Defaults to `"primary"`. * @example { input: null, type: "number", $: [inputNumber()] } */ function inputNumber( props: { color?: ValueOrState<ThemeColor>; accentColor?: ValueOrState<ThemeColor>; } = {}, ): PartialElement { const color = toState(props.color ?? "neutral", "color"); const accentColor = toState(props.accentColor ?? "primary", "accentColor"); return { type: "number", _onSchedule: (node, element) => { if (node.tagName !== "input") { console.warn(`"inputNumber" primitive patch must use input tag`); } (element as any).type = "number"; }, style: { fontFamily: "inherit", lineHeight: "inherit", minWidth: themeSpacing(10), paddingInlineStart: (listener) => themeSpacing(themeDensity(listener) * 3), paddingInlineEnd: (listener) => themeSpacing(themeDensity(listener) * 1.5), paddingBlock: (listener) => themeSpacing(themeDensity(listener) * 1), borderRadius: (listener) => themeSpacing(themeDensity(listener) * 1), fontSize: (listener) => themeSize(listener, "inherit"), border: "none", outlineOffset: "-1px", outline: (listener) => `1px solid ${themeColor(listener, "shift-4", color.get(listener))}`, color: (listener) => themeColor(listener, "shift-9", color.get(listener)), backgroundColor: (listener) => themeColor(listener, "inherit", color.get(listener)), "&::-webkit-inner-spin-button, &::-webkit-outer-spin-button": { opacity: 1, }, "&:hover:not([disabled]):not([aria-busy=true]), &:focus-visible": { outline: (listener) => `${themeSpacing(0.5)} solid ${themeColor(listener, "shift-6", accentColor.get(listener))}`, }, "&[disabled]": { opacity: 0.7, cursor: "not-allowed", backgroundColor: (listener) => themeColor(listener, "shift-2", "neutral"), outline: (listener) => `1px solid ${themeColor(listener, "shift-4", "neutral")}`, color: (listener) => themeColor(listener, "shift-8", "neutral"), }, }, }; } export { inputNumber }; ``` ### inputOTP ```ts import type { PartialElement } from "@domphy/core"; import { themeSpacing } from "@domphy/theme"; /** * Lays out a one-time-password container as a horizontal row of inputs and * wires keyboard navigation: auto-advance on input, backspace/arrow movement, * and paste distribution across the child inputs. Apply to a container element * (e.g. `<div>`) whose direct children are the OTP `<input>` boxes. Takes no * props. * * @example { div: null, $: [inputOTP()], children: [{ input: null }, { input: null }] } */ function inputOTP(): PartialElement { return { style: { display: "flex", alignItems: "center", gap: themeSpacing(2), "& > *": { minWidth: `${themeSpacing(9)}!important`, }, }, _onMount: (node) => { const container = node.domElement as HTMLElement; const getInputs = () => Array.from(container.querySelectorAll("input")) as HTMLInputElement[]; const onInput = (e: Event) => { const inputs = getInputs(); const target = e.target as HTMLInputElement; const idx = inputs.indexOf(target); if (target.value && idx < inputs.length - 1) { inputs[idx + 1].focus(); } }; const onKeydown = (e: KeyboardEvent) => { const inputs = getInputs(); const target = e.target as HTMLInputElement; const idx = inputs.indexOf(target); if (e.key === "Backspace" && !target.value && idx > 0) { inputs[idx - 1].focus(); } if (e.key === "ArrowLeft" && idx > 0) inputs[idx - 1].focus(); if (e.key === "ArrowRight" && idx < inputs.length - 1) inputs[idx + 1].focus(); }; const onPaste = (e: ClipboardEvent) => { e.preventDefault(); const text = e.clipboardData?.getData("text") ?? ""; const inputs = getInputs(); const found = inputs.indexOf(e.target as HTMLInputElement); const startIdx = found === -1 ? 0 : found; [...text].forEach((char, i) => { if (inputs[startIdx + i]) inputs[startIdx + i].value = char; }); const lastFilled = Math.min( startIdx + text.length - 1, inputs.length - 1, ); inputs[lastFilled]?.focus(); }; container.addEventListener("input", onInput); container.addEventListener("keydown", onKeydown as EventListener); container.addEventListener("paste", onPaste as EventListener); node.addHook("Remove", () => { container.removeEventListener("input", onInput); container.removeEventListener("keydown", onKeydown as EventListener); container.removeEventListener("paste", onPaste as EventListener); }); }, }; } export { inputOTP }; ``` ### inputRadio ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeSize, themeSpacing, } from "@domphy/theme"; /** * Styles a custom radio button with a themed circular box, checked dot, hover, * focus and disabled states. Apply to an `<input>` element of type `radio` * (the patch sets `type: "radio"`). * * @hostTag input * @param props.color - Optional theme color tone for the box/border (`ValueOrState<ThemeColor>`). Defaults to `"neutral"`. * @param props.accentColor - Optional theme color tone for the checked dot and focus ring (`ValueOrState<ThemeColor>`). Defaults to `"primary"`. * @example { input: null, type: "radio", $: [inputRadio()] } */ function inputRadio( props: { color?: ValueOrState<ThemeColor>; accentColor?: ValueOrState<ThemeColor>; } = {}, ): PartialElement { const color = toState(props.color ?? "neutral", "color"); const accentColor = toState(props.accentColor ?? "primary", "accentColor"); return { type: "radio", _onInsert: (node) => { if (node.tagName !== "input") { console.warn( `"inputRadio" primitive patch must use input tag and radio type`, ); return; } }, style: { fontSize: (listener) => themeSize(listener, "inherit"), appearance: "none", display: "inline-flex", position: "relative", width: themeSpacing(6), height: themeSpacing(6), justifyContent: "center", alignItems: "center", transition: "background-color 300ms, outline-color 300ms", margin: 0, padding: 0, "&::before": { content: `""`, display: "block", borderRadius: "50%", lineHeight: 1, cursor: "pointer", border: "none", outlineOffset: "-1px", outline: (listener) => `1px solid ${themeColor(listener, "shift-4", color.get(listener))}`, color: (listener) => themeColor(listener, "shift-9", color.get(listener)), width: themeSpacing(4), height: themeSpacing(4), }, "&:hover::before": { backgroundColor: (listener) => themeColor(listener, "shift-2", color.get(listener)), }, "&:checked::before": { outline: (listener) => `1px solid ${themeColor(listener, "shift-6", accentColor.get(listener))}`, }, "&:checked::after": { content: `""`, position: "absolute", inset: "30%", borderRadius: "50%", backgroundColor: (listener) => themeColor(listener, "shift-8", accentColor.get(listener)), }, "&:checked:hover:not([disabled])::before": { backgroundColor: (listener) => themeColor(listener, "shift-7", accentColor.get(listener)), }, "&:focus-visible": { borderRadius: "50%", outline: (listener) => `${themeSpacing(0.5)} solid ${themeColor(listener, "shift-6", accentColor.get(listener))}`, }, "&[disabled]": { cursor: "not-allowed", }, "&[disabled]::before, &[disabled]::after": { outline: "none", backgroundColor: (listener) => themeColor(listener, "shift-4", "neutral"), pointerEvents: "none", }, }, }; } export { inputRadio }; ``` ### inputRange ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeSpacing } from "@domphy/theme"; /** * Styles a range slider with a themed track and thumb, hover, focus and * disabled states. Apply to an `<input>` element of type `range` (the patch * sets `type: "range"`). * * @hostTag input * @param props.color - Optional theme color tone for the slider track (`ValueOrState<ThemeColor>`). Defaults to `"neutral"`. * @param props.accentColor - Optional theme color tone for the thumb and focus ring (`ValueOrState<ThemeColor>`). Defaults to `"primary"`. * @example { input: null, type: "range", $: [inputRange()] } */ function inputRange( props: { color?: ValueOrState<ThemeColor>; accentColor?: ValueOrState<ThemeColor>; } = {}, ): PartialElement { const color = toState(props.color ?? "neutral", "color"); const accentColor = toState(props.accentColor ?? "primary", "accentColor"); return { type: "range", _onInsert: (node) => { if (node.tagName !== "input") { console.warn(`"inputRange" primitive patch must use input tag`); } }, style: { appearance: "none", width: "100%", margin: 0, padding: 0, height: themeSpacing(4), background: "transparent", cursor: "pointer", "&::-webkit-slider-runnable-track": { height: themeSpacing(1.5), borderRadius: themeSpacing(999), backgroundColor: (listener) => themeColor(listener, "shift-3", color.get(listener)), }, "&::-webkit-slider-thumb": { appearance: "none", width: themeSpacing(4), height: themeSpacing(4), borderRadius: themeSpacing(999), border: "none", marginTop: `calc((${themeSpacing(1.5)} - ${themeSpacing(4)}) / 2)`, backgroundColor: (listener) => themeColor(listener, "shift-9", accentColor.get(listener)), }, "&:hover:not([disabled])::-webkit-slider-thumb": { backgroundColor: (listener) => themeColor(listener, "shift-10", accentColor.get(listener)), }, "&:focus-visible": { outline: (listener) => `${themeSpacing(0.5)} solid ${themeColor(listener, "shift-6", accentColor.get(listener))}`, outlineOffset: themeSpacing(1), borderRadius: themeSpacing(2), }, "&[disabled]": { opacity: 0.7, cursor: "not-allowed", }, }, }; } export { inputRange }; ``` ### inputSearch ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeDensity, themeSize, themeSpacing, } from "@domphy/theme"; /** * Styles a search input with themed border, padding, placeholder color, native * search decorations, hover, focus and disabled states. Apply to an `<input>` * element of type `search` (the patch sets `type: "search"`). * * @hostTag input * @param props.color - Optional theme color tone for text/border/placeholder (`ValueOrState<ThemeColor>`). Defaults to `"neutral"`. * @param props.accentColor - Optional theme color tone for the hover/focus ring (`ValueOrState<ThemeColor>`). Defaults to `"primary"`. * @example { input: null, type: "search", $: [inputSearch()] } */ function inputSearch( props: { color?: ValueOrState<ThemeColor>; accentColor?: ValueOrState<ThemeColor>; } = {}, ): PartialElement { const color = toState(props.color ?? "neutral", "color"); const accentColor = toState(props.accentColor ?? "primary", "accentColor"); return { type: "search", _onSchedule: (node, element) => { if (node.tagName !== "input") { console.warn(`"inputSearch" primitive patch must use input tag`); } (element as any).type = "search"; }, style: { fontFamily: "inherit", fontSize: (listener) => themeSize(listener, "inherit"), lineHeight: "inherit", color: (listener) => themeColor(listener, "shift-9", color.get(listener)), backgroundColor: (listener) => themeColor(listener, "inherit", color.get(listener)), border: "none", outlineOffset: "-1px", outline: (listener) => `1px solid ${themeColor(listener, "shift-4", color.get(listener))}`, borderRadius: (listener) => themeSpacing(themeDensity(listener) * 1), minWidth: themeSpacing(32), paddingInline: (listener) => themeSpacing(themeDensity(listener) * 3), paddingBlock: (listener) => themeSpacing(themeDensity(listener) * 1), "&::placeholder": { color: (listener) => themeColor(listener, "shift-7", color.get(listener)), }, "&::-webkit-search-decoration": { display: "none", }, "&::-webkit-search-cancel-button": { cursor: "pointer", }, "&:hover:not([disabled]):not([aria-busy=true]), &:focus-visible": { outline: (listener) => `${themeSpacing(0.5)} solid ${themeColor(listener, "shift-6", accentColor.get(listener))}`, }, "&[disabled]": { opacity: 0.7, cursor: "not-allowed", color: (listener) => themeColor(listener, "shift-8", "neutral"), backgroundColor: (listener) => themeColor(listener, "shift-2", "neutral"), outline: (listener) => `1px solid ${themeColor(listener, "shift-4", "neutral")}`, }, }, }; } export { inputSearch }; ``` ### inputSwitch ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeSize, themeSpacing, } from "@domphy/theme"; /** * Styles a checkbox as a toggle switch: themed track and sliding knob that * animates and recolors on checked, plus a disabled state. Apply to an * `<input>` element of type `checkbox` (the patch sets `type: "checkbox"`). * * @hostTag input * @param props.accentColor - Optional theme color tone for the checked track (`ValueOrState<ThemeColor>`). Defaults to `"primary"`. * @example { input: null, type: "checkbox", $: [inputSwitch()] } */ function inputSwitch( props: { accentColor?: ValueOrState<ThemeColor> } = {}, ): PartialElement { const accentColor = toState(props.accentColor ?? "primary", "accentColor"); return { role: "switch", dataTone: "shift-2", type: "checkbox", _onSchedule: (node) => { if (node.tagName !== "input") { console.warn(`"inputSwitch" primitive patch must use input tag`); return; } }, style: { fontSize: (listener) => themeSize(listener, "inherit"), appearance: "none", position: "relative", display: "inline-flex", width: themeSpacing(9), height: themeSpacing(6), cursor: "pointer", margin: `0`, paddingBlock: themeSpacing(1), "&:checked": { "&::before": { backgroundColor: (listener) => themeColor(listener, "increase-3", accentColor.get(listener)), }, "&::after": { left: `calc(100% - ${themeSpacing(3.5)})`, }, }, "&::after": { content: `""`, aspectRatio: `1/1`, position: "absolute", width: themeSpacing(3), height: themeSpacing(3), borderRadius: themeSpacing(999), left: themeSpacing(0.5), top: "50%", transform: "translateY(-50%)", transition: "left 0.3s", backgroundColor: (listener) => themeColor(listener, "decrease-3"), }, "&::before": { content: '""', width: "100%", borderRadius: themeSpacing(999), display: "inline-block", fontSize: (listener) => themeSize(listener, "inherit"), lineHeight: 1, backgroundColor: (listener) => themeColor(listener), }, "&[disabled]": { opacity: 0.7, cursor: "not-allowed", }, }, }; } export { inputSwitch }; ``` ### inputText ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeDensity, themeSize, themeSpacing, } from "@domphy/theme"; /** * Themed single-line text input primitive. Sets `type="text"` and styles the * field with themed border, focus ring, placeholder, disabled and validation * (`data-status`) states. Apply to an `<input>` element. * * @hostTag input * @param props - Optional configuration. * @param props.color - Base color tone for text/border/background. Defaults to `"neutral"`. * @param props.accentColor - Accent color tone for the hover/focus outline. Defaults to `"primary"`. * @example { input: "", type: "text", placeholder: "Name", $: [inputText()] } */ function inputText( props: { color?: ValueOrState<ThemeColor>; accentColor?: ValueOrState<ThemeColor>; } = {}, ): PartialElement { const color = toState(props.color ?? "neutral", "color"); const accentColor = toState(props.accentColor ?? "primary", "accentColor"); return { type: "text", _onSchedule: (node, element) => { if (node.tagName !== "input") { console.warn( `"inputText" primitive patch must use input tag and text type`, ); } (element as any).type = "text"; }, style: { fontFamily: "inherit", lineHeight: "inherit", minWidth: themeSpacing(10), paddingInline: (listener) => themeSpacing(themeDensity(listener) * 3), paddingBlock: (listener) => themeSpacing(themeDensity(listener) * 1), borderRadius: (listener) => themeSpacing(themeDensity(listener) * 1), fontSize: (listener) => themeSize(listener, "inherit"), border: "none", outlineOffset: "-1px", outline: (listener) => `1px solid ${themeColor(listener, "shift-4", color.get(listener))}`, color: (listener) => themeColor(listener, "shift-9", color.get(listener)), backgroundColor: (listener) => themeColor(listener, "inherit", color.get(listener)), "&::placeholder": { color: (listener) => themeColor(listener, "shift-7"), }, "&:not(:placeholder-shown)": { color: (listener) => themeColor(listener, "shift-10"), }, "&:hover:not([disabled]):not([aria-busy=true]), &:focus-visible": { outline: (listener) => `${themeSpacing(0.5)} solid ${themeColor(listener, "shift-6", accentColor.get(listener))}`, }, "&[disabled]": { opacity: 0.7, cursor: "not-allowed", backgroundColor: (listener) => themeColor(listener, "shift-2", "neutral"), outline: (listener) => `1px solid ${themeColor(listener, "shift-4", "neutral")}`, color: (listener) => themeColor(listener, "shift-8", "neutral"), }, "&:invalid:not(:placeholder-shown)": { outline: (listener) => `${themeSpacing(0.5)} solid ${themeColor(listener, "shift-6", "error")}`, }, "&[data-status=error]": { outline: (listener) => `${themeSpacing(0.5)} solid ${themeColor(listener, "shift-6", "error")}`, }, "&[data-status=warning]": { outline: (listener) => `${themeSpacing(0.5)} solid ${themeColor(listener, "shift-6", "warning")}`, }, }, }; } export { inputText }; ``` ### keyboard ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeSize, themeSpacing, } from "@domphy/theme"; /** * Renders keyboard-key styling (themed background, border and padding) for a * keystroke hint. Apply to a `<kbd>` element. * * @hostTag kbd * @param props - Optional configuration. * @param props.color - Color tone for text/background/border. Defaults to `"neutral"`. * @example { kbd: "Ctrl", $: [keyboard()] } */ function keyboard( props: { color?: ValueOrState<ThemeColor> } = {}, ): PartialElement { const color = toState(props.color ?? "neutral", "color"); return { _onInsert: (node) => { if (node.tagName !== "kbd") { console.warn(`"keyboard" primitive patch must use kbd tag`); } }, style: { fontSize: (listener) => themeSize(listener, "inherit"), color: (listener) => themeColor(listener, "shift-9", color.get(listener)), backgroundColor: (listener) => themeColor(listener, "inherit", color.get(listener)), paddingBlock: themeSpacing(0.5), paddingInline: themeSpacing(1.5), borderRadius: themeSpacing(1), outline: (listener) => `1px solid ${themeColor(listener, "shift-4", color.get(listener))}`, }, }; } export { keyboard }; ``` ### label ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeSize, themeSpacing, } from "@domphy/theme"; /** * Themed form-label primitive: inline-flex layout with gap, themed text color, * focus-within highlighting and a disabled (`aria-disabled`) state. Apply to a * `<label>` element. * * @hostTag label * @param props - Optional configuration. * @param props.color - Base color tone for the label text. Defaults to `"neutral"`. * @param props.accentColor - Accent color tone applied on focus-within. Defaults to `"primary"`. * @example { label: "Email", htmlFor: "email", $: [label()] } */ function label( props: { color?: ValueOrState<ThemeColor>; accentColor?: ValueOrState<ThemeColor>; } = {}, ): PartialElement { const color = toState(props.color ?? "neutral", "color"); const accentColor = toState(props.accentColor ?? "primary", "accentColor"); return { _onInsert: (node) => { if (node.tagName !== "label") { console.warn(`"label" primitive patch must use label tag`); } }, style: { display: "inline-flex", alignItems: "center", gap: themeSpacing(2), fontSize: (listener) => themeSize(listener, "inherit"), color: (listener) => themeColor(listener, "shift-9", color.get(listener)), cursor: "pointer", "&:focus-within": { color: (listener) => themeColor(listener, "shift-10", accentColor.get(listener)), }, "&[aria-disabled=true]": { opacity: 0.7, cursor: "not-allowed", color: (listener) => themeColor(listener, "shift-8", "neutral"), }, }, }; } export { label }; ``` ### link ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeSize, themeSpacing, } from "@domphy/theme"; /** * Themed hyperlink primitive: styles text color, hover underline, visited, * focus ring and a disabled state. Apply to an `<a>` element. * * @hostTag a * @param props - Optional configuration. * @param props.color - Base color tone for the link text. Defaults to `"primary"`. * @param props.accentColor - Accent color tone for visited/focus states. Defaults to `"secondary"`. * @example { a: "Home", href: "/", $: [link()] } */ function link( props: { color?: ValueOrState<ThemeColor>; accentColor?: ValueOrState<ThemeColor>; } = {}, ): PartialElement { const color = toState(props.color ?? "primary", "color"); const accentColor = toState(props.accentColor ?? "secondary", "accentColor"); return { _onInsert: (node) => { if (node.tagName !== "a") { console.warn(`"link" primitive patch must use a tag`); } }, style: { fontSize: (listener) => themeSize(listener, "inherit"), backgroundColor: (listener) => themeColor(listener), color: (listener) => themeColor(listener, "shift-9", color.get(listener)), textDecoration: "none", "&:visited": { color: (listener) => themeColor(listener, "shift-9", accentColor.get(listener)), }, "&:hover:not([disabled])": { color: (listener) => themeColor(listener, "shift-10", color.get(listener)), textDecoration: "underline", }, "&:focus-visible": { borderRadius: themeSpacing(1), outlineOffset: themeSpacing(1), outline: (listener) => `${themeSpacing(0.5)} solid ${themeColor(listener, "shift-6", accentColor.get(listener))}`, }, "&[disabled]": { opacity: 0.7, cursor: "not-allowed", color: (listener) => themeColor(listener, "shift-8", "neutral"), }, }, }; } export { link }; ``` ### list ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeDensity, themeSize, themeSpacing, } from "@domphy/theme"; /** * Styles a navigation/display list container. Sets `list-style: none` and * zero padding; pairs with `listItem` and `listItemButton`. Apply to `<ul>`. * * @hostTag ul * @param props.color - Surface color tone. Optional `ThemeColor`, defaults to `"neutral"`. * @example { ul: [...], $: [list()] } */ function list(props: { color?: ThemeColor } = {}): PartialElement { const { color = "neutral" } = props; return { _context: { list: { color } }, style: { listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", }, }; } /** * A non-interactive list row. Typically wraps an icon + text. Apply to `<li>`. * * @hostTag li * @param props.dense - Reduce vertical padding. Optional `boolean`, defaults to `false`. * @example { li: "Item", $: [listItem()] } */ function listItem(props: { dense?: boolean } = {}): PartialElement { const { dense = false } = props; return { style: { display: "flex", alignItems: "center", gap: (listener) => themeSpacing(themeDensity(listener) * 2), paddingBlock: (listener) => themeSpacing(dense ? 1 : themeDensity(listener) * 2), paddingInline: (listener) => themeSpacing(themeDensity(listener) * 3), fontSize: (listener) => themeSize(listener, "inherit"), }, }; } /** * An interactive (clickable) list row with hover/focus-visible states. Apply * to `<button>` or `<a>` inside an `<li>`. * * @param props.color - Color tone. Optional `ValueOrState<ThemeColor>`, defaults to `"neutral"`. * @param props.accentColor - Focus/active accent. Optional `ThemeColor`, defaults to `"primary"`. * @param props.dense - Reduce vertical padding. Optional `boolean`, defaults to `false`. * @example { button: "Action", $: [listItemButton()] } */ function listItemButton( props: { color?: ValueOrState<ThemeColor>; accentColor?: ThemeColor; dense?: boolean; } = {}, ): PartialElement { const color = toState(props.color ?? "neutral", "color"); const accentColor = props.accentColor ?? "primary"; const { dense = false } = props; return { cursor: "pointer", style: { appearance: "none", border: "none", background: "transparent", textDecoration: "none", textAlign: "left", display: "flex", alignItems: "center", width: "100%", gap: (listener) => themeSpacing(themeDensity(listener) * 2), paddingBlock: (listener) => themeSpacing(dense ? 1 : themeDensity(listener) * 2), paddingInline: (listener) => themeSpacing(themeDensity(listener) * 3), fontSize: (listener) => themeSize(listener, "inherit"), color: (listener) => themeColor(listener, "shift-9", color.get(listener)), borderRadius: (listener) => themeSpacing(themeDensity(listener) * 1), transition: "background-color 150ms ease", "&:hover:not([disabled])": { backgroundColor: (listener) => themeColor(listener, "shift-2", color.get(listener)), }, "&[aria-current=page], &[aria-selected=true]": { backgroundColor: (listener) => themeColor(listener, "shift-3", accentColor), color: (listener) => themeColor(listener, "shift-12", accentColor), }, "&:focus-visible": { outline: (listener) => `2px solid ${themeColor(listener, "shift-6", accentColor)}`, outlineOffset: "-2px", }, "&[disabled]": { opacity: 0.5, cursor: "not-allowed" }, }, }; } export { list, listItem, listItemButton }; ``` ### mark ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeSize, themeSpacing, } from "@domphy/theme"; /** * Themed highlight primitive: gives marked/highlighted inline text a tinted * background, rounded corners and padding. Apply to a `<mark>` element. * * @hostTag mark * @param props - Optional configuration. * @param props.accentColor - Accent color tone for the highlight fill and text. Defaults to `"highlight"`. * @example { mark: "important", $: [mark()] } */ function mark( props: { accentColor?: ValueOrState<ThemeColor> } = {}, ): PartialElement { const accentColor = toState(props.accentColor ?? "highlight", "accentColor"); return { _onInsert: (node) => { if (node.tagName !== "mark") { console.warn(`"mark" primitive patch must use mark tag`); } }, dataTone: "shift-2", style: { display: "inline-flex", alignItems: "center", fontSize: (listener) => themeSize(listener, "inherit"), color: (listener) => themeColor(listener, "shift-9", accentColor.get(listener)), backgroundColor: (listener) => themeColor(listener, "inherit", accentColor.get(listener)), height: themeSpacing(6), borderRadius: themeSpacing(1), paddingInline: themeSpacing(1.5), }, }; } export { mark }; ``` ### menu ```ts import { merge, type PartialElement, toState, type ValueOrState, } from "@domphy/core"; import { type ThemeColor, themeColor, themeDensity, themeSize, themeSpacing, } from "@domphy/theme"; /** * Themed menu container that provides selection context (`activeKey`, * `selectable`) to child `menuItem` patches and lays them out vertically. * Sets `role="menu"`. Typically applied to a container element such as a * `<div>` or `<ul>`. * * @param props - Optional configuration. * @param props.activeKey - Currently selected item key, accepts a value or `State`. Defaults to `null`. * @param props.selectable - Whether items track and update the active selection. Defaults to `true`. * @param props.color - Background color tone for the menu. Defaults to `"neutral"`. * @example { div: "", $: [menu({ activeKey: 0 })] } */ function menu( props: { activeKey?: ValueOrState<number | string>; selectable?: boolean; color?: ThemeColor; } = {}, ): PartialElement { const { color = "neutral", selectable = true } = props; const partial: PartialElement = { role: "menu", dataTone: "shift-17", _onSchedule: (_node, element) => { const partial = { _context: { menu: { activeKey: toState(props.activeKey ?? null), selectable, }, }, }; merge(element, partial); }, style: { display: "flex", flexDirection: "column", paddingBlock: (listener) => themeSpacing(themeDensity(listener) * 2), paddingInline: (listener) => themeSpacing(themeDensity(listener) * 2), fontSize: (listener) => themeSize(listener, "inherit"), backgroundColor: (listener) => themeColor(listener, "inherit", color), }, }; return partial; } export { menu }; ``` ### menuItem ```ts import type { ElementNode, PartialElement } from "@domphy/core"; import { type ThemeColor, themeColor, themeDensity, themeSize, themeSpacing, } from "@domphy/theme"; /** * Themed menu entry for use inside a `menu`. Sets `role="menuitem"`, wires * click/keyboard selection (Enter/Space activate; Arrow/Home/End move focus), * and reflects the active item via `aria-current`. Apply to a `<button>` * element placed within a `menu`. * * @hostTag button * @param props - Optional configuration. * @param props.accentColor - Accent color tone for the active/focus indicator. Defaults to `"primary"`. * @param props.color - Base color tone for the item. Defaults to `"neutral"`. * @example { button: "Profile", $: [menuItem()] } */ function menuItem( props: { accentColor?: ThemeColor; color?: ThemeColor } = {}, ): PartialElement { const { accentColor = "primary", color = "neutral" } = props; const partial: PartialElement = { role: "menuitem", _onInsert: (node) => { if (node.tagName !== "button") { console.warn(`"menuItem" patch must use button tag`); } const context = node.getContext("menu"); if (!context) { console.warn(`"menuItem" patch must be used inside a "menu"`); return; } let children = (node.parent?.children.items ?? []) as ElementNode[]; children = children.filter( (n) => n.type === "ElementNode" && n.attributes.get("role") === "menuitem", ); // Strict key check: an explicit _key of 0 or "" must not fall back to index. const key = node.key !== null && node.key !== undefined ? node.key : children.indexOf(node); if (context.selectable) { node.attributes.set( "ariaCurrent", (listener) => context.activeKey.get(listener) === key || undefined, ); node.addEvent("click", () => context.activeKey.set(key)); } }, onKeyDown: (e: KeyboardEvent, node) => { const k = (e as KeyboardEvent).key; if (k === "Enter" || k === " ") { e.preventDefault(); (node.domElement as HTMLElement)?.click(); return; } if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(k)) return; e.preventDefault(); const items = (node.parent?.children.items ?? []).filter( (n) => n.type === "ElementNode" && (n as ElementNode).attributes.get("role") === "menuitem", ) as ElementNode[]; if (!items.length) return; const idx = items.indexOf(node); let next = idx; if (k === "ArrowDown") next = (idx + 1) % items.length; else if (k === "ArrowUp") next = (idx - 1 + items.length) % items.length; else if (k === "Home") next = 0; else if (k === "End") next = items.length - 1; (items[next].domElement as HTMLElement)?.focus(); }, style: { cursor: "pointer", display: "flex", alignItems: "center", gap: (_listener) => themeSpacing(2), width: "100%", fontSize: (listener) => themeSize(listener, "inherit"), height: (listener) => themeSpacing(6 + themeDensity(listener) * 2), paddingInline: (listener) => themeSpacing(themeDensity(listener) * 3), border: "none", outline: "none", color: (listener) => themeColor(listener, "shift-9", color), backgroundColor: (listener) => themeColor(listener, "inherit", color), "&:hover:not([disabled]):not([aria-current=true])": { backgroundColor: (listener) => themeColor(listener, "shift-2", color), }, // Menu uses the current/indicator band instead of the selected fill band. "&[aria-current=true]": { backgroundColor: (listener) => themeColor(listener, "shift-3", accentColor), color: (listener) => themeColor(listener, "shift-10"), }, "&:focus-visible": { outline: (listener) => `${themeSpacing(0.5)} solid ${themeColor(listener, "shift-6", accentColor)}`, outlineOffset: `-${themeSpacing(0.5)}`, }, }, }; return partial; } export { menuItem }; ``` ### motion ```ts import type { PartialElement, State } from "@domphy/core"; /** * One keyframe. Shorthands `x`/`y` (px), `scale`, `rotate` (deg) compose into a * single `transform`; any other key is a raw CSS property (e.g. `opacity`, * `backgroundColor`). */ export type MotionKeyframe = { x?: number | string; y?: number | string; scale?: number | string; rotate?: number | string; } & Record<string, string | number>; export interface MotionProps { /** Starting keyframe applied before the enter animation. */ initial?: MotionKeyframe; /** Target keyframe. Pass a `State` to re-animate whenever it changes. */ animate?: MotionKeyframe | State<MotionKeyframe>; /** Keyframe animated to before the element is removed. */ exit?: MotionKeyframe; transition?: { /** ms, default 300. */ duration?: number; /** ms, default 0. */ delay?: number; /** CSS easing, default "ease". */ easing?: string; iterations?: number; }; } const isState = (value: unknown): value is State<MotionKeyframe> => !!value && typeof (value as State<MotionKeyframe>).get === "function" && (value as { _isState?: boolean })._isState === true; const toStyles = (frame: MotionKeyframe): Keyframe => { const out: Record<string, string | number> = {}; const transforms: string[] = []; for (const key in frame) { const value = frame[key]; if (key === "x") { transforms.push( `translateX(${typeof value === "number" ? `${value}px` : value})`, ); } else if (key === "y") { transforms.push( `translateY(${typeof value === "number" ? `${value}px` : value})`, ); } else if (key === "scale") { transforms.push(`scale(${value})`); } else if (key === "rotate") { transforms.push( `rotate(${typeof value === "number" ? `${value}deg` : value})`, ); } else { out[key] = value; } } if (transforms.length) out.transform = transforms.join(" "); return out as Keyframe; }; /** * Animation primitive driven by the Web Animations API. Runs an enter * animation on mount (`initial` -> `animate`), re-animates whenever `animate` * is a `State` that changes, and plays the `exit` keyframe before removal. * Has no host-tag restriction; apply to any element you want to animate. * * @param props - Optional configuration (see {@link MotionProps}). * @param props.initial - Starting keyframe applied before the enter animation. * @param props.animate - Target keyframe, or a `State` to re-animate on change. * @param props.exit - Keyframe animated to before the element is removed. * @param props.transition - Timing options. * @param props.transition.duration - Duration in ms. Defaults to `300`. * @param props.transition.delay - Delay in ms. Defaults to `0`. * @param props.transition.easing - CSS easing. Defaults to `"ease"`. * @param props.transition.iterations - Number of iterations. Defaults to `1`. * @example { div: "Hello", $: [motion({ initial: { opacity: 0 }, animate: { opacity: 1 } })] } */ function motion(props: MotionProps = {}): PartialElement { const { initial, animate, exit, transition = {} } = props; const options: KeyframeAnimationOptions = { duration: transition.duration ?? 300, delay: transition.delay ?? 0, easing: transition.easing ?? "ease", iterations: transition.iterations ?? 1, fill: "both", }; return { _onMount: (node) => { const el = node.domElement as HTMLElement | null; if (!el || typeof el.animate !== "function") return; const target = isState(animate) ? animate.get() : animate; if (target) { el.animate( initial ? [toStyles(initial), toStyles(target)] : [toStyles(target)], options, ); } else if (initial) { Object.assign(el.style, toStyles(initial)); } if (isState(animate)) { const release = animate.addListener((next: MotionKeyframe) => { el.animate([toStyles(next)], options); }); node.setMetadata("motionRelease", release); } }, _onBeforeRemove: (node, done) => { const el = node.domElement as HTMLElement | null; if (!el || !exit || typeof el.animate !== "function") return done(); el.animate([toStyles(exit)], options).finished.then(done, done); }, _onRemove: (node) => { const release = node.getMetadata("motionRelease") as | (() => void) | undefined; release?.(); }, }; } export { motion }; ``` ### orderedList ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeSize, themeSpacing, } from "@domphy/theme"; /** * Themed ordered-list primitive: decimal markers positioned outside, reset * margins and themed text color. Apply to an `<ol>` element. * * @hostTag ol * @param props - Optional configuration. * @param props.color - Color tone for the list text. Defaults to `"neutral"`. * @example { ol: "", $: [orderedList()], children: [{ li: "First" }] } */ function orderedList( props: { color?: ValueOrState<ThemeColor> } = {}, ): PartialElement { const color = toState(props.color ?? "neutral", "color"); return { _onInsert: (node) => { if (node.tagName !== "ol") { console.warn(`"orderedList" primitive patch must use ol tag`); } }, style: { fontSize: (listener) => themeSize(listener, "inherit"), backgroundColor: (listener) => themeColor(listener), color: (listener) => themeColor(listener, "shift-9", color.get(listener)), marginTop: 0, marginBottom: 0, paddingLeft: themeSpacing(3), listStyleType: "decimal", listStylePosition: "outside", }, }; } export { orderedList }; ``` ### pagination ```ts import { type DomphyElement, type PartialElement, toState, type ValueOrState, } from "@domphy/core"; import { type ThemeColor, themeColor, themeDensity, themeSize, themeSpacing, } from "@domphy/theme"; function getPages(current: number, total: number): (number | "...")[] { if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1); const pages: (number | "...")[] = [1]; if (current > 3) pages.push("..."); const start = Math.max(2, current - 1); const end = Math.min(total - 1, current + 1); for (let i = start; i <= end; i++) pages.push(i); if (current < total - 2) pages.push("..."); pages.push(total); return pages; } /** * Themed pagination control. Renders previous/next buttons plus truncated page * numbers (with ellipses), tracks the current page in a `State`, and updates it * on click. Apply to a `<div>` element. * * @hostTag div * @param props - Configuration. * @param props.total - Required. Total number of pages. * @param props.value - Current page, accepts a value or `State`. Defaults to `1`. * @param props.color - Base color tone for the page buttons. Defaults to `"neutral"`. * @param props.accentColor - Accent color tone for the active page. Defaults to `"primary"`. * @example { div: "", $: [pagination({ total: 10, value: 1 })] } */ function pagination(props: { value?: ValueOrState<number>; total: number; color?: ThemeColor; accentColor?: ThemeColor; }): PartialElement { const { total, color = "neutral", accentColor = "primary" } = props; const state = toState(props.value ?? 1); const btnBase = { display: "inline-flex", alignItems: "center", justifyContent: "center", minWidth: (listener: any) => themeSpacing(6 + themeDensity(listener) * 2), height: (listener: any) => themeSpacing(6 + themeDensity(listener) * 2), paddingInline: (listener: any) => themeSpacing(themeDensity(listener) * 2), borderRadius: (listener: any) => themeSpacing(themeDensity(listener) * 1), border: "none", cursor: "pointer", fontSize: (listener: any) => themeSize(listener, "inherit"), backgroundColor: "transparent", color: (listener: any) => themeColor(listener, "shift-9", color), "&:hover:not([disabled])": { backgroundColor: (listener: any) => themeColor(listener, "shift-2", color), }, "&[disabled]": { opacity: 0.4, cursor: "not-allowed", }, }; const activeStyle = { ...btnBase, backgroundColor: (listener: any) => themeColor(listener, "shift-6", accentColor), color: (listener: any) => themeColor(listener, "shift-11", accentColor), fontWeight: "600", cursor: "default", "&:hover:not([disabled])": { backgroundColor: (listener: any) => themeColor(listener, "shift-6", accentColor), }, }; return { role: "navigation", ariaLabel: "Pagination", _onInsert: (node) => { if (node.tagName !== "div") console.warn('"pagination" patch must use div tag'); }, _onInit: (node) => { const content: DomphyElement<"div"> = { div: (listener) => { const page = state.get(listener); const items: DomphyElement[] = []; // Prev button items.push({ button: "‹", type: "button", ariaLabel: "Previous page", disabled: page <= 1, onClick: () => page > 1 && state.set(page - 1), style: btnBase, }); // Page buttons for (const p of getPages(page, total)) { if (p === "...") { items.push({ span: "…", ariaHidden: "true", style: { display: "inline-flex", alignItems: "center", paddingInline: (listener: any) => themeSpacing(themeDensity(listener) * 2), color: (listener: any) => themeColor(listener, "shift-7", color), }, }); } else { const isActive = p === page; items.push({ button: String(p), type: "button", ariaLabel: `Page ${p}`, ariaCurrent: isActive ? "page" : undefined, disabled: isActive, onClick: () => state.set(p), style: isActive ? activeStyle : btnBase, }); } } // Next button items.push({ button: "›", type: "button", ariaLabel: "Next page", disabled: page >= total, onClick: () => page < total && state.set(page + 1), style: btnBase, }); return items; }, style: { display: "flex", alignItems: "center", gap: themeSpacing(1), }, }; node.children.insert(content); }, style: { display: "inline-flex", }, }; } export { pagination }; ``` ### paragraph ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeSize } from "@domphy/theme"; /** * Themed paragraph primitive: comfortable line-height, reset margins and themed * text color. Apply to a `<p>` element. * * @hostTag p * @param props - Optional configuration. * @param props.color - Color tone for the paragraph text. Defaults to `"neutral"`. * @example { p: "Hello world", $: [paragraph()] } */ function paragraph( props: { color?: ValueOrState<ThemeColor> } = {}, ): PartialElement { const color = toState(props.color ?? "neutral", "color"); return { _onInsert: (node) => { if (node.tagName !== "p") { console.warn(`"paragraph" primitive patch must use p tag`); } }, style: { fontSize: (listener) => themeSize(listener, "inherit"), color: (listener) => themeColor(listener, "shift-9", color.get(listener)), lineHeight: 1.5, marginTop: 0, marginBottom: 0, }, }; } export { paragraph }; ``` ### popover ```ts import { type DomphyElement, merge, type PartialElement, toState, type ValueOrState, } from "@domphy/core"; import type { Placement } from "@domphy/floating"; import { creatFloating } from "../utils/floating.js"; /** * Floating popover primitive. Attaches to its host as the anchor/trigger and * shows a floating `content` element (with `role="dialog"`) on click or hover, * positioned via `@domphy/floating`. Returns the anchor partial, which merges * trigger wiring (haspopup/expanded, focus/blur dismissal). Apply to the * trigger element you want the popover anchored to. * * @param props - Configuration. * @param props.openOn - Interaction that opens the popover: `"click"` or `"hover"`. Defaults to `"click"`. * @param props.open - Open state, accepts a value or `State`. Defaults to `false`. * @param props.placement - Floating placement (e.g. `"bottom"`, `"top-start"`), value or `State`. Defaults to `"bottom"`. * @param props.content - The floating content element to display. * @example { button: "Open", $: [popover({ openOn: "click", content: { div: "Hi" } })] } */ function popover(props: { openOn?: "click" | "hover"; open?: ValueOrState<boolean>; placement?: ValueOrState<Placement>; content: DomphyElement; }): PartialElement { const { open = false, placement = "bottom", openOn = "click" } = props; let popoverId: string | null = null; const openState = toState(open); const placeState = toState(placement); const { show, hide, anchorPartial } = creatFloating({ open: openState, placement: placeState, content: props.content, }); const popoverPartial: PartialElement = { role: "dialog", dataTone: "shift-11", onMouseEnter: () => openOn === "hover" && show(), onMouseLeave: () => openOn === "hover" && hide(), _onInsert: (node) => { const id = node.attributes.get("id"); popoverId = id || node.nodeId; !id && node.attributes.set("id", popoverId); }, }; props.content.$ ||= []; props.content.$.push(popoverPartial); const triggerPartial: PartialElement = { ariaHaspopup: "dialog", ariaExpanded: (listener) => openState.get(listener), onMouseEnter: () => openOn === "hover" && show(), onMouseLeave: () => openOn === "hover" && hide(), onClick: () => { if (openOn === "click") { if (openState.get()) { hide(); } else { show(); } } }, onKeyDown: (e) => { if ((e as KeyboardEvent).key === "Escape" && openState.get()) hide(); }, onFocus: () => openOn === "hover" && show(), onBlur: (e, node) => { const related = (e as FocusEvent).relatedTarget as Node | null; const root = node.getRoot().domElement as Element; const floatingEl = popoverId ? root.querySelector(`#${CSS.escape(popoverId)}`) : null; if (related && floatingEl?.contains(related)) return; hide(); }, _onMount: (node) => popoverId && node.attributes.set("ariaControls", popoverId), }; merge(anchorPartial, triggerPartial); return anchorPartial; } export { popover }; ``` ### popoverArrow ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import type { Placement } from "@domphy/floating"; import { type ThemeColor, themeColor, themeSize, themeSpacing, } from "@domphy/theme"; /** * Renders a small rotated arrow (via a `::after` pseudo-element) that points from a * popover/tooltip toward its anchor, positioned and oriented based on the floating placement. * The arrow direction is computed by flipping the given placement. No host-tag check is * performed; apply it to the popover container element. * * @param props.placement - Floating placement the popover sits at; the arrow is drawn on the * opposite (flipped) side. Accepts a value or reactive state. Defaults to `"bottom-end"`. * One of: `top` | `bottom` | `left` | `right` | `top-start` | `top-end` | `bottom-start` | * `bottom-end` | `left-start` | `left-end` | `right-start` | `right-end`. * @param props.sideOffset - CSS length used to offset the arrow toward the start/end edge. * Defaults to `themeSpacing(6)`. * @param props.color - Theme color tone for the arrow fill and border. Defaults to `"neutral"`. * @param props.bordered - Whether the arrow draws a 1px border (set to `0px` when false). * Defaults to `true`. * @example { div: [...], $: [popoverArrow({ placement: "top" })] } */ function popoverArrow( props: { placement?: ValueOrState<Placement>; sideOffset?: string; color?: ThemeColor; bordered?: boolean; } = {}, ): PartialElement { const { placement = "bottom-end", color = "neutral", sideOffset = themeSpacing(6), bordered = true, } = props; const place = toState(placement); const flipMap: Record<Placement, Placement> = { top: "bottom", bottom: "top", left: "right", right: "left", "top-start": "bottom-end", "top-end": "bottom-start", "bottom-start": "top-end", "bottom-end": "top-start", "left-start": "right-end", "left-end": "right-start", "right-start": "left-end", "right-end": "left-start", }; const getFlipped = (listener: any) => flipMap[place.get(listener)] ?? flipMap["bottom-end"]; const start = (pos: string) => pos.includes("start") ? sideOffset : pos.includes("end") ? "auto" : "50%"; const end = (pos: string) => pos.includes("end") ? sideOffset : pos.includes("start") ? "auto" : "50%"; return { style: { fontSize: (listener) => themeSize(listener), backgroundColor: (listener) => themeColor(listener), color: (listener) => themeColor(listener, "shift-9", color), position: "relative", "&::after": { content: `""`, position: "absolute", width: themeSpacing(1.5), height: themeSpacing(1.5), backgroundColor: (listener) => themeColor(listener, "inherit", color), borderWidth: bordered ? "1px" : "0px", borderColor: (listener) => themeColor(listener, "inherit", color), borderTopStyle: (listener) => { const pos = getFlipped(listener); return pos.includes("top") || pos.includes("right") ? `solid` : "none"; }, borderBottomStyle: (listener) => { const pos = getFlipped(listener); return pos.includes("bottom") || pos.includes("left") ? `solid` : "none"; }, borderLeftStyle: (listener) => { const pos = getFlipped(listener); return pos.includes("top") || pos.includes("left") ? `solid` : "none"; }, borderRightStyle: (listener) => { const pos = getFlipped(listener); return pos.includes("bottom") || pos.includes("right") ? `solid` : "none"; }, top: (listener) => { const pos = getFlipped(listener); return pos.includes("top") ? 0 : pos.includes("bottom") ? "auto" : start(pos); }, right: (listener) => { const pos = getFlipped(listener); return pos.includes("right") ? 0 : pos.includes("left") ? "auto" : end(pos); }, bottom: (listener) => { const pos = getFlipped(listener); return pos.includes("bottom") ? 0 : pos.includes("top") ? "auto" : end(pos); }, left: (listener) => { const pos = getFlipped(listener); return pos.includes("left") ? 0 : pos.includes("right") ? "auto" : start(pos); }, transform: (listener) => { const pos = getFlipped(listener); const x = pos.includes("right") || (pos.includes("end") && !pos.includes("left")) ? "50%" : "-50%"; const y = pos.includes("bottom") || (pos.includes("end") && !pos.includes("top")) ? "50%" : "-50%"; return `translate(${x},${y}) rotate(45deg)`; }, }, }, }; } export { popoverArrow }; ``` ### preformated ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeDensity, themeSize, themeSpacing, } from "@domphy/theme"; /** * Styles a preformatted text block: inherited font size, themed foreground/background, * no border, density-scaled padding and rounded corners. * * @hostTag pre * @param props.color - Theme color tone for text and background. Accepts a value or reactive * state. Defaults to `"neutral"`. * @example { pre: "const x = 1", $: [preformated()] } */ function preformated( props: { color?: ValueOrState<ThemeColor> } = {}, ): PartialElement { const color = toState(props.color ?? "neutral", "color"); return { dataTone: "shift-2", _onInsert: (node) => { if (node.tagName !== "pre") { console.warn(`"preformated" primitive patch must use pre tag`); } }, style: { fontSize: (listener) => themeSize(listener, "inherit"), color: (listener) => themeColor(listener, "shift-9", color.get(listener)), backgroundColor: (listener) => themeColor(listener, "inherit", color.get(listener)), border: "none", paddingBlock: (listener) => themeSpacing(themeDensity(listener) * 2), paddingInline: (listener) => themeSpacing(themeDensity(listener) * 3), borderRadius: (listener) => themeSpacing(themeDensity(listener) * 2), }, }; } export { preformated }; ``` ### progress ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeSpacing } from "@domphy/theme"; /** * Styles a native progress bar: full-width, pill-shaped track with a themed fill, * including the WebKit progress-bar/value pseudo-elements and a width transition. * * @hostTag progress * @param props.color - Theme color tone for the track/background. Accepts a value or reactive * state. Defaults to `"neutral"`. * @param props.accentColor - Theme color tone for the filled value. Accepts a value or reactive * state. Defaults to `"primary"`. * @example { progress: null, value: 40, max: 100, $: [progress()] } */ function progress( props: { color?: ValueOrState<ThemeColor>; accentColor?: ValueOrState<ThemeColor>; } = {}, ): PartialElement { const color = toState(props.color ?? "neutral", "color"); const accentColor = toState(props.accentColor ?? "primary", "accentColor"); return { _onInsert: (node) => { if (node.tagName !== "progress") { console.warn(`"progress" primitive patch must use progress tag`); } }, style: { appearance: "none", width: "100%", height: themeSpacing(2), border: 0, borderRadius: themeSpacing(999), overflow: "hidden", backgroundColor: (listener) => themeColor(listener, "shift-3", color.get(listener)), "&::-webkit-progress-bar": { backgroundColor: (listener) => themeColor(listener, "shift-3", color.get(listener)), borderRadius: themeSpacing(999), }, "&::-webkit-progress-value": { backgroundColor: (listener) => themeColor(listener, "shift-9", accentColor.get(listener)), borderRadius: themeSpacing(999), transition: "width 220ms ease", }, }, }; } export { progress }; ``` ### rating ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeSpacing } from "@domphy/theme"; const STAR_FILLED = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="1em" height="1em">` + `<path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/>` + `</svg>`; const STAR_EMPTY = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="1em" height="1em">` + `<path d="M22 9.24l-7.19-.62L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21 12 17.27 18.18 21l-1.63-7.03L22 9.24zM12 15.4l-3.76 2.27 1-4.28-3.32-2.88 4.38-.38L12 6.1l1.71 4.04 4.38.38-3.32 2.88 1 4.28L12 15.4z"/>` + `</svg>`; /** * Interactive star rating applied to a container `<div>`. Manages its own star * children: click to set, Arrow keys to adjust, hover to preview. In `readOnly` * mode stars are non-interactive. Apply to a `<div>` element. * * @hostTag div * @param props.value - Current rating (0 – max). `ValueOrState<number>`, defaults to `0`. * @param props.max - Total number of stars. Optional `number`, defaults to `5`. * @param props.onChange - Called with the new value when the user picks a star. * @param props.readOnly - Disable interaction. Optional `boolean`, defaults to `false`. * @param props.color - Star color tone. Optional `ThemeColor`, defaults to `"warning"`. * @example { div: null, $: [rating({ value: ratingState, onChange: (v) => ratingState.set(v) })] } */ function rating( props: { value?: ValueOrState<number>; max?: number; onChange?: (value: number) => void; readOnly?: boolean; color?: ThemeColor; } = {}, ): PartialElement { const { max = 5, readOnly = false, onChange } = props; const color = props.color ?? "warning"; const valueState = toState(props.value ?? 0); return { role: "group", ariaLabel: "Rating", style: { display: "inline-flex", gap: themeSpacing(0.5), fontSize: "1.5rem", cursor: readOnly ? "default" : "pointer", color: (listener) => themeColor(listener, "shift-8", color), }, _onMount: (node) => { const container = node.domElement as HTMLElement; let current = valueState.get(); let hovered = 0; const render = () => { const active = hovered > 0 ? hovered : current; Array.from(container.children).forEach((star, i) => { (star as HTMLElement).innerHTML = i < active ? STAR_FILLED : STAR_EMPTY; }); }; container.innerHTML = ""; for (let i = 1; i <= max; i++) { const star = document.createElement("button"); star.type = "button"; star.setAttribute("aria-label", `${i} star${i > 1 ? "s" : ""}`); star.style.cssText = "background:none;border:none;padding:0;cursor:inherit;color:inherit;font-size:inherit;display:flex;align-items:center;"; if (!readOnly) { const index = i; star.addEventListener("click", () => { const next = index === current ? 0 : index; current = next; valueState.set(next); onChange?.(next); hovered = 0; render(); }); star.addEventListener("mouseenter", () => { hovered = index; render(); }); star.addEventListener("mouseleave", () => { hovered = 0; render(); }); star.addEventListener("keydown", (e: KeyboardEvent) => { let next = current; if (e.key === "ArrowRight" || e.key === "ArrowUp") { next = Math.min(max, current + 1); e.preventDefault(); } else if (e.key === "ArrowLeft" || e.key === "ArrowDown") { next = Math.max(0, current - 1); e.preventDefault(); } else { return; } current = next; valueState.set(next); onChange?.(next); render(); const target = next > 0 ? next - 1 : 0; (container.children[target] as HTMLElement)?.focus(); }); } container.appendChild(star); } render(); const release = valueState.addListener((v) => { current = v; if (hovered === 0) render(); }); node.addHook("Remove", release); }, }; } export { rating }; ``` ### segmented ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeSpacing } from "@domphy/theme"; /** * Container patch that establishes a `segmented` context for single-select navigation. * Style: inline pill-shaped control with muted background. Use with `segmentedItem` patches on child `<button>` elements. * * @param props.value - Initially selected item key. Accepts a value or state. Defaults to `""`. * @param props.color - Theme color for the control background. Defaults to `"neutral"`. * @example { div: null, $: [segmented({ value: "month" })] } */ function segmented( props: { value?: ValueOrState<string>; color?: ThemeColor } = {}, ): PartialElement { const { color = "neutral" } = props; return { role: "radiogroup", _context: { segmented: { value: toState(props.value ?? "") }, }, style: { display: "inline-flex", paddingBlock: themeSpacing(1), paddingInline: themeSpacing(1), gap: themeSpacing(0.5), borderRadius: themeSpacing(10), backgroundColor: (listener) => themeColor(listener, "shift-2", color), }, }; } export { segmented }; ``` ### segmentedItem ```ts import { type ElementNode, type PartialElement, toState, type ValueOrState, } from "@domphy/core"; import { type ThemeColor, themeColor, themeSize, themeSpacing, } from "@domphy/theme"; /** * Styles and wires a single option inside a `segmented` control on the host `<button>`. * Sets `aria-checked` and handles click-to-select against the parent `segmented` context. * * @hostTag button * @param props.color - Theme color for resting state. Defaults to `"neutral"`. * @param props.accentColor - Theme color for selected state. Defaults to `"primary"`. * @example { button: "Month", $: [segmentedItem()] } */ function segmentedItem( props: { color?: ValueOrState<ThemeColor>; accentColor?: ValueOrState<ThemeColor>; } = {}, ): PartialElement { const color = toState(props.color ?? "neutral", "color"); const accentColor = toState(props.accentColor ?? "primary", "accentColor"); return { role: "radio", _onInsert: (node) => { if (node.tagName !== "button") { console.warn(`"segmentedItem" patch must use button tag`); } const ctx = node.getContext("segmented"); if (!ctx) { console.warn(`"segmentedItem" patch must be used inside a "segmented"`); return; } const siblings = (node.parent?.children.items ?? []) as ElementNode[]; const items = siblings.filter( (sibling) => sibling.type === "ElementNode" && sibling.attributes.get("role") === "radio", ); // node.key is null (not undefined) when absent — check both so an // explicit _key of 0 or "" keeps its real key instead of "null"/index. const key = node.key !== null && node.key !== undefined ? String(node.key) : String(items.indexOf(node)); node.attributes.set( "ariaChecked", (listener) => ctx.value.get(listener) === key, ); node.addEvent("click", () => ctx.value.set(key)); }, style: { cursor: "pointer", fontSize: (listener) => themeSize(listener, "inherit"), height: themeSpacing(6), paddingBlock: themeSpacing(1), paddingInline: themeSpacing(3), border: "none", borderRadius: themeSpacing(10), color: (listener) => themeColor(listener, "shift-9", color.get(listener)), backgroundColor: "transparent", transition: "background-color 300ms ease", "&:hover:not([disabled]):not([aria-checked=true])": { backgroundColor: (listener) => themeColor(listener, "shift-3", color.get(listener)), }, "&[aria-checked=true]": { backgroundColor: (listener) => themeColor(listener, "shift-0", accentColor.get(listener)), color: (listener) => themeColor(listener, "shift-10", accentColor.get(listener)), }, "&:focus-visible": { outline: (listener) => `${themeSpacing(0.5)} solid ${themeColor(listener, "shift-6", accentColor.get(listener))}`, outlineOffset: `-${themeSpacing(0.5)}`, }, "&[disabled]": { opacity: 0.7, cursor: "not-allowed", }, }, }; } export { segmentedItem }; ``` ### select ```ts import type { Listener, PartialElement } from "@domphy/core"; import { type ThemeColor, themeColor, themeColorToken, themeDensity, themeSize, themeSpacing, } from "@domphy/theme"; /** * Styles a native `<select>` control: removes the default appearance, applies themed * colors, outline, density-scaled padding/radius, a custom chevron background icon, and * hover/focus/disabled/optgroup/option states. * * @hostTag select * @param props.color - Theme color tone for text, background and outline. Defaults to `"neutral"`. * @param props.accentColor - Theme color tone for hover/focus outlines. Defaults to `"primary"`. * @example { select: [{ option: "A" }], $: [select()] } */ function select( props: { color?: ThemeColor; accentColor?: ThemeColor } = {}, ): PartialElement { const { color = "neutral", accentColor = "primary" } = props; return { _onInsert: (node) => { if (node.tagName !== "select") { console.warn(`"select" primitive patch must use select tag`); } }, style: { appearance: "none", fontFamily: "inherit", fontSize: (listener) => themeSize(listener, "inherit"), lineHeight: "inherit", color: (listener) => themeColor(listener, "shift-9", color), backgroundColor: (listener) => themeColor(listener, "inherit", color), border: "none", outlineOffset: "-1px", outline: (listener) => `1px solid ${themeColor(listener, "shift-4", color)}`, borderRadius: (listener) => themeSpacing(themeDensity(listener) * 1), paddingBlock: (listener) => themeSpacing(themeDensity(listener) * 1), paddingLeft: (listener) => themeSpacing(themeDensity(listener) * 3), paddingRight: (listener) => themeSpacing(themeDensity(listener) * 5), backgroundImage: (l: Listener) => { const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 6"><path d="M1 1l4 4 4-4" stroke="${themeColorToken(l, "shift-7")}" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>`; return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`; }, backgroundRepeat: "no-repeat", backgroundPosition: `right ${themeSpacing(2)} center`, backgroundSize: `${themeSpacing(2.5)} ${themeSpacing(1.5)}`, "&:not([multiple])": { height: (listener) => themeSpacing(6 + themeDensity(listener) * 2), }, "&:hover:not([disabled]):not([aria-busy=true])": { outline: (listener) => `${themeSpacing(0.5)} solid ${themeColor(listener, "shift-5", accentColor)}`, }, "&:focus-visible": { outline: (listener) => `${themeSpacing(0.5)} solid ${themeColor(listener, "shift-6", accentColor)}`, }, "& optgroup": { color: (listener) => themeColor(listener, "shift-11", color), }, "& option[disabled]": { color: (listener) => themeColor(listener, "shift-7", "neutral"), }, "&[disabled]": { opacity: 0.7, cursor: "not-allowed", color: (listener) => themeColor(listener, "shift-8", "neutral"), outline: (listener) => `1px solid ${themeColor(listener, "shift-4", "neutral")}`, backgroundColor: (listener) => themeColor(listener, "shift-2", "neutral"), }, }, }; } export { select }; ``` ### selectBox ```ts import { type DomphyElement, merge, type PartialElement, type StyleObject, toState, type ValueOrState, } from "@domphy/core"; import type { Placement } from "@domphy/floating"; import { type ThemeColor, themeColor, themeDensity, themeSize, themeSpacing, } from "@domphy/theme"; import { creatFloating } from "../utils/floating.js"; import { tag } from "./tag.js"; /** * A clickable select trigger box that renders the currently selected option(s) as removable * tags and toggles a floating popover (the dropdown content) anchored to itself. Selected * labels are derived from `options` matching the bound `value`; removing a tag updates the value. * * @hostTag div * @param props.multiple - Whether multiple selection is allowed (renders removable tags and * keeps the popover open on click). Defaults to `false`. * @param props.value - Bound selection value(s). Accepts a value or reactive state of an array of * `number | string | null | undefined`, or a single `number | string | null | undefined`. * @param props.options - List of `{ label, value }` options used to resolve selected labels. * Defaults to `[]`. * @param props.placement - Floating placement of the dropdown popover. Accepts a value or * reactive state. Defaults to `"bottom"`. * @param props.content - Required. The popover/dropdown content element shown when open. * @param props.color - Theme color tone for the box text/background. Defaults to `"neutral"`. * @param props.open - Whether the popover is open. Accepts a value or reactive state. Defaults to `false`. * @example { div: null, $: [selectBox({ content: { div: [...] }, options: [{ label: "A", value: "a" }] })] } */ function selectBox(props: { multiple?: boolean; value?: ValueOrState< | Array<number | string | null | undefined> | number | string | null | undefined >; options?: Array<{ label: string; value: string }>; placement?: ValueOrState<Placement>; content: DomphyElement; color?: ThemeColor; open?: ValueOrState<boolean>; }): PartialElement { const { options = [], placement = "bottom", color = "neutral", open = false, multiple = false, } = props; const state = toState(props.value); const openState = toState(open); const { show, hide, anchorPartial } = creatFloating({ open: openState, placement: toState(placement), content: props.content, }); const popoverPartial: PartialElement = { onClick: () => !multiple && hide(), }; merge(props.content, popoverPartial); const wrap: DomphyElement<"div"> = { div: (listener) => { const val = state.get(listener); const vals = Array.isArray(val) ? val : [val]; const opts = options.filter((opt) => vals.includes(opt.value)); return opts.map((opt) => ({ span: opt.label, $: [tag({ color, removable: multiple })], _key: opt.value, _onRemove: (_node) => { const cur = state.get(); const curVals = Array.isArray(cur) ? cur : [cur]; const filter = curVals.filter((v) => v !== opt.value); multiple ? state.set(filter as any) : state.set(filter[0] as any); }, })) as DomphyElement<"span">[]; }, style: { display: "flex", flexWrap: "wrap", gap: themeSpacing(1), flex: 1, } as StyleObject, }; const partial: PartialElement = { _onInsert: (node) => { if (node.tagName !== "div") { console.warn(`"selectBox" patch must use div tag`); } }, _onInit: (node) => node.children.insert(wrap), onClick: () => (openState.get() ? hide() : show()), style: { cursor: "pointer", display: "flex", alignItems: "center", minHeight: (listener) => themeSpacing(6 + themeDensity(listener) * 2), minWidth: themeSpacing(32), outlineOffset: "-1px", outline: (listener) => `1px solid ${themeColor(listener, "shift-4", "neutral")}`, paddingInline: (listener) => themeSpacing(themeDensity(listener) * 2), borderRadius: (listener) => themeSpacing(themeDensity(listener) * 1), fontSize: (listener) => themeSize(listener, "inherit"), color: (listener) => themeColor(listener, "shift-9", color), backgroundColor: (listener) => themeColor(listener, "inherit", color), }, }; merge(anchorPartial, partial); return anchorPartial; } export { selectBox }; ``` ### selectItem ```ts import type { PartialElement } from "@domphy/core"; import { type ThemeColor, themeColor, themeDensity, themeSize, themeSpacing, } from "@domphy/theme"; /** * A single selectable option row (`role="option"`) for use inside a `selectList`. Reads the * `select` context to reflect/toggle selection: it sets `aria-selected` from the bound value and * toggles the value (single or multiple) on click. Styles hover/selected/focus states. * * @hostTag div * @param props.accentColor - Theme color tone for the selected/focus state. Defaults to `"primary"`. * @param props.color - Theme color tone for text/background. Defaults to `"neutral"`. * @param props.value - The option value compared against and written to the select state. * Defaults to `null`. * @example { div: "Option A", $: [selectItem({ value: "a" })] } */ function selectItem( props: { accentColor?: ThemeColor; color?: ThemeColor; value?: number | string; } = {}, ): PartialElement { const { accentColor = "primary", color = "neutral", value = null } = props; const partial: PartialElement = { role: "option", _onInit: (node) => { if (node.tagName !== "div") { console.warn(`"selectItem" patch must use div tag`); } const select = node.getContext("select"); if (select) { const state = select.value; node.attributes.set("ariaSelected", (listener) => { const val = state.get(listener); return select.multiple ? val.includes(value) : val === value; }); node.addEvent("click", () => { const val = state.get(); if (select.multiple) { val.includes(value) ? state.set(val.filter((v: number | string) => v !== value)) : state.set(val.concat([value])); } else { val !== value && state.set(value); } }); } }, style: { cursor: "pointer", display: "flex", alignItems: "center", fontSize: (listener) => themeSize(listener, "inherit"), height: (listener) => themeSpacing(6 + themeDensity(listener) * 2), paddingInline: (listener) => themeSpacing(themeDensity(listener) * 3), border: "none", outline: "none", color: (listener) => themeColor(listener, "shift-9", color), backgroundColor: (listener) => themeColor(listener, "inherit", color), "&:hover:not([disabled]):not([aria-selected=true])": { backgroundColor: (listener) => themeColor(listener, "shift-2", color), }, "&[aria-selected=true]": { backgroundColor: (listener) => themeColor(listener, "shift-6", accentColor), color: (listener) => themeColor(listener, "shift-11"), }, "&:focus-visible": { outline: (listener) => `${themeSpacing(0.5)} solid ${themeColor(listener, "shift-6", accentColor)}`, outlineOffset: `-${themeSpacing(0.5)}`, }, }, }; return partial; } export { selectItem }; ``` ### selectList ```ts import { type DomphyElement, type PartialElement, toState, type ValueOrState, } from "@domphy/core"; import { type ThemeColor, themeColor, themeDensity, themeSize, themeSpacing, } from "@domphy/theme"; /** * Container for a list of `selectItem`s that owns the selection state. It exposes a `select` * context (`{ value, multiple }`) consumed by child items, and injects hidden `<input>`(s) * carrying the selected value(s) under `name` for form submission. * * @hostTag div * @param props.multiple - Whether multiple selection is allowed; also sets the default empty * value (`[]` vs `null`). Defaults to `false`. * @param props.value - Bound selection value(s). Accepts a value or reactive state of an array of * `number | string | null`, or a single `number | string | null`. Defaults to `[]` when * `multiple`, otherwise `null`. * @param props.color - Theme color tone for the background. Defaults to `"neutral"`. * @param props.name - Name attribute for the hidden inputs (form field name). * @example { div: [{ div: "A", $: [selectItem({ value: "a" })] }], $: [selectList({ name: "pick" })] } */ function selectList( props: { multiple?: boolean; value?: ValueOrState< Array<number | string | null> | number | string | null >; color?: ThemeColor; name?: string; } = {}, ): PartialElement { const { color = "neutral", multiple = false } = props; const state = toState(props.value ?? (multiple ? [] : null)); const inputs: DomphyElement<"div"> = { div: (listener) => { const val = state.get(listener); const vals = Array.isArray(val) ? val : [val]; return vals.map((v) => ({ input: null, name: props.name, value: v || "", })); }, hidden: true, }; const partial: PartialElement = { dataTone: "shift-17", _context: { select: { value: state, multiple, }, }, _onInit: (node) => { if (node.tagName !== "div") { console.warn(`"selectList" patch must use a div tag`); } node.children.insert(inputs); }, style: { display: "flex", flexDirection: "column", paddingBlock: (listener) => themeSpacing(themeDensity(listener) * 2), paddingInline: (listener) => themeSpacing(themeDensity(listener) * 2), fontSize: (listener) => themeSize(listener, "inherit"), backgroundColor: (listener) => themeColor(listener, "inherit", color), }, }; return partial; } export { selectList }; ``` ### skeleton ```ts import { hashString, type PartialElement, type StyleObject, toState, type ValueOrState, } from "@domphy/core"; import { type ThemeColor, themeColor, themeSize, themeSpacing, } from "@domphy/theme"; /** * A loading placeholder block with a pulsing opacity animation. Marked `aria-hidden`, themed * background/foreground, fixed height, slight rounding. No host-tag check; typically applied * to a block-level element such as a `div` or `span`. * * @param props.color - Theme color tone for the placeholder. Accepts a value or reactive state. * Defaults to `"neutral"`. * @example { div: null, $: [skeleton()] } */ function skeleton( props: { color?: ValueOrState<ThemeColor> } = {}, ): PartialElement { const color = toState(props.color ?? "neutral", "color"); const keyframes = { "0%,100%": { opacity: 1 }, "50%": { opacity: 0.4 }, }; const animationName = hashString(JSON.stringify(keyframes)); return { ariaHidden: "true", dataTone: "shift-2", style: { fontSize: (listener) => themeSize(listener), color: (listener) => themeColor(listener, "shift-9", color.get(listener)), height: themeSpacing(6), display: "block", borderRadius: themeSpacing(1), backgroundColor: (listener) => themeColor(listener, "inherit", color.get(listener)), animation: `${animationName} 1.5s ease-in-out infinite`, [`@keyframes ${animationName}`]: keyframes, } as StyleObject, }; } export { skeleton }; ``` ### small ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeSize } from "@domphy/theme"; /** * Styles small/secondary text: one step smaller font size (`data-size="decrease-1"`) with a * themed foreground color. * * @hostTag small * @param props.color - Theme color tone for the text. Accepts a value or reactive state. * Defaults to `"neutral"`. * @example { small: "fine print", $: [small()] } */ function small( props: { color?: ValueOrState<ThemeColor> } = {}, ): PartialElement { const color = toState(props.color ?? "neutral", "color"); return { dataSize: "decrease-1", _onInsert: (node) => { if (node.tagName !== "small") { console.warn(`"small" primitive patch must use small tag`); } }, style: { fontSize: (listener) => themeSize(listener, "inherit"), color: (listener) => themeColor(listener, "shift-9", color.get(listener)), }, }; } export { small }; ``` ### spinner ```ts import type { PartialElement, StyleObject } from "@domphy/core"; import { hashString, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeSize, themeSpacing, } from "@domphy/theme"; const keyframes = { to: { transform: "rotate(360deg)" } }; const animationName = hashString(JSON.stringify(keyframes)); /** * A circular loading spinner: a themed ring with a contrasting top border that rotates * continuously. Marked `role="status"` with `aria-label="loading"`. * * @hostTag span * @param props.color - Theme color tone for the ring/highlight. Accepts a value or reactive * state. Defaults to `"neutral"`. * @example { span: null, $: [spinner()] } */ function spinner( props: { color?: ValueOrState<ThemeColor> } = {}, ): PartialElement { const color = toState(props.color ?? "neutral", "color"); return { role: "status", ariaLabel: "loading", _onInsert: (node) => { if (node.tagName !== "span") { console.warn(`"spinner" patch must use span tag`); } }, style: { fontSize: (listener) => themeSize(listener), backgroundColor: (listener) => themeColor(listener), color: (listener) => themeColor(listener, "shift-9", color.get(listener)), display: "inline-block", margin: 0, flexShrink: 0, width: themeSpacing(6), height: themeSpacing(6), borderRadius: "50%", border: (listener) => `${themeSpacing(0.5)} solid ${themeColor(listener, "shift-4", color.get(listener))}`, borderTopColor: (listener) => themeColor(listener, "shift-9", color.get(listener)), boxSizing: "border-box", padding: 0, animation: `${animationName} 0.7s linear infinite`, [`@keyframes ${animationName}`]: keyframes, } as StyleObject, }; } export { spinner }; ``` ### splitter ```ts import { merge, type PartialElement, toState } from "@domphy/core"; import { themeColor, themeSpacing } from "@domphy/theme"; /** * Root of a resizable split layout. Lays out children as a flex row (horizontal) or column * (vertical) and provides a `splitter` context (`{ direction, size, min, max }`) consumed by * `splitterPanel` and `splitterHandle`. `size` is a reactive state holding the first panel's * percentage. No host-tag check; typically applied to a `div`. * * @param props.direction - Split orientation, `"horizontal"` | `"vertical"`. Defaults to `"horizontal"`. * @param props.defaultSize - Initial size (percentage) of the resizable panel. Defaults to `50`. * @param props.min - Minimum panel size (percentage). Defaults to `10`. * @param props.max - Maximum panel size (percentage). Defaults to `90`. * @example { div: [...], $: [splitter({ direction: "vertical" })] } */ function splitter( props: { direction?: "horizontal" | "vertical"; defaultSize?: number; min?: number; max?: number; } = {}, ): PartialElement { const { direction = "horizontal", defaultSize = 50, min = 10, max = 90, } = props; return { _onSchedule: (_node, element) => { merge(element, { _context: { splitter: { direction, size: toState(defaultSize), min, max, }, }, }); }, style: { display: "flex", flexDirection: direction === "horizontal" ? "row" : "column", overflow: "hidden", }, }; } /** * The resizable panel inside a `splitter`. Reads the `splitter` context and binds its * width (horizontal) or height (vertical) to the context `size` state, updating reactively as * the handle is dragged. Warns if used outside a `splitter`. Takes no props. * * @example { div: [...], $: [splitterPanel()] } */ function splitterPanel(): PartialElement { return { _onMount: (node) => { const ctx = node.getContext("splitter"); if (!ctx) { console.warn(`"splitterPanel" patch must be used inside a "splitter"`); return; } const el = node.domElement as HTMLElement; const prop = ctx.direction === "horizontal" ? "width" : "height"; el.style[prop] = `${ctx.size.get()}%`; el.style.flexShrink = "0"; el.style.overflow = "auto"; const release = ctx.size.addListener((size: number) => { el.style[prop] = `${size}%`; }); node.addHook("Remove", release); }, }; } /** * The draggable divider inside a `splitter`. Reads the `splitter` context, shows the * appropriate resize cursor, and updates the context `size` state (clamped to `min`/`max`) * via mouse drag or keyboard: Arrow keys move by 1%, Home/End jump to min/max, hold Shift * for 10× step. Sets `role="separator"`, `tabindex="0"`, and `aria-value*` attributes. * Warns if used outside a `splitter`. Takes no props. * * @example { div: null, $: [splitterHandle()] } */ function splitterHandle(): PartialElement { return { role: "separator", tabindex: 0, _onMount: (node) => { const ctx = node.getContext("splitter"); if (!ctx) { console.warn(`"splitterHandle" patch must be used inside a "splitter"`); return; } const handle = node.domElement as HTMLElement; const isHorizontal = ctx.direction === "horizontal"; handle.style.cursor = isHorizontal ? "col-resize" : "row-resize"; handle.setAttribute( "aria-orientation", isHorizontal ? "vertical" : "horizontal", ); handle.setAttribute("aria-valuemin", String(ctx.min)); handle.setAttribute("aria-valuemax", String(ctx.max)); handle.setAttribute("aria-valuenow", String(Math.round(ctx.size.get()))); const releaseAriaValue = ctx.size.addListener((size: number) => { handle.setAttribute("aria-valuenow", String(Math.round(size))); }); const onKeydown = (e: KeyboardEvent) => { const step = e.shiftKey ? 10 : 1; let next: number | null = null; if (isHorizontal) { if (e.key === "ArrowRight") next = Math.min(ctx.size.get() + step, ctx.max); else if (e.key === "ArrowLeft") next = Math.max(ctx.size.get() - step, ctx.min); } else { if (e.key === "ArrowDown") next = Math.min(ctx.size.get() + step, ctx.max); else if (e.key === "ArrowUp") next = Math.max(ctx.size.get() - step, ctx.min); } if (e.key === "Home") next = ctx.min; else if (e.key === "End") next = ctx.max; if (next !== null) { e.preventDefault(); ctx.size.set(next); } }; let cancelDrag: (() => void) | null = null; const onMousedown = (e: MouseEvent) => { e.preventDefault(); const container = handle.parentElement!; const onMousemove = (e: MouseEvent) => { const rect = container.getBoundingClientRect(); const raw = isHorizontal ? ((e.clientX - rect.left) / rect.width) * 100 : ((e.clientY - rect.top) / rect.height) * 100; ctx.size.set(Math.min(Math.max(raw, ctx.min), ctx.max)); }; const onMouseup = () => { document.removeEventListener("mousemove", onMousemove); document.removeEventListener("mouseup", onMouseup); cancelDrag = null; }; cancelDrag = () => { document.removeEventListener("mousemove", onMousemove); document.removeEventListener("mouseup", onMouseup); cancelDrag = null; }; document.addEventListener("mousemove", onMousemove); document.addEventListener("mouseup", onMouseup); }; handle.addEventListener("keydown", onKeydown); handle.addEventListener("mousedown", onMousedown); node.addHook("Remove", () => { cancelDrag?.(); releaseAriaValue(); handle.removeEventListener("keydown", onKeydown); handle.removeEventListener("mousedown", onMousedown); }); }, style: { flexShrink: 0, display: "flex", alignItems: "center", justifyContent: "center", backgroundColor: (listener) => themeColor(listener, "shift-2"), "&:hover": { backgroundColor: (listener) => themeColor(listener, "shift-3"), }, "&:focus-visible": { outline: (listener) => `2px solid ${themeColor(listener, "shift-6")}`, outlineOffset: "2px", }, "&::after": { content: '""', borderRadius: themeSpacing(999), backgroundColor: (listener) => themeColor(listener, "shift-4"), }, }, }; } export { splitter, splitterPanel, splitterHandle }; ``` ### stepItem ```ts import type { ElementNode, PartialElement, State } from "@domphy/core"; import { type ThemeColor, themeColor, themeSize, themeSpacing, } from "@domphy/theme"; /** * Styles a single step inside a `steps` container. Sets `data-status` * (`"pending"` | `"active"` | `"done"`) and `aria-current="step"` on the host element * based on the parent `steps` context. The element's content is the step label. * * @example { li: "Shipping", $: [stepItem()] } */ function stepItem(): PartialElement { // Mutable slots populated from context in _onInsert; defaults match steps defaults. let color: ThemeColor = "neutral"; let accentColor: ThemeColor = "primary"; return { _onInsert: (node) => { const context = node.getContext("steps") as | { current: State<number>; direction: "horizontal" | "vertical"; color: ThemeColor; accentColor: ThemeColor; } | undefined; if (!context) { console.warn(`"stepItem" patch must be used inside a "steps"`); return; } // Read color values from context so steps() props take effect. color = context.color; accentColor = context.accentColor; const siblings = (node.parent?.children.items ?? []) as ElementNode[]; const items = siblings.filter((n) => n.type === "ElementNode"); const index = items.indexOf(node); if (node.domElement) node.domElement.dataset.step = String(index + 1); node.attributes.set("dataStatus", (listener) => { const current = context.current.get(listener); if (index < current) return "done"; if (index === current) return "active"; return "pending"; }); node.attributes.set("ariaCurrent", (listener) => { return context.current.get(listener) === index ? "step" : undefined; }); }, style: { position: "relative", display: "flex", flexDirection: "column", alignItems: "center", gap: themeSpacing(1), flex: "1", fontSize: (listener) => themeSize(listener, "decrease-1"), textAlign: "center", // Circle indicator via ::before using data-step content "&::before": { content: "attr(data-step)", display: "flex", alignItems: "center", justifyContent: "center", width: themeSpacing(6), height: themeSpacing(6), borderRadius: themeSpacing(999), fontSize: (listener) => themeSize(listener, "decrease-1"), fontWeight: "600", flexShrink: "0", border: (listener) => `2px solid ${themeColor(listener, "shift-4", color)}`, backgroundColor: (listener) => themeColor(listener, "inherit"), color: (listener) => themeColor(listener, "shift-8"), transition: "background-color 200ms ease, color 200ms ease, border-color 200ms ease", zIndex: "1", }, // Connector line to the previous sibling — shown on non-first items "&:not(:first-child)::after": { content: '""', position: "absolute", top: themeSpacing(3), right: `calc(50% + ${themeSpacing(3)})`, left: `calc(-50% + ${themeSpacing(3)})`, height: "2px", backgroundColor: (listener) => themeColor(listener, "shift-3", color), zIndex: "0", }, // Active step — accent colored filled circle "&[data-status=active]::before": { backgroundColor: (listener) => themeColor(listener, "shift-6", accentColor), borderColor: (listener) => themeColor(listener, "shift-6", accentColor), color: (listener) => themeColor(listener, "shift-15", accentColor), }, // Done step — muted filled circle with checkmark "&[data-status=done]::before": { content: '"✓"', backgroundColor: (listener) => themeColor(listener, "shift-3", color), borderColor: (listener) => themeColor(listener, "shift-3", color), color: (listener) => themeColor(listener, "shift-9", color), }, // Done step connector — filled track "&[data-status=done]:not(:first-child)::after": { backgroundColor: (listener) => themeColor(listener, "shift-3", color), }, // Active step connector — accent track up to the active item "&[data-status=active]:not(:first-child)::after": { backgroundColor: (listener) => themeColor(listener, "shift-6", accentColor), }, // Pending text — muted "&[data-status=pending]": { color: (listener) => themeColor(listener, "shift-7"), }, // Active text — default emphasis "&[data-status=active]": { color: (listener) => themeColor(listener, "shift-10"), fontWeight: "600", }, // Done text — secondary "&[data-status=done]": { color: (listener) => themeColor(listener, "shift-8"), }, }, }; } export { stepItem }; ``` ### steps ```ts import { merge, type PartialElement, toState, type ValueOrState, } from "@domphy/core"; import { type ThemeColor, themeSpacing } from "@domphy/theme"; /** * Container patch for a step-progress indicator. Establishes `steps` context * with a reactive `current` index. Use with `stepItem` patches on child elements. * * @param props.current - Zero-based index of the active step. Accepts a value or state. Defaults to `0`. * @param props.direction - `"horizontal"` (default) or `"vertical"` layout. * @param props.color - Theme color for pending/track elements. Defaults to `"neutral"`. * @param props.accentColor - Theme color for active/completed elements. Defaults to `"primary"`. * @example { ol: null, $: [steps({ current: 1 })] } */ function steps( props: { current?: ValueOrState<number>; direction?: "horizontal" | "vertical"; color?: ThemeColor; accentColor?: ThemeColor; } = {}, ): PartialElement { const direction = props.direction ?? "horizontal"; const color = props.color ?? "neutral"; const accentColor = props.accentColor ?? "primary"; const partial: PartialElement = { _onSchedule: (_node, element) => { const contextPartial = { _context: { steps: { current: toState(props.current ?? 0), direction, color, accentColor, }, }, }; merge(element, contextPartial); }, style: { display: "flex", flexDirection: direction === "vertical" ? "column" : "row", alignItems: direction === "vertical" ? "flex-start" : "center", gap: themeSpacing(2), listStyle: "none", margin: "0", padding: "0", }, }; return partial; } export { steps }; ``` ### strong ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeSize } from "@domphy/theme"; /** * Styles strongly emphasized (bold) text: inherited font size, `font-weight: 700`, and a * themed foreground color. * * @hostTag strong * @param props.color - Theme color tone for the text. Accepts a value or reactive state. * Defaults to `"neutral"`. * @example { strong: "important", $: [strong()] } */ function strong( props: { color?: ValueOrState<ThemeColor> } = {}, ): PartialElement { const color = toState(props.color ?? "neutral", "color"); return { _onInsert: (node) => { if (node.tagName !== "strong") { console.warn(`"strong" primitive patch must use strong tag`); } }, style: { fontSize: (listener) => themeSize(listener, "inherit"), fontWeight: 700, color: (listener) => themeColor(listener, "shift-11", color.get(listener)), backgroundColor: (listener) => themeColor(listener), }, }; } export { strong }; ``` ### subscript ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeSize } from "@domphy/theme"; /** * Renders subscript text (shrunk, baseline-lowered) for the host `<sub>` element. * * @hostTag sub * @param props.color - Theme color for the text. Optional, accepts a value or state. Defaults to `"neutral"`. * @example { sub: "2", $: [subscript()] } */ function subscript( props: { color?: ValueOrState<ThemeColor> } = {}, ): PartialElement { const color = toState(props.color ?? "neutral", "color"); return { _onInsert: (node) => { if (node.tagName !== "sub") { console.warn(`"subscript" primitive patch must use sub tag`); } }, style: { fontSize: (listener) => themeSize(listener, "decrease-1"), verticalAlign: "sub", lineHeight: 0, color: (listener) => themeColor(listener, "shift-9", color.get(listener)), }, }; } export { subscript }; ``` ### superscript ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeSize } from "@domphy/theme"; /** * Renders superscript text (shrunk, baseline-raised) for the host `<sup>` element. * * @hostTag sup * @param props.color - Theme color for the text. Optional, accepts a value or state. Defaults to `"neutral"`. * @example { sup: "2", $: [superscript()] } */ function superscript( props: { color?: ValueOrState<ThemeColor> } = {}, ): PartialElement { const color = toState(props.color ?? "neutral", "color"); return { _onInsert: (node) => { if (node.tagName !== "sup") { console.warn(`"superscript" primitive patch must use sup tag`); } }, style: { fontSize: (listener) => themeSize(listener, "decrease-1"), verticalAlign: "super", lineHeight: 0, color: (listener) => themeColor(listener, "shift-9", color.get(listener)), }, }; } export { superscript }; ``` ### tab ```ts import type { ElementNode, PartialElement } from "@domphy/core"; import { type ThemeColor, themeColor, themeDensity, themeSize, themeSpacing, } from "@domphy/theme"; /** * Styles a single tab trigger inside a `tabs` tablist on the host `<button>` element. * Wires up the tab's id/aria-controls/aria-selected, click selection, and * arrow/Home/End keyboard navigation via the surrounding `tabs` context. * Must be used inside a `tabs` patch. * * @hostTag button * @param props.accentColor - Theme color for the active/focus underline. Optional. Defaults to `"primary"`. * @param props.color - Theme color for the resting/hover underline and text. Optional. Defaults to `"neutral"`. * @example { button: "Tab 1", $: [tab()] } */ function tab( props: { accentColor?: ThemeColor; color?: ThemeColor } = {}, ): PartialElement { const { accentColor = "primary", color = "neutral" } = props; const partial: PartialElement = { role: "tab", _onInsert: (node) => { if (node.tagName !== "button") { console.warn(`"tab" patch must use button tag`); } const context = node.getContext("tabs"); if (!context) { console.warn(`"tab" patch must be used inside a "tabs"`); return; } let children = (node.parent?.children.items ?? []) as ElementNode[]; children = children.filter( (n) => n.type === "ElementNode" && n.attributes.get("role") === "tab", ); const key = node.key !== null && node.key !== undefined ? node.key : children.indexOf(node); const part: PartialElement = { id: `tab${node.parent!.nodeId}${key}`, ariaControls: `tabpanel${node.parent!.nodeId}${key}`, ariaSelected: (listener) => context.activeKey.get(listener) === key, onClick: () => context.activeKey.set(key), onKeyDown: (e: Event) => { const k = (e as KeyboardEvent).key; if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(k)) return; e.preventDefault(); const tabs = (node.parent?.children.items ?? []).filter( (n) => n.type === "ElementNode" && (n as ElementNode).attributes.get("role") === "tab", ) as ElementNode[]; if (!tabs.length) return; const idx = tabs.indexOf(node); let next = idx; if (k === "ArrowRight") next = (idx + 1) % tabs.length; else if (k === "ArrowLeft") next = (idx - 1 + tabs.length) % tabs.length; else if (k === "Home") next = 0; else if (k === "End") next = tabs.length - 1; const target = tabs[next]; context.activeKey.set(target.key ?? next); (target.domElement as HTMLElement)?.focus(); }, }; node.merge(part); }, style: { cursor: "pointer", fontSize: (listener) => themeSize(listener, "inherit"), height: (listener) => themeSpacing(6 + themeDensity(listener) * 2), paddingInline: (listener) => themeSpacing(themeDensity(listener) * 4), border: "none", outline: "none", color: (listener) => themeColor(listener, "shift-9"), backgroundColor: (listener) => themeColor(listener, "inherit"), boxShadow: (listener) => `inset 0 -${themeSpacing(0.5)} 0 0 ${themeColor(listener, "shift-1", color)}`, "&:hover:not([disabled])": { boxShadow: (listener) => `inset 0 -${themeSpacing(0.5)} 0 0 ${themeColor(listener, "shift-2", color)}`, }, "&[aria-selected=true]:not([disabled])": { boxShadow: (listener) => `inset 0 -${themeSpacing(0.5)} 0 0 ${themeColor(listener, "shift-6", accentColor)}`, }, "&:focus-visible": { boxShadow: (listener) => `inset 0 -${themeSpacing(0.5)} 0 0 ${themeColor(listener, "shift-6", accentColor)}`, }, }, }; return partial; } export { tab }; ``` ### tabPanel ```ts import type { ElementNode, PartialElement } from "@domphy/core"; import { themeDensity, themeSpacing } from "@domphy/theme"; /** * Styles a tab panel inside a `tabs` tablist. Wires up the panel's * id/aria-labelledby and toggles `hidden` based on the surrounding `tabs` * context's active key. Must be used inside a `tabs` patch. Takes no props. * * @hostTag div * @example { div: "Panel content", $: [tabPanel()] } */ function tabPanel(): PartialElement { const partial: PartialElement = { role: "tabpanel", style: { paddingBlock: (listener) => themeSpacing(themeDensity(listener) * 2), paddingInline: (listener) => themeSpacing(themeDensity(listener) * 2), }, _onInsert: (node) => { const context = node.getContext("tabs"); if (!context) { console.warn(`"tabPanel" patch must be used inside a "tabs"`); return; } let children = (node.parent?.children.items ?? []) as ElementNode[]; children = children.filter( (n) => n.type === "ElementNode" && n.attributes.get("role") === "tabpanel", ); const key = node.key !== null && node.key !== undefined ? node.key : children.indexOf(node); const part: PartialElement = { id: `tabpanel${node.parent!.nodeId}${key}`, ariaLabelledby: `tab${node.parent!.nodeId}${key}`, hidden: (listener) => context.activeKey.get(listener) !== key, }; node.merge(part); }, }; return partial; } export { tabPanel }; ``` ### table ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeDensity, themeSize, themeSpacing, } from "@domphy/theme"; /** * Styles a data table (header/body/footer cells, caption, row hover, borders) * on the host `<table>` element. * * @hostTag table * @param props.color - Theme color applied across cells and text. Optional, accepts a value or state. Defaults to `"neutral"`. * @example { table: null, $: [table()] } */ function table( props: { color?: ValueOrState<ThemeColor> } = {}, ): PartialElement { const color = toState(props.color ?? "neutral", "color"); return { _onInsert: (node) => { if (node.tagName !== "table") { console.warn(`"table" primitive patch must use table tag`); } }, style: { fontSize: (listener) => themeSize(listener, "inherit"), color: (listener) => themeColor(listener, "shift-9", color.get(listener)), width: "100%", borderCollapse: "collapse", "& caption": { captionSide: "bottom", }, "& th, & thead td": { textAlign: "left", fontWeight: 500, paddingInline: (listener) => themeSpacing(themeDensity(listener) * 3), paddingBlock: (listener) => themeSpacing(themeDensity(listener) * 1), color: (listener) => themeColor(listener, "shift-10", color.get(listener)), backgroundColor: (listener) => themeColor(listener, "inherit"), }, "& td": { textAlign: "left", paddingInline: (listener) => themeSpacing(themeDensity(listener) * 3), paddingBlock: (listener) => themeSpacing(themeDensity(listener) * 1), color: (listener) => themeColor(listener, "shift-9", color.get(listener)), boxShadow: (listener) => `inset 0 1px 0 ${themeColor(listener, "shift-3", color.get(listener))}`, fontSize: (listener) => themeSize(listener, "inherit"), }, "& tfoot th, & tfoot td": { textAlign: "left", fontWeight: 500, paddingInline: (listener) => themeSpacing(themeDensity(listener) * 3), paddingBlock: (listener) => themeSpacing(themeDensity(listener) * 1), color: (l) => themeColor(l, "shift-10", color.get(l)), backgroundColor: (l) => themeColor(l, "inherit"), boxShadow: (l) => `inset 0 -1px 0 ${themeColor(l, "shift-4", color.get(l))}`, }, "& tr": { backgroundColor: (listener) => themeColor(listener, "inherit"), }, "& tbody tr:hover": { backgroundColor: (listener) => `${themeColor(listener, "shift-2")}!important`, }, }, }; } export { table }; ``` ### tabs ```ts import { merge, type PartialElement, toState, type ValueOrState, } from "@domphy/core"; /** * Container patch that establishes a `tabs` context (with a shared `activeKey` * state) and the `tablist` role for child `tab`/`tabPanel` patches. No host tag * check; typically applied to a wrapper element. * * @param props.activeKey - Initially active tab key. Optional, accepts a value or state of `number | string`. Defaults to `0`. * @example { div: null, $: [tabs({ activeKey: 0 })] } */ function tabs( props: { activeKey?: ValueOrState<number | string> } = {}, ): PartialElement { const partial: PartialElement = { role: "tablist", ariaOrientation: "horizontal", _onSchedule: (_node, element) => { const partial = { _context: { tabs: { activeKey: toState(props.activeKey ?? 0), }, }, }; merge(element, partial); }, }; return partial; } export { tabs }; ``` ### tag ```ts import type { DomphyElement, PartialElement } from "@domphy/core"; import { toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeSize, themeSpacing, } from "@domphy/theme"; const xSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M6.707 5.293l5.293 5.292l5.293 -5.292a1 1 0 0 1 1.414 1.414l-5.292 5.293l5.292 5.293a1 1 0 0 1 -1.414 1.414l-5.293 -5.292l-5.293 5.292a1 1 0 1 1 -1.414 -1.414l5.292 -5.293l-5.292 -5.293a1 1 0 0 1 1.414 -1.414" /></svg>`; /** * Styles an inline chip/tag (rounded, bordered, optional remove button). * No host tag check; typically applied to a `<span>`. When `removable` is true, * a close button is inserted that removes the host node on click. * * @hostTag span * @param props.color - Theme color for the chip background/border/text. Optional, accepts a value or state. Defaults to `"neutral"`. * @param props.removable - When true, renders a remove (x) button that removes the tag on click. Optional. Defaults to `false`. * @example { span: "Label", $: [tag({ removable: true })] } */ function tag( props: { color?: ValueOrState<ThemeColor>; removable?: boolean } = {}, ): PartialElement { const { removable = false } = props; const color = toState(props.color ?? "neutral", "color"); return { dataTone: "shift-2", _onInit: (node) => { const removeBtn: DomphyElement<"span"> = { span: xSvg, onClick: (e) => { (e as Event).stopPropagation(); node.remove(); }, style: { display: "inline-flex", alignItems: "center", cursor: "pointer", borderRadius: themeSpacing(1), width: themeSpacing(4), height: themeSpacing(4), flexShrink: 0, "&:hover": { backgroundColor: (listener) => themeColor(listener, "shift-4", color.get(listener)), }, }, }; removable && node.children.insert(removeBtn); }, style: { display: "inline-flex", alignItems: "center", whiteSpace: "nowrap", userSelect: "none", height: themeSpacing(6), paddingBlock: "0px", borderRadius: themeSpacing(1), paddingInlineStart: themeSpacing(2), paddingInlineEnd: removable ? themeSpacing(1) : themeSpacing(2), gap: themeSpacing(2), fontSize: (listener) => themeSize(listener, "inherit"), backgroundColor: (listener) => themeColor(listener, "inherit", color.get(listener)), color: (listener) => themeColor(listener, "shift-9", color.get(listener)), border: "none", outlineOffset: "-1px", outline: (listener) => `1px solid ${themeColor(listener, "shift-4", color.get(listener))}`, }, }; } export { tag }; ``` ### textarea ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeDensity, themeSize, themeSpacing, } from "@domphy/theme"; /** * Styles a multi-line text input (border, focus/hover/invalid/disabled states) * on the host `<textarea>` element, with optional auto-resize to content. * * @hostTag textarea * @param props.color - Theme color for the border and text. Optional, accepts a value or state. Defaults to `"neutral"`. * @param props.accentColor - Theme color for hover/focus outline. Optional, accepts a value or state. Defaults to `"primary"`. * @param props.autoResize - When true, grows the textarea height to fit its content on input. Optional. Defaults to `false`. * @example { textarea: null, $: [textarea({ autoResize: true })] } */ function textarea( props: { color?: ValueOrState<ThemeColor>; accentColor?: ValueOrState<ThemeColor>; autoResize?: boolean; } = {}, ): PartialElement { const color = toState(props.color ?? "neutral", "color"); const accentColor = toState(props.accentColor ?? "primary", "accentColor"); const { autoResize = false } = props; return { _onInsert: (node) => { if (node.tagName !== "textarea") { console.warn(`"textarea" primitive patch must use textarea tag`); } }, _onMount: (node) => { if (autoResize) { const el = node.domElement as HTMLTextAreaElement; el.style.overflow = "hidden"; const resize = () => { el.style.height = "auto"; el.style.height = `${el.scrollHeight}px`; }; el.addEventListener("input", resize); resize(); node.addHook("Remove", () => el.removeEventListener("input", resize)); } }, style: { fontFamily: "inherit", lineHeight: "inherit", resize: "vertical", paddingInline: (listener) => themeSpacing(themeDensity(listener) * 2), paddingBlock: (listener) => themeSpacing(themeDensity(listener) * 1.5), border: "none", borderRadius: (listener) => themeSpacing(themeDensity(listener) * 1.5), fontSize: (listener) => themeSize(listener, "inherit"), color: (listener) => themeColor(listener, "shift-9", color.get(listener)), outlineOffset: "-1px", outline: (listener) => `1px solid ${themeColor(listener, "shift-4", color.get(listener))}`, backgroundColor: (listener) => themeColor(listener, "inherit", color.get(listener)), "&::placeholder": { color: (listener) => themeColor(listener, "shift-7"), }, "&:hover:not([disabled]):not([aria-busy=true])": { outline: (listener) => `${themeSpacing(0.5)} solid ${themeColor(listener, "shift-5", accentColor.get(listener))}`, }, "&:focus-visible": { outline: (listener) => `${themeSpacing(0.5)} solid ${themeColor(listener, "shift-6", accentColor.get(listener))}`, }, "&:invalid": { outline: (listener) => `${themeSpacing(0.5)} solid ${themeColor(listener, "shift-5", "error")}`, }, "&[disabled]": { opacity: 0.7, cursor: "not-allowed", color: (listener) => themeColor(listener, "shift-8", "neutral"), outline: (listener) => `1px solid ${themeColor(listener, "shift-4", "neutral")}`, backgroundColor: (listener) => themeColor(listener, "shift-2", "neutral"), }, }, }; } export { textarea }; ``` ### timeline ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeDensity, themeSpacing, } from "@domphy/theme"; /** * Container for a vertical timeline. Sets list reset styles. Apply to `<ol>` or `<ul>`. * * @example { ol: [...], $: [timeline()] } */ function timeline(): PartialElement { return { style: { listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", }, }; } /** * A single event row in a `timeline`. Uses a 2-column grid: the left column holds * a dot (`::before`) and optional connector line (`::after`); the right column holds * the user's content. Apply to `<li>`. * * @param props.active - Full-opacity dot (accent color). `ValueOrState<boolean>`, defaults to `false`. * @param props.last - Suppress the vertical connector below this item. `boolean`, defaults to `false`. * @param props.color - Dot/connector color tone. `ThemeColor`, defaults to `"neutral"`. * @param props.accentColor - Active dot color tone. `ThemeColor`, defaults to `"primary"`. * @example { li: [{ b: "2024" }, { p: "Event" }], $: [timelineItem({ active: true })] } */ function timelineItem( props: { active?: ValueOrState<boolean>; last?: boolean; color?: ThemeColor; accentColor?: ThemeColor; } = {}, ): PartialElement { const { last = false } = props; const color = props.color ?? "neutral"; const accentColor = props.accentColor ?? "primary"; const activeState = toState(props.active ?? false); return { style: { display: "grid", gridTemplateColumns: "2rem 1fr", columnGap: (listener) => themeSpacing(themeDensity(listener) * 2), paddingBottom: (listener) => last ? "0" : themeSpacing(themeDensity(listener) * 4), position: "relative", // Dot "&::before": { content: '""', display: "block", width: "0.75rem", height: "0.75rem", borderRadius: "50%", justifySelf: "center", marginTop: "0.25em", transition: "background-color 200ms ease, opacity 200ms ease", backgroundColor: (listener) => themeColor( listener, "shift-8", activeState.get(listener) ? accentColor : color, ), opacity: (listener) => (activeState.get(listener) ? "1" : "0.4"), }, // Vertical connector to the next item ...(last ? {} : { "&::after": { content: '""', position: "absolute", left: "calc(1rem - 1px)", top: "1.25rem", bottom: (listener) => `-${themeSpacing(themeDensity(listener) * 4)}`, width: "2px", backgroundColor: (listener) => themeColor(listener, "shift-3", color), }, }), }, }; } export { timeline, timelineItem }; ``` ### toast ```ts import type { DomphyElement, ElementNode, PartialElement } from "@domphy/core"; import { toState } from "@domphy/core"; import { type ThemeColor, themeColor, themeDensity, themeSize, themeSpacing, } from "@domphy/theme"; type ToastPosition = | "top-left" | "top-center" | "top-right" | "bottom-left" | "bottom-center" | "bottom-right"; /** * Renders a transient notification surface as a fixed-position overlay (portaled * into a corner stack), animating in on mount and out before removal. No host * tag check; typically applied to a `<div>`. * * @param props.position - Corner of the screen for the toast stack. Optional, one of `"top-left" | "top-center" | "top-right" | "bottom-left" | "bottom-center" | "bottom-right"`. Defaults to `"top-center"`. * @param props.color - Theme color for the toast surface. Optional. Defaults to `"neutral"`. * @example { div: "Saved!", $: [toast({ position: "top-right" })] } */ function toast( props: { position?: ToastPosition; color?: ThemeColor } = {}, ): PartialElement { const { position = "top-center", color = "neutral" } = props; const state = toState(false); const isTop = position.startsWith("top"); const isCenter = position.endsWith("center"); const isRight = position.endsWith("right"); const overlayEle: DomphyElement<"div"> = { div: [], id: `domphy-toast-${position}`, style: { position: "fixed", display: "flex", flexDirection: isTop ? "column" : "column-reverse", alignItems: isCenter ? "center" : isRight ? "end" : "start", inset: 0, gap: themeSpacing(4), zIndex: 30, padding: themeSpacing(6), pointerEvents: "none", }, }; return { _portal: (rootNode) => { let overlay = rootNode.domElement!.querySelector( `#domphy-toast-${position}`, ); if (!overlay) { const overlayNode = rootNode.children!.insert( overlayEle, ) as ElementNode; overlay = overlayNode.domElement!; } return overlay; }, role: "status", ariaAtomic: "true", // Toast is rendered as an overlay surface, so it uses the inverted branch. dataTone: "shift-17", style: { minWidth: themeSpacing(32), pointerEvents: "auto", paddingBlock: (listener) => themeSpacing(themeDensity(listener) * 2), paddingInline: (listener) => themeSpacing(themeDensity(listener) * 4), borderRadius: (listener) => themeSpacing(themeDensity(listener) * 2), fontSize: (listener) => themeSize(listener, "inherit"), color: (listener) => themeColor(listener, "shift-9", color), backgroundColor: (listener) => themeColor(listener, "inherit", color), boxShadow: (listener) => `0 ${themeSpacing(2)} ${themeSpacing(9)} ${themeColor(listener, "shift-4", "neutral")}`, opacity: (listener) => Number(state.get(listener)), transform: (listener) => state.get(listener) ? "translateY(0)" : isTop ? "translateY(-100%)" : "translateY(100%)", transition: "opacity 300ms ease, transform 300ms ease", }, _onMount: () => requestAnimationFrame(() => state.set(true)), _onBeforeRemove: (node, done) => { let finished = false; const finish = () => { if (finished) return; finished = true; node.domElement!.removeEventListener("transitionend", onEnd); done(); }; const onEnd = (e: Event) => { if ((e as TransitionEvent).propertyName === "transform") finish(); }; node.domElement!.addEventListener("transitionend", onEnd); // Fallback: if transitionend never fires (reduced-motion, display:none, // early detach), unblock removal after the transition duration + buffer. setTimeout(finish, 350); state.set(false); }, }; } export { toast }; ``` ### toggle ```ts import { type ElementNode, type PartialElement, toState, type ValueOrState, } from "@domphy/core"; import { type ThemeColor, themeColor, themeSize, themeSpacing, } from "@domphy/theme"; /** * Styles a single toggle button inside a `toggleGroup` on the host `<button>` * element. Wires up `aria-pressed` and click-to-toggle against the surrounding * `toggleGroup` context (single- or multi-select). Must be used inside a * `toggleGroup` patch. * * @hostTag button * @param props.color - Theme color for the resting/hover background and text. Optional, accepts a value or state. Defaults to `"neutral"`. * @param props.accentColor - Theme color for the pressed/focus state. Optional, accepts a value or state. Defaults to `"primary"`. * @example { button: "Bold", $: [toggle()] } */ function toggle( props: { color?: ValueOrState<ThemeColor>; accentColor?: ValueOrState<ThemeColor>; } = {}, ): PartialElement { const color = toState(props.color ?? "neutral", "color"); const accentColor = toState(props.accentColor ?? "primary", "accentColor"); return { role: "button", _onInsert: (node) => { if (node.tagName !== "button") { console.warn(`"toggle" patch must use button tag`); } const ctx = node.getContext("toggleGroup"); if (!ctx) { console.warn(`"toggle" patch must be used inside a "toggleGroup"`); return; } const children = (node.parent?.children.items ?? []) as ElementNode[]; const items = children.filter( (n) => n.type === "ElementNode" && n.attributes.get("role") === "button", ); // node.key is null (not undefined) when absent — check both so an // explicit _key of 0 or "" keeps its real key instead of "null"/index. const key = node.key !== null && node.key !== undefined ? String(node.key) : String(items.indexOf(node)); node.attributes.set("ariaPressed", (listener) => { const val = ctx.value.get(listener); return Array.isArray(val) ? val.includes(key) : val === key; }); node.addEvent("click", () => { const val = ctx.value.get(); if (ctx.multiple) { const arr = Array.isArray(val) ? [...val] : []; ctx.value.set( arr.includes(key) ? arr.filter((v) => v !== key) : [...arr, key], ); } else { ctx.value.set(val === key ? "" : key); } }); }, style: { cursor: "pointer", fontSize: (listener) => themeSize(listener, "inherit"), height: themeSpacing(6), paddingBlock: themeSpacing(1), paddingInline: themeSpacing(2), border: "none", borderRadius: themeSpacing(1), color: (listener) => themeColor(listener, "shift-9", color.get(listener)), backgroundColor: (listener) => themeColor(listener, "inherit", color.get(listener)), transition: "background-color 300ms ease", "&:hover:not([disabled])": { backgroundColor: (listener) => themeColor(listener, "shift-2", color.get(listener)), }, "&[aria-pressed=true]": { backgroundColor: (listener) => themeColor(listener, "shift-3", accentColor.get(listener)), color: (listener) => themeColor(listener, "shift-12", accentColor.get(listener)), }, "&:focus-visible": { outline: (listener) => `${themeSpacing(0.5)} solid ${themeColor(listener, "shift-6", accentColor.get(listener))}`, outlineOffset: `-${themeSpacing(0.5)}`, }, "&[disabled]": { opacity: 0.7, cursor: "not-allowed", }, }, }; } export { toggle }; ``` ### toggleGroup ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeSize, themeSpacing, } from "@domphy/theme"; /** * Container patch that establishes a `toggleGroup` context (shared selection * `value` + `multiple` flag) and `group` role for child `toggle` patches, with * a bordered segmented-control style. No host tag check; typically applied to a * wrapper element. * * @param props.value - Selected toggle key(s). Optional, accepts a value or state of `string | string[]`. Defaults to `[]` when `multiple`, otherwise `""`. * @param props.multiple - When true, allows multiple toggles selected at once. Optional. Defaults to `false`. * @param props.color - Theme color for the group background/border. Optional. Defaults to `"neutral"`. * @example { div: null, $: [toggleGroup({ multiple: true })] } */ function toggleGroup( props: { value?: ValueOrState<string | string[]>; multiple?: boolean; color?: ThemeColor; } = {}, ): PartialElement { const { multiple = false, color = "neutral" } = props; return { role: "group", _context: { toggleGroup: { value: toState(props.value ?? (multiple ? [] : "")), multiple, }, }, style: { display: "flex", paddingBlock: themeSpacing(1), paddingInline: themeSpacing(1), gap: themeSpacing(1), borderRadius: themeSpacing(2), fontSize: (listener) => themeSize(listener, "inherit"), backgroundColor: (listener) => themeColor(listener, "inherit", color), outline: (listener) => `1px solid ${themeColor(listener, "shift-3", color)}`, outlineOffset: "-1px", }, }; } export { toggleGroup }; ``` ### toolbar ```ts import type { DomphyElement, PartialElement } from "@domphy/core"; import { themeDensity, themeSpacing } from "@domphy/theme"; /** * A horizontal flex row with vertically centered items. Useful for headers, * toolbars, navigation bars, and action strips. * * @param props.gap - Spacing multiplier for gap between items (default 4 = 1em). * @example { header: [...], $: [toolbar()] } * @example { nav: [...], $: [toolbar({ gap: 3 })] } */ function toolbar(props: { gap?: number } = {}): PartialElement { const gap = props.gap ?? 4; return { style: { display: "flex", alignItems: "center", gap: (listener) => themeSpacing(themeDensity(listener) * gap), }, }; } /** * A flex spacer that expands to fill available space in a toolbar, pushing * subsequent items to the far end. * * @example { header: [logo, toolbarSpacer(), nav, actions], $: [toolbar()] } */ function toolbarSpacer(): DomphyElement { return { div: null, style: { flex: "1 1 0" } }; } export { toolbar, toolbarSpacer }; ``` ### tooltip ```ts import { type DomphyElement, merge, type PartialElement, toState, type ValueOrState, } from "@domphy/core"; import type { Placement } from "@domphy/floating"; import { themeColor, themeDensity, themeSize, themeSpacing, } from "@domphy/theme"; import { creatFloating } from "../utils/floating.js"; import { popoverArrow } from "./popoverArrow.js"; /** * Attaches a floating tooltip to the host element, shown on hover/focus and * hidden on leave/blur/Escape. Returns the anchor (trigger) partial; the tooltip * surface is positioned via the floating utility and linked with * `aria-describedby`. No host tag check; applied to the trigger element. * * @param props.open - Controlled open state. Optional, accepts a value or state. Defaults to `false`. * @param props.placement - Floating placement relative to the trigger. Optional, accepts a value or state (`Placement`). Defaults to `"top"`. * @param props.content - Tooltip text content. Optional, accepts a value or state (string only). Defaults to `"Tooltip Content"`. * @example { button: "Hover me", $: [tooltip({ content: "Help text" })] } */ function tooltip( props: { open?: ValueOrState<boolean>; placement?: ValueOrState<Placement>; content?: ValueOrState<string>; } = {}, ): PartialElement { const { open = false, placement = "top", content = "Tooltip Content", } = props; const placeState = toState(placement); const contentState = toState(content); // Pre-generate ID so the trigger can reference it via aria-describedby // before the tooltip content element is first inserted into the DOM. const tooltipId = `domphy-tt-${Math.random().toString(36).slice(2, 9)}`; const contentElement: DomphyElement<"span"> = { span: (listener) => contentState.get(listener), id: tooltipId, }; const { show, hide, anchorPartial } = creatFloating({ open, placement: placeState, content: contentElement, }); const tooltipPartial: PartialElement = { role: "tooltip", dataSize: "decrease-1", dataTone: "shift-17", style: { paddingBlock: (listener) => themeSpacing(themeDensity(listener) * 1), paddingInline: (listener) => themeSpacing(themeDensity(listener) * 3), borderRadius: (listener) => themeSpacing(themeDensity(listener) * 1), color: (listener) => themeColor(listener, "shift-9"), backgroundColor: (listener) => themeColor(listener), fontSize: (listener) => themeSize(listener, "inherit"), }, $: [popoverArrow({ placement: placeState, bordered: false })], }; contentElement.$ ||= []; contentElement.$.push(tooltipPartial); const triggerPartial: PartialElement = { ariaDescribedby: tooltipId, onMouseEnter: () => show(), onMouseLeave: () => hide(), onFocus: () => show(), onBlur: () => hide(), onKeyDown: (e) => (e as KeyboardEvent).key === "Escape" && hide(), }; merge(anchorPartial, triggerPartial); return anchorPartial; } export { tooltip }; ``` ### transitionGroup ```ts import { ElementNode, type PartialElement } from "@domphy/core"; type RectMap = Map<string, DOMRect>; function getItemId(node: ElementNode, index: number): string { if (node.key !== undefined && node.key !== null) { return String(node.key); } return `index-${index}`; } /** * Animates child reordering using the FLIP technique: records each child's * position before an update and smoothly transitions it from its old to new * position afterward. No host tag check; applied to the list container. * * @param props.duration - Transition duration in milliseconds. Optional. Defaults to `300`. * @param props.delay - Transition delay in milliseconds. Optional. Defaults to `0`. * @example { ul: null, $: [transitionGroup({ duration: 300 })] } */ function transitionGroup( props: { duration?: number; delay?: number } = {}, ): PartialElement { const { duration = 300, delay = 0 } = props; let previousRects: RectMap = new Map(); // Cancels any in-flight animation for a given DOM element before starting a new one. const cancelMap = new Map<HTMLElement, () => void>(); return { _onBeforeUpdate: (node) => { previousRects = new Map(); node.children.items.forEach((item, index) => { if (!(item instanceof ElementNode)) return; const dom = item.domElement as HTMLElement | undefined; if (!dom) return; previousRects.set(getItemId(item, index), dom.getBoundingClientRect()); }); }, _onUpdate: (node) => { node.children.items.forEach((item, index) => { if (!(item instanceof ElementNode)) return; const dom = item.domElement as HTMLElement | undefined; if (!dom) return; const key = getItemId(item, index); const prev = previousRects.get(key); if (!prev) return; const next = dom.getBoundingClientRect(); const deltaX = prev.left - next.left; const deltaY = prev.top - next.top; if (Math.abs(deltaX) < 0.5 && Math.abs(deltaY) < 0.5) return; // Cancel any in-flight animation on this element before starting a new one. cancelMap.get(dom)?.(); dom.style.transition = "none"; dom.style.transform = `translate(${deltaX}px, ${deltaY}px)`; dom.getBoundingClientRect(); requestAnimationFrame(() => { dom.style.transition = `transform ${duration}ms ease ${delay}ms`; dom.style.transform = "translate(0px, 0px)"; }); let cancelled = false; const finish = () => { if (cancelled) return; cancelled = true; cancelMap.delete(dom); dom.removeEventListener("transitionend", onEnd); dom.style.transition = ""; dom.style.transform = ""; }; const onEnd = (event: Event) => { if ((event as TransitionEvent).propertyName === "transform") finish(); }; cancelMap.set(dom, () => { cancelled = true; cancelMap.delete(dom); dom.removeEventListener("transitionend", onEnd); }); dom.addEventListener("transitionend", onEnd); setTimeout(finish, duration + delay + 34); }); previousRects.clear(); }, }; } export { transitionGroup }; ``` ### unorderedList ```ts import { type PartialElement, toState, type ValueOrState } from "@domphy/core"; import { type ThemeColor, themeColor, themeSize, themeSpacing, } from "@domphy/theme"; /** * Styles a bulleted list (disc markers, reset margins, themed text) on the host * `<ul>` element. * * @hostTag ul * @param props.color - Theme color for the list text. Optional, accepts a value or state. Defaults to `"neutral"`. * @example { ul: null, $: [unorderedList()] } */ function unorderedList( props: { color?: ValueOrState<ThemeColor> } = {}, ): PartialElement { const color = toState(props.color ?? "neutral", "color"); return { _onInsert: (node) => { if (node.tagName !== "ul") { console.warn(`"unorderedList" primitive patch must use ul tag`); } }, style: { fontSize: (listener) => themeSize(listener, "inherit"), backgroundColor: (listener) => themeColor(listener), color: (listener) => themeColor(listener, "shift-9", color.get(listener)), marginTop: 0, marginBottom: 0, paddingLeft: themeSpacing(3), listStyleType: "disc", listStylePosition: "outside", }, }; } export { unorderedList }; ```