diff --git a/.changeset/vue-bindings.md b/.changeset/vue-bindings.md new file mode 100644 index 0000000..3beabcc --- /dev/null +++ b/.changeset/vue-bindings.md @@ -0,0 +1,5 @@ +--- +'@dunky.dev/state-machine-vue': minor +--- + +Add the Vue 3 bindings package (`@dunky.dev/state-machine-vue`). It mirrors the React package's API one-for-one — `useMachine`, `useSelector`, `normalize`, `mergeProps`, and the `ComponentEffect`/`ComponentEffects` types — 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. 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..abba29b --- /dev/null +++ b/packages/vue/README.md @@ -0,0 +1,266 @@ +# `@dunky.dev/state-machine-vue` + +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/state-machine-vue' + +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. + +--- + +## `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 | +| `ComponentEffects` | `ComponentEffect[]` — a component's effect list | +| `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..9854b11 --- /dev/null +++ b/packages/vue/package.json @@ -0,0 +1,47 @@ +{ + "name": "@dunky.dev/state-machine-vue", + "version": "0.2.0", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/dunky-dev/state-machine.git", + "directory": "packages/vue" + }, + "files": [ + "dist" + ], + "type": "module", + "sideEffects": false, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "publishConfig": { + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "access": "public" + }, + "scripts": { + "build": "tsdown" + }, + "dependencies": { + "@dunky.dev/state-machine": "workspace:^", + "@dunky.dev/state-machine-utils": "workspace:^" + }, + "devDependencies": { + "@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..d113d89 --- /dev/null +++ b/packages/vue/src/index.ts @@ -0,0 +1,4 @@ +export { useMachine, type ComponentEffect, type ComponentEffects } from './use-machine' +export { useSelector } from './use-selector' +export { normalize, type Bindings } from './normalize' +export { mergeProps } from './merge-props' diff --git a/packages/vue/src/merge-props.ts b/packages/vue/src/merge-props.ts new file mode 100644 index 0000000..dcb13b0 --- /dev/null +++ b/packages/vue/src/merge-props.ts @@ -0,0 +1,33 @@ +import { mergeProps as baseMergeProps } from '@dunky.dev/state-machine-utils' + +type AnyProps = Record + +/** + * Merge consumer props with the component's (normalized) props the Vue way. + * + * Handlers are chained consumer-first with the `defaultPrevented` veto (the + * shared base does this). On top of that, the two Vue-specific class/style + * conventions: + * + * - `class` is concatenated with a single space when both sides are strings + * (Vue's `class` also accepts arrays/objects; those fall through to the base's + * "library wins", since there's no general string concat for them). + * - `style` is merged into a `[consumerStyle, libraryStyle]` array — Vue's array + * style binding, where the later entry wins on conflicting keys. + * + * Everything else: library wins (the component owns its semantics — `id`, `role`, + * `aria-*`). + */ +export function mergeProps(consumer: AnyProps | undefined, library: AnyProps): AnyProps { + const merged = baseMergeProps(consumer, library) + if (!consumer) return merged + + if (consumer.style != null && library.style != null) { + merged.style = [consumer.style, library.style] + } + 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..bce7104 --- /dev/null +++ b/packages/vue/src/normalize.ts @@ -0,0 +1,170 @@ +/** + * Translate the machine layer's LOGICAL surface to Vue DOM props. + * + * Logical handler → DOM event prop (Vue `onXxx` listener form, as accepted by + * `h()` / a `v-bind`-spread object) + * Logical attr → DOM/ARIA attr + */ + +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 maps + // to Vue's `onInput` (fires live on every change, like React's onChange) rather + // than `onChange` (which fires only on commit/blur). 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.) + +// 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/vue/src/use-machine.ts b/packages/vue/src/use-machine.ts new file mode 100644 index 0000000..923b47f --- /dev/null +++ b/packages/vue/src/use-machine.ts @@ -0,0 +1,140 @@ +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' + +/** + * 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 Vue. The deps are prop NAMES (typed `(keyof Props)[]`, + * so typos are compile errors); the bridge turns them into a precise Vue `watch` + * source, so the effect re-subscribes only when one of those props actually + * changes — not every change, never stale. The setup/teardown runs immediately + * and re-runs (after cleanup) whenever a named dep changes. `machine` is always + * an implicit dep. + */ +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 `watch` so only the one whose dep changed re-subscribes. + * + * Unlike React there is no rules-of-hooks constraint — `useMachine` sets up the + * watchers once during `setup()`, not per render — but keeping it a stable + * module constant (`export const xEffects = [...]`) stays the convention so the + * list reads the same across every framework binding. + */ +export type ComponentEffects = ComponentEffect[] + +/** + * The one generic Vue bridge. Every component's generated api.ts calls this + * with the agnostic pieces — a config factory and the connect — plus the + * component's substrate effects and the resolved props: + * + * useMachine(tooltipMachineConfig, connectTooltip, tooltipEffects, props) + * + * It: builds the machine from props, wraps it in a connector, starts on mount / + * stops on unmount (the connector's reactions follow the machine's lifecycle + * automatically), keeps props fresh via setProps, runs the component's + * prop-dependent effects (Escape, etc. — one `watch` each, keyed on their named + * prop deps), and exposes the connector's stable snapshot as a Vue `computed`. + * Returns the connect() api (reactive) + the running machine. + * + * The machine is built ONCE (from the props read at setup time); later prop + * changes flow through setProps — recreating would lose state. Because Vue + * `setup()` runs once, this is a plain build, not a memo. + */ +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: ComponentEffects>, Props>, + props: MaybeRefOrGetter, +): { api: ComputedRef; machine: ReturnType> } { + // Resolve the reactive props input to a plain, UNWRAPPED copy on demand. + // `props` may be a component's `props` proxy, a ref, or a getter — `toValue` + // handles all three. The shallow copy matters: a component's `props` proxy keeps + // a STABLE identity and mutates fields in place, so handing the live proxy to + // connector.setProps would make its value-dedup (`Object.is` on the object) see + // the same reference every time and skip every update. A fresh copy each read + // lets the connector compare by FIELD value, so a real prop change propagates + // and an equal-valued one is still deduped. + const read = (): Props => ({ ...toValue(props) }) + + // Build machine + connector once. The setup-time props seed context + initial + // state. (Vue `setup()` runs once, so no useMemo is needed — these are plain + // consts that live for the component's lifetime.) + const service = machine(createConfig(read())) + const connection = connector(service, connect, read()) + + // Drive Vue reactivity off the connector's stable, memoized snapshot. The + // connector recomputes lazily on a machine change or a props change and wakes + // its subscribers; we mirror its current snapshot into a shallowRef and bump it + // on every wake. Snapshot identity only changes on a real change, so reads stay + // stable (no needless re-renders, no tearing). + const snapshot = shallowRef(connection.snapshot) + const off = connection.subscribe(() => { + snapshot.value = connection.snapshot + }) + onBeforeUnmount(off) + + // Keep consumer props fresh (controlled flags, callbacks). setProps value-dedups, + // so an equal props object doesn't churn. `deep` because a component's `props` + // object mutates its fields in place (stable identity, changing values). + watch(read, next => connection.setProps(next), { deep: true }) + + // Lifecycle: boot on mount, tear down on unmount. The connector wired its + // reactions to the machine's start/stop, so start()/stop() is all the bridge + // needs — reactions follow automatically. We deliberately do NOT call + // connection.destroy(): the connector shares this component's lifetime with the + // machine (both built above), so they're GC'd together. destroy() exists for + // callers that build a connector standalone, outside this shared-lifetime pattern. + onMounted(() => service.start()) + onBeforeUnmount(() => service.stop()) + + // Component effects — the prop-dependent platform listeners (Escape, etc.) the + // machine can't own. One `watch` per entry, sourced on its named props, so an + // effect re-subscribes only when one of its deps actually changes. The source is + // 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 getter returning a fresh array would + // fire on every prop change, since its identity always differs). The watcher + // runs immediately (setup) and on each dep change re-runs after the prior + // cleanup; the final cleanup runs on unmount via Vue's onCleanup. + 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..cb4c09f --- /dev/null +++ b/packages/vue/src/use-selector.ts @@ -0,0 +1,59 @@ +import { onScopeDispose, readonly, shallowRef, type DeepReadonly, type Ref } from 'vue' +import type { EqualityFn, Machine } from '@dunky.dev/state-machine' + +/** + * Fine-grained, selector-based subscription for leaf components. + * + * The selector reads from the machine directly (`m.context.x`, `m.matches(...)`) + * and the returned ref updates only when the selected VALUE changes — not on + * every machine change. + * + * Mechanism (not field-level auto-tracking): the machine's `select` is a coarse + * bus. Every selection re-evaluates its selector on each machine notify and + * value-compares the result; the ref is bumped only when its selected value + * actually changed. So the WORK done per machine change is O(selectors on that + * machine) — each re-evaluates — but the Vue UPDATES are O(selectors whose value + * changed). For a leaf list backed by ONE machine per item (the common shape), + * each item has a single selector on its own machine, so a change wakes only that + * item. The deduping is what makes thousands of leaves cheap; it is value-based, + * not dependency-graph based. + * + * const open = useSelector(m, () => m.matches('open')) + * const isHL = useSelector(m, () => m.context.highlightedValue === value) + * + * Equality is `Object.is` by default (the Selection's own dedup); pass a custom + * `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>> { + // One Selection over the machine, value-deduped (coarse bus + value compare, + // not field-level dependency tracking). Seed the ref with its current value so + // the first read is correct before any change fires. + const selection = machine.select(selector) + const value = shallowRef(selection.value) as Ref + + // The Selection only fires when the selected value changes (Object.is, or the + // caller's `isEqual`), so the ref is bumped exactly on real changes — an + // unrelated machine change never wakes this leaf. We pass `isEqual` through so + // object selections dedup by value instead of identity. + const off = selection.subscribe(next => { + value.value = next + }, isEqual) + + // Dispose with the surrounding effect scope (component unmount or an explicit + // effectScope). The Selection is the consumer's to release, same as core's + // `select`. + 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..60a0dc9 --- /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/state-machine-vue' + +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..6d0eb23 --- /dev/null +++ b/packages/vue/tests/normalize.test.ts @@ -0,0 +1,309 @@ +/** + * 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/state-machine-vue' + +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' })) + }) +}) diff --git a/packages/vue/tests/use-machine.test.ts b/packages/vue/tests/use-machine.test.ts new file mode 100644 index 0000000..bd7f4ad --- /dev/null +++ b/packages/vue/tests/use-machine.test.ts @@ -0,0 +1,226 @@ +// @vitest-environment jsdom +/** + * `useMachine` — the Vue bridge composable. These tests pin the behavioral + * contract the README documents: build ONCE, run the machine lifecycle (start on + * mount / stop on unmount), keep consumer props fresh via setProps (value-deduped), + * run the connector's reactions across the machine lifecycle, run each + * ComponentEffect as its own dep-keyed watch, and expose the connector's stable + * snapshot as a reactive api — returning `{ api, machine }`. + */ +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 ComponentEffects, useMachine } from '@dunky.dev/state-machine-vue' + +type ToggleState = 'closed' | 'open' +interface ToggleCtx { + count: number +} +type ToggleEvent = { type: 'toggle' } + +interface ToggleProps { + label?: string + onOpenChange?: (open: boolean) => void +} + +const createConfig = + (): ((props: ToggleProps) => TransitionConfig) => () => ({ + initial: 'closed', + context: { count: 0 }, + states: { + closed: { + on: { toggle: { target: 'open', actions: write($ => ({ count: $.context.count + 1 })) } }, + }, + open: { on: { toggle: { target: 'closed' } } }, + }, + }) + +type ToggleApi = { + open: boolean + label: string | undefined + count: number + toggle: () => void +} + +const connect: Connect = ({ + state, + context, + props, + send, +}) => ({ + open: state === 'open', + label: props.label, + count: context.count, + toggle: () => send({ type: 'toggle' }), +}) + +const reaction = makeReaction() +connect.reactions = [ + reaction( + m => m.state === 'open', + (open, props) => props.onOpenChange?.(open), + ), +] + +type ToggleMachine = ReturnType> +const noEffects: ComponentEffects = [] + +afterEach(() => vi.clearAllMocks()) + +function harness( + props: ToggleProps, + effects: ComponentEffects = 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: ComponentEffects = [[() => (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: ComponentEffects = [[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: ComponentEffects = [[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: ComponentEffects = [ + [ + 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..dcff81d --- /dev/null +++ b/packages/vue/tests/use-selector.test.ts @@ -0,0 +1,213 @@ +// @vitest-environment jsdom +/** + * `useSelector` — fine-grained leaf subscription. These tests pin the README + * contract: the selector reads the machine directly, the returned ref updates + * ONLY when the selected value changes (value-deduped, `Object.is` by default, + * custom `isEqual` for object selections), the component re-renders only when its + * ref changes, and a change to one leaf's slice wakes only that leaf (the + * O(readers) property). + */ +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/state-machine-vue' + +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 2f59d3f..a9cc51a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -180,6 +180,25 @@ 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: + '@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/state-machine': @@ -303,6 +322,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/state-machine-vue': + 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': @@ -313,10 +363,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 @@ -1647,6 +1697,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'} @@ -1739,6 +1793,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] @@ -2283,6 +2340,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'} @@ -3082,6 +3143,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==} @@ -3111,6 +3179,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'} @@ -3152,6 +3279,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'} @@ -3178,6 +3309,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==} @@ -3572,6 +3706,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'} @@ -3606,6 +3744,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'} @@ -3667,6 +3808,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: @@ -3802,6 +3946,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==} @@ -3814,6 +3966,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'} @@ -3842,6 +3997,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'} @@ -4096,6 +4255,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'} @@ -4166,6 +4329,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} @@ -4256,6 +4424,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==} @@ -4464,6 +4636,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} @@ -4507,6 +4682,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==} @@ -5144,6 +5327,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==} @@ -5200,6 +5386,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'} @@ -5352,6 +5543,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==} @@ -5382,6 +5576,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'} @@ -5397,6 +5594,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} @@ -5495,6 +5696,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==} @@ -5972,6 +6176,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'} @@ -6554,6 +6762,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'} @@ -6633,6 +6861,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'} @@ -8336,6 +8568,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 @@ -8501,6 +8742,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 @@ -8826,6 +9069,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': @@ -9521,13 +9767,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: @@ -9536,6 +9784,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 @@ -9577,6 +9831,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': {} @@ -9626,6 +9973,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 @@ -9648,6 +9997,8 @@ snapshots: agent-base@7.1.4: {} + alien-signals@1.0.13: {} + anser@1.4.10: {} ansi-colors@4.1.3: {} @@ -10160,6 +10511,8 @@ snapshots: comma-separated-tokens@2.0.3: {} + commander@10.0.1: {} + commander@11.1.0: {} commander@12.1.0: {} @@ -10190,6 +10543,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 @@ -10258,6 +10616,8 @@ snapshots: dataloader@1.4.0: {} + de-indent@1.0.2: {} + debug@2.6.9: dependencies: ms: 2.0.0 @@ -10350,6 +10710,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: {} @@ -10358,6 +10727,8 @@ snapshots: emoji-regex@8.0.0: {} + emoji-regex@9.2.2: {} + empathic@2.0.1: {} encodeurl@1.0.2: {} @@ -10378,6 +10749,8 @@ snapshots: entities@6.0.1: {} + entities@7.0.1: {} + entities@8.0.0: {} env-editor@0.4.2: {} @@ -10700,6 +11073,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 @@ -10755,6 +11133,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 @@ -10990,6 +11377,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: {} @@ -11163,6 +11552,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 @@ -11239,6 +11634,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: @@ -12491,6 +12896,8 @@ snapshots: ms@2.1.3: {} + muggle-string@0.4.1: {} + mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -12527,6 +12934,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: @@ -12742,6 +13153,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 @@ -12791,6 +13204,8 @@ snapshots: parseurl@1.3.3: {} + path-browserify@1.0.1: {} + path-exists@4.0.0: {} path-is-absolute@1.0.1: {} @@ -12799,6 +13214,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 @@ -12883,6 +13303,8 @@ snapshots: property-information@7.2.0: {} + proto-list@1.2.4: {} + proxy-compare@3.0.1: {} publint@0.3.21: @@ -13570,6 +13992,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 @@ -14027,6 +14455,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 @@ -14099,6 +14547,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..cfc5739 --- /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/state-machine-vue": "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..515bd16 --- /dev/null +++ b/sandbox/vue/src/App.vue @@ -0,0 +1,63 @@ + + + + + diff --git a/sandbox/vue/src/CommandPalette.vue b/sandbox/vue/src/CommandPalette.vue new file mode 100644 index 0000000..bacc42b --- /dev/null +++ b/sandbox/vue/src/CommandPalette.vue @@ -0,0 +1,149 @@ + + + diff --git a/sandbox/vue/src/main.ts b/sandbox/vue/src/main.ts new file mode 100644 index 0000000..53eba50 --- /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..87a5927 --- /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/state-machine-vue': 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 7dbe31a..360c0cc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,6 +16,7 @@ "paths": { "@dunky.dev/state-machine": ["./packages/core/src"], "@dunky.dev/state-machine-react": ["./packages/react/src"], + "@dunky.dev/state-machine-vue": ["./packages/vue/src"], "@dunky.dev/state-machine-native": ["./packages/native/src"], "@dunky.dev/state-machine-opentui": ["./packages/opentui/src"], "@dunky.dev/state-machine-utils": ["./packages/shared/utils/src"], 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..5c4263a --- /dev/null +++ b/website/src/content/docs/libs/vue.mdx @@ -0,0 +1,191 @@ +--- +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 `