Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/vue-bindings.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 21 additions & 0 deletions packages/vue/LICENSE
Original file line number Diff line number Diff line change
@@ -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.
266 changes: 266 additions & 0 deletions packages/vue/README.md
Original file line number Diff line number Diff line change
@@ -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 `<script>`
read `api.value.x`; in a `<template>` Vue auto-unwraps the ref, so it's `api.x`.

In an SFC, call it in `<script setup>` and spread the bindings with `v-bind`:

```vue
<script setup lang="ts">
import { useMachine, normalize } from '@dunky.dev/state-machine-vue'
import { tooltipMachineConfig, connectTooltip, tooltipEffects } from './tooltip'

const props = defineProps<TooltipProps>()
const { api } = useMachine(tooltipMachineConfig, connectTooltip, tooltipEffects, props)
</script>

<template>
<button v-bind="normalize(api.triggerProps)">Hover me</button>
<div v-if="api.open" v-bind="normalize(api.contentProps)">Tooltip</div>
</template>
```

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<TooltipMachine, TooltipMachineProps>

/** 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
<template>
<!-- normalize(api.triggerProps) → { onClick, 'aria-describedby', role, ... } -->
<button v-bind="normalize(api.triggerProps)">Open</button>
</template>
```

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
<template>
<button v-bind="mergeProps(consumerProps, normalize(api.triggerProps))">Open</button>
</template>
```

- **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<M, P>` | `[ (machine, props) => cleanup, (keyof P)[] ]` — one substrate effect + its prop deps |
| `ComponentEffects<M, P>` | `ComponentEffect<M, P>[]` — a component's effect list |
| `Bindings` | `Record<string, unknown>` — the loose shape `normalize` accepts |
47 changes: 47 additions & 0 deletions packages/vue/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
4 changes: 4 additions & 0 deletions packages/vue/src/index.ts
Original file line number Diff line number Diff line change
@@ -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'
33 changes: 33 additions & 0 deletions packages/vue/src/merge-props.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { mergeProps as baseMergeProps } from '@dunky.dev/state-machine-utils'

type AnyProps = Record<string, unknown>

/**
* 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
}
Loading
Loading