From 4ea03923eacdabcc1796a6096728a2948ee1dab1 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Fri, 14 Aug 2026 12:23:09 +0200 Subject: [PATCH 1/2] feat(vue): the Vue substrate, opening with Dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new substrate under packages/vue, delivering the dialog machine to Vue 3 with the same anatomy and behavior contract as the React binding: - @dunky.dev/vue-dialog — the compound component (Trigger, Portal, Backdrop, Viewport, Content, Title, Description, Close) in Vue's native shape: core options as props, core callbacks as emits, v-model:open as the controlled contract. Content's initialFocus takes a MaybeRefOrGetter, resolved at open time so late-filling template refs work. - @dunky.dev/vue-use-focus-trap / vue-use-scroll-lock — the Vue lifecycle wrappers over the framework-free DOM utils. - @dunky-dev/vue — the private substrate harness (Storybook vue3-vite, pnpm dev:vue), with the story set mirroring the React scenarios. - scripts/templates/packages/vue — the scaffold stamps future primitives into this substrate too. The full React dialog behavior suite is ported (35 tests): dismissal gating and vetoes, controlled contract, ARIA wiring, focus management, layer stack containment, scroll lock scoping, back navigation, exit animations, nesting. Blocked on dunky-dev/state-machine#33: @dunky.dev/vue-state-machine is not on npm yet, so the lockfile is not committed here — refresh it once the adapter publishes. Co-Authored-By: Claude Fable 5 --- .changeset/vue-dialog.md | 40 + .changeset/vue-hooks.md | 12 + knip.config.ts | 3 + package.json | 1 + packages/vue/.storybook/main.ts | 15 + packages/vue/.storybook/manager.ts | 14 + packages/vue/dialog/README.md | 41 + packages/vue/dialog/SPEC.md | 185 ++++ packages/vue/dialog/package.json | 53 ++ packages/vue/dialog/src/context.ts | 29 + packages/vue/dialog/src/dialog.ts | 422 +++++++++ packages/vue/dialog/src/effects.ts | 28 + packages/vue/dialog/src/index.ts | 13 + packages/vue/dialog/src/use-dialog.ts | 32 + packages/vue/dialog/stories/dialog.stories.ts | 807 +++++++++++++++++ packages/vue/dialog/tests/dialog.test.ts | 827 ++++++++++++++++++ packages/vue/hooks/use-focus-trap/README.md | 28 + packages/vue/hooks/use-focus-trap/SPEC.md | 53 ++ .../vue/hooks/use-focus-trap/package.json | 48 + .../vue/hooks/use-focus-trap/src/index.ts | 1 + .../use-focus-trap/src/use-focus-trap.ts | 32 + .../tests/use-focus-trap.test.ts | 66 ++ packages/vue/hooks/use-scroll-lock/README.md | 28 + packages/vue/hooks/use-scroll-lock/SPEC.md | 48 + .../vue/hooks/use-scroll-lock/package.json | 48 + .../vue/hooks/use-scroll-lock/src/index.ts | 1 + .../use-scroll-lock/src/use-scroll-lock.ts | 19 + .../tests/use-scroll-lock.test.ts | 36 + packages/vue/package.json | 17 + pnpm-workspace.yaml | 1 + .../templates/packages/vue/__name__/README.md | 29 + .../templates/packages/vue/__name__/SPEC.md | 36 + .../packages/vue/__name__/package.json | 49 ++ .../packages/vue/__name__/src/__name__.ts | 75 ++ .../packages/vue/__name__/src/context.ts | 17 + .../packages/vue/__name__/src/effects.ts | 21 + .../packages/vue/__name__/src/index.ts | 2 + .../packages/vue/__name__/src/use-__name__.ts | 24 + .../vue/__name__/stories/__name__.stories.ts | 22 + .../vue/__name__/tests/__name__.test.ts | 44 + tsconfig.json | 5 +- tsdown.config.ts | 3 + 42 files changed, 3274 insertions(+), 1 deletion(-) create mode 100644 .changeset/vue-dialog.md create mode 100644 .changeset/vue-hooks.md create mode 100644 packages/vue/.storybook/main.ts create mode 100644 packages/vue/.storybook/manager.ts create mode 100644 packages/vue/dialog/README.md create mode 100644 packages/vue/dialog/SPEC.md create mode 100644 packages/vue/dialog/package.json create mode 100644 packages/vue/dialog/src/context.ts create mode 100644 packages/vue/dialog/src/dialog.ts create mode 100644 packages/vue/dialog/src/effects.ts create mode 100644 packages/vue/dialog/src/index.ts create mode 100644 packages/vue/dialog/src/use-dialog.ts create mode 100644 packages/vue/dialog/stories/dialog.stories.ts create mode 100644 packages/vue/dialog/tests/dialog.test.ts create mode 100644 packages/vue/hooks/use-focus-trap/README.md create mode 100644 packages/vue/hooks/use-focus-trap/SPEC.md create mode 100644 packages/vue/hooks/use-focus-trap/package.json create mode 100644 packages/vue/hooks/use-focus-trap/src/index.ts create mode 100644 packages/vue/hooks/use-focus-trap/src/use-focus-trap.ts create mode 100644 packages/vue/hooks/use-focus-trap/tests/use-focus-trap.test.ts create mode 100644 packages/vue/hooks/use-scroll-lock/README.md create mode 100644 packages/vue/hooks/use-scroll-lock/SPEC.md create mode 100644 packages/vue/hooks/use-scroll-lock/package.json create mode 100644 packages/vue/hooks/use-scroll-lock/src/index.ts create mode 100644 packages/vue/hooks/use-scroll-lock/src/use-scroll-lock.ts create mode 100644 packages/vue/hooks/use-scroll-lock/tests/use-scroll-lock.test.ts create mode 100644 packages/vue/package.json create mode 100644 scripts/templates/packages/vue/__name__/README.md create mode 100644 scripts/templates/packages/vue/__name__/SPEC.md create mode 100644 scripts/templates/packages/vue/__name__/package.json create mode 100644 scripts/templates/packages/vue/__name__/src/__name__.ts create mode 100644 scripts/templates/packages/vue/__name__/src/context.ts create mode 100644 scripts/templates/packages/vue/__name__/src/effects.ts create mode 100644 scripts/templates/packages/vue/__name__/src/index.ts create mode 100644 scripts/templates/packages/vue/__name__/src/use-__name__.ts create mode 100644 scripts/templates/packages/vue/__name__/stories/__name__.stories.ts create mode 100644 scripts/templates/packages/vue/__name__/tests/__name__.test.ts 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 + + + +``` + +`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 + + + +``` 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 + + + +``` + +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 + + + +``` 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 + + + +``` + + +## 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'], From 14e672c06299457dd2819cf2037478c1b1669664 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Fri, 14 Aug 2026 12:40:37 +0200 Subject: [PATCH 2/2] docs(vue): tighten the comments that outgrew the react binding's register Co-Authored-By: Claude Fable 5 --- packages/vue/dialog/src/context.ts | 5 ++--- packages/vue/dialog/src/dialog.ts | 3 +-- packages/vue/dialog/src/use-dialog.ts | 5 ++--- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/vue/dialog/src/context.ts b/packages/vue/dialog/src/context.ts index 7ed5d95..e22820b 100644 --- a/packages/vue/dialog/src/context.ts +++ b/packages/vue/dialog/src/context.ts @@ -8,9 +8,8 @@ export interface DialogContextValue { // Escape, focus, and assistive-tech containment. depth: number // The element the Portal teleported into, or null for the page body — - // Content scopes the scroll lock to it. Read lazily (a getter, so the - // Portal's prop stays live): the root provides null; Portal re-provides - // the context with the field filled in. + // Content scopes the scroll lock to it. A getter so the Portal's prop stays + // live: the root provides null; Portal re-provides the context filled in. container: () => HTMLElement | null // The rendered Backdrop element, shared because Backdrop and Content are // sibling parts: Content's stack entry excepts its own backdrop from the diff --git a/packages/vue/dialog/src/dialog.ts b/packages/vue/dialog/src/dialog.ts index 6972145..7e14194 100644 --- a/packages/vue/dialog/src/dialog.ts +++ b/packages/vue/dialog/src/dialog.ts @@ -252,8 +252,7 @@ export const Content: PartComponent = defineComponent({ // One watcher keeps the ordering right both ways: the stack joins before // focus moves in, and on close it must release the layers beneath // (un-inert them) before focus can move back out to one of them. The - // element is part of the source — a template ref only fills after setup, - // so the open edge of a defaultOpen dialog arrives with the element. + // element is part of the source: a template ref only fills after setup. watch( () => [api.value.open, contentEl.value] as const, ([open, content], _previous, onCleanup) => { diff --git a/packages/vue/dialog/src/use-dialog.ts b/packages/vue/dialog/src/use-dialog.ts index a29a175..b15230b 100644 --- a/packages/vue/dialog/src/use-dialog.ts +++ b/packages/vue/dialog/src/use-dialog.ts @@ -13,9 +13,8 @@ export function useDialog( emit: DialogEmit, ): { api: ComputedRef; machine: DialogMachine } { const id = useId() - // The core callbacks forward to emits: listeners run synchronously, so the - // veto contract (payload.preventDefault) holds unchanged, and attach/detach - // stays live without ever changing these wrappers' identity. + // The core callbacks forward to emits — listeners run synchronously, so the + // veto contract (payload.preventDefault) holds unchanged. const callbacks: DialogCallbacks = { onOpenChange: open => emit('update:open', open), onEscapeKeyDown: event => emit('escapeKeyDown', event),