diff --git a/.changeset/vue-dialog.md b/.changeset/vue-dialog.md
new file mode 100644
index 0000000..66342fc
--- /dev/null
+++ b/.changeset/vue-dialog.md
@@ -0,0 +1,40 @@
+---
+'@dunky.dev/vue-dialog': minor
+---
+
+New substrate: the Vue 3 binding for `@dunky.dev/dialog`. The same compound
+anatomy and behavior contract as the React binding — one core machine, a new
+host — delivered in Vue's native shape: the core options are props, the core
+callbacks are emits, and `v-model:open` is the controlled contract
+(`update:open` reports real transitions only; per the controlled contract a
+dismissal on a controlled dialog emits nothing — decide it at its source in
+the dismissal emits, which carry `preventDefault()` for the veto).
+
+```vue
+
+
+
+
+ Open
+
+
+
+
+ Title
+ Description
+ Close
+
+
+
+
+
+```
+
+`Content`'s `initialFocus` accepts a `MaybeRefOrGetter` —
+the Vue idiom for "resolve at open time", so template refs that fill after
+setup work. Everything else follows the core spec: native `` window,
+layer stack with assistive-tech containment, focus trap with Close as the
+cycle's last stop, scroll lock (scoped to the Portal container when given),
+exit animations through `data-state="closing"`, and `closeOnBack`.
diff --git a/.changeset/vue-hooks.md b/.changeset/vue-hooks.md
new file mode 100644
index 0000000..1641a02
--- /dev/null
+++ b/.changeset/vue-hooks.md
@@ -0,0 +1,12 @@
+---
+'@dunky.dev/vue-use-focus-trap': minor
+'@dunky.dev/vue-use-scroll-lock': minor
+---
+
+New substrate: the Vue lifecycle wrappers over the framework-free DOM utils,
+mirroring the React hooks one-for-one. `useFocusTrap(ref, options?)` follows
+the target ref (a template ref fills after setup, so the trap arms when the
+element appears and releases with it); `useScrollLock(locked?, target?)`
+accepts `MaybeRefOrGetter` for both parameters so the lock tracks reactive
+state. The behavior itself lives in `@dunky.dev/dom-focus-trap` and
+`@dunky.dev/dom-scroll-lock` — these composables own only the lifecycle.
diff --git a/knip.config.ts b/knip.config.ts
index d17eefc..fb77b09 100644
--- a/knip.config.ts
+++ b/knip.config.ts
@@ -17,6 +17,9 @@ const config: KnipConfig = {
'packages/react/*': {
entry: ['stories/*.stories.tsx'],
},
+ 'packages/vue/*': {
+ entry: ['stories/*.stories.ts'],
+ },
},
}
diff --git a/package.json b/package.json
index c625454..6d3903d 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,7 @@
"scaffold": "node scripts/scaffold.ts",
"dev": "pnpm dev:react",
"dev:react": "pnpm --filter @dunky-dev/react dev",
+ "dev:vue": "pnpm --filter @dunky-dev/vue dev",
"dev:expo": "pnpm --filter @dunky-dev/native dev",
"dev:ios": "pnpm --filter @dunky-dev/native ondevice:ios",
"dev:android": "pnpm --filter @dunky-dev/native ondevice:android",
diff --git a/packages/vue/.storybook/main.ts b/packages/vue/.storybook/main.ts
new file mode 100644
index 0000000..e6cce53
--- /dev/null
+++ b/packages/vue/.storybook/main.ts
@@ -0,0 +1,15 @@
+import type { StorybookConfig } from '@storybook/vue3-vite'
+
+const config: StorybookConfig = {
+ stories: ['../**/*.stories.@(ts|tsx)'],
+ framework: '@storybook/vue3-vite',
+ core: {
+ disableTelemetry: true,
+ disableWhatsNewNotifications: true,
+ },
+ features: {
+ sidebarOnboardingChecklist: false,
+ },
+}
+
+export default config
diff --git a/packages/vue/.storybook/manager.ts b/packages/vue/.storybook/manager.ts
new file mode 100644
index 0000000..3bfb5c2
--- /dev/null
+++ b/packages/vue/.storybook/manager.ts
@@ -0,0 +1,14 @@
+import { addons } from 'storybook/manager-api'
+import { create } from 'storybook/theming'
+
+addons.setConfig({
+ showToolbar: true,
+ layoutCustomisations: {
+ showPanel: () => false,
+ },
+ theme: create({
+ base: 'light',
+ brandTitle: 'dunky',
+ brandUrl: './',
+ }),
+})
diff --git a/packages/vue/dialog/README.md b/packages/vue/dialog/README.md
new file mode 100644
index 0000000..1136fbb
--- /dev/null
+++ b/packages/vue/dialog/README.md
@@ -0,0 +1,41 @@
+# @dunky.dev/vue-dialog
+
+Vue binding for [`@dunky.dev/dialog`](../../core/dialog): a compound
+component — `Dialog` plus its parts — that drives the framework-free dialog
+machine. The root owns the machine; parts translate the core's logical
+bindings into DOM attributes and handlers, and wire the DOM-only concerns
+(teleport, focus trap, scroll lock, layer stack).
+
+Behavior contract: [`../../core/dialog/SPEC.md`](../../core/dialog/SPEC.md).
+Vue-specific surface: [SPEC.md](./SPEC.md).
+
+## Install
+
+```sh
+npm install @dunky.dev/vue-dialog
+```
+
+## Usage
+
+```vue
+
+
+
+
+ Delete...
+
+
+
+
+ Delete file?
+ This cannot be undone.
+ Delete
+ Cancel
+
+
+
+
+
+```
diff --git a/packages/vue/dialog/SPEC.md b/packages/vue/dialog/SPEC.md
new file mode 100644
index 0000000..9d8c03b
--- /dev/null
+++ b/packages/vue/dialog/SPEC.md
@@ -0,0 +1,185 @@
+# SPEC / Vue / Dialog
+
+The Vue implementation of the [core spec](../../core/dialog/SPEC.md).
+
+## Docs
+
+🔗 [`dunky.dev/ui/components/dialog`](https://dunky.dev/ui/components/dialog).
+
+## Install
+
+```sh
+npm install @dunky.dev/vue-dialog
+```
+
+## Usage
+
+```vue
+
+
+
+
+ Open
+
+
+
+
+ Title
+ Description
+ Close
+
+
+
+
+
+```
+
+The parts hang off the root — `` resolves through
+`
+
+
+ ...
+
+```
diff --git a/packages/vue/hooks/use-focus-trap/SPEC.md b/packages/vue/hooks/use-focus-trap/SPEC.md
new file mode 100644
index 0000000..c7a4441
--- /dev/null
+++ b/packages/vue/hooks/use-focus-trap/SPEC.md
@@ -0,0 +1,53 @@
+# SPEC / Vue / useFocusTrap
+
+The Vue binding of the
+[DOM focus-trap spec](../../../dom/utils/focus-trap/SPEC.md) — the trap
+behavior is framework-free; this composable owns only the Vue lifecycle.
+
+## Install
+
+```sh
+npm install @dunky.dev/vue-use-focus-trap
+```
+
+## Usage
+
+```vue
+
+
+
+ ...
+
+```
+
+Vue-specific notes on top of the DOM contract:
+
+- The trap follows the target ref: it binds when the ref holds an element
+ (a template ref fills after setup, so the binding must follow the ref,
+ not the call site), releases when it clears or the component unmounts,
+ and re-arms on a new element.
+- Options are read through the composable's closure on every Tab press, so
+ inline `enabled` / `last` see the latest state — the per-press
+ re-evaluation the DOM contract promises — without ever re-binding the
+ listener.
+
+## API
+
+### `useFocusTrap(target, options?)`
+
+Returns nothing — the trap lives and dies with the component.
+
+| Param | Type | Default | Description |
+| --------- | -------------------------- | ------- | -------------------------------------------------------------------------------------- |
+| `target` | `Ref` | — | The container to trap Tab / Shift+Tab within. |
+| `options` | `UseFocusTrapOptions` | `{}` | The DOM trap's options: `enabled?: () => boolean`, `last?: () => HTMLElement \| null`. |
diff --git a/packages/vue/hooks/use-focus-trap/package.json b/packages/vue/hooks/use-focus-trap/package.json
new file mode 100644
index 0000000..6f25456
--- /dev/null
+++ b/packages/vue/hooks/use-focus-trap/package.json
@@ -0,0 +1,48 @@
+{
+ "name": "@dunky.dev/vue-use-focus-trap",
+ "version": "0.0.0",
+ "description": "Vue binding for @dunky.dev/dom-focus-trap.",
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/dunky-dev/ui.git",
+ "directory": "packages/vue/hooks/use-focus-trap"
+ },
+ "files": [
+ "dist",
+ "src",
+ "SPEC.md"
+ ],
+ "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/dom-focus-trap": "workspace:*"
+ },
+ "devDependencies": {
+ "@testing-library/vue": "^8.1.0",
+ "vue": "^3.5.41"
+ },
+ "peerDependencies": {
+ "vue": "*"
+ }
+}
diff --git a/packages/vue/hooks/use-focus-trap/src/index.ts b/packages/vue/hooks/use-focus-trap/src/index.ts
new file mode 100644
index 0000000..52559c7
--- /dev/null
+++ b/packages/vue/hooks/use-focus-trap/src/index.ts
@@ -0,0 +1 @@
+export { useFocusTrap, type UseFocusTrapOptions } from './use-focus-trap'
diff --git a/packages/vue/hooks/use-focus-trap/src/use-focus-trap.ts b/packages/vue/hooks/use-focus-trap/src/use-focus-trap.ts
new file mode 100644
index 0000000..fe59f1e
--- /dev/null
+++ b/packages/vue/hooks/use-focus-trap/src/use-focus-trap.ts
@@ -0,0 +1,32 @@
+import { watch, type Ref } from 'vue'
+import { trapFocus } from '@dunky.dev/dom-focus-trap'
+import type { TrapFocusOptions } from '@dunky.dev/dom-focus-trap'
+
+export interface UseFocusTrapOptions extends TrapFocusOptions {}
+
+/**
+ * Traps Tab / Shift+Tab within `target` while it holds an element — the Vue
+ * lifecycle around `trapFocus`. The trap follows the ref: a template ref only
+ * fills after setup, so the binding arms when the element appears, releases
+ * when it clears or the component unmounts, and re-arms on a new element.
+ */
+export function useFocusTrap(
+ target: Ref,
+ options: UseFocusTrapOptions = {},
+): void {
+ // Options are read through the closure on each Tab press, so inline
+ // `enabled` / `last` see the latest state without re-binding the listener.
+ watch(
+ target,
+ (container, _previous, onCleanup) => {
+ if (container === null) return
+ onCleanup(
+ trapFocus(container, {
+ enabled: () => options.enabled?.() !== false,
+ last: () => options.last?.() ?? null,
+ }),
+ )
+ },
+ { immediate: true, flush: 'post' },
+ )
+}
diff --git a/packages/vue/hooks/use-focus-trap/tests/use-focus-trap.test.ts b/packages/vue/hooks/use-focus-trap/tests/use-focus-trap.test.ts
new file mode 100644
index 0000000..6785de1
--- /dev/null
+++ b/packages/vue/hooks/use-focus-trap/tests/use-focus-trap.test.ts
@@ -0,0 +1,66 @@
+// @vitest-environment jsdom
+// The Vue lifecycle around @dunky.dev/dom-focus-trap — the wrap/no-op/enabled
+// behavior itself is covered in the util's own tests.
+import { defineComponent, h, nextTick, shallowRef } from 'vue'
+import { cleanup, render, screen } from '@testing-library/vue'
+import { afterEach, describe, expect, it } from 'vitest'
+import { useFocusTrap } from '@dunky.dev/vue-use-focus-trap'
+
+const Trap = defineComponent({
+ props: {
+ enabled: { type: Function, default: undefined },
+ },
+ setup(props) {
+ const target = shallowRef(null)
+ useFocusTrap(target, { enabled: props.enabled as (() => boolean) | undefined })
+ return () =>
+ h('div', { ref: target, tabindex: -1, 'data-testid': 'container' }, [
+ h('button', { type: 'button' }, 'first'),
+ h('button', { type: 'button' }, 'last'),
+ ])
+ },
+})
+
+// dispatchEvent returns false when a handler called preventDefault — the trap
+// is synchronous, so no Vue flush is involved. (Vue TL's fireEvent resolves
+// void, unlike React TL's, so it can't report this.)
+const pressTab = (): boolean =>
+ screen
+ .getByTestId('container')
+ .dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }))
+
+// Auto-cleanup needs vitest globals; this repo runs with globals: false.
+afterEach(cleanup)
+
+describe('useFocusTrap', () => {
+ it('traps while mounted and releases on unmount', async () => {
+ const { unmount } = render(Trap)
+ // The trap follows the template ref, which fills one flush after mount.
+ await nextTick()
+ screen.getByText('last').focus()
+
+ expect(pressTab()).toBe(false)
+ expect(document.activeElement).toBe(screen.getByText('first'))
+
+ const container = screen.getByTestId('container')
+ const last = screen.getByText('last')
+ last.focus()
+ unmount()
+ // The listener is gone with the unmount — a Tab on the detached container
+ // is no longer intercepted.
+ expect(
+ container.dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }),
+ ),
+ ).toBe(true)
+ })
+
+ it('forwards enabled() to the trap without re-binding', () => {
+ render(Trap, { props: { enabled: () => false } })
+ const last = screen.getByText('last')
+ last.focus()
+
+ expect(pressTab()).toBe(true)
+ expect(document.activeElement).toBe(last)
+ })
+})
diff --git a/packages/vue/hooks/use-scroll-lock/README.md b/packages/vue/hooks/use-scroll-lock/README.md
new file mode 100644
index 0000000..ae4e6b3
--- /dev/null
+++ b/packages/vue/hooks/use-scroll-lock/README.md
@@ -0,0 +1,28 @@
+# @dunky.dev/vue-use-scroll-lock
+
+Vue binding for [`@dunky.dev/dom-scroll-lock`](../../../dom/utils/scroll-lock):
+`useScrollLock(locked, target?)` locks scrolling while the component is
+mounted — on the page body, or on the `target` element when one is given (e.g.
+a scoped surface locks its own container, not the page). The lock behavior
+itself is framework-free — this composable only owns the Vue lifecycle.
+
+## Install
+
+```sh
+npm install @dunky.dev/vue-use-scroll-lock
+```
+
+## Usage
+
+```vue
+
+
+
+ ...
+
+```
diff --git a/packages/vue/hooks/use-scroll-lock/SPEC.md b/packages/vue/hooks/use-scroll-lock/SPEC.md
new file mode 100644
index 0000000..c5cfde2
--- /dev/null
+++ b/packages/vue/hooks/use-scroll-lock/SPEC.md
@@ -0,0 +1,48 @@
+# SPEC / Vue / useScrollLock
+
+The Vue binding of the
+[DOM scroll-lock spec](../../../dom/utils/scroll-lock/SPEC.md) — the lock
+behavior is framework-free; this composable owns only the Vue lifecycle.
+
+## Install
+
+```sh
+npm install @dunky.dev/vue-use-scroll-lock
+```
+
+## Usage
+
+```vue
+
+
+
+ ...
+
+```
+
+Vue-specific notes on top of the DOM contract:
+
+- The lock holds while the component is mounted and `locked` is true;
+ unmounting or turning `locked` off releases it. A `target` change
+ releases the old container and locks the new one. Both parameters
+ accept a plain value, a ref, or a getter — the lock follows them
+ reactively.
+- The DOM contract's shared per-container lock does the multi-holder
+ arithmetic: several mounted lockers (nested modal layers) hold one lock,
+ and the container restores when the last unmounts.
+
+## API
+
+### `useScrollLock(locked?, target?)`
+
+Returns nothing — the lock lives and dies with the component.
+
+| Param | Type | Default | Description |
+| -------- | --------------------------------------- | ------------- | ------------------------------------------------------------------------------------------- |
+| `locked` | `MaybeRefOrGetter` | `true` | Whether the lock is held. |
+| `target` | `MaybeRefOrGetter` | the page body | The scroll container to lock (e.g. a scoped surface locks its own container, not the page). |
diff --git a/packages/vue/hooks/use-scroll-lock/package.json b/packages/vue/hooks/use-scroll-lock/package.json
new file mode 100644
index 0000000..75c8a10
--- /dev/null
+++ b/packages/vue/hooks/use-scroll-lock/package.json
@@ -0,0 +1,48 @@
+{
+ "name": "@dunky.dev/vue-use-scroll-lock",
+ "version": "0.0.0",
+ "description": "Vue binding for @dunky.dev/dom-scroll-lock.",
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/dunky-dev/ui.git",
+ "directory": "packages/vue/hooks/use-scroll-lock"
+ },
+ "files": [
+ "dist",
+ "src",
+ "SPEC.md"
+ ],
+ "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/dom-scroll-lock": "workspace:*"
+ },
+ "devDependencies": {
+ "@testing-library/vue": "^8.1.0",
+ "vue": "^3.5.41"
+ },
+ "peerDependencies": {
+ "vue": "*"
+ }
+}
diff --git a/packages/vue/hooks/use-scroll-lock/src/index.ts b/packages/vue/hooks/use-scroll-lock/src/index.ts
new file mode 100644
index 0000000..3d8ec04
--- /dev/null
+++ b/packages/vue/hooks/use-scroll-lock/src/index.ts
@@ -0,0 +1 @@
+export { useScrollLock } from './use-scroll-lock'
diff --git a/packages/vue/hooks/use-scroll-lock/src/use-scroll-lock.ts b/packages/vue/hooks/use-scroll-lock/src/use-scroll-lock.ts
new file mode 100644
index 0000000..871e36d
--- /dev/null
+++ b/packages/vue/hooks/use-scroll-lock/src/use-scroll-lock.ts
@@ -0,0 +1,19 @@
+import { toValue, watchEffect, type MaybeRefOrGetter } from 'vue'
+import { lockScroll } from '@dunky.dev/dom-scroll-lock'
+
+/**
+ * Locks scrolling while mounted and `locked` — the Vue lifecycle around
+ * `lockScroll`. Targets the page body unless a `target` element is given (e.g.
+ * a scoped/portaled surface locks its own container, not the page). The lock
+ * is shared per container: with several holders (e.g. nested modal layers),
+ * the container is restored only when the last one releases.
+ */
+export function useScrollLock(
+ locked: MaybeRefOrGetter = true,
+ target?: MaybeRefOrGetter,
+): void {
+ watchEffect(onCleanup => {
+ if (!toValue(locked)) return
+ onCleanup(lockScroll(toValue(target) ?? undefined))
+ })
+}
diff --git a/packages/vue/hooks/use-scroll-lock/tests/use-scroll-lock.test.ts b/packages/vue/hooks/use-scroll-lock/tests/use-scroll-lock.test.ts
new file mode 100644
index 0000000..8b82166
--- /dev/null
+++ b/packages/vue/hooks/use-scroll-lock/tests/use-scroll-lock.test.ts
@@ -0,0 +1,36 @@
+// @vitest-environment jsdom
+// The Vue lifecycle around @dunky.dev/dom-scroll-lock — the refcount/restore
+// behavior itself is covered in the util's own tests.
+import { defineComponent } from 'vue'
+import { cleanup, render } from '@testing-library/vue'
+import { afterEach, describe, expect, it } from 'vitest'
+import { useScrollLock } from '@dunky.dev/vue-use-scroll-lock'
+
+const Locker = defineComponent({
+ props: {
+ locked: { type: Boolean, default: true },
+ },
+ setup(props) {
+ useScrollLock(() => props.locked)
+ return () => null
+ },
+})
+
+// Auto-cleanup needs vitest globals; this repo runs with globals: false.
+afterEach(cleanup)
+
+describe('useScrollLock', () => {
+ it('locks body scroll while mounted and releases on unmount', () => {
+ const { unmount } = render(Locker)
+ expect(document.body.style.overflow).toBe('hidden')
+
+ unmount()
+ expect(document.body.style.overflow).toBe('')
+ })
+
+ it('does not lock when locked=false', () => {
+ const { unmount } = render(Locker, { props: { locked: false } })
+ expect(document.body.style.overflow).toBe('')
+ unmount()
+ })
+})
diff --git a/packages/vue/package.json b/packages/vue/package.json
new file mode 100644
index 0000000..8951dc4
--- /dev/null
+++ b/packages/vue/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "@dunky-dev/vue",
+ "version": "0.0.0",
+ "private": true,
+ "license": "MIT",
+ "type": "module",
+ "scripts": {
+ "dev": "storybook dev -p 6007 -c .storybook",
+ "build": "storybook build -c .storybook"
+ },
+ "devDependencies": {
+ "@storybook/vue3-vite": "^10.5.0",
+ "storybook": "^10.5.0",
+ "vite": "^8.1.4",
+ "vue": "^3.5.41"
+ }
+}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 362af61..ce34d0c 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -8,5 +8,6 @@ minimumReleaseAgeExclude:
- '@dunky.dev/state-machine-bindings@0.3.2'
- '@dunky.dev/state-machine-utils@0.3.2'
- '@dunky.dev/state-machine@0.3.2'
+ - '@dunky.dev/vue-state-machine@0.4.0'
publicHoistPattern:
- '*storybook*'
diff --git a/scripts/templates/packages/vue/__name__/README.md b/scripts/templates/packages/vue/__name__/README.md
new file mode 100644
index 0000000..3be7d38
--- /dev/null
+++ b/scripts/templates/packages/vue/__name__/README.md
@@ -0,0 +1,29 @@
+# @dunky.dev/vue-__name__
+
+Vue binding for [`@dunky.dev/__name__`](../../core/__name__): a compound
+component — `__Name__` plus its parts — that drives the framework-free
+machine. The root owns the machine; parts translate the core's logical
+bindings into DOM attributes and handlers.
+
+Behavior contract: [`../../core/__name__/SPEC.md`](../../core/__name__/SPEC.md).
+Vue-specific surface: [SPEC.md](./SPEC.md).
+
+## Install
+
+```sh
+npm install @dunky.dev/vue-__name__
+```
+
+## Usage
+
+```vue
+
+
+
+ <__Name__ @disable="() => {}">
+ <__Name__.Root>go
+
+
+```
diff --git a/scripts/templates/packages/vue/__name__/SPEC.md b/scripts/templates/packages/vue/__name__/SPEC.md
new file mode 100644
index 0000000..d6d80c2
--- /dev/null
+++ b/scripts/templates/packages/vue/__name__/SPEC.md
@@ -0,0 +1,36 @@
+# SPEC / Vue / __Name__
+
+The Vue implementation of the [core spec](../../core/__name__/SPEC.md).
+
+## Docs
+
+🔗 [`dunky.dev/ui/components/__name__`](https://dunky.dev/ui/components/__name__).
+
+
+## Install
+
+```sh
+npm install @dunky.dev/vue-__name__
+```
+
+## Usage
+
+
+```vue
+
+
+
+ <__Name__ />
+
+```
+
+
+## API
+
+
+| Prop | Type | Default | Description |
+| --- | --- | --- | --- |
+| `prop` | `number` | `1337` | Magical number. |
+| `...` | `...` | `...` | ... |
diff --git a/scripts/templates/packages/vue/__name__/package.json b/scripts/templates/packages/vue/__name__/package.json
new file mode 100644
index 0000000..15f4352
--- /dev/null
+++ b/scripts/templates/packages/vue/__name__/package.json
@@ -0,0 +1,49 @@
+{
+ "name": "@dunky.dev/vue-__name__",
+ "version": "0.0.0",
+ "description": "Vue binding for @dunky.dev/__name__.",
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/dunky-dev/ui.git",
+ "directory": "packages/vue/__name__"
+ },
+ "files": [
+ "dist",
+ "src",
+ "SPEC.md"
+ ],
+ "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/__name__": "workspace:*",
+ "@dunky.dev/vue-state-machine": "^0.4.0"
+ },
+ "devDependencies": {
+ "@testing-library/vue": "^8.1.0",
+ "vue": "^3.5.41"
+ },
+ "peerDependencies": {
+ "vue": "*"
+ }
+}
diff --git a/scripts/templates/packages/vue/__name__/src/__name__.ts b/scripts/templates/packages/vue/__name__/src/__name__.ts
new file mode 100644
index 0000000..9cb1edb
--- /dev/null
+++ b/scripts/templates/packages/vue/__name__/src/__name__.ts
@@ -0,0 +1,75 @@
+import {
+ defineComponent,
+ h,
+ provide,
+ type ButtonHTMLAttributes,
+ type DefineComponent,
+} from 'vue'
+import type { __Name__Callbacks, __Name__Options } from '@dunky.dev/__name__'
+
+import { mergeProps, normalize } from '@dunky.dev/vue-state-machine'
+import { __Name__ContextKey, use__Name__Context } from './context'
+import { use__Name__, type __Name__Emit } from './use-__name__'
+
+// Explicit so the exports satisfy --isolatedDeclarations (a bare
+// defineComponent call gives the variable no annotatable type).
+type PartComponent = DefineComponent
+
+// =============================================================================
+// <__Name__> — root, owns the machine and renders no DOM
+// =============================================================================
+
+/** The root's props: the core options; the core callbacks are emits (typed
+ * here as listener props so templates and TSX check them). */
+export interface __Name__Props extends Omit<__Name__Options, keyof __Name__Callbacks> {
+ /** Fired when the primitive becomes disabled. */
+ onDisable?: () => void
+}
+
+const __Name__Root = defineComponent({
+ name: '__Name__',
+ props: {
+ // Vue casts an undeclared-default Boolean prop to false — `default:
+ // undefined` keeps the tri-state the options contract needs.
+ disabled: { type: Boolean, default: undefined },
+ },
+ emits: ['disable'],
+ setup(props, { slots, emit }) {
+ const value = use__Name__(props, emit as __Name__Emit)
+ provide(__Name__ContextKey, value)
+ return () => slots.default?.()
+ },
+})
+
+// =============================================================================
+// <__Name__.Root> — placeholder part: wires the root bindings onto an element.
+// TODO(spec): replace with one part per piece of the anatomy in SPEC.md.
+// =============================================================================
+
+export interface __Name__RootProps extends ButtonHTMLAttributes {}
+
+export const Root: PartComponent<__Name__RootProps> = defineComponent({
+ name: '__Name__Root',
+ inheritAttrs: false,
+ setup(_props, { slots, attrs }) {
+ const { api } = use__Name__Context()
+ return () =>
+ h(
+ 'button',
+ mergeProps({ type: 'button', ...attrs }, normalize(api.value.parts.root)),
+ slots.default?.(),
+ )
+ },
+}) as unknown as PartComponent<__Name__RootProps>
+
+// Parts
+// -----------------------------------------------------------------------------
+
+export interface Parts {
+ Root: typeof Root
+}
+
+export const __Name__: DefineComponent<__Name__Props> & Parts = Object.assign(
+ __Name__Root as unknown as DefineComponent<__Name__Props>,
+ { Root },
+)
diff --git a/scripts/templates/packages/vue/__name__/src/context.ts b/scripts/templates/packages/vue/__name__/src/context.ts
new file mode 100644
index 0000000..56e60ba
--- /dev/null
+++ b/scripts/templates/packages/vue/__name__/src/context.ts
@@ -0,0 +1,17 @@
+import { inject, type ComputedRef, type InjectionKey } from 'vue'
+import type { __Name__Api, __Name__Machine } from '@dunky.dev/__name__'
+
+export interface __Name__ContextValue {
+ api: ComputedRef<__Name__Api>
+ machine: __Name__Machine
+}
+
+export const __Name__ContextKey: InjectionKey<__Name__ContextValue> = Symbol('__Name__Context')
+
+export const use__Name__Context = (): __Name__ContextValue => {
+ const context = inject(__Name__ContextKey, undefined)
+ if (context === undefined) {
+ throw new Error('__Name__ parts must be rendered within a <__Name__> root')
+ }
+ return context
+}
diff --git a/scripts/templates/packages/vue/__name__/src/effects.ts b/scripts/templates/packages/vue/__name__/src/effects.ts
new file mode 100644
index 0000000..b316f8d
--- /dev/null
+++ b/scripts/templates/packages/vue/__name__/src/effects.ts
@@ -0,0 +1,21 @@
+import type { ComponentEffect } from '@dunky.dev/vue-state-machine'
+import type { __Name__Machine, __Name__Options } from '@dunky.dev/__name__'
+
+// Substrate effects: prop-driven or platform work the machine can't own.
+// useMachine runs one watcher per entry, keyed on the listed prop deps.
+type __Name__Effect = ComponentEffect<__Name__Machine, __Name__Options>
+
+// Config that lives in machine context is synced through events, so guards keep
+// working at runtime — the machine never reads props. Document listeners and
+// platform APIs also belong here (see the dialog for an example).
+const syncDisabled: __Name__Effect = [
+ (machine, props) => {
+ const disabled = props.disabled ?? false
+ if (machine.context.disabled !== disabled) {
+ machine.send({ type: 'SET_DISABLED', disabled })
+ }
+ },
+ ['disabled'],
+]
+
+export const __camelName__Effects: __Name__Effect[] = [syncDisabled]
diff --git a/scripts/templates/packages/vue/__name__/src/index.ts b/scripts/templates/packages/vue/__name__/src/index.ts
new file mode 100644
index 0000000..8488220
--- /dev/null
+++ b/scripts/templates/packages/vue/__name__/src/index.ts
@@ -0,0 +1,2 @@
+export { __Name__, type __Name__Props, type __Name__RootProps } from './__name__'
+export type { __Name__Callbacks, __Name__Options } from '@dunky.dev/__name__'
diff --git a/scripts/templates/packages/vue/__name__/src/use-__name__.ts b/scripts/templates/packages/vue/__name__/src/use-__name__.ts
new file mode 100644
index 0000000..32baf7b
--- /dev/null
+++ b/scripts/templates/packages/vue/__name__/src/use-__name__.ts
@@ -0,0 +1,24 @@
+import { useMachine } from '@dunky.dev/vue-state-machine'
+import { __camelName__Machine, __camelName__Connect } from '@dunky.dev/__name__'
+import type { __Name__Callbacks, __Name__Options } from '@dunky.dev/__name__'
+
+import type { __Name__ContextValue } from './context'
+import { __camelName__Effects } from './effects'
+
+export type __Name__Emit = (event: 'disable') => void
+
+/**
+ * Owns one __name__ machine for the <__Name__> root. `useMachine` creates it
+ * once, re-syncs options through the reactive getter, runs the substrate
+ * effects, and exposes the connected api as a ref. The core callbacks forward
+ * to emits — listeners run synchronously, and attach/detach stays live.
+ */
+export function use__Name__(props: __Name__Options, emit: __Name__Emit): __Name__ContextValue {
+ const callbacks: __Name__Callbacks = {
+ disable: () => emit('disable'),
+ }
+ return useMachine(__camelName__Machine, __camelName__Connect, __camelName__Effects, () => ({
+ ...props,
+ ...callbacks,
+ }))
+}
diff --git a/scripts/templates/packages/vue/__name__/stories/__name__.stories.ts b/scripts/templates/packages/vue/__name__/stories/__name__.stories.ts
new file mode 100644
index 0000000..12d9df4
--- /dev/null
+++ b/scripts/templates/packages/vue/__name__/stories/__name__.stories.ts
@@ -0,0 +1,22 @@
+import { h } from 'vue'
+import type { Meta, StoryObj } from '@storybook/vue3-vite'
+import { __Name__ } from '@dunky.dev/vue-__name__'
+
+const meta: Meta = {
+ title: 'Primitives/__Name__',
+ component: __Name__,
+}
+
+export default meta
+type StoryType = StoryObj
+
+// The primitive ships headless — the story is the consumer, so it brings the
+// styles. `data-state` on every part is the real styling hook.
+export const standard: StoryType = {
+ render: () => ({
+ setup: () => () =>
+ h(__Name__, { onDisable: () => console.log('disabled') }, {
+ default: () => h(__Name__.Root, null, { default: () => 'go' }),
+ }),
+ }),
+}
diff --git a/scripts/templates/packages/vue/__name__/tests/__name__.test.ts b/scripts/templates/packages/vue/__name__/tests/__name__.test.ts
new file mode 100644
index 0000000..8acf1a0
--- /dev/null
+++ b/scripts/templates/packages/vue/__name__/tests/__name__.test.ts
@@ -0,0 +1,44 @@
+// @vitest-environment jsdom
+// The Vue edge of the __name__ — behavior only; the machine's own contract
+// is covered in @dunky.dev/__name__'s tests.
+import { defineComponent, h } from 'vue'
+import { cleanup, fireEvent, render, screen } from '@testing-library/vue'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { __Name__ } from '@dunky.dev/vue-__name__'
+
+// Spreads its attrs onto the root, so tests drive props and listeners through
+// TL's `render(..., { props })` / `rerender` on this wrapper.
+const Default__Name__ = defineComponent({
+ inheritAttrs: false,
+ setup(_props, { attrs }) {
+ return () => h(__Name__, { ...attrs }, { default: () => h(__Name__.Root, null, { default: () => 'go' }) })
+ },
+})
+
+// Auto-cleanup needs vitest globals; this repo runs with globals: false.
+afterEach(cleanup)
+
+describe('__Name__', () => {
+ it('disables on press', async () => {
+ const onDisable = vi.fn()
+ render(Default__Name__, { props: { onDisable } })
+ await fireEvent.click(screen.getByRole('button'))
+ expect(onDisable).toHaveBeenCalledTimes(1)
+ })
+
+ it('emits disable when the controlled disabled prop turns on', async () => {
+ const onDisable = vi.fn()
+ const { rerender } = render(Default__Name__, { props: { onDisable } })
+ expect(onDisable).not.toHaveBeenCalled()
+
+ await rerender({ disabled: true })
+ expect(onDisable).toHaveBeenCalledTimes(1)
+ })
+
+ it('translates the core bindings onto the element', async () => {
+ render(Default__Name__, { props: { disabled: true } })
+ const root = await screen.findByRole('button')
+ expect(root.getAttribute('data-state')).toBe('idle')
+ expect(root.getAttribute('aria-disabled')).toBe('true')
+ })
+})
diff --git a/tsconfig.json b/tsconfig.json
index 9f55675..193c857 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -19,12 +19,15 @@
"@dunky.dev/dialog": ["./packages/core/dialog/src"],
"@dunky.dev/native-dialog": ["./packages/native/dialog/src"],
"@dunky.dev/react-dialog": ["./packages/react/dialog/src"],
+ "@dunky.dev/vue-dialog": ["./packages/vue/dialog/src"],
"@dunky.dev/dom-overlay": ["./packages/dom/utils/overlay/src"],
"@dunky.dev/dom-focus-trap": ["./packages/dom/utils/focus-trap/src"],
"@dunky.dev/dom-navigation": ["./packages/dom/utils/navigation/src"],
"@dunky.dev/dom-scroll-lock": ["./packages/dom/utils/scroll-lock/src"],
"@dunky.dev/react-use-focus-trap": ["./packages/react/hooks/use-focus-trap/src"],
- "@dunky.dev/react-use-scroll-lock": ["./packages/react/hooks/use-scroll-lock/src"]
+ "@dunky.dev/react-use-scroll-lock": ["./packages/react/hooks/use-scroll-lock/src"],
+ "@dunky.dev/vue-use-focus-trap": ["./packages/vue/hooks/use-focus-trap/src"],
+ "@dunky.dev/vue-use-scroll-lock": ["./packages/vue/hooks/use-scroll-lock/src"]
},
"types": ["@types/node", "vitest/globals"]
},
diff --git a/tsdown.config.ts b/tsdown.config.ts
index b4c0c31..aed423d 100644
--- a/tsdown.config.ts
+++ b/tsdown.config.ts
@@ -21,6 +21,9 @@ export default defineConfig({
'packages/react/dialog',
'packages/react/hooks/use-focus-trap',
'packages/react/hooks/use-scroll-lock',
+ 'packages/vue/dialog',
+ 'packages/vue/hooks/use-focus-trap',
+ 'packages/vue/hooks/use-scroll-lock',
],
entry: ['src/index.ts'],
format: ['esm'],