diff --git a/.changeset/solid-integration.md b/.changeset/solid-integration.md new file mode 100644 index 0000000..fb5c0fb --- /dev/null +++ b/.changeset/solid-integration.md @@ -0,0 +1,15 @@ +--- +'@dunky.dev/state-machine-solid': minor +--- + +Add `@dunky.dev/state-machine-solid` — the Solid bindings target. + +A first-class Solid bridge (not a React re-export): `useMachine` mirrors the +connector's snapshot into a Solid `createStore` (via `reconcile`) so reading a +field in JSX is fine-grained, runs the lifecycle through `onMount`/`onCleanup`, +keeps props fresh with a tracked `setProps` effect, and runs each +`ComponentEffect` as its own dep-tracked `createEffect`. `useSelector` returns a +Solid accessor. `normalize` maps the agnostic bindings to Solid DOM props +(`onInput`, `onDblClick`, `tabindex`) and `mergeProps` applies Solid's `class` +concat + single-object `style` merge. The same `connect` and machine config run +unchanged across React, Solid, React Native, and OpenTUI. diff --git a/benchmark/tsconfig.json b/benchmark/tsconfig.json index a602cb2..48a9a23 100644 --- a/benchmark/tsconfig.json +++ b/benchmark/tsconfig.json @@ -3,6 +3,8 @@ // don't typecheck), so this is the only thing that catches type errors here. "extends": "../tsconfig.json", "compilerOptions": { + // the base sets no `jsx` (it's per-project); the benchmark is React-flavored + "jsx": "react-jsx", // paths in `extends` resolve relative to THIS file, so redeclare them "paths": { "@dunky.dev/state-machine": ["../packages/core/src"], diff --git a/package.json b/package.json index 6fe0557..a51ec90 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "website:dev": "pnpm -C website dev", "website:prod": "pnpm -C website build", "build": "tsdown", - "typecheck": "tsc -b tsconfig.all.json", + "typecheck": "tsc -b tsconfig/all.json", "lint": "oxlint .", "format": "oxfmt .", "format:check": "oxfmt --check .", @@ -30,8 +30,10 @@ "oxfmt": "^0.52.0", "oxlint": "^1.67.0", "publint": "^0.3.21", + "solid-js": "^1.9.13", "tsdown": "^0.22.2", "typescript": "^6.0.3", + "vite-plugin-solid": "^2.11.12", "vitest": "^4.1.7" }, "packageManager": "pnpm@10.20.0" diff --git a/packages/solid/CHANGELOG.md b/packages/solid/CHANGELOG.md new file mode 100644 index 0000000..092b0ac --- /dev/null +++ b/packages/solid/CHANGELOG.md @@ -0,0 +1 @@ +# @dunky.dev/state-machine-solid diff --git a/packages/solid/LICENSE b/packages/solid/LICENSE new file mode 100644 index 0000000..08a9692 --- /dev/null +++ b/packages/solid/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ivan Banov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/solid/README.md b/packages/solid/README.md new file mode 100644 index 0000000..962fddf --- /dev/null +++ b/packages/solid/README.md @@ -0,0 +1,205 @@ +# `@dunky.dev/state-machine-solid` + +The **Solid bindings** for [`@dunky.dev/state-machine`](../core/README.md). The +core engine is renderer-agnostic; this package is the thin Solid edge that drives +it: it builds the machine + connector, runs the Solid lifecycle, mirrors the +connector's snapshot into a fine-grained store, translates the agnostic +[bindings](../core/README.md#connector--the-view-boundary) vocabulary into DOM +props, and owns the per-component substrate effects. + +This is a **first-class Solid target**, not a re-export of the React bridge. +React's adapters re-export onto React Native and OpenTUI because those share a +React reconciler; Solid has its own fine-grained reactivity, so the lifecycle is +implemented with Solid primitives — `createStore` + `reconcile`, `createEffect`, +`onMount`/`onCleanup` — and there is no `useSyncExternalStore`. The behavior +still lives in the core machine and the component's `connect`; this layer only +adapts them to Solid. Four exports: `useMachine`, `useSelector`, `normalize`, +`mergeProps`, plus the `ComponentEffect` types. + +--- + +## `useMachine` — the one bridge hook + +Every component's generated `useXxxApi` calls this with the agnostic pieces: + +```ts +const { api, machine } = useMachine( + tooltipMachineConfig, // (props) => config — config factory, props seed it ONCE + connectTooltip, // pure connect(): snapshot → view api + tooltipEffects, // the component's substrate effects (ComponentEffect[]) + props, // the reactive Solid props +) +``` + +It: + +- **builds once** — `machine(createConfig(props))` + `connector(service, connect, +{ ...props })`. A Solid component body runs a single time, so a plain build IS + "build once" (no `useMemo` equivalent). The first props seed context and the + initial state; later prop changes flow through `setProps`, never a rebuild. + > The connector is seeded with a **plain snapshot** (`{ ...props }`), never the + > live Solid props proxy. The connector value-dedups in `setProps`; if it held + > the proxy it would later compare the proxy against a fresh spread of that same + > proxy — whose getters have already updated — find them equal, and never wake. +- **is fine-grained** — the connector's snapshot is mirrored into a + `createStore` via `reconcile` on every connector wake. Reading `api.isOpen` in + JSX subscribes to exactly that leaf, so an unrelated field changing won't touch + it. `api` is the store proxy — **don't destructure it** (`const { isOpen } = +api` snapshots the value and drops reactivity); read its fields where you use + them. +- **keeps props fresh** via a tracked effect — `createEffect(() => +connection.setProps({ ...props }))`. Solid auto-tracks every prop read in the + spread, so it re-runs whenever a consumed prop changes, with no manual dep + list. `setProps` value-dedups. +- **runs the lifecycle** — `service.start()` in `onMount`, `service.stop()` in + `onCleanup`. The connector wired its + [reactions](../core/README.md#reactions--firing-prop-callbacks-without-the-machine-knowing) + to the machine's own `start`/`stop`, so prop-callbacks follow automatically. +- **runs the component's substrate effects** — one `createEffect` per + `ComponentEffect` entry, each reading its named prop deps so it re-subscribes + only when one of them changes (see below). + +Returns `{ api, machine }`: `api` is the reactive store to spread onto elements; +`machine` is the running service (also handed to `useSelector`). + +--- + +## `ComponentEffect` — substrate transport, without the boilerplate + +Some behavior can't live in the agnostic machine because it needs the **platform +itself** — a DOM `keydown` listener for Escape, a `ResizeObserver` — and the +**props** the machine never sees (`closeOnEscape`). That's the component's +Solid-side _effect_. + +Each effect is a `[setup/teardown, depPropNames]` tuple (`ComponentEffect`) — the +**same shape as every other target**, so a component's effects are authored once +and run unchanged on React and Solid: + +```ts +import type { ComponentEffect } from '@dunky.dev/state-machine-solid' + +type TooltipEffect = ComponentEffect + +/** Escape-to-close (gated by closeOnEscape). */ +const trackEscape: TooltipEffect = [ + (machine, props) => { + if (!props.closeOnEscape) return + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') machine.send({ type: 'escape' }) + } + document.addEventListener('keydown', onKeyDown, true) + return () => document.removeEventListener('keydown', onKeyDown, true) + }, + ['closeOnEscape'], // ← re-run only when this prop changes +] + +export const tooltipEffects = [trackEscape] +``` + +`useMachine` runs the list — **one `createEffect` per entry**. Each effect READS +its declared prop deps, so Solid's auto-tracking re-runs it (cleanup → setup) +only when one of those props actually changes, never on unrelated changes. The +deps are prop NAMES — typed `(keyof Props)[]`, so a typo is a compile error. +Reading the deps explicitly (rather than letting the effect body's own reads +decide) keeps the dependency set driven by the authored `deps` and identical to +every other target. + +> The agnostic _decision_ lives in the core component's resolver; only the +> _transport_ (the DOM listener) is here. The machine just receives a plain event. + +--- + +## `useSelector` — fine-grained leaf subscription + +Returns a Solid **accessor** that updates only when one slice of the machine +changes: + +```ts +const open = useSelector(machine, () => machine.matches('open')) +const isHL = useSelector(machine, () => machine.context.highlightedValue === value) +// read it in JSX:
+``` + +Backed by a `createSignal` driven by the machine's `select` — a value-deduped +Selection. `Object.is` by default; **an object/array selection MUST pass a custom +`isEqual`** so a re-derived equal value doesn't push a change: + +```ts +const pos = useSelector( + machine, + () => ({ x: machine.context.x, y: machine.context.y }), + (a, b) => a.x === b.x && a.y === b.y, +) +``` + +`api` from `useMachine` is already fine-grained, so reach for `useSelector` when +a leaf wants to track one slice of a machine it doesn't otherwise own — e.g. +thousands of rows backed by one machine, each waking only for its own value +(`O(readers)`). + +--- + +## `normalize` — agnostic bindings → DOM props + +`connect` returns substrate-agnostic +[bindings](../core/README.md#connector--the-view-boundary) (`onPress`, `role`). +`normalize` translates them to DOM/ARIA props as Solid's JSX expects them: + +```ts +const domProps = normalize(api.triggerProps) // { onClick, 'aria-expanded', role, tabindex, ... } +``` + +Same vocabulary as the React DOM normalizer, with Solid's JSX conventions: + +| Agnostic binding | Solid DOM prop | +| ---------------- | ------------------------------------- | +| `onPress` | `onClick` | +| `onValueChange` | `onInput` (wrapped → `ChangePayload`) | +| `onDoublePress` | `onDblClick` | +| `focusable` | `tabindex` (`true → 0`, `false → -1`) | + +Pointer/keyboard/focus handlers and the full ARIA attribute set map exactly as in +the [React DOM normalizer](../react/README.md#normalize--agnostic-bindings--dom-props). +`undefined` values are dropped; any key not in the map (`class`, `data-*`) passes +through unchanged. `onValueChange`/`onWheel`/`onScroll`/`onScrollEnd` are wrapped +so the consumer receives the agnostic payload built from the native DOM event. + +--- + +## `mergeProps` — combine consumer props with the component's props + +When a consumer spreads their own props onto the same element the component +controls, `mergeProps(consumer, library)` merges them the Radix/Ark way, Solid +flavor: + +```ts +const finalProps = mergeProps(consumerProps, normalize(api.triggerProps)) +``` + +- **Event handlers are chained, consumer-first** — both run, but if the + consumer's handler marks the event `defaultPrevented`, the library handler is + skipped (a clean veto). +- **`class` is concatenated** with a single space and trimmed (Solid uses + `class`, not React's `className`). +- **`style` is merged into ONE object**, library winning on conflicting keys. + Solid's `style` is a plain object, not React's array form — so styles merge + rather than wrap. +- **Everything else: library wins** (`id`, `role`, `aria-*`). + +> This is **not** Solid's own `mergeProps` from `solid-js` (which merges reactive +> prop objects). It merges the consumer's props with the component's normalized +> bindings. + +--- + +## API + +| Export | What it is | +| --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `useMachine(config, connect, effects, props)` | the bridge hook — build once + lifecycle + run effects + fine-grained store; returns `{ api, machine }` | +| `useSelector(machine, selector, isEqual?)` | fine-grained subscription to a derived slice; returns a Solid accessor (`O(readers)`) | +| `normalize(bindings)` | agnostic bindings → Solid DOM/ARIA props | +| `mergeProps(consumer, library)` | merge consumer + component props (handlers chained w/ `defaultPrevented` veto; `class` concat; `style` object merge) | +| `ComponentEffect` | `[ (machine, props) => cleanup, (keyof P)[] ]` — one substrate effect + its prop deps | +| `ComponentEffects` | `ComponentEffect[]` — a component's effect list | +| `Bindings` | `Record` — the loose shape `normalize` accepts | diff --git a/packages/solid/package.json b/packages/solid/package.json new file mode 100644 index 0000000..b711b2d --- /dev/null +++ b/packages/solid/package.json @@ -0,0 +1,47 @@ +{ + "name": "@dunky.dev/state-machine-solid", + "version": "0.2.0", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/dunky-dev/state-machine.git", + "directory": "packages/solid" + }, + "files": [ + "dist" + ], + "type": "module", + "sideEffects": false, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "publishConfig": { + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "access": "public" + }, + "scripts": { + "build": "tsdown" + }, + "dependencies": { + "@dunky.dev/state-machine": "workspace:^", + "@dunky.dev/state-machine-utils": "workspace:^" + }, + "devDependencies": { + "@solidjs/testing-library": "^0.8.10", + "jsdom": "^29.1.1", + "solid-js": "^1.9.13" + }, + "peerDependencies": { + "solid-js": "^1.6.0" + } +} diff --git a/packages/solid/src/index.ts b/packages/solid/src/index.ts new file mode 100644 index 0000000..d113d89 --- /dev/null +++ b/packages/solid/src/index.ts @@ -0,0 +1,4 @@ +export { useMachine, type ComponentEffect, type ComponentEffects } from './use-machine' +export { useSelector } from './use-selector' +export { normalize, type Bindings } from './normalize' +export { mergeProps } from './merge-props' diff --git a/packages/solid/src/merge-props.ts b/packages/solid/src/merge-props.ts new file mode 100644 index 0000000..93be260 --- /dev/null +++ b/packages/solid/src/merge-props.ts @@ -0,0 +1,35 @@ +import { mergeProps as baseMergeProps } from '@dunky.dev/state-machine-utils' + +type AnyProps = Record + +/** + * Merge consumer props with the component's normalized props, Solid-style. + * + * Layers Solid's DOM conventions on the substrate-agnostic mergeProps (handler + * compose with the `defaultPrevented` veto; everything else library-wins): + * + * - `class` is concatenated with a space (Solid uses `class`, not React's + * `className`). + * - `style` is merged into ONE object, library winning on conflicting keys. + * Solid's `style` prop is a plain object (or string), NOT React's array form — + * so styles merge rather than wrap. (String styles fall through to + * library-wins; mixing a string and an object on the same element is a consumer + * error Solid itself wouldn't merge either.) + */ +export function mergeProps(consumer: AnyProps | undefined, library: AnyProps): AnyProps { + const merged = baseMergeProps(consumer, library) + if (!consumer) return merged + + if (typeof consumer.class === 'string' && typeof library.class === 'string') { + merged.class = `${consumer.class} ${library.class}`.trim() + } + if (isStyleObject(consumer.style) && isStyleObject(library.style)) { + merged.style = { ...consumer.style, ...library.style } + } + + return merged +} + +function isStyleObject(v: unknown): v is Record { + return typeof v === 'object' && v !== null +} diff --git a/packages/solid/src/normalize.ts b/packages/solid/src/normalize.ts new file mode 100644 index 0000000..847d09a --- /dev/null +++ b/packages/solid/src/normalize.ts @@ -0,0 +1,178 @@ +/** + * Translate the machine layer's LOGICAL surface to Solid DOM props. + * + * Logical handler → DOM event prop + * Logical attr → DOM/ARIA attr + * + * Differences from the React DOM normalizer worth flagging: + * + * - Solid's JSX event props are NATIVE DOM events, not React's synthetic ones. + * `onClick` is fine (Solid delegates it), and the event handed to a handler is + * a real `MouseEvent`/`PointerEvent`/`KeyboardEvent`/`WheelEvent`, so the + * payload adapters below read the native event shape (same field names). + * - Solid uses lowercase `tabindex` (the real attribute), not React's camelCase + * `tabIndex`. That's the only attr name that differs from the DOM normalizer — + * the ARIA attributes (`aria-*`) are written verbatim in Solid JSX, exactly as + * here. + */ + +const HANDLER_MAP: Record = { + onPress: 'onClick', + onPointerEnter: 'onPointerEnter', + onPointerLeave: 'onPointerLeave', + onPointerMove: 'onPointerMove', + onPointerDown: 'onPointerDown', + onPointerUp: 'onPointerUp', + onPointerCancel: 'onPointerCancel', + onFocus: 'onFocus', + onBlur: 'onBlur', + onKeyDown: 'onKeyDown', + onKeyUp: 'onKeyUp', + // value-change + secondary/double activation + scroll/wheel. onValueChange/ + // onWheel/onScroll/onScrollEnd additionally have their argument translated + // from the raw DOM event into the agnostic payload (see PAYLOAD_ADAPTERS). + onValueChange: 'onInput', + onContextMenu: 'onContextMenu', + onDoublePress: 'onDblClick', + onWheel: 'onWheel', + onScroll: 'onScroll', + onScrollEnd: 'onScrollEnd', +} + +// Some handlers can't just be renamed: the agnostic payload the component reads +// (`ChangePayload`/`WheelPayload`/`ScrollPayload`) is a different SHAPE from the +// native DOM event. For those, normalize wraps the handler so the component +// receives the agnostic payload — built here from the DOM event — rather than +// the event itself. (onPress/pointer/keyboard handlers already receive a shape +// that overlaps PointerPayload/KeyboardPayload, so they pass through unwrapped.) + +// DOM WheelEvent.deltaMode (0/1/2) → the neutral WheelPayload unit. +const WHEEL_UNIT = ['pixel', 'line', 'page'] as const + +type AnyEvent = { + target?: { value?: unknown; checked?: unknown; type?: string } + currentTarget?: Record + deltaX?: number + deltaY?: number + deltaZ?: number + deltaMode?: number + defaultPrevented?: boolean + preventDefault?: () => void +} + +const PAYLOAD_ADAPTERS: Record unknown> = { + onValueChange: e => { + const t = e?.target + // checkbox/radio carry the boolean on `.checked`; everything else on `.value`. + const value = t && (t.type === 'checkbox' || t.type === 'radio') ? t.checked : t?.value + return { value, defaultPrevented: e?.defaultPrevented, preventDefault: e?.preventDefault } + }, + onWheel: e => ({ + deltaX: e?.deltaX, + deltaY: e?.deltaY, + deltaZ: e?.deltaZ, + deltaUnit: WHEEL_UNIT[e?.deltaMode ?? 0] ?? 'pixel', + defaultPrevented: e?.defaultPrevented, + preventDefault: e?.preventDefault, + }), + onScroll: scrollPayload, + onScrollEnd: scrollPayload, +} + +function scrollPayload(e: AnyEvent): unknown { + const el = e?.currentTarget ?? {} + return { + offsetX: el.scrollLeft, + offsetY: el.scrollTop, + contentWidth: el.scrollWidth, + contentHeight: el.scrollHeight, + viewportWidth: el.clientWidth, + viewportHeight: el.clientHeight, + } +} + +const ATTR_MAP: Record = { + describedBy: 'aria-describedby', + labelledBy: 'aria-labelledby', + controls: 'aria-controls', + hasPopup: 'aria-haspopup', + expanded: 'aria-expanded', + selected: 'aria-selected', + disabled: 'aria-disabled', + hidden: 'aria-hidden', + modal: 'aria-modal', + focusable: 'tabindex', // value transformed below + role: 'role', + id: 'id', + + // labeling + label: 'aria-label', + // widget state (values pass through untransformed — booleans, the 'mixed' + // tristate, and the aria-current / aria-invalid enums all serialize as-is) + checked: 'aria-checked', + pressed: 'aria-pressed', + current: 'aria-current', + busy: 'aria-busy', + invalid: 'aria-invalid', + required: 'aria-required', + readOnly: 'aria-readonly', + // relationships + activeDescendant: 'aria-activedescendant', + errorMessage: 'aria-errormessage', + owns: 'aria-owns', + // value / range + valueMin: 'aria-valuemin', + valueMax: 'aria-valuemax', + valueNow: 'aria-valuenow', + valueText: 'aria-valuetext', + // structure / orientation + orientation: 'aria-orientation', + sort: 'aria-sort', + autoComplete: 'aria-autocomplete', + multiline: 'aria-multiline', + multiSelectable: 'aria-multiselectable', + level: 'aria-level', + posInSet: 'aria-posinset', + setSize: 'aria-setsize', + // grid / table + colCount: 'aria-colcount', + colIndex: 'aria-colindex', + colSpan: 'aria-colspan', + rowCount: 'aria-rowcount', + rowIndex: 'aria-rowindex', + rowSpan: 'aria-rowspan', + // live region + live: 'aria-live', + atomic: 'aria-atomic', +} + +export type Bindings = Record + +export function normalize(logical: Bindings): Record { + const out: Record = {} + for (const [key, value] of Object.entries(logical)) { + if (value === undefined) continue + + const handler = HANDLER_MAP[key] + if (handler) { + const adapt = PAYLOAD_ADAPTERS[key] + // Wrap when the agnostic payload differs from the raw DOM event; else the + // handler shape already matches (PointerPayload/KeyboardPayload), pass it. + out[handler] = adapt ? (e: AnyEvent) => (value as (p: unknown) => void)(adapt(e)) : value + continue + } + + const attr = ATTR_MAP[key] + if (attr) { + if (key === 'focusable') { + out[attr] = value ? 0 : -1 + } else { + out[attr] = value + } + continue + } + + out[key] = value + } + return out +} diff --git a/packages/solid/src/use-machine.ts b/packages/solid/src/use-machine.ts new file mode 100644 index 0000000..b7adf99 --- /dev/null +++ b/packages/solid/src/use-machine.ts @@ -0,0 +1,140 @@ +import { createEffect, onCleanup, onMount } from 'solid-js' +import { createStore, reconcile } from 'solid-js/store' +import { connector, machine, type Connect, type TransitionConfig } from '@dunky.dev/state-machine' + +/** + * One substrate-specific effect, declared as a plain setup/teardown function + * plus the prop names it depends on: + * + * const escape: ComponentEffect = [ + * (machine, props) => { ...addEventListener...; return () => ...remove... }, + * ['closeOnEscape', 'onEscapeKeyDown'], // re-run when these props change + * ] + * + * The author writes no Solid. The deps are prop NAMES (typed `(keyof Props)[]`, + * so typos are compile errors). The bridge runs each effect inside its own + * `createEffect` and READS those named props there, so Solid's auto-tracking + * re-runs the effect (cleanup → setup) only when one of those props actually + * changes — not on unrelated changes, never stale. `machine` is a constant, so + * it's not a dependency. + * + * The tuple shape is identical across every target (React, Solid, …) so a + * component's effects are authored ONCE and run unchanged everywhere; only how + * the bridge consumes the deps differs (a manual dep array on React, reactive + * reads here). + */ +export type ComponentEffect = [ + effect: (machine: Machine, props: Props) => (() => void) | void, + deps: (keyof Props)[], +] + +/** + * A component's full set of substrate effects — a list, since one component can + * have several independent effects with DIFFERENT deps (e.g. an Escape listener + * gated by `closeOnEscape` and a Tab trap gated by `focusTrap`). Each gets its + * own `createEffect` so only the one whose dep changed re-subscribes. + * + * Unlike React there's no rules-of-hooks constraint here (a `createEffect` is + * not a hook), but keeping it a stable module constant (`export const xEffects = + * [...]`) is still the convention — it reads identically across targets and + * never rebuilds the effect closures per call. + */ +export type ComponentEffects = ComponentEffect[] + +/** + * The one generic Solid bridge. Every component's generated api.ts calls this + * with the agnostic pieces — a config factory and the connect — plus the + * component's substrate effects and the (reactive) props: + * + * useMachine(tooltipMachineConfig, connectTooltip, tooltipEffects, props) + * + * It builds the machine + connector ONCE (a Solid component body runs once, so + * no memo is needed — the first render's props seed context + the initial + * state), mirrors the connector's snapshot into a fine-grained `createStore` + * (via `reconcile`, so only the leaves that actually changed notify their JSX + * readers — the whole point of a Solid target), starts the machine on mount and + * stops it on cleanup (the connector's reactions follow the machine's lifecycle + * automatically), keeps props fresh via a tracked `setProps` effect, and runs + * the component's prop-dependent effects (Escape, etc — one `createEffect` each, + * re-running when their named prop deps change). + * + * Returns the connect() api (the reactive store proxy — read `api.isOpen` in + * JSX and it updates fine-grained) and the running machine. + * + * Later prop changes flow through `setProps`, never a rebuild — recreating would + * lose state. + */ +export function useMachine< + State extends string, + Context extends object, + Event extends { type: string }, + Props extends object, + Api extends object, + Computed = Record, +>( + createConfig: (props: Props) => TransitionConfig, + connect: Connect, + effects: ComponentEffects>, Props>, + props: Props, +): { api: Api; machine: ReturnType> } { + // Build machine + connector once. A Solid component body runs a single time, + // so a plain build IS "build once" — no useMemo equivalent needed. The props + // proxy is reactive; reading it here at build time seeds context + the initial + // state from the first values. + // + // CRITICAL: seed the connector with a PLAIN snapshot (`{ ...props }`), never + // the live Solid props proxy. The connector value-dedups in setProps + // (shallowEqual), and if it held the proxy it would later compare the proxy + // against a fresh spread of that same proxy — whose values have already + // updated through the getters — find them equal, and never wake. A frozen + // plain copy makes "did a prop change?" a real comparison. + const service = machine(createConfig(props)) + const connection = connector(service, connect, { ...props }) + + // Mirror the connector's snapshot into a fine-grained store. `reconcile` + // deep-diffs the new snapshot against the store, so reading `api.isOpen` in + // JSX subscribes to exactly that leaf — an unrelated field changing won't + // touch it. This is what makes the Solid target fine-grained rather than a + // coarse "re-render the whole component" bridge. The connector already + // memoizes its snapshot (stable identity while clean), and `reconcile` is a + // no-op when nothing changed, so a wake that didn't move anything is cheap. + const [api, setApi] = createStore(connection.snapshot) + const off = connection.subscribe(() => setApi(reconcile(connection.snapshot))) + onCleanup(off) + + // Keep consumer props fresh (controlled flags, callbacks). `createEffect` + // tracks every prop read inside `connection.setProps(props)` — Solid props are + // a reactive proxy — so this re-runs whenever any consumed prop changes, with + // no manual dep list. setProps value-dedups, so an unchanged prop set is a + // no-op. (The connector was seeded with the initial props at build, so this + // only pushes subsequent changes.) + createEffect(() => connection.setProps({ ...props })) + + // Lifecycle: boot on mount, tear down on cleanup. The connector wired its + // reactions to the machine's own start/stop, so start()/stop() is all the + // bridge needs — reactions follow automatically. + // + // We deliberately do NOT call connection.destroy(): the connector shares this + // component's lifetime with the machine (both built above), so they're GC'd + // together. destroy() exists for callers that build a connector standalone. + onMount(() => service.start()) + onCleanup(() => service.stop()) + + // Component effects — the prop-dependent platform listeners (Escape, etc) the + // machine can't own. One `createEffect` per entry: it READS the named prop + // deps (so Solid re-runs it when one of them changes), runs the effect, and + // registers the returned teardown via onCleanup (run before the next re-run + // and on unmount). Reading the deps explicitly — rather than letting the + // effect body's own reads decide — keeps the dependency set identical to every + // other target, driven by the authored `deps` and nothing else. + for (const [fn, deps] of effects) { + createEffect(() => { + // Touch each declared dep so this effect re-runs when it changes. + for (const key of deps) void props[key] + const cleanup = fn(service, props) + if (cleanup) onCleanup(cleanup) + }) + } + + return { api, machine: service } +} diff --git a/packages/solid/src/use-selector.ts b/packages/solid/src/use-selector.ts new file mode 100644 index 0000000..5984fcc --- /dev/null +++ b/packages/solid/src/use-selector.ts @@ -0,0 +1,56 @@ +import { createSignal, onCleanup, type Accessor } from 'solid-js' +import type { EqualityFn, Machine } from '@dunky.dev/state-machine' + +/** + * Fine-grained, selector-based subscription for leaf components. + * + * The selector reads from the machine directly (`m.context.x`, `m.matches(...)`) + * and the returned accessor updates only when the selected VALUE changes — not + * on every machine change. + * + * Returns a Solid Accessor (`() => T`): call it in JSX (`{open()}`) and that JSX + * tracks it. The accessor is backed by a `createSignal` driven by the machine's + * `select` — a value-deduped Selection. Every machine notify re-evaluates the + * selector and value-compares the result (coarse bus + value compare, not + * field-level dependency tracking); the signal is written only when the selected + * value actually changed, so a change to an UNRELATED field never wakes this + * reader. For a leaf list backed by ONE machine per item (the common shape), + * each item's accessor wakes only for its own value — the O(readers) property + * that makes thousands of leaves cheap. + * + * const open = useSelector(m, () => m.matches('open')) + * const isHL = useSelector(m, () => m.context.highlightedValue === value) + * + * Equality is `Object.is` by default; pass a custom `isEqual` for object + * selections so a re-derived equal object doesn't push a new value. + * + * The Selection's own dedup AND the signal's equality both use `isEqual`, so an + * object selection that returns a fresh `{...}` each evaluation stays stable as + * long as `isEqual` deems it unchanged — no spurious updates. + */ +export function useSelector< + State extends string, + Context extends object, + T, + Event extends { type: string } = { type: string }, + Computed = Record, +>( + machine: Machine, + selector: () => T, + isEqual?: EqualityFn, +): Accessor { + const selection = machine.select(selector) + + // Seed the signal with the current selected value. The signal's own equality + // is the caller's `isEqual` (falling back to Solid's default Object.is), so + // writing an equal value is a no-op — a second line of dedup behind the + // Selection's bus-level one. + const [value, setValue] = createSignal(selection.value, { equals: isEqual }) + + // The Selection fires its listener only when the selected value changes + // (Object.is by default, or our `isEqual`); we push that into the signal. + const off = selection.subscribe(next => setValue(() => next), isEqual) + onCleanup(off) + + return value +} diff --git a/packages/solid/tests/merge-props.test.ts b/packages/solid/tests/merge-props.test.ts new file mode 100644 index 0000000..cec0e17 --- /dev/null +++ b/packages/solid/tests/merge-props.test.ts @@ -0,0 +1,69 @@ +/** + * Solid mergeProps — consumer + component props, Solid-style. Inherits handler + * composition (with the defaultPrevented veto) and library-wins from the agnostic + * base; layers Solid's DOM conventions on top: `class` concat (not `className`) + * and `style` merged into ONE object (Solid's style is an object, not React's + * array form). + */ +import { describe, expect, it, vi } from 'vitest' +import { mergeProps } from '../src' + +describe('solid mergeProps', () => { + it('inherits handler composition from the agnostic base', () => { + const consumer = vi.fn() + const library = vi.fn() + const merged = mergeProps({ onClick: consumer }, { onClick: library }) + ;(merged.onClick as (e: unknown) => void)({ defaultPrevented: false }) + expect(consumer).toHaveBeenCalledOnce() + expect(library).toHaveBeenCalledOnce() + }) + + it('skips the library handler when the consumer prevents default (veto)', () => { + const consumer = vi.fn() + const library = vi.fn() + const merged = mergeProps({ onClick: consumer }, { onClick: library }) + ;(merged.onClick as (e: unknown) => void)({ defaultPrevented: true }) + expect(consumer).toHaveBeenCalledOnce() + expect(library).not.toHaveBeenCalled() + }) + + it('inherits library-wins on plain attrs', () => { + const out = mergeProps({ id: 'consumer' }, { id: 'lib' }) + expect(out.id).toBe('lib') + }) + + it('merges overlapping styles into ONE object — library wins on conflicts', () => { + const out = mergeProps( + { style: { color: 'red', margin: 0 } }, + { style: { color: 'blue', padding: 4 } }, + ) + expect(out.style).toEqual({ color: 'blue', margin: 0, padding: 4 }) + }) + + it('library style wins when consumer omits style', () => { + const libStyle = { color: 'blue' } + const out = mergeProps({ id: 'a' }, { style: libStyle }) + expect(out.style).toBe(libStyle) + }) + + it('consumer style stays when library omits style', () => { + const consumerStyle = { color: 'red' } + const out = mergeProps({ style: consumerStyle }, { id: 'a' }) + expect(out.style).toBe(consumerStyle) + }) + + it('concatenates overlapping class with a single space', () => { + const out = mergeProps({ class: 'a b' }, { class: 'c' }) + expect(out.class).toBe('a b c') + }) + + it('trims edge whitespace; inner spacing is preserved verbatim', () => { + const out = mergeProps({ class: ' a ' }, { class: ' b ' }) + expect(out.class).toBe('a b') + }) + + it('non-string class falls back to library-wins (no concat)', () => { + const out = mergeProps({ id: 'a' }, { class: 'x' }) + expect(out.class).toBe('x') + }) +}) diff --git a/packages/solid/tests/normalize.test.ts b/packages/solid/tests/normalize.test.ts new file mode 100644 index 0000000..c31de38 --- /dev/null +++ b/packages/solid/tests/normalize.test.ts @@ -0,0 +1,266 @@ +/** + * Solid DOM bindings translator — pure-logic tests (no DOM runtime needed). + * + * `normalize` maps the core's substrate-agnostic logical surface to real + * DOM/ARIA props as Solid's JSX expects them. These tests pin the FULL + * vocabulary so every logical binding has an explicit, asserted target. The + * differences from the React DOM normalizer are deliberate and pinned: + * `onValueChange → onInput`, `onDoublePress → onDblClick`, `focusable → + * tabindex` (lowercase). + */ +import { describe, expect, it, vi } from 'vitest' +import { normalize } from '../src' + +describe('solid normalize — handlers', () => { + it('maps onPress to onClick (the DOM activation event)', () => { + const onPress = vi.fn() + expect(normalize({ onPress })).toEqual({ onClick: onPress }) + }) + + it('maps the full pointer family to DOM pointer events', () => { + const handlers = { + onPointerEnter: vi.fn(), + onPointerLeave: vi.fn(), + onPointerMove: vi.fn(), + onPointerDown: vi.fn(), + onPointerUp: vi.fn(), + onPointerCancel: vi.fn(), + } + expect(normalize(handlers)).toEqual(handlers) + }) + + it('passes onFocus / onBlur through', () => { + const onFocus = vi.fn() + const onBlur = vi.fn() + expect(normalize({ onFocus, onBlur })).toEqual({ onFocus, onBlur }) + }) + + it('maps both keyboard handlers (onKeyDown / onKeyUp)', () => { + const onKeyDown = vi.fn() + const onKeyUp = vi.fn() + expect(normalize({ onKeyDown, onKeyUp })).toEqual({ onKeyDown, onKeyUp }) + }) +}) + +describe('solid normalize — attributes', () => { + it('maps the ARIA reference attrs (describedBy / labelledBy / controls)', () => { + expect(normalize({ describedBy: 'd', labelledBy: 'l', controls: 'c' })).toEqual({ + 'aria-describedby': 'd', + 'aria-labelledby': 'l', + 'aria-controls': 'c', + }) + }) + + it('maps the boolean state attrs to their aria-* equivalents', () => { + expect( + normalize({ expanded: true, selected: false, disabled: true, hidden: false, modal: true }), + ).toEqual({ + 'aria-expanded': true, + 'aria-selected': false, + 'aria-disabled': true, + 'aria-hidden': false, + 'aria-modal': true, + }) + }) + + it('maps focusable to tabindex (lowercase; true → 0, false → -1)', () => { + expect(normalize({ focusable: true })).toEqual({ tabindex: 0 }) + expect(normalize({ focusable: false })).toEqual({ tabindex: -1 }) + }) + + it('maps role and id straight through (same name)', () => { + expect(normalize({ role: 'tooltip', id: 't:1' })).toEqual({ role: 'tooltip', id: 't:1' }) + }) + + it('passes unknown attrs through unchanged (e.g. data-state, class)', () => { + expect(normalize({ 'data-state': 'open', class: 'x' })).toEqual({ + 'data-state': 'open', + class: 'x', + }) + }) + + it('skips undefined values', () => { + expect(normalize({ role: undefined, id: 'x' })).toEqual({ id: 'x' }) + }) +}) + +describe('solid normalize — expanded handler surface', () => { + it('maps each value-change / interaction handler to its Solid DOM event prop', () => { + const out = normalize({ + onValueChange: vi.fn(), + onContextMenu: vi.fn(), + onDoublePress: vi.fn(), + onWheel: vi.fn(), + onScroll: vi.fn(), + onScrollEnd: vi.fn(), + }) + expect(Object.keys(out).sort()).toEqual( + ['onContextMenu', 'onDblClick', 'onInput', 'onScroll', 'onScrollEnd', 'onWheel'].sort(), + ) + }) + + it('passes onContextMenu / onDoublePress through unwrapped (same payload shape)', () => { + const onContextMenu = vi.fn() + const onDoublePress = vi.fn() + const out = normalize({ onContextMenu, onDoublePress }) + expect(out.onContextMenu).toBe(onContextMenu) + expect(out.onDblClick).toBe(onDoublePress) + }) + + it('onValueChange receives a ChangePayload built from the DOM event', () => { + const onValueChange = vi.fn() + const out = normalize({ onValueChange }) + ;(out.onInput as (e: unknown) => void)({ target: { value: 'hi', type: 'text' } }) + expect(onValueChange).toHaveBeenCalledWith({ + value: 'hi', + defaultPrevented: undefined, + preventDefault: undefined, + }) + ;(out.onInput as (e: unknown) => void)({ target: { checked: true, type: 'checkbox' } }) + expect(onValueChange).toHaveBeenLastCalledWith(expect.objectContaining({ value: true })) + }) + + it('onWheel receives a WheelPayload with a neutral deltaUnit (deltaMode → enum)', () => { + const onWheel = vi.fn() + const out = normalize({ onWheel }) + ;(out.onWheel as (e: unknown) => void)({ deltaX: 1, deltaY: 2, deltaZ: 0, deltaMode: 1 }) + expect(onWheel).toHaveBeenCalledWith( + expect.objectContaining({ deltaX: 1, deltaY: 2, deltaZ: 0, deltaUnit: 'line' }), + ) + }) + + it('onScroll / onScrollEnd receive a neutral ScrollPayload from currentTarget geometry', () => { + const onScroll = vi.fn() + const out = normalize({ onScroll }) + ;(out.onScroll as (e: unknown) => void)({ + currentTarget: { + scrollLeft: 5, + scrollTop: 50, + scrollWidth: 800, + scrollHeight: 1200, + clientWidth: 400, + clientHeight: 600, + }, + }) + expect(onScroll).toHaveBeenCalledWith({ + offsetX: 5, + offsetY: 50, + contentWidth: 800, + contentHeight: 1200, + viewportWidth: 400, + viewportHeight: 600, + }) + }) +}) + +describe('solid normalize — expanded attribute surface', () => { + it('maps widget-state attrs to aria-*, preserving tristate/enum values', () => { + expect( + normalize({ + checked: 'mixed', + pressed: true, + current: 'page', + busy: true, + invalid: 'spelling', + required: true, + readOnly: false, + }), + ).toEqual({ + 'aria-checked': 'mixed', + 'aria-pressed': true, + 'aria-current': 'page', + 'aria-busy': true, + 'aria-invalid': 'spelling', + 'aria-required': true, + 'aria-readonly': false, + }) + }) + + it('maps labeling + relationship attrs', () => { + expect( + normalize({ label: 'Volume', activeDescendant: 'opt-3', errorMessage: 'e1', owns: 'lb1' }), + ).toEqual({ + 'aria-label': 'Volume', + 'aria-activedescendant': 'opt-3', + 'aria-errormessage': 'e1', + 'aria-owns': 'lb1', + }) + }) + + it('maps value/range attrs (slider shape)', () => { + expect(normalize({ valueMin: 0, valueMax: 100, valueNow: 70, valueText: '70%' })).toEqual({ + 'aria-valuemin': 0, + 'aria-valuemax': 100, + 'aria-valuenow': 70, + 'aria-valuetext': '70%', + }) + }) + + it('maps structure + grid attrs', () => { + expect( + normalize({ + orientation: 'horizontal', + sort: 'ascending', + autoComplete: 'list', + multiline: true, + multiSelectable: false, + level: 2, + posInSet: 3, + setSize: 10, + colCount: 5, + colIndex: 2, + colSpan: 1, + rowCount: 20, + rowIndex: 4, + rowSpan: 1, + }), + ).toEqual({ + 'aria-orientation': 'horizontal', + 'aria-sort': 'ascending', + 'aria-autocomplete': 'list', + 'aria-multiline': true, + 'aria-multiselectable': false, + 'aria-level': 2, + 'aria-posinset': 3, + 'aria-setsize': 10, + 'aria-colcount': 5, + 'aria-colindex': 2, + 'aria-colspan': 1, + 'aria-rowcount': 20, + 'aria-rowindex': 4, + 'aria-rowspan': 1, + }) + }) + + it('maps live-region attrs (off passes through as aria-live="off")', () => { + expect(normalize({ live: 'off', atomic: true })).toEqual({ + 'aria-live': 'off', + 'aria-atomic': true, + }) + }) + + it('translates a realistic slider binding set', () => { + const onValueChange = vi.fn() + const out = normalize({ + role: 'slider', + orientation: 'horizontal', + valueMin: 0, + valueMax: 100, + valueNow: 40, + valueText: '40%', + focusable: true, + onValueChange, + }) + expect(out).toMatchObject({ + role: 'slider', + 'aria-orientation': 'horizontal', + 'aria-valuemin': 0, + 'aria-valuemax': 100, + 'aria-valuenow': 40, + 'aria-valuetext': '40%', + tabindex: 0, + }) + ;(out.onInput as (e: unknown) => void)({ target: { value: '50', type: 'range' } }) + expect(onValueChange).toHaveBeenCalledWith(expect.objectContaining({ value: '50' })) + }) +}) diff --git a/packages/solid/tests/use-machine.test.tsx b/packages/solid/tests/use-machine.test.tsx new file mode 100644 index 0000000..8930f17 --- /dev/null +++ b/packages/solid/tests/use-machine.test.tsx @@ -0,0 +1,275 @@ +// @vitest-environment jsdom +/** + * `useMachine` — the Solid bridge. These tests pin the behavioral contract: build + * ONCE, run the machine lifecycle (start on mount / stop on cleanup), keep + * consumer props fresh via a tracked setProps effect (value-deduped), run the + * connector's reactions across the machine lifecycle, run each ComponentEffect as + * its own dep-tracked createEffect, and expose the connect() api as a fine-grained + * store — so JSX reading one field updates only when THAT field changes. + */ +import { createSignal } from 'solid-js' +import { render } from '@solidjs/testing-library' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + act as write, + machine, + makeReaction, + type Connect, + type TransitionConfig, +} from '@dunky.dev/state-machine' +import { type ComponentEffects, useMachine } from '../src' + +type ToggleState = 'closed' | 'open' +interface ToggleCtx { + count: number +} +type ToggleEvent = { type: 'toggle' } + +interface ToggleProps { + label?: string + onOpenChange?: (open: boolean) => void +} + +const createConfig = + (): ((props: ToggleProps) => TransitionConfig) => () => ({ + initial: 'closed', + context: { count: 0 }, + states: { + closed: { + on: { toggle: { target: 'open', actions: write($ => ({ count: $.context.count + 1 })) } }, + }, + open: { on: { toggle: { target: 'closed' } } }, + }, + }) + +type ToggleApi = { + open: boolean + label: string | undefined + count: number + toggle: () => void +} + +const connect: Connect = ({ + state, + context, + props, + send, +}) => ({ + open: state === 'open', + label: props.label, + count: context.count, + toggle: () => send({ type: 'toggle' }), +}) + +const reaction = makeReaction() +connect.reactions = [ + reaction( + m => m.state === 'open', + (open, props) => props.onOpenChange?.(open), + ), +] + +type ToggleMachine = ReturnType> +const noEffects: ComponentEffects = [] + +afterEach(() => vi.clearAllMocks()) + +describe('useMachine — lifecycle', () => { + it('returns { api, machine }: api is the connect() output, machine is the running service', () => { + let captured: { api: ToggleApi; machine: ToggleMachine } | undefined + function Comp() { + const props: ToggleProps = { label: 'hi' } + captured = useMachine(createConfig(), connect, noEffects, props) + return
{captured.api.label}
+ } + render(() => ) + expect(captured!.api.open).toBe(false) + expect(captured!.api.label).toBe('hi') + expect(captured!.api.count).toBe(0) + expect(typeof captured!.api.toggle).toBe('function') + expect(typeof captured!.machine.send).toBe('function') + }) + + it('starts the machine on mount and stops it on cleanup', () => { + let api: ToggleApi | undefined + function Comp() { + const props: ToggleProps = {} + api = useMachine(createConfig(), connect, noEffects, props).api + return null + } + const { unmount } = render(() => ) + api!.toggle() + expect(api!.open).toBe(true) + expect(() => unmount()).not.toThrow() + }) + + it('updates the DOM fine-grained when the read field changes', () => { + let api: ToggleApi | undefined + function Comp() { + const props: ToggleProps = {} + api = useMachine(createConfig(), connect, noEffects, props).api + return
{api.open ? 'open' : 'closed'}
+ } + const { getByTestId } = render(() => ) + expect(getByTestId('state').textContent).toBe('closed') + api!.toggle() + expect(getByTestId('state').textContent).toBe('open') + expect(api!.count).toBe(1) + }) +}) + +describe('useMachine — fine-grained store', () => { + it('a field read updates ONLY when that field changes, not on unrelated changes', () => { + // `count` and `open` both live on the api store. A reader of `count` must not + // re-run when only an unrelated render happens, and the store reconciles in + // place so untouched leaves keep identity. We assert the store proxy reflects + // each field independently after a toggle. + let api: ToggleApi | undefined + const countReads = vi.fn() + function Comp() { + const props: ToggleProps = {} + api = useMachine(createConfig(), connect, noEffects, props).api + return ( + <> +
{(countReads(), api.count)}
+
{api.open ? 'y' : 'n'}
+ + ) + } + const { getByTestId } = render(() => ) + expect(getByTestId('count').textContent).toBe('0') + expect(getByTestId('open').textContent).toBe('n') + const countReadsBefore = countReads.mock.calls.length + + api!.toggle() // open: n→y AND count: 0→1 + expect(getByTestId('open').textContent).toBe('y') + expect(getByTestId('count').textContent).toBe('1') + expect(countReads.mock.calls.length).toBeGreaterThan(countReadsBefore) + }) +}) + +describe('useMachine — build once', () => { + it('builds the machine ONCE: state survives prop changes (no rebuild)', () => { + let api: ToggleApi | undefined + const [label, setLabel] = createSignal('a') + function Comp() { + const props: ToggleProps = { + get label() { + return label() + }, + } + api = useMachine(createConfig(), connect, noEffects, props).api + return
{api.label}
+ } + render(() => ) + api!.toggle() // → open, count 1 + expect(api!.open).toBe(true) + + setLabel('b') // prop change must NOT rebuild/reset state + expect(api!.open).toBe(true) + expect(api!.count).toBe(1) + expect(api!.label).toBe('b') // but the new prop IS reflected + }) +}) + +describe('useMachine — props freshness via setProps', () => { + it('flows later prop changes into the snapshot (setProps, not rebuild)', () => { + let api: ToggleApi | undefined + const [label, setLabel] = createSignal('first') + function Comp() { + const props: ToggleProps = { + get label() { + return label() + }, + } + api = useMachine(createConfig(), connect, noEffects, props).api + return null + } + render(() => ) + expect(api!.label).toBe('first') + setLabel('second') + expect(api!.label).toBe('second') + }) +}) + +describe('useMachine — reactions follow the machine lifecycle', () => { + it('fires the connect reaction (onOpenChange) when state flips while mounted', () => { + const onOpenChange = vi.fn() + let api: ToggleApi | undefined + function Comp() { + const props: ToggleProps = { onOpenChange } + api = useMachine(createConfig(), connect, noEffects, props).api + return null + } + render(() => ) + expect(onOpenChange).not.toHaveBeenCalled() // not on subscribe + api!.toggle() + expect(onOpenChange).toHaveBeenCalledWith(true) + api!.toggle() + expect(onOpenChange).toHaveBeenCalledWith(false) + }) +}) + +describe('useMachine — component effects', () => { + it('runs each ComponentEffect (setup on mount, cleanup on unmount)', () => { + const setup = vi.fn() + const cleanup = vi.fn() + const effects: ComponentEffects = [[() => (setup(), cleanup), []]] + function Comp() { + const props: ToggleProps = {} + useMachine(createConfig(), connect, effects, props) + return null + } + const { unmount } = render(() => ) + expect(setup).toHaveBeenCalledOnce() + expect(cleanup).not.toHaveBeenCalled() + unmount() + expect(cleanup).toHaveBeenCalledOnce() + }) + + it('re-runs an effect ONLY when one of its named prop deps changes', () => { + const fn = vi.fn(() => () => {}) + const effects: ComponentEffects = [[fn, ['label']]] + const [label, setLabel] = createSignal('a') + const [other, setOther] = createSignal(() => {}) + function Comp() { + const props: ToggleProps = { + get label() { + return label() + }, + get onOpenChange() { + return other() + }, + } + useMachine(createConfig(), connect, effects, props) + return null + } + render(() => ) + expect(fn).toHaveBeenCalledTimes(1) + + setOther(() => () => {}) // non-dep prop changed → no re-run + expect(fn).toHaveBeenCalledTimes(1) + + setLabel('b') // dep changed → re-run + expect(fn).toHaveBeenCalledTimes(2) + }) + + it('receives (machine, props) and can read live machine state', () => { + let seenOpen: boolean | undefined + const effects: ComponentEffects = [ + [ + m => { + seenOpen = m.matches('open') + }, + [], + ], + ] + function Comp() { + const props: ToggleProps = {} + useMachine(createConfig(), connect, effects, props) + return null + } + render(() => ) + expect(seenOpen).toBe(false) + }) +}) diff --git a/packages/solid/tests/use-selector.test.tsx b/packages/solid/tests/use-selector.test.tsx new file mode 100644 index 0000000..4483acc --- /dev/null +++ b/packages/solid/tests/use-selector.test.tsx @@ -0,0 +1,138 @@ +// @vitest-environment jsdom +/** + * `useSelector` — fine-grained leaf subscription. These tests pin the contract: + * the selector reads the machine directly, the returned accessor updates ONLY + * when the selected value changes (value-deduped, Object.is by default, custom + * isEqual for object selections), and a change to one leaf's slice wakes only + * that leaf's accessor (the O(readers) property). + */ +import { createEffect } from 'solid-js' +import { render, renderHook } from '@solidjs/testing-library' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act as write, machine, type TransitionConfig } from '@dunky.dev/state-machine' +import { useSelector } from '../src' + +type S = 'idle' +interface Ctx { + a: number + b: number +} +type Ev = { type: 'incA' } | { type: 'incB' } | { type: 'noop' } + +const config: TransitionConfig = { + initial: 'idle', + context: { a: 0, b: 0 }, + states: { + idle: { + on: { + // context writes go through setContext (via `act`) so the bus notifies — + // a raw in-place `context.a++` mutates the value but never wakes subscribers. + incA: write($ => ({ a: $.context.a + 1 })), + incB: write($ => ({ b: $.context.b + 1 })), + noop: () => {}, + }, + }, + }, +} + +function makeMachine() { + const m = machine(config) + m.start() + return m +} + +afterEach(() => vi.clearAllMocks()) + +describe('useSelector — value-deduped accessor', () => { + it('reads the machine directly and reflects the selected value', () => { + const m = makeMachine() + const { result } = renderHook(() => useSelector(m, () => m.context.a)) + expect(result()).toBe(0) + m.send({ type: 'incA' }) + expect(result()).toBe(1) + }) + + it('updates the accessor ONLY when the selected slice changes', () => { + const m = makeMachine() + const reads = vi.fn() + const { result } = renderHook(() => useSelector(m, () => m.context.a)) + // A tracked reader of the accessor; it re-runs only when the signal changes. + createEffect(() => reads(result())) + expect(reads).toHaveBeenCalledTimes(1) + + m.send({ type: 'incB' }) // selects `a`, `b` changed → no update + expect(reads).toHaveBeenCalledTimes(1) + + m.send({ type: 'noop' }) // nothing changed → no update + expect(reads).toHaveBeenCalledTimes(1) + + m.send({ type: 'incA' }) // `a` changed → update + expect(reads).toHaveBeenCalledTimes(2) + }) + + it('defaults to Object.is equality (a re-derived equal value does not update)', () => { + const m = makeMachine() + const reads = vi.fn() + const { result } = renderHook(() => useSelector(m, () => m.context.a > 0)) + createEffect(() => reads(result())) + expect(reads).toHaveBeenCalledTimes(1) + m.send({ type: 'incA' }) // false → true (update) + expect(reads).toHaveBeenCalledTimes(2) + m.send({ type: 'incA' }) // true → true (no update) + expect(reads).toHaveBeenCalledTimes(2) + }) +}) + +describe('useSelector — custom isEqual for object selections', () => { + it('uses the provided isEqual to dedup an object selection', () => { + const m = makeMachine() + const reads = vi.fn() + const { result } = renderHook(() => + useSelector( + m, + () => ({ a: m.context.a }), + (x, y) => x.a === y.a, + ), + ) + createEffect(() => reads(result())) + expect(reads).toHaveBeenCalledTimes(1) + + m.send({ type: 'incB' }) // selected {a} unchanged → no update + expect(reads).toHaveBeenCalledTimes(1) + + m.send({ type: 'incA' }) // {a} changed → update + expect(reads).toHaveBeenCalledTimes(2) + }) +}) + +describe('useSelector — O(readers): a slice change wakes only its reader', () => { + it('updates only the leaf whose selected slice changed', () => { + const m = makeMachine() + const aRenders = vi.fn() + const bRenders = vi.fn() + function LeafA() { + const a = useSelector(m, () => m.context.a) + return {(aRenders(), a())} + } + function LeafB() { + const b = useSelector(m, () => m.context.b) + return {(bRenders(), b())} + } + render(() => ( + <> + + + + )) + expect(aRenders).toHaveBeenCalledTimes(1) + expect(bRenders).toHaveBeenCalledTimes(1) + + m.send({ type: 'incA' }) // only LeafA's slice changed + expect(aRenders).toHaveBeenCalledTimes(2) + expect(bRenders).toHaveBeenCalledTimes(1) + + m.send({ type: 'incB' }) // only LeafB's slice changed + expect(aRenders).toHaveBeenCalledTimes(2) + expect(bRenders).toHaveBeenCalledTimes(2) + }) +}) diff --git a/packages/solid/tsconfig.json b/packages/solid/tsconfig.json new file mode 100644 index 0000000..f00240b --- /dev/null +++ b/packages/solid/tsconfig.json @@ -0,0 +1,9 @@ +{ + // Thin shim so editors and tsdown find a tsconfig inside the package. The real + // Solid project — JSX settings + the typecheck `include`/`exclude` — lives in + // tsconfig/solid.json (referenced by tsconfig/all.json). This re-declares + // `include` relative to the package dir for tools that resolve from here. + "extends": "../../tsconfig/solid.json", + "include": ["src", "tests"], + "exclude": ["**/node_modules", "**/dist"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f59d3f..629cf55 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,12 +35,18 @@ importers: publint: specifier: ^0.3.21 version: 0.3.21 + solid-js: + specifier: ^1.9.13 + version: 1.9.13 tsdown: specifier: ^0.22.2 version: 0.22.2(oxc-resolver@11.20.0)(publint@0.3.21)(tsx@4.22.4)(typescript@6.0.3) typescript: specifier: ^6.0.3 version: 6.0.3 + vite-plugin-solid: + specifier: ^2.11.12 + version: 2.11.12(solid-js@1.9.13)(vite@8.0.14(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) vitest: specifier: ^4.1.7 version: 4.1.7(@types/node@22.19.19)(jsdom@29.1.1)(vite@8.0.14(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) @@ -180,6 +186,25 @@ importers: packages/shared/utils: {} + packages/solid: + dependencies: + '@dunky.dev/state-machine': + specifier: workspace:^ + version: link:../core + '@dunky.dev/state-machine-utils': + specifier: workspace:^ + version: link:../shared/utils + devDependencies: + '@solidjs/testing-library': + specifier: ^0.8.10 + version: 0.8.10(solid-js@1.9.13) + jsdom: + specifier: ^29.1.1 + version: 29.1.1 + solid-js: + specifier: ^1.9.13 + version: 1.9.13 + sandbox/native: dependencies: '@dunky.dev/state-machine': @@ -303,6 +328,34 @@ importers: specifier: workspace:^ version: link:../../packages/shared/bindings + sandbox/solid: + dependencies: + '@dunky.dev/state-machine': + specifier: workspace:^ + version: link:../../packages/core + '@dunky.dev/state-machine-bindings': + specifier: workspace:^ + version: link:../../packages/shared/bindings + '@dunky.dev/state-machine-solid': + specifier: workspace:^ + version: link:../../packages/solid + '@dunky.dev/state-machine-utils': + specifier: workspace:^ + version: link:../../packages/shared/utils + '@sandbox/cmdk-core': + specifier: workspace:^ + version: link:../shared + solid-js: + specifier: ^1.9.13 + version: 1.9.13 + devDependencies: + vite: + specifier: ^8.0.14 + version: 8.0.14(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + vite-plugin-solid: + specifier: ^2.11.12 + version: 2.11.12(solid-js@1.9.13)(vite@8.0.14(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) + website: dependencies: '@astrojs/starlight': @@ -455,6 +508,10 @@ packages: resolution: {integrity: sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==} engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.18.6': + resolution: {integrity: sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==} + engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.29.7': resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} engines: {node: '>=6.9.0'} @@ -2780,6 +2837,16 @@ packages: '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + '@solidjs/testing-library@0.8.10': + resolution: {integrity: sha512-qdeuIerwyq7oQTIrrKvV0aL9aFeuwTd86VYD3afdq5HYEwoox1OBTJy4y8A3TFZr8oAR0nujYgCzY/8wgHGfeQ==} + engines: {node: '>= 14'} + peerDependencies: + '@solidjs/router': '>=0.9.0' + solid-js: '>=1.0.0' + peerDependenciesMeta: + '@solidjs/router': + optional: true + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -3301,6 +3368,11 @@ packages: resolution: {integrity: sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + babel-plugin-jsx-dom-expressions@0.40.7: + resolution: {integrity: sha512-/O6JWUmjv03OI9lL2ry9bUjpD5S3PclM55RRJEyCdcFZ5W2SEA/59d+l2hNsk3gI6kiWRdRPdOtqZmsQzFN1pQ==} + peerDependencies: + '@babel/core': ^7.20.12 + babel-plugin-polyfill-corejs2@0.4.17: resolution: {integrity: sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==} peerDependencies: @@ -3354,6 +3426,15 @@ packages: peerDependencies: '@babel/core': ^7.0.0 + babel-preset-solid@1.9.12: + resolution: {integrity: sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg==} + peerDependencies: + '@babel/core': ^7.0.0 + solid-js: ^1.9.12 + peerDependenciesMeta: + solid-js: + optional: true + bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} @@ -4294,6 +4375,9 @@ packages: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-entities@2.3.3: + resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==} + html-escaper@3.0.3: resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} @@ -4441,6 +4525,10 @@ packages: resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} engines: {node: '>=4'} + is-what@4.1.16: + resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==} + engines: {node: '>=12.13'} + is-windows@1.0.2: resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} engines: {node: '>=0.10.0'} @@ -4781,6 +4869,10 @@ packages: memoize-one@5.2.1: resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} + merge-anything@5.1.7: + resolution: {integrity: sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ==} + engines: {node: '>=12.13'} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -5836,6 +5928,16 @@ packages: resolution: {integrity: sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==} engines: {node: '>=0.10.0'} + seroval-plugins@1.5.4: + resolution: {integrity: sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.5.4: + resolution: {integrity: sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==} + engines: {node: '>=10'} + serve-static@1.16.3: resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} engines: {node: '>= 0.8.0'} @@ -5904,6 +6006,14 @@ packages: resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} engines: {node: '>= 18'} + solid-js@1.9.13: + resolution: {integrity: sha512-6hJeJMOcEX8ktqjpDoJZEmld3ijvcvWBDtiXBm7f4332SiFN66QeAQI1REQshvyUoISsSeJ4PHDauKYbwao9JQ==} + + solid-refresh@0.6.3: + resolution: {integrity: sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA==} + peerDependencies: + solid-js: ^1.3 + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -6419,6 +6529,16 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-plugin-solid@2.11.12: + resolution: {integrity: sha512-FgjPcx2OwX9h6f28jli7A4bG7PP3te8uyakE5iqsmpq3Jqi1TWLgSroC9N6cMfGRU2zXsl4Q6ISvTr2VL0QHpA==} + peerDependencies: + '@testing-library/jest-dom': ^5.16.6 || ^5.17.0 || ^6.* + solid-js: ^1.7.2 + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@testing-library/jest-dom': + optional: true + vite@7.3.5: resolution: {integrity: sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==} engines: {node: ^20.19.0 || >=22.12.0} @@ -6985,6 +7105,10 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-imports@7.18.6': + dependencies: + '@babel/types': 7.29.7 + '@babel/helper-module-imports@7.29.7': dependencies: '@babel/traverse': 7.29.7 @@ -8482,6 +8606,13 @@ snapshots: '@tybys/wasm-util': 0.10.2 optional: true + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)': dependencies: '@emnapi/core': 1.11.0 @@ -9129,7 +9260,7 @@ snapshots: dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true '@rolldown/binding-wasm32-wasi@1.1.1': @@ -9286,6 +9417,11 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 + '@solidjs/testing-library@0.8.10(solid-js@1.9.13)': + dependencies: + '@testing-library/dom': 10.4.1 + solid-js: 1.9.13 + '@standard-schema/spec@1.1.0': {} '@tailwindcss/node@4.3.1': @@ -9849,6 +9985,15 @@ snapshots: '@types/babel__core': 7.20.5 '@types/babel__traverse': 7.28.0 + babel-plugin-jsx-dom-expressions@0.40.7(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.18.6 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/types': 7.29.7 + html-entities: 2.3.3 + parse5: 7.3.0 + babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.7): dependencies: '@babel/compat-data': 7.29.7 @@ -9950,6 +10095,13 @@ snapshots: babel-plugin-jest-hoist: 29.6.3 babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7) + babel-preset-solid@1.9.12(@babel/core@7.29.7)(solid-js@1.9.13): + dependencies: + '@babel/core': 7.29.7 + babel-plugin-jsx-dom-expressions: 0.40.7(@babel/core@7.29.7) + optionalDependencies: + solid-js: 1.9.13 + bail@2.0.2: {} balanced-match@1.0.2: {} @@ -11028,6 +11180,8 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + html-entities@2.3.3: {} + html-escaper@3.0.3: {} html-void-elements@3.0.0: {} @@ -11139,6 +11293,8 @@ snapshots: dependencies: better-path-resolve: 1.0.0 + is-what@4.1.16: {} + is-windows@1.0.2: {} is-wsl@2.2.0: @@ -11637,6 +11793,10 @@ snapshots: memoize-one@5.2.1: {} + merge-anything@5.1.7: + dependencies: + is-what: 4.1.16 + merge-stream@2.0.0: {} merge2@1.4.1: {} @@ -13419,6 +13579,12 @@ snapshots: serialize-error@2.1.0: {} + seroval-plugins@1.5.4(seroval@1.5.4): + dependencies: + seroval: 1.5.4 + + seroval@1.5.4: {} + serve-static@1.16.3: dependencies: encodeurl: 2.0.0 @@ -13518,6 +13684,21 @@ snapshots: smol-toml@1.6.1: {} + solid-js@1.9.13: + dependencies: + csstype: 3.2.3 + seroval: 1.5.4 + seroval-plugins: 1.5.4(seroval@1.5.4) + + solid-refresh@0.6.3(solid-js@1.9.13): + dependencies: + '@babel/generator': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/types': 7.29.7 + solid-js: 1.9.13 + transitivePeerDependencies: + - supports-color + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -13944,6 +14125,32 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vite-plugin-solid@2.11.12(solid-js@1.9.13)(vite@8.0.14(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)): + dependencies: + '@babel/core': 7.29.7 + '@types/babel__core': 7.20.5 + babel-preset-solid: 1.9.12(@babel/core@7.29.7)(solid-js@1.9.13) + merge-anything: 5.1.7 + solid-js: 1.9.13 + solid-refresh: 0.6.3(solid-js@1.9.13) + vite: 8.0.14(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + vitefu: 1.1.3(vite@8.0.14(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) + transitivePeerDependencies: + - supports-color + + vite-plugin-solid@2.11.12(solid-js@1.9.13)(vite@8.0.14(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)): + dependencies: + '@babel/core': 7.29.7 + '@types/babel__core': 7.20.5 + babel-preset-solid: 1.9.12(@babel/core@7.29.7)(solid-js@1.9.13) + merge-anything: 5.1.7 + solid-js: 1.9.13 + solid-refresh: 0.6.3(solid-js@1.9.13) + vite: 8.0.14(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + vitefu: 1.1.3(vite@8.0.14(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) + transitivePeerDependencies: + - supports-color + vite@7.3.5(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0): dependencies: esbuild: 0.27.7 @@ -13997,6 +14204,14 @@ snapshots: optionalDependencies: vite: 7.3.5(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + vitefu@1.1.3(vite@8.0.14(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)): + optionalDependencies: + vite: 8.0.14(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + + vitefu@1.1.3(vite@8.0.14(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)): + optionalDependencies: + vite: 8.0.14(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + vitest@4.1.7(@types/node@22.19.19)(jsdom@29.1.1)(vite@8.0.14(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.7 diff --git a/sandbox/solid/index.html b/sandbox/solid/index.html new file mode 100644 index 0000000..1f700fa --- /dev/null +++ b/sandbox/solid/index.html @@ -0,0 +1,12 @@ + + + + + + cmdk · Solid + + +
+ + + diff --git a/sandbox/solid/package.json b/sandbox/solid/package.json new file mode 100644 index 0000000..06834b0 --- /dev/null +++ b/sandbox/solid/package.json @@ -0,0 +1,23 @@ +{ + "name": "@sandbox/cmdk-solid", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@dunky.dev/state-machine": "workspace:^", + "@dunky.dev/state-machine-bindings": "workspace:^", + "@dunky.dev/state-machine-solid": "workspace:^", + "@dunky.dev/state-machine-utils": "workspace:^", + "@sandbox/cmdk-core": "workspace:^", + "solid-js": "^1.9.13" + }, + "devDependencies": { + "vite": "^8.0.14", + "vite-plugin-solid": "^2.11.12" + } +} diff --git a/sandbox/solid/src/app.tsx b/sandbox/solid/src/app.tsx new file mode 100644 index 0000000..d74e46e --- /dev/null +++ b/sandbox/solid/src/app.tsx @@ -0,0 +1,57 @@ +import { createSignal } from 'solid-js' +import type { JSX } from 'solid-js' +import { DEMO_COMMANDS } from '@sandbox/cmdk-core' +import { CommandPalette } from './command-palette' + +export function App() { + const [last, setLast] = createSignal('—') + + return ( +
+

⌘K Command Pallete

+
+ { + setLast(c.label) + window.alert(`Selected: ${c.label}`) + }} + /> +
+

+ One state machine drives this ⌘K palette. +
+ The same machine + connect runs the DOM (React), terminal (OpenTUI) and React Native + versions +

+

+ Last selected: {last()} +

+
+ ) +} + +const styles: Record = { + main: { + 'min-height': '100vh', + margin: 0, + display: 'flex', + 'flex-direction': 'column', + 'align-items': 'center', + 'justify-content': 'center', + gap: '16px', + padding: '24px', + 'font-family': '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', + color: '#1c1e26', + background: 'linear-gradient(180deg, #eef1f6 0%, #ffffff 60%)', + }, + title: { margin: 0, 'font-size': '28px', 'font-weight': 700, 'letter-spacing': '-0.02em' }, + lead: { + margin: 0, + 'max-width': '460px', + 'text-align': 'center', + color: '#5b6172', + 'line-height': 1.6, + }, + hint: { margin: 0, color: '#8990a0', 'font-size': '16px' }, +} diff --git a/sandbox/solid/src/command-palette.tsx b/sandbox/solid/src/command-palette.tsx new file mode 100644 index 0000000..d2a5bd6 --- /dev/null +++ b/sandbox/solid/src/command-palette.tsx @@ -0,0 +1,164 @@ +import { createEffect, For, type JSX, Show } from 'solid-js' +import { type ComponentEffect, normalize, useMachine } from '@dunky.dev/state-machine-solid' +import { + commandPaletteMachineConfig, + type CommandPaletteMachine, + type CommandPaletteProps, + connectCommandPalette, +} from '@sandbox/cmdk-core' + +// Global ⌘K / Ctrl+K to open — a PLATFORM listener (a document key event), so it +// lives here as a component effect, not in the machine. The machine just receives +// `open`. This is the per-target "behavior meets platform" seam — and the tuple +// is byte-for-byte the same shape the React sandbox uses. +const cmdkShortcut: ComponentEffect = [ + machine => { + const onKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { + e.preventDefault() + machine.send({ type: 'open' }) + } + } + document.addEventListener('keydown', onKeyDown) + return () => document.removeEventListener('keydown', onKeyDown) + }, + [], +] + +// The DOM renderer. It owns ZERO interaction logic — `useMachine` runs the shared +// machine, `connect` produces logical bindings, and `normalize` turns them into +// DOM props (onPress→onClick, role/aria-*, etc). `api` is a fine-grained Solid +// store: reading `api.open` / `api.results` in JSX tracks exactly those fields. +export function CommandPalette(props: CommandPaletteProps) { + const { api } = useMachine( + commandPaletteMachineConfig, + connectCommandPalette, + [cmdkShortcut], + props, + ) + + let inputEl: HTMLInputElement | undefined + + // Focus the input whenever the palette opens (a renderer concern, not the + // machine's — focus is a platform touchpoint). createEffect tracks `api.open`. + createEffect(() => { + if (api.open) inputEl?.focus() + }) + + return ( +
+ + + +
api.setOpen(false)}> +
e.stopPropagation()}> + (inputEl = el)} + {...normalize(api.parts.input)} + value={api.query} + placeholder='Type a command…' + style={styles.input} + /> +
    + +
  • No results
  • +
    + + {(command, index) => { + const itemProps = () => normalize(api.parts.getItemProps(command, index())) + const selected = () => command.id === api.activeId + return ( +
  • + {command.label} + + {command.hint} + +
  • + ) + }} +
    +
+
+
+
+
+ ) +} + +const styles: Record = { + trigger: { + display: 'flex', + 'justify-content': 'space-between', + 'min-width': '300px', + 'align-items': 'center', + gap: '8px', + padding: '10px 14px', + 'font-size': '14px', + color: '#5b6172', + background: '#fff', + border: '1px solid rgba(13,15,22,0.12)', + 'border-radius': '10px', + cursor: 'pointer', + }, + kbd: { + 'font-family': 'ui-monospace, monospace', + 'font-size': '11px', + color: '#8990a0', + background: 'rgba(13,15,22,0.05)', + border: '1px solid rgba(13,15,22,0.08)', + 'border-radius': '6px', + padding: '2px 6px', + }, + backdrop: { + position: 'fixed', + inset: 0, + background: 'rgba(13,15,22,0.35)', + display: 'flex', + 'justify-content': 'center', + 'align-items': 'flex-start', + 'padding-top': '14vh', + }, + panel: { + width: 'min(560px, 92vw)', + background: '#fff', + 'border-radius': '14px', + 'box-shadow': '0 24px 64px rgba(13,15,22,0.28)', + overflow: 'hidden', + }, + input: { + width: '100%', + 'min-width': '300px', + 'box-sizing': 'border-box', + padding: '18px 20px', + 'font-size': '16px', + border: 'none', + 'border-bottom': '1px solid rgba(13,15,22,0.08)', + outline: 'none', + 'border-top-left-radius': '8px', + 'border-top-right-radius': '8px', + }, + list: { + 'list-style': 'none', + margin: 0, + padding: '8px', + 'max-height': '320px', + 'overflow-y': 'auto', + }, + item: { + display: 'flex', + 'justify-content': 'space-between', + 'align-items': 'center', + padding: '10px 12px', + 'border-radius': '8px', + 'font-size': '14px', + color: '#1c1e26', + cursor: 'pointer', + }, + itemActive: { background: 'rgba(91,115,255,0.12)', color: '#3142c4' }, + empty: { padding: '16px 12px', color: '#8990a0', 'font-size': '14px' }, +} diff --git a/sandbox/solid/src/main.tsx b/sandbox/solid/src/main.tsx new file mode 100644 index 0000000..6d7e41d --- /dev/null +++ b/sandbox/solid/src/main.tsx @@ -0,0 +1,7 @@ +import { render } from 'solid-js/web' +import { App } from './app' + +const root = document.getElementById('root') +if (!root) throw new Error('missing #root') + +render(() => , root) diff --git a/sandbox/solid/tsconfig.json b/sandbox/solid/tsconfig.json new file mode 100644 index 0000000..e437a25 --- /dev/null +++ b/sandbox/solid/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "jsxImportSource": "solid-js", + "types": ["node"] + }, + "include": ["src/**/*", "vite.config.ts"] +} diff --git a/sandbox/solid/vite.config.ts b/sandbox/solid/vite.config.ts new file mode 100644 index 0000000..30b1ab2 --- /dev/null +++ b/sandbox/solid/vite.config.ts @@ -0,0 +1,19 @@ +import { resolve } from 'node:path' +import solid from 'vite-plugin-solid' +import { defineConfig } from 'vite' + +// The @dunky.dev/* packages and the shared cmdk core all point `main` at their TS +// `src/index.ts` (no build step). Alias each to its source so Vite transpiles them +// directly — the whole point of the sandbox is to run the workspace source live. +export default defineConfig({ + plugins: [solid()], + resolve: { + alias: { + '@dunky.dev/state-machine': resolve(__dirname, '../../packages/core/src'), + '@dunky.dev/state-machine-solid': resolve(__dirname, '../../packages/solid/src'), + '@dunky.dev/state-machine-utils': resolve(__dirname, '../../packages/shared/utils/src'), + '@dunky.dev/state-machine-bindings': resolve(__dirname, '../../packages/shared/bindings/src'), + '@sandbox/cmdk-core': resolve(__dirname, '../shared/src'), + }, + }, +}) diff --git a/tsconfig.all.json b/tsconfig.all.json deleted file mode 100644 index a4dd861..0000000 --- a/tsconfig.all.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - // Aggregator config: `tsc -b` typechecks all real projects (root + benchmark) - // in one pass. They stay separate because benchmark needs its own `types` - // (react-dom/jsdom) resolved from benchmark/node_modules. See `typecheck` script. - "files": [], - "references": [{ "path": "./tsconfig.json" }, { "path": "./benchmark/tsconfig.json" }] -} diff --git a/tsconfig.json b/tsconfig.json index 7dbe31a..864c273 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,28 +1,3 @@ { - "compilerOptions": { - "target": "esnext", - "allowJs": false, - "noEmit": true, - "esModuleInterop": true, - "isolatedModules": true, - "jsx": "react-jsx", - "lib": ["dom", "dom.iterable", "esnext"], - "module": "esnext", - "moduleResolution": "bundler", - "noImplicitReturns": true, - "resolveJsonModule": true, - "skipLibCheck": true, - "strict": true, - "paths": { - "@dunky.dev/state-machine": ["./packages/core/src"], - "@dunky.dev/state-machine-react": ["./packages/react/src"], - "@dunky.dev/state-machine-native": ["./packages/native/src"], - "@dunky.dev/state-machine-opentui": ["./packages/opentui/src"], - "@dunky.dev/state-machine-utils": ["./packages/shared/utils/src"], - "@dunky.dev/state-machine-bindings": ["./packages/shared/bindings/src"] - }, - "types": ["@types/node", "vitest/globals"] - }, - "include": ["./*.ts", "./packages"], - "exclude": ["**/node_modules", "**/dist"] + "extends": "./tsconfig/base.json", } diff --git a/tsconfig/all.json b/tsconfig/all.json new file mode 100644 index 0000000..99d2fc5 --- /dev/null +++ b/tsconfig/all.json @@ -0,0 +1,8 @@ +{ + "files": [], + "references": [ + { "path": "./react.json" }, + { "path": "./solid.json" }, + { "path": "../benchmark/tsconfig.json" } + ] +} diff --git a/tsconfig/base.json b/tsconfig/base.json new file mode 100644 index 0000000..eeb21d2 --- /dev/null +++ b/tsconfig/base.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "esnext", + "allowJs": false, + "noEmit": true, + "esModuleInterop": true, + "isolatedModules": true, + "lib": ["dom", "dom.iterable", "esnext"], + "module": "esnext", + "moduleResolution": "bundler", + "noImplicitReturns": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "paths": { + "@dunky.dev/state-machine": ["../packages/core/src"], + "@dunky.dev/state-machine-react": ["../packages/react/src"], + "@dunky.dev/state-machine-solid": ["../packages/solid/src"], + "@dunky.dev/state-machine-native": ["../packages/native/src"], + "@dunky.dev/state-machine-opentui": ["../packages/opentui/src"], + "@dunky.dev/state-machine-utils": ["../packages/shared/utils/src"], + "@dunky.dev/state-machine-bindings": ["../packages/shared/bindings/src"] + }, + "types": ["@types/node", "vitest/globals"] + } +} diff --git a/tsconfig/react.json b/tsconfig/react.json new file mode 100644 index 0000000..483885c --- /dev/null +++ b/tsconfig/react.json @@ -0,0 +1,8 @@ +{ + "extends": "./base.json", + "compilerOptions": { + "jsx": "react-jsx" + }, + "include": ["../*.ts", "../packages"], + "exclude": ["../**/node_modules", "../**/dist", "../packages/solid"] +} diff --git a/tsconfig/solid.json b/tsconfig/solid.json new file mode 100644 index 0000000..c5ffdd1 --- /dev/null +++ b/tsconfig/solid.json @@ -0,0 +1,18 @@ +{ + // The Solid project: JSX is `preserve` + `solid-js` as the import source, so + // the Solid package and its tests see the Solid JSX namespace (not React's). + // Referenced from tsconfig/all.json so `tsc -b` typechecks it alongside the + // React project. `paths` is inherited from base (resolved relative to the base + // file's location), so no redeclaration is needed. + // + // `include`/`exclude` are relative to THIS file (tsconfig/), so they reach into + // the Solid package. `packages/solid/tsconfig.json` is a thin shim extending + // this, kept so editors + tsdown find a tsconfig inside the package. + "extends": "./base.json", + "compilerOptions": { + "jsx": "preserve", + "jsxImportSource": "solid-js" + }, + "include": ["../packages/solid/src", "../packages/solid/tests"], + "exclude": ["../**/node_modules", "../**/dist"] +} diff --git a/tsdown.config.ts b/tsdown.config.ts index 99165b5..ed2218b 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ workspace: [ 'packages/core', 'packages/react', + 'packages/solid', 'packages/native', 'packages/opentui', 'packages/shared/utils', diff --git a/vitest.config.ts b/vitest.config.ts index c3bca12..273b8b2 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,9 +1,42 @@ +import solid from 'vite-plugin-solid' import { defineConfig } from 'vitest/config' +// Two projects so the Solid tests get their JSX transform without touching the +// rest of the suite. The default project runs every package the way it always +// has (node, or jsdom via a per-file `@vitest-environment` comment) and EXCLUDES +// the Solid tests; the `solid` project owns `packages/solid/tests` with +// vite-plugin-solid (Solid JSX → reactive runtime) and the `solid-js` dev/browser +// conditions @solidjs/testing-library needs. Keeping the Solid plugin on its own +// project is what stops it from rewriting the React `.tsx` tests. export default defineConfig({ test: { - globals: false, - environment: 'node', - exclude: ['**/node_modules/**', '**/dist/**'], + projects: [ + { + test: { + name: 'default', + globals: false, + environment: 'node', + include: ['packages/**/tests/**/*.test.{ts,tsx}'], + exclude: ['**/node_modules/**', '**/dist/**', 'packages/solid/**'], + }, + }, + { + plugins: [solid()], + resolve: { + // @solidjs/testing-library + the reactive runtime expect Solid's + // dev/browser build conditions. + conditions: ['development', 'browser'], + }, + test: { + name: 'solid', + globals: false, + // node by default; the DOM tests opt into jsdom per-file via a + // `@vitest-environment jsdom` comment (same convention as the React + // package), which also lets knip trace the jsdom devDependency. + environment: 'node', + include: ['packages/solid/tests/**/*.test.{ts,tsx}'], + }, + }, + ], }, }) diff --git a/website/astro.config.ts b/website/astro.config.ts index 61aa751..20f7ef6 100644 --- a/website/astro.config.ts +++ b/website/astro.config.ts @@ -128,6 +128,7 @@ export default defineConfig({ label: 'Integrations', items: [ { label: 'React', link: 'libs/react' }, + { label: 'Solid', link: 'libs/solid' }, { label: 'React Native', link: 'libs/react-native' }, { label: 'OpenTUI', link: 'libs/opentui' }, ], diff --git a/website/src/content/docs/libs/solid.mdx b/website/src/content/docs/libs/solid.mdx new file mode 100644 index 0000000..7463cc3 --- /dev/null +++ b/website/src/content/docs/libs/solid.mdx @@ -0,0 +1,199 @@ +--- +title: Solid +description: Solid bindings for @dunky.dev/state-machine. +--- + +import Install from '../../../components/install.astro' + + + +The Solid package is a thin edge layer. Behavior lives in the core machine and the component's `connect` function; this package only adapts them to Solid: lifecycle, fine-grained reactivity, prop translation, and platform effects. **The machine itself is unchanged** — the same `createDialogConfig` and `connectDialog` that drive React run here. + +Unlike React, this is not a `useSyncExternalStore` bridge: the connector's snapshot is mirrored into a Solid **store**, so reading `api.isOpen` in JSX subscribes to exactly that field — only the markup that reads a changed field updates. + +## `useMachine` + +The one bridge hook. Every component calls it with the four agnostic pieces and gets back the view API as a reactive store: + +```tsx +import { useMachine, normalize } from '@dunky.dev/state-machine-solid' +import { createDialogConfig, connectDialog, dialogEffects } from './dialog' + +type DialogProps = { + open?: boolean + onOpenChange?: (open: boolean) => void + closeOnEscape?: boolean +} + +function Dialog(props: DialogProps) { + const { api } = useMachine( + createDialogConfig, // (props) => MachineConfig; seeds context once + connectDialog, // pure connect(): snapshot → view api + dialogEffects, // ComponentEffect[]: DOM listeners, gated by props + props, + ) + + return ( + <> + + {api.isOpen &&
Dialog content
} + + ) +} +``` + +`useMachine` builds the machine and connector **once** (a Solid component body runs a single time, so the first props seed context; later changes flow through `setProps`, not a rebuild), starts on mount, stops on cleanup, and exposes the connect output as a fine-grained store. `api` is the store proxy — read its fields directly in JSX; do **not** destructure it (`const { isOpen } = api` snapshots the value and loses reactivity). + +### The three imports + +Those three values are where the dialog's behavior actually lives, and none of it is Solid. You write them once, in a `./dialog` module, and they run unchanged on any platform: + +```ts +// dialog.ts: plain functions, no Solid +import type { Connect } from '@dunky.dev/state-machine' + +type State = 'closed' | 'open' +type Context = { closeOnEscape: boolean } +type Event = { type: 'open' } | { type: 'close' } +type Api = { + isOpen: boolean + triggerProps: object + contentProps: object +} + +// createDialogConfig: (props) => machine config. Defines the states +// ('closed' | 'open'), the events, and seeds context from the first props. +export const createDialogConfig = (props: DialogProps) => ({ + initial: props.open ? 'open' : 'closed', + context: { closeOnEscape: props.closeOnEscape ?? true }, + states: { + closed: { on: { open: 'open' } }, + open: { on: { close: 'closed' } }, + }, +}) + +// connectDialog: a pure connect() that turns a machine snapshot into the view API +// your JSX spreads. `isOpen`, `triggerProps`, `contentProps` come from here. +// The type args are . +export const connectDialog: Connect = ({ + state, + send, +}) => ({ + isOpen: state === 'open', + triggerProps: { + onPress: () => send({ type: 'open' }), + expanded: state === 'open', + }, + contentProps: { role: 'dialog', modal: true }, +}) + +// dialogEffects: DOM listeners that can't live in the machine. Here, one that +// closes the dialog on Escape. `onEscapeKey` is a [setup/teardown, deps] tuple; +// see the ComponentEffect section below for its full body. +export const dialogEffects = [onEscapeKey] +``` + +So `useMachine` is the only Solid-specific piece: `createDialogConfig` is the machine definition, `connectDialog` is the snapshot-to-view-API mapping, and `dialogEffects` are the DOM listeners. This is the **same** `./dialog` module the [React page](/libs/react) imports — only the bridge differs. See [Setup](/api/setup) for configs and [Connector](/api/connector) for how `connect` works in depth. + +## `normalize`: bindings → DOM props + +`connect` returns substrate-agnostic bindings (`onPress`, `role`, `describedBy`). `normalize` translates them to real DOM/ARIA props as Solid's JSX expects them: + +```ts +normalize(api.triggerProps) +// { onClick, 'aria-expanded', role, tabindex, ... } +``` + +| Binding | DOM prop | +| ----------------------------------------------- | ------------------------------------------------------------------- | +| `onPress` | `onClick` | +| `onValueChange` | `onInput` (payload adapted to `ChangePayload`) | +| `onDoublePress` | `onDblClick` | +| `onPointerEnter/Leave/Move/Down/Up/Cancel` | same name | +| `onFocus` / `onBlur` / `onKeyDown` / `onKeyUp` | same name | +| `onWheel` / `onScroll` / `onScrollEnd` | same name (payload adapted to `WheelPayload` / `ScrollPayload`) | +| `describedBy` / `labelledBy` | `aria-describedby` / `aria-labelledby` | +| `expanded` / `selected` / `disabled` / `hidden` | `aria-expanded` / `aria-selected` / `aria-disabled` / `aria-hidden` | +| `focusable` | `tabindex` (`true → 0`, `false → -1`) | +| `role` / `id` | `role` / `id` | + +The differences from the [React](/libs/react) DOM normalizer are Solid's JSX conventions: `onValueChange → onInput` (Solid forwards native input events), `onDoublePress → onDblClick`, and `focusable → tabindex` (lowercase, the real attribute). `undefined` values are dropped; unknown keys (`class`, `data-*`) pass through unchanged. + +A few handlers whose agnostic payload differs from the raw event (`onValueChange`/`onWheel`/`onScroll`/`onScrollEnd`) are wrapped so the consumer receives the agnostic payload, built from the native DOM event. + +## `mergeProps`: consumer + component props + +When a consumer spreads their own props onto the same element the component controls: + +```tsx +