diff --git a/.changeset/svelte-integration.md b/.changeset/svelte-integration.md new file mode 100644 index 0000000..f705eec --- /dev/null +++ b/.changeset/svelte-integration.md @@ -0,0 +1,5 @@ +--- +'@dunky.dev/state-machine-svelte': minor +--- + +Add Svelte 5 bindings: `@dunky.dev/state-machine-svelte`. A thin, runes-based edge layer mirroring the React package — `useMachine` (build-once bridge + lifecycle + prop-scoped substrate effects, returning a reactive `{ api, machine }`), `useSelector` (fine-grained leaf subscription returning `{ current }`), and `normalize`/`mergeProps` for Svelte's DOM idiom (lowercase `on*` event props, `class`/`style` string merge). The package ships its `src` uncompiled so the consumer's Svelte compiler processes its `.svelte.ts` runes modules. diff --git a/benchmark/CHANGELOG.md b/benchmark/CHANGELOG.md index 7466059..8740298 100644 --- a/benchmark/CHANGELOG.md +++ b/benchmark/CHANGELOG.md @@ -25,7 +25,6 @@ The suite measures `@dunky.dev/state-machine`'s hot paths in isolation and compares the runnable parts against XState and Zag. It runs in one Node process with `--expose-gc` (Node 24, Apple Silicon for the published figures); each section is also exported as a `run*()` function for isolation. Fairness rules that define what the numbers mean: - - **Synchronous-only comparison in ops/sec loops.** Dunky and XState `send` synchronously and are measured everywhere; Zag's `send` is microtask-batched (async), so it appears only where it runs synchronously — construction, memory (headless `VanillaMachine`), and React rendering (`@zag-js/react`). A tight `tinybench` loop can't fairly time an async engine, so those cells are marked `n/a ᵃ`. Missing first-class primitives (e.g. XState has no lazy/memoized `computed`) are marked `n/a ᶠ`. - **Shared module-level config across instances.** All engines reuse one `const` config — matching how a real app declares a machine once and instantiates it many times — so construction/memory loops time machine construction, not config-literal allocation. - **Two XState variants** in fine-grain tables: `xstate` (subscribe + hand-written value diff, matching Dunky's built-in dedup) and `xstate-raw` (stock subscribe, fires on every snapshot). diff --git a/benchmark/demo/CHANGELOG.md b/benchmark/demo/CHANGELOG.md index 3c89e28..e806dfb 100644 --- a/benchmark/demo/CHANGELOG.md +++ b/benchmark/demo/CHANGELOG.md @@ -52,7 +52,6 @@ The demo makes the ops/sec tables **visible**: four panels (Dunky, XState, Zag, and a Plain JS control), each a grid of real per-cell state machines fed one ramping change stream under an equal per-frame budget. Run it with `pnpm benchmark:demo`; it's idle until you press **Start**. It is deliberately **engine-bound, not DOM-bound** — a naive "render a grid" demo would measure React's render cost (which dominates and ties every engine), so the design isolates engine cost: - - **Every cell is a real machine** doing the work the suite measures per update: a guarded transition (guard fallthrough) that writes context feeding a computed/derived value, then reads it back. - **Paint is off the hot path.** Each panel is a `` heatmap on a throttled ~10fps tick — one cheap fill per cell, no per-cell React — so paint cost is tiny and identical across panels; what differs is the engine. - **Equal time budget.** Each frame, every panel gets the same few ms to drain its queue; a cheaper-per-update engine clears more and its backlog stays near zero, a costlier one falls behind. diff --git a/benchmark/demo/README.md b/benchmark/demo/README.md index 68de9fa..eac46ff 100644 --- a/benchmark/demo/README.md +++ b/benchmark/demo/README.md @@ -48,12 +48,12 @@ nothing. This demo is deliberately **engine-bound**, not DOM-bound: ## What you're watching -| Panel | Per-cell model | -| ---------------------- | ------------------------------------------------------------- | -| **Dunky** | machine per cell · guarded transition + memoized computed | -| **XState** | actor per cell · guarded transition + assign-derived field | -| **Zag** | VanillaMachine per cell · guarded transition + bindable cells | -| **Vanilla** (control) | no engine — the same guard walk + derive as plain JS | +| Panel | Per-cell model | +| --------------------- | ------------------------------------------------------------- | +| **Dunky** | machine per cell · guarded transition + memoized computed | +| **XState** | actor per cell · guarded transition + assign-derived field | +| **Zag** | VanillaMachine per cell · guarded transition + bindable cells | +| **Vanilla** (control) | no engine — the same guard walk + derive as plain JS | > **One asymmetry, disclosed:** Dunky's derived value is a **lazy/memoized > `computed`** (recomputes only when its input changes); XState and Zag recompute diff --git a/package.json b/package.json index 1c4a591..59ca78e 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,8 @@ "devDependencies": { "@changesets/changelog-github": "^0.7.0", "@changesets/cli": "^2.31.0", + "@sveltejs/vite-plugin-svelte": "^7.1.2", + "@testing-library/svelte": "^5.4.2", "@types/node": "^22.10.2", "husky": "^9.1.7", "knip": "^6.15.0", diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 581d0cd..44a2261 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -19,15 +19,15 @@ **⚡️ Blazing fast.** Design systems and complex UIs can run hundreds of live machines at once. Dunky is tuned for exactly that load. [See the benchmark →](https://github.com/dunky-dev/state-machine/tree/main/benchmark#readme) ```ts - import { setup } from "@dunky.dev/state-machine"; + import { setup } from '@dunky.dev/state-machine' const toggle = setup({ - initial: "off", + initial: 'off', states: { - off: { on: { TOGGLE: "on" } }, - on: { on: { TOGGLE: "off" } }, + off: { on: { TOGGLE: 'on' } }, + on: { on: { TOGGLE: 'off' } }, }, - }); + }) ``` This is our first public release (`0.1.0`). The engine is stable and tested; the target bridges are early and evolving. Come kick the tires, watch the live benchmark, and tell us where it breaks. diff --git a/packages/native/CHANGELOG.md b/packages/native/CHANGELOG.md index 6be3459..1e796c7 100644 --- a/packages/native/CHANGELOG.md +++ b/packages/native/CHANGELOG.md @@ -5,7 +5,6 @@ ### Minor Changes - [`d0e20a5`](https://github.com/dunky-dev/state-machine/commit/d0e20a5ac3ca953c923e05819034e420394af83b) Thanks [@ivanbanov](https://github.com/ivanbanov)! - Rename the framework adapters to the `state-machine-*` suffix convention so the whole scope is consistent (`state-machine`, `state-machine-react`, `state-machine-native`, `state-machine-opentui`, `state-machine-utils`, `state-machine-bindings`). - - `@dunky.dev/react-state-machine` → `@dunky.dev/state-machine-react` - `@dunky.dev/native-state-machine` → `@dunky.dev/state-machine-native` - `@dunky.dev/opentui-state-machine` → `@dunky.dev/state-machine-opentui` @@ -36,15 +35,15 @@ **⚡️ Blazing fast.** Design systems and complex UIs can run hundreds of live machines at once. Dunky is tuned for exactly that load. [See the benchmark →](https://github.com/dunky-dev/state-machine/tree/main/benchmark#readme) ```ts - import { setup } from "@dunky.dev/state-machine"; + import { setup } from '@dunky.dev/state-machine' const toggle = setup({ - initial: "off", + initial: 'off', states: { - off: { on: { TOGGLE: "on" } }, - on: { on: { TOGGLE: "off" } }, + off: { on: { TOGGLE: 'on' } }, + on: { on: { TOGGLE: 'off' } }, }, - }); + }) ``` This is our first public release (`0.1.0`). The engine is stable and tested; the target bridges are early and evolving. Come kick the tires, watch the live benchmark, and tell us where it breaks. diff --git a/packages/opentui/CHANGELOG.md b/packages/opentui/CHANGELOG.md index 3f75703..e3acf3c 100644 --- a/packages/opentui/CHANGELOG.md +++ b/packages/opentui/CHANGELOG.md @@ -5,7 +5,6 @@ ### Minor Changes - [`d0e20a5`](https://github.com/dunky-dev/state-machine/commit/d0e20a5ac3ca953c923e05819034e420394af83b) Thanks [@ivanbanov](https://github.com/ivanbanov)! - Rename the framework adapters to the `state-machine-*` suffix convention so the whole scope is consistent (`state-machine`, `state-machine-react`, `state-machine-native`, `state-machine-opentui`, `state-machine-utils`, `state-machine-bindings`). - - `@dunky.dev/react-state-machine` → `@dunky.dev/state-machine-react` - `@dunky.dev/native-state-machine` → `@dunky.dev/state-machine-native` - `@dunky.dev/opentui-state-machine` → `@dunky.dev/state-machine-opentui` @@ -34,15 +33,15 @@ **⚡️ Blazing fast.** Design systems and complex UIs can run hundreds of live machines at once. Dunky is tuned for exactly that load. [See the benchmark →](https://github.com/dunky-dev/state-machine/tree/main/benchmark#readme) ```ts - import { setup } from "@dunky.dev/state-machine"; + import { setup } from '@dunky.dev/state-machine' const toggle = setup({ - initial: "off", + initial: 'off', states: { - off: { on: { TOGGLE: "on" } }, - on: { on: { TOGGLE: "off" } }, + off: { on: { TOGGLE: 'on' } }, + on: { on: { TOGGLE: 'off' } }, }, - }); + }) ``` This is our first public release (`0.1.0`). The engine is stable and tested; the target bridges are early and evolving. Come kick the tires, watch the live benchmark, and tell us where it breaks. diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md index cbd34d8..9941039 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -5,7 +5,6 @@ ### Minor Changes - [`d0e20a5`](https://github.com/dunky-dev/state-machine/commit/d0e20a5ac3ca953c923e05819034e420394af83b) Thanks [@ivanbanov](https://github.com/ivanbanov)! - Rename the framework adapters to the `state-machine-*` suffix convention so the whole scope is consistent (`state-machine`, `state-machine-react`, `state-machine-native`, `state-machine-opentui`, `state-machine-utils`, `state-machine-bindings`). - - `@dunky.dev/react-state-machine` → `@dunky.dev/state-machine-react` - `@dunky.dev/native-state-machine` → `@dunky.dev/state-machine-native` - `@dunky.dev/opentui-state-machine` → `@dunky.dev/state-machine-opentui` @@ -35,15 +34,15 @@ **⚡️ Blazing fast.** Design systems and complex UIs can run hundreds of live machines at once. Dunky is tuned for exactly that load. [See the benchmark →](https://github.com/dunky-dev/state-machine/tree/main/benchmark#readme) ```ts - import { setup } from "@dunky.dev/state-machine"; + import { setup } from '@dunky.dev/state-machine' const toggle = setup({ - initial: "off", + initial: 'off', states: { - off: { on: { TOGGLE: "on" } }, - on: { on: { TOGGLE: "off" } }, + off: { on: { TOGGLE: 'on' } }, + on: { on: { TOGGLE: 'off' } }, }, - }); + }) ``` This is our first public release (`0.1.0`). The engine is stable and tested; the target bridges are early and evolving. Come kick the tires, watch the live benchmark, and tell us where it breaks. diff --git a/packages/shared/bindings/CHANGELOG.md b/packages/shared/bindings/CHANGELOG.md index dd1a56c..ce1f01c 100644 --- a/packages/shared/bindings/CHANGELOG.md +++ b/packages/shared/bindings/CHANGELOG.md @@ -19,15 +19,15 @@ **⚡️ Blazing fast.** Design systems and complex UIs can run hundreds of live machines at once. Dunky is tuned for exactly that load. [See the benchmark →](https://github.com/dunky-dev/state-machine/tree/main/benchmark#readme) ```ts - import { setup } from "@dunky.dev/state-machine"; + import { setup } from '@dunky.dev/state-machine' const toggle = setup({ - initial: "off", + initial: 'off', states: { - off: { on: { TOGGLE: "on" } }, - on: { on: { TOGGLE: "off" } }, + off: { on: { TOGGLE: 'on' } }, + on: { on: { TOGGLE: 'off' } }, }, - }); + }) ``` This is our first public release (`0.1.0`). The engine is stable and tested; the target bridges are early and evolving. Come kick the tires, watch the live benchmark, and tell us where it breaks. diff --git a/packages/shared/utils/CHANGELOG.md b/packages/shared/utils/CHANGELOG.md index d5f7be4..ab28089 100644 --- a/packages/shared/utils/CHANGELOG.md +++ b/packages/shared/utils/CHANGELOG.md @@ -19,15 +19,15 @@ **⚡️ Blazing fast.** Design systems and complex UIs can run hundreds of live machines at once. Dunky is tuned for exactly that load. [See the benchmark →](https://github.com/dunky-dev/state-machine/tree/main/benchmark#readme) ```ts - import { setup } from "@dunky.dev/state-machine"; + import { setup } from '@dunky.dev/state-machine' const toggle = setup({ - initial: "off", + initial: 'off', states: { - off: { on: { TOGGLE: "on" } }, - on: { on: { TOGGLE: "off" } }, + off: { on: { TOGGLE: 'on' } }, + on: { on: { TOGGLE: 'off' } }, }, - }); + }) ``` This is our first public release (`0.1.0`). The engine is stable and tested; the target bridges are early and evolving. Come kick the tires, watch the live benchmark, and tell us where it breaks. diff --git a/packages/svelte/LICENSE b/packages/svelte/LICENSE new file mode 100644 index 0000000..08a9692 --- /dev/null +++ b/packages/svelte/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/svelte/README.md b/packages/svelte/README.md new file mode 100644 index 0000000..aba99a8 --- /dev/null +++ b/packages/svelte/README.md @@ -0,0 +1,217 @@ +# `@dunky.dev/state-machine-svelte` + +The **Svelte 5 bindings** for [`@dunky.dev/state-machine`](../core/README.md). +The core engine is renderer-agnostic; this package is the thin Svelte edge that +drives it: it builds the machine + connector, runs the Svelte lifecycle, bridges +the connector's snapshot into Svelte reactivity (runes), translates the agnostic +[bindings](../core/README.md#connector--the-view-boundary) vocabulary into DOM +props, and owns the per-component substrate effects. + +Everything here is deliberately small — the behavior lives in the core machine +and the component's `connect`; this layer only adapts them to Svelte. There are +four exports: one bridge (`useMachine`, which also runs the component's substrate +effects), one leaf-subscription helper (`useSelector`), and two prop helpers +(`normalize`, `mergeProps`) — plus the `ComponentEffect` types. + +> **Svelte 5 only.** `useMachine` and `useSelector` use runes (`$state`, +> `$effect`), so they ship as `.svelte.ts` modules and are compiled by your +> Svelte build (Vite plugin / SvelteKit), exactly like a component. The package +> ships its `src` uncompiled for that reason. + +--- + +## `useMachine` — the one bridge + +Every component's generated `useXxxApi` calls this with the agnostic pieces: + +```svelte + + + +``` + +It: + +- **builds once** — `machine(createConfig(props))` + `connector(service, connect, props)`. + The first props read seeds context and the initial state; recreating would lose + state, so later prop changes flow through `setProps`, not a rebuild. +- **keeps props fresh** via an `$effect` calling `connection.setProps(getProps())`. + `getProps()` reads the component's reactive props, so it re-runs when they + change; `setProps` value-dedups, so an unchanged read doesn't churn. +- **runs the lifecycle**: `service.start()` on setup, `service.stop()` on destroy, + wired in one `$effect` whose cleanup Svelte calls automatically. 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 with no teardown + threading here. +- **runs the component's substrate effects** — one `$effect` per `ComponentEffect` + entry, each reading only its named prop deps (see below). +- **exposes the snapshot** through a `$state`-backed `view.api` getter, seeded with + the connector's initial snapshot and reassigned on each connector notify. The + connector memoizes, so the identity changes only on a real change — reading + `view.api` in markup updates only then. + +Returns `{ api, machine }` (both getters): `api` is the `connect()` output to +spread onto elements; `machine` is the running service (also handed to +`useSelector`). + +### Why a props getter + +React hands `useMachine` a fresh `props` value each render. Svelte props are +reactive bindings, so the bridge instead takes `() => props` and reads it inside +its effects — that's how `setProps` and the substrate effects see current values +without a per-render call. Pass `() => props` (or `() => ({ ...resolved })` after +applying defaults). + +--- + +## `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 +Svelte-side _effect_. + +Each effect is a `[setup/teardown, depPropNames]` tuple (`ComponentEffect`), the +**same shape as the React binding** — only how `useMachine` runs it differs: + +```ts +import type { ComponentEffect } from '@dunky.dev/state-machine-svelte' + +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 `$effect` per entry**. Each effect reads only +its named props, so runes wake it _only when one of those values actually +changes_ (the precise-dependency behavior React got from a manual dep array, here +from automatic tracking) — never for an unrelated machine change. Returning the +setup's teardown lets Svelte clean it up on re-run / destroy. + +> Unlike React there's no rules-of-hooks constraint, so the list need not be a +> module constant — but keeping it one (`export const xEffects = [...]`) stays the +> tidy convention. + +> The agnostic _decision_ (gate + veto) belongs in the core component's resolver; +> only the _transport_ (the DOM listener) is here. Same split as everywhere: +> agnostic policy in core, platform wiring at the edge. + +--- + +## `useSelector` — fine-grained leaf subscription + +For a leaf that should update only when **one slice** of the machine changes (the +`O(readers)` path that matters at scale — thousands of items, each waking only for +its own value): + +```ts +const open = useSelector(machine, () => machine.matches('open')) +// in markup: {#if open.current} … {/if} +``` + +It returns `{ current }` — a single reactive getter (a bare value can't carry its +reactivity across the `return`). The selector reads the machine directly and the +value updates only when the selected value changes — `Object.is` by default. **A +selector that returns a fresh object/array each call should pass a custom +`isEqual`** so an equal-but-new value isn't seen as 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, +) +``` + +Internally it wraps the selector in one `Selection` and subscribes in an +`$effect`, writing a `$state` cell that `current` reads. The Selection's +value-dedup gates the update; there's no getSnapshot-identity hazard to guard +against (unlike React) because `$state` only notifies on reassignment. + +**`useMachine` vs. `useSelector`.** `useMachine` drives the whole component off +the connector's snapshot (memoized, so it updates only on a real change). +`useSelector` is for _within_ that tree — a child that wants to track just one +field. Reach for it when a subtree is large enough that whole-snapshot updates are +wasteful. + +--- + +## `normalize` — agnostic bindings → DOM props + +`connect` returns substrate-agnostic [bindings](../core/README.md#connector--the-view-boundary) +(`onPress`, `describedBy`, `role`). `normalize` translates them to real DOM/ARIA +props in Svelte's idiom — lowercase `on*` events, `tabindex`, `aria-*`: + +```ts +const domProps = normalize(view.api.triggerProps) // { onclick, 'aria-describedby', role, … } +``` + +The mapping mirrors the React DOM normalizer, with two Svelte differences: event +props are the **lowercase DOM names** (`onclick`, `onkeydown`) rather than +camelCase synthetic-event props, and `focusable` → `tabindex` (lowercase). The +`aria-*` names are identical — ARIA is part of the DOM, not the framework. A few +handlers whose agnostic payload differs from the raw event +(`onValueChange`/`onWheel`/`onScroll`/`onScrollEnd`) are wrapped so the consumer +receives the agnostic payload. `undefined` values are dropped, and any key not in +the map passes through unchanged. + +--- + +## `mergeProps` — combine consumer props with the component's props + +When a consumer spreads their own props onto the same element the component +controls, the two prop sets have to merge sensibly. `mergeProps(consumer, library)` +does it the Radix/Ark way: + +```ts +const finalProps = mergeProps(consumerProps, normalize(view.api.triggerProps)) +``` + +- **Event handlers are chained, consumer-first** — both run, the consumer's + before the library's — **but if the consumer's handler marks the event + `defaultPrevented`, the library handler is skipped.** Detection is on Svelte's + lowercase `on*` props. +- **`style` is concatenated** as a string (Svelte styles are strings, not React's + array form), joined with `; ` and trimmed. String + string only; else library + wins. +- **`class` is concatenated** with a single space and trimmed (the Svelte + attribute name, React's `className`). String + string only; else library wins. +- **Everything else: library wins** — the component owns its semantics (`id`, + `role`, `aria-*`). + +If the consumer passes no props, the library props are returned as-is. + +--- + +## API + +| Export | What it is | +| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------- | +| `useMachine(config, connect, effects, getProps)` | the bridge — build once + lifecycle + run the component effects + reactive snapshot; returns `{ api, machine }` (getters) | +| `useSelector(machine, selector, isEqual?)` | fine-grained subscription to a derived slice (`O(readers)`); returns `{ current }` | +| `normalize(bindings)` | agnostic bindings → DOM/ARIA props (lowercase `on*`, `tabindex`, `aria-*`) | +| `mergeProps(consumer, library)` | merge consumer + component props (handlers chained w/ `defaultPrevented` veto; `style`/`class` concatenated; else library wins) | +| `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/svelte/package.json b/packages/svelte/package.json new file mode 100644 index 0000000..b6347c9 --- /dev/null +++ b/packages/svelte/package.json @@ -0,0 +1,34 @@ +{ + "name": "@dunky.dev/state-machine-svelte", + "version": "0.2.0", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/dunky-dev/state-machine.git", + "directory": "packages/svelte" + }, + "files": [ + "src" + ], + "type": "module", + "sideEffects": false, + "main": "./src/index.ts", + "types": "./src/index.ts", + "svelte": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "svelte": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "dependencies": { + "@dunky.dev/state-machine": "workspace:^" + }, + "devDependencies": { + "svelte": "^5.43.2" + }, + "peerDependencies": { + "svelte": "^5" + } +} diff --git a/packages/svelte/src/index.ts b/packages/svelte/src/index.ts new file mode 100644 index 0000000..fc9954e --- /dev/null +++ b/packages/svelte/src/index.ts @@ -0,0 +1,4 @@ +export { useMachine, type ComponentEffect, type ComponentEffects } from './use-machine.svelte' +export { useSelector } from './use-selector.svelte' +export { normalize, type Bindings } from './normalize' +export { mergeProps } from './merge-props' diff --git a/packages/svelte/src/merge-props.ts b/packages/svelte/src/merge-props.ts new file mode 100644 index 0000000..dd6b73a --- /dev/null +++ b/packages/svelte/src/merge-props.ts @@ -0,0 +1,71 @@ +type AnyProps = Record +type AnyHandler = (...args: unknown[]) => unknown + +// A Svelte DOM event prop: lowercase `on` + an event name (`onclick`, +// `onkeydown`, `onpointerenter`). This is where the Svelte merge diverges from +// the shared `baseMergeProps`, whose detector keys off React's camelCase form +// (`on` + an UPPERCASE letter). After `normalize`, the library props are all +// lowercase, so we chain on the lowercase shape instead. +const isEventHandlerKey = (key: string): boolean => + key.length > 2 && key.startsWith('on') && key[2] !== key[2]!.toUpperCase() + +const isFn = (v: unknown): v is AnyHandler => typeof v === 'function' + +function compose(consumer: AnyHandler, library: AnyHandler): AnyHandler { + return (...args) => { + consumer(...args) + // Respect consumer's defaultPrevented — if the first arg looks like an event + // whose default was prevented, the library handler is skipped. Matches the + // Radix/Ark convention the React/base mergers use. + const event = args[0] as { defaultPrevented?: boolean } | undefined + if (event && typeof event === 'object' && event.defaultPrevented) return + return library(...args) + } +} + +/** + * Merge a consumer's props with the component's (library) props for the same + * element — the Svelte counterpart of the React `mergeProps`. + * + * - **Event handlers are chained, consumer-first**, with the same + * `defaultPrevented` veto: if the consumer's handler prevents the event, the + * library's is skipped. Detection is on Svelte's lowercase `on*` props. + * - **`class` is concatenated** with a single space and trimmed (`'a b'` + `'c'` + * → `'a b c'`), the Svelte attribute name (React's `className`). String + string + * only; otherwise library wins. + * - **`style` is concatenated** as a string (Svelte styles are strings, not the + * React array form), joined with `; ` and trimmed. String + string only; + * otherwise library wins. + * - **Everything else: library wins** — the component owns its semantics + * (`id`, `role`, `aria-*`). + * + * If the consumer passes no props, the library props are returned as-is. + */ +export function mergeProps(consumer: AnyProps | undefined, library: AnyProps): AnyProps { + if (!consumer) return library + const out: AnyProps = { ...consumer } + + for (const [key, libValue] of Object.entries(library)) { + const consumerValue = consumer[key] + + if (isEventHandlerKey(key) && isFn(consumerValue) && isFn(libValue)) { + out[key] = compose(consumerValue, libValue) + continue + } + + if (key === 'class' && typeof consumerValue === 'string' && typeof libValue === 'string') { + out.class = `${consumerValue} ${libValue}`.trim() + continue + } + + if (key === 'style' && typeof consumerValue === 'string' && typeof libValue === 'string') { + out.style = `${consumerValue.replace(/;\s*$/, '')}; ${libValue}`.trim() + continue + } + + // Default: library wins. + out[key] = libValue + } + + return out +} diff --git a/packages/svelte/src/normalize.ts b/packages/svelte/src/normalize.ts new file mode 100644 index 0000000..43723fb --- /dev/null +++ b/packages/svelte/src/normalize.ts @@ -0,0 +1,180 @@ +/** + * Translate the machine layer's LOGICAL surface to Svelte DOM props. + * + * Logical handler → DOM event prop + * Logical attr → DOM/ARIA attr + * + * Differences from the React DOM normalizer worth flagging: + * + * - Svelte 5 event props are the lowercase DOM attribute names (`onclick`, + * `onkeydown`, `onpointerenter`), not React's camelCase synthetic-event props + * (`onClick`, `onKeyDown`). Spread onto an element, `{...normalize(api.x)}` + * attaches real DOM listeners. + * - `focusable` → `tabindex` (lowercase), where React used `tabIndex`. + * - The `aria-*` attribute names are identical to React's — ARIA is part of the + * DOM, not the framework — so the attr map matches the React one verbatim. + * - The payload adapters are identical too: Svelte hands the handler the raw DOM + * event, the same shape React's normalizer adapts from. + */ + +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 +// raw DOM event. For those, normalize wraps the handler so the component +// receives the agnostic payload — built here from the DOM event — rather than +// the DOM event itself. (onPress/pointer/keyboard handlers already receive a +// shape that overlaps PointerPayload/KeyboardPayload, so they pass through +// unwrapped, exactly as onPress always has.) + +// 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/svelte/src/use-machine.svelte.ts b/packages/svelte/src/use-machine.svelte.ts new file mode 100644 index 0000000..c60f20a --- /dev/null +++ b/packages/svelte/src/use-machine.svelte.ts @@ -0,0 +1,149 @@ +/// +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 Svelte. The deps are prop NAMES (typed `(keyof Props)[]`, + * so typos are compile errors); the bridge turns them into a tracked `$effect` + * so the effect re-subscribes only when one of those props actually changes — + * not on every change, never stale. `machine` is always an implicit dep. + * + * Identical in shape to the React `ComponentEffect` — what changes is only how + * `useMachine` runs it (a Svelte `$effect`, not a React `useEffect`). + */ +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 `$effect` so only the one whose dep changed re-subscribes. + * + * Unlike React, Svelte has no rules-of-hooks: `useMachine` sets up the effects + * once at call time, so the list need not be a module constant. Keeping it one + * (`export const xEffects = [...]`) is still the tidy convention. + */ +export type ComponentEffects = ComponentEffect[] + +type Service< + State extends string, + Context extends object, + Event extends { type: string }, + Computed, +> = ReturnType> + +/** + * The one generic Svelte bridge. Every component's generated api calls this with + * the agnostic pieces — a config factory and the connect — plus the component's + * substrate effects and a GETTER for the resolved props: + * + * const view = useMachine(tooltipMachineConfig, connectTooltip, tooltipEffects, () => props) + * // then in markup: diff --git a/packages/svelte/tests/fixtures/toggle.ts b/packages/svelte/tests/fixtures/toggle.ts new file mode 100644 index 0000000..7459b17 --- /dev/null +++ b/packages/svelte/tests/fixtures/toggle.ts @@ -0,0 +1,50 @@ +import { act, type Connect, type TransitionConfig } from '@dunky.dev/state-machine' + +/** + * A tiny toggle machine used by the useMachine tests — the Svelte counterpart of + * the fixture the React bridge tests use. `open` flips state and counts toggles; + * `label` is a pass-through prop that proves prop changes flow through `setProps` + * (and don't rebuild the machine). + */ + +export type ToggleState = 'closed' | 'open' +export type ToggleContext = { count: number } +export type ToggleEvent = { type: 'toggle' } +export type ToggleProps = { label?: string } +export type ToggleApi = { + open: boolean + count: number + label: string | undefined + toggle: () => void +} + +export const createToggleConfig = (): TransitionConfig< + ToggleState, + ToggleContext, + ToggleEvent +> => ({ + initial: 'closed', + context: { count: 0 }, + states: { + // The count write goes through `act` (setContext) so subscribers wake. + closed: { + on: { toggle: { target: 'open', actions: act($ => ({ count: $.context.count + 1 })) } }, + }, + open: { + on: { toggle: { target: 'closed', actions: act($ => ({ count: $.context.count + 1 })) } }, + }, + }, +}) + +export const connectToggle: Connect< + ToggleState, + ToggleContext, + ToggleEvent, + ToggleProps, + ToggleApi +> = ({ state, context, props, send }) => ({ + open: state === 'open', + count: context.count, + label: props.label, + toggle: () => send({ type: 'toggle' }), +}) diff --git a/packages/svelte/tests/merge-props.test.ts b/packages/svelte/tests/merge-props.test.ts new file mode 100644 index 0000000..3d10078 --- /dev/null +++ b/packages/svelte/tests/merge-props.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from 'vitest' +import { mergeProps } from '@dunky.dev/state-machine-svelte' + +describe('mergeProps', () => { + it('chains overlapping handlers — consumer first, library second', () => { + const calls: string[] = [] + const consumer = vi.fn(() => calls.push('consumer')) + const library = vi.fn(() => calls.push('library')) + const merged = mergeProps({ onclick: consumer }, { onclick: library }) + ;(merged.onclick as (e: unknown) => void)({ defaultPrevented: false }) + expect(calls).toEqual(['consumer', 'library']) + }) + + it('skips the library handler when the consumer prevents default', () => { + 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('only chains lowercase on* props (Svelte event shape), not camelCase', () => { + const consumer = vi.fn() + const library = vi.fn() + // camelCase isn't a Svelte DOM event prop → library wins, no chaining. + const merged = mergeProps({ onClick: consumer }, { onClick: library }) + expect(merged.onClick).toBe(library) + }) + + it('library wins on plain attrs', () => { + const out = mergeProps({ id: 'consumer' }, { id: 'lib' }) + expect(out.id).toBe('lib') + }) + + it('concatenates overlapping styles as a string (consumer first, library second)', () => { + const out = mergeProps({ style: 'color: red' }, { style: 'background: blue' }) + expect(out.style).toBe('color: red; background: blue') + }) + + it('drops a trailing semicolon on the consumer style before joining', () => { + const out = mergeProps({ style: 'color: red;' }, { style: 'background: blue' }) + expect(out.style).toBe('color: red; background: blue') + }) + + it('library style wins when consumer omits style', () => { + const out = mergeProps({ id: 'a' }, { style: 'color: blue' }) + expect(out.style).toBe('color: blue') + }) + + it('consumer style stays when library omits style', () => { + const out = mergeProps({ style: 'color: red' }, { id: 'a' }) + expect(out.style).toBe('color: red') + }) + + 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 on class; inner spacing is preserved verbatim', () => { + const out = mergeProps({ class: ' a ' }, { class: ' b ' }) + // `${' a '} ${' b '}` → ' a b ' → trim → 'a 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') + }) + + it('returns the library props as-is when the consumer passes none', () => { + const library = { id: 'lib', onclick: vi.fn() } + expect(mergeProps(undefined, library)).toBe(library) + }) +}) diff --git a/packages/svelte/tests/normalize.test.ts b/packages/svelte/tests/normalize.test.ts new file mode 100644 index 0000000..5cadfa2 --- /dev/null +++ b/packages/svelte/tests/normalize.test.ts @@ -0,0 +1,308 @@ +/** + * Svelte DOM bindings translator — pure-logic tests (no DOM runtime needed). + * + * `normalize` maps the core's substrate-agnostic logical surface + * (`@dunky.dev/state-machine`'s `EventBindings` + `AttrBindings`) to real + * DOM/ARIA props in Svelte's idiom: lowercase `on*` event props, `tabindex`, + * and the (framework-neutral) `aria-*` attrs. These tests pin the FULL + * vocabulary so every logical binding has an explicit, asserted DOM target — + * nothing relies on accidental pass-through. + */ +import { describe, expect, it, vi } from 'vitest' +import { normalize } from '@dunky.dev/state-machine-svelte' + +describe('svelte 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 lowercase DOM pointer events', () => { + const onPress = vi.fn() + const out = normalize({ + onPointerEnter: vi.fn(), + onPointerLeave: vi.fn(), + onPointerMove: vi.fn(), + onPointerDown: vi.fn(), + onPointerUp: vi.fn(), + onPointerCancel: vi.fn(), + onPress, + }) + expect(Object.keys(out).sort()).toEqual( + [ + 'onpointerenter', + 'onpointerleave', + 'onpointermove', + 'onpointerdown', + 'onpointerup', + 'onpointercancel', + 'onclick', + ].sort(), + ) + }) + + it('maps onFocus / onBlur to lowercase', () => { + const onFocus = vi.fn() + const onBlur = vi.fn() + expect(normalize({ onFocus, onBlur })).toEqual({ onfocus: onFocus, onblur: onBlur }) + }) + + it('maps both keyboard handlers (onkeydown / onkeyup)', () => { + const onKeyDown = vi.fn() + const onKeyUp = vi.fn() + expect(normalize({ onKeyDown, onKeyUp })).toEqual({ onkeydown: onKeyDown, onkeyup: onKeyUp }) + }) +}) + +describe('svelte 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 hasPopup to aria-haspopup (string or boolean)', () => { + expect(normalize({ hasPopup: 'menu' })).toEqual({ 'aria-haspopup': 'menu' }) + expect(normalize({ hasPopup: true })).toEqual({ 'aria-haspopup': true }) + }) + + 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 (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)', () => { + expect(normalize({ 'data-state': 'open' })).toEqual({ 'data-state': 'open' }) + }) + + it('skips undefined values', () => { + expect(normalize({ role: undefined, id: 'x' })).toEqual({ id: 'x' }) + }) +}) + +describe('svelte normalize — combined surface (trigger shape)', () => { + it('translates a realistic trigger binding set', () => { + const onPress = vi.fn() + const out = normalize({ + id: 'menu:1:trigger', + role: 'button', + controls: 'menu:1:content', + hasPopup: 'menu', + expanded: true, + focusable: true, + onPress, + onKeyDown: vi.fn(), + 'data-state': 'open', + }) + expect(out).toMatchObject({ + id: 'menu:1:trigger', + role: 'button', + 'aria-controls': 'menu:1:content', + 'aria-haspopup': 'menu', + 'aria-expanded': true, + tabindex: 0, + onclick: onPress, + 'data-state': 'open', + }) + expect(typeof out.onkeydown).toBe('function') + }) +}) + +describe('svelte normalize — expanded handler surface', () => { + it('maps each value-change / interaction handler to its lowercase 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( + ['oninput', 'oncontextmenu', 'ondblclick', '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('svelte 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/svelte/tests/use-machine.test.ts b/packages/svelte/tests/use-machine.test.ts new file mode 100644 index 0000000..44d9995 --- /dev/null +++ b/packages/svelte/tests/use-machine.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest' +import { render, fireEvent } from '@testing-library/svelte' +import { tick } from 'svelte' +import ToggleHarness from './fixtures/toggle-harness.svelte' + +type Sink = { + api?: import('./fixtures/toggle').ToggleApi + renders: number + effectRuns: number + effectCleanups: number +} + +const newSink = (): Sink => ({ renders: 0, effectRuns: 0, effectCleanups: 0 }) + +describe('useMachine', () => { + it('exposes the connect() api and drives the markup', async () => { + const sink = newSink() + const { getByTestId } = render(ToggleHarness, { props: { label: 'a', sink } }) + await tick() + + const btn = getByTestId('toggle') + expect(btn.textContent).toContain('a') + expect(btn.textContent).toContain('closed') + expect(sink.api?.open).toBe(false) + }) + + it('builds the machine ONCE: state survives prop changes', async () => { + const sink = newSink() + const { getByTestId, rerender } = render(ToggleHarness, { props: { label: 'a', sink } }) + await tick() + + await fireEvent.click(getByTestId('toggle')) // → open, count 1 + await tick() + expect(sink.api?.open).toBe(true) + expect(sink.api?.count).toBe(1) + + // A prop change must NOT rebuild the machine — state + count persist, and the + // fresh label flows through setProps. + await rerender({ label: 'b', sink }) + await tick() + expect(sink.api?.open).toBe(true) + expect(sink.api?.count).toBe(1) + expect(sink.api?.label).toBe('b') + }) + + it('produces one new snapshot per real machine change', async () => { + const sink = newSink() + const { getByTestId } = render(ToggleHarness, { props: { label: 'a', sink } }) + await tick() + const baseline = sink.renders + const firstApi = sink.api + + // Each toggle is a real state change → exactly one new snapshot (the + // connector memoizes, so the identity changes only on a real change). + await fireEvent.click(getByTestId('toggle')) + await tick() + expect(sink.renders).toBe(baseline + 1) + expect(sink.api).not.toBe(firstApi) + }) + + it('runs a component effect on mount and cleans it up on unmount', async () => { + const sink = newSink() + const { unmount } = render(ToggleHarness, { + props: { label: 'a', sink, trackLabelEffect: true }, + }) + await tick() + expect(sink.effectRuns).toBe(1) + expect(sink.effectCleanups).toBe(0) + + unmount() + await tick() + expect(sink.effectCleanups).toBe(1) + }) + + it('does not re-run a prop-scoped effect on an unrelated machine change', async () => { + const sink = newSink() + const { getByTestId } = render(ToggleHarness, { + props: { label: 'a', sink, trackLabelEffect: true }, + }) + await tick() + expect(sink.effectRuns).toBe(1) + + // Toggling changes machine state, NOT the `label` prop the effect tracks — + // so the effect stays put. This is the precise-dependency guarantee: the + // effect reads only its named props, so runes wake it only for those. + await fireEvent.click(getByTestId('toggle')) + await tick() + expect(sink.effectRuns).toBe(1) + }) + + it('re-runs an effect when its named prop dep changes (cleaning up first)', async () => { + const sink = newSink() + const { rerender } = render(ToggleHarness, { + props: { label: 'a', sink, trackLabelEffect: true }, + }) + await tick() + expect(sink.effectRuns).toBe(1) + + // dep changed → re-run, and the prior setup is torn down first. + await rerender({ label: 'b', sink, trackLabelEffect: true }) + await tick() + expect(sink.effectRuns).toBe(2) + expect(sink.effectCleanups).toBe(1) + }) +}) diff --git a/packages/svelte/tests/use-selector.test.ts b/packages/svelte/tests/use-selector.test.ts new file mode 100644 index 0000000..7019058 --- /dev/null +++ b/packages/svelte/tests/use-selector.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { render } from '@testing-library/svelte' +import { tick } from 'svelte' +import SelectorHarness from './fixtures/selector-harness.svelte' +import { makeCounters } from './fixtures/counters' + +describe('useSelector', () => { + it('reads the current selected value on mount', async () => { + const m = makeCounters() + const sink = { value: undefined as unknown, updates: 0 } + const { getByTestId } = render(SelectorHarness, { + props: { machine: m, selector: () => m.context.a, sink }, + }) + await tick() + expect(getByTestId('value').textContent).toBe('0') + expect(sink.value).toBe(0) + }) + + it('updates ONLY when the selected slice changes', async () => { + const m = makeCounters() + const sink = { value: undefined as unknown, updates: 0 } + render(SelectorHarness, { props: { machine: m, selector: () => m.context.a, sink } }) + await tick() + const baseline = sink.updates + + // selects `a`; bumping `b` must NOT update this selection. + m.send({ type: 'incB' }) + await tick() + expect(sink.updates).toBe(baseline) + expect(sink.value).toBe(0) + + // bumping `a` updates it. + m.send({ type: 'incA' }) + await tick() + expect(sink.updates).toBe(baseline + 1) + expect(sink.value).toBe(1) + }) + + it('uses the provided isEqual to dedup an object selection', async () => { + const m = makeCounters() + const sink = { value: undefined as unknown, updates: 0 } + render(SelectorHarness, { + props: { + machine: m, + selector: () => ({ a: m.context.a }), + isEqual: (x: unknown, y: unknown) => (x as { a: number }).a === (y as { a: number }).a, + sink, + }, + }) + await tick() + const baseline = sink.updates + + // `b` changes, the selected `{ a }` is equal under isEqual → no update, + // despite the selector returning a fresh object each evaluation. + m.send({ type: 'incB' }) + await tick() + expect(sink.updates).toBe(baseline) + + // `a` changes → the object differs under isEqual → one update. + m.send({ type: 'incA' }) + await tick() + expect(sink.updates).toBe(baseline + 1) + expect((sink.value as { a: number }).a).toBe(1) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f59d3f..5c24a41 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,12 @@ importers: '@changesets/cli': specifier: ^2.31.0 version: 2.31.0(@types/node@22.19.19) + '@sveltejs/vite-plugin-svelte': + specifier: ^7.1.2 + version: 7.1.2(svelte@5.56.4)(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)) + '@testing-library/svelte': + specifier: ^5.4.2 + version: 5.4.2(svelte@5.56.4)(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@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))) '@types/node': specifier: ^22.10.2 version: 22.19.19 @@ -28,7 +34,7 @@ importers: version: 17.0.7 oxfmt: specifier: ^0.52.0 - version: 0.52.0 + version: 0.52.0(svelte@5.56.4) oxlint: specifier: ^1.67.0 version: 1.67.0 @@ -180,6 +186,16 @@ importers: packages/shared/utils: {} + packages/svelte: + dependencies: + '@dunky.dev/state-machine': + specifier: workspace:^ + version: link:../core + devDependencies: + svelte: + specifier: ^5.43.2 + version: 5.56.4 + sandbox/native: dependencies: '@dunky.dev/state-machine': @@ -303,6 +319,31 @@ importers: specifier: workspace:^ version: link:../../packages/shared/bindings + sandbox/svelte: + 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-svelte': + specifier: workspace:^ + version: link:../../packages/svelte + '@sandbox/cmdk-core': + specifier: workspace:^ + version: link:../shared + svelte: + specifier: ^5.43.2 + version: 5.56.4 + devDependencies: + '@sveltejs/vite-plugin-svelte': + specifier: ^7.1.2 + version: 7.1.2(svelte@5.56.4)(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)) + 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) + website: dependencies: '@astrojs/starlight': @@ -313,10 +354,10 @@ importers: version: link:../packages/core '@vercel/analytics': specifier: ^2.0.1 - version: 2.0.1(react@19.2.6) + version: 2.0.1(react@19.2.6)(svelte@5.56.4) '@vercel/speed-insights': specifier: ^2.0.0 - version: 2.0.0(react@19.2.6) + version: 2.0.0(react@19.2.6)(svelte@5.56.4) devDependencies: '@astrojs/mdx': specifier: ^6.0.3 @@ -2783,6 +2824,18 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@sveltejs/acorn-typescript@1.0.10': + resolution: {integrity: sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==} + peerDependencies: + acorn: ^8.9.0 + + '@sveltejs/vite-plugin-svelte@7.1.2': + resolution: {integrity: sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==} + engines: {node: ^20.19 || ^22.12 || >=24} + peerDependencies: + svelte: ^5.46.4 + vite: ^8.0.0-beta.7 || ^8.0.0 + '@tailwindcss/node@4.3.1': resolution: {integrity: sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==} @@ -2890,6 +2943,25 @@ packages: '@types/react-dom': optional: true + '@testing-library/svelte-core@1.1.3': + resolution: {integrity: sha512-KkMAvXeWorxN2Yn0kdC1lfoAItxpoj4uOWzxK5leDrNxonLvS5nwBFvztrroyTszQ0Wf/EU6iLT8JhY5qcn22g==} + engines: {node: '>=16'} + peerDependencies: + svelte: ^3 || ^4 || ^5 || ^5.0.0-next.0 + + '@testing-library/svelte@5.4.2': + resolution: {integrity: sha512-4o31E4HGo5BU5KwPkulNRocEden+7Tt9JYm9uhln5ajF7DULeyFA46BBWVfKJ8Ms9B3JmOFPTIiVamH7n3KpuQ==} + engines: {node: '>= 10'} + peerDependencies: + svelte: ^3 || ^4 || ^5 || ^5.0.0-next.0 + vite: '*' + vitest: '*' + peerDependenciesMeta: + vite: + optional: true + vitest: + optional: true + '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} @@ -2991,6 +3063,9 @@ packages: '@types/tough-cookie@4.0.5': resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -3244,6 +3319,10 @@ packages: aria-query@5.3.0: resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + aria-query@5.3.1: + resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} + engines: {node: '>= 0.4'} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -3903,11 +3982,22 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} + esm-env@1.2.2: + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} hasBin: true + esrap@2.2.12: + resolution: {integrity: sha512-On0QbLyaiAkVC4eXtgnXK9Kh2opit+3rcUSOc45DqJ2s/X2eXAHsGOKRSJ6IDagQEW5vPyivANfXUiqgXC67Rw==} + peerDependencies: + '@typescript-eslint/types': ^8.2.0 + peerDependenciesMeta: + '@typescript-eslint/types': + optional: true + estree-util-attach-comments@3.0.0: resolution: {integrity: sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==} @@ -4437,6 +4527,9 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-reference@3.0.3: + resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} + is-subdir@1.2.0: resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} engines: {node: '>=4'} @@ -4652,6 +4745,9 @@ packages: resolution: {integrity: sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q==} engines: {node: '>=22.13.0'} + locate-character@3.0.0: + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} @@ -6045,6 +6141,10 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + svelte@5.56.4: + resolution: {integrity: sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==} + engines: {node: '>=18'} + svgo@4.0.1: resolution: {integrity: sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==} engines: {node: '>=16'} @@ -6744,6 +6844,9 @@ packages: resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} engines: {node: '>=12.20'} + zimmerframe@1.1.4: + resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} + zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} @@ -9288,6 +9391,28 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@sveltejs/acorn-typescript@1.0.10(acorn@8.16.0)': + dependencies: + acorn: 8.16.0 + + '@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.4)(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: + deepmerge: 4.3.1 + magic-string: 0.30.21 + obug: 2.1.1 + svelte: 5.56.4 + 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)) + + '@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.4)(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: + deepmerge: 4.3.1 + magic-string: 0.30.21 + obug: 2.1.1 + svelte: 5.56.4 + 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)) + '@tailwindcss/node@4.3.1': dependencies: '@jridgewell/remapping': 2.3.5 @@ -9378,6 +9503,19 @@ snapshots: '@types/react': 19.2.15 '@types/react-dom': 19.2.3(@types/react@19.2.15) + '@testing-library/svelte-core@1.1.3(svelte@5.56.4)': + dependencies: + svelte: 5.56.4 + + '@testing-library/svelte@5.4.2(svelte@5.56.4)(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@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: + '@testing-library/dom': 10.4.1 + '@testing-library/svelte-core': 1.1.3(svelte@5.56.4) + svelte: 5.56.4 + 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) + 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)) + '@tybys/wasm-util@0.10.2': dependencies: tslib: 2.8.1 @@ -9497,6 +9635,8 @@ snapshots: '@types/tough-cookie@4.0.5': {} + '@types/trusted-types@2.0.7': {} + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} @@ -9521,13 +9661,15 @@ snapshots: '@urql/core': 5.2.0 wonka: 6.3.6 - '@vercel/analytics@2.0.1(react@19.2.6)': + '@vercel/analytics@2.0.1(react@19.2.6)(svelte@5.56.4)': optionalDependencies: react: 19.2.6 + svelte: 5.56.4 - '@vercel/speed-insights@2.0.0(react@19.2.6)': + '@vercel/speed-insights@2.0.0(react@19.2.6)(svelte@5.56.4)': optionalDependencies: react: 19.2.6 + svelte: 5.56.4 '@vitejs/plugin-react@6.0.2(babel-plugin-react-compiler@1.0.0)(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: @@ -9699,6 +9841,8 @@ snapshots: dependencies: dequal: 2.0.3 + aria-query@5.3.1: {} + aria-query@5.3.2: {} array-iterate@2.0.1: {} @@ -10476,8 +10620,14 @@ snapshots: escape-string-regexp@5.0.0: {} + esm-env@1.2.2: {} + esprima@4.0.1: {} + esrap@2.2.12: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + estree-util-attach-comments@3.0.0: dependencies: '@types/estree': 1.0.9 @@ -11135,6 +11285,10 @@ snapshots: is-potential-custom-element-name@1.0.1: {} + is-reference@3.0.3: + dependencies: + '@types/estree': 1.0.9 + is-subdir@1.2.0: dependencies: better-path-resolve: 1.0.0 @@ -11388,6 +11542,8 @@ snapshots: rfdc: 1.4.1 wrap-ansi: 10.0.0 + locate-character@3.0.0: {} + locate-path@5.0.0: dependencies: p-locate: 4.1.0 @@ -12665,7 +12821,7 @@ snapshots: '@oxc-resolver/binding-win32-arm64-msvc': 11.20.0 '@oxc-resolver/binding-win32-x64-msvc': 11.20.0 - oxfmt@0.52.0: + oxfmt@0.52.0(svelte@5.56.4): dependencies: tinypool: 2.1.0 optionalDependencies: @@ -12688,6 +12844,7 @@ snapshots: '@oxfmt/binding-win32-arm64-msvc': 0.52.0 '@oxfmt/binding-win32-ia32-msvc': 0.52.0 '@oxfmt/binding-win32-x64-msvc': 0.52.0 + svelte: 5.56.4 oxlint@1.67.0: optionalDependencies: @@ -13647,6 +13804,27 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + svelte@5.56.4: + dependencies: + '@jridgewell/remapping': 2.3.5 + '@jridgewell/sourcemap-codec': 1.5.5 + '@sveltejs/acorn-typescript': 1.0.10(acorn@8.16.0) + '@types/estree': 1.0.9 + '@types/trusted-types': 2.0.7 + acorn: 8.16.0 + aria-query: 5.3.1 + axobject-query: 4.1.0 + clsx: 2.1.1 + devalue: 5.8.1 + esm-env: 1.2.2 + esrap: 2.2.12 + is-reference: 3.0.3 + locate-character: 3.0.0 + magic-string: 0.30.21 + zimmerframe: 1.1.4 + transitivePeerDependencies: + - '@typescript-eslint/types' + svgo@4.0.1: dependencies: commander: 11.1.0 @@ -13997,6 +14175,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 @@ -14168,6 +14354,8 @@ snapshots: yocto-queue@1.2.2: {} + zimmerframe@1.1.4: {} + zod@4.4.3: {} zwitch@2.0.4: {} diff --git a/sandbox/README.md b/sandbox/README.md index ff6ddfc..6a33149 100644 --- a/sandbox/README.md +++ b/sandbox/README.md @@ -1,7 +1,7 @@ -# cmdk sandbox — one machine, three substrates +# cmdk sandbox — one machine, four substrates A ⌘K **command palette** driven by a single substrate-agnostic state machine, -rendered three ways. The interesting parts — fuzzy filtering, arrow-key +rendered four ways. The interesting parts — fuzzy filtering, arrow-key navigation with wraparound, active-row tracking, selection — all live in `shared/`, the same bytes on every target. Each app only supplies the markup and runs its substrate's `normalize()` over the bindings the shared `connect()` @@ -10,24 +10,32 @@ produces. ``` sandbox/ ├── shared/ @sandbox/cmdk-core — the machine + connect() + commands (NO framework) -├── react/ Vite + React DOM → normalize → onClick / aria-* / role +├── react/ Vite + React DOM → normalize → onClick / aria-* / role +├── svelte/ Vite + Svelte 5 → normalize → onclick / aria-* / role ├── opentui/ Bun + @opentui/react → normalize → onMouseDown / focusable / cells └── native/ Expo + React Native → normalize → onPress / accessibilityState ``` -The split that makes this work: the lifecycle hook (`useMachine`) comes from -`@dunky.dev/state-machine-react` — all three targets render through a React -reconciler — while the **prop translator** (`normalize`) comes from each target's -own package. The OpenTUI app is the clearest proof: it imports `useMachine` from -the React binding and `normalize` from `@dunky.dev/state-machine-opentui`, exactly -the "bring your own framework hook, pair it with the agnostic translator" model. +The split that makes this work: each app pairs a **lifecycle hook** (`useMachine`) +with a **prop translator** (`normalize`), both from the target's binding package. +The React, OpenTUI, and React Native apps all render through a React reconciler, so +they share React's `useMachine` and only swap `normalize` — the OpenTUI app is the +clearest proof, importing `useMachine` from the React binding and `normalize` from +`@dunky.dev/state-machine-opentui`. The **Svelte** app shows the other axis: it +brings its _own_ `useMachine` (built on runes) from +`@dunky.dev/state-machine-svelte` — a different reconciler entirely — yet runs the +exact same `shared/` machine and `connect()` unchanged. Same behavior, bring your +own framework. ## Run ```bash -# DOM — opens at http://localhost:5173 +# DOM (React) — opens at http://localhost:5173 pnpm -C sandbox/react dev +# DOM (Svelte 5) — opens at http://localhost:5173 +pnpm -C sandbox/svelte dev + # Terminal — needs Bun. Press ⌘K / Ctrl+K to open the palette. pnpm -C sandbox/opentui dev @@ -35,5 +43,8 @@ pnpm -C sandbox/opentui dev pnpm -C sandbox/native start # then press i / a, or scan the QR ``` -All three consume the workspace packages straight from their TypeScript `src/` -(Vite alias / Bun workspace / Metro watch-folders) — no build step. +All four consume the workspace packages straight from their TypeScript `src/` +(Vite alias / Bun workspace / Metro watch-folders) — no build step. The Svelte app +aliases `@dunky.dev/state-machine-svelte` to its `src` too, so the `vite-plugin-svelte` +compiles the binding's `.svelte.ts` runes modules live — exactly how a consumer's +Svelte build processes the package. diff --git a/sandbox/svelte/index.html b/sandbox/svelte/index.html new file mode 100644 index 0000000..64b9b60 --- /dev/null +++ b/sandbox/svelte/index.html @@ -0,0 +1,12 @@ + + + + + + cmdk · Svelte + + +
+ + + diff --git a/sandbox/svelte/package.json b/sandbox/svelte/package.json new file mode 100644 index 0000000..4726021 --- /dev/null +++ b/sandbox/svelte/package.json @@ -0,0 +1,22 @@ +{ + "name": "@sandbox/cmdk-svelte", + "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-svelte": "workspace:^", + "@sandbox/cmdk-core": "workspace:^", + "svelte": "^5.43.2" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^7.1.2", + "vite": "^8.0.14" + } +} diff --git a/sandbox/svelte/src/app.svelte b/sandbox/svelte/src/app.svelte new file mode 100644 index 0000000..99282c4 --- /dev/null +++ b/sandbox/svelte/src/app.svelte @@ -0,0 +1,58 @@ + + +
+

⌘K Command Pallete

+
+ +
+

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

+

Last selected: {last}

+
+ + diff --git a/sandbox/svelte/src/command-palette.svelte b/sandbox/svelte/src/command-palette.svelte new file mode 100644 index 0000000..7d62ca0 --- /dev/null +++ b/sandbox/svelte/src/command-palette.svelte @@ -0,0 +1,173 @@ + + +
+ + + {#if view.api.open} + + {/if} +
+ + diff --git a/sandbox/svelte/src/main.ts b/sandbox/svelte/src/main.ts new file mode 100644 index 0000000..dec3111 --- /dev/null +++ b/sandbox/svelte/src/main.ts @@ -0,0 +1,7 @@ +import { mount } from 'svelte' +import App from './app.svelte' + +const target = document.getElementById('app') +if (!target) throw new Error('missing #app') + +mount(App, { target }) diff --git a/sandbox/svelte/svelte.config.js b/sandbox/svelte/svelte.config.js new file mode 100644 index 0000000..af5e7ee --- /dev/null +++ b/sandbox/svelte/svelte.config.js @@ -0,0 +1,6 @@ +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' + +export default { + // Lets ` + + +{#if view.api.isOpen} +
Dialog content
+{/if} +``` + +`useMachine` builds the machine and connector **once** (the first props read seeds context; later changes flow through `setProps`, not a rebuild), starts on mount, stops on unmount, and exposes the connector's stable snapshot through `view.api` — a `$state`-backed getter, so reading it in markup updates only on a real change. + +Props are passed as a **getter** (`() => props`), not a value. Svelte props are reactive bindings; the getter lets the bridge read their current form inside its effects — the Svelte analogue of React's per-render `props` argument. There is no `setProps` call in your component. + +### The three imports + +Those three values are where the dialog's behavior actually lives, and none of it is Svelte. You write them once, in a `./dialog` module, and they run unchanged on any platform: + +```ts +// dialog.ts: plain functions, no Svelte +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 markup 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 Svelte-specific piece: `createDialogConfig` is the machine definition, `connectDialog` is the snapshot-to-view-API mapping, and `dialogEffects` are the DOM listeners. The exact same three modules drive the [React](/libs/react) version. 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 in Svelte's idiom — lowercase `on*` event props, `tabindex`, `aria-*`: + +```ts +normalize(view.api.triggerProps) +// { onclick, 'aria-expanded', role, tabindex, ... } +``` + +| Binding | DOM prop | +| ----------------------------------------------- | ------------------------------------------------------------------- | +| `onPress` | `onclick` | +| `onPointerEnter/Leave/Move/Down` | `onpointerenter` / … (lowercase) | +| `onFocus` / `onBlur` / `onKeyDown` | `onfocus` / `onblur` / `onkeydown` | +| `describedBy` | `aria-describedby` | +| `labelledBy` | `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 logical names are renderer-neutral by design — `onPress` not `onclick`, `checked` not `aria-checked` — so the same `connect` output drives DOM, React, or React Native, each through its own `normalize`. `undefined` values are dropped. Unknown keys pass through unchanged. + +## `mergeProps`: consumer + component props + +When a consumer spreads their own props onto the same element the component controls: + +```svelte +