diff --git a/.changeset/vue-bindings.md b/.changeset/vue-bindings.md new file mode 100644 index 0000000..012e16c --- /dev/null +++ b/.changeset/vue-bindings.md @@ -0,0 +1,24 @@ +--- +'@dunky.dev/vue-state-machine': minor +--- + +Add the Vue 3 bindings package (`@dunky.dev/vue-state-machine`). It mirrors the React package's API one-for-one — `useMachine`, `useSelector`, `normalize`, `mergeProps`, and the `ComponentEffect` type — implemented with Vue's reactivity: `useMachine` builds the machine + connector once in `setup()`, runs `start`/`stop` on the mount lifecycle, pushes prop changes through `setProps`, runs each `ComponentEffect` as its own dep-keyed `watch`, and exposes the connector snapshot as a `ComputedRef`; `useSelector` returns a value-deduped readonly ref; `normalize` translates the agnostic bindings to Vue DOM/ARIA props; `mergeProps` merges consumer + component props with Vue's `class`/`style` conventions. + +```vue + + + +``` diff --git a/AGENTS.md b/AGENTS.md index 7d6c7c1..654cc5b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ repo. This file is the canonical entry point: read it first, every time. This is Dunky's state-machine monorepo: UI behavior authored once as plain TypeScript state machines (`packages/core`), rendered anywhere -through thin per-substrate targets (`react`, `native`, `opentui`), with +through thin per-substrate targets (`react`, `vue`, `native`, `opentui`), with a benchmark suite, per-substrate sandboxes, and the docs website alongside. @@ -24,12 +24,12 @@ editing files in that scope — it overrides anything here for that scope ## Scopes -| Scope | Path | What it is | -| --------- | ------------- | ---------------------------------------------------------------------------------- | -| Packages | `packages/**` | The core machine, substrate targets (react, native, opentui), and shared internals | -| Benchmark | `benchmark/` | Perf suite comparing against competitor libraries | -| Sandbox | `sandbox/` | Per-substrate demo apps for manual verification | -| Website | `website/` | The docs site | +| Scope | Path | What it is | +| --------- | ------------- | --------------------------------------------------------------------------------------- | +| Packages | `packages/**` | The core machine, substrate targets (react, vue, native, opentui), and shared internals | +| Benchmark | `benchmark/` | Perf suite comparing against competitor libraries | +| Sandbox | `sandbox/` | Per-substrate demo apps for manual verification | +| Website | `website/` | The docs site | Some changes are cross-scope: a change in `core/` may need follow-up in the targets, sandboxes, and docs — and vice versa. Check what else your diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 421a558..be5350d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -29,7 +29,7 @@ The host | bridged per target v +------------------------------------------------------------------------+ -| (react, native, opentui, …) | +| (react, vue, native, opentui, …) | | Runtime-specific bridge | | • lifecycle (build + start/stop) • normalize bindings -> props | | • selector subscription | @@ -58,7 +58,7 @@ actions. Nothing in `core/` knows that React or the DOM exists. substrate-agnostic event and attr vocabulary (`onPress`, `role`, …); `shared/utils` owns cross-target helpers (mergeProps, composeHandlers, positioning). -**`/`** is the substrate side — `react`, `native`, `opentui`, and any +**`/`** is the substrate side — `react`, `vue`, `native`, `opentui`, and any future renderer. Each target is the runtime bridge for one environment: the lifecycle bridge, the event normalization, and the selector subscription all live here. @@ -93,12 +93,12 @@ Zag, whose machines read props directly.) ## Project structure -| File / location | What it owns | -| --------------------------- | ------------------------------------------------------------- | -| `packages/core/` | State-machine engine (plain-mutation kernel) | -| `packages/shared/bindings/` | Substrate-agnostic event + attr vocabulary (onPress, role, …) | -| `packages/shared/utils/` | mergeProps, composeHandlers, positioning, memo | -| `packages//` | Hook + normalize per substrate (react, native, opentui, …) | +| File / location | What it owns | +| --------------------------- | --------------------------------------------------------------- | +| `packages/core/` | State-machine engine (plain-mutation kernel) | +| `packages/shared/bindings/` | Substrate-agnostic event + attr vocabulary (onPress, role, …) | +| `packages/shared/utils/` | mergeProps, composeHandlers, positioning, memo | +| `packages//` | Hook + normalize per substrate (react, vue, native, opentui, …) | ## The map @@ -121,7 +121,7 @@ shared/bindings substrate-agnostic event + attr vocabulary shared/utils cross-target, cross-component helpers +-- (composeHandlers, positioning, memo, mergeProps) - one substrate (react, native, opentui, …) + one substrate (react, vue, native, opentui, …) | runtime, hooks, and props translator +-- use-machine lifecycle bridge (build + start/stop + useSyncExternalStore) +-- use-selector fine-grained leaf subscription (O(readers)) @@ -136,7 +136,7 @@ Three package groups, three jobs: event + attr vocabulary; `shared/utils` owns agnostic helpers (positioning, prop merging, memoization). - **`/`** — _the substrate side_. One folder per renderer - (`react`, `native`, `opentui`). Owns its runtime bridge and its props translator. + (`react`, `vue`, `native`, `opentui`). Owns its runtime bridge and its props translator. ## The machine parts @@ -177,7 +177,7 @@ whether it needs props/platform or not: | Term | What it is | | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **host** | The agnostic core — `packages/core/*`. Declares what behavior is. | -| **target** | A substrate-specific bridge package and its render environment — `packages//*` (`react`, `native`, `opentui`, …). | +| **target** | A substrate-specific bridge package and its render environment — `packages//*` (`react`, `vue`, `native`, `opentui`, …). | | **machine** | A state-graph config consumed by `machine()`; returns a startable service. | | **connect** | A function returning the logical surface a view spreads onto elements. | | **bindings** | The substrate-agnostic event + attr vocabulary — lives in `shared/bindings`, consumed by every target's normalize. Each target's rename map and drop set are vocabulary-typed (`HandlerTargets`/`AttrTargets`, `HandlerKey`/`AttrKey`), so a typo'd or unknown key is a compile error. | diff --git a/packages/vue/LICENSE b/packages/vue/LICENSE new file mode 100644 index 0000000..08a9692 --- /dev/null +++ b/packages/vue/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/vue/README.md b/packages/vue/README.md new file mode 100644 index 0000000..93651af --- /dev/null +++ b/packages/vue/README.md @@ -0,0 +1,266 @@ +# `@dunky.dev/vue-state-machine` + +The **Vue 3 bindings** for [`@dunky.dev/state-machine`](../core/README.md). The +core engine is renderer-agnostic; this package is the thin Vue edge that drives +it: it builds the machine + connector, runs the Vue lifecycle, bridges the +connector's snapshot into Vue reactivity, 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 Vue. There are +four exports: one bridge composable (`useMachine`, which also runs the +component's substrate effects), one leaf-subscription composable (`useSelector`), +and two prop helpers (`normalize`, `mergeProps`) — plus the `ComponentEffect` +types. The export names and signatures match the +[React package](../react/README.md) one-for-one; only the implementation is Vue. + +--- + +## `useMachine` — the one bridge composable + +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 component's reactive props (a getter / ref also works) +) +``` + +It: + +- **builds once** — `machine(createConfig(props))` + `connector(service, connect, props)`. + Vue `setup()` runs once per instance, so these are plain consts (no memo). The + props read at setup seed context and the initial state; recreating would lose + state, so later prop changes flow through `setProps`, not a rebuild. +- **keeps props fresh** via `watch(props, p => connection.setProps(p))`. Vue's + `props` object keeps a stable identity and mutates its fields in place, so the + watch is `deep`; `setProps` value-dedups, so an equal-valued update doesn't + recompute the snapshot. +- **runs the lifecycle**: `service.start()` on `onMounted`, `service.stop()` on + `onBeforeUnmount`. 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 with + no teardown threading here. +- **runs the component's substrate effects** — one `watch` per `ComponentEffect` + entry, sourced on its named prop deps (see below). The generated `useApi` never + touches Vue directly; passing the effects list here is all it does. +- **drives Vue** via a `shallowRef` mirrored from the connector's stable, + memoized snapshot (updated on `connection.subscribe`), exposed as a `computed`. + Its identity only changes on a real change, so reads stay stable — no tearing, + no over-rendering. + +Returns `{ api, machine }`: `api` is a `ComputedRef` of the `connect()` output; +`machine` is the running service (also handed to `useSelector`). In a ` + + +``` + +The same call works in a JSX/TSX setup or a manual `h()` render function — `api` +is just a `computed` ref and `normalize(...)` a plain props object, so spread it +however your renderer spreads props. + +--- + +## `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`, a prevent-able +`onEscapeKeyDown` veto). That's the component's Vue-side _effect_. + +Each effect is a `[setup/teardown, depPropNames]` tuple (`ComponentEffect`). A +component declares one named const per effect and exports a flat list. **No Vue +in the component file** — the generated `useApi` owns the `watch`es: + +```ts +// a target component's effects.ts (illustrative — components live outside this repo) +import type { ComponentEffect } from '@dunky.dev/vue-state-machine' + +type TooltipEffect = ComponentEffect + +/** Escape-to-close (gated by closeOnEscape; honors the onEscapeKeyDown veto). */ +const trackEscape: TooltipEffect = [ + (machine, props) => { + if (!props.closeOnEscape) return + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'Escape') return + if (resolveEscape({ ...props, state: machine.state }).close) { + e.stopPropagation() + machine.send({ type: 'escape' }) + } + } + document.addEventListener('keydown', onKeyDown, true) + return () => document.removeEventListener('keydown', onKeyDown, true) + }, + ['closeOnEscape', 'onEscapeKeyDown'], // ← re-run only when these props change +] + +export const tooltipEffects = [trackEscape] +``` + +`useMachine` runs the list — **one `watch` per entry**, each sourced on that +entry's named props (so the component file never touches Vue): + +```ts +// inside useMachine, for each [fn, deps] of effects: +// watch( +// deps.map(k => () => props[k]), // an ARRAY OF GETTERS, value-compared per entry +// (_n, _p, onCleanup) => { const off = fn(machine, props); if (off) onCleanup(off) }, +// { immediate: true }, +// ) +``` + +**Why a list of per-effect deps** (not one combined set): each effect re-runs only +when _its own_ deps change — toggling `focusTrap` doesn't churn the Escape +listener. **Why an array of getters** (not one getter returning an array): Vue +value-compares each entry and each getter touches only its own prop key, so a +change to a _non-dep_ prop never re-runs the effect — a single getter returning a +fresh array would fire on every prop change, since its identity always differs. +`machine` is always an implicit dep. + +> The agnostic _decision_ (gate + veto) lives in the core component's resolver +> (`resolveEscape`); only the _transport_ (the DOM listener) is here. The machine +> just receives a plain `escape` event. This is the Vue counterpart of a core +> `effect` — but one that may read props and touch the DOM, which a core effect +> can't. + +--- + +## `useSelector` — fine-grained leaf subscription + +For a leaf component that should update only when **one slice** of the machine +changes (not on every machine change) — the `O(readers)` path that matters at +scale (e.g. thousands of menu items, each waking only when _its own_ highlighted +state flips): + +```ts +const open = useSelector(machine, () => machine.matches('open')) +const isHL = useSelector(machine, () => machine.context.highlightedValue === value) +``` + +It returns a **readonly ref**. The selector reads from the machine directly; the +ref 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 value doesn't bump the ref: + +```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 machine `Selection` and feeds its +value-deduped notifications into a `shallowRef`, disposing on scope teardown. + +**`useMachine` vs. `useSelector`.** `useMachine` is the per-instance bridge: it +drives the whole component off the connector's coarse snapshot (the connector +already memoizes, so it only changes on a real change). `useSelector` is for +_within_ that tree — a child that wants to update on just one field, decoupled +from the parent's snapshot. 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 Vue's `onXxx` listener form, so the same `connect` can target DOM, React, +or canvas — each via its own `normalize`: + +```vue + +``` + +The mapping mirrors React's, with the Vue-appropriate names: + +| Agnostic binding | Vue DOM prop | +| ----------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| `onPress` | `onClick` | +| `onValueChange` | `onInput` (wrapped → `ChangePayload`; fires live, like React's `onChange`) | +| `onContextMenu` / `onDoublePress` | `onContextmenu` / `onDblclick` | +| `onWheel` / `onScroll` / `onScrollEnd` | `onWheel` / `onScroll` / `onScrollend` (wrapped → `WheelPayload` / `ScrollPayload`) | +| `onPointerEnter/Leave/Move/Down/Up/Cancel` | `onPointerenter` / `onPointerleave` / … (Vue listener casing) | +| `onFocus` / `onBlur` / `onKeyDown` / `onKeyUp` | `onFocus` / `onBlur` / `onKeydown` / `onKeyup` | +| `describedBy` / `labelledBy` / `controls` / `label` | `aria-describedby` / `aria-labelledby` / `aria-controls` / `aria-label` | +| `expanded` / `selected` / `disabled` / `hidden` / `modal` | `aria-expanded` / `aria-selected` / `aria-disabled` / `aria-hidden` / `aria-modal` | +| `checked` / `pressed` / `current` / `busy` / `invalid` / `required` / `readOnly` | matching `aria-*` (value untransformed) | +| `valueMin/Max/Now/Text` | `aria-valuemin` / `-valuemax` / `-valuenow` / `-valuetext` | +| `orientation` / `sort` / `autoComplete` / `level` / `posInSet` / `setSize` / grid `col*`/`row*` | the matching `aria-*` attr | +| `activeDescendant` / `errorMessage` / `owns` / `hasPopup` | `aria-activedescendant` / `-errormessage` / `-owns` / `-haspopup` | +| `live` / `atomic` | `aria-live` / `aria-atomic` | +| `focusable` | `tabindex` (`true → 0`, `false → -1`) | +| `role` / `id` | `role` / `id` | + +A few handlers whose agnostic payload differs from the raw event +(`onValueChange`/`onWheel`/`onScroll`/`onScrollEnd`) are wrapped so the consumer +receives the agnostic payload, not the DOM event. `undefined` values are dropped, +and any key not in the map passes through unchanged — so a binding the renderer +already understands needs no entry. +[Check out the full mapping here](./src/normalize.ts). + +--- + +## `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, with Vue's `class`/`style` conventions: + +```vue + +``` + +- **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** (a clean veto). (A key + counts as a handler when it's `on` + an uppercase letter.) +- **`style` is merged, not overwritten.** If both sides set `style`, the result is + the Vue array form `[consumerStyle, libraryStyle]` (later entry wins on + conflicting keys). If only one side sets it, that one is kept. +- **`class` is concatenated** with a single space and trimmed at the edges, when + both sides are strings. (Vue's `class` also accepts arrays/objects; those fall + through to library-wins.) +- **Everything else: library wins.** A plain attr the component sets (`id`, `role`, + `aria-*`) overrides the consumer's — the component owns its semantics. + +If the consumer passes no props, the library props are returned as-is. + +--- + +## API + +| Export | What it is | +| --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| `useMachine(config, connect, effects, props)` | the bridge composable — build once + lifecycle + run the component effects + reactive snapshot; returns `{ api, machine }` | +| `useSelector(machine, selector, isEqual?)` | fine-grained subscription to a derived slice as a readonly ref (`O(readers)`) | +| `normalize(bindings)` | agnostic bindings → Vue DOM/ARIA props | +| `mergeProps(consumer, library)` | merge consumer + component props (handlers chained w/ `defaultPrevented` veto; `class`/`style` merged; else library wins) | +| `ComponentEffect` | `[ (machine, props) => cleanup, (keyof P)[] ]` — one substrate effect + its prop deps | +| `Bindings` | `Record` — the loose shape `normalize` accepts | diff --git a/packages/vue/package.json b/packages/vue/package.json new file mode 100644 index 0000000..6bb14b0 --- /dev/null +++ b/packages/vue/package.json @@ -0,0 +1,49 @@ +{ + "name": "@dunky.dev/vue-state-machine", + "version": "0.0.0", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/dunky-dev/state-machine.git", + "directory": "packages/vue" + }, + "files": [ + "dist", + "src" + ], + "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": { + "@dunky.dev/state-machine-bindings": "workspace:*", + "@vue/test-utils": "^2.4.6", + "jsdom": "^29.1.1", + "vue": "^3.5.13" + }, + "peerDependencies": { + "vue": "^3" + } +} diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts new file mode 100644 index 0000000..eac700c --- /dev/null +++ b/packages/vue/src/index.ts @@ -0,0 +1,4 @@ +export { useMachine, type ComponentEffect } from './use-machine' +export { useSelector } from './use-selector' +export { normalize, type Bindings } from './normalize' +export { mergeProps } from './merge-props' diff --git a/packages/vue/src/merge-props.ts b/packages/vue/src/merge-props.ts new file mode 100644 index 0000000..2b47060 --- /dev/null +++ b/packages/vue/src/merge-props.ts @@ -0,0 +1,19 @@ +import { mergeProps as baseMergeProps } from '@dunky.dev/state-machine-utils' + +type AnyProps = Record + +export function mergeProps(consumer: AnyProps | undefined, library: AnyProps): AnyProps { + const merged = baseMergeProps(consumer, library) + if (!consumer) return merged + + // Vue's array style binding — later entry wins on conflicting keys. + if (consumer.style != null && library.style != null) { + merged.style = [consumer.style, library.style] + } + // Non-string class shapes (array/object) fall through to the base's library-wins. + if (typeof consumer.class === 'string' && typeof library.class === 'string') { + merged.class = `${consumer.class} ${library.class}`.trim() + } + + return merged +} diff --git a/packages/vue/src/normalize.ts b/packages/vue/src/normalize.ts new file mode 100644 index 0000000..dd60855 --- /dev/null +++ b/packages/vue/src/normalize.ts @@ -0,0 +1,167 @@ +// Translate the machine layer's logical surface to Vue DOM props (the +// `h()` / v-bind-spread shape). Multi-word DOM events keep only the leading +// capital (`onPointerenter`): Vue hyphenates the camel tail when deriving the +// event name, so `onPointerEnter` would listen to `pointer-enter`. +import type { + AttrKey, + AttrTargets, + HandlerKey, + HandlerTargets, +} from '@dunky.dev/state-machine-bindings' + +export const HANDLER_MAP: HandlerTargets = { + onPress: 'onClick', + onPointerEnter: 'onPointerenter', + onPointerLeave: 'onPointerleave', + onPointerMove: 'onPointermove', + onPointerDown: 'onPointerdown', + onPointerUp: 'onPointerup', + onPointerCancel: 'onPointercancel', + onFocus: 'onFocus', + onBlur: 'onBlur', + onKeyDown: 'onKeydown', + onKeyUp: 'onKeyup', + // onValueChange/onWheel/onScroll/onScrollEnd also have their argument translated (see PAYLOAD_ADAPTERS). + // `input`, not `change`: live per-keystroke, matching React's onChange semantics. + 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.) + +// 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 + 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, + } +} + +export const ATTR_MAP: AttrTargets = { + 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 as HandlerKey] + if (handler) { + const adapt = PAYLOAD_ADAPTERS[key] + out[handler] = adapt ? (e: AnyEvent) => (value as (p: unknown) => void)(adapt(e)) : value + continue + } + + const attr = ATTR_MAP[key as AttrKey] + if (attr) { + if (key === 'focusable') { + out[attr] = value ? 0 : -1 + } else { + out[attr] = value + } + continue + } + + out[key] = value + } + return out +} diff --git a/packages/vue/src/use-machine.ts b/packages/vue/src/use-machine.ts new file mode 100644 index 0000000..71301c2 --- /dev/null +++ b/packages/vue/src/use-machine.ts @@ -0,0 +1,88 @@ +import { + computed, + onBeforeUnmount, + onMounted, + shallowRef, + toValue, + watch, + type ComputedRef, + type MaybeRefOrGetter, +} from 'vue' +import { connector, machine, type Connect, type TransitionConfig } from '@dunky.dev/state-machine' + +/** + * A substrate-specific effect: a setup/teardown function plus the prop names it depends on. + * Deps are prop key names (typed, so typos compile-error); the bridge maps them to watch sources. + * + * const escape: ComponentEffect = [ + * (machine, props) => { addEventListener(…); return () => removeEventListener(…) }, + * ['closeOnEscape'], + * ] + * + * One watcher runs per entry, keyed on its named deps — no rules-of-hooks here, + * but keep the list a stable module constant so it reads the same as the other bindings. + */ +export type ComponentEffect = [ + effect: (machine: Machine, props: Props) => (() => void) | void, + deps: (keyof Props)[], +] + +/** + * The generic Vue bridge. Builds the machine once from the setup-time props, + * keeps props fresh via setProps, runs substrate effects, and drives Vue via a + * shallowRef over the connector's snapshot. + */ +export function useMachine< + State extends string, + Context extends object, + Event extends { type: string }, + Props extends object, + Api, + Computed = Record, +>( + createConfig: (props: Props) => TransitionConfig, + connect: Connect, + effects: ComponentEffect>, Props>[], + props: MaybeRefOrGetter, +): { api: ComputedRef; machine: ReturnType> } { + // A fresh unwrapped copy per read: a component's `props` proxy keeps a stable + // identity and mutates in place, so handing it to setProps would make the + // connector's dedup see "no change" forever. + const read = (): Props => ({ ...toValue(props) }) + + const service = machine(createConfig(read())) + const connection = connector(service, connect, read()) + + // Mirror the connector's memoized snapshot into a shallowRef on every wake — + // identity only changes on a real change, so reads stay stable. + const snapshot = shallowRef(connection.snapshot) + const off = connection.subscribe(() => { + snapshot.value = connection.snapshot + }) + onBeforeUnmount(off) + + // `deep` because the props proxy mutates its fields in place. + watch(read, next => connection.setProps(next), { deep: true }) + + // Lifecycle: start on mount, stop on unmount. Reactions follow the machine's + // lifecycle automatically. Not calling connection.destroy() — connector and + // machine share the component's lifetime, so they're GC'd together. + onMounted(() => service.start()) + onBeforeUnmount(() => service.stop()) + + // One watcher per effect entry. The source is an ARRAY OF GETTERS, not one + // getter returning an array: Vue value-compares each entry, so a change to a + // non-dep prop never re-runs the effect (a fresh array's identity always differs). + for (const [fn, deps] of effects) { + watch( + deps.map(k => () => toValue(props)[k]), + (_next, _prev, onCleanup) => { + const cleanup = fn(service, read()) + if (cleanup) onCleanup(cleanup) + }, + { immediate: true }, + ) + } + + return { api: computed(() => snapshot.value), machine: service } +} diff --git a/packages/vue/src/use-selector.ts b/packages/vue/src/use-selector.ts new file mode 100644 index 0000000..fbb65c0 --- /dev/null +++ b/packages/vue/src/use-selector.ts @@ -0,0 +1,37 @@ +import { onScopeDispose, readonly, shallowRef, type DeepReadonly, type Ref } from 'vue' +import type { EqualityFn, Machine } from '@dunky.dev/state-machine' + +/** + * Fine-grained subscription for leaf components — the ref updates only when the selected VALUE changes. + * + * const open = useSelector(m, () => m.matches('open')) + * const isHL = useSelector(m, () => m.context.highlightedValue === value) + * + * Equality is `Object.is` by default; pass `isEqual` for object selections so a + * re-derived equal object doesn't bump the ref. Returns a readonly ref — the + * selection is derived state, not writable. + */ +export function useSelector< + State extends string, + Context extends object, + T, + Event extends { type: string } = { type: string }, + Computed = Record, +>( + machine: Machine, + selector: () => T, + isEqual?: EqualityFn, +): Readonly>> { + // Seed with the current value so the first read is correct before any change fires. + const selection = machine.select(selector) + const value = shallowRef(selection.value) as Ref + + const off = selection.subscribe(next => { + value.value = next + }, isEqual) + + // Dispose with the surrounding effect scope (component unmount or an explicit effectScope). + onScopeDispose(off) + + return readonly(value) +} diff --git a/packages/vue/tests/merge-props.test.ts b/packages/vue/tests/merge-props.test.ts new file mode 100644 index 0000000..ab2f156 --- /dev/null +++ b/packages/vue/tests/merge-props.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest' +import { mergeProps } from '@dunky.dev/vue-state-machine' + +describe('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('inherits library-wins on plain attrs', () => { + const out = mergeProps({ id: 'consumer' }, { id: 'lib' }) + expect(out.id).toBe('lib') + }) + + it('wraps overlapping styles into an array — consumer first, library second', () => { + const consumerStyle = { color: 'red' } + const libStyle = { color: 'blue' } + const out = mergeProps({ style: consumerStyle }, { style: libStyle }) + expect(out.style).toEqual([consumerStyle, libStyle]) + }) + + 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 classes 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 ' }) + // `${' a '} ${' b '}` → ' a b ' → trim → 'a b' + // (2 trailing + 1 separator + 2 leading = 5 inner spaces) + expect(out.class).toBe('a b') + }) + + it('non-string class falls back to library-wins (no concat)', () => { + // consumer.class is unset; library's wins as a plain key. + const out = mergeProps({ id: 'a' }, { class: 'x' }) + expect(out.class).toBe('x') + }) +}) diff --git a/packages/vue/tests/normalize.test.ts b/packages/vue/tests/normalize.test.ts new file mode 100644 index 0000000..bf5ba9d --- /dev/null +++ b/packages/vue/tests/normalize.test.ts @@ -0,0 +1,313 @@ +/** + * Vue 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 Vue's `onXxx` listener form. 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/vue-state-machine' +import { ATTR_MAP, HANDLER_MAP } from '../src/normalize' +import { describeVocabularyAccounting } from '../../shared/bindings/tests/fixtures/vocabulary-accounting' + +describe('vue 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 Vue pointer listeners', () => { + const onPointerEnter = vi.fn() + const onPointerLeave = vi.fn() + const onPointerMove = vi.fn() + const onPointerDown = vi.fn() + const onPointerUp = vi.fn() + const onPointerCancel = vi.fn() + expect( + normalize({ + onPointerEnter, + onPointerLeave, + onPointerMove, + onPointerDown, + onPointerUp, + onPointerCancel, + }), + ).toEqual({ + onPointerenter: onPointerEnter, + onPointerleave: onPointerLeave, + onPointermove: onPointerMove, + onPointerdown: onPointerDown, + onPointerup: onPointerUp, + onPointercancel: onPointerCancel, + }) + }) + + 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 → onKeydown / onKeyup)', () => { + const onKeyDown = vi.fn() + const onKeyUp = vi.fn() + expect(normalize({ onKeyDown, onKeyUp })).toEqual({ onKeydown: onKeyDown, onKeyup: onKeyUp }) + }) +}) + +describe('vue 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('vue 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('vue normalize — expanded handler surface', () => { + it('maps each value-change / interaction handler to its Vue listener 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('vue 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' })) + }) +}) + +describeVocabularyAccounting('vue', normalize, { map: HANDLER_MAP }, { map: ATTR_MAP }) diff --git a/packages/vue/tests/use-machine.test.ts b/packages/vue/tests/use-machine.test.ts new file mode 100644 index 0000000..743aa65 --- /dev/null +++ b/packages/vue/tests/use-machine.test.ts @@ -0,0 +1,220 @@ +// @vitest-environment jsdom +// `useMachine` — the Vue bridge composable: build once, start/stop with the +// mount lifecycle, keep props fresh, run dep-keyed effects, reactive snapshot. +import { defineComponent, h, nextTick, type PropType } from 'vue' +import { mount } from '@vue/test-utils' +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 ComponentEffect, useMachine } from '@dunky.dev/vue-state-machine' + +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: ComponentEffect[] = [] + +afterEach(() => vi.clearAllMocks()) + +function harness( + props: ToggleProps, + effects: ComponentEffect[] = noEffects, +) { + const sink: { api?: ToggleApi; machine?: ToggleMachine } = {} + const Comp = defineComponent({ + props: { + label: { type: String, required: false }, + onOpenChange: { type: Function as PropType<(open: boolean) => void>, required: false }, + }, + setup(p) { + const { api, machine: m } = useMachine(createConfig(), connect, effects, p as ToggleProps) + sink.machine = m + return () => { + sink.api = api.value + return h('div', { 'data-testid': 'label' }, api.value.label ?? '∅') + } + }, + }) + return { sink, Comp, props } +} + +describe('useMachine — lifecycle', () => { + it('returns { api, machine }: api is the connect() output, machine is the running service', () => { + const { sink, Comp, props } = harness({ label: 'hi' }) + mount(Comp, { props }) + expect(sink.api).toMatchObject({ open: false, label: 'hi', count: 0 }) + expect(typeof sink.api!.toggle).toBe('function') + expect(typeof sink.machine!.send).toBe('function') + }) + + it('starts the machine on mount and stops it on unmount', async () => { + const { sink, Comp, props } = harness({}) + const wrapper = mount(Comp, { props }) + sink.api!.toggle() + await nextTick() + expect(sink.api!.open).toBe(true) + expect(() => wrapper.unmount()).not.toThrow() + }) + + it('reflects snapshot changes in the reactive api', async () => { + const { sink, Comp, props } = harness({}) + mount(Comp, { props }) + sink.api!.toggle() + await nextTick() + expect(sink.api!.open).toBe(true) + expect(sink.api!.count).toBe(1) + }) +}) + +describe('useMachine — build once', () => { + it('builds the machine ONCE: state survives prop changes (no rebuild)', async () => { + const { sink, Comp } = harness({ label: 'a' }) + const wrapper = mount(Comp, { props: { label: 'a' } }) + sink.api!.toggle() // → open, count 1 + await nextTick() + expect(sink.api!.open).toBe(true) + + await wrapper.setProps({ label: 'b' }) // prop change must NOT rebuild/reset state + expect(sink.api!.open).toBe(true) + expect(sink.api!.count).toBe(1) + expect(sink.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)', async () => { + const { sink, Comp } = harness({ label: 'first' }) + const wrapper = mount(Comp, { props: { label: 'first' } }) + expect(sink.api!.label).toBe('first') + await wrapper.setProps({ label: 'second' }) + expect(sink.api!.label).toBe('second') + }) + + it('value-dedups: an equal-valued prop update does not churn the snapshot', async () => { + const { sink, Comp } = harness({ label: 'x' }) + const wrapper = mount(Comp, { props: { label: 'x' } }) + const snap1 = sink.api + await wrapper.setProps({ label: 'x' }) // same value + expect(sink.api).toBe(snap1) // stable identity → no recompute + }) +}) + +describe('useMachine — reactions follow the machine lifecycle', () => { + it('fires the connect reaction (onOpenChange) when state flips while mounted', async () => { + const onOpenChange = vi.fn() + const { sink, Comp } = harness({ onOpenChange }) + mount(Comp, { props: { onOpenChange } }) + expect(onOpenChange).not.toHaveBeenCalled() // not on subscribe + sink.api!.toggle() + await nextTick() + expect(onOpenChange).toHaveBeenCalledWith(true) + sink.api!.toggle() + await nextTick() + expect(onOpenChange).toHaveBeenCalledWith(false) + }) +}) + +describe('useMachine — component effects', () => { + it('runs each ComponentEffect as its own effect (setup on mount, cleanup on unmount)', () => { + const setup = vi.fn() + const cleanup = vi.fn() + const effects: ComponentEffect[] = [[() => (setup(), cleanup), []]] + const { Comp } = harness({}, effects) + const wrapper = mount(Comp, { props: {} }) + expect(setup).toHaveBeenCalledOnce() + expect(cleanup).not.toHaveBeenCalled() + wrapper.unmount() + expect(cleanup).toHaveBeenCalledOnce() + }) + + it('re-runs an effect ONLY when one of its named prop deps changes', async () => { + const fn = vi.fn(() => () => {}) + const effects: ComponentEffect[] = [[fn, ['label']]] + const { Comp } = harness({ label: 'a' }, effects) + const wrapper = mount(Comp, { props: { label: 'a' } }) + expect(fn).toHaveBeenCalledTimes(1) + + await wrapper.setProps({ label: 'a' }) // dep unchanged → no re-run + expect(fn).toHaveBeenCalledTimes(1) + + await wrapper.setProps({ label: 'b' }) // dep changed → re-run + expect(fn).toHaveBeenCalledTimes(2) + }) + + it('does NOT re-run an effect when a NON-dep prop changes', async () => { + const fn = vi.fn(() => () => {}) + const effects: ComponentEffect[] = [[fn, ['label']]] + const { Comp } = harness({ label: 'a' }, effects) + const wrapper = mount(Comp, { props: { label: 'a', onOpenChange: () => {} } }) + expect(fn).toHaveBeenCalledTimes(1) + await wrapper.setProps({ label: 'a', onOpenChange: () => {} }) // new fn identity, label same + expect(fn).toHaveBeenCalledTimes(1) + }) + + it('receives (machine, props) and can read live machine state', () => { + let seenOpen: boolean | undefined + const effects: ComponentEffect[] = [ + [ + m => { + seenOpen = m.matches('open') + }, + [], + ], + ] + const { Comp } = harness({}, effects) + mount(Comp, { props: {} }) + expect(seenOpen).toBe(false) + }) +}) diff --git a/packages/vue/tests/use-selector.test.ts b/packages/vue/tests/use-selector.test.ts new file mode 100644 index 0000000..57b5151 --- /dev/null +++ b/packages/vue/tests/use-selector.test.ts @@ -0,0 +1,207 @@ +// @vitest-environment jsdom +// `useSelector` — fine-grained leaf subscription: the ref updates only when the +// selected value changes, so a machine change wakes only the leaves it touched. +import { defineComponent, h, nextTick } from 'vue' +import { mount } from '@vue/test-utils' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act as write, machine, type TransitionConfig } from '@dunky.dev/state-machine' +import { useSelector } from '@dunky.dev/vue-state-machine' + +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 updates', () => { + it('reads the machine directly and reflects the selected value', async () => { + const m = makeMachine() + let seen: number | undefined + const Comp = defineComponent({ + setup() { + const a = useSelector(m, () => m.context.a) + return () => { + seen = a.value + return null + } + }, + }) + mount(Comp) + expect(seen).toBe(0) + m.send({ type: 'incA' }) + await nextTick() + expect(seen).toBe(1) + }) + + it('re-renders ONLY when the selected slice changes (not on every machine change)', async () => { + const m = makeMachine() + const renders = vi.fn() + const Comp = defineComponent({ + setup() { + const a = useSelector(m, () => m.context.a) + return () => { + renders() + return h('span', a.value) + } + }, + }) + mount(Comp) + expect(renders).toHaveBeenCalledTimes(1) + + m.send({ type: 'incB' }) // selects `a`, `b` changed → no re-render + await nextTick() + expect(renders).toHaveBeenCalledTimes(1) + + m.send({ type: 'noop' }) // nothing changed → no re-render + await nextTick() + expect(renders).toHaveBeenCalledTimes(1) + + m.send({ type: 'incA' }) // `a` changed → re-render + await nextTick() + expect(renders).toHaveBeenCalledTimes(2) + }) + + it('defaults to Object.is equality (a re-derived equal value does not re-render)', async () => { + const m = makeMachine() + const renders = vi.fn() + const Comp = defineComponent({ + setup() { + const gt0 = useSelector(m, () => m.context.a > 0) // boolean: flips only at 0→1 + return () => { + renders() + return h('span', String(gt0.value)) + } + }, + }) + mount(Comp) + expect(renders).toHaveBeenCalledTimes(1) + m.send({ type: 'incA' }) // false → true (re-render) + await nextTick() + expect(renders).toHaveBeenCalledTimes(2) + m.send({ type: 'incA' }) // true → true (no re-render) + await nextTick() + expect(renders).toHaveBeenCalledTimes(2) + }) +}) + +describe('useSelector — custom isEqual for object selections', () => { + it('uses the provided isEqual to dedup an object selection', async () => { + const m = makeMachine() + const renders = vi.fn() + const Comp = defineComponent({ + setup() { + const sel = useSelector( + m, + () => ({ a: m.context.a }), + (x, y) => x.a === y.a, + ) + return () => { + renders() + return h('span', String(sel.value.a)) + } + }, + }) + mount(Comp) + expect(renders).toHaveBeenCalledTimes(1) + + m.send({ type: 'incB' }) // selected {a} unchanged → no re-render + await nextTick() + expect(renders).toHaveBeenCalledTimes(1) + + m.send({ type: 'incA' }) // {a} changed → re-render + await nextTick() + expect(renders).toHaveBeenCalledTimes(2) + }) +}) + +describe('useSelector — fresh selector closure', () => { + it('evaluates the selector against current machine state on each change', async () => { + const m = makeMachine() + let seen: boolean | undefined + const target = 1 + const Comp = defineComponent({ + setup() { + const hit = useSelector(m, () => m.context.a === target) + return () => { + seen = hit.value + return null + } + }, + }) + mount(Comp) + expect(seen).toBe(false) // a=0 === 1 is false + + m.send({ type: 'incA' }) // a → 1; compares to target 1 + await nextTick() + expect(seen).toBe(true) + }) +}) + +describe('useSelector — O(readers): a slice change wakes only its reader', () => { + it('re-renders only the leaf whose selected slice changed', async () => { + const m = makeMachine() + const aRenders = vi.fn() + const bRenders = vi.fn() + const LeafA = defineComponent({ + setup() { + const a = useSelector(m, () => m.context.a) + return () => { + aRenders() + return h('span', a.value) + } + }, + }) + const LeafB = defineComponent({ + setup() { + const b = useSelector(m, () => m.context.b) + return () => { + bRenders() + return h('span', b.value) + } + }, + }) + const Parent = defineComponent({ + setup() { + return () => h('div', [h(LeafA), h(LeafB)]) + }, + }) + mount(Parent) + expect(aRenders).toHaveBeenCalledTimes(1) + expect(bRenders).toHaveBeenCalledTimes(1) + + m.send({ type: 'incA' }) // only LeafA's slice changed + await nextTick() + expect(aRenders).toHaveBeenCalledTimes(2) + expect(bRenders).toHaveBeenCalledTimes(1) + + m.send({ type: 'incB' }) // only LeafB's slice changed + await nextTick() + expect(aRenders).toHaveBeenCalledTimes(2) + expect(bRenders).toHaveBeenCalledTimes(2) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24ef84d..285feb3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -189,6 +189,28 @@ importers: packages/shared/utils: {} + packages/vue: + dependencies: + '@dunky.dev/state-machine': + specifier: workspace:* + version: link:../core + '@dunky.dev/state-machine-utils': + specifier: workspace:* + version: link:../shared/utils + devDependencies: + '@dunky.dev/state-machine-bindings': + specifier: workspace:* + version: link:../shared/bindings + '@vue/test-utils': + specifier: ^2.4.6 + version: 2.4.11(@vue/compiler-dom@3.5.38)(@vue/server-renderer@3.5.38(vue@3.5.38(typescript@6.0.3)))(vue@3.5.38(typescript@6.0.3)) + jsdom: + specifier: ^29.1.1 + version: 29.1.1 + vue: + specifier: ^3.5.13 + version: 3.5.38(typescript@6.0.3) + sandbox/native: dependencies: '@dunky.dev/native-state-machine': @@ -312,6 +334,37 @@ importers: specifier: workspace:^ version: link:../../packages/shared/bindings + sandbox/vue: + 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-utils': + specifier: workspace:^ + version: link:../../packages/shared/utils + '@dunky.dev/vue-state-machine': + specifier: workspace:^ + version: link:../../packages/vue + '@sandbox/cmdk-core': + specifier: workspace:^ + version: link:../shared + vue: + specifier: ^3.5.13 + version: 3.5.38(typescript@6.0.3) + devDependencies: + '@vitejs/plugin-vue': + specifier: ^6.0.0 + version: 6.0.7(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))(vue@3.5.38(typescript@6.0.3)) + 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) + vue-tsc: + specifier: ^2.2.0 + version: 2.2.12(typescript@6.0.3) + website: dependencies: '@astrojs/starlight': @@ -322,10 +375,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)(vue@3.5.38(typescript@6.0.3)) '@vercel/speed-insights': specifier: ^2.0.0 - version: 2.0.0(react@19.2.6) + version: 2.0.0(react@19.2.6)(vue@3.5.38(typescript@6.0.3)) devDependencies: '@astrojs/mdx': specifier: ^6.0.3 @@ -1672,6 +1725,10 @@ packages: '@types/node': optional: true + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + '@isaacs/fs-minipass@4.0.1': resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} @@ -1764,6 +1821,9 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@one-ini/wasm@0.1.1': + resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} + '@opentui/core-darwin-arm64@0.4.1': resolution: {integrity: sha512-ocs73hj9n0zLArOTpUWIXCWU6ERThG+3wQzO78EvfaR4hb5FRrDHGKWTzXpr6ukSKsUtKdztK5XYTPsJ5e3vww==} cpu: [arm64] @@ -2342,6 +2402,10 @@ packages: cpu: [x64] os: [win32] + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@publint/pack@0.1.4': resolution: {integrity: sha512-HDVTWq3H0uTXiU0eeSQntcVUTPP3GamzeXI41+x7uU9J65JgWQh3qWZHblR1i0npXfFtF+mxBiU2nJH8znxWnQ==} engines: {node: '>=18'} @@ -3170,6 +3234,13 @@ packages: babel-plugin-react-compiler: optional: true + '@vitejs/plugin-vue@6.0.7': + resolution: {integrity: sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + vue: ^3.2.25 + '@vitest/expect@4.1.7': resolution: {integrity: sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==} @@ -3199,6 +3270,65 @@ packages: '@vitest/utils@4.1.7': resolution: {integrity: sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==} + '@volar/language-core@2.4.15': + resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==} + + '@volar/source-map@2.4.15': + resolution: {integrity: sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==} + + '@volar/typescript@2.4.15': + resolution: {integrity: sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==} + + '@vue/compiler-core@3.5.38': + resolution: {integrity: sha512-s99aGxWYig9ErHbct27KXEGhrBYlRI6c4MwAgXErOAbX9xiW37/uMa+XUDO69zLz83dng8UUZ70CTOJrLrYrEQ==} + + '@vue/compiler-dom@3.5.38': + resolution: {integrity: sha512-JTqp25l8aFfJYF7/KmsXZjAxJz7T+SjmTJLoXVjHtc2BrSgSiW2n9Aem/cWq1OPe68A8JL06B3eVdhlP0H4TVw==} + + '@vue/compiler-sfc@3.5.38': + resolution: {integrity: sha512-DuA2GiZawSEW442iw/9+Fkol8hTgb4Ke5KkhmSry65QA7YuyMbIdy8p0XZRMvNwJdgRz307W8g1CSzdvS4nuNg==} + + '@vue/compiler-ssr@3.5.38': + resolution: {integrity: sha512-7s+W5Gc42FGxZMcuwl8H5B29T8BJPMdBT7KHFE+BbAuZ/iTEdTtv7z2XiMjiaUUw4w3ZcCEdHs36RuYJ2VA7bA==} + + '@vue/compiler-vue2@2.7.16': + resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==} + + '@vue/language-core@2.2.12': + resolution: {integrity: sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/reactivity@3.5.38': + resolution: {integrity: sha512-pG6LV/NDNRbKizcUjFFLAfjaL8mcv4DmR9avNcUw2gDHBzZneuS2TWCmp633ynzxz9YYKNeEPK2I8Wraqy2HUQ==} + + '@vue/runtime-core@3.5.38': + resolution: {integrity: sha512-iyW8WVfF1CpCXxncZY5Ei6rSd6oZr5DgEom//fUjRBRl56AXPD+s9ATvukRt77ZFTuYlnVA1bxY+dJB94tWVYw==} + + '@vue/runtime-dom@3.5.38': + resolution: {integrity: sha512-apX2wt9sdfDshS+a2xueFZLVpt0GkRJZSoPmrW/SA4yzXTznhfcMVW59gr7h4YQeY0vJhdJkk2rsIDwgfFgC5A==} + + '@vue/server-renderer@3.5.38': + resolution: {integrity: sha512-vue8vbf2QlV4quHqzwmJy6dWfmRhP1J8l4wtZg60CL6VoKqcPY2oe7may3+1d9qfpedjK5PRLFqd5k3Isj9mUw==} + peerDependencies: + vue: 3.5.38 + + '@vue/shared@3.5.38': + resolution: {integrity: sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==} + + '@vue/test-utils@2.4.11': + resolution: {integrity: sha512-GDqaqZsA6m2E5vNzej0aYiIb6BX8xV9pNSbbbXKOfEYwg7ZNblVX8suyqmUBThq8VIrgAJNxn+z72hVtUeiWHA==} + peerDependencies: + '@vue/compiler-dom': 3.x + '@vue/server-renderer': 3.x + vue: 3.x + peerDependenciesMeta: + '@vue/server-renderer': + optional: true + '@xmldom/xmldom@0.8.13': resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} engines: {node: '>=10.0.0'} @@ -3242,6 +3372,10 @@ packages: '@zag-js/vanilla@1.41.2': resolution: {integrity: sha512-5eGvJ6VqW3byU2L/u2hwrwYV0kBNtisuGrxF2HXS3o/4CVqbxRVw43HWXRi7st3eB6sPFNSFqpxx5lPfzQUCmg==} + abbrev@2.0.0: + resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -3268,6 +3402,9 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + alien-signals@1.0.13: + resolution: {integrity: sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==} + anser@1.4.10: resolution: {integrity: sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==} @@ -3662,6 +3799,10 @@ packages: comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + commander@11.1.0: resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} engines: {node: '>=16'} @@ -3696,6 +3837,9 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + config-chain@1.1.13: + resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} + connect@3.7.0: resolution: {integrity: sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==} engines: {node: '>= 0.10.0'} @@ -3757,6 +3901,9 @@ packages: dataloader@1.4.0: resolution: {integrity: sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw==} + de-indent@1.0.2: + resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} + debug@2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} peerDependencies: @@ -3892,6 +4039,14 @@ packages: oxc-resolver: optional: true + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + editorconfig@1.0.7: + resolution: {integrity: sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==} + engines: {node: '>=14'} + hasBin: true + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -3904,6 +4059,9 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + empathic@2.0.1: resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==} engines: {node: '>=14'} @@ -3932,6 +4090,10 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + entities@8.0.0: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} @@ -4186,6 +4348,10 @@ packages: resolution: {integrity: sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw==} engines: {node: '>=20'} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + formatly@0.3.0: resolution: {integrity: sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==} engines: {node: '>=18.3.0'} @@ -4256,6 +4422,11 @@ packages: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + glob@13.0.6: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} @@ -4346,6 +4517,10 @@ packages: hastscript@9.0.1: resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + hermes-compiler@250829098.0.10: resolution: {integrity: sha512-TcRlZ0/TlyfJqquRFAWoyElVNnkdYRi/sEp4/Qy8/GYxjg8j2cS9D4MjuaQ+qimkmLN7AmO+44IznRf06mAr0w==} @@ -4554,6 +4729,9 @@ packages: resolution: {integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==} engines: {node: '>=8'} + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jest-environment-node@29.7.0: resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -4597,6 +4775,14 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + js-beautify@1.15.4: + resolution: {integrity: sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==} + engines: {node: '>=14'} + hasBin: true + + js-cookie@3.0.8: + resolution: {integrity: sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -5238,6 +5424,9 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -5294,6 +5483,11 @@ packages: resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==} engines: {node: '>=18'} + nopt@7.2.1: + resolution: {integrity: sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + hasBin: true + normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -5446,6 +5640,9 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + package-manager-detector@0.2.11: resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==} @@ -5476,6 +5673,9 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -5491,6 +5691,10 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + path-scurry@2.0.2: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} @@ -5589,6 +5793,9 @@ packages: property-information@7.2.0: resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + proto-list@1.2.4: + resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + proxy-compare@3.0.1: resolution: {integrity: sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q==} @@ -6066,6 +6273,10 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + string-width@7.2.0: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} @@ -6648,6 +6859,26 @@ packages: vlq@1.0.1: resolution: {integrity: sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==} + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + + vue-component-type-helpers@3.3.5: + resolution: {integrity: sha512-Fe1jyPJoUGpJOYKOri44jduR7My4yYINOMJISuMAbmrs+L5LbIDUc8NTWZYY3EJLK0yPLuCmcd5zoCsE4k2/KA==} + + vue-tsc@2.2.12: + resolution: {integrity: sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==} + hasBin: true + peerDependencies: + typescript: '>=5.0.0' + + vue@3.5.38: + resolution: {integrity: sha512-vAMKHfImQlYSy0C+PBue4s3ERZ2xGKfgZg5GXAsLInq1dyh2H78ILVP5sK0KPFPVW4kv+OGCIvBEondcjpZp7A==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + w3c-xmlserializer@5.0.0: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} @@ -6727,6 +6958,10 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + wrap-ansi@9.0.2: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} @@ -8430,6 +8665,15 @@ snapshots: optionalDependencies: '@types/node': 22.19.19 + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + '@isaacs/fs-minipass@4.0.1': dependencies: minipass: 7.1.3 @@ -8602,6 +8846,8 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@one-ini/wasm@0.1.1': {} + '@opentui/core-darwin-arm64@0.4.1': optional: true @@ -8927,6 +9173,9 @@ snapshots: '@pagefind/windows-x64@1.5.2': optional: true + '@pkgjs/parseargs@0.11.0': + optional: true + '@publint/pack@0.1.4': {} '@quansync/fs@1.0.0': @@ -9622,13 +9871,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)(vue@3.5.38(typescript@6.0.3))': optionalDependencies: react: 19.2.6 + vue: 3.5.38(typescript@6.0.3) - '@vercel/speed-insights@2.0.0(react@19.2.6)': + '@vercel/speed-insights@2.0.0(react@19.2.6)(vue@3.5.38(typescript@6.0.3))': optionalDependencies: react: 19.2.6 + vue: 3.5.38(typescript@6.0.3) '@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: @@ -9637,6 +9888,12 @@ snapshots: optionalDependencies: babel-plugin-react-compiler: 1.0.0 + '@vitejs/plugin-vue@6.0.7(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))(vue@3.5.38(typescript@6.0.3))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + 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) + vue: 3.5.38(typescript@6.0.3) + '@vitest/expect@4.1.7': dependencies: '@standard-schema/spec': 1.1.0 @@ -9678,6 +9935,99 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + '@volar/language-core@2.4.15': + dependencies: + '@volar/source-map': 2.4.15 + + '@volar/source-map@2.4.15': {} + + '@volar/typescript@2.4.15': + dependencies: + '@volar/language-core': 2.4.15 + path-browserify: 1.0.1 + vscode-uri: 3.1.0 + + '@vue/compiler-core@3.5.38': + dependencies: + '@babel/parser': 7.29.7 + '@vue/shared': 3.5.38 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.38': + dependencies: + '@vue/compiler-core': 3.5.38 + '@vue/shared': 3.5.38 + + '@vue/compiler-sfc@3.5.38': + dependencies: + '@babel/parser': 7.29.7 + '@vue/compiler-core': 3.5.38 + '@vue/compiler-dom': 3.5.38 + '@vue/compiler-ssr': 3.5.38 + '@vue/shared': 3.5.38 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.15 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.38': + dependencies: + '@vue/compiler-dom': 3.5.38 + '@vue/shared': 3.5.38 + + '@vue/compiler-vue2@2.7.16': + dependencies: + de-indent: 1.0.2 + he: 1.2.0 + + '@vue/language-core@2.2.12(typescript@6.0.3)': + dependencies: + '@volar/language-core': 2.4.15 + '@vue/compiler-dom': 3.5.38 + '@vue/compiler-vue2': 2.7.16 + '@vue/shared': 3.5.38 + alien-signals: 1.0.13 + minimatch: 9.0.9 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + optionalDependencies: + typescript: 6.0.3 + + '@vue/reactivity@3.5.38': + dependencies: + '@vue/shared': 3.5.38 + + '@vue/runtime-core@3.5.38': + dependencies: + '@vue/reactivity': 3.5.38 + '@vue/shared': 3.5.38 + + '@vue/runtime-dom@3.5.38': + dependencies: + '@vue/reactivity': 3.5.38 + '@vue/runtime-core': 3.5.38 + '@vue/shared': 3.5.38 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.38(vue@3.5.38(typescript@6.0.3))': + dependencies: + '@vue/compiler-ssr': 3.5.38 + '@vue/shared': 3.5.38 + vue: 3.5.38(typescript@6.0.3) + + '@vue/shared@3.5.38': {} + + '@vue/test-utils@2.4.11(@vue/compiler-dom@3.5.38)(@vue/server-renderer@3.5.38(vue@3.5.38(typescript@6.0.3)))(vue@3.5.38(typescript@6.0.3))': + dependencies: + '@vue/compiler-dom': 3.5.38 + js-beautify: 1.15.4 + vue: 3.5.38(typescript@6.0.3) + vue-component-type-helpers: 3.3.5 + optionalDependencies: + '@vue/server-renderer': 3.5.38(vue@3.5.38(typescript@6.0.3)) + '@xmldom/xmldom@0.8.13': {} '@xmldom/xmldom@0.9.10': {} @@ -9727,6 +10077,8 @@ snapshots: '@zag-js/types': 1.41.2 '@zag-js/utils': 1.41.2 + abbrev@2.0.0: {} + abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 @@ -9749,6 +10101,8 @@ snapshots: agent-base@7.1.4: {} + alien-signals@1.0.13: {} + anser@1.4.10: {} ansi-colors@4.1.3: {} @@ -10261,6 +10615,8 @@ snapshots: comma-separated-tokens@2.0.3: {} + commander@10.0.1: {} + commander@11.1.0: {} commander@12.1.0: {} @@ -10291,6 +10647,11 @@ snapshots: concat-map@0.0.1: {} + config-chain@1.1.13: + dependencies: + ini: 1.3.8 + proto-list: 1.2.4 + connect@3.7.0: dependencies: debug: 2.6.9 @@ -10359,6 +10720,8 @@ snapshots: dataloader@1.4.0: {} + de-indent@1.0.2: {} + debug@2.6.9: dependencies: ms: 2.0.0 @@ -10451,6 +10814,15 @@ snapshots: optionalDependencies: oxc-resolver: 11.20.0 + eastasianwidth@0.2.0: {} + + editorconfig@1.0.7: + dependencies: + '@one-ini/wasm': 0.1.1 + commander: 10.0.1 + minimatch: 9.0.9 + semver: 7.8.1 + ee-first@1.1.1: {} electron-to-chromium@1.5.364: {} @@ -10459,6 +10831,8 @@ snapshots: emoji-regex@8.0.0: {} + emoji-regex@9.2.2: {} + empathic@2.0.1: {} encodeurl@1.0.2: {} @@ -10479,6 +10853,8 @@ snapshots: entities@6.0.1: {} + entities@7.0.1: {} + entities@8.0.0: {} env-editor@0.4.2: {} @@ -10801,6 +11177,11 @@ snapshots: dependencies: tiny-inflate: 1.0.3 + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + formatly@0.3.0: dependencies: fd-package-json: 2.0.0 @@ -10856,6 +11237,15 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + glob@13.0.6: dependencies: minimatch: 10.2.5 @@ -11091,6 +11481,8 @@ snapshots: property-information: 7.2.0 space-separated-tokens: 2.0.2 + he@1.2.0: {} + hermes-compiler@250829098.0.10: {} hermes-estree@0.29.1: {} @@ -11264,6 +11656,12 @@ snapshots: transitivePeerDependencies: - supports-color + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + jest-environment-node@29.7.0: dependencies: '@jest/environment': 29.7.0 @@ -11340,6 +11738,16 @@ snapshots: jiti@2.7.0: {} + js-beautify@1.15.4: + dependencies: + config-chain: 1.1.13 + editorconfig: 1.0.7 + glob: 10.5.0 + js-cookie: 3.0.8 + nopt: 7.2.1 + + js-cookie@3.0.8: {} + js-tokens@4.0.0: {} js-yaml@3.14.2: @@ -12592,6 +13000,8 @@ snapshots: ms@2.1.3: {} + muggle-string@0.4.1: {} + mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -12628,6 +13038,10 @@ snapshots: node-releases@2.0.46: {} + nopt@7.2.1: + dependencies: + abbrev: 2.0.0 + normalize-path@3.0.0: {} npm-package-arg@11.0.3: @@ -12843,6 +13257,8 @@ snapshots: p-try@2.2.0: {} + package-json-from-dist@1.0.1: {} + package-manager-detector@0.2.11: dependencies: quansync: 0.2.11 @@ -12892,6 +13308,8 @@ snapshots: parseurl@1.3.3: {} + path-browserify@1.0.1: {} + path-exists@4.0.0: {} path-is-absolute@1.0.1: {} @@ -12900,6 +13318,11 @@ snapshots: path-parse@1.0.7: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + path-scurry@2.0.2: dependencies: lru-cache: 11.5.1 @@ -12984,6 +13407,8 @@ snapshots: property-information@7.2.0: {} + proto-list@1.2.4: {} + proxy-compare@3.0.1: {} publint@0.3.21: @@ -13671,6 +14096,12 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + string-width@7.2.0: dependencies: emoji-regex: 10.6.0 @@ -14128,6 +14559,26 @@ snapshots: vlq@1.0.1: {} + vscode-uri@3.1.0: {} + + vue-component-type-helpers@3.3.5: {} + + vue-tsc@2.2.12(typescript@6.0.3): + dependencies: + '@volar/typescript': 2.4.15 + '@vue/language-core': 2.2.12(typescript@6.0.3) + typescript: 6.0.3 + + vue@3.5.38(typescript@6.0.3): + dependencies: + '@vue/compiler-dom': 3.5.38 + '@vue/compiler-sfc': 3.5.38 + '@vue/runtime-dom': 3.5.38 + '@vue/server-renderer': 3.5.38(vue@3.5.38(typescript@6.0.3)) + '@vue/shared': 3.5.38 + optionalDependencies: + typescript: 6.0.3 + w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 @@ -14200,6 +14651,12 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + wrap-ansi@9.0.2: dependencies: ansi-styles: 6.2.3 diff --git a/sandbox/vue/index.html b/sandbox/vue/index.html new file mode 100644 index 0000000..7b40562 --- /dev/null +++ b/sandbox/vue/index.html @@ -0,0 +1,12 @@ + + + + + + cmdk · Vue + + +
+ + + diff --git a/sandbox/vue/package.json b/sandbox/vue/package.json new file mode 100644 index 0000000..ef4d766 --- /dev/null +++ b/sandbox/vue/package.json @@ -0,0 +1,24 @@ +{ + "name": "@sandbox/cmdk-vue", + "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-utils": "workspace:^", + "@dunky.dev/vue-state-machine": "workspace:^", + "@sandbox/cmdk-core": "workspace:^", + "vue": "^3.5.13" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^6.0.0", + "vite": "^8.0.14", + "vue-tsc": "^2.2.0" + } +} diff --git a/sandbox/vue/src/app.vue b/sandbox/vue/src/app.vue new file mode 100644 index 0000000..f82bb98 --- /dev/null +++ b/sandbox/vue/src/app.vue @@ -0,0 +1,63 @@ + + + + + diff --git a/sandbox/vue/src/command-palette.vue b/sandbox/vue/src/command-palette.vue new file mode 100644 index 0000000..4f94906 --- /dev/null +++ b/sandbox/vue/src/command-palette.vue @@ -0,0 +1,154 @@ + + + diff --git a/sandbox/vue/src/main.ts b/sandbox/vue/src/main.ts new file mode 100644 index 0000000..ca7f7bd --- /dev/null +++ b/sandbox/vue/src/main.ts @@ -0,0 +1,7 @@ +import { createApp } from 'vue' +import App from './app.vue' + +const root = document.getElementById('app') +if (!root) throw new Error('missing #app') + +createApp(App).mount(root) diff --git a/sandbox/vue/tsconfig.json b/sandbox/vue/tsconfig.json new file mode 100644 index 0000000..d650450 --- /dev/null +++ b/sandbox/vue/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "types": ["node"] + }, + "include": ["src/**/*", "src/**/*.vue", "vite.config.ts"] +} diff --git a/sandbox/vue/vite.config.ts b/sandbox/vue/vite.config.ts new file mode 100644 index 0000000..32c38af --- /dev/null +++ b/sandbox/vue/vite.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { resolve } from 'node:path' + +// 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: [vue()], + resolve: { + alias: { + '@dunky.dev/state-machine': resolve(__dirname, '../../packages/core/src'), + '@dunky.dev/vue-state-machine': resolve(__dirname, '../../packages/vue/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.json b/tsconfig.json index 89b203e..b065931 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,6 +16,7 @@ "paths": { "@dunky.dev/state-machine": ["./packages/core/src"], "@dunky.dev/react-state-machine": ["./packages/react/src"], + "@dunky.dev/vue-state-machine": ["./packages/vue/src"], "@dunky.dev/native-state-machine": ["./packages/native/src"], "@dunky.dev/opentui-state-machine": ["./packages/opentui/src"], "@dunky.dev/state-machine-utils": ["./packages/shared/utils/src"], diff --git a/tsdown.config.ts b/tsdown.config.ts index 99165b5..668fe92 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ workspace: [ 'packages/core', 'packages/react', + 'packages/vue', 'packages/native', 'packages/opentui', 'packages/shared/utils', diff --git a/website/astro.config.ts b/website/astro.config.ts index 61aa751..9f1f77d 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: 'Vue', link: 'libs/vue' }, { label: 'React Native', link: 'libs/react-native' }, { label: 'OpenTUI', link: 'libs/opentui' }, ], diff --git a/website/src/content/docs/libs/vue.mdx b/website/src/content/docs/libs/vue.mdx new file mode 100644 index 0000000..78edf75 --- /dev/null +++ b/website/src/content/docs/libs/vue.mdx @@ -0,0 +1,193 @@ +--- +title: Vue +description: Vue 3 bindings for @dunky.dev/state-machine. +--- + +import Install from '../../../components/install.astro' + + + +The Vue package is a thin edge layer. Behavior lives in the core machine and the component's `connect` function; this package only adapts them to Vue: lifecycle, reactivity, prop translation, and platform effects. The exports match the [React package](/libs/react) one-for-one — `useMachine`, `useSelector`, `normalize`, `mergeProps` — so the only thing that changes between frameworks is the import. + +## `useMachine` + +The one bridge composable. Every component calls it with the four agnostic pieces and gets back the view API. In an SFC, call it in ` + + +``` + +`useMachine` builds the machine and connector **once** in `setup()` (the props read there seed context; later changes flow through `setProps`, not a rebuild), starts on mount, stops on unmount, and exposes the connector's stable snapshot as a `computed`. In a `