diff --git a/.changeset/dom-dialog-exit-in-place.md b/.changeset/dom-dialog-exit-in-place.md
new file mode 100644
index 0000000..b474cf7
--- /dev/null
+++ b/.changeset/dom-dialog-exit-in-place.md
@@ -0,0 +1,18 @@
+---
+'@dunky.dev/dom-dialog': minor
+---
+
+`startExitWindow` takes `inPlace`, with the layer's `viewport`, for a dialog
+rendered without a portal. The exit window hides the still-painting layer by
+walking up to the content's outermost ancestor below the portal container —
+the layer itself when portalled, but for a layer that sits in the page's own
+branch that walk takes the page around it out of interaction too. In place,
+the hiding stops at the layer: from its viewport down, or the content alone
+when it has none.
+
+```ts
+startExitWindow(content, { inPlace: true, viewport, backdrop, onComplete })
+```
+
+The option defaults off, so portalled dialogs and today's callers behave as
+before. The Vue binding passes it for a `Content` rendered without a `Portal`.
diff --git a/.changeset/vue-dialog.md b/.changeset/vue-dialog.md
new file mode 100644
index 0000000..3d0ac74
--- /dev/null
+++ b/.changeset/vue-dialog.md
@@ -0,0 +1,54 @@
+---
+'@dunky.dev/vue-dialog': minor
+---
+
+New substrate: the Vue binding for `@dunky.dev/dialog`, targeting Vue 3.5+
+(peer `vue@^3.5.0` — the base id comes from `useId`, so server-rendered ids
+survive hydration). The same compound anatomy and behavior contract as the
+React and Solid bindings — 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` binds the controlled pair. Listeners run synchronously, so
+`preventDefault()` on a dismissal payload still vetoes it. `update:open`
+reports a change, it never requests one: per the controlled contract a
+controlled dialog — `v-model:open` included — doesn't move on its own Trigger,
+Escape, or outside press, so wire each intent at its source.
+
+```vue
+
+
+
+
+ Open
+
+
+
+
+ Title
+ Description
+ Close
+
+
+
+
+
+```
+
+An uncontrolled `` needs none of that: its own parts open and close
+it, and an absent boolean prop keeps the core default rather than Vue's
+`false` cast — `` is modal and uncontrolled, ``
+starts open. `Content`'s `initialFocus` and `restoreFocus` accept an element,
+a ref, or a getter, resolved at open and close time; a template unwraps refs
+before the element mounts, so it passes a getter
+(`:initial-focus="() => cancelButton"`). The Portal renders nothing on the
+server and teleports once mounted, so hydration matches; under ``
+a cached dialog parks its layers off the page, state intact, until the view
+returns. Everything else
+follows the core spec: a `div` window with the dialog role, 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` with the Forward reopen.
diff --git a/.changeset/vue-hooks.md b/.changeset/vue-hooks.md
new file mode 100644
index 0000000..b0f7765
--- /dev/null
+++ b/.changeset/vue-hooks.md
@@ -0,0 +1,14 @@
+---
+'@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 Solid primitives' shape (peer `vue@^3.3.0`).
+`useFocusTrap(target, options?)` and `useScrollLock(locked?, target?)` take a
+`MaybeRefOrGetter` — a template ref, a getter, or the element — and follow it
+reactively. Both start on mount, when a template ref has filled, and never
+run during server rendering; a `` deactivation releases them like
+an unmount and reactivation picks them back up, as React's `` does.
+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/ARCHITECTURE.md b/ARCHITECTURE.md
index 0d5e090..1432b80 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -6,7 +6,7 @@ one per host environment — the **substrates**. Behavior cannot drift between
hosts because it exists in exactly one place.
A substrate is any environment a primitive is delivered to: a framework
-(react), another framework (solid), or a different host entirely
+(react), other frameworks (solid, vue), or a different host entirely
(native). Substrates are cheap by design; the expensive thing — the behavior
— is written once.
@@ -66,9 +66,9 @@ edges — lives under `dom/components/` instead. A util is primitive-agnostic
and imports nothing from the repo but a smaller util; a component package is
the opposite, and may import the primitive's core package and any DOM util.
Both are equally
-framework-free. The split matters as substrates multiply: React and Solid
-differ in how they schedule an effect, not in what the effect does, so the
-what is written once and each binding contributes only its lifecycle.
+framework-free. The split matters as substrates multiply: React, Solid, and
+Vue differ in how they schedule an effect, not in what the effect does, so
+the what is written once and each binding contributes only its lifecycle.
Machine logic that several primitives need — the controlled/uncontrolled
machinery (`@dunky.dev/controllable`) — lives the same way under
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 01912a0..961ddaf 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -18,7 +18,7 @@ pnpm install
| Command | What it does |
| ------------------------------ | ---------------------------------------------------------- |
| `pnpm scaffold ` | Stamps a new primitive across every substrate |
-| `pnpm test` | vitest suite (core + dom + react), watch mode |
+| `pnpm test` | vitest suite (core, dom, web bindings), watch mode |
| `pnpm test:native` | The native substrate's jest suite (jest-expo + RNTL) |
| `pnpm test:ci` | Everything once (vitest + native jest) — what CI runs |
| `pnpm typecheck` | `tsc --noEmit` across the whole workspace |
@@ -36,7 +36,7 @@ pnpm test packages/core/dialog/tests/machine.test.ts
## Storybook
-Each UI substrate (React, Solid, ...) is a self-contained package under
+Each UI substrate (React, Solid, Vue, ...) is a self-contained package under
`packages/` with its own Storybook — the fastest way to see a
change actually render. Every substrate gets an explicit `dev:`
script:
@@ -44,6 +44,8 @@ script:
```bash
pnpm dev # alias for dev:react (the default substrate)
pnpm dev:react # @dunky-dev/react Storybook at http://localhost:6006
+pnpm dev:solid # @dunky-dev/solid Storybook at http://localhost:6008
+pnpm dev:vue # @dunky-dev/vue Storybook at http://localhost:6007
pnpm build-storybook # static build of the react substrate's Storybook
```
diff --git a/README.md b/README.md
index 51c3a18..51a3e89 100644
--- a/README.md
+++ b/README.md
@@ -25,23 +25,23 @@ up there with the same behavior and the same a11y.
connect() -> logical bindings
(onPress, role, data-state, ...)
|
- +----------------+----------------+
- | |
- v |
- +------------------------+ |
- | dom/components/ | the DOM half |
- | document listeners, | shared by every |
- | focus/stack sequence | DOM host |
- +-----+------------+-----+ |
- | ^ |
- | | dom/utils/* |
- | | focus-trap, overlay, |
- | | scroll-lock, ... |
- v v
- +-----------+ +-----------+ +-----------+
- | react | | solid | | native |
- +-----------+ +-----------+ +-----------+
- render + host lifecycle only
+ +----------------+----------------------+
+ | |
+ v |
+ +------------------------+ |
+ | dom/components/ | the DOM half |
+ | document listeners, | shared by every |
+ | focus/stack sequence | DOM host |
+ +-----+------------+-----+ |
+ | ^ |
+ | | dom/utils/* |
+ | | focus-trap, overlay, |
+ | | scroll-lock, ... |
+ v v
+ +-----------+ +-----------+ +-----------+ +-----------+
+ | react | | solid | | vue | | native |
+ +-----------+ +-----------+ +-----------+ +-----------+
+ render + host lifecycle only
```
The engine that runs the core machines lives in its own repo:
@@ -63,8 +63,8 @@ renders, not how machines execute.
ordered focus sequences), written once and shared by every DOM host.
Published as `@dunky.dev/dom-`.
- **`/`** — the render. A thin binding per host (`react/`,
- `solid/`, `native/`) that wires the machine to real elements. Published as
- `@dunky.dev/-` (e.g. `@dunky.dev/react-dialog`).
+ `solid/`, `vue/`, `native/`) that wires the machine to real elements.
+ Published as `@dunky.dev/-` (e.g. `@dunky.dev/react-dialog`).
The dependency direction is one-way: `substrate -> dom -> core`. A binding
adds no behavior of its own — if a decision is needed, it moves into the core
diff --git a/knip.config.ts b/knip.config.ts
index 9f7fa4c..ff8fb22 100644
--- a/knip.config.ts
+++ b/knip.config.ts
@@ -28,6 +28,9 @@ const config: KnipConfig = {
// The babel presets are referenced as strings in tsdown.config.ts.
ignoreDependencies: ['babel-preset-solid', '@babel/preset-typescript'],
},
+ 'packages/vue/*': {
+ entry: ['stories/*.stories.ts'],
+ },
},
}
diff --git a/package.json b/package.json
index 12fd423..bb3ef32 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,7 @@
"test:native": "pnpm --filter @dunky-dev/native test",
"test:ci": "vitest run && pnpm test:native",
"build": "tsdown",
- "typecheck": "tsc --noEmit && tsc --noEmit -p packages/solid",
+ "typecheck": "tsc --noEmit && tsc --noEmit -p packages/solid && tsc --noEmit -p packages/vue",
"lint": "oxlint --ignore-pattern '.worktrees' .",
"format": "oxfmt .",
"format:check": "oxfmt --check .",
@@ -18,6 +18,7 @@
"dev": "pnpm dev:react",
"dev:react": "pnpm --filter @dunky-dev/react dev",
"dev:solid": "pnpm --filter @dunky-dev/solid 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/dom/components/dialog/SPEC.md b/packages/dom/components/dialog/SPEC.md
index 95cacc2..83bdfec 100644
--- a/packages/dom/components/dialog/SPEC.md
+++ b/packages/dom/components/dialog/SPEC.md
@@ -103,6 +103,12 @@ and watched for the end of its visual, which the substrate forwards to the
machine as `exit.complete`. The disposer undoes both — it is the reopen
interrupt as much as the final unmount.
+What gets hidden is the layer: the content's outermost ancestor below the
+portal `container`. A layer rendered in place — without a portal — sits in the
+page's own branch instead, where that walk would take the page around it out
+too, so `inPlace` bounds the hiding to the layer itself: from its `viewport`
+down, or the content alone when it has none.
+
### Back navigation
`guardBackNavigation` makes the host's Back a dismissal, and its Forward the
diff --git a/packages/dom/components/dialog/src/exit-window.ts b/packages/dom/components/dialog/src/exit-window.ts
index 26ae75b..21539dc 100644
--- a/packages/dom/components/dialog/src/exit-window.ts
+++ b/packages/dom/components/dialog/src/exit-window.ts
@@ -5,6 +5,16 @@ export interface ExitWindowOptions {
container?: HTMLElement | null
/** The layer's backdrop, portalled alongside the content. */
backdrop?: Element | null
+ /**
+ * The layer renders in place, without a portal, so it sits in the page's
+ * own branch rather than a container of its own: the exit hides the layer
+ * alone — from its `viewport` down, or the content when it has none —
+ * instead of the content's outermost ancestor below `container`.
+ * @default false
+ */
+ inPlace?: boolean
+ /** The layer's viewport: what an `inPlace` exit hides along with the content. */
+ viewport?: Element | null
/** Forwarded to the machine as `exit.complete`. */
onComplete: () => void
}
@@ -16,11 +26,12 @@ export interface ExitWindowOptions {
* interrupt (and the final unmount) undoing both.
*/
export function startExitWindow(content: HTMLElement, options: ExitWindowOptions): () => void {
- const undoHide = hideExitingLayer(
- content,
- options.container ?? document.body,
- options.backdrop ?? null,
- )
+ // In place, the layer's own parent bounds the walk, so it stops at the
+ // layer instead of climbing into the page around it.
+ const boundary = options.inPlace
+ ? ((options.viewport ?? content).parentElement ?? document.body)
+ : (options.container ?? document.body)
+ const undoHide = hideExitingLayer(content, boundary, options.backdrop ?? null)
const cancelWatch = watchExitAnimation(content, options.onComplete)
return () => {
cancelWatch()
diff --git a/packages/dom/components/dialog/tests/dialog.test.ts b/packages/dom/components/dialog/tests/dialog.test.ts
index 81755f8..eac0cbd 100644
--- a/packages/dom/components/dialog/tests/dialog.test.ts
+++ b/packages/dom/components/dialog/tests/dialog.test.ts
@@ -361,6 +361,36 @@ describe('startExitWindow', () => {
content.dispatchEvent(new Event('transitionend'))
expect(onComplete).not.toHaveBeenCalled()
})
+
+ // A layer rendered in place sits in the page's own branch: climbing to the
+ // outermost ancestor below the body would take the page out with it.
+ describe('in place', () => {
+ const mountInPlace = (withViewport: boolean): Record => {
+ const page = document.createElement('main')
+ const viewport = document.createElement('div')
+ const content = document.createElement('div')
+ page.append(withViewport ? viewport : content)
+ if (withViewport) viewport.append(content)
+ document.body.append(page)
+ return { page, viewport, content }
+ }
+
+ it('hides the layer from its viewport down, leaving the page around it live', () => {
+ const { page, viewport, content } = mountInPlace(true)
+ startExitWindow(content, { inPlace: true, viewport, onComplete: vi.fn() })
+
+ expect(viewport.hasAttribute('inert')).toBe(true)
+ expect(page.hasAttribute('inert')).toBe(false)
+ })
+
+ it('hides the content alone when the layer has no viewport', () => {
+ const { page, content } = mountInPlace(false)
+ startExitWindow(content, { inPlace: true, onComplete: vi.fn() })
+
+ expect(content.hasAttribute('inert')).toBe(true)
+ expect(page.hasAttribute('inert')).toBe(false)
+ })
+ })
})
describe('guardBackNavigation', () => {
diff --git a/packages/vue/.storybook/main.ts b/packages/vue/.storybook/main.ts
new file mode 100644
index 0000000..80c022d
--- /dev/null
+++ b/packages/vue/.storybook/main.ts
@@ -0,0 +1,39 @@
+import type { StorybookConfig } from '@storybook/vue3-vite'
+import { mergeConfig } from 'vite'
+
+const config: StorybookConfig = {
+ stories: ['../**/*.stories.@(ts|tsx)'],
+ framework: {
+ name: '@storybook/vue3-vite',
+ // Docgen reads `.vue` files; the parts are `.ts` components.
+ options: { docgen: false },
+ },
+ core: {
+ disableTelemetry: true,
+ disableWhatsNewNotifications: true,
+ },
+ features: {
+ sidebarOnboardingChecklist: false,
+ },
+ viteFinal: viteConfig =>
+ mergeConfig(viteConfig, {
+ // A linked state-machine adapter (cross-repo development) resolves
+ // `vue` from its own checkout; two copies split the component instance,
+ // so every import is pinned to this workspace's.
+ resolve: { dedupe: ['vue'] },
+ // Vue's esm-bundler build takes its feature flags from the bundler
+ // (@vitejs/plugin-vue defines them; this framework adds no such plugin)
+ // and logs a notice on every load without them. The binding, its
+ // adapter, the stories, and Storybook's renderer use setup() and render
+ // functions only, so the Options API stays out — which also shows the
+ // dialog working in an app that turns it off. A static Storybook has no
+ // production devtools and no hydration.
+ define: {
+ __VUE_OPTIONS_API__: 'false',
+ __VUE_PROD_DEVTOOLS__: 'false',
+ __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: '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..6f8880e
--- /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
+ ×
+
+
+
+
+
+```
diff --git a/packages/vue/dialog/SPEC.md b/packages/vue/dialog/SPEC.md
new file mode 100644
index 0000000..8b04061
--- /dev/null
+++ b/packages/vue/dialog/SPEC.md
@@ -0,0 +1,298 @@
+# 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: `
+
+
+ ...
+
+```
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..e660cb0
--- /dev/null
+++ b/packages/vue/hooks/use-focus-trap/SPEC.md
@@ -0,0 +1,63 @@
+# 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 target is a `MaybeRefOrGetter` — a template ref, a getter, or the
+ element itself; a component counts as its root element (`$el`), read when
+ the target resolves — `$el` isn't reactive, so a component that swaps its
+ root element needs a ref on the element instead. The trap arms when the
+ component mounts (a template ref has filled by then), re-arms when the
+ target yields a new element, and releases on unmount. A ``
+ deactivation releases it too and reactivation arms it again, as React's
+ `` runs an effect's cleanup; mounted into a view that is already
+ deactivated, it waits for the view's return, and reached by a restored
+ view's activation before it has mounted (an async component under
+ ``), it waits for its own mount. Mounted hooks never run during
+ server rendering, so the trap touches no DOM there.
+- The deactivated-view walk and the `$el` unwrapping are local copies of the
+ dialog binding's, by design — see [its SPEC](../../dialog/SPEC.md)'s
+ `` note.
+- The options object is read on each Tab press, so inline `enabled` / `last`
+ see the latest state without re-binding the listener — the per-press
+ re-evaluation the DOM contract promises.
+
+## API
+
+### `useFocusTrap(target, options?)`
+
+Returns nothing — the trap lives and dies with the component.
+
+| Param | Type | Default | Description |
+| --------- | ------------------------------------------------------------------------------- | ------- | -------------------------------------------------------------------------------------- |
+| `target` | `MaybeRefOrGetter` | — | 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..07dc807
--- /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.43"
+ },
+ "peerDependencies": {
+ "vue": "^3.3.0"
+ }
+}
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..cd2c1bd
--- /dev/null
+++ b/packages/vue/hooks/use-focus-trap/src/use-focus-trap.ts
@@ -0,0 +1,100 @@
+import {
+ effectScope,
+ getCurrentInstance,
+ onActivated,
+ onDeactivated,
+ onMounted,
+ onScopeDispose,
+ onUnmounted,
+ toValue,
+ watch,
+ type ComponentPublicInstance,
+ type EffectScope,
+ type MaybeRefOrGetter,
+} from 'vue'
+import { trapFocus } from '@dunky.dev/dom-focus-trap'
+import type { TrapFocusOptions } from '@dunky.dev/dom-focus-trap'
+
+export interface UseFocusTrapOptions extends TrapFocusOptions {}
+
+// The instance fields the walk reads, structurally: Vue 3.6 types the chain
+// as its Vapor-aware generic instance.
+interface InstanceNode {
+ isDeactivated: boolean
+ parent: InstanceNode | null
+}
+
+// Whether the component sits in a deactivated view — it mounted
+// into a cached view after the user left it, and waits for the return.
+function inDeactivatedView(instance: InstanceNode | null): boolean {
+ for (let node = instance; node !== null; node = node.parent) {
+ if (node.isDeactivated) return true
+ }
+ return false
+}
+
+// A template ref on a component holds its instance; the element is its `$el`
+// (a text or comment node for a fragment root). Duck-typed: an element from
+// another realm — an iframe's document — fails `instanceof HTMLElement`.
+function toElement(
+ target: HTMLElement | ComponentPublicInstance | null | undefined,
+): HTMLElement | null {
+ if (typeof target !== 'object' || target === null) return null
+ const element = ('$el' in target ? target.$el : target) as Node | null
+ return element?.nodeType === Node.ELEMENT_NODE ? (element as HTMLElement) : null
+}
+
+/**
+ * Traps Tab / Shift+Tab within `target` while the component is mounted and
+ * the target holds an element — the Vue lifecycle around `trapFocus`. Arms on
+ * mount, re-arms when the target yields a new element, releases on unmount.
+ * A component counts as its root element (`$el`).
+ */
+export function useFocusTrap(
+ target: MaybeRefOrGetter,
+ options: UseFocusTrapOptions = {},
+): void {
+ // Armed from mount, when a template ref has filled — never during server
+ // rendering — and released after the DOM is gone, as React runs an
+ // effect's cleanup after commit. A deactivation releases it
+ // too, as React's does. The trap reads the options per Tab
+ // press, so `enabled` / `last` stay live without re-binding. Released by
+ // hand: Vue 3.6 also runs a watcher's onCleanup when its source merely
+ // re-evaluates.
+ const instance = getCurrentInstance()
+ let scope: EffectScope | undefined
+ const arm = (): void => {
+ if (scope !== undefined) return
+ // Detached: the component scope stops before the DOM is removed.
+ scope = effectScope(true)
+ scope.run(() => {
+ let untrap: (() => void) | undefined
+ watch(
+ () => toElement(toValue(target)),
+ container => {
+ untrap?.()
+ untrap = container === null ? undefined : trapFocus(container, options)
+ },
+ { immediate: true, flush: 'post' },
+ )
+ onScopeDispose(() => untrap?.())
+ })
+ }
+ const disarm = (): void => {
+ scope?.stop()
+ scope = undefined
+ }
+ let mounted = false
+ onMounted(() => {
+ mounted = true
+ if (!inDeactivatedView(instance)) arm()
+ })
+ // Also fires on a kept-alive first mount, right after `onMounted` — and can
+ // reach a component that hasn't mounted yet (an async one under
+ // ), which `onMounted` starts instead.
+ onActivated(() => {
+ if (mounted) arm()
+ })
+ onDeactivated(disarm)
+ onUnmounted(disarm)
+}
diff --git a/packages/vue/hooks/use-focus-trap/tests/use-focus-trap.test.tsx b/packages/vue/hooks/use-focus-trap/tests/use-focus-trap.test.tsx
new file mode 100644
index 0000000..18fcf75
--- /dev/null
+++ b/packages/vue/hooks/use-focus-trap/tests/use-focus-trap.test.tsx
@@ -0,0 +1,172 @@
+// @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 { KeepAlive, Suspense, defineComponent, nextTick, ref, type PropType } from 'vue'
+import { cleanup, render, screen } from '@testing-library/vue'
+import { afterEach, describe, expect, it, onTestFinished } from 'vitest'
+import { useFocusTrap } from '@dunky.dev/vue-use-focus-trap'
+
+const Trap = defineComponent({
+ props: { enabled: { type: Function as PropType<() => boolean>, default: undefined } },
+ setup(props) {
+ const target = ref(null)
+ // The closure defers the props read to each Tab press.
+ useFocusTrap(target, { enabled: () => props.enabled?.() !== false })
+ return () => (
+
+ first
+ last
+
+ )
+ },
+})
+
+// dispatchEvent returns false when a handler called preventDefault — Vue
+// TL's fireEvent resolves void, so it can't report that.
+const tab = (target: Element): boolean =>
+ target.dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }),
+ )
+
+// The trap listens on the document: a Tab pressed off the document (on a
+// detached or cached container) reaches no listener either way, so a check
+// that the trap let go presses it on the page.
+const outsideButton = (): HTMLButtonElement => {
+ const outside = document.createElement('button')
+ document.body.append(outside)
+ onTestFinished(() => outside.remove())
+ return outside
+}
+
+// Auto-cleanup needs vitest globals; this repo runs with globals: false.
+afterEach(cleanup)
+
+describe('useFocusTrap', () => {
+ it('traps while mounted and releases on unmount', () => {
+ const { unmount } = render(Trap)
+ screen.getByText('last').focus()
+
+ expect(tab(screen.getByTestId('container'))).toBe(false)
+ expect(document.activeElement).toBe(screen.getByText('first'))
+
+ const outside = outsideButton()
+ unmount()
+ expect(tab(outside)).toBe(true)
+ })
+
+ it('forwards enabled() to the trap without re-binding', () => {
+ render(Trap, { props: { enabled: () => false } })
+ const last = screen.getByText('last')
+ last.focus()
+
+ expect(tab(screen.getByTestId('container'))).toBe(true)
+ expect(document.activeElement).toBe(last)
+ })
+
+ it('lets go while a KeepAlive deactivation holds the component, and re-arms on reactivation', async () => {
+ const shown = ref(true)
+ render(() => {shown.value ? : null} )
+ const container = screen.getByTestId('container')
+ const outside = outsideButton()
+
+ shown.value = false
+ await nextTick()
+ expect(tab(outside)).toBe(true)
+
+ shown.value = true
+ await nextTick()
+ expect(tab(container)).toBe(false)
+ })
+
+ it('stays unarmed when mounted into a deactivated KeepAlive view, and arms once it returns', async () => {
+ const shown = ref(true)
+ const loaded = ref(false)
+ const Page = defineComponent(() => () => (loaded.value ? : loading ))
+ render(() => {shown.value ? : null} )
+ shown.value = false
+ await nextTick()
+
+ loaded.value = true // mounts the trap into the cached view
+ await nextTick()
+ expect(tab(outsideButton())).toBe(true)
+
+ shown.value = true
+ await nextTick()
+ expect(tab(screen.getByTestId('container'))).toBe(false)
+ })
+
+ // A restored view's activation reaches every part it holds, mounted or
+ // not: an async one still pending under arms with its own mount.
+ it('stays unarmed for an async component a restored view reaches before it mounts', async () => {
+ let resolve = (): void => {}
+ const pending = new Promise(done => (resolve = done))
+ const container = document.createElement('div')
+ container.append(document.createElement('button'))
+ document.body.append(container)
+ onTestFinished(() => container.remove())
+ const Async = defineComponent({
+ async setup() {
+ useFocusTrap(container)
+ await pending
+ return () => null
+ },
+ })
+ const shown = ref(true)
+ const Page = defineComponent(() => () => (
+
+
+
+ ))
+ render(() => {shown.value ? : null} )
+ shown.value = false
+ await nextTick()
+ shown.value = true
+ await nextTick()
+ expect(tab(outsideButton())).toBe(true)
+
+ resolve()
+ await new Promise(settled => setTimeout(settled))
+ expect(tab(container)).toBe(false)
+ })
+
+ it("takes a component's ref as its root element", () => {
+ const Panel = defineComponent((_, { slots }) => () => (
+
+ {slots.default?.()}
+
+ ))
+ render(
+ defineComponent(() => {
+ const panel = ref | null>(null)
+ useFocusTrap(panel)
+ return () => (
+
+ first
+ last
+
+ )
+ }),
+ )
+ screen.getByText('last').focus()
+ expect(tab(screen.getByTestId('container'))).toBe(false)
+ expect(document.activeElement).toBe(screen.getByText('first'))
+ })
+
+ // An iframe's elements belong to another realm, which `instanceof` misses.
+ it('traps within an element from another document', () => {
+ const frame = document.createElement('iframe')
+ document.body.append(frame)
+ onTestFinished(() => frame.remove())
+ const frameDocument = frame.contentDocument as Document
+ const container = frameDocument.createElement('div')
+ container.append(frameDocument.createElement('button'))
+ frameDocument.body.append(container)
+ render(
+ defineComponent(() => {
+ useFocusTrap(container)
+ return () => null
+ }),
+ )
+ expect(tab(container)).toBe(false)
+ })
+})
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..4d59a07
--- /dev/null
+++ b/packages/vue/hooks/use-scroll-lock/SPEC.md
@@ -0,0 +1,62 @@
+# 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` resolves true;
+ unmounting or turning `locked` off releases it, and so does a ``
+ deactivation — reactivation locks again, as React's `` runs an
+ effect's cleanup; mounted into a view that is already deactivated, it waits
+ for the view's return, and reached by a restored view's activation before it
+ has mounted (an async component under ``), it waits for its own
+ mount. Both parameters accept a `MaybeRefOrGetter` — a plain value, a ref,
+ or a getter, a component counting as its root element (`$el`) — so the lock
+ tracks reactive state: a `target` change releases the old container and
+ locks the new one. `$el` itself isn't reactive: a component that swaps its
+ root element needs a ref on the element instead.
+- An omitted `target` means the page body. A target that resolves to
+ `null` or `undefined` — a template ref before its element renders — means
+ "no target yet" and locks nothing; the lock engages once the element
+ resolves. Mounted hooks never run during server rendering, so there is no
+ body to touch there.
+- 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 releases.
+- The deactivated-view walk and the `$el` unwrapping are local copies of the
+ dialog binding's, by design — see [its SPEC](../../dialog/SPEC.md)'s
+ `` note.
+
+## 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). `null` / `undefined` locks nothing. |
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..d4383e3
--- /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.43"
+ },
+ "peerDependencies": {
+ "vue": "^3.3.0"
+ }
+}
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..d05e3f7
--- /dev/null
+++ b/packages/vue/hooks/use-scroll-lock/src/use-scroll-lock.ts
@@ -0,0 +1,101 @@
+import {
+ effectScope,
+ getCurrentInstance,
+ onActivated,
+ onDeactivated,
+ onMounted,
+ onScopeDispose,
+ onUnmounted,
+ toValue,
+ watch,
+ type ComponentPublicInstance,
+ type EffectScope,
+ type MaybeRefOrGetter,
+} from 'vue'
+import { lockScroll } from '@dunky.dev/dom-scroll-lock'
+
+// The instance fields the walk reads, structurally: Vue 3.6 types the chain
+// as its Vapor-aware generic instance.
+interface InstanceNode {
+ isDeactivated: boolean
+ parent: InstanceNode | null
+}
+
+// Whether the component sits in a deactivated view — it mounted
+// into a cached view after the user left it, and waits for the return.
+function inDeactivatedView(instance: InstanceNode | null): boolean {
+ for (let node = instance; node !== null; node = node.parent) {
+ if (node.isDeactivated) return true
+ }
+ return false
+}
+
+// A template ref on a component holds its instance; the element is its `$el`
+// (a text or comment node for a fragment root). Duck-typed: an element from
+// another realm — an iframe's document — fails `instanceof HTMLElement`.
+function toElement(
+ target: HTMLElement | ComponentPublicInstance | null | undefined,
+): HTMLElement | null {
+ if (typeof target !== 'object' || target === null) return null
+ const element = ('$el' in target ? target.$el : target) as Node | null
+ return element?.nodeType === Node.ELEMENT_NODE ? (element as HTMLElement) : null
+}
+
+/**
+ * Locks scrolling while the component is mounted and `locked` — the Vue
+ * lifecycle around `lockScroll`. Targets the page body unless a `target` is
+ * given. A target that resolves to nothing — a template ref before its
+ * element renders — means "no target yet" and locks nothing; the lock engages
+ * once the element resolves. A component counts as its root element (`$el`).
+ * The lock is shared per container: it restores when the last holder
+ * releases.
+ */
+export function useScrollLock(
+ locked: MaybeRefOrGetter = true,
+ target?: MaybeRefOrGetter,
+): void {
+ // Held from mount — never during server rendering, where there is no body
+ // — and released after the DOM is gone, as React runs an effect's cleanup
+ // after commit. A deactivation releases it too, as React's
+ // does. Released by hand: Vue 3.6 also runs a watcher's
+ // onCleanup when its source merely re-evaluates.
+ const instance = getCurrentInstance()
+ let scope: EffectScope | undefined
+ const hold = (): void => {
+ if (scope !== undefined) return
+ // Detached: the component scope stops before the DOM is removed.
+ scope = effectScope(true)
+ scope.run(() => {
+ let unlock: (() => void) | undefined
+ watch(
+ [
+ () => toValue(locked),
+ () => (target === undefined ? document.body : toElement(toValue(target))),
+ ],
+ ([isLocked, container]) => {
+ unlock?.()
+ unlock = !isLocked || container === null ? undefined : lockScroll(container)
+ },
+ { immediate: true, flush: 'post' },
+ )
+ onScopeDispose(() => unlock?.())
+ })
+ }
+ const drop = (): void => {
+ scope?.stop()
+ scope = undefined
+ }
+ let mounted = false
+ onMounted(() => {
+ mounted = true
+ if (!inDeactivatedView(instance)) hold()
+ })
+ // Also fires on a kept-alive first mount, right after `onMounted` — and can
+ // reach a component that hasn't mounted yet (an async one under
+ // ), which `onMounted` starts instead.
+ onActivated(() => {
+ if (mounted) hold()
+ })
+ onDeactivated(drop)
+ onUnmounted(drop)
+}
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..9221486
--- /dev/null
+++ b/packages/vue/hooks/use-scroll-lock/tests/use-scroll-lock.test.ts
@@ -0,0 +1,157 @@
+// @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 { KeepAlive, Suspense, defineComponent, h, nextTick, ref } from 'vue'
+import { cleanup, render } from '@testing-library/vue'
+import { afterEach, describe, expect, it, onTestFinished } from 'vitest'
+import { useScrollLock } from '@dunky.dev/vue-use-scroll-lock'
+
+// TL Vue has no renderHook: a component that only calls the composable.
+const Locker = (...args: Parameters) =>
+ defineComponent({
+ setup() {
+ useScrollLock(...args)
+ return () => null
+ },
+ })
+
+const mountLock = (...args: Parameters): ReturnType =>
+ render(Locker(...args))
+
+// Auto-cleanup needs vitest globals; this repo runs with globals: false. A
+// test that fails before its end must not leave the body locked.
+afterEach(cleanup)
+
+describe('useScrollLock', () => {
+ it('locks body scroll while mounted and releases on unmount', () => {
+ const { unmount } = mountLock()
+ expect(document.body.style.overflowY).toBe('hidden')
+
+ unmount()
+ expect(document.body.style.overflowY).toBe('')
+ })
+
+ it('does not lock when locked=false', () => {
+ mountLock(false)
+ expect(document.body.style.overflowY).toBe('')
+ })
+
+ it('locks nothing when target is null — a target not yet resolved', () => {
+ mountLock(true, () => null)
+ expect(document.body.style.overflowY).toBe('')
+ })
+
+ // `ref()` holds undefined until its element renders.
+ it('locks nothing while a template ref is still empty, and its element once it renders', async () => {
+ const shown = ref(false)
+ const target = ref()
+ render(
+ defineComponent({
+ setup() {
+ useScrollLock(true, target)
+ return () => h('div', shown.value ? [h('section', { ref: target })] : [])
+ },
+ }),
+ )
+ expect(document.body.style.overflowY).toBe('')
+
+ shown.value = true
+ await nextTick()
+ expect(target.value?.style.overflowY).toBe('hidden')
+ expect(document.body.style.overflowY).toBe('')
+ })
+
+ it('follows a reactive locked flag, releasing when it turns off', async () => {
+ const locked = ref(true)
+ mountLock(locked)
+ expect(document.body.style.overflowY).toBe('hidden')
+
+ locked.value = false
+ await nextTick()
+ expect(document.body.style.overflowY).toBe('')
+ })
+
+ it('releases while a KeepAlive deactivation holds the component, and locks again on reactivation', async () => {
+ const shown = ref(true)
+ const Lock = Locker()
+ render(() => h(KeepAlive, null, () => (shown.value ? h(Lock) : null)))
+ expect(document.body.style.overflowY).toBe('hidden')
+
+ shown.value = false
+ await nextTick()
+ expect(document.body.style.overflowY).toBe('')
+
+ shown.value = true
+ await nextTick()
+ expect(document.body.style.overflowY).toBe('hidden')
+ })
+
+ it('holds nothing when mounted into a deactivated KeepAlive view, and locks once it returns', async () => {
+ const shown = ref(true)
+ const loaded = ref(false)
+ const Lock = Locker()
+ const Page = defineComponent(() => () => (loaded.value ? h(Lock) : null))
+ render(() => h(KeepAlive, null, () => (shown.value ? h(Page) : null)))
+ shown.value = false
+ await nextTick()
+
+ loaded.value = true // mounts the locker into the cached view
+ await nextTick()
+ expect(document.body.style.overflowY).toBe('')
+
+ shown.value = true
+ await nextTick()
+ expect(document.body.style.overflowY).toBe('hidden')
+ })
+
+ // A restored view's activation reaches every part it holds, mounted or
+ // not: an async one still pending under waits for its own mount.
+ it('holds nothing for an async component a restored view reaches before it mounts', async () => {
+ let resolve = (): void => {}
+ const pending = new Promise(done => (resolve = done))
+ const Async = defineComponent({
+ async setup() {
+ useScrollLock()
+ await pending
+ return () => null
+ },
+ })
+ const shown = ref(true)
+ const Page = defineComponent(() => () => h(Suspense, null, { default: () => h(Async) }))
+ render(() => h(KeepAlive, null, () => (shown.value ? h(Page) : null)))
+ shown.value = false
+ await nextTick()
+ shown.value = true
+ await nextTick()
+ expect(document.body.style.overflowY).toBe('')
+
+ resolve()
+ await new Promise(settled => setTimeout(settled))
+ expect(document.body.style.overflowY).toBe('hidden')
+ })
+
+ it("takes a component's ref as its root element", () => {
+ const Surface = defineComponent(() => () => h('section'))
+ const surface = ref | null>(null)
+ render(
+ defineComponent({
+ setup() {
+ useScrollLock(true, surface)
+ return () => h(Surface, { ref: surface })
+ },
+ }),
+ )
+ expect((surface.value?.$el as HTMLElement | undefined)?.style.overflowY).toBe('hidden')
+ expect(document.body.style.overflowY).toBe('')
+ })
+
+ // An iframe's elements belong to another realm, which `instanceof` misses.
+ it('locks an element from another document', () => {
+ const frame = document.createElement('iframe')
+ document.body.append(frame)
+ onTestFinished(() => frame.remove())
+ const surface = frame.contentDocument?.body ?? null
+ mountLock(true, surface)
+ expect(surface?.style.overflowY).toBe('hidden')
+ })
+})
diff --git a/packages/vue/package.json b/packages/vue/package.json
new file mode 100644
index 0000000..20aded9
--- /dev/null
+++ b/packages/vue/package.json
@@ -0,0 +1,18 @@
+{
+ "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.6.1",
+ "@vitejs/plugin-vue-jsx": "^5.1.6",
+ "storybook": "^10.6.1",
+ "vite": "^8.1.4",
+ "vue": "^3.5.43"
+ }
+}
diff --git a/packages/vue/tsconfig.json b/packages/vue/tsconfig.json
new file mode 100644
index 0000000..8da94a5
--- /dev/null
+++ b/packages/vue/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ // Vue's TSX (tests) needs Vue's JSX namespace, not React's — so
+ // packages/vue typechecks as its own project (the root tsconfig excludes
+ // it; `pnpm typecheck` runs both). `paths` is inherited from the root file.
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "jsx": "preserve",
+ "jsxImportSource": "vue"
+ },
+ "include": ["."],
+ "exclude": ["**/node_modules", "**/dist"]
+}
diff --git a/packages/vue/vitest.config.ts b/packages/vue/vitest.config.ts
new file mode 100644
index 0000000..ece52a1
--- /dev/null
+++ b/packages/vue/vitest.config.ts
@@ -0,0 +1,21 @@
+import vueJsx from '@vitejs/plugin-vue-jsx'
+import { defineConfig } from 'vitest/config'
+
+export default defineConfig({
+ // The TSX tests compile through Vue's JSX transform, which must not rewrite
+ // the React `.tsx` tests — hence a project of its own.
+ plugins: [vueJsx()],
+ resolve: {
+ // A linked state-machine adapter (cross-repo development) resolves `vue`
+ // from its own checkout; two copies split the component instance, so
+ // every import is pinned to this workspace's.
+ dedupe: ['vue'],
+ },
+ test: {
+ name: 'vue',
+ globals: false,
+ // node by default; DOM tests opt into jsdom per-file via `@vitest-environment`.
+ environment: 'node',
+ include: ['**/tests/**/*.test.{ts,tsx}'],
+ },
+})
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 56c79a4..6d0d34f 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -10,6 +10,7 @@ minimumReleaseAgeExclude:
- '@dunky.dev/state-machine-dom@0.1.0'
- '@dunky.dev/state-machine-utils@0.4.0'
- '@dunky.dev/state-machine@0.3.3'
+ - '@dunky.dev/vue-state-machine@0.3.0'
- '@dom-expressions/babel-plugin-jsx@0.50.0-next.43'
- '@solidjs/signals@2.0.0-rc.1'
- '@solidjs/web@2.0.0-rc.1'
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..5269535
--- /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.3.0"
+ },
+ "devDependencies": {
+ "@testing-library/vue": "^8.1.0",
+ "vue": "^3.5.43"
+ },
+ "peerDependencies": {
+ "vue": "^3.4.0"
+ }
+}
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..7633e49
--- /dev/null
+++ b/scripts/templates/packages/vue/__name__/src/__name__.ts
@@ -0,0 +1,97 @@
+import {
+ defineComponent,
+ h,
+ provide,
+ type ButtonHTMLAttributes,
+ type ComponentOptionsMixin,
+ type DefineComponent,
+ type EmitsOptions,
+} 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__ } from './use-__name__'
+
+// The options-carrying type an SFC gets: the function-signature
+// defineComponent infers a bare constructor, which tooling built around SFCs
+// (Storybook's Meta) rejects. The value is the same options object either
+// way, so each part's cast only names it — and gives the export the explicit
+// type --isolatedDeclarations needs.
+type __Name__Component = DefineComponent<
+ Props,
+ {},
+ {},
+ {},
+ {},
+ ComponentOptionsMixin,
+ ComponentOptionsMixin,
+ Emits
+>
+
+// Vue casts an absent Boolean prop to `false`; `default: undefined` keeps it
+// absent, so the core's defaults hold.
+const booleanOption = { type: Boolean, default: undefined }
+
+// =============================================================================
+// <__Name__> — root, owns the machine and renders no DOM
+// =============================================================================
+
+/** The core options; the core callbacks are the emits (`__Name__Emits`). */
+export interface __Name__Props extends Omit<__Name__Options, keyof __Name__Callbacks> {}
+
+/** The core callbacks as emits, listeners called synchronously. */
+export type __Name__Emits = {
+ /** Fired when the primitive becomes disabled. */
+ disable: () => void
+}
+
+const __Name__Root = defineComponent<__Name__Props, __Name__Emits>(
+ (props, { emit, slots }) => {
+ // Built once: a fresh forwarder per read would re-run the effects keyed on
+ // a callback on every prop change.
+ const callbacks: __Name__Callbacks = { disable: () => emit('disable') }
+ const value = use__Name__(() => ({ ...props, ...callbacks }))
+ provide(__Name__ContextKey, value)
+ return () => slots.default?.()
+ },
+ {
+ name: '__Name__',
+ // Renders no element of its own.
+ inheritAttrs: false,
+ props: { disabled: booleanOption },
+ emits: ['disable'],
+ },
+) as __Name__Component<__Name__Props, __Name__Emits>
+
+// =============================================================================
+// <__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 = defineComponent(
+ (_props: __Name__RootProps, { attrs, slots }) => {
+ const { api } = use__Name__Context()
+ return () =>
+ h(
+ 'button',
+ mergeProps({ type: 'button', ...attrs }, normalize(api.value.parts.root)),
+ slots.default?.(),
+ )
+ },
+ { name: '__Name__Root', inheritAttrs: false },
+) as __Name__Component<__Name__RootProps>
+
+// Parts
+// -----------------------------------------------------------------------------
+
+export interface Parts {
+ Root: typeof Root
+}
+
+export const __Name__: __Name__Component<__Name__Props, __Name__Emits> & Parts = Object.assign(
+ __Name__Root,
+ { 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..def8656
--- /dev/null
+++ b/scripts/templates/packages/vue/__name__/src/context.ts
@@ -0,0 +1,19 @@
+import { inject, type ComputedRef, type InjectionKey } from 'vue'
+import type { __Name__Api, __Name__Machine } from '@dunky.dev/__name__'
+
+export interface __Name__ContextValue {
+ // The connected api: its value is replaced on every machine change, so a
+ // part whose render reads it re-renders exactly then.
+ 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, null)
+ if (context === null) {
+ 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..c4bf220
--- /dev/null
+++ b/scripts/templates/packages/vue/__name__/src/effects.ts
@@ -0,0 +1,23 @@
+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 each entry after mount and re-runs it when a listed prop
+// changes.
+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
+// anything else a DOM host would write identically do NOT belong here — they
+// live in @dunky.dev/dom-__name__, so every DOM substrate shares one copy.
+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..87039d6
--- /dev/null
+++ b/scripts/templates/packages/vue/__name__/src/index.ts
@@ -0,0 +1,7 @@
+export {
+ __Name__,
+ type __Name__Props,
+ type __Name__Emits,
+ 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..8759ebc
--- /dev/null
+++ b/scripts/templates/packages/vue/__name__/src/use-__name__.ts
@@ -0,0 +1,22 @@
+import { computed, toValue, type MaybeRefOrGetter } from 'vue'
+import { useMachine } from '@dunky.dev/vue-state-machine'
+import { __camelName__Machine, __camelName__Connect } from '@dunky.dev/__name__'
+import type { __Name__Options } from '@dunky.dev/__name__'
+
+import type { __Name__ContextValue } from './context'
+import { __camelName__Effects } from './effects'
+
+/**
+ * Owns one __name__ machine for the <__Name__> root: created once, options
+ * re-synced through the reactive getter, effects run after mount, api a ref.
+ */
+export function use__Name__(options: MaybeRefOrGetter<__Name__Options>): __Name__ContextValue {
+ // A computed: the adapter reads the options from every effect's dependency
+ // getter, and the object is built once per change rather than per read.
+ return useMachine(
+ __camelName__Machine,
+ __camelName__Connect,
+ __camelName__Effects,
+ computed(() => toValue(options)),
+ )
+}
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..9aefb64
--- /dev/null
+++ b/scripts/templates/packages/vue/__name__/stories/__name__.stories.ts
@@ -0,0 +1,33 @@
+import type { Component } 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
+
+// Runtime-compiled templates resolve components by registered name, so the
+// dotted part names an SFC resolves from the `__Name__` import are registered
+// as such, derived from the parts themselves so none can be missed.
+const components: Record = { __Name__ }
+for (const [name, part] of Object.entries(__Name__)) {
+ if (/^[A-Z]/.test(name)) components[`__Name__.${name}`] = part as Component
+}
+
+// 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: () => ({
+ components,
+ setup: () => ({ disable: () => console.log('disabled') }),
+ template: `
+ <__Name__ @disable="disable">
+ <__Name__.Root>go
+
+ `,
+ }),
+}
diff --git a/scripts/templates/packages/vue/__name__/tests/__name__.test.tsx b/scripts/templates/packages/vue/__name__/tests/__name__.test.tsx
new file mode 100644
index 0000000..cf3b25e
--- /dev/null
+++ b/scripts/templates/packages/vue/__name__/tests/__name__.test.tsx
@@ -0,0 +1,43 @@
+// @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 { nextTick, ref, type EmitsToProps } from 'vue'
+import { cleanup, render, screen } from '@testing-library/vue'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { __Name__, type __Name__Emits, type __Name__Props } from '@dunky.dev/vue-__name__'
+
+const Default__Name__ = (props: __Name__Props & EmitsToProps<__Name__Emits>) => (
+ <__Name__ {...props}>
+ <__Name__.Root>go
+
+)
+
+// Auto-cleanup needs vitest globals; this repo runs with globals: false.
+afterEach(cleanup)
+
+describe('__Name__', () => {
+ it('disables on press', () => {
+ const onDisable = vi.fn()
+ render(() => )
+ screen.getByRole('button').click()
+ expect(onDisable).toHaveBeenCalledTimes(1)
+ })
+
+ it('fires disable when the controlled disabled prop turns on', async () => {
+ const onDisable = vi.fn()
+ const disabled = ref(false)
+ render(() => )
+ expect(onDisable).not.toHaveBeenCalled()
+
+ disabled.value = true
+ await nextTick() // Vue batches the update into a microtask
+ expect(onDisable).toHaveBeenCalledTimes(1)
+ })
+
+ it('translates the core bindings onto the element', () => {
+ render(() => )
+ const root = screen.getByRole('button')
+ expect(root.getAttribute('data-state')).toBe('idle')
+ expect(root.getAttribute('aria-disabled')).toBe('true')
+ })
+})
diff --git a/tsconfig.json b/tsconfig.json
index 4e39ec3..f7dc284 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -20,6 +20,7 @@
"@dunky.dev/native-dialog": ["./packages/native/dialog/src"],
"@dunky.dev/react-dialog": ["./packages/react/dialog/src"],
"@dunky.dev/solid-dialog": ["./packages/solid/dialog/src"],
+ "@dunky.dev/vue-dialog": ["./packages/vue/dialog/src"],
"@dunky.dev/dom-dialog": ["./packages/dom/components/dialog/src"],
"@dunky.dev/dom-overlay": ["./packages/dom/utils/overlay/src"],
"@dunky.dev/dom-focus-trap": ["./packages/dom/utils/focus-trap/src"],
@@ -29,21 +30,25 @@
"@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/solid-use-focus-trap": ["./packages/solid/hooks/use-focus-trap/src"],
- "@dunky.dev/solid-use-scroll-lock": ["./packages/solid/hooks/use-scroll-lock/src"]
+ "@dunky.dev/solid-use-scroll-lock": ["./packages/solid/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"]
},
"include": ["./*.ts", "./packages"],
// The native Expo shell (entry + .rnstorybook) imports the generated,
// gitignored storybook.requires.ts — Metro compiles that glue, tsc would
- // only ever see the missing module in CI. packages/solid needs Solid's JSX
- // namespace (`jsx: preserve` + solid-js import source), so it typechecks as
- // its own project — packages/solid/tsconfig.json, run by `pnpm typecheck`.
+ // only ever see the missing module in CI. packages/solid and packages/vue
+ // need their framework's JSX namespace (`jsx: preserve` + its import
+ // source), so each typechecks as its own project —
+ // packages//tsconfig.json, run by `pnpm typecheck`.
"exclude": [
"**/node_modules",
"**/dist",
"packages/native/index.ts",
"packages/native/.rnstorybook",
- "packages/solid"
+ "packages/solid",
+ "packages/vue"
]
}
diff --git a/tsdown.config.ts b/tsdown.config.ts
index 2704eb7..57aaa4a 100644
--- a/tsdown.config.ts
+++ b/tsdown.config.ts
@@ -26,6 +26,9 @@ export default defineConfig({
'packages/solid/dialog',
'packages/solid/hooks/use-focus-trap',
'packages/solid/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'],
diff --git a/vitest.config.ts b/vitest.config.ts
index 9f786a4..2189d89 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -1,8 +1,8 @@
import { defineConfig } from 'vitest/config'
-// Two projects: the Solid tests need vite-plugin-solid's JSX transform, which
-// must not rewrite the React `.tsx` tests. The solid project lives with its
-// substrate (packages/solid/vitest.config.ts).
+// One project per JSX flavor: the Solid and Vue tests need their framework's
+// JSX transform, which must not rewrite the React `.tsx` tests. Each lives
+// with its substrate (packages//vitest.config.ts).
export default defineConfig({
test: {
projects: [
@@ -20,12 +20,14 @@ export default defineConfig({
'scripts/templates/**',
'packages/native/**',
'packages/solid/**',
+ 'packages/vue/**',
'**/.worktrees/**',
'**/.claude/**',
],
},
},
'./packages/solid/vitest.config.ts',
+ './packages/vue/vitest.config.ts',
],
},
})