From 4ea03923eacdabcc1796a6096728a2948ee1dab1 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Fri, 14 Aug 2026 12:23:09 +0200 Subject: [PATCH 01/33] 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 02/33] 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), From 4f44bf1e8555e84fc6c7cacd7c2922946d2da470 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Mon, 5 Oct 2026 07:22:57 +0200 Subject: [PATCH 03/33] feat(vue): rebuild the focus-trap and scroll-lock hooks on mount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both hooks bound their DOM work from setup — a watchEffect and an immediate watcher — which also runs during server rendering, where there is no document, and before a template ref has filled. They now arm on mount and follow their reactive inputs from there, mirroring the Solid hooks' shape: a MaybeRefOrGetter target (and `locked` flag), an omitted target meaning the page body and `null` meaning "no target yet". A deactivation releases them like an unmount and reactivation arms them again — the adapter pauses a kept-alive machine the same way, as React's runs its effect cleanups. Without it a cached view kept the page locked and Tab trapped in a detached container. Tests port the React/Solid suites (the scroll-lock `null` target case was missing) plus the reactive and KeepAlive lifecycles; SPEC and README follow the Solid hooks' format. Co-Authored-By: Claude Opus 5.5 --- packages/vue/hooks/use-focus-trap/README.md | 8 +-- packages/vue/hooks/use-focus-trap/SPEC.md | 26 +++---- .../vue/hooks/use-focus-trap/package.json | 4 +- .../use-focus-trap/src/use-focus-trap.ts | 65 +++++++++++------ .../tests/use-focus-trap.test.ts | 66 ----------------- .../tests/use-focus-trap.test.tsx | 72 +++++++++++++++++++ packages/vue/hooks/use-scroll-lock/SPEC.md | 24 ++++--- .../vue/hooks/use-scroll-lock/package.json | 4 +- .../use-scroll-lock/src/use-scroll-lock.ts | 53 +++++++++++--- .../tests/use-scroll-lock.test.ts | 70 +++++++++++++----- 10 files changed, 245 insertions(+), 147 deletions(-) delete mode 100644 packages/vue/hooks/use-focus-trap/tests/use-focus-trap.test.ts create mode 100644 packages/vue/hooks/use-focus-trap/tests/use-focus-trap.test.tsx diff --git a/packages/vue/hooks/use-focus-trap/README.md b/packages/vue/hooks/use-focus-trap/README.md index 985a538..d7d8ce2 100644 --- a/packages/vue/hooks/use-focus-trap/README.md +++ b/packages/vue/hooks/use-focus-trap/README.md @@ -1,9 +1,9 @@ # @dunky.dev/vue-use-focus-trap Vue binding for [`@dunky.dev/dom-focus-trap`](../../../dom/utils/focus-trap): -`useFocusTrap(ref)` traps Tab / Shift+Tab within the referenced container while -it exists. The trap behavior itself is framework-free — this composable only -owns the Vue lifecycle. +`useFocusTrap(target)` traps Tab / Shift+Tab within the target container while +the component is mounted. The trap behavior itself is framework-free — this +composable only owns the Vue lifecycle. ## Install @@ -23,6 +23,6 @@ useFocusTrap(panel, { enabled: () => isTopmost(panel.value) }) ``` diff --git a/packages/vue/hooks/use-focus-trap/SPEC.md b/packages/vue/hooks/use-focus-trap/SPEC.md index c7a4441..81abe2d 100644 --- a/packages/vue/hooks/use-focus-trap/SPEC.md +++ b/packages/vue/hooks/use-focus-trap/SPEC.md @@ -26,20 +26,20 @@ useFocusTrap(panel, { enabled: () => isTopmostLayer(props.id) }) ``` 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. +- The target is a `MaybeRefOrGetter` — a template ref, a getter, or the + element itself. 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. Mounted hooks never run during server rendering, so + the trap touches no DOM there. +- 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 @@ -47,7 +47,7 @@ Vue-specific notes on top of the DOM contract: 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`. | +| 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 index 6f25456..07dc807 100644 --- a/packages/vue/hooks/use-focus-trap/package.json +++ b/packages/vue/hooks/use-focus-trap/package.json @@ -40,9 +40,9 @@ }, "devDependencies": { "@testing-library/vue": "^8.1.0", - "vue": "^3.5.41" + "vue": "^3.5.43" }, "peerDependencies": { - "vue": "*" + "vue": "^3.3.0" } } 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 index fe59f1e..da0c8c9 100644 --- a/packages/vue/hooks/use-focus-trap/src/use-focus-trap.ts +++ b/packages/vue/hooks/use-focus-trap/src/use-focus-trap.ts @@ -1,32 +1,55 @@ -import { watch, type Ref } from 'vue' +import { + effectScope, + onActivated, + onBeforeUnmount, + onDeactivated, + onMounted, + toValue, + watch, + 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 {} /** - * 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. + * 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. */ export function useFocusTrap( - target: Ref, + target: MaybeRefOrGetter, 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' }, - ) + // Armed from mount, when a template ref has filled; a + // deactivation releases it like an unmount, as React's runs the + // effect's cleanup. Mounted hooks never run during server rendering. The + // trap reads the options object per Tab press, so `enabled` / `last` stay + // live without re-binding. + let scope: EffectScope | undefined + const arm = (): void => { + if (scope !== undefined) return + scope = effectScope() + scope.run(() => + watch( + () => toValue(target), + (container, _previous, onCleanup) => { + if (container == null) return + onCleanup(trapFocus(container, options)) + }, + { immediate: true, flush: 'post' }, + ), + ) + } + const release = (): void => { + scope?.stop() + scope = undefined + } + onMounted(arm) + // Also fires on a kept-alive first mount, right after `onMounted`. + onActivated(arm) + onDeactivated(release) + onBeforeUnmount(release) } 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 deleted file mode 100644 index 6785de1..0000000 --- a/packages/vue/hooks/use-focus-trap/tests/use-focus-trap.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -// @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-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..bdab801 --- /dev/null +++ b/packages/vue/hooks/use-focus-trap/tests/use-focus-trap.test.tsx @@ -0,0 +1,72 @@ +// @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, defineComponent, nextTick, ref, type PropType } 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 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 () => ( +
+ + +
+ ) + }, +}) + +// 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 }), + ) + +// 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 container = screen.getByTestId('container') + screen.getByText('last').focus() + unmount() + // The listener is gone with the unmount — a Tab on the detached container + // is no longer intercepted. + expect(tab(container)).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') + + shown.value = false + await nextTick() + expect(tab(container)).toBe(true) + + shown.value = true + await nextTick() + expect(tab(container)).toBe(false) + }) +}) diff --git a/packages/vue/hooks/use-scroll-lock/SPEC.md b/packages/vue/hooks/use-scroll-lock/SPEC.md index c5cfde2..57edc02 100644 --- a/packages/vue/hooks/use-scroll-lock/SPEC.md +++ b/packages/vue/hooks/use-scroll-lock/SPEC.md @@ -27,14 +27,18 @@ useScrollLock() // the page behind can't scroll while mounted 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 lock holds while the component is mounted and `locked` resolves true; + unmounting or turning `locked` off releases it. Both parameters accept a + `MaybeRefOrGetter` — a plain value, a ref, or a getter — so the lock + tracks reactive state: a `target` change releases the old container and + locks the new one. +- An omitted `target` means the page body; `null` means "no target yet" + and locks nothing — the lock engages once a ref or getter resolves the + element. 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 unmounts. + and the container restores when the last releases. ## API @@ -42,7 +46,7 @@ Vue-specific notes on top of the DOM contract: 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). | +| 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` locks nothing. | diff --git a/packages/vue/hooks/use-scroll-lock/package.json b/packages/vue/hooks/use-scroll-lock/package.json index 75c8a10..d4383e3 100644 --- a/packages/vue/hooks/use-scroll-lock/package.json +++ b/packages/vue/hooks/use-scroll-lock/package.json @@ -40,9 +40,9 @@ }, "devDependencies": { "@testing-library/vue": "^8.1.0", - "vue": "^3.5.41" + "vue": "^3.5.43" }, "peerDependencies": { - "vue": "*" + "vue": "^3.3.0" } } 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 index 871e36d..6f965cc 100644 --- a/packages/vue/hooks/use-scroll-lock/src/use-scroll-lock.ts +++ b/packages/vue/hooks/use-scroll-lock/src/use-scroll-lock.ts @@ -1,19 +1,52 @@ -import { toValue, watchEffect, type MaybeRefOrGetter } from 'vue' +import { + effectScope, + onActivated, + onBeforeUnmount, + onDeactivated, + onMounted, + toValue, + watch, + type EffectScope, + 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. + * Locks scrolling while the component is mounted and `locked` — the Vue + * lifecycle around `lockScroll`. Targets the page body unless a `target` is + * given. A `null` target means "no target yet" and locks nothing; pass a ref + * or a getter and the lock engages once the element resolves. The lock is + * shared per container: it restores when the last holder releases. */ export function useScrollLock( locked: MaybeRefOrGetter = true, target?: MaybeRefOrGetter, ): void { - watchEffect(onCleanup => { - if (!toValue(locked)) return - onCleanup(lockScroll(toValue(target) ?? undefined)) - }) + // Held from mount; a deactivation releases it like an unmount, + // as React's runs the effect's cleanup. Mounted hooks never run + // during server rendering, where there is no body to lock. + let scope: EffectScope | undefined + const hold = (): void => { + if (scope !== undefined) return + scope = effectScope() + scope.run(() => + watch( + [() => toValue(locked), () => (target === undefined ? undefined : toValue(target))], + ([isLocked, container], _previous, onCleanup) => { + if (!isLocked || container === null) return + onCleanup(lockScroll(container)) + }, + { immediate: true, flush: 'post' }, + ), + ) + } + const release = (): void => { + scope?.stop() + scope = undefined + } + onMounted(hold) + // Also fires on a kept-alive first mount, right after `onMounted`. + onActivated(hold) + onDeactivated(release) + onBeforeUnmount(release) } 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 index 8b82166..34901ca 100644 --- 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 @@ -1,36 +1,68 @@ // @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 { KeepAlive, defineComponent, h, nextTick, ref } from 'vue' +import { render } from '@testing-library/vue' +import { 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 - }, -}) +// TL Vue has no renderHook: a component that only calls the composable. +const Locker = (...args: Parameters) => + defineComponent({ + setup() { + useScrollLock(...args) + return () => null + }, + }) -// Auto-cleanup needs vitest globals; this repo runs with globals: false. -afterEach(cleanup) +const mountLock = (...args: Parameters): ReturnType => + render(Locker(...args)) describe('useScrollLock', () => { it('locks body scroll while mounted and releases on unmount', () => { - const { unmount } = render(Locker) - expect(document.body.style.overflow).toBe('hidden') + const { unmount } = mountLock() + expect(document.body.style.overflowY).toBe('hidden') unmount() - expect(document.body.style.overflow).toBe('') + expect(document.body.style.overflowY).toBe('') }) it('does not lock when locked=false', () => { - const { unmount } = render(Locker, { props: { locked: false } }) - expect(document.body.style.overflow).toBe('') + const { unmount } = mountLock(false) + expect(document.body.style.overflowY).toBe('') + unmount() + }) + + it('locks nothing when target is null — a target not yet resolved', () => { + const { unmount } = mountLock(true, () => null) + expect(document.body.style.overflowY).toBe('') + unmount() + }) + + it('follows a reactive locked flag, releasing when it turns off', async () => { + const locked = ref(true) + const { unmount } = mountLock(locked) + expect(document.body.style.overflowY).toBe('hidden') + + locked.value = false + await nextTick() + expect(document.body.style.overflowY).toBe('') + unmount() + }) + + it('releases while a KeepAlive deactivation holds the component, and locks again on reactivation', async () => { + const shown = ref(true) + const Lock = Locker() + const { unmount } = 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') unmount() }) }) From a8fc96b0aeca28087a70429c12786208bf84ffc9 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Mon, 5 Oct 2026 15:01:15 +0200 Subject: [PATCH 04/33] feat(vue): rebuild Dialog on the shared DOM layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Vue dialog carried its own effects.ts and inlined every document-level sequence; it now contributes Vue's lifecycle only, like the React and Solid bindings: domDialogEffects through the adapter, openDialogLayer / startExitWindow on the machine's open edge, guardBackNavigation on the root, and the press gating and trap options from @dunky.dev/dom-dialog. That brings what main grew since the first attempt — restoreFocus, the Forward reopen, escapeScope, and the inner-popup stand-down — and Content renders a div with the role instead of . The Vue-specific corners: - Boolean props declare `default: undefined`, so an absent one keeps the core default instead of Vue's `false` cast (`open` absent stays uncontrolled, `modal` defaults on). - The core callbacks are emits — onOpenChange is `update:open`, so `v-model:open` binds the controlled pair — called synchronously, so a listener's preventDefault() still vetoes a dismissal. - The base id is useId() with the `options.id ?? id` rule, so hydrated parts carry the ids the server rendered. - initialFocus / restoreFocus take MaybeRefOrGetter, resolved at open / close time: a template ref only fills after setup. - Portal teleports only after it mounts, so server and hydration render nothing; a container swap re-creates the teleport like React's and Solid's portals; a deactivation takes it down while the adapter pauses the machine. - Parts merge attrs through the adapter's mergeProps (inheritAttrs: false); a part's element is its `$el`. - Exported as DefineComponent — Storybook's Meta rejects the bare constructor type function-signature defineComponent infers. Tests port the React/Solid suite 1:1 (57) and add the Vue-only ones: template boolean casting, v-model:open, kebab-case emit vetoes, the initialFocus getter, pre-start Title presence and its removal, id derivation, container swap, KeepAlive, hydration, and server rendering without a document. TSX tests need Vue's JSX namespace and transform, so packages/vue typechecks as its own project and runs as its own vitest project; `resolve.dedupe` keeps a linked adapter on this workspace's vue. Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- packages/vue/dialog/SPEC.md | 194 ++-- packages/vue/dialog/package.json | 9 +- packages/vue/dialog/src/context.ts | 20 +- packages/vue/dialog/src/dialog.ts | 543 +++++----- packages/vue/dialog/src/effects.ts | 28 - packages/vue/dialog/src/index.ts | 1 + packages/vue/dialog/src/use-dialog.ts | 37 +- packages/vue/dialog/tests/dialog.test.ts | 827 --------------- packages/vue/dialog/tests/dialog.test.tsx | 1127 +++++++++++++++++++++ packages/vue/dialog/tests/ssr.test.tsx | 39 + packages/vue/package.json | 7 +- packages/vue/tsconfig.json | 12 + packages/vue/vitest.config.ts | 21 + pnpm-workspace.yaml | 2 +- tsconfig.json | 10 +- vitest.config.ts | 8 +- 17 files changed, 1674 insertions(+), 1213 deletions(-) delete mode 100644 packages/vue/dialog/src/effects.ts delete mode 100644 packages/vue/dialog/tests/dialog.test.ts create mode 100644 packages/vue/dialog/tests/dialog.test.tsx create mode 100644 packages/vue/dialog/tests/ssr.test.tsx create mode 100644 packages/vue/tsconfig.json create mode 100644 packages/vue/vitest.config.ts diff --git a/package.json b/package.json index 868667c..1bb5d3f 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 .", diff --git a/packages/vue/dialog/SPEC.md b/packages/vue/dialog/SPEC.md index 9d8c03b..6da0afa 100644 --- a/packages/vue/dialog/SPEC.md +++ b/packages/vue/dialog/SPEC.md @@ -36,25 +36,33 @@ import { Dialog } from '@dunky.dev/vue-dialog' ``` -The parts hang off the root — `` resolves through -` ``` -`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`. +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. 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 index 1641a02..b0f7765 100644 --- a/.changeset/vue-hooks.md +++ b/.changeset/vue-hooks.md @@ -4,9 +4,11 @@ --- 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 +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. From b19b2015c6819c4c4e586a175566a393623c9b08 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Mon, 5 Oct 2026 16:29:31 +0200 Subject: [PATCH 09/33] fix(vue): hold the open edge, trap, and lock through Vue 3.6's watcher cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running the suite on vue@3.6.0-rc.10 broke one test: with `open` handed back as undefined, Escape no longer closed the dialog. 3.6 stores a watch callback's onCleanup on the effect that its base run() cleans up first, so the cleanup now also fires when the source merely re-evaluates — not only before the next callback. The open edge watches `api.value.open`, which re-evaluates on every api change, so the dialog left the layer stack while staying open. The trap and the scroll lock watch sources that can re-evaluate the same way. Each now keeps its disposer by hand — released before the next run and on the scope's end — as the adapter's effects already do. 3.5 behavior is unchanged. Also types the connected api as the adapter's ComputedRef: 3.6's ComputedRef is no longer a Ref, so `Readonly>` stopped accepting it. Co-Authored-By: Claude Opus 5.5 --- packages/vue/dialog/src/context.ts | 4 +- packages/vue/dialog/src/dialog.ts | 42 ++++++++++--------- packages/vue/dialog/src/use-dialog.ts | 4 +- .../use-focus-trap/src/use-focus-trap.ts | 25 ++++++----- .../use-scroll-lock/src/use-scroll-lock.ts | 25 ++++++----- .../packages/vue/__name__/src/context.ts | 4 +- 6 files changed, 59 insertions(+), 45 deletions(-) diff --git a/packages/vue/dialog/src/context.ts b/packages/vue/dialog/src/context.ts index 50aae38..7b43b33 100644 --- a/packages/vue/dialog/src/context.ts +++ b/packages/vue/dialog/src/context.ts @@ -1,10 +1,10 @@ -import { inject, type InjectionKey, type Ref, type ShallowRef } from 'vue' +import { inject, type ComputedRef, type InjectionKey, type ShallowRef } from 'vue' import type { DialogApi, DialogMachine } from '@dunky.dev/dialog' export interface DialogContextValue { // The connected api: its value is replaced on every machine change, so a // part whose render reads it re-renders exactly then. - api: Readonly> + api: ComputedRef machine: DialogMachine // Nesting level (1 = top-level). Decides the topmost dialog of a stack for // Escape, focus, and assistive-tech containment. diff --git a/packages/vue/dialog/src/dialog.ts b/packages/vue/dialog/src/dialog.ts index 4e26c90..6cda963 100644 --- a/packages/vue/dialog/src/dialog.ts +++ b/packages/vue/dialog/src/dialog.ts @@ -352,34 +352,38 @@ export const Content: DialogComponent = defineComponent( // dialog stays mounted through `closing`, and Content rendered while not // open is that exit window. The sequences and their inverses are the DOM // package's; this watcher only ties them to Vue's lifecycle — it starts - // once the element exists. + // once the element exists. Released by hand rather than through the + // watcher's onCleanup, which Vue 3.6 also runs when the source merely + // re-evaluates — on every api change. whileActive(() => { + let release: (() => void) | undefined watch( () => api.value.open, - (open, _previous, onCleanup) => { + open => { + release?.() + release = undefined const content = contentRef.value if (content === null) return - onCleanup( - open - ? openDialogLayer(content, { - id: machine.context.id, - depth, - modal: machine.context.modal, - backdrop: () => backdropRef.value, - initialFocus: toValue(props.initialFocus), - restoreFocus: () => toValue(props.restoreFocus) ?? null, - dismiss: () => machine.send({ type: 'close' }), - }) - : startExitWindow(content, { - container: container(), - backdrop: backdropRef.value, - onComplete: () => machine.send({ type: 'exit.complete' }), - }), - ) + release = open + ? openDialogLayer(content, { + id: machine.context.id, + depth, + modal: machine.context.modal, + backdrop: () => backdropRef.value, + initialFocus: toValue(props.initialFocus), + restoreFocus: () => toValue(props.restoreFocus) ?? null, + dismiss: () => machine.send({ type: 'close' }), + }) + : startExitWindow(content, { + container: container(), + backdrop: backdropRef.value, + onComplete: () => machine.send({ type: 'exit.complete' }), + }) }, { immediate: true }, ) + onScopeDispose(() => release?.()) }) // The lock spans the whole mount — through `closing` too: releasing it diff --git a/packages/vue/dialog/src/use-dialog.ts b/packages/vue/dialog/src/use-dialog.ts index 5f1fae7..27c5a3a 100644 --- a/packages/vue/dialog/src/use-dialog.ts +++ b/packages/vue/dialog/src/use-dialog.ts @@ -1,11 +1,11 @@ -import { toValue, useId, type MaybeRefOrGetter, type Ref } from 'vue' +import { toValue, useId, type ComputedRef, type MaybeRefOrGetter } from 'vue' import { useMachine } from '@dunky.dev/vue-state-machine' import { dialogMachine, dialogConnect } from '@dunky.dev/dialog' import type { DialogApi, DialogMachine, DialogOptions } from '@dunky.dev/dialog' import { domDialogEffects } from '@dunky.dev/dom-dialog' export function useDialog(options: MaybeRefOrGetter): { - api: Readonly> + api: ComputedRef machine: DialogMachine } { const id = useId() 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 index da0c8c9..dca4b15 100644 --- a/packages/vue/hooks/use-focus-trap/src/use-focus-trap.ts +++ b/packages/vue/hooks/use-focus-trap/src/use-focus-trap.ts @@ -4,6 +4,7 @@ import { onBeforeUnmount, onDeactivated, onMounted, + onScopeDispose, toValue, watch, type EffectScope, @@ -27,29 +28,33 @@ export function useFocusTrap( // deactivation releases it like an unmount, as React's runs the // effect's cleanup. Mounted hooks never run during server rendering. The // trap reads the options object per Tab press, so `enabled` / `last` stay - // live without re-binding. + // live without re-binding. Released by hand rather than through the + // watcher's onCleanup, which Vue 3.6 also runs when the target merely + // re-evaluates. let scope: EffectScope | undefined const arm = (): void => { if (scope !== undefined) return scope = effectScope() - scope.run(() => + scope.run(() => { + let untrap: (() => void) | undefined watch( () => toValue(target), - (container, _previous, onCleanup) => { - if (container == null) return - onCleanup(trapFocus(container, options)) + container => { + untrap?.() + untrap = container == null ? undefined : trapFocus(container, options) }, { immediate: true, flush: 'post' }, - ), - ) + ) + onScopeDispose(() => untrap?.()) + }) } - const release = (): void => { + const disarm = (): void => { scope?.stop() scope = undefined } onMounted(arm) // Also fires on a kept-alive first mount, right after `onMounted`. onActivated(arm) - onDeactivated(release) - onBeforeUnmount(release) + onDeactivated(disarm) + onBeforeUnmount(disarm) } 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 index 6f965cc..802bf72 100644 --- a/packages/vue/hooks/use-scroll-lock/src/use-scroll-lock.ts +++ b/packages/vue/hooks/use-scroll-lock/src/use-scroll-lock.ts @@ -4,6 +4,7 @@ import { onBeforeUnmount, onDeactivated, onMounted, + onScopeDispose, toValue, watch, type EffectScope, @@ -24,29 +25,33 @@ export function useScrollLock( ): void { // Held from mount; a deactivation releases it like an unmount, // as React's runs the effect's cleanup. Mounted hooks never run - // during server rendering, where there is no body to lock. + // during server rendering, where there is no body to lock. Released by + // hand rather than through the watcher's onCleanup, which Vue 3.6 also + // runs when a source merely re-evaluates. let scope: EffectScope | undefined const hold = (): void => { if (scope !== undefined) return scope = effectScope() - scope.run(() => + scope.run(() => { + let unlock: (() => void) | undefined watch( [() => toValue(locked), () => (target === undefined ? undefined : toValue(target))], - ([isLocked, container], _previous, onCleanup) => { - if (!isLocked || container === null) return - onCleanup(lockScroll(container)) + ([isLocked, container]) => { + unlock?.() + unlock = !isLocked || container === null ? undefined : lockScroll(container) }, { immediate: true, flush: 'post' }, - ), - ) + ) + onScopeDispose(() => unlock?.()) + }) } - const release = (): void => { + const drop = (): void => { scope?.stop() scope = undefined } onMounted(hold) // Also fires on a kept-alive first mount, right after `onMounted`. onActivated(hold) - onDeactivated(release) - onBeforeUnmount(release) + onDeactivated(drop) + onBeforeUnmount(drop) } diff --git a/scripts/templates/packages/vue/__name__/src/context.ts b/scripts/templates/packages/vue/__name__/src/context.ts index fb8ce6e..def8656 100644 --- a/scripts/templates/packages/vue/__name__/src/context.ts +++ b/scripts/templates/packages/vue/__name__/src/context.ts @@ -1,10 +1,10 @@ -import { inject, type InjectionKey, type Ref } from 'vue' +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: Readonly> + api: ComputedRef<__Name__Api> machine: __Name__Machine } From 0c6a74e2b2ff4aea24ca709b1a975dd240bd26d2 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Mon, 5 Oct 2026 16:45:50 +0200 Subject: [PATCH 10/33] docs(vue): spec the KeepAlive lifecycle and pin the $el handle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SPECs described mount and unmount but not what a deactivation does — the Portal takes the layers down, the guard, trap, and lock release, and reactivation restores them — nor did a test hold the documented element handle: a template ref on a part reaches its element as `$el` because every part renders exactly one root element. Co-Authored-By: Claude Opus 5.5 --- packages/vue/dialog/SPEC.md | 7 ++++++- packages/vue/dialog/tests/dialog.test.tsx | 16 ++++++++++++++++ packages/vue/hooks/use-focus-trap/SPEC.md | 6 ++++-- packages/vue/hooks/use-scroll-lock/SPEC.md | 4 +++- 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/packages/vue/dialog/SPEC.md b/packages/vue/dialog/SPEC.md index 6da0afa..33db885 100644 --- a/packages/vue/dialog/SPEC.md +++ b/packages/vue/dialog/SPEC.md @@ -125,6 +125,11 @@ Vue-specific notes on top of the core contract: re-enter. A nested dialog unmounted along with the parent it was opened from does come back, and so does one whose page reloaded in between — the entry remembers the dialog's place in the stack, not the instance that planted it. +- **``**: a deactivated dialog's machine is paused by the + adapter, as React's `` pauses its effects, so its DOM work ends + too — the Portal takes the layers down (Vue would leave teleported content + painted over the next view), and the history guard, focus trap, and scroll + lock release. Reactivation restores them with the open state intact. - **Server rendering** touches no DOM: the root and its Trigger render, the document-level work starts on mount, and the base id comes from `useId`, so the hydrated parts carry the ids the server rendered. @@ -153,7 +158,7 @@ The root: owns open/close state, renders no DOM. Accepts the core | Emit | Payload | Description | | -------------------- | ------------------------------- | -------------------------------------------------------------------------------------- | -| `update:open` | `open: boolean` | Fired on every open/close transition with the new value; with `open`, `v-model`. | +| `update:open` | `open: boolean` | Fired on every open/close transition with the new value; with `open`, `v-model:open`. | | `escape-key-down` | `event: KeyboardPayload` | Fired before an Escape dismissal (the `KeyboardEvent`); `preventDefault()` vetoes. | | `interact-outside` | `event?: PointerPayload` | Fired before an outside-press dismissal (the `MouseEvent`); `preventDefault()` vetoes. | | `back-navigation` | `event?: BackNavigationPayload` | Fired before a back-navigation dismissal; `preventDefault()` vetoes. | diff --git a/packages/vue/dialog/tests/dialog.test.tsx b/packages/vue/dialog/tests/dialog.test.tsx index 687c3ed..d375d95 100644 --- a/packages/vue/dialog/tests/dialog.test.tsx +++ b/packages/vue/dialog/tests/dialog.test.tsx @@ -1031,6 +1031,22 @@ describe('Dialog', () => { expect(screen.getByRole('dialog').hasAttribute('aria-labelledby')).toBe(false) }) + // Every part renders exactly one root element, so a component ref is + // the consumer's handle on it. + it("a part's template ref reaches its element as $el", async () => { + const content = ref | null>(null) + await renderSettled(() => ( + + + + content + + + + )) + expect(content.value?.$el).toBe(screen.getByRole('dialog')) + }) + it('derives the part ids from the id prop, and from useId when it is absent or undefined', async () => { await renderSettled(() => ( <> diff --git a/packages/vue/hooks/use-focus-trap/SPEC.md b/packages/vue/hooks/use-focus-trap/SPEC.md index 81abe2d..e9bdef5 100644 --- a/packages/vue/hooks/use-focus-trap/SPEC.md +++ b/packages/vue/hooks/use-focus-trap/SPEC.md @@ -35,8 +35,10 @@ Vue-specific notes on top of the DOM contract: - The target is a `MaybeRefOrGetter` — a template ref, a getter, or the element itself. 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. Mounted hooks never run during server rendering, so - the trap touches no DOM there. + releases on unmount. A `` deactivation releases it too and + reactivation arms it again, as React's `` runs an effect's + cleanup. Mounted hooks never run during server rendering, so the trap + touches no DOM there. - 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. diff --git a/packages/vue/hooks/use-scroll-lock/SPEC.md b/packages/vue/hooks/use-scroll-lock/SPEC.md index 57edc02..f2eb6f3 100644 --- a/packages/vue/hooks/use-scroll-lock/SPEC.md +++ b/packages/vue/hooks/use-scroll-lock/SPEC.md @@ -28,7 +28,9 @@ useScrollLock() // the page behind can't scroll while mounted 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. Both parameters accept a + unmounting or turning `locked` off releases it, and so does a `` + deactivation — reactivation locks again, as React's `` runs an + effect's cleanup. Both parameters accept a `MaybeRefOrGetter` — a plain value, a ref, or a getter — so the lock tracks reactive state: a `target` change releases the old container and locks the new one. From 0cbcbaaf49f7840c36b1d611939da747486a5bc6 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Mon, 5 Oct 2026 17:01:42 +0200 Subject: [PATCH 11/33] fix(vue): park kept-alive layers instead of unmounting them; open after render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two lifecycle corrections to the Dialog: - A deactivation unmounted the Portal's teleport, so the content of a cached open dialog — typed input, scroll position — was lost when the view came back, against the very point of KeepAlive. The Portal now disables its teleport instead: Vue parks the layers back in place, inside the cached view and off the document, and re-enabling brings the same nodes back. The open sequence waits out the flush that moves them home — focus would drop with the move. - The open edge re-ran before the update rendered. A Content rendered with the root (no Portal) stays mounted while closed, and a stylesheet keyed on `data-state` can hide it, so the open sequence found nothing rendered to focus — the window fell back, or in a browser nothing took focus at all. It now runs post-render, as a React effect runs after commit; so does the history guard's sync, for the same reason. Co-Authored-By: Claude Opus 5.5 --- .changeset/vue-dialog.md | 4 +- packages/vue/dialog/SPEC.md | 10 +++-- packages/vue/dialog/src/dialog.ts | 48 ++++++++++++++++------- packages/vue/dialog/tests/dialog.test.tsx | 46 +++++++++++++++++++--- 4 files changed, 82 insertions(+), 26 deletions(-) diff --git a/.changeset/vue-dialog.md b/.changeset/vue-dialog.md index 3acff67..3d0ac74 100644 --- a/.changeset/vue-dialog.md +++ b/.changeset/vue-dialog.md @@ -45,7 +45,9 @@ 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. Everything else +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 diff --git a/packages/vue/dialog/SPEC.md b/packages/vue/dialog/SPEC.md index 33db885..ab7e621 100644 --- a/packages/vue/dialog/SPEC.md +++ b/packages/vue/dialog/SPEC.md @@ -126,10 +126,12 @@ Vue-specific notes on top of the core contract: does come back, and so does one whose page reloaded in between — the entry remembers the dialog's place in the stack, not the instance that planted it. - **``**: a deactivated dialog's machine is paused by the - adapter, as React's `` pauses its effects, so its DOM work ends - too — the Portal takes the layers down (Vue would leave teleported content - painted over the next view), and the history guard, focus trap, and scroll - lock release. Reactivation restores them with the open state intact. + adapter, as React's `` pauses its effects, so its DOM work pauses + too: the Portal parks the layers back in place, inside the cached view and + off the document — Vue would otherwise leave teleported content painted + over the next view — and the history guard, focus trap, and scroll lock + release. Reactivation brings the same layers back, their content's state + intact, and runs the open sequence again. - **Server rendering** touches no DOM: the root and its Trigger render, the document-level work starts on mount, and the base id comes from `useId`, so the hydrated parts carry the ids the server rendered. diff --git a/packages/vue/dialog/src/dialog.ts b/packages/vue/dialog/src/dialog.ts index 6cda963..c1ba033 100644 --- a/packages/vue/dialog/src/dialog.ts +++ b/packages/vue/dialog/src/dialog.ts @@ -4,6 +4,7 @@ import { effectScope, h, inject, + nextTick, onActivated, onBeforeUnmount, onDeactivated, @@ -70,22 +71,32 @@ const booleanOption = { type: Boolean, default: undefined } // Runs `effects` in a scope that lives while the component is mounted and // active. A deactivation ends it like an unmount — the adapter // pauses the machine the same way, as React's runs its effect -// cleanups — and reactivation runs it again. Mounted hooks never run during -// server rendering, so neither does it. +// cleanups — and reactivation runs it again, once the flush that restores the +// view is done: a Portal re-enables its teleport in that flush, and the +// layer has to be back in place before its sequences run. Mounted hooks +// never run during server rendering, so neither does it. function whileActive(effects: () => void): void { let scope: EffectScope | undefined + let active = false const start = (): void => { - if (scope !== undefined) return + if (!active || scope !== undefined) return scope = effectScope() scope.run(effects) } const stop = (): void => { + active = false scope?.stop() scope = undefined } - onMounted(start) - // Also fires on a kept-alive first mount, right after `onMounted`. - onActivated(start) + onMounted(() => { + active = true + start() + }) + // Also fires on a kept-alive first mount, after `onMounted` already started. + onActivated(() => { + active = true + void nextTick(start) + }) onDeactivated(stop) onBeforeUnmount(stop) } @@ -155,7 +166,7 @@ const DialogRoot = defineComponent( }) guard.sync(open) }, - { immediate: true }, + { immediate: true, flush: 'post' }, ) onScopeDispose(() => { guard?.release() @@ -229,13 +240,17 @@ export const Portal: DialogComponent = defineComponent( // The server has no document to teleport into, so it renders no portal — // and the client's hydration pass must render the same: the teleport - // arrives with the first update after mount. A deactivation - // takes it down again, since Vue leaves teleported content painted over - // the next view while the machine behind it is paused. - const active = shallowRef(false) + // arrives with the first update after mount. + const mounted = shallowRef(false) onMounted(() => { - active.value = true + mounted.value = true }) + + // A deactivation parks the layers back in place, inside the + // cached subtree and off the document — Vue would leave teleported + // content painted over the next view while the machine behind it is + // paused. Disabling, not unmounting, keeps their state for reactivation. + const active = shallowRef(true) onActivated(() => { active.value = true }) @@ -257,10 +272,10 @@ export const Portal: DialogComponent = defineComponent( return () => { // `mounted`, not `open`: an animated dialog stays in the tree through // `closing` so its exit visual can play before everything unmounts. - if (!active.value || !context.api.value.mounted) return null + if (!mounted.value || !context.api.value.mounted) return null return h( Teleport, - { key: generation, to: props.container ?? document.body }, + { key: generation, to: props.container ?? document.body, disabled: !active.value }, slots.default?.() ?? [], ) } @@ -381,7 +396,10 @@ export const Content: DialogComponent = defineComponent( onComplete: () => machine.send({ type: 'exit.complete' }), }) }, - { immediate: true }, + // `post`: a Content rendered with the root stays mounted, so the + // sequence must see the DOM the open state renders — a React effect + // runs after commit. + { immediate: true, flush: 'post' }, ) onScopeDispose(() => release?.()) }) diff --git a/packages/vue/dialog/tests/dialog.test.tsx b/packages/vue/dialog/tests/dialog.test.tsx index d375d95..f240958 100644 --- a/packages/vue/dialog/tests/dialog.test.tsx +++ b/packages/vue/dialog/tests/dialog.test.tsx @@ -1000,6 +1000,27 @@ describe('Dialog', () => { expect(document.activeElement).toBe(screen.getByLabelText('Name')) }) + // A Content rendered with the root (no Portal) stays mounted while closed, + // and a stylesheet may hide it by its state — the open sequence has to + // wait for the update that renders it open, as a React effect does. + it('an always-mounted Content takes focus once the update that opens it has rendered', async () => { + const style = document.createElement('style') + style.textContent = '[data-state="closed"][role="dialog"] { display: none; }' + document.head.append(style) + await renderSettled(() => ( + + Trigger + + + + + )) + + await openDialog() + expect(document.activeElement).toBe(screen.getByLabelText('Name')) + style.remove() + }) + // Children mount before their parent, so a Title rendered with the root // (no Portal) reports itself before the root starts the machine. it('a Title mounted before the root starts its machine still labels the dialog', async () => { @@ -1090,18 +1111,29 @@ describe('Dialog', () => { // The adapter pauses a deactivated dialog's machine, as React's // does — so its layers can't stay painted, holding the page, meanwhile. - it('a KeepAlive deactivation takes the layers down and releases the page; reactivation restores them', async () => { + it('a KeepAlive deactivation parks the layers and releases the page; reactivation restores both, state intact', async () => { const shown = ref(true) - const Page = defineComponent(() => () => ) + const Page = defineComponent(() => () => ( + + + + + + + + + + )) const Elsewhere = defineComponent(() => () => ) const { container } = await renderSettled(() => ( {shown.value ? : } )) - expect(container.hasAttribute('inert')).toBe(true) + const field = screen.getByLabelText('Name') as HTMLInputElement + field.value = 'typed' shown.value = false await nextTick() - expect(screen.queryByRole('dialog', { hidden: true })).toBeNull() + expect(field.isConnected).toBe(false) // parked with the cached view expect(container.hasAttribute('inert')).toBe(false) expect(document.body.style.overflowY).not.toBe('hidden') const elsewhere = screen.getByText('Elsewhere') @@ -1111,8 +1143,10 @@ describe('Dialog', () => { shown.value = true await nextTick() - const dialog = screen.getByRole('dialog') // still open: the state survived - expect(document.activeElement).toBe(dialog) + await nextTick() // the open sequence waits out the flush that re-enables the teleport + expect(screen.getByLabelText('Name')).toBe(field) + expect(field.value).toBe('typed') + expect(document.activeElement).toBe(field) expect(document.body.style.overflowY).toBe('hidden') }) From 039b7441bafeba8d206c547431499a69ef50d080 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Mon, 5 Oct 2026 17:06:55 +0200 Subject: [PATCH 12/33] fix(vue): run the exit window in `closing` only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Content took "rendered while not open" to mean the exit window — true inside a Portal, which unmounts the layers once the dialog is closed, but a Content rendered with the root stays mounted while plainly closed. There, startExitWindow hid the content's outermost ancestor: a closed inline modal dialog left the whole page root inert and aria-hidden. The watcher now keys on the machine's three states — open runs the open sequence, closing the exit window, closed nothing. The React and Solid bindings carry the same assumption; see the PR notes. Co-Authored-By: Claude Opus 5.5 --- packages/vue/dialog/src/dialog.ts | 59 ++++++++++++----------- packages/vue/dialog/tests/dialog.test.tsx | 15 ++++++ 2 files changed, 45 insertions(+), 29 deletions(-) diff --git a/packages/vue/dialog/src/dialog.ts b/packages/vue/dialog/src/dialog.ts index c1ba033..298fb0a 100644 --- a/packages/vue/dialog/src/dialog.ts +++ b/packages/vue/dialog/src/dialog.ts @@ -363,42 +363,43 @@ export const Content: DialogComponent = defineComponent( const { api, machine, depth, container, backdropRef } = useDialogContext() const contentRef = shallowRef(null) - // The machine's `open` state is the edge, not mount/unmount: an animated - // dialog stays mounted through `closing`, and Content rendered while not - // open is that exit window. The sequences and their inverses are the DOM - // package's; this watcher only ties them to Vue's lifecycle — it starts - // once the element exists. Released by hand rather than through the - // watcher's onCleanup, which Vue 3.6 also runs when the source merely - // re-evaluates — on every api change. + // The machine's state is the edge, not mount/unmount: an animated dialog + // stays mounted through `closing`, its exit window, and a Content rendered + // with the root (no Portal) is mounted while closed too. The sequences + // and their inverses are the DOM package's; this watcher only ties them + // to Vue's lifecycle, once the element exists. Released by hand rather + // than through the watcher's onCleanup, which Vue 3.6 also runs when the + // source merely re-evaluates. whileActive(() => { let release: (() => void) | undefined watch( - () => api.value.open, - open => { + () => (api.value.open ? 'open' : api.value.mounted ? 'closing' : 'closed'), + state => { release?.() release = undefined const content = contentRef.value - if (content === null) return - - release = open - ? openDialogLayer(content, { - id: machine.context.id, - depth, - modal: machine.context.modal, - backdrop: () => backdropRef.value, - initialFocus: toValue(props.initialFocus), - restoreFocus: () => toValue(props.restoreFocus) ?? null, - dismiss: () => machine.send({ type: 'close' }), - }) - : startExitWindow(content, { - container: container(), - backdrop: backdropRef.value, - onComplete: () => machine.send({ type: 'exit.complete' }), - }) + if (content === null || state === 'closed') return + + release = + state === 'open' + ? openDialogLayer(content, { + id: machine.context.id, + depth, + modal: machine.context.modal, + backdrop: () => backdropRef.value, + initialFocus: toValue(props.initialFocus), + restoreFocus: () => toValue(props.restoreFocus) ?? null, + dismiss: () => machine.send({ type: 'close' }), + }) + : startExitWindow(content, { + container: container(), + backdrop: backdropRef.value, + onComplete: () => machine.send({ type: 'exit.complete' }), + }) }, - // `post`: a Content rendered with the root stays mounted, so the - // sequence must see the DOM the open state renders — a React effect - // runs after commit. + // `post`: the sequences must see the DOM the new state renders — a + // stylesheet may hide a closed window — as a React effect runs after + // commit. { immediate: true, flush: 'post' }, ) onScopeDispose(() => release?.()) diff --git a/packages/vue/dialog/tests/dialog.test.tsx b/packages/vue/dialog/tests/dialog.test.tsx index f240958..3ac271a 100644 --- a/packages/vue/dialog/tests/dialog.test.tsx +++ b/packages/vue/dialog/tests/dialog.test.tsx @@ -1021,6 +1021,21 @@ describe('Dialog', () => { style.remove() }) + // The exit window is the `closing` state only: a Content rendered with + // the root is also mounted while plainly closed, and must leave the page + // alone then. + it('a closed Content rendered with the root leaves the page live', async () => { + const { container } = await renderSettled(() => ( + + Trigger + content + + )) + await pressEscape() + expect(container.hasAttribute('inert')).toBe(false) + expect(container.hasAttribute('aria-hidden')).toBe(false) + }) + // Children mount before their parent, so a Title rendered with the root // (no Portal) reports itself before the root starts the machine. it('a Title mounted before the root starts its machine still labels the dialog', async () => { From 8669729e9f2eb78ab6aa29dcd2d13e1c946ec953 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Mon, 5 Oct 2026 17:08:50 +0200 Subject: [PATCH 13/33] fix(vue): key the teleport on its resolved target; an empty ref locks nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Portal re-created its teleport on any `container` change, including null -> undefined: the same body target, yet the open layer remounted and its content's state was lost. It now keys on the resolved target. - useScrollLock took a provided target resolving to `undefined` for "the page body" — but `ref()` holds undefined until its element renders, so a template ref locked the body first, then moved. A provided target that resolves to nothing now locks nothing, as useFocusTrap already treated it; only an omitted target means the body, and Content names the body for its page-level lock. Co-Authored-By: Claude Opus 5.5 --- packages/vue/dialog/src/dialog.ts | 6 +++--- packages/vue/hooks/use-scroll-lock/SPEC.md | 15 ++++++------- .../use-scroll-lock/src/use-scroll-lock.ts | 11 +++++----- .../tests/use-scroll-lock.test.ts | 21 +++++++++++++++++++ 4 files changed, 38 insertions(+), 15 deletions(-) diff --git a/packages/vue/dialog/src/dialog.ts b/packages/vue/dialog/src/dialog.ts index 298fb0a..c26b9cf 100644 --- a/packages/vue/dialog/src/dialog.ts +++ b/packages/vue/dialog/src/dialog.ts @@ -263,7 +263,7 @@ export const Portal: DialogComponent = defineComponent( // containment was computed for the old placement. let generation = 0 watch( - () => props.container, + () => props.container ?? null, () => { generation++ }, @@ -408,8 +408,8 @@ export const Content: DialogComponent = defineComponent( // The lock spans the whole mount — through `closing` too: releasing it // mid-exit would bring the scrollbar back and reflow the page under the // still-painting layer. The context's `null` means "page body", not the - // hook's "no target yet" — map it to the hook's body default. - useScrollLock(machine.context.modal, () => container() ?? undefined) + // hook's "no target yet" — name the body. Read on mount, client-side. + useScrollLock(machine.context.modal, () => container() ?? document.body) useFocusTrap( contentRef, diff --git a/packages/vue/hooks/use-scroll-lock/SPEC.md b/packages/vue/hooks/use-scroll-lock/SPEC.md index f2eb6f3..60701c8 100644 --- a/packages/vue/hooks/use-scroll-lock/SPEC.md +++ b/packages/vue/hooks/use-scroll-lock/SPEC.md @@ -34,9 +34,10 @@ Vue-specific notes on top of the DOM contract: `MaybeRefOrGetter` — a plain value, a ref, or a getter — so the lock tracks reactive state: a `target` change releases the old container and locks the new one. -- An omitted `target` means the page body; `null` means "no target yet" - and locks nothing — the lock engages once a ref or getter resolves the - element. Mounted hooks never run during server rendering, so there is no +- 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, @@ -48,7 +49,7 @@ Vue-specific notes on top of the DOM contract: 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` locks nothing. | +| 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/src/use-scroll-lock.ts b/packages/vue/hooks/use-scroll-lock/src/use-scroll-lock.ts index 802bf72..3c5bb71 100644 --- a/packages/vue/hooks/use-scroll-lock/src/use-scroll-lock.ts +++ b/packages/vue/hooks/use-scroll-lock/src/use-scroll-lock.ts @@ -15,9 +15,10 @@ import { lockScroll } from '@dunky.dev/dom-scroll-lock' /** * Locks scrolling while the component is mounted and `locked` — the Vue * lifecycle around `lockScroll`. Targets the page body unless a `target` is - * given. A `null` target means "no target yet" and locks nothing; pass a ref - * or a getter and the lock engages once the element resolves. The lock is - * shared per container: it restores when the last holder releases. + * 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. The lock is shared per container: it restores + * when the last holder releases. */ export function useScrollLock( locked: MaybeRefOrGetter = true, @@ -35,10 +36,10 @@ export function useScrollLock( scope.run(() => { let unlock: (() => void) | undefined watch( - [() => toValue(locked), () => (target === undefined ? undefined : toValue(target))], + [() => toValue(locked), () => (target === undefined ? document.body : toValue(target))], ([isLocked, container]) => { unlock?.() - unlock = !isLocked || container === null ? undefined : lockScroll(container) + unlock = !isLocked || container == null ? undefined : lockScroll(container) }, { immediate: true, flush: 'post' }, ) 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 index 34901ca..d640dcb 100644 --- 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 @@ -39,6 +39,27 @@ describe('useScrollLock', () => { unmount() }) + // `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() + const { unmount } = 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('') + unmount() + }) + it('follows a reactive locked flag, releasing when it turns off', async () => { const locked = ref(true) const { unmount } = mountLock(locked) From 37eff687ca5bbdd12047020a5d4c90d05eef4ff3 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Mon, 5 Oct 2026 17:11:25 +0200 Subject: [PATCH 14/33] fix(vue): hold still when mounted into a deactivated KeepAlive view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Async data can mount a dialog into a cached view after the user has left it. Nothing fires to say so — the activation hooks only come with the view's return — so the Portal teleported the layers over the current view, the open sequence took focus and contained the page, the guard planted a history entry, and the hooks locked and trapped. Each now checks, on mount, whether an ancestor is a deactivated KeepAlive view (`isDeactivated` up the instance chain, a typed field of the instance) and waits for the activation. A DOM-connectivity check would also stop an app mounted into a detached element, which test utils do by default. Co-Authored-By: Claude Opus 5.5 --- packages/vue/dialog/SPEC.md | 4 +- packages/vue/dialog/src/dialog.ts | 24 ++++++++-- packages/vue/dialog/tests/dialog.test.tsx | 46 +++++++++++++++++++ packages/vue/hooks/use-focus-trap/SPEC.md | 3 +- .../use-focus-trap/src/use-focus-trap.ts | 16 ++++++- .../tests/use-focus-trap.test.tsx | 20 ++++++++ packages/vue/hooks/use-scroll-lock/SPEC.md | 3 +- .../use-scroll-lock/src/use-scroll-lock.ts | 16 ++++++- .../tests/use-scroll-lock.test.ts | 19 ++++++++ 9 files changed, 141 insertions(+), 10 deletions(-) diff --git a/packages/vue/dialog/SPEC.md b/packages/vue/dialog/SPEC.md index ab7e621..75fba2b 100644 --- a/packages/vue/dialog/SPEC.md +++ b/packages/vue/dialog/SPEC.md @@ -131,7 +131,9 @@ Vue-specific notes on top of the core contract: off the document — Vue would otherwise leave teleported content painted over the next view — and the history guard, focus trap, and scroll lock release. Reactivation brings the same layers back, their content's state - intact, and runs the open sequence again. + intact, and runs the open sequence again. A dialog mounted into a view + that is already deactivated — async data resolving after the user left — + holds still the same way until the view returns. - **Server rendering** touches no DOM: the root and its Trigger render, the document-level work starts on mount, and the base id comes from `useId`, so the hydrated parts carry the ids the server rendered. diff --git a/packages/vue/dialog/src/dialog.ts b/packages/vue/dialog/src/dialog.ts index c26b9cf..685558c 100644 --- a/packages/vue/dialog/src/dialog.ts +++ b/packages/vue/dialog/src/dialog.ts @@ -2,6 +2,7 @@ import { Teleport, defineComponent, effectScope, + getCurrentInstance, h, inject, nextTick, @@ -16,6 +17,7 @@ import { toValue, watch, type ButtonHTMLAttributes, + type ComponentInternalInstance, type ComponentOptionsMixin, type DefineComponent, type EffectScope, @@ -68,6 +70,16 @@ type DialogComponent = DefineComponent< // (`open` absent = uncontrolled) hold. const booleanOption = { type: Boolean, default: undefined } +// Whether the component sits in a view that is deactivated: async +// data can mount a dialog into a cached view after the user left it, and its +// DOM then waits in the cache for the view's return. +function inDeactivatedView(instance: ComponentInternalInstance | null): boolean { + for (let node = instance; node !== null; node = node.parent) { + if (node.isDeactivated) return true + } + return false +} + // Runs `effects` in a scope that lives while the component is mounted and // active. A deactivation ends it like an unmount — the adapter // pauses the machine the same way, as React's runs its effect @@ -76,6 +88,7 @@ const booleanOption = { type: Boolean, default: undefined } // layer has to be back in place before its sequences run. Mounted hooks // never run during server rendering, so neither does it. function whileActive(effects: () => void): void { + const instance = getCurrentInstance() let scope: EffectScope | undefined let active = false const start = (): void => { @@ -89,7 +102,7 @@ function whileActive(effects: () => void): void { scope = undefined } onMounted(() => { - active = true + active = !inDeactivatedView(instance) start() }) // Also fires on a kept-alive first mount, after `onMounted` already started. @@ -242,15 +255,16 @@ export const Portal: DialogComponent = defineComponent( // and the client's hydration pass must render the same: the teleport // arrives with the first update after mount. const mounted = shallowRef(false) - onMounted(() => { - mounted.value = true - }) - // A deactivation parks the layers back in place, inside the // cached subtree and off the document — Vue would leave teleported // content painted over the next view while the machine behind it is // paused. Disabling, not unmounting, keeps their state for reactivation. const active = shallowRef(true) + const instance = getCurrentInstance() + onMounted(() => { + mounted.value = true + active.value = !inDeactivatedView(instance) + }) onActivated(() => { active.value = true }) diff --git a/packages/vue/dialog/tests/dialog.test.tsx b/packages/vue/dialog/tests/dialog.test.tsx index 3ac271a..9380e58 100644 --- a/packages/vue/dialog/tests/dialog.test.tsx +++ b/packages/vue/dialog/tests/dialog.test.tsx @@ -1165,6 +1165,52 @@ describe('Dialog', () => { expect(document.body.style.overflowY).toBe('hidden') }) + // Async data can mount a dialog into a cached view after the user left it. + it('a dialog mounted into a deactivated KeepAlive view holds still until the view returns', async () => { + const shown = ref(true) + const loaded = ref(false) + const Page = defineComponent( + () => () => + loaded.value ? ( + + + content + + + ) : ( + loading + ), + ) + const Elsewhere = defineComponent(() => () => ) + const before: unknown = window.history.state + const { container } = await renderSettled(() => ( + {shown.value ? : } + )) + shown.value = false + await nextTick() + + loaded.value = true + await nextTick() + await nextTick() + expect(screen.queryByRole('dialog', { hidden: true })).toBeNull() + expect(container.hasAttribute('inert')).toBe(false) + expect(document.body.style.overflowY).not.toBe('hidden') + expect(window.history.state).toEqual(before) + + shown.value = true + await nextTick() + await nextTick() + expect(document.activeElement).toBe(screen.getByRole('dialog')) + expect(document.body.style.overflowY).toBe('hidden') + + // Unmounting releases the guard entry the activation planted. + const consume = new Promise(resolve => + window.addEventListener('popstate', resolve, { once: true }), + ) + cleanup() + await consume + }) + it('hydrates server-rendered markup without a mismatch, keeping its ids', async () => { const App = () => const html = await renderToString(createSSRApp(App)) diff --git a/packages/vue/hooks/use-focus-trap/SPEC.md b/packages/vue/hooks/use-focus-trap/SPEC.md index e9bdef5..80bb6ff 100644 --- a/packages/vue/hooks/use-focus-trap/SPEC.md +++ b/packages/vue/hooks/use-focus-trap/SPEC.md @@ -37,7 +37,8 @@ Vue-specific notes on top of the DOM contract: 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 hooks never run during server rendering, so the trap + cleanup; mounted into a view that is already deactivated, it waits for + the view's return. Mounted hooks never run during server rendering, so the trap touches no DOM there. - 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 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 index dca4b15..85cd060 100644 --- a/packages/vue/hooks/use-focus-trap/src/use-focus-trap.ts +++ b/packages/vue/hooks/use-focus-trap/src/use-focus-trap.ts @@ -1,5 +1,6 @@ import { effectScope, + getCurrentInstance, onActivated, onBeforeUnmount, onDeactivated, @@ -7,6 +8,7 @@ import { onScopeDispose, toValue, watch, + type ComponentInternalInstance, type EffectScope, type MaybeRefOrGetter, } from 'vue' @@ -15,6 +17,15 @@ import type { TrapFocusOptions } from '@dunky.dev/dom-focus-trap' export interface UseFocusTrapOptions extends TrapFocusOptions {} +// Whether the component sits in a view that is deactivated — it +// mounted into a cached view after the user left it, and waits for the return. +function inDeactivatedView(instance: ComponentInternalInstance | null): boolean { + for (let node = instance; node !== null; node = node.parent) { + if (node.isDeactivated) return true + } + return false +} + /** * Traps Tab / Shift+Tab within `target` while the component is mounted and * the target holds an element — the Vue lifecycle around `trapFocus`. Arms on @@ -31,6 +42,7 @@ export function useFocusTrap( // live without re-binding. Released by hand rather than through the // watcher's onCleanup, which Vue 3.6 also runs when the target merely // re-evaluates. + const instance = getCurrentInstance() let scope: EffectScope | undefined const arm = (): void => { if (scope !== undefined) return @@ -52,7 +64,9 @@ export function useFocusTrap( scope?.stop() scope = undefined } - onMounted(arm) + onMounted(() => { + if (!inDeactivatedView(instance)) arm() + }) // Also fires on a kept-alive first mount, right after `onMounted`. onActivated(arm) onDeactivated(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 index bdab801..7cb9c77 100644 --- 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 @@ -69,4 +69,24 @@ describe('useFocusTrap', () => { 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() + const outside = document.createElement('button') + document.body.append(outside) + expect(tab(outside)).toBe(true) + + shown.value = true + await nextTick() + expect(tab(screen.getByTestId('container'))).toBe(false) + outside.remove() + }) }) diff --git a/packages/vue/hooks/use-scroll-lock/SPEC.md b/packages/vue/hooks/use-scroll-lock/SPEC.md index 60701c8..bb8dacf 100644 --- a/packages/vue/hooks/use-scroll-lock/SPEC.md +++ b/packages/vue/hooks/use-scroll-lock/SPEC.md @@ -30,7 +30,8 @@ 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. Both parameters accept a + effect's cleanup; mounted into a view that is already deactivated, it + waits for the view's return. Both parameters accept a `MaybeRefOrGetter` — a plain value, a ref, or a getter — so the lock tracks reactive state: a `target` change releases the old container and locks the new one. 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 index 3c5bb71..b1fcc64 100644 --- a/packages/vue/hooks/use-scroll-lock/src/use-scroll-lock.ts +++ b/packages/vue/hooks/use-scroll-lock/src/use-scroll-lock.ts @@ -1,5 +1,6 @@ import { effectScope, + getCurrentInstance, onActivated, onBeforeUnmount, onDeactivated, @@ -7,11 +8,21 @@ import { onScopeDispose, toValue, watch, + type ComponentInternalInstance, type EffectScope, type MaybeRefOrGetter, } from 'vue' import { lockScroll } from '@dunky.dev/dom-scroll-lock' +// Whether the component sits in a view that is deactivated — it +// mounted into a cached view after the user left it, and waits for the return. +function inDeactivatedView(instance: ComponentInternalInstance | null): boolean { + for (let node = instance; node !== null; node = node.parent) { + if (node.isDeactivated) return true + } + return false +} + /** * Locks scrolling while the component is mounted and `locked` — the Vue * lifecycle around `lockScroll`. Targets the page body unless a `target` is @@ -29,6 +40,7 @@ export function useScrollLock( // during server rendering, where there is no body to lock. Released by // hand rather than through the watcher's onCleanup, which Vue 3.6 also // runs when a source merely re-evaluates. + const instance = getCurrentInstance() let scope: EffectScope | undefined const hold = (): void => { if (scope !== undefined) return @@ -50,7 +62,9 @@ export function useScrollLock( scope?.stop() scope = undefined } - onMounted(hold) + onMounted(() => { + if (!inDeactivatedView(instance)) hold() + }) // Also fires on a kept-alive first mount, right after `onMounted`. onActivated(hold) onDeactivated(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 index d640dcb..705e28a 100644 --- 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 @@ -86,4 +86,23 @@ describe('useScrollLock', () => { expect(document.body.style.overflowY).toBe('hidden') unmount() }) + + 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)) + const { unmount } = 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') + unmount() + }) }) From ef5eb35f2c296645315affe5cf1a76ddd77b7ded Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Mon, 5 Oct 2026 17:18:55 +0200 Subject: [PATCH 15/33] fix(vue): type the KeepAlive walk structurally for Vue 3.6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vue 3.6 types `instance.parent` as its Vapor-aware GenericComponentInstance, which ComponentInternalInstance no longer accepts. The walk reads only `isDeactivated` and `parent`, so it now takes exactly those — both versions typecheck. Co-Authored-By: Claude Opus 5.5 --- packages/vue/dialog/src/dialog.ts | 10 ++++++++-- .../vue/hooks/use-focus-trap/src/use-focus-trap.ts | 10 ++++++++-- .../vue/hooks/use-scroll-lock/src/use-scroll-lock.ts | 10 ++++++++-- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/packages/vue/dialog/src/dialog.ts b/packages/vue/dialog/src/dialog.ts index 685558c..5fdba0e 100644 --- a/packages/vue/dialog/src/dialog.ts +++ b/packages/vue/dialog/src/dialog.ts @@ -17,7 +17,6 @@ import { toValue, watch, type ButtonHTMLAttributes, - type ComponentInternalInstance, type ComponentOptionsMixin, type DefineComponent, type EffectScope, @@ -70,10 +69,17 @@ type DialogComponent = DefineComponent< // (`open` absent = uncontrolled) hold. const booleanOption = { type: Boolean, default: undefined } +// 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 view that is deactivated: async // data can mount a dialog into a cached view after the user left it, and its // DOM then waits in the cache for the view's return. -function inDeactivatedView(instance: ComponentInternalInstance | null): boolean { +function inDeactivatedView(instance: InstanceNode | null): boolean { for (let node = instance; node !== null; node = node.parent) { if (node.isDeactivated) return true } 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 index 85cd060..774b705 100644 --- a/packages/vue/hooks/use-focus-trap/src/use-focus-trap.ts +++ b/packages/vue/hooks/use-focus-trap/src/use-focus-trap.ts @@ -8,7 +8,6 @@ import { onScopeDispose, toValue, watch, - type ComponentInternalInstance, type EffectScope, type MaybeRefOrGetter, } from 'vue' @@ -17,9 +16,16 @@ 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 view that is deactivated — it // mounted into a cached view after the user left it, and waits for the return. -function inDeactivatedView(instance: ComponentInternalInstance | null): boolean { +function inDeactivatedView(instance: InstanceNode | null): boolean { for (let node = instance; node !== null; node = node.parent) { if (node.isDeactivated) return true } 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 index b1fcc64..0237945 100644 --- a/packages/vue/hooks/use-scroll-lock/src/use-scroll-lock.ts +++ b/packages/vue/hooks/use-scroll-lock/src/use-scroll-lock.ts @@ -8,15 +8,21 @@ import { onScopeDispose, toValue, watch, - type ComponentInternalInstance, 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 view that is deactivated — it // mounted into a cached view after the user left it, and waits for the return. -function inDeactivatedView(instance: ComponentInternalInstance | null): boolean { +function inDeactivatedView(instance: InstanceNode | null): boolean { for (let node = instance; node !== null; node = node.parent) { if (node.isDeactivated) return true } From 7d226747cc8a7694368cba5cfa30cd7905c7cc2c Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Mon, 5 Oct 2026 17:33:30 +0200 Subject: [PATCH 16/33] chore(vue): depend on the adapter's first publish, ^0.3.0 @dunky.dev/vue-state-machine is seeded at 0.2.0, as Solid's adapter was, so its first publish is 0.3.0: the dialog and the scaffold template take `^0.3.0`, and minimumReleaseAgeExclude lets that fresh release install. Verified against the packed adapter (0.2.0 tarball, the publish candidate), which resolves one runtime copy as the real publish will. Co-Authored-By: Claude Opus 5.5 --- packages/vue/dialog/package.json | 2 +- pnpm-workspace.yaml | 2 +- scripts/templates/packages/vue/__name__/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/vue/dialog/package.json b/packages/vue/dialog/package.json index 010af50..1167e4d 100644 --- a/packages/vue/dialog/package.json +++ b/packages/vue/dialog/package.json @@ -38,7 +38,7 @@ "dependencies": { "@dunky.dev/dialog": "workspace:*", "@dunky.dev/dom-dialog": "workspace:*", - "@dunky.dev/vue-state-machine": "^0.1.0", + "@dunky.dev/vue-state-machine": "^0.3.0", "@dunky.dev/vue-use-focus-trap": "workspace:*", "@dunky.dev/vue-use-scroll-lock": "workspace:*" }, diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 86b4a24..6d0d34f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -10,7 +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.1.0' + - '@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__/package.json b/scripts/templates/packages/vue/__name__/package.json index a1b2a8b..ee0afdf 100644 --- a/scripts/templates/packages/vue/__name__/package.json +++ b/scripts/templates/packages/vue/__name__/package.json @@ -37,7 +37,7 @@ }, "dependencies": { "@dunky.dev/__name__": "workspace:*", - "@dunky.dev/vue-state-machine": "^0.1.0" + "@dunky.dev/vue-state-machine": "^0.3.0" }, "devDependencies": { "@testing-library/vue": "^8.1.0", From 282ac14cefa7a52724126f643a77919a2d0a6759 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Mon, 5 Oct 2026 17:46:18 +0200 Subject: [PATCH 17/33] =?UTF-8?q?fix(vue):=20review=20findings=20=E2=80=94?= =?UTF-8?q?=20teardown=20order,=20component=20refs,=20inline=20Content?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the code review of the branch: - Teardown ran parent-first, before the DOM was removed: closing a parent over an open child restored focus into a page the child's layer still held, then the child restored into content about to vanish — focus ended on . The open edge, guard, trap, and lock now release in the unmounted hook, children first and after the DOM is gone, as React runs effect cleanups after commit (detached scopes, since the component scope stops earlier). - A template ref on a component holds its instance: as initialFocus it threw after the layer registered, leaving the page inert; as restoreFocus it restored nothing; the hooks crashed. A component now counts as its root element (`$el`), in the dialog and both hooks. - Content rendered without a Portal: the scroll lock held while the dialog was closed, and an animated exit hid the content's outermost ancestor — the whole app root. The lock now follows open/closing, and without a Portal the exit window hides the window alone. - Reactivation started the open sequence from a nextTick callback, outside the component, so its errors bypassed the app's error handler. It now starts in the activated hook and waits for the layer to be back in place through a reactive flag. - The Portal's re-key compares the resolved target (an explicit document.body no longer re-creates the teleport) and re-renders only when the dialog's mounted state flips. The history guard installs only when closeOnBack is set. The SPEC records the decisions the review raised that stay as they are: element-only `container`, per-app `useId` (set `app.config.idPrefix` with several apps), defineModel wrappers under the controlled contract, and the veto's reach over modifier and throwing listeners. Co-Authored-By: Claude Opus 5.5 --- packages/vue/dialog/SPEC.md | 67 +++--- packages/vue/dialog/src/context.ts | 3 + packages/vue/dialog/src/dialog.ts | 218 ++++++++++-------- packages/vue/dialog/tests/dialog.test.tsx | 77 ++++++- packages/vue/hooks/use-focus-trap/SPEC.md | 10 +- .../use-focus-trap/src/use-focus-trap.ts | 39 ++-- .../tests/use-focus-trap.test.tsx | 23 ++ packages/vue/hooks/use-scroll-lock/SPEC.md | 12 +- .../use-scroll-lock/src/use-scroll-lock.ts | 44 ++-- .../tests/use-scroll-lock.test.ts | 16 ++ 10 files changed, 348 insertions(+), 161 deletions(-) diff --git a/packages/vue/dialog/SPEC.md b/packages/vue/dialog/SPEC.md index 75fba2b..8fa453b 100644 --- a/packages/vue/dialog/SPEC.md +++ b/packages/vue/dialog/SPEC.md @@ -49,27 +49,34 @@ Vue-specific notes on top of the core contract: change, it never requests one: a controlled dialog — `v-model:open` included — doesn't move on its own Trigger, Close, Escape, or outside press. Wire the intent at its source: `@click` on the Trigger, a handler - on `escape-key-down`, your own action buttons. + on `escape-key-down`, your own action buttons. A wrapper that forwards its + own `defineModel('open')` through `v-model:open` makes the dialog + controlled the moment that model holds a value, with the same obligation. + The veto composes with plain listeners; a modifier listener + (`@click.capture`, `.once`) is a separate DOM listener the part's handler + doesn't consult. A listener that throws is caught by Vue's error handling, + so it can't veto. - **Boolean props follow the core defaults when absent.** Vue casts an absent Boolean prop to `false`; this root declares its booleans without that cast, so `` is modal and uncontrolled, and a bare attribute (``) switches the option on. - **`Portal`** teleports the layers to `document.body`, or to a `container` element you supply. Nothing is kept mounted while closed; an `animated` - dialog stays mounted through the core contract's `closing` state so its - exit can play — see the exit-animation note below. The server renders no - portal (there is no document to teleport into), and so neither does the - client's hydration pass: the teleport arrives with the first update after - the Portal mounts. Swapping `container` while open re-creates the teleport - on the new target rather than moving it, like the React and Solid portals — - the open sequence runs again against the new placement. When scoped to a - `container`, the scroll lock applies to that container instead of the page, - and the backdrop/viewport must be positioned `absolute` (not `fixed`) so the - overlay pins to the container. Because an `absolute` overlay can't stay fixed - inside a scrolling element, a scoped container that needs a scrollable - background should be a non-scrolling positioned boundary wrapping an inner - scroller — portal into the boundary; the overlay fills its visible box and - the backdrop blocks the scroller behind it (see the `scoped` story). + dialog stays mounted through the core contract's `closing` state so its exit + can play — see the exit-animation note below. The server renders no portal + (there is no document to teleport into), and so neither does the client's + hydration pass: the teleport arrives with the first update after the Portal + mounts. Swapping `container` while open re-creates the teleport on the new + target rather than moving it, like the React and Solid portals — the open + sequence runs again against the new placement. `container` takes an element, + not a selector — query it first. When scoped to a `container`, the scroll + lock applies to that container instead of the page, and the + backdrop/viewport must be positioned `absolute` (not `fixed`) so the overlay + pins to the container. Because an `absolute` overlay can't stay fixed inside + a scrolling element, a scoped container that needs a scrollable background + should be a non-scrolling positioned boundary wrapping an inner scroller — + portal into the boundary; the overlay fills its visible box and the backdrop + blocks the scroller behind it (see the `scoped` story). - **`Content`** renders a `
` carrying the `dialog` (or `alertdialog`) role, not the native `` element. The dialog window is the initial focus target — focusable in script, out of the tab order — which needs @@ -81,12 +88,16 @@ Vue-specific notes on top of the core contract: (see the core spec's Internals). With the role explicit and the element neutral, there is nothing left to gain and one conformance rule left to break. -- **`Content`'s `initialFocus`** accepts an element, a ref, or a getter - (`MaybeRefOrGetter`), resolved at open time; `restoreFocus` takes the same - shape, resolved at close time. A template unwraps a ref when it renders — - before an element inside the dialog has mounted — so a template passes a - getter: `:initial-focus="() => cancelButton"`. Script and TSX pass the ref - itself. +- **`Content`'s `initialFocus`** accepts an element, a component (its `$el`), + a ref to either, or a getter, resolved at open time; `restoreFocus` takes + the same shape, resolved at close time. A template unwraps a ref when it + renders — before an element inside the dialog has mounted — so a template + passes a getter: `:initial-focus="() => cancelButton"`. Script and TSX pass + the ref itself. +- **`Content` without a `Portal`** stays mounted while closed, so its DOM + work follows the dialog's state, not its mount: the scroll lock holds while + the dialog is open or closing, and the exit window hides the window + alone — it sits in the page, not in a container of its own. - **Element access**: every part renders exactly one root element, so a template ref on a part reaches it as `$el` (`contentPart.value.$el`). A non-modal Backdrop renders nothing, and the Portal renders no element of @@ -136,7 +147,9 @@ Vue-specific notes on top of the core contract: holds still the same way until the view returns. - **Server rendering** touches no DOM: the root and its Trigger render, the document-level work starts on mount, and the base id comes from `useId`, so - the hydrated parts carry the ids the server rendered. + the hydrated parts carry the ids the server rendered. `useId` counts per + app: with several Vue apps on one page, give each its own + `app.config.idPrefix` — dialogs share one layer stack, keyed by id. - Everything ships headless, per the core contract's [Internals](../../core/dialog/SPEC.md#internals). @@ -211,11 +224,11 @@ Content. The dialog window; renders a `
` with the `dialog` role. Default slot: the window's content. -| Prop | Type | Default | Description | -| -------------- | ---------------------------------------------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `initialFocus` | `MaybeRefOrGetter` | the dialog window | The element to focus when the dialog opens — resolved at open time. | -| `restoreFocus` | `MaybeRefOrGetter` | — | Focused on close when nothing meaningful held focus before opening (the body, or an element since removed) — resolved at close time. Typically the trigger. | -| `...attrs` | `HTMLAttributes` | — | Merged onto the rendered `
`. | +| Prop | Type | Default | Description | +| -------------- | ------------------------------------------------------------------------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `initialFocus` | `MaybeRefOrGetter` | the dialog window | The element to focus when the dialog opens — resolved at open time. | +| `restoreFocus` | `MaybeRefOrGetter` | — | Focused on close when nothing meaningful held focus before opening (the body, or an element since removed) — resolved at close time. Typically the trigger. | +| `...attrs` | `HTMLAttributes` | — | Merged onto the rendered `
`. | ### `Dialog.Title` diff --git a/packages/vue/dialog/src/context.ts b/packages/vue/dialog/src/context.ts index 7b43b33..4e08de3 100644 --- a/packages/vue/dialog/src/context.ts +++ b/packages/vue/dialog/src/context.ts @@ -13,6 +13,9 @@ export interface DialogContextValue { // 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 + // Whether the parts render inside a Portal — without one the window sits in + // the page itself, which bounds what its exit window may hide. + portalled: boolean // The rendered Backdrop, shared because Backdrop and Content are sibling // parts: Content's stack entry excepts its own backdrop from the // containment so it stays pressable while its dialog is topmost. diff --git a/packages/vue/dialog/src/dialog.ts b/packages/vue/dialog/src/dialog.ts index 5fdba0e..3b8574e 100644 --- a/packages/vue/dialog/src/dialog.ts +++ b/packages/vue/dialog/src/dialog.ts @@ -1,5 +1,6 @@ import { Teleport, + computed, defineComponent, effectScope, getCurrentInstance, @@ -7,7 +8,6 @@ import { inject, nextTick, onActivated, - onBeforeUnmount, onDeactivated, onMounted, onScopeDispose, @@ -18,6 +18,7 @@ import { watch, type ButtonHTMLAttributes, type ComponentOptionsMixin, + type ComponentPublicInstance, type DefineComponent, type EffectScope, type EmitsOptions, @@ -47,12 +48,11 @@ import { mergeProps, normalize } from '@dunky.dev/vue-state-machine' import { DialogContextKey, useDialogContext } from './context' import { useDialog } from './use-dialog' -// Explicit so the exports satisfy --isolatedDeclarations (a bare -// defineComponent call gives the variable no annotatable type). It is the -// options-carrying type an SFC gets: the function-signature defineComponent -// types its result as a bare constructor, which tooling built around SFCs -// (Storybook's Meta) rejects — the value is the same options object either -// way, so the casts below only name it. +// 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 DialogComponent = DefineComponent< Props, {}, @@ -76,9 +76,8 @@ interface InstanceNode { parent: InstanceNode | null } -// Whether the component sits in a view that is deactivated: async -// data can mount a dialog into a cached view after the user left it, and its -// DOM then waits in the cache for the view's return. +// Whether the component sits in a deactivated view: async data +// can mount a dialog into a cached view after the user left it. function inDeactivatedView(instance: InstanceNode | null): boolean { for (let node = instance; node !== null; node = node.parent) { if (node.isDeactivated) return true @@ -86,38 +85,42 @@ function inDeactivatedView(instance: InstanceNode | null): boolean { return false } -// Runs `effects` in a scope that lives while the component is mounted and -// active. A deactivation ends it like an unmount — the adapter -// pauses the machine the same way, as React's runs its effect -// cleanups — and reactivation runs it again, once the flush that restores the -// view is done: a Portal re-enables its teleport in that flush, and the -// layer has to be back in place before its sequences run. Mounted hooks -// never run during server rendering, so neither does it. +// Runs `effects` in a scope that lives while the component is mounted and its +// view active. A deactivation ends it like an unmount — the +// adapter pauses the machine the same way, as React's runs its +// effect cleanups. Released after the DOM is gone and children first, as +// React runs cleanups after commit: a parent closing over an open child must +// not restore focus into a layer that is still registered above it. Mounted +// hooks never run during server rendering, so neither does this. function whileActive(effects: () => void): void { const instance = getCurrentInstance() let scope: EffectScope | undefined - let active = false const start = (): void => { - if (!active || scope !== undefined) return - scope = effectScope() + if (scope !== undefined) return + // Detached: the component scope stops before the DOM is removed, parent + // first — the release order above needs the unmounted hook instead. + scope = effectScope(true) scope.run(effects) } const stop = (): void => { - active = false scope?.stop() scope = undefined } onMounted(() => { - active = !inDeactivatedView(instance) - start() + if (!inDeactivatedView(instance)) start() }) // Also fires on a kept-alive first mount, after `onMounted` already started. - onActivated(() => { - active = true - void nextTick(start) - }) + onActivated(start) onDeactivated(stop) - onBeforeUnmount(stop) + onUnmounted(stop) +} + +// A template ref on a component holds its instance; the element is its `$el`. +function toElement( + target: HTMLElement | ComponentPublicInstance | null | undefined, +): HTMLElement | null { + const element = target instanceof HTMLElement ? target : target?.$el + return element instanceof HTMLElement ? element : null } // ============================================================================= @@ -163,35 +166,42 @@ const DialogRoot = defineComponent( const { api, machine } = useDialog(() => ({ ...props, ...callbacks })) const backdropRef = shallowRef(null) - provide(DialogContextKey, { api, machine, depth, container: () => null, backdropRef }) + provide(DialogContextKey, { + api, + machine, + depth, + container: () => null, + portalled: false, + backdropRef, + }) // The guard lives on the root — it concerns the dialog's openness, not any // rendered part. It spans more than the open state, so it can't be the // watcher's cleanup: a Back-close leaves the registration parked for the // Forward that may reopen it, and only the scope's end — an unmount or a // deactivation — ends the episode outright. - let guard: BackNavigationGuard | null = null - - whileActive(() => { - watch( - () => api.value.open, - open => { - if (!machine.context.closeOnBack) return - guard ??= guardBackNavigation({ - backNavigate: () => api.value.backNavigate(), - forwardNavigate: () => api.value.forwardNavigate(), - isOpen: () => machine.matches('open'), - depth, - }) - guard.sync(open) - }, - { immediate: true, flush: 'post' }, - ) - onScopeDispose(() => { - guard?.release() - guard = null + if (machine.context.closeOnBack) { + let guard: BackNavigationGuard | null = null + whileActive(() => { + watch( + () => api.value.open, + open => { + guard ??= guardBackNavigation({ + backNavigate: () => api.value.backNavigate(), + forwardNavigate: () => api.value.forwardNavigate(), + isOpen: () => machine.matches('open'), + depth, + }) + guard.sync(open) + }, + { immediate: true, flush: 'post' }, + ) + onScopeDispose(() => { + guard?.release() + guard = null + }) }) - }) + } return () => slots.default?.() }, @@ -201,13 +211,13 @@ const DialogRoot = defineComponent( // through to whichever single part happens to render. inheritAttrs: false, props: { - id: { type: String, default: undefined }, + id: String, open: booleanOption, defaultOpen: booleanOption, modal: booleanOption, - role: { type: String as PropType, default: undefined }, + role: String as PropType, closeOnEscape: booleanOption, - escapeScope: { type: String as PropType, default: undefined }, + escapeScope: String as PropType, closeOnInteractOutside: booleanOption, closeOnBack: booleanOption, animated: booleanOption, @@ -228,7 +238,7 @@ const DialogRoot = defineComponent( export interface DialogTriggerProps extends ButtonHTMLAttributes {} -export const Trigger: DialogComponent = defineComponent( +export const Trigger = defineComponent( (_props: DialogTriggerProps, { attrs, slots }) => { const { api } = useDialogContext() return () => @@ -250,25 +260,28 @@ export interface DialogPortalProps { container?: HTMLElement | null } -export const Portal: DialogComponent = defineComponent( +export const Portal = defineComponent( (props: DialogPortalProps, { slots }) => { const context = useDialogContext() // Re-provide the context with the scoped container (null = page body) so // Content locks the right scroll surface. - provide(DialogContextKey, { ...context, container: () => props.container ?? null }) + provide(DialogContextKey, { + ...context, + container: () => props.container ?? null, + portalled: true, + }) // The server has no document to teleport into, so it renders no portal — // and the client's hydration pass must render the same: the teleport - // arrives with the first update after mount. - const mounted = shallowRef(false) - // A deactivation parks the layers back in place, inside the - // cached subtree and off the document — Vue would leave teleported - // content painted over the next view while the machine behind it is - // paused. Disabling, not unmounting, keeps their state for reactivation. + // arrives with the first update after mount. While the view is + // deactivated by , the teleport is disabled: Vue parks the + // layers back in place, in the cached view and off the document, instead + // of leaving them painted over the next view — with their state intact. + const ready = shallowRef(false) const active = shallowRef(true) const instance = getCurrentInstance() onMounted(() => { - mounted.value = true + ready.value = true active.value = !inDeactivatedView(instance) }) onActivated(() => { @@ -278,21 +291,24 @@ export const Portal: DialogComponent = defineComponent( active.value = false }) + // `mounted`, not `open`: an animated dialog stays in the tree through + // `closing` so its exit visual can play before everything unmounts. A + // computed, so the portal re-renders when that flips, not on every change. + const mounted = computed(() => context.api.value.mounted) + // A container swap re-creates the teleport instead of moving it, like // React's and Solid's portals: a moved window drops focus, and its // containment was computed for the old placement. let generation = 0 watch( - () => props.container ?? null, + () => props.container ?? document.body, () => { generation++ }, ) return () => { - // `mounted`, not `open`: an animated dialog stays in the tree through - // `closing` so its exit visual can play before everything unmounts. - if (!mounted.value || !context.api.value.mounted) return null + if (!ready.value || !mounted.value) return null return h( Teleport, { key: generation, to: props.container ?? document.body, disabled: !active.value }, @@ -309,7 +325,7 @@ export const Portal: DialogComponent = defineComponent( export interface DialogBackdropProps extends HTMLAttributes {} -export const Backdrop: DialogComponent = defineComponent( +export const Backdrop = defineComponent( (_props: DialogBackdropProps, { attrs, slots }) => { const { api, machine, backdropRef } = useDialogContext() return () => { @@ -339,7 +355,7 @@ export const Backdrop: DialogComponent = defineComponent( export interface DialogViewportProps extends HTMLAttributes {} -export const Viewport: DialogComponent = defineComponent( +export const Viewport = defineComponent( (_props: DialogViewportProps, { attrs, slots }) => { const { api, machine } = useDialogContext() return () => { @@ -365,8 +381,9 @@ export const Viewport: DialogComponent = defineComponent( // close, traps while modal // ============================================================================= -/** An element, a ref to one, or a getter — resolved when the dialog reads it. */ -type FocusTarget = MaybeRefOrGetter +/** An element, a component, a ref to either, or a getter — resolved when the + * dialog reads it. A component counts as its root element (`$el`). */ +type FocusTarget = MaybeRefOrGetter export interface DialogContentProps extends HTMLAttributes { /** The element to focus when the dialog opens — resolved at open time, so a @@ -378,27 +395,43 @@ export interface DialogContentProps extends HTMLAttributes { restoreFocus?: FocusTarget } -export const Content: DialogComponent = defineComponent( +export const Content = defineComponent( (props: DialogContentProps, { attrs, slots }) => { - const { api, machine, depth, container, backdropRef } = useDialogContext() + const { api, machine, depth, container, portalled, backdropRef } = useDialogContext() const contentRef = shallowRef(null) + // Whether the window is where it renders: back from a cache it + // is still parked until the Portal re-enables its teleport, in the flush + // that restores the view — so the sequences wait that flush out. + const placed = shallowRef(true) + const instance = getCurrentInstance() + onMounted(() => { + placed.value = !inDeactivatedView(instance) + }) + onDeactivated(() => { + placed.value = false + }) + onActivated(() => { + void nextTick(() => { + placed.value = true + }) + }) + // The machine's state is the edge, not mount/unmount: an animated dialog // stays mounted through `closing`, its exit window, and a Content rendered // with the root (no Portal) is mounted while closed too. The sequences // and their inverses are the DOM package's; this watcher only ties them - // to Vue's lifecycle, once the element exists. Released by hand rather - // than through the watcher's onCleanup, which Vue 3.6 also runs when the - // source merely re-evaluates. + // to Vue's lifecycle. Released by hand: Vue 3.6 also runs a watcher's + // onCleanup when its source merely re-evaluates — on every api change. whileActive(() => { let release: (() => void) | undefined watch( - () => (api.value.open ? 'open' : api.value.mounted ? 'closing' : 'closed'), - state => { + [() => (api.value.open ? 'open' : api.value.mounted ? 'closing' : 'closed'), placed], + ([state, isPlaced]) => { release?.() release = undefined const content = contentRef.value - if (content === null || state === 'closed') return + if (content === null || !isPlaced || state === 'closed') return release = state === 'open' @@ -407,12 +440,14 @@ export const Content: DialogComponent = defineComponent( depth, modal: machine.context.modal, backdrop: () => backdropRef.value, - initialFocus: toValue(props.initialFocus), - restoreFocus: () => toValue(props.restoreFocus) ?? null, + initialFocus: toElement(toValue(props.initialFocus)), + restoreFocus: () => toElement(toValue(props.restoreFocus)), dismiss: () => machine.send({ type: 'close' }), }) : startExitWindow(content, { - container: container(), + // Without a Portal the window sits in the page itself: the + // exit hides the window, not its outermost ancestor. + container: portalled ? container() : content.parentElement, backdrop: backdropRef.value, onComplete: () => machine.send({ type: 'exit.complete' }), }) @@ -425,11 +460,14 @@ export const Content: DialogComponent = defineComponent( onScopeDispose(() => release?.()) }) - // The lock spans the whole mount — through `closing` too: releasing it - // mid-exit would bring the scrollbar back and reflow the page under the - // still-painting layer. The context's `null` means "page body", not the - // hook's "no target yet" — name the body. Read on mount, client-side. - useScrollLock(machine.context.modal, () => container() ?? document.body) + // The lock spans the dialog's occupancy — through `closing` too: + // releasing it mid-exit would bring the scrollbar back and reflow the page + // under the still-painting layer. The context's `null` means "page body", + // which the hook needs named. + useScrollLock( + () => machine.context.modal && api.value.mounted, + () => container() ?? document.body, + ) useFocusTrap( contentRef, @@ -455,7 +493,7 @@ export const Content: DialogComponent = defineComponent( export interface DialogTitleProps extends HTMLAttributes {} -export const Title: DialogComponent = defineComponent( +export const Title = defineComponent( (_props: DialogTitleProps, { attrs, slots }) => { const { api, machine } = useDialogContext() @@ -475,7 +513,7 @@ export const Title: DialogComponent = defineComponent( export interface DialogDescriptionProps extends HTMLAttributes {} -export const Description: DialogComponent = defineComponent( +export const Description = defineComponent( (_props: DialogDescriptionProps, { attrs, slots }) => { const { api, machine } = useDialogContext() @@ -494,7 +532,7 @@ export const Description: DialogComponent = defineCompon export interface DialogCloseProps extends ButtonHTMLAttributes {} -export const Close: DialogComponent = defineComponent( +export const Close = defineComponent( (_props: DialogCloseProps, { attrs, slots }) => { const { api } = useDialogContext() return () => diff --git a/packages/vue/dialog/tests/dialog.test.tsx b/packages/vue/dialog/tests/dialog.test.tsx index 9380e58..c3a59f6 100644 --- a/packages/vue/dialog/tests/dialog.test.tsx +++ b/packages/vue/dialog/tests/dialog.test.tsx @@ -53,6 +53,10 @@ const press = async (element: HTMLElement): Promise => { const openDialog = (): Promise => press(screen.getByText('Trigger')) +// A macrotask: every flush a reactivation chains (restore, re-enable the +// teleport, then the open sequence) has run. +const afterFlushes = (): Promise => new Promise(resolve => setTimeout(resolve)) + const pressEscape = (): Promise => fireEvent.keyDown(document.body, { key: 'Escape' }) // Runtime-compiled templates resolve components by registered name, so the @@ -1034,6 +1038,73 @@ describe('Dialog', () => { await pressEscape() expect(container.hasAttribute('inert')).toBe(false) expect(container.hasAttribute('aria-hidden')).toBe(false) + expect(document.body.style.overflowY).not.toBe('hidden') + }) + + it('an animated Content rendered with the root hides only itself through its exit', async () => { + const { container } = await renderSettled(() => ( + + Trigger + content + + )) + await pressEscape() + const dialog = screen.getByRole('dialog', { hidden: true }) + expect(dialog.getAttribute('data-state')).toBe('closing') + expect(dialog.hasAttribute('inert')).toBe(true) + expect(container.hasAttribute('inert')).toBe(false) + expect(screen.getByText('Trigger').closest('[inert]')).toBeNull() + }) + + // Cleanup after the DOM is gone, children first — as React's effects — + // or the parent would restore focus while the child still holds the page. + it("closing a parent over an open child returns focus to the parent's trigger", async () => { + const open = ref(false) + await renderSettled(() => ( + + Trigger + + + + + inner + + + + + + )) + const trigger = screen.getByText('Trigger') + trigger.focus() + open.value = true + await afterFlushes() + expect(document.activeElement).toBe(screen.getByRole('dialog', { name: 'Inner' })) + + open.value = false + await afterFlushes() + expect(document.activeElement).toBe(trigger) + }) + + // A template ref on a component holds its instance, not an element. + it("initialFocus and restoreFocus take a component's ref as its element", async () => { + const Action = defineComponent(() => () => ) + const action = ref | null>(null) + const trigger = ref | null>(null) + await renderSettled(() => ( + + Trigger + + + + + + + )) + await openDialog() // a click leaves focus on the body: the fallback applies + expect(document.activeElement).toBe(screen.getByText('Action')) + + await pressEscape() + expect(document.activeElement).toBe(screen.getByText('Trigger')) }) // Children mount before their parent, so a Title rendered with the root @@ -1157,8 +1228,7 @@ describe('Dialog', () => { expect(elsewhere.dispatchEvent(tab)).toBe(true) // the trap let go shown.value = true - await nextTick() - await nextTick() // the open sequence waits out the flush that re-enables the teleport + await afterFlushes() // the open sequence waits out the flush that re-enables the teleport expect(screen.getByLabelText('Name')).toBe(field) expect(field.value).toBe('typed') expect(document.activeElement).toBe(field) @@ -1198,8 +1268,7 @@ describe('Dialog', () => { expect(window.history.state).toEqual(before) shown.value = true - await nextTick() - await nextTick() + await afterFlushes() expect(document.activeElement).toBe(screen.getByRole('dialog')) expect(document.body.style.overflowY).toBe('hidden') diff --git a/packages/vue/hooks/use-focus-trap/SPEC.md b/packages/vue/hooks/use-focus-trap/SPEC.md index 80bb6ff..644eb26 100644 --- a/packages/vue/hooks/use-focus-trap/SPEC.md +++ b/packages/vue/hooks/use-focus-trap/SPEC.md @@ -33,7 +33,7 @@ useFocusTrap(panel, { enabled: () => isTopmostLayer(props.id) }) Vue-specific notes on top of the DOM contract: - The target is a `MaybeRefOrGetter` — a template ref, a getter, or the - element itself. The trap arms when the component mounts (a template ref + element itself; a component counts as its root element (`$el`). 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 @@ -50,7 +50,7 @@ Vue-specific notes on top of the DOM contract: 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`. | +| 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/src/use-focus-trap.ts b/packages/vue/hooks/use-focus-trap/src/use-focus-trap.ts index 774b705..faa5966 100644 --- a/packages/vue/hooks/use-focus-trap/src/use-focus-trap.ts +++ b/packages/vue/hooks/use-focus-trap/src/use-focus-trap.ts @@ -2,12 +2,13 @@ import { effectScope, getCurrentInstance, onActivated, - onBeforeUnmount, onDeactivated, onMounted, onScopeDispose, + onUnmounted, toValue, watch, + type ComponentPublicInstance, type EffectScope, type MaybeRefOrGetter, } from 'vue' @@ -23,8 +24,8 @@ interface InstanceNode { parent: InstanceNode | null } -// Whether the component sits in a view that is deactivated — it -// mounted into a cached view after the user left it, and waits for the return. +// 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 @@ -32,34 +33,44 @@ function inDeactivatedView(instance: InstanceNode | null): boolean { return false } +// A template ref on a component holds its instance; the element is its `$el`. +function toElement( + target: HTMLElement | ComponentPublicInstance | null | undefined, +): HTMLElement | null { + const element = target instanceof HTMLElement ? target : target?.$el + return element instanceof HTMLElement ? element : 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, + target: MaybeRefOrGetter, options: UseFocusTrapOptions = {}, ): void { - // Armed from mount, when a template ref has filled; a - // deactivation releases it like an unmount, as React's runs the - // effect's cleanup. Mounted hooks never run during server rendering. The - // trap reads the options object per Tab press, so `enabled` / `last` stay - // live without re-binding. Released by hand rather than through the - // watcher's onCleanup, which Vue 3.6 also runs when the target merely + // 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 - scope = effectScope() + // Detached: the component scope stops before the DOM is removed. + scope = effectScope(true) scope.run(() => { let untrap: (() => void) | undefined watch( - () => toValue(target), + () => toElement(toValue(target)), container => { untrap?.() - untrap = container == null ? undefined : trapFocus(container, options) + untrap = container === null ? undefined : trapFocus(container, options) }, { immediate: true, flush: 'post' }, ) @@ -76,5 +87,5 @@ export function useFocusTrap( // Also fires on a kept-alive first mount, right after `onMounted`. onActivated(arm) onDeactivated(disarm) - onBeforeUnmount(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 index 7cb9c77..b0aa739 100644 --- 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 @@ -89,4 +89,27 @@ describe('useFocusTrap', () => { expect(tab(screen.getByTestId('container'))).toBe(false) outside.remove() }) + + 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 () => ( + + + + + ) + }), + ) + screen.getByText('last').focus() + expect(tab(screen.getByTestId('container'))).toBe(false) + expect(document.activeElement).toBe(screen.getByText('first')) + }) }) diff --git a/packages/vue/hooks/use-scroll-lock/SPEC.md b/packages/vue/hooks/use-scroll-lock/SPEC.md index bb8dacf..2a86903 100644 --- a/packages/vue/hooks/use-scroll-lock/SPEC.md +++ b/packages/vue/hooks/use-scroll-lock/SPEC.md @@ -32,8 +32,8 @@ Vue-specific notes on top of the DOM contract: 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. Both parameters accept a - `MaybeRefOrGetter` — a plain value, a ref, or a getter — so the lock - tracks reactive state: a `target` change releases the old container and + `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. - An omitted `target` means the page body. A target that resolves to `null` or `undefined` — a template ref before its element renders — means @@ -50,7 +50,7 @@ Vue-specific notes on top of the DOM contract: 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. | +| 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/src/use-scroll-lock.ts b/packages/vue/hooks/use-scroll-lock/src/use-scroll-lock.ts index 0237945..ebaf506 100644 --- a/packages/vue/hooks/use-scroll-lock/src/use-scroll-lock.ts +++ b/packages/vue/hooks/use-scroll-lock/src/use-scroll-lock.ts @@ -2,12 +2,13 @@ import { effectScope, getCurrentInstance, onActivated, - onBeforeUnmount, onDeactivated, onMounted, onScopeDispose, + onUnmounted, toValue, watch, + type ComponentPublicInstance, type EffectScope, type MaybeRefOrGetter, } from 'vue' @@ -20,8 +21,8 @@ interface InstanceNode { parent: InstanceNode | null } -// Whether the component sits in a view that is deactivated — it -// mounted into a cached view after the user left it, and waits for the return. +// 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 @@ -29,35 +30,48 @@ function inDeactivatedView(instance: InstanceNode | null): boolean { return false } +// A template ref on a component holds its instance; the element is its `$el`. +function toElement( + target: HTMLElement | ComponentPublicInstance | null | undefined, +): HTMLElement | null { + const element = target instanceof HTMLElement ? target : target?.$el + return element instanceof HTMLElement ? element : 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. The lock is shared per container: it restores - * when the last holder releases. + * 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, + target?: MaybeRefOrGetter, ): void { - // Held from mount; a deactivation releases it like an unmount, - // as React's runs the effect's cleanup. Mounted hooks never run - // during server rendering, where there is no body to lock. Released by - // hand rather than through the watcher's onCleanup, which Vue 3.6 also - // runs when a source merely re-evaluates. + // 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 - scope = effectScope() + // 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 : toValue(target))], + [ + () => toValue(locked), + () => (target === undefined ? document.body : toElement(toValue(target))), + ], ([isLocked, container]) => { unlock?.() - unlock = !isLocked || container == null ? undefined : lockScroll(container) + unlock = !isLocked || container === null ? undefined : lockScroll(container) }, { immediate: true, flush: 'post' }, ) @@ -74,5 +88,5 @@ export function useScrollLock( // Also fires on a kept-alive first mount, right after `onMounted`. onActivated(hold) onDeactivated(drop) - onBeforeUnmount(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 index 705e28a..23fe9d6 100644 --- 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 @@ -105,4 +105,20 @@ describe('useScrollLock', () => { expect(document.body.style.overflowY).toBe('hidden') unmount() }) + + it("takes a component's ref as its root element", () => { + const Surface = defineComponent(() => () => h('section')) + const surface = ref | null>(null) + const { unmount } = render( + defineComponent({ + setup() { + useScrollLock(true, surface) + return () => h(Surface, { ref: surface }) + }, + }), + ) + expect((surface.value?.$el as HTMLElement).style.overflowY).toBe('hidden') + expect(document.body.style.overflowY).toBe('') + unmount() + }) }) From 2ee3840e70e1a78a7f7c55a2a65388c5f937407f Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Mon, 5 Oct 2026 17:48:46 +0200 Subject: [PATCH 18/33] chore(scaffold): type the vue template's parts by their cast alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches the dialog: the cast names the options-carrying component type and already gives the export the explicit type --isolatedDeclarations needs, so the annotation repeated it. Re-verified by stamping a primitive in a scratch copy — tests, typecheck, lint, format, and publint pass. Co-Authored-By: Claude Opus 5.5 --- .../templates/packages/vue/__name__/src/__name__.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/scripts/templates/packages/vue/__name__/src/__name__.ts b/scripts/templates/packages/vue/__name__/src/__name__.ts index cd60974..7633e49 100644 --- a/scripts/templates/packages/vue/__name__/src/__name__.ts +++ b/scripts/templates/packages/vue/__name__/src/__name__.ts @@ -13,12 +13,11 @@ import { mergeProps, normalize } from '@dunky.dev/vue-state-machine' import { __Name__ContextKey, use__Name__Context } from './context' import { use__Name__ } from './use-__name__' -// Explicit so the exports satisfy --isolatedDeclarations (a bare -// defineComponent call gives the variable no annotatable type). It is the -// options-carrying type an SFC gets: the function-signature defineComponent -// types its result as a bare constructor, which tooling built around SFCs -// (Storybook's Meta) rejects — the value is the same options object either -// way, so the casts below only name it. +// 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, {}, @@ -72,7 +71,7 @@ const __Name__Root = defineComponent<__Name__Props, __Name__Emits>( export interface __Name__RootProps extends ButtonHTMLAttributes {} -export const Root: __Name__Component<__Name__RootProps> = defineComponent( +export const Root = defineComponent( (_props: __Name__RootProps, { attrs, slots }) => { const { api } = use__Name__Context() return () => From 006ab8c2323d9497e1e72319e501d69e8d75b02a Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Tue, 6 Oct 2026 14:56:38 +0200 Subject: [PATCH 19/33] test(vue): press Tab on the page when checking a parked trap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The KeepAlive test pressed Tab on the cached container — off the document, where the trap's document-level listener never hears it — so it passed whether or not deactivation released the trap. It now presses Tab on an element in the page, and fails when the release is removed. Co-Authored-By: Claude Opus 5.5 --- .../vue/hooks/use-focus-trap/tests/use-focus-trap.test.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 index b0aa739..a3381b1 100644 --- 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 @@ -60,14 +60,19 @@ describe('useFocusTrap', () => { const shown = ref(true) render(() => {shown.value ? : null}) const container = screen.getByTestId('container') + // The cached container is off the document; a Tab there would reach no + // listener either way — press it on the page instead. + const outside = document.createElement('button') + document.body.append(outside) shown.value = false await nextTick() - expect(tab(container)).toBe(true) + expect(tab(outside)).toBe(true) shown.value = true await nextTick() expect(tab(container)).toBe(false) + outside.remove() }) it('stays unarmed when mounted into a deactivated KeepAlive view, and arms once it returns', async () => { From aef01f4cafc53616723a8335dfc104fb65cadcd5 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Tue, 6 Oct 2026 14:58:02 +0200 Subject: [PATCH 20/33] chore(scaffold): the vue template peers vue ^3.4.0, as its adapter does A scaffolded Vue binding depends on @dunky.dev/vue-state-machine, whose final peer range is `vue ^3.4.0`; the template's own `^3.3.0` promised consumers a floor its dependency doesn't support. (The hooks keep `^3.3.0`: they don't use the adapter, and their suite passes on 3.3.13.) Co-Authored-By: Claude Opus 5.5 --- scripts/templates/packages/vue/__name__/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/templates/packages/vue/__name__/package.json b/scripts/templates/packages/vue/__name__/package.json index ee0afdf..5269535 100644 --- a/scripts/templates/packages/vue/__name__/package.json +++ b/scripts/templates/packages/vue/__name__/package.json @@ -44,6 +44,6 @@ "vue": "^3.5.43" }, "peerDependencies": { - "vue": "^3.3.0" + "vue": "^3.4.0" } } From 50757676fc16e3d7cb60385fcbc3be44bccea2d5 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Tue, 6 Oct 2026 17:50:49 +0200 Subject: [PATCH 21/33] fix(vue): open and release the layer on the rendered state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adapter syncs a controlled `open` in a post-flush job, so the Content's `post` watcher — already queued by the prop change — saw the new state before the render it queued. A controlled close released the parent's layer while its open child still held the page inert (a browser refuses the focus restore, so focus fell to ), and a controlled open of a Portal-less Content moved focus before its stylesheet un-hid it. The sequences now follow the state the window's DOM shows, committed by the mounted and updated hooks; a portalled window never renders `closed`, so its release stays with the unmount, after the DOM is gone. KeepAlive also restores a view's children first: placements now run by depth, so a restored nested stack reopens outermost first and each layer takes focus from the one beneath, as on the first open. The nested-close test runs with the browser's rule that an inert element can't take focus (jsdom grants it, which hid the bug); the inline test opens through the controlled prop; a new test covers the restored stack. All three fail on the previous Content. Co-Authored-By: Claude Opus 5.5 --- packages/vue/dialog/src/dialog.ts | 66 +++++++++++++---- packages/vue/dialog/tests/dialog.test.tsx | 90 +++++++++++++++++++---- 2 files changed, 128 insertions(+), 28 deletions(-) diff --git a/packages/vue/dialog/src/dialog.ts b/packages/vue/dialog/src/dialog.ts index 3b8574e..418d9c7 100644 --- a/packages/vue/dialog/src/dialog.ts +++ b/packages/vue/dialog/src/dialog.ts @@ -12,6 +12,7 @@ import { onMounted, onScopeDispose, onUnmounted, + onUpdated, provide, shallowRef, toValue, @@ -115,6 +116,23 @@ function whileActive(effects: () => void): void { onUnmounted(stop) } +// runs a restored view's activated hooks children first, yet a +// nested stack must reopen outermost first, as it first opened: each layer +// joins the stack over the one beneath and takes focus from it. Placements +// wait out the flush that re-enables the teleports, then run by depth. +const pendingPlacements: Array<{ depth: number; place: () => void }> = [] + +function placeByDepth(depth: number, place: () => void): void { + if (pendingPlacements.length === 0) { + void nextTick(() => { + const batch = pendingPlacements.splice(0) + batch.sort((a, b) => a.depth - b.depth) + for (const entry of batch) entry.place() + }) + } + pendingPlacements.push({ depth, place }) +} + // A template ref on a component holds its instance; the element is its `$el`. function toElement( target: HTMLElement | ComponentPublicInstance | null | undefined, @@ -385,6 +403,9 @@ export const Viewport = defineComponent( * dialog reads it. A component counts as its root element (`$el`). */ type FocusTarget = MaybeRefOrGetter +// What the window's DOM shows: `closing` is an animated dialog's exit window. +type WindowState = 'open' | 'closing' | 'closed' + export interface DialogContentProps extends HTMLAttributes { /** The element to focus when the dialog opens — resolved at open time, so a * template ref that fills after setup works. @default the dialog window */ @@ -400,33 +421,48 @@ export const Content = defineComponent( const { api, machine, depth, container, portalled, backdropRef } = useDialogContext() const contentRef = shallowRef(null) + // The state the window's DOM shows, committed by the hooks that follow its + // render. The machine can run ahead of the DOM: a change made in a + // post-flush job (the adapter syncs a controlled `open` there) reaches a + // `post` watcher before the render it queued. The sequences must see what + // the state rendered — a stylesheet may hide a closed window — as a React + // effect runs after commit. + let rendered: WindowState = 'closed' + const shown = shallowRef('closed') + // Whether the window is where it renders: back from a cache it // is still parked until the Portal re-enables its teleport, in the flush // that restores the view — so the sequences wait that flush out. const placed = shallowRef(true) const instance = getCurrentInstance() onMounted(() => { + shown.value = rendered placed.value = !inDeactivatedView(instance) }) + onUpdated(() => { + shown.value = rendered + }) onDeactivated(() => { placed.value = false }) onActivated(() => { - void nextTick(() => { + placeByDepth(depth, () => { placed.value = true }) }) - // The machine's state is the edge, not mount/unmount: an animated dialog + // The rendered state is the edge, not mount/unmount: an animated dialog // stays mounted through `closing`, its exit window, and a Content rendered - // with the root (no Portal) is mounted while closed too. The sequences - // and their inverses are the DOM package's; this watcher only ties them - // to Vue's lifecycle. Released by hand: Vue 3.6 also runs a watcher's - // onCleanup when its source merely re-evaluates — on every api change. + // with the root (no Portal) is mounted while closed too. A portalled + // window never renders `closed` — it unmounts, and the scope's end + // releases it once the DOM is gone. The sequences and their inverses are + // the DOM package's; this watcher only ties them to Vue's lifecycle. + // Released by hand: Vue 3.6 also runs a watcher's onCleanup when its + // source merely re-evaluates. whileActive(() => { let release: (() => void) | undefined watch( - [() => (api.value.open ? 'open' : api.value.mounted ? 'closing' : 'closed'), placed], + [shown, placed], ([state, isPlaced]) => { release?.() release = undefined @@ -452,10 +488,9 @@ export const Content = defineComponent( onComplete: () => machine.send({ type: 'exit.complete' }), }) }, - // `post`: the sequences must see the DOM the new state renders — a - // stylesheet may hide a closed window — as a React effect runs after - // commit. - { immediate: true, flush: 'post' }, + // `sync`: both sources change only in the hooks above, which already + // run once the DOM is in place. + { immediate: true, flush: 'sync' }, ) onScopeDispose(() => release?.()) }) @@ -477,12 +512,15 @@ export const Content = defineComponent( // A neutral element with the role, not : the window carries // tabindex (forbidden on ), and this contract doesn't use // showModal() — see SPEC.md. - return () => - h( + return () => { + const { open, mounted, parts } = api.value + rendered = open ? 'open' : mounted ? 'closing' : 'closed' + return h( 'div', - { ...mergeProps(attrs, normalize(api.value.parts.content)), ref: contentRef }, + { ...mergeProps(attrs, normalize(parts.content)), ref: contentRef }, slots.default?.(), ) + } }, { name: 'DialogContent', inheritAttrs: false, props: ['initialFocus', 'restoreFocus'] }, ) as DialogComponent diff --git a/packages/vue/dialog/tests/dialog.test.tsx b/packages/vue/dialog/tests/dialog.test.tsx index c3a59f6..02aee39 100644 --- a/packages/vue/dialog/tests/dialog.test.tsx +++ b/packages/vue/dialog/tests/dialog.test.tsx @@ -14,7 +14,7 @@ import { } from 'vue' import { renderToString } from 'vue/server-renderer' import { cleanup, fireEvent, render, screen } from '@testing-library/vue' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { Dialog, type DialogEmits, type DialogProps } from '@dunky.dev/vue-dialog' // The root's props plus its emits' listener props (`onUpdate:open`, ...). @@ -59,6 +59,19 @@ const afterFlushes = (): Promise => new Promise(resolve => setTimeout(reso const pressEscape = (): Promise => fireEvent.keyDown(document.body, { key: 'Escape' }) +// jsdom moves focus onto an inert element; a browser refuses. The tests on +// which layer's teardown un-inerts the page first need the browser's rule. +const refuseInertFocus = (): void => { + const focus = HTMLElement.prototype.focus + const spy = vi.spyOn(HTMLElement.prototype, 'focus').mockImplementation(function ( + this: HTMLElement, + options?: FocusOptions, + ) { + if (this.closest('[inert]') === null) focus.call(this, options) + }) + onTestFinished(() => spy.mockRestore()) +} + // Runtime-compiled templates resolve components by registered name, so the // dotted part names an SFC resolves from the `Dialog` import are registered // as such. @@ -1006,23 +1019,26 @@ describe('Dialog', () => { // A Content rendered with the root (no Portal) stays mounted while closed, // and a stylesheet may hide it by its state — the open sequence has to - // wait for the update that renders it open, as a React effect does. + // wait for the update that renders it open, as a React effect does. A + // controlled open is the strict case: the adapter syncs it in a post-flush + // job, ahead of that render. it('an always-mounted Content takes focus once the update that opens it has rendered', async () => { const style = document.createElement('style') style.textContent = '[data-state="closed"][role="dialog"] { display: none; }' document.head.append(style) + onTestFinished(() => style.remove()) + const open = ref(false) await renderSettled(() => ( - - Trigger + )) - await openDialog() + open.value = true + await nextTick() expect(document.activeElement).toBe(screen.getByLabelText('Name')) - style.remove() }) // The exit window is the `closing` state only: a Content rendered with @@ -1056,9 +1072,12 @@ describe('Dialog', () => { expect(screen.getByText('Trigger').closest('[inert]')).toBeNull() }) - // Cleanup after the DOM is gone, children first — as React's effects — - // or the parent would restore focus while the child still holds the page. + // Released after the DOM is gone, children first — as React's effects — + // or the parent would restore focus while the child still holds the page + // inert. A controlled close is the strict case: the adapter syncs it in a + // post-flush job, ahead of the render that unmounts the stack. it("closing a parent over an open child returns focus to the parent's trigger", async () => { + refuseInertFocus() const open = ref(false) await renderSettled(() => ( @@ -1173,6 +1192,10 @@ describe('Dialog', () => { const first = document.createElement('div') const second = document.createElement('div') document.body.append(first, second) + onTestFinished(() => { + first.remove() + second.remove() + }) const container = ref(first) await renderSettled(() => ( @@ -1191,8 +1214,6 @@ describe('Dialog', () => { expect(document.activeElement).toBe(dialog) // the open edge ran again expect(first.style.overflowY).not.toBe('hidden') expect(second.style.overflowY).toBe('hidden') - first.remove() - second.remove() }) // The adapter pauses a deactivated dialog's machine, as React's @@ -1235,6 +1256,45 @@ describe('Dialog', () => { expect(document.body.style.overflowY).toBe('hidden') }) + // restores a view's children first; the stack must come back + // as it first opened — outermost first, each layer taking focus from the + // one beneath — or the inner layer's close has nowhere to return focus. + it('a restored KeepAlive view reopens a nested stack outermost first', async () => { + refuseInertFocus() + const warn = vi.spyOn(console, 'warn') + onTestFinished(() => warn.mockRestore()) + const shown = ref(true) + const Page = defineComponent(() => () => ( + + + + + + + + + + + + + + + )) + const Elsewhere = defineComponent(() => () => ) + await renderSettled(() => {shown.value ? : }) + await afterFlushes() + + shown.value = false + await nextTick() + shown.value = true + await afterFlushes() + expect(document.activeElement).toBe(screen.getByLabelText('Inner field')) + expect(warn).not.toHaveBeenCalled() + + await pressEscape() + expect(document.activeElement).toBe(screen.getByLabelText('Outer field')) + }) + // Async data can mount a dialog into a cached view after the user left it. it('a dialog mounted into a deactivated KeepAlive view holds still until the view returns', async () => { const shown = ref(true) @@ -1291,17 +1351,19 @@ describe('Dialog', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const error = vi.spyOn(console, 'error').mockImplementation(() => {}) const app = createSSRApp(App) + onTestFinished(() => { + warn.mockRestore() + error.mockRestore() + app.unmount() + host.remove() + }) app.mount(host) await nextTick() expect(warn).not.toHaveBeenCalled() expect(error).not.toHaveBeenCalled() - warn.mockRestore() - error.mockRestore() // The portal arrives after hydration, under the id the server announced. expect(screen.getByRole('dialog').id).toBe(serverControls) - app.unmount() - host.remove() }) }) }) From c790c2065c7e864cb699e9399226ec7e7d7a42b1 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Tue, 6 Oct 2026 18:17:23 +0200 Subject: [PATCH 22/33] fix(vue): realm-safe element refs, a typed container, an inline exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Element refs are duck-typed (`nodeType`), not `instanceof HTMLElement`: an iframe document's elements failed that check, so the scroll lock, the focus trap, and the dialog's focus targets dropped them — a regression from unwrapping component refs. - Portal's `container` has a runtime type: a selector (Teleport's own idiom) placed the layer yet left the scroll lock nothing to resolve; Vue now warns. - Without a Portal, the exit window hides the layer from its Viewport down, as the portalled one does — a fixed full-screen Viewport otherwise stayed pressable over the page for the length of the exit. The hook suites gain a test per realm fix and tear down in shared hooks, so a failed test no longer leaves a lock or a listener behind; the trap's release-on-unmount check now presses Tab on the page, where a leaked listener would hear it. The SPECs record the container warning, the inline exit, `$el` not being reactive, and the previous commit's restored-stack order. Co-Authored-By: Claude Opus 5.5 --- packages/vue/dialog/SPEC.md | 45 +++++++++-------- packages/vue/dialog/src/context.ts | 3 ++ packages/vue/dialog/src/dialog.ts | 33 +++++++++---- packages/vue/dialog/tests/dialog.test.tsx | 13 +++-- packages/vue/hooks/use-focus-trap/SPEC.md | 16 ++++--- .../use-focus-trap/src/use-focus-trap.ts | 9 ++-- .../tests/use-focus-trap.test.tsx | 48 +++++++++++++------ packages/vue/hooks/use-scroll-lock/SPEC.md | 11 +++-- .../use-scroll-lock/src/use-scroll-lock.ts | 9 ++-- .../tests/use-scroll-lock.test.ts | 41 +++++++++------- 10 files changed, 143 insertions(+), 85 deletions(-) diff --git a/packages/vue/dialog/SPEC.md b/packages/vue/dialog/SPEC.md index 8fa453b..a975088 100644 --- a/packages/vue/dialog/SPEC.md +++ b/packages/vue/dialog/SPEC.md @@ -69,14 +69,14 @@ Vue-specific notes on top of the core contract: mounts. Swapping `container` while open re-creates the teleport on the new target rather than moving it, like the React and Solid portals — the open sequence runs again against the new placement. `container` takes an element, - not a selector — query it first. When scoped to a `container`, the scroll - lock applies to that container instead of the page, and the - backdrop/viewport must be positioned `absolute` (not `fixed`) so the overlay - pins to the container. Because an `absolute` overlay can't stay fixed inside - a scrolling element, a scoped container that needs a scrollable background - should be a non-scrolling positioned boundary wrapping an inner scroller — - portal into the boundary; the overlay fills its visible box and the backdrop - blocks the scroller behind it (see the `scoped` story). + not a selector — query it first; Vue warns when it gets a string. When + scoped to a `container`, the scroll lock applies to that container instead + of the page, and the backdrop/viewport must be positioned `absolute` (not + `fixed`) so the overlay pins to the container. Because an `absolute` overlay + can't stay fixed inside a scrolling element, a scoped container that needs a + scrollable background should be a non-scrolling positioned boundary wrapping + an inner scroller — portal into the boundary; the overlay fills its visible + box and the backdrop blocks the scroller behind it (see the `scoped` story). - **`Content`** renders a `
` carrying the `dialog` (or `alertdialog`) role, not the native `` element. The dialog window is the initial focus target — focusable in script, out of the tab order — which needs @@ -95,9 +95,12 @@ Vue-specific notes on top of the core contract: passes a getter: `:initial-focus="() => cancelButton"`. Script and TSX pass the ref itself. - **`Content` without a `Portal`** stays mounted while closed, so its DOM - work follows the dialog's state, not its mount: the scroll lock holds while - the dialog is open or closing, and the exit window hides the window - alone — it sits in the page, not in a container of its own. + work follows the dialog's state, not its mount — once the update that + renders the state has applied, as a React effect runs after commit, so a + stylesheet that hides a closed window is out of the way when focus moves + in. The scroll lock holds while the dialog is open or closing, and the exit + window hides the layer alone — its Viewport, or the window when it has + none — since it sits in the page, not in a container of its own. - **Element access**: every part renders exactly one root element, so a template ref on a part reaches it as `$el` (`contentPart.value.$el`). A non-modal Backdrop renders nothing, and the Portal renders no element of @@ -136,15 +139,17 @@ Vue-specific notes on top of the core contract: re-enter. A nested dialog unmounted along with the parent it was opened from does come back, and so does one whose page reloaded in between — the entry remembers the dialog's place in the stack, not the instance that planted it. -- **``**: a deactivated dialog's machine is paused by the - adapter, as React's `` pauses its effects, so its DOM work pauses - too: the Portal parks the layers back in place, inside the cached view and - off the document — Vue would otherwise leave teleported content painted - over the next view — and the history guard, focus trap, and scroll lock - release. Reactivation brings the same layers back, their content's state - intact, and runs the open sequence again. A dialog mounted into a view - that is already deactivated — async data resolving after the user left — - holds still the same way until the view returns. +- **``**: a deactivated dialog's machine is paused by the adapter, + as React's `` pauses its effects, so its DOM work pauses too: the + Portal parks the layers back in place, inside the cached view and off the + document — Vue would otherwise leave teleported content painted over the + next view — and the history guard, focus trap, and scroll lock release. + Reactivation brings the same layers back, their content's state intact, and + runs the open sequence again — a nested stack outermost first, as it first + opened, so each layer's close still returns focus to the one beneath. A + dialog mounted into a view that is already deactivated — async data + resolving after the user left — holds still the same way until the view + returns. - **Server rendering** touches no DOM: the root and its Trigger render, the document-level work starts on mount, and the base id comes from `useId`, so the hydrated parts carry the ids the server rendered. `useId` counts per diff --git a/packages/vue/dialog/src/context.ts b/packages/vue/dialog/src/context.ts index 4e08de3..04ab00c 100644 --- a/packages/vue/dialog/src/context.ts +++ b/packages/vue/dialog/src/context.ts @@ -20,6 +20,9 @@ export interface DialogContextValue { // parts: Content's stack entry excepts its own backdrop from the // containment so it stays pressable while its dialog is topmost. backdropRef: ShallowRef + // The rendered Viewport: without a Portal the layer sits in the page, and + // Content's exit window hides the layer from its Viewport down. + viewportRef: ShallowRef } export const DialogContextKey: InjectionKey = Symbol('DialogContext') diff --git a/packages/vue/dialog/src/dialog.ts b/packages/vue/dialog/src/dialog.ts index 418d9c7..80852b1 100644 --- a/packages/vue/dialog/src/dialog.ts +++ b/packages/vue/dialog/src/dialog.ts @@ -133,12 +133,15 @@ function placeByDepth(depth: number, place: () => void): void { pendingPlacements.push({ depth, place }) } -// A template ref on a component holds its instance; the element is its `$el`. +// 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 { - const element = target instanceof HTMLElement ? target : target?.$el - return element instanceof HTMLElement ? element : 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 } // ============================================================================= @@ -183,6 +186,7 @@ const DialogRoot = defineComponent( } const { api, machine } = useDialog(() => ({ ...props, ...callbacks })) const backdropRef = shallowRef(null) + const viewportRef = shallowRef(null) provide(DialogContextKey, { api, @@ -191,6 +195,7 @@ const DialogRoot = defineComponent( container: () => null, portalled: false, backdropRef, + viewportRef, }) // The guard lives on the root — it concerns the dialog's openness, not any @@ -334,7 +339,13 @@ export const Portal = defineComponent( ) } }, - { name: 'DialogPortal', inheritAttrs: false, props: ['container'] }, + { + name: 'DialogPortal', + inheritAttrs: false, + // Typed at runtime too: a selector — Teleport's own idiom — would place + // the layer yet leave the scroll lock nothing to resolve, so Vue warns. + props: { container: { type: Object as PropType } }, + }, ) as DialogComponent // ============================================================================= @@ -375,7 +386,7 @@ export interface DialogViewportProps extends HTMLAttributes {} export const Viewport = defineComponent( (_props: DialogViewportProps, { attrs, slots }) => { - const { api, machine } = useDialogContext() + const { api, machine, viewportRef } = useDialogContext() return () => { const { onClick, ...bindings } = normalize(api.value.parts.viewport) as { onClick?: (event: MouseEvent) => void @@ -388,7 +399,7 @@ export const Viewport = defineComponent( }, }) - return h('div', merged, slots.default?.()) + return h('div', { ...merged, ref: viewportRef }, slots.default?.()) } }, { name: 'DialogViewport', inheritAttrs: false }, @@ -418,7 +429,8 @@ export interface DialogContentProps extends HTMLAttributes { export const Content = defineComponent( (props: DialogContentProps, { attrs, slots }) => { - const { api, machine, depth, container, portalled, backdropRef } = useDialogContext() + const { api, machine, depth, container, portalled, backdropRef, viewportRef } = + useDialogContext() const contentRef = shallowRef(null) // The state the window's DOM shows, committed by the hooks that follow its @@ -481,9 +493,10 @@ export const Content = defineComponent( dismiss: () => machine.send({ type: 'close' }), }) : startExitWindow(content, { - // Without a Portal the window sits in the page itself: the - // exit hides the window, not its outermost ancestor. - container: portalled ? container() : content.parentElement, + // Without a Portal the layer sits in the page itself: the + // exit hides the layer — its Viewport, or the window bare — + // not the outermost ancestor. + container: portalled ? container() : (viewportRef.value ?? content).parentElement, backdrop: backdropRef.value, onComplete: () => machine.send({ type: 'exit.complete' }), }) diff --git a/packages/vue/dialog/tests/dialog.test.tsx b/packages/vue/dialog/tests/dialog.test.tsx index 02aee39..efdce2b 100644 --- a/packages/vue/dialog/tests/dialog.test.tsx +++ b/packages/vue/dialog/tests/dialog.test.tsx @@ -1057,17 +1057,20 @@ describe('Dialog', () => { expect(document.body.style.overflowY).not.toBe('hidden') }) - it('an animated Content rendered with the root hides only itself through its exit', async () => { + it('an animated layer rendered with the root hides only itself through its exit', async () => { const { container } = await renderSettled(() => ( Trigger - content + + content + )) await pressEscape() - const dialog = screen.getByRole('dialog', { hidden: true }) - expect(dialog.getAttribute('data-state')).toBe('closing') - expect(dialog.hasAttribute('inert')).toBe(true) + expect(screen.getByRole('dialog', { hidden: true }).getAttribute('data-state')).toBe( + 'closing', + ) + expect(screen.getByTestId('viewport').hasAttribute('inert')).toBe(true) expect(container.hasAttribute('inert')).toBe(false) expect(screen.getByText('Trigger').closest('[inert]')).toBeNull() }) diff --git a/packages/vue/hooks/use-focus-trap/SPEC.md b/packages/vue/hooks/use-focus-trap/SPEC.md index 644eb26..89bcec9 100644 --- a/packages/vue/hooks/use-focus-trap/SPEC.md +++ b/packages/vue/hooks/use-focus-trap/SPEC.md @@ -33,13 +33,15 @@ useFocusTrap(panel, { enabled: () => isTopmostLayer(props.id) }) 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`). 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. Mounted hooks never run during server rendering, so the trap - touches no DOM there. + 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. Mounted hooks never run during + server rendering, so the trap touches no DOM there. - 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. 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 index faa5966..5d92638 100644 --- a/packages/vue/hooks/use-focus-trap/src/use-focus-trap.ts +++ b/packages/vue/hooks/use-focus-trap/src/use-focus-trap.ts @@ -33,12 +33,15 @@ function inDeactivatedView(instance: InstanceNode | null): boolean { return false } -// A template ref on a component holds its instance; the element is its `$el`. +// 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 { - const element = target instanceof HTMLElement ? target : target?.$el - return element instanceof HTMLElement ? element : 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 } /** 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 index a3381b1..a41e097 100644 --- 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 @@ -3,7 +3,7 @@ // behavior itself is covered in the util's own tests. import { KeepAlive, defineComponent, nextTick, ref, type PropType } from 'vue' import { cleanup, render, screen } from '@testing-library/vue' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, onTestFinished } from 'vitest' import { useFocusTrap } from '@dunky.dev/vue-use-focus-trap' const Trap = defineComponent({ @@ -28,6 +28,16 @@ const tab = (target: Element): boolean => 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) @@ -39,12 +49,9 @@ describe('useFocusTrap', () => { expect(tab(screen.getByTestId('container'))).toBe(false) expect(document.activeElement).toBe(screen.getByText('first')) - const container = screen.getByTestId('container') - screen.getByText('last').focus() + const outside = outsideButton() unmount() - // The listener is gone with the unmount — a Tab on the detached container - // is no longer intercepted. - expect(tab(container)).toBe(true) + expect(tab(outside)).toBe(true) }) it('forwards enabled() to the trap without re-binding', () => { @@ -60,10 +67,7 @@ describe('useFocusTrap', () => { const shown = ref(true) render(() => {shown.value ? : null}) const container = screen.getByTestId('container') - // The cached container is off the document; a Tab there would reach no - // listener either way — press it on the page instead. - const outside = document.createElement('button') - document.body.append(outside) + const outside = outsideButton() shown.value = false await nextTick() @@ -72,7 +76,6 @@ describe('useFocusTrap', () => { shown.value = true await nextTick() expect(tab(container)).toBe(false) - outside.remove() }) it('stays unarmed when mounted into a deactivated KeepAlive view, and arms once it returns', async () => { @@ -85,14 +88,11 @@ describe('useFocusTrap', () => { loaded.value = true // mounts the trap into the cached view await nextTick() - const outside = document.createElement('button') - document.body.append(outside) - expect(tab(outside)).toBe(true) + expect(tab(outsideButton())).toBe(true) shown.value = true await nextTick() expect(tab(screen.getByTestId('container'))).toBe(false) - outside.remove() }) it("takes a component's ref as its root element", () => { @@ -117,4 +117,22 @@ describe('useFocusTrap', () => { 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/SPEC.md b/packages/vue/hooks/use-scroll-lock/SPEC.md index 2a86903..a6c8dea 100644 --- a/packages/vue/hooks/use-scroll-lock/SPEC.md +++ b/packages/vue/hooks/use-scroll-lock/SPEC.md @@ -30,11 +30,12 @@ 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. 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. + effect's cleanup; mounted into a view that is already deactivated, it waits + for the view's return. 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 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 index ebaf506..767d3a6 100644 --- a/packages/vue/hooks/use-scroll-lock/src/use-scroll-lock.ts +++ b/packages/vue/hooks/use-scroll-lock/src/use-scroll-lock.ts @@ -30,12 +30,15 @@ function inDeactivatedView(instance: InstanceNode | null): boolean { return false } -// A template ref on a component holds its instance; the element is its `$el`. +// 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 { - const element = target instanceof HTMLElement ? target : target?.$el - return element instanceof HTMLElement ? element : 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 } /** 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 index 23fe9d6..ef391f2 100644 --- 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 @@ -2,8 +2,8 @@ // The Vue lifecycle around @dunky.dev/dom-scroll-lock — the refcount/restore // behavior itself is covered in the util's own tests. import { KeepAlive, defineComponent, h, nextTick, ref } from 'vue' -import { render } from '@testing-library/vue' -import { describe, expect, it } from 'vitest' +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. @@ -18,6 +18,10 @@ const Locker = (...args: Parameters) => 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() @@ -28,22 +32,20 @@ describe('useScrollLock', () => { }) it('does not lock when locked=false', () => { - const { unmount } = mountLock(false) + mountLock(false) expect(document.body.style.overflowY).toBe('') - unmount() }) it('locks nothing when target is null — a target not yet resolved', () => { - const { unmount } = mountLock(true, () => null) + mountLock(true, () => null) expect(document.body.style.overflowY).toBe('') - unmount() }) // `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() - const { unmount } = render( + render( defineComponent({ setup() { useScrollLock(true, target) @@ -57,24 +59,22 @@ describe('useScrollLock', () => { await nextTick() expect(target.value?.style.overflowY).toBe('hidden') expect(document.body.style.overflowY).toBe('') - unmount() }) it('follows a reactive locked flag, releasing when it turns off', async () => { const locked = ref(true) - const { unmount } = mountLock(locked) + mountLock(locked) expect(document.body.style.overflowY).toBe('hidden') locked.value = false await nextTick() expect(document.body.style.overflowY).toBe('') - unmount() }) it('releases while a KeepAlive deactivation holds the component, and locks again on reactivation', async () => { const shown = ref(true) const Lock = Locker() - const { unmount } = render(() => h(KeepAlive, null, () => (shown.value ? h(Lock) : null))) + render(() => h(KeepAlive, null, () => (shown.value ? h(Lock) : null))) expect(document.body.style.overflowY).toBe('hidden') shown.value = false @@ -84,7 +84,6 @@ describe('useScrollLock', () => { shown.value = true await nextTick() expect(document.body.style.overflowY).toBe('hidden') - unmount() }) it('holds nothing when mounted into a deactivated KeepAlive view, and locks once it returns', async () => { @@ -92,7 +91,7 @@ describe('useScrollLock', () => { const loaded = ref(false) const Lock = Locker() const Page = defineComponent(() => () => (loaded.value ? h(Lock) : null)) - const { unmount } = render(() => h(KeepAlive, null, () => (shown.value ? h(Page) : null))) + render(() => h(KeepAlive, null, () => (shown.value ? h(Page) : null))) shown.value = false await nextTick() @@ -103,13 +102,12 @@ describe('useScrollLock', () => { shown.value = true await nextTick() expect(document.body.style.overflowY).toBe('hidden') - unmount() }) it("takes a component's ref as its root element", () => { const Surface = defineComponent(() => () => h('section')) const surface = ref | null>(null) - const { unmount } = render( + render( defineComponent({ setup() { useScrollLock(true, surface) @@ -117,8 +115,17 @@ describe('useScrollLock', () => { }, }), ) - expect((surface.value?.$el as HTMLElement).style.overflowY).toBe('hidden') + expect((surface.value?.$el as HTMLElement | undefined)?.style.overflowY).toBe('hidden') expect(document.body.style.overflowY).toBe('') - unmount() + }) + + // 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') }) }) From b25c2a60eb0cbcf3b4f2d1403f146b7501824ae0 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Tue, 6 Oct 2026 18:17:44 +0200 Subject: [PATCH 23/33] perf(vue): build the dialog options once per change The adapter reads the options from each effect's dependency getter, and a getter rebuilt the spread object on every read: 7 builds at mount, 3 for a prop no effect reads, 4 for an `open` change. A computed builds it once per change (1, 1, 1). The scaffold template's hook does the same. Co-Authored-By: Claude Opus 5.5 --- packages/vue/dialog/src/use-dialog.ts | 17 ++++++++++++----- .../packages/vue/__name__/src/use-__name__.ts | 11 +++++++++-- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/packages/vue/dialog/src/use-dialog.ts b/packages/vue/dialog/src/use-dialog.ts index 27c5a3a..63dcf02 100644 --- a/packages/vue/dialog/src/use-dialog.ts +++ b/packages/vue/dialog/src/use-dialog.ts @@ -1,4 +1,4 @@ -import { toValue, useId, type ComputedRef, type MaybeRefOrGetter } from 'vue' +import { computed, toValue, useId, type ComputedRef, type MaybeRefOrGetter } from 'vue' import { useMachine } from '@dunky.dev/vue-state-machine' import { dialogMachine, dialogConnect } from '@dunky.dev/dialog' import type { DialogApi, DialogMachine, DialogOptions } from '@dunky.dev/dialog' @@ -11,8 +11,15 @@ export function useDialog(options: MaybeRefOrGetter): { const id = useId() // `?? id` (not spread order): an explicit `id: undefined` must not knock out // the generated fallback — ids also key the dialog stack, so they must exist. - return useMachine(dialogMachine, dialogConnect, domDialogEffects, () => { - const resolved = toValue(options) - return { ...resolved, id: resolved.id ?? id } - }) + // 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( + dialogMachine, + dialogConnect, + domDialogEffects, + computed(() => { + const resolved = toValue(options) + return { ...resolved, id: resolved.id ?? id } + }), + ) } diff --git a/scripts/templates/packages/vue/__name__/src/use-__name__.ts b/scripts/templates/packages/vue/__name__/src/use-__name__.ts index 5edf837..8759ebc 100644 --- a/scripts/templates/packages/vue/__name__/src/use-__name__.ts +++ b/scripts/templates/packages/vue/__name__/src/use-__name__.ts @@ -1,4 +1,4 @@ -import type { MaybeRefOrGetter } from 'vue' +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__' @@ -11,5 +11,12 @@ import { __camelName__Effects } from './effects' * re-synced through the reactive getter, effects run after mount, api a ref. */ export function use__Name__(options: MaybeRefOrGetter<__Name__Options>): __Name__ContextValue { - return useMachine(__camelName__Machine, __camelName__Connect, __camelName__Effects, options) + // 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)), + ) } From aa4264701503862d1e4819e2e37805b54de04cf0 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Tue, 6 Oct 2026 18:17:53 +0200 Subject: [PATCH 24/33] test(vue): survive a failed test or a shuffle; derive the part registries - The suite's teardown waits for history to settle back: unmounting a guarded dialog spends its entry through an asynchronous traversal, which a failed test left to land in the next one (a forced failure in three back-navigation tests cascaded into two more; now none). Each test also starts on a plain entry, since a guard entry an earlier test left current read as the next test's own: 'closes on the browser Back instead of navigating' failed under `--sequence.shuffle` (seed 4), as it still does in the React and Solid suites; twelve seeds now pass. The tests that settled their own traversals no longer need to. Spies, styles, and containers a test installs are undone in onTestFinished. - The dotted-name registries for runtime templates (tests, stories, the scaffold template's story) are derived from the parts: a part missing from a hand-written list only warned and rendered an unknown element. - 'renders role=alertdialog when requested' is dropped: 'alertdialog does not dismiss on backdrop press by default' renders and queries the same role, so it never failed alone. React and Solid carry the same pair. Co-Authored-By: Claude Opus 5.5 --- packages/vue/dialog/stories/dialog.stories.ts | 16 ++---- packages/vue/dialog/tests/dialog.test.tsx | 52 +++++++------------ .../vue/__name__/stories/__name__.stories.ts | 8 ++- 3 files changed, 30 insertions(+), 46 deletions(-) diff --git a/packages/vue/dialog/stories/dialog.stories.ts b/packages/vue/dialog/stories/dialog.stories.ts index b5bc779..298755e 100644 --- a/packages/vue/dialog/stories/dialog.stories.ts +++ b/packages/vue/dialog/stories/dialog.stories.ts @@ -12,17 +12,11 @@ type StoryType = StoryObj // The stories are runtime-compiled templates, which resolve components by // registered name — so the dotted part names an SFC resolves from the -// `Dialog` import are registered as such. -const dialogComponents: Record = { - Dialog, - 'Dialog.Trigger': Dialog.Trigger, - 'Dialog.Portal': Dialog.Portal, - 'Dialog.Backdrop': Dialog.Backdrop, - 'Dialog.Viewport': Dialog.Viewport, - 'Dialog.Content': Dialog.Content, - 'Dialog.Title': Dialog.Title, - 'Dialog.Description': Dialog.Description, - 'Dialog.Close': Dialog.Close, +// `Dialog` import are registered as such, derived from the parts themselves +// so none can be missed. +const dialogComponents: Record = { Dialog } +for (const [name, part] of Object.entries(Dialog)) { + if (/^[A-Z]/.test(name)) dialogComponents[`Dialog.${name}`] = part as Component } // The primitive ships headless — the story is the consumer, so it brings the diff --git a/packages/vue/dialog/tests/dialog.test.tsx b/packages/vue/dialog/tests/dialog.test.tsx index efdce2b..dd2759e 100644 --- a/packages/vue/dialog/tests/dialog.test.tsx +++ b/packages/vue/dialog/tests/dialog.test.tsx @@ -14,7 +14,7 @@ import { } from 'vue' import { renderToString } from 'vue/server-renderer' import { cleanup, fireEvent, render, screen } from '@testing-library/vue' -import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { Dialog, type DialogEmits, type DialogProps } from '@dunky.dev/vue-dialog' // The root's props plus its emits' listener props (`onUpdate:open`, ...). @@ -74,21 +74,25 @@ const refuseInertFocus = (): void => { // Runtime-compiled templates resolve components by registered name, so the // dotted part names an SFC resolves from the `Dialog` import are registered -// as such. -const dialogComponents: Record = { - Dialog, - 'Dialog.Trigger': Dialog.Trigger, - 'Dialog.Portal': Dialog.Portal, - 'Dialog.Backdrop': Dialog.Backdrop, - 'Dialog.Viewport': Dialog.Viewport, - 'Dialog.Content': Dialog.Content, - 'Dialog.Title': Dialog.Title, - 'Dialog.Description': Dialog.Description, - 'Dialog.Close': Dialog.Close, +// as such — derived from the parts themselves, so none can be missed. +const dialogComponents: Record = { Dialog } +for (const [name, part] of Object.entries(Dialog)) { + if (/^[A-Z]/.test(name)) dialogComponents[`Dialog.${name}`] = part as Component } // Auto-cleanup needs vitest globals; this repo runs with globals: false. -afterEach(cleanup) +// Unmounting a guarded dialog spends its history entry through a traversal, +// asynchronous in jsdom, that would land in the next test — a failed test +// leaves entries armed — so the teardown waits for history to settle back. +// And each test starts on a plain entry: a guard entry an earlier test left +// current would read as this test's own. +beforeEach(() => { + if (window.history.state !== null) window.history.replaceState(null, '') +}) +afterEach(async () => { + cleanup() + for (let task = 0; task < 10 && window.history.state !== null; task++) await afterFlushes() +}) describe('Dialog', () => { describe('open / close', () => { @@ -355,11 +359,6 @@ describe('Dialog', () => { expect(dialog.hasAttribute('aria-describedby')).toBe(false) }) - it('renders role=alertdialog when requested', async () => { - await renderSettled(() => ) - expect(screen.queryByRole('alertdialog')).not.toBeNull() - }) - it('omits aria-modal when modal=false', async () => { await renderSettled(() => ) expect(screen.getByRole('dialog').hasAttribute('aria-modal')).toBe(false) @@ -408,10 +407,10 @@ describe('Dialog', () => { // mechanism that prevents it: focus never scrolls the locked surface. it('moves focus without scrolling the locked surface', async () => { const focusSpy = vi.spyOn(HTMLElement.prototype, 'focus') + onTestFinished(() => focusSpy.mockRestore()) await renderSettled(() => ) expect(focusSpy).toHaveBeenCalledWith({ preventScroll: true }) - focusSpy.mockRestore() }) it('moves focus to the first form field when the dialog contains one', async () => { @@ -521,6 +520,7 @@ describe('Dialog', () => { it('locks the portal container, not the body, when scoped', async () => { const panel = document.createElement('div') document.body.append(panel) + onTestFinished(() => panel.remove()) await renderSettled(() => ( @@ -535,7 +535,6 @@ describe('Dialog', () => { await pressEscape() expect(panel.style.overflowY).not.toBe('hidden') - panel.remove() }) }) @@ -619,12 +618,6 @@ describe('Dialog', () => { await traverse(() => window.history.back()) await traverse(() => window.history.forward()) expect(screen.queryByRole('dialog')).toBeNull() - - // The decline left the still-watched entry current; unmounting consumes - // it — settle that traversal here, not in the next test. - const consume = nextPop() - cleanup() - await consume }) // The nested round-trip: closing the outer takes the inner's whole @@ -667,13 +660,6 @@ describe('Dialog', () => { expect(layers()).toBe('O-') await traverse(() => window.history.forward()) expect(layers()).toBe('OI') - - // Both layers are armed again; unmounting frees their entries one - // traversal at a time — settle both pops here, not in the next test. - const consume = nextPop() - cleanup() - await consume - await nextPop() }) // Both layers guarded and closed in one update — a "close all" affordance, diff --git a/scripts/templates/packages/vue/__name__/stories/__name__.stories.ts b/scripts/templates/packages/vue/__name__/stories/__name__.stories.ts index 9703bc2..9aefb64 100644 --- a/scripts/templates/packages/vue/__name__/stories/__name__.stories.ts +++ b/scripts/templates/packages/vue/__name__/stories/__name__.stories.ts @@ -1,3 +1,4 @@ +import type { Component } from 'vue' import type { Meta, StoryObj } from '@storybook/vue3-vite' import { __Name__ } from '@dunky.dev/vue-__name__' @@ -11,8 +12,11 @@ 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. -const components = { __Name__, '__Name__.Root': __Name__.Root } +// 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. From ffad99b2fc43807008f1e40f4196d1492f16349d Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Wed, 7 Oct 2026 11:24:08 +0200 Subject: [PATCH 25/33] feat(dom-dialog): bound an in-place layer's exit window to the layer `startExitWindow` hides the still-painting layer by walking up to the content's outermost ancestor below the portal container. For a dialog rendered without a portal, that ancestor is a branch of the page itself, so the walk took the page around the layer out of interaction for the length of the exit. `inPlace` (with the layer's `viewport`) bounds the hiding to the layer: from its viewport down, or the content alone. It defaults off, so portalled layers and today's callers are unchanged. The decision lived in the Vue binding, which derived the boundary from its Viewport's parent; it belongs here, written once. Co-Authored-By: Claude Opus 5.5 --- .changeset/dom-dialog-exit-in-place.md | 18 +++++++++++ packages/dom/components/dialog/SPEC.md | 6 ++++ .../dom/components/dialog/src/exit-window.ts | 21 +++++++++---- .../components/dialog/tests/dialog.test.ts | 30 +++++++++++++++++++ 4 files changed, 70 insertions(+), 5 deletions(-) create mode 100644 .changeset/dom-dialog-exit-in-place.md 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/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', () => { From d901d863a91b68c5b7b41e8626aa0263c7ca8d0f Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Wed, 7 Oct 2026 11:25:35 +0200 Subject: [PATCH 26/33] fix(vue): run every dialog's close before any open in an update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vue settles each component's hooks on its own, in component order. A step that closed one dialog and opened another in the same update — a wizard's Next, a sibling hand-over — ran them in declaration order: the opener could take focus first and the closer's restore take it back, or the opener record the closing window as its focus home and send focus to on close. Two animated layers starting their exits together lost one layer's `inert`: a release recomputed the containment around the other, that exit skipped the already-hidden root, and the next release stripped it — a fading confirm dialog still took clicks. The Content's sequences now run as React's commit runs them: once the update has rendered, in one shared pass — every release before any start, releases innermost first, exit windows before open sequences, opens outermost first. The pass takes over the depth-ordered KeepAlive placement, and an unmount still releases at once, after the DOM is gone and children first. The exit window passes `inPlace` and the Viewport to dom-dialog instead of deriving the boundary itself. Tests: a matrix of sibling hand-overs (declaration order, animated, and modal combinations; focus and inertness checked after every step, with browsers' inert focus rule) and two stacked exits (a stack-scoped Escape, a controlled stack closed at once). All 18 fail on the previous Content. Co-Authored-By: Claude Opus 5.5 --- packages/vue/dialog/SPEC.md | 8 + packages/vue/dialog/src/dialog.ts | 226 ++++++++++++++-------- packages/vue/dialog/tests/dialog.test.tsx | 161 ++++++++++++++- 3 files changed, 313 insertions(+), 82 deletions(-) diff --git a/packages/vue/dialog/SPEC.md b/packages/vue/dialog/SPEC.md index a975088..8e8feb8 100644 --- a/packages/vue/dialog/SPEC.md +++ b/packages/vue/dialog/SPEC.md @@ -139,6 +139,14 @@ Vue-specific notes on top of the core contract: re-enter. A nested dialog unmounted along with the parent it was opened from does come back, and so does one whose page reloaded in between — the entry remembers the dialog's place in the stack, not the instance that planted it. +- **Dialogs that change together** — a wizard's Next closing one dialog and + opening the next in the same update, or a stack closing at once — run their + DOM sequences once the update has rendered, every close before any open, as + React's commit does: the dialog opened keeps the focus it took, the last + close returns focus to the trigger the hand-over started from, and an + exiting layer stays inert while others open or close around it, however the + dialogs are declared. Nested layers release innermost first and open + outermost first. - **``**: a deactivated dialog's machine is paused by the adapter, as React's `` pauses its effects, so its DOM work pauses too: the Portal parks the layers back in place, inside the cached view and off the diff --git a/packages/vue/dialog/src/dialog.ts b/packages/vue/dialog/src/dialog.ts index 80852b1..729d70c 100644 --- a/packages/vue/dialog/src/dialog.ts +++ b/packages/vue/dialog/src/dialog.ts @@ -6,7 +6,6 @@ import { getCurrentInstance, h, inject, - nextTick, onActivated, onDeactivated, onMounted, @@ -89,17 +88,16 @@ function inDeactivatedView(instance: InstanceNode | null): boolean { // Runs `effects` in a scope that lives while the component is mounted and its // view active. A deactivation ends it like an unmount — the // adapter pauses the machine the same way, as React's runs its -// effect cleanups. Released after the DOM is gone and children first, as -// React runs cleanups after commit: a parent closing over an open child must -// not restore focus into a layer that is still registered above it. Mounted -// hooks never run during server rendering, so neither does this. +// effect cleanups. Mounted hooks never run during server rendering, so +// neither does this. function whileActive(effects: () => void): void { const instance = getCurrentInstance() let scope: EffectScope | undefined const start = (): void => { if (scope !== undefined) return - // Detached: the component scope stops before the DOM is removed, parent - // first — the release order above needs the unmounted hook instead. + // Detached, and ended in the unmounted hook — after the DOM is gone and + // children first, as React runs a removed subtree's cleanups — rather + // than with the component scope, which stops parent first, before. scope = effectScope(true) scope.run(effects) } @@ -116,21 +114,65 @@ function whileActive(effects: () => void): void { onUnmounted(stop) } -// runs a restored view's activated hooks children first, yet a -// nested stack must reopen outermost first, as it first opened: each layer -// joins the stack over the one beneath and takes focus from it. Placements -// wait out the flush that re-enables the teleports, then run by depth. -const pendingPlacements: Array<{ depth: number; place: () => void }> = [] - -function placeByDepth(depth: number, place: () => void): void { - if (pendingPlacements.length === 0) { - void nextTick(() => { - const batch = pendingPlacements.splice(0) - batch.sort((a, b) => a.depth - b.depth) - for (const entry of batch) entry.place() - }) +// What the window's DOM shows: `closing` is an animated dialog's exit window. +type WindowState = 'open' | 'closing' | 'closed' + +// One Content's DOM work for an update: undo the sequence it holds, then start +// the one its DOM shows now. +interface LayerCommit { + depth: number + // Whether `start` runs an open sequence, rather than an exit window. + opening: () => boolean + release: () => void + start: () => void +} + +// Vue settles each component's hooks on its own, in component order, so a +// dialog opening as another closes in the same update — a wizard's Next — +// would run in declaration order, and the closer's restore take focus back +// from the opener. The sequences run as React's commit runs them instead, +// once the whole update has rendered: every release before any start. +// Releases go innermost first, each layer's close restoring focus while the +// one beneath still holds the stack. Exit windows start before open +// sequences, so a new layer's containment can't take over an exiting layer's +// hiding and strip it when that layer closes. Opens go outermost first, each +// layer taking focus from the one beneath — as a nested stack first opens, +// and as a restored view, whose hooks run children first, must +// reopen. +const pendingCommits = new Set() + +function commitAfterUpdate(commit: LayerCommit): void { + // Queued during Vue's flush, a microtask runs once the flush is done: + // before anything awaiting `nextTick()`, and before the browser paints. + if (pendingCommits.size === 0) queueMicrotask(flushCommits) + pendingCommits.add(commit) +} + +function flushCommits(): void { + const batch = [...pendingCommits] + pendingCommits.clear() + // Every step runs even when one throws; the failures surface after. + const failures: unknown[] = [] + const run = (step: () => void): void => { + try { + step() + } catch (error) { + failures.push(error) + } + } + batch.sort((a, b) => b.depth - a.depth) + for (const commit of batch) run(commit.release) + batch.sort((a, b) => a.depth - b.depth) + for (const commit of batch) { + if (!commit.opening()) run(commit.start) + } + for (const commit of batch) { + if (commit.opening()) run(commit.start) } - pendingPlacements.push({ depth, place }) + if (failures.length > 1) { + throw new AggregateError(failures, 'Dialog: several layer sequences threw') + } + if (failures.length === 1) throw failures[0] } // A template ref on a component holds its instance; the element is its `$el` @@ -414,9 +456,6 @@ export const Viewport = defineComponent( * dialog reads it. A component counts as its root element (`$el`). */ type FocusTarget = MaybeRefOrGetter -// What the window's DOM shows: `closing` is an animated dialog's exit window. -type WindowState = 'open' | 'closing' | 'closed' - export interface DialogContentProps extends HTMLAttributes { /** The element to focus when the dialog opens — resolved at open time, so a * template ref that fills after setup works. @default the dialog window */ @@ -433,56 +472,58 @@ export const Content = defineComponent( useDialogContext() const contentRef = shallowRef(null) - // The state the window's DOM shows, committed by the hooks that follow its - // render. The machine can run ahead of the DOM: a change made in a - // post-flush job (the adapter syncs a controlled `open` there) reaches a - // `post` watcher before the render it queued. The sequences must see what - // the state rendered — a stylesheet may hide a closed window — as a React - // effect runs after commit. + // The state the window's DOM shows, taken by the hooks that follow its + // render: the machine can run ahead of the DOM — the adapter syncs a + // controlled `open` in a post-flush job, before the render it causes — + // and the sequences must see what the state rendered (a stylesheet may + // hide a closed window), as a React effect runs after commit. let rendered: WindowState = 'closed' - const shown = shallowRef('closed') + let shown: WindowState = 'closed' + // Whether the view is live: a deactivation parks the layer. + let active = false + // The sequence the window holds, and what it stands for. + let applied: WindowState = 'closed' + let release: (() => void) | undefined + + // The shown state is the edge, not mount/unmount: an animated dialog stays + // mounted through `closing`, its exit window, and a Content rendered with + // the root (no Portal) is mounted while closed too. + const target = (): WindowState => (active && contentRef.value !== null ? shown : 'closed') + + const releaseApplied = (): void => { + const previous = release + release = undefined + applied = 'closed' + previous?.() + } - // Whether the window is where it renders: back from a cache it - // is still parked until the Portal re-enables its teleport, in the flush - // that restores the view — so the sequences wait that flush out. - const placed = shallowRef(true) - const instance = getCurrentInstance() - onMounted(() => { - shown.value = rendered - placed.value = !inDeactivatedView(instance) - }) - onUpdated(() => { - shown.value = rendered - }) - onDeactivated(() => { - placed.value = false - }) - onActivated(() => { - placeByDepth(depth, () => { - placed.value = true - }) - }) + // The commit's steps run through a watcher, so an error in a sequence + // reaches the app's errorHandler like any other component error. + let step: (() => void) | undefined + const stepRequest = shallowRef(0) + watch(stepRequest, () => step?.(), { flush: 'sync' }) + const runStep = (next: () => void): void => { + step = next + stepRequest.value++ + } - // The rendered state is the edge, not mount/unmount: an animated dialog - // stays mounted through `closing`, its exit window, and a Content rendered - // with the root (no Portal) is mounted while closed too. A portalled - // window never renders `closed` — it unmounts, and the scope's end - // releases it once the DOM is gone. The sequences and their inverses are - // the DOM package's; this watcher only ties them to Vue's lifecycle. - // Released by hand: Vue 3.6 also runs a watcher's onCleanup when its - // source merely re-evaluates. - whileActive(() => { - let release: (() => void) | undefined - watch( - [shown, placed], - ([state, isPlaced]) => { - release?.() - release = undefined + // The sequences and their inverses are the DOM package's; the commit only + // ties them to Vue's lifecycle. + const commit: LayerCommit = { + depth, + opening: () => target() === 'open', + release: () => + runStep(() => { + if (target() !== applied) releaseApplied() + }), + start: () => + runStep(() => { + const next = target() const content = contentRef.value - if (content === null || !isPlaced || state === 'closed') return - + if (next === applied || content === null) return + applied = next release = - state === 'open' + next === 'open' ? openDialogLayer(content, { id: machine.context.id, depth, @@ -493,19 +534,46 @@ export const Content = defineComponent( dismiss: () => machine.send({ type: 'close' }), }) : startExitWindow(content, { - // Without a Portal the layer sits in the page itself: the - // exit hides the layer — its Viewport, or the window bare — - // not the outermost ancestor. - container: portalled ? container() : (viewportRef.value ?? content).parentElement, + container: container(), backdrop: backdropRef.value, + inPlace: !portalled, + viewport: viewportRef.value, onComplete: () => machine.send({ type: 'exit.complete' }), }) - }, - // `sync`: both sources change only in the hooks above, which already - // run once the DOM is in place. - { immediate: true, flush: 'sync' }, - ) - onScopeDispose(() => release?.()) + }), + } + + const instance = getCurrentInstance() + const schedule = (): void => { + if (target() !== applied) commitAfterUpdate(commit) + } + onMounted(() => { + shown = rendered + active = !inDeactivatedView(instance) + schedule() + }) + onUpdated(() => { + shown = rendered + schedule() + }) + // Back from a cache, the window is parked until the Portal + // re-enables its teleport, in the flush that restores the view — which + // the commit waits out. + onActivated(() => { + active = true + schedule() + }) + onDeactivated(() => { + active = false + schedule() + }) + // A portalled window never shows `closed` — it unmounts, released at once + // after the DOM is gone and children first, as React runs a removed + // subtree's cleanups: a parent closing over an open child must not + // restore focus into a layer still registered above it. + onUnmounted(() => { + pendingCommits.delete(commit) + releaseApplied() }) // The lock spans the dialog's occupancy — through `closing` too: diff --git a/packages/vue/dialog/tests/dialog.test.tsx b/packages/vue/dialog/tests/dialog.test.tsx index dd2759e..b127616 100644 --- a/packages/vue/dialog/tests/dialog.test.tsx +++ b/packages/vue/dialog/tests/dialog.test.tsx @@ -72,6 +72,9 @@ const refuseInertFocus = (): void => { onTestFinished(() => spy.mockRestore()) } +// The view a switches to and back from. +const Elsewhere = defineComponent(() => () => ) + // Runtime-compiled templates resolve components by registered name, so the // dotted part names an SFC resolves from the `Dialog` import are registered // as such — derived from the parts themselves, so none can be missed. @@ -1093,6 +1096,161 @@ describe('Dialog', () => { expect(document.activeElement).toBe(trigger) }) + // A step that closes one dialog and opens a sibling — a wizard's Next — + // runs every close before any open, as React's commit does: the opened + // dialog keeps the focus it took, an exiting one stays out of reach, and + // the last close returns focus to the trigger the hand-over started from, + // however the two are declared, animated, or modal. + describe('a hand-over between sibling dialogs', () => { + const cases = [false, true].flatMap(nextFirst => + [false, true].flatMap(firstAnimated => + [false, true].flatMap(nextAnimated => + [true, false].map(nextModal => ({ + name: [ + nextFirst ? 'the next step declared first' : 'declared in step order', + firstAnimated ? 'an animated first step' : 'an instant first step', + nextAnimated ? 'an animated next step' : 'an instant next step', + nextModal ? 'modal' : 'non-modal', + ].join(', '), + nextFirst, + firstAnimated, + nextAnimated, + nextModal, + })), + ), + ), + ) + + const expectExitsInert = (): void => { + for (const exiting of document.querySelectorAll('[data-state="closing"][role="dialog"]')) { + expect(exiting.closest('[inert]')).not.toBeNull() + } + } + + const expectHandedTo = (name: string): void => { + expect(screen.getByRole('dialog', { name }).contains(document.activeElement)).toBe(true) + expectExitsInert() + } + + it.each(cases)('$name', async ({ nextFirst, firstAnimated, nextAnimated, nextModal }) => { + refuseInertFocus() + const first = ref(false) + const next = ref(false) + await renderSettled(() => { + const steps = [ + (first.value = false)} + > + (first.value = true)}>Start + + + + + + , + (next.value = false)} + > + + + + + + , + ] + return
{nextFirst ? steps.reverse() : steps}
+ }) + const trigger = screen.getByText('Start') + trigger.focus() + await press(trigger) + expectHandedTo('First') + + await press(screen.getByText('Next')) + expectHandedTo('Next') + await press(screen.getByText('Back')) // reopens an animated first step mid-exit + expectHandedTo('First') + await press(screen.getByText('Next')) + expectHandedTo('Next') + + await pressEscape() // closes the next step inside the first step's exit + expect(document.activeElement).toBe(trigger) + expectExitsInert() + }) + }) + + // Two exits starting in one update — a stack-scoped Escape, or a stack + // closed at once — each hide their own layer once every release has run; + // run in turn, a release's containment undo strips the other's hiding. + const ExitingStack = (props: { outer?: boolean; inner?: boolean }) => ( + + Trigger + + + + + Inner trigger + + + + inner + + + + + + + + ) + + it('a stack-scoped Escape keeps every exiting layer inert', async () => { + await renderSettled(() => ) + await openDialog() + await press(screen.getByText('Inner trigger')) + + await pressEscape() + expect(screen.getByTestId('viewport').hasAttribute('inert')).toBe(true) + expect(screen.getByTestId('inner-viewport').hasAttribute('inert')).toBe(true) + expect(screen.getByTestId('inner-backdrop').hasAttribute('inert')).toBe(true) + }) + + // The controlled close is the strict case: the adapter syncs both in + // post-flush jobs, ahead of the renders they cause. + it('closing two controlled animated layers in one update keeps both inert', async () => { + const outer = ref(true) + const inner = ref(true) + await renderSettled(() => ) + await afterFlushes() // the inner layer mounts with the outer's teleport, an update later + + outer.value = false + inner.value = false + await nextTick() + expect(screen.getByTestId('viewport').hasAttribute('inert')).toBe(true) + expect(screen.getByTestId('inner-viewport').hasAttribute('inert')).toBe(true) + }) + // A template ref on a component holds its instance, not an element. it("initialFocus and restoreFocus take a component's ref as its element", async () => { const Action = defineComponent(() => () => ) @@ -1220,7 +1378,6 @@ describe('Dialog', () => {
)) - const Elsewhere = defineComponent(() => () => ) const { container } = await renderSettled(() => ( {shown.value ? : } )) @@ -1269,7 +1426,6 @@ describe('Dialog', () => {
)) - const Elsewhere = defineComponent(() => () => ) await renderSettled(() => {shown.value ? : }) await afterFlushes() @@ -1300,7 +1456,6 @@ describe('Dialog', () => { loading ), ) - const Elsewhere = defineComponent(() => () => ) const before: unknown = window.history.state const { container } = await renderSettled(() => ( {shown.value ? : } From fe38533e1451579ac4ab36ab36f2ec3895e71cd2 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Wed, 7 Oct 2026 11:25:45 +0200 Subject: [PATCH 27/33] fix(vue): read a null open as absent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `null` is Vue's "no value", but the core takes anything other than `undefined` as control: `:open="maybeNull"` silently made the dialog controlled — its Trigger did nothing, and a `default-open` dialog stuck open. The root now hands the core `open: props.open ?? undefined`; the other options already fall back on `null` through the core's own `??`. Co-Authored-By: Claude Opus 5.5 --- packages/vue/dialog/SPEC.md | 8 +++++--- packages/vue/dialog/src/dialog.ts | 8 +++++++- packages/vue/dialog/tests/dialog.test.tsx | 21 +++++++++++++++++++++ 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/packages/vue/dialog/SPEC.md b/packages/vue/dialog/SPEC.md index 8e8feb8..808fe48 100644 --- a/packages/vue/dialog/SPEC.md +++ b/packages/vue/dialog/SPEC.md @@ -57,9 +57,11 @@ Vue-specific notes on top of the core contract: doesn't consult. A listener that throws is caught by Vue's error handling, so it can't veto. - **Boolean props follow the core defaults when absent.** Vue casts an absent - Boolean prop to `false`; this root declares its booleans without that - cast, so `` is modal and uncontrolled, and a bare attribute - (``) switches the option on. + Boolean prop to `false`; this root declares its booleans without that cast, + so `` is modal and uncontrolled, and a bare attribute + (``) switches the option on. `open` reads `null` — + Vue's "no value" — as absent too: a bound `open` that holds `null` keeps + the dialog uncontrolled, where the core would take it as control. - **`Portal`** teleports the layers to `document.body`, or to a `container` element you supply. Nothing is kept mounted while closed; an `animated` dialog stays mounted through the core contract's `closing` state so its exit diff --git a/packages/vue/dialog/src/dialog.ts b/packages/vue/dialog/src/dialog.ts index 729d70c..214b8b5 100644 --- a/packages/vue/dialog/src/dialog.ts +++ b/packages/vue/dialog/src/dialog.ts @@ -226,7 +226,13 @@ const DialogRoot = defineComponent( onBackNavigation: event => emit('backNavigation', event), onForwardNavigation: event => emit('forwardNavigation', event), } - const { api, machine } = useDialog(() => ({ ...props, ...callbacks })) + const { api, machine } = useDialog(() => ({ + ...props, + // `null` is Vue's "no value": the core takes anything but `undefined` + // as control, so a bound `open` holding `null` reads as absent. + open: props.open ?? undefined, + ...callbacks, + })) const backdropRef = shallowRef(null) const viewportRef = shallowRef(null) diff --git a/packages/vue/dialog/tests/dialog.test.tsx b/packages/vue/dialog/tests/dialog.test.tsx index b127616..6202b50 100644 --- a/packages/vue/dialog/tests/dialog.test.tsx +++ b/packages/vue/dialog/tests/dialog.test.tsx @@ -920,6 +920,27 @@ describe('Dialog', () => { expect(screen.queryByRole('dialog')).toBeNull() }) + // `null` is Vue's "no value": a bound `open` holding it is absent — the + // core takes any other value, `null` included, as control. + it('a null open is absent: the dialog stays uncontrolled', async () => { + await renderSettled( + defineComponent({ + components: dialogComponents, + template: ` + + + content + + + `, + }), + ) + expect(screen.queryByRole('dialog')).not.toBeNull() + + await pressEscape() + expect(screen.queryByRole('dialog')).toBeNull() + }) + it('v-model:open is the controlled contract — the dialog follows the bound value alone', async () => { const open = ref(false) await renderSettled( From 4126d481fc3b852a53b0e0cb540e1b932fb097e1 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Wed, 7 Oct 2026 11:25:59 +0200 Subject: [PATCH 28/33] fix(vue): start on a view's activation only once mounted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A restored view's activation reaches every part it holds, mounted or not: an async component still pending under got its `onActivated` before its `onMounted`, so the scroll lock engaged (and the dialog's history guard would plant its entry) before there was anything on screen. The hooks and the binding's own lifecycle now start on activation only once mounted, leaving a pending part to its own mount — as the final adapter does for the machine. Co-Authored-By: Claude Opus 5.5 --- packages/vue/dialog/SPEC.md | 3 +- packages/vue/dialog/src/dialog.ts | 10 +++++-- packages/vue/hooks/use-focus-trap/SPEC.md | 4 ++- .../use-focus-trap/src/use-focus-trap.ts | 10 +++++-- packages/vue/hooks/use-scroll-lock/SPEC.md | 12 ++++---- .../use-scroll-lock/src/use-scroll-lock.ts | 10 +++++-- .../tests/use-scroll-lock.test.ts | 28 ++++++++++++++++++- 7 files changed, 63 insertions(+), 14 deletions(-) diff --git a/packages/vue/dialog/SPEC.md b/packages/vue/dialog/SPEC.md index 808fe48..84d99ea 100644 --- a/packages/vue/dialog/SPEC.md +++ b/packages/vue/dialog/SPEC.md @@ -159,7 +159,8 @@ Vue-specific notes on top of the core contract: opened, so each layer's close still returns focus to the one beneath. A dialog mounted into a view that is already deactivated — async data resolving after the user left — holds still the same way until the view - returns. + returns, and a part that a restored view's activation reaches before it has + mounted (an async one under ``) starts with its own mount. - **Server rendering** touches no DOM: the root and its Trigger render, the document-level work starts on mount, and the base id comes from `useId`, so the hydrated parts carry the ids the server rendered. `useId` counts per diff --git a/packages/vue/dialog/src/dialog.ts b/packages/vue/dialog/src/dialog.ts index 214b8b5..44cb727 100644 --- a/packages/vue/dialog/src/dialog.ts +++ b/packages/vue/dialog/src/dialog.ts @@ -92,6 +92,7 @@ function inDeactivatedView(instance: InstanceNode | null): boolean { // neither does this. function whileActive(effects: () => void): void { const instance = getCurrentInstance() + let mounted = false let scope: EffectScope | undefined const start = (): void => { if (scope !== undefined) return @@ -106,10 +107,15 @@ function whileActive(effects: () => void): void { scope = undefined } onMounted(() => { + mounted = true if (!inDeactivatedView(instance)) start() }) - // Also fires on a kept-alive first mount, after `onMounted` already started. - onActivated(start) + // Also fires on a kept-alive first mount, after `onMounted` already + // started — and can reach a part that hasn't mounted yet (an async one + // under ), which `onMounted` starts instead. + onActivated(() => { + if (mounted) start() + }) onDeactivated(stop) onUnmounted(stop) } diff --git a/packages/vue/hooks/use-focus-trap/SPEC.md b/packages/vue/hooks/use-focus-trap/SPEC.md index 89bcec9..529c6de 100644 --- a/packages/vue/hooks/use-focus-trap/SPEC.md +++ b/packages/vue/hooks/use-focus-trap/SPEC.md @@ -40,7 +40,9 @@ Vue-specific notes on top of the DOM contract: 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. Mounted hooks never run during + 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 options object is read on each Tab press, so inline `enabled` / `last` see the latest state without re-binding the listener — the per-press 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 index 5d92638..cd2c1bd 100644 --- a/packages/vue/hooks/use-focus-trap/src/use-focus-trap.ts +++ b/packages/vue/hooks/use-focus-trap/src/use-focus-trap.ts @@ -84,11 +84,17 @@ export function useFocusTrap( 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`. - onActivated(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-scroll-lock/SPEC.md b/packages/vue/hooks/use-scroll-lock/SPEC.md index a6c8dea..3d1235c 100644 --- a/packages/vue/hooks/use-scroll-lock/SPEC.md +++ b/packages/vue/hooks/use-scroll-lock/SPEC.md @@ -31,11 +31,13 @@ Vue-specific notes on top of the DOM contract: 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. 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. + 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 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 index 767d3a6..d05e3f7 100644 --- a/packages/vue/hooks/use-scroll-lock/src/use-scroll-lock.ts +++ b/packages/vue/hooks/use-scroll-lock/src/use-scroll-lock.ts @@ -85,11 +85,17 @@ export function useScrollLock( 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`. - onActivated(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 index ef391f2..9221486 100644 --- 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 @@ -1,7 +1,7 @@ // @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, defineComponent, h, nextTick, ref } from 'vue' +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' @@ -104,6 +104,32 @@ describe('useScrollLock', () => { 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) From fc36865d4138098d9c72afb2084e96f4f782489e Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Wed, 7 Oct 2026 11:26:11 +0200 Subject: [PATCH 29/33] docs(vue): correct the listener, error, and server-markup notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Modifier listeners: with the final adapter a `.capture` listener, or a `.once` one bound from the first render, runs first and vetoes the part's handler, as a listener bound twice does; a `.passive` one can't prevent. The SPEC said the part ignored them. - A throwing dismissal listener: the SPEC said it can't veto. Vue's error handling decides — with an errorHandler, or in production, the error is reported and the dismissal proceeds; in development without one, Vue rethrows and the dismissal aborts. - Server markup: a `defaultOpen` dialog's Trigger renders `aria-expanded="true"` and an `aria-controls` naming a window that only arrives on the client, and an in-place Content lacks its labelling until its Title mounts — as React's server markup does. Documented as a gap; the SSR test no longer asserts the dangling `aria-controls` as intended. - The deactivated-view walk and `$el` unwrapping stay local copies in the dialog and both hooks, recorded as a decision (a hook imports only the DOM util it wraps). - READMEs: the focus-trap sample used an undefined `isTopmost`; the dialog sample used Close as a Cancel button, which the SPEC reserves for the corner ×. Co-Authored-By: Claude Opus 5.5 --- packages/vue/dialog/README.md | 2 +- packages/vue/dialog/SPEC.md | 49 ++++++++++++++------- packages/vue/dialog/tests/ssr.test.tsx | 5 ++- packages/vue/hooks/use-focus-trap/README.md | 5 ++- packages/vue/hooks/use-focus-trap/SPEC.md | 3 ++ packages/vue/hooks/use-scroll-lock/SPEC.md | 3 ++ 6 files changed, 47 insertions(+), 20 deletions(-) diff --git a/packages/vue/dialog/README.md b/packages/vue/dialog/README.md index 1136fbb..6f8880e 100644 --- a/packages/vue/dialog/README.md +++ b/packages/vue/dialog/README.md @@ -32,7 +32,7 @@ import { Dialog } from '@dunky.dev/vue-dialog' Delete file? This cannot be undone. - Cancel + × diff --git a/packages/vue/dialog/SPEC.md b/packages/vue/dialog/SPEC.md index 84d99ea..4b9c0f8 100644 --- a/packages/vue/dialog/SPEC.md +++ b/packages/vue/dialog/SPEC.md @@ -46,16 +46,17 @@ Vue-specific notes on top of the core contract: are `escape-key-down`, `interact-outside`, `back-navigation`, and `forward-navigation`. Listeners run synchronously, so `preventDefault()` on a payload still vetoes, per the core contract. `update:open` reports a - change, it never requests one: a controlled dialog — `v-model:open` - included — doesn't move on its own Trigger, Close, Escape, or outside - press. Wire the intent at its source: `@click` on the Trigger, a handler - on `escape-key-down`, your own action buttons. A wrapper that forwards its - own `defineModel('open')` through `v-model:open` makes the dialog - controlled the moment that model holds a value, with the same obligation. - The veto composes with plain listeners; a modifier listener - (`@click.capture`, `.once`) is a separate DOM listener the part's handler - doesn't consult. A listener that throws is caught by Vue's error handling, - so it can't veto. + change, it never requests one: a controlled dialog — `v-model:open` included + — doesn't move on its own Trigger, Close, Escape, or outside press. Wire the + intent at its source: `@click` on the Trigger, a handler on + `escape-key-down`, your own action buttons. A wrapper that forwards its own + `defineModel('open')` through `v-model:open` makes the dialog controlled the + moment that model holds a value, with the same obligation. A dismissal + listener that throws vetoes nothing by design, but Vue's error handling + decides what follows: with an `app.config.errorHandler`, or in a production + build, Vue reports the error and the dismissal proceeds; in development + without one, Vue rethrows it, which aborts the dismissal — the dialog stays + open there. - **Boolean props follow the core defaults when absent.** Vue casts an absent Boolean prop to `false`; this root declares its booleans without that cast, so `` is modal and uncontrolled, and a bare attribute @@ -109,10 +110,12 @@ Vue-specific notes on top of the core contract: its own. - **Attributes** are merged, not inherited: each part merges what you pass through the adapter's `mergeProps`. Your listeners run before the part's - own, and `preventDefault()` in yours skips it; `class` and `style` merge; - the attributes the part owns (`id`, `role`, `aria-*`, `data-state`) win. - The root and the Portal render no element of their own and ignore extra - attributes. + own, and `preventDefault()` in yours skips it — a listener bound twice too, + and a `.capture` one, or a `.once` one bound from the first render, both of + which run first; a `.passive` listener can't prevent, so it can't veto. + `class` and `style` merge; the attributes the part owns (`id`, `role`, + `aria-*`, `data-state`) win. The root and the Portal render no element of + their own and ignore extra attributes. - **`Backdrop`** renders nothing when the dialog is non-modal (`:modal="false"`), per the core parts contract. - **Exit animation** (`animated`): style the exit on the parts' @@ -160,12 +163,26 @@ Vue-specific notes on top of the core contract: dialog mounted into a view that is already deactivated — async data resolving after the user left — holds still the same way until the view returns, and a part that a restored view's activation reaches before it has - mounted (an async one under ``) starts with its own mount. + mounted (an async one under ``) starts with its own mount. The + binding walks the instance chain for a deactivated view itself, for the work + it owns — the Portal's parking, the window's sequences, the history guard — + as the adapter does for the machine and each hook for its own lifecycle. + That walk and the `$el` unwrapping are local copies here and in both hooks, + by design: a substrate hook imports only the DOM util it wraps, so the three + can't share a helper without a new package. - **Server rendering** touches no DOM: the root and its Trigger render, the document-level work starts on mount, and the base id comes from `useId`, so the hydrated parts carry the ids the server rendered. `useId` counts per app: with several Vue apps on one page, give each its own - `app.config.idPrefix` — dialogs share one layer stack, keyed by id. + `app.config.idPrefix` — dialogs share one layer stack, keyed by id. The + layers themselves arrive on the client, which leaves the server markup short + meanwhile: a `defaultOpen` dialog's Trigger already renders + `aria-expanded="true"` and an `aria-controls` naming a window not yet in the + document, and a Content rendered with the root (no Portal) carries no + `aria-labelledby` / `aria-describedby` until its Title and Description + mount. The React binding's server markup has the same gaps; leaving + `aria-controls` out until the window exists needs the core to track the + window's presence. - Everything ships headless, per the core contract's [Internals](../../core/dialog/SPEC.md#internals). diff --git a/packages/vue/dialog/tests/ssr.test.tsx b/packages/vue/dialog/tests/ssr.test.tsx index ed4f248..713bbdf 100644 --- a/packages/vue/dialog/tests/ssr.test.tsx +++ b/packages/vue/dialog/tests/ssr.test.tsx @@ -30,9 +30,10 @@ describe('Dialog on the server', () => { expect(html).not.toContain('role="dialog"') }) - it('renders an open dialog without its portal, the trigger already announcing it', async () => { + // The window arrives once the client mounts — SPEC.md lists what the + // trigger announces meanwhile. + it('renders an open dialog without its portal', async () => { const html = await render(true) - expect(html).toMatch(/aria-controls="v-[^"]+-content"/) expect(html).toContain('data-state="open"') expect(html).not.toContain('role="dialog"') }) diff --git a/packages/vue/hooks/use-focus-trap/README.md b/packages/vue/hooks/use-focus-trap/README.md index d7d8ce2..b90b426 100644 --- a/packages/vue/hooks/use-focus-trap/README.md +++ b/packages/vue/hooks/use-focus-trap/README.md @@ -16,10 +16,13 @@ npm install @dunky.dev/vue-use-focus-trap ```vue