Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
4ab9f35
feat(vue): add Vue 3 bindings package + sandbox
ivanbanov Jun 23, 2026
0fea000
docs(vue): use idiomatic SFC template examples
ivanbanov Jun 23, 2026
d2dd392
Merge branch 'main' into vue
ivanbanov Jun 30, 2026
ec544a2
Merge branch 'main' into vue
ivanbanov Jul 10, 2026
7793db7
Merge branch 'main' into vue
ivanbanov Jul 13, 2026
1252ab2
Merge remote-tracking branch 'origin/main' into vue
ivanbanov Aug 14, 2026
76b9f77
refactor(vue): align the Vue target with main's conventions
ivanbanov Aug 14, 2026
eb3b668
docs(changeset): add a usage example to the vue bindings changeset
ivanbanov Aug 14, 2026
63db1f2
docs(vue): trim the comments to the react target's register
ivanbanov Aug 14, 2026
dd32d84
docs(vue): link the full normalize mapping from the docs tables
ivanbanov Aug 14, 2026
119b3d2
Merge origin/main into vue — adopt the rolled-back bindings surface
ivanbanov Aug 16, 2026
56ef7a0
Merge origin/main into vue — TypeScript 7, per-package tsconfigs, the…
ivanbanov Oct 4, 2026
1f9bf3e
chore(vue): give the package its own tsconfig and bump the Vue tooling
ivanbanov Oct 4, 2026
dadb1b5
fix(vue): run ComponentEffects after mount, in React's order
ivanbanov Oct 4, 2026
863e523
refactor(vue): translate through the shared @dunky.dev/state-machine-dom
ivanbanov Oct 4, 2026
9e7f16e
fix(vue): hand useSelector's selection back as-is
ivanbanov Oct 4, 2026
5133af6
feat(vue): mergeProps merges every class/style shape and handler arrays
ivanbanov Oct 4, 2026
9c94432
fix(vue): set the true Vue floor — ^3.3.0 — and run effects on all of it
ivanbanov Oct 4, 2026
5033d10
feat(sandbox): bring the Vue demo up to the React and Solid ones
ivanbanov Oct 4, 2026
93d9e87
chore(vue): seed the package like solid and describe it for consumers
ivanbanov Oct 4, 2026
3e39f26
docs(vue): rewrite the package README and docs page, list Vue everywhere
ivanbanov Oct 4, 2026
5a71372
fix(vue): keep a throwing ComponentEffect from stranding the others
ivanbanov Oct 5, 2026
22ef971
chore(sandbox): drop the Vue demo's alias-only dependencies
ivanbanov Oct 5, 2026
f0c8a94
test(vue): pin a child's pre-start send from its own onMounted
ivanbanov Oct 5, 2026
a0dd401
fix(vue): re-select on reactive reads and follow a swapped machine
ivanbanov Oct 5, 2026
cf6f475
fix(vue): isolate a throwing effect and clean up after the DOM is gone
ivanbanov Oct 5, 2026
f19f66d
refactor(vue): fold handler arrays before the base merge decides
ivanbanov Oct 5, 2026
2cf837b
docs(vue): document the review fixes and fix the Escape example
ivanbanov Oct 5, 2026
b2d522f
fix(vue): run every selection in one tracked watcher; subscribe nothi…
ivanbanov Oct 5, 2026
5f97e12
fix(vue): finish starting every effect before reporting a failure
ivanbanov Oct 5, 2026
46f2725
docs(vue): drop the 3.3–3.4 SSR caveat, declare closeOnEscape in the …
ivanbanov Oct 5, 2026
a2ab32c
docs(contributing): keep the sandbox list change to Vue
ivanbanov Oct 5, 2026
2f40a6f
fix(vue): select lazily through a computed; the Vue floor becomes ^3.4.0
ivanbanov Oct 5, 2026
497a276
fix(vue): stop parent-first on unmount; report every effect failure
ivanbanov Oct 5, 2026
60dc7aa
fix(vue): keep the consumer's class/style when the library's is nullish
ivanbanov Oct 5, 2026
2da2859
docs(vue): the ^3.4.0 floor, computed selections, and where to send from
ivanbanov Oct 5, 2026
1efc26d
fix(vue): record each effect as it starts; settle every teardown failure
ivanbanov Oct 5, 2026
6116e3a
perf(vue): watch for a machine swap only when the machine can swap
ivanbanov Oct 5, 2026
58ef992
docs(vue): note the KeepAlive part-hook order and useSelector before …
ivanbanov Oct 5, 2026
52ebd68
fix(vue): give each effect run its own props; keep sends from leaking…
ivanbanov Oct 6, 2026
04e9c44
fix(vue): read a server selection fresh, trigger selections without a…
ivanbanov Oct 6, 2026
144ee6d
fix(vue): keep Vue's handler arrays, veto modifier listeners, type cl…
ivanbanov Oct 6, 2026
0a12fcc
docs(vue): make every sample compile and state the final contract
ivanbanov Oct 6, 2026
972bfbf
docs(dom): note that Vue derives its listener names from DOM_HANDLER_MAP
ivanbanov Oct 6, 2026
b49a4f1
fix(vue): hold still when mounted into a deactivated KeepAlive view
ivanbanov Oct 6, 2026
17bdfa6
docs(vue): KeepAlive mounts that hold still, and post-flush effect ti…
ivanbanov Oct 6, 2026
2ee2534
fix(vue): untracked() leaks no effects; report both run errors; start…
ivanbanov Oct 7, 2026
537876d
fix(vue): keep named members in MergedProps; veto from arrays of modi…
ivanbanov Oct 7, 2026
0f2b479
docs(vue): where the modifier veto can't reach, and Vapor's KeepAlive…
ivanbanov Oct 7, 2026
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
57 changes: 57 additions & 0 deletions .changeset/vue-bindings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
---
'@dunky.dev/vue-state-machine': minor
---

Add `@dunky.dev/vue-state-machine` — the Vue bindings target, for `vue` `^3.4.0`.

A first-class Vue bridge with the same four exports as the React and Solid
targets. `useMachine` builds the machine and connector once in `setup()`,
exposes the connector's memoized snapshot as a `ComputedRef`, and keeps props
fresh through `setProps`. After mount it starts the machine, then each
`ComponentEffect`, as React does; an effect re-runs only when one of its named
prop deps changes, after the DOM has been patched, and each run gets its own
snapshot of the props, so its cleanup undoes exactly what it set up. On unmount
the machine stops before the component's children unmount, then the effect
cleanups run. Under `<KeepAlive>` a deactivated component pauses — machine
stopped, effects torn down — and resumes with its state intact, like React's
`<Activity>`; one mounted into a view that is already deactivated holds still
until the view activates. Server rendering starts nothing and runs no effect.
`useSelector` returns a readonly ref holding the selected value as-is; it
re-selects on machine changes and when a reactive value the selector reads
changes, and takes the machine as a value, ref, or getter. `normalize` reuses
the shared DOM translation from `@dunky.dev/state-machine-dom` with Vue's
listener names (`onPointerenter`, `onKeydown` — Vue derives the DOM event by
hyphenating a listener's camel tail). `mergeProps` chains handlers with the
`defaultPrevented` veto, which Vue's own `mergeProps` doesn't — a consumer's
handler array included, and a capture listener like `onClickCapture` too — and
merges `class`/`style` of any shape as `[consumer, library]`.

```vue
<script setup lang="ts">
import { useMachine, normalize } from '@dunky.dev/vue-state-machine'
import { createDialogConfig, connectDialog, type DialogProps } from './dialog'
import { dialogEffects } from './dialog-effects'

// Vue casts an absent Boolean prop to `false`; `undefined` keeps the core's defaults.
const props = withDefaults(defineProps<DialogProps>(), {
open: undefined,
closeOnEscape: undefined,
})
const { api } = useMachine(createDialogConfig, connectDialog, dialogEffects, props)
</script>

<template>
<button v-bind="normalize(api.triggerProps)">Open</button>
<div v-if="api.isOpen" v-bind="normalize(api.contentProps)">Dialog content</div>
</template>
```

Rules a consumer needs. Declare boolean props with `default: undefined`: an
absent `Boolean` prop is cast to `false`, which would make `open` permanently
controlled and drop a `modal: true` default. Replace prop values instead of
mutating them in place: props reach the machine through `setProps`, which
compares by identity. In an async `setup()`, call `useMachine` before the first
`await` — Vue binds only the lifecycle hooks registered before it. And send from
event handlers, `watch` callbacks, or hooks rather than `watchEffect`, a
`computed`, or a render function: the bridge runs its own callbacks untracked,
but the machine's actions and guards run inside whatever sent.
2 changes: 1 addition & 1 deletion .lintstagedrc.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"*.{ts,tsx}": ["oxlint --fix --no-error-on-unmatched-pattern", "oxfmt"],
"*.{ts,tsx,vue}": ["oxlint --fix --no-error-on-unmatched-pattern", "oxfmt"],
"*.astro": ["oxlint --fix --no-error-on-unmatched-pattern"],
"*.{md,mdx}": ["oxfmt"]
}
1 change: 1 addition & 0 deletions ACCESSIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ record:
|
+-- react -> aria-hidden
+-- solid -> aria-hidden
+-- vue -> aria-hidden
+-- native -> aria-hidden (RN's web-aligned alias, fanned out per platform)
+-- opentui -> visible={false} (no accessibility tree; the visual analog)
```
Expand Down
18 changes: 9 additions & 9 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ repo. This file is the canonical entry point: read it first, every time.

This is Dunky's state-machine monorepo: UI behavior authored once as
plain TypeScript state machines (`packages/core`), rendered anywhere
through thin per-substrate targets (`react`, `solid`, `native`, `opentui`), with
a benchmark suite, per-substrate sandboxes, and the docs website
alongside.
through thin per-substrate targets (`react`, `solid`, `vue`, `native`,
`opentui`), with a benchmark suite, per-substrate sandboxes, and the
docs website alongside.

## Preflight

Expand All @@ -24,12 +24,12 @@ editing files in that scope — it overrides anything here for that scope

## Scopes

| Scope | Path | What it is |
| --------- | ------------- | ----------------------------------------------------------------------------------------- |
| Packages | `packages/**` | The core machine, substrate targets (react, solid, native, opentui), and shared internals |
| Benchmark | `benchmark/` | Perf suite comparing against competitor libraries |
| Sandbox | `sandbox/` | Per-substrate demo apps for manual verification |
| Website | `website/` | The docs site |
| Scope | Path | What it is |
| --------- | ------------- | ---------------------------------------------------------------------------------------------- |
| Packages | `packages/**` | The core machine, substrate targets (react, solid, vue, native, opentui), and shared internals |
| Benchmark | `benchmark/` | Perf suite comparing against competitor libraries |
| Sandbox | `sandbox/` | Per-substrate demo apps for manual verification |
| Website | `website/` | The docs site |

Some changes are cross-scope: a change in `core/` may need follow-up in
the targets, sandboxes, and docs — and vice versa. Check what else your
Expand Down
24 changes: 12 additions & 12 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ The host
| bridged per target
v
+------------------------------------------------------------------------+
| <target> (react, solid, native, opentui, …) |
| <target> (react, solid, vue, native, opentui, …) |
| Runtime-specific bridge |
| • lifecycle (build + start/stop) • normalize bindings -> props |
| • selector subscription |
Expand Down Expand Up @@ -58,7 +58,7 @@ actions. Nothing in `core/` knows that React or the DOM exists.
substrate-agnostic event and attr vocabulary (`onPress`, `role`, …); `shared/utils`
owns cross-target helpers (mergeProps, composeHandlers).

**`<target>/`** is the substrate side — `react`, `solid`, `native`, `opentui`, and any
**`<target>/`** is the substrate side — `react`, `solid`, `vue`, `native`, `opentui`, and any
future renderer. Each target is the runtime bridge for one environment: the
lifecycle bridge, the event normalization, and the selector subscription all
live here.
Expand Down Expand Up @@ -93,12 +93,12 @@ Zag, whose machines read props directly.)

## Project structure

| File / location | What it owns |
| --------------------------- | ----------------------------------------------------------------- |
| `packages/core/` | State-machine engine (plain-mutation kernel) |
| `packages/shared/bindings/` | Substrate-agnostic event + attr vocabulary (onPress, role, …) |
| `packages/shared/utils/` | mergeProps, composeHandlers |
| `packages/<target>/` | Hook + normalize per substrate (react, solid, native, opentui, …) |
| File / location | What it owns |
| --------------------------- | ---------------------------------------------------------------------- |
| `packages/core/` | State-machine engine (plain-mutation kernel) |
| `packages/shared/bindings/` | Substrate-agnostic event + attr vocabulary (onPress, role, …) |
| `packages/shared/utils/` | mergeProps, composeHandlers |
| `packages/<target>/` | Hook + normalize per substrate (react, solid, vue, native, opentui, …) |

## The map

Expand All @@ -121,9 +121,9 @@ shared/bindings substrate-agnostic event + attr vocabulary
shared/utils cross-target, cross-component helpers
+-- (mergeProps, composeHandlers)

<target> one substrate (react, solid, native, opentui, …)
<target> one substrate (react, solid, vue, native, opentui, …)
| runtime, hooks, and props translator
+-- use-machine lifecycle bridge (build + start/stop + useSyncExternalStore)
+-- use-machine lifecycle bridge (build + start/stop + snapshot subscription)
+-- use-selector fine-grained leaf subscription (O(readers))
+-- normalize bindings -> target props
```
Expand All @@ -136,7 +136,7 @@ Three package groups, three jobs:
event + attr vocabulary; `shared/utils` owns agnostic helpers (prop
merging, handler composition).
- **`<target>/`** — _the substrate side_. One folder per renderer
(`react`, `solid`, `native`, `opentui`). Owns its runtime bridge and its props translator.
(`react`, `solid`, `vue`, `native`, `opentui`). Owns its runtime bridge and its props translator.

## The machine parts

Expand Down Expand Up @@ -177,7 +177,7 @@ whether it needs props/platform or not:
| Term | What it is |
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **host** | The agnostic core — `packages/core/*`. Declares what behavior is. |
| **target** | A substrate-specific bridge package and its render environment — `packages/<target>/*` (`react`, `solid`, `native`, `opentui`, …). |
| **target** | A substrate-specific bridge package and its render environment — `packages/<target>/*` (`react`, `solid`, `vue`, `native`, `opentui`, …). |
| **machine** | A state-graph config consumed by `machine()`; returns a startable service. |
| **connect** | A function returning the logical surface a view spreads onto elements. |
| **bindings** | The substrate-agnostic event + attr vocabulary — lives in `shared/bindings`, consumed by every target's normalize. Each target's rename map and drop set are vocabulary-typed (`HandlerTargets`/`AttrTargets`, `HandlerKey`/`AttrKey`), so a typo'd or unknown key is a compile error. |
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ the short version:

```bash
pnpm -C sandbox/react dev
pnpm -C sandbox/vue dev
pnpm -C sandbox/opentui dev # terminal — needs Bun
pnpm -C sandbox/native dev # Expo — needs a simulator or device
```
Expand Down
14 changes: 9 additions & 5 deletions packages/dom/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import type { AttrTargets, HandlerTargets } from '@dunky.dev/state-machine-bindings'

/**
* Handler names shared verbatim by every DOM target. The two divergent keys
* Handler names shared by every DOM target. The two divergent keys
* (`onValueChange`, `onDoublePress`) are deliberately absent — each target
* adds its own.
* adds its own. Each value is `on` + the camelCased DOM event: React and Solid
* use them verbatim, and Vue derives its listener names from them by
* lowercasing everything after the leading capital (`onPointerEnter` →
* `onPointerenter`), so a new entry must follow that shape.
*/
export const DOM_HANDLER_MAP: HandlerTargets = {
onPress: 'onClick',
Expand All @@ -26,7 +29,7 @@ export const DOM_HANDLER_MAP: HandlerTargets = {
/**
* The `aria-` projection of the logical attr vocabulary — pure DOM truth,
* identical in every DOM target. `focusable` is deliberately absent: its
* target prop differs in casing (React `tabIndex`, Solid `tabindex`).
* target prop differs in casing (React `tabIndex`, Solid and Vue `tabindex`).
*/
export const DOM_ATTR_MAP: AttrTargets = {
describedBy: 'aria-describedby',
Expand Down Expand Up @@ -84,8 +87,9 @@ export const DOM_ATTR_MAP: AttrTargets = {
}

/**
* The DOM event fields the payload adapters read. React's synthetic events
* and Solid's native events expose the same names, so one shape serves both.
* The DOM event fields the payload adapters read. React's synthetic events and
* the native events Solid and Vue hand over expose the same names, so one
* shape serves every target.
*/
export type AnyEvent = {
target?: { value?: unknown; checked?: unknown; type?: string }
Expand Down
1 change: 1 addition & 0 deletions packages/vue/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# @dunky.dev/vue-state-machine
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.
Loading
Loading