Skip to content

feat(vue): the Vue substrate, opening with Dialog - #38

Draft
ivanbanov wants to merge 35 commits into
mainfrom
vue-dialog
Draft

ivanbanov wants to merge 35 commits into
mainfrom
vue-dialog

Conversation

@ivanbanov

@ivanbanov ivanbanov commented Aug 14, 2026 •

Copy link
Copy Markdown
Member

What

The Vue substrate, opening with Dialog, built on main's shared DOM layer:

  • @dunky.dev/vue-dialog: the compound Dialog (Trigger, Portal, Backdrop, Viewport, Content, Title, Description, Close), with the same anatomy, behavior, and a11y contract as the React and Solid bindings. Every DOM sequence comes from @dunky.dev/dom-dialog and every decision from the core machine. The binding adds Vue's lifecycle only.
  • @dunky.dev/vue-use-focus-trap / @dunky.dev/vue-use-scroll-lock: Vue lifecycle wrappers over the DOM utils, in the Solid primitives' shape (MaybeRefOrGetter inputs).
  • @dunky-dev/vue Storybook harness (pnpm dev:vue, port 6007, @storybook/vue3-vite), with twelve stories that mirror React/Solid one for one.
  • scripts/templates/packages/vue, aligned with the react/solid templates, so pnpm scaffold <name> stamps Vue too.
  • One shared, additive change: @dunky.dev/dom-dialog's startExitWindow takes inPlace (with the layer's viewport) for a dialog rendered without a portal. It defaults off, so React and Solid are unchanged.

Important

Blocked on dunky-dev/state-machine#33. @dunky.dev/vue-state-machine is not on npm yet. The packages depend on its first publish (^0.3.0, with '@dunky.dev/vue-state-machine@0.3.0' in minimumReleaseAgeExclude). Locally it resolves through an uncommitted file: override to the packed final adapter (vue-sm-0f2b479.tgz). The lockfile is deliberately uncommitted (the Solid PR did the same), so CI's frozen install fails until the publish. After the publish: drop the override, run pnpm install, and commit the lockfile.

Vue-specific decisions

  • Booleans declare default: undefined. An absent boolean keeps the core default instead of Vue's false cast, so an absent open stays uncontrolled and modal defaults on. A null open (Vue's "no value") also counts as absent.
  • Callbacks are emits. onOpenChange is update:open, so v-model:open binds the controlled pair. The dismissals are escape-key-down, interact-outside, back-navigation, forward-navigation. Listeners run synchronously, so preventDefault() still vetoes. Per the core's controlled contract, update:open reports a change and never requests one: a v-model'd dialog does not move on its own Trigger, Escape, or outside press. The intent is wired at its source, as React/Solid consumers do.
  • ids: useId() with the options.id ?? id rule, stable across hydration.
  • initialFocus / restoreFocus take a MaybeRefOrGetter of an element or a component (its $el), resolved at open/close time. A template unwraps refs before the element mounts, so a template passes a getter: :initial-focus="() => cancel".
  • Portal teleports only after it mounts, so the server and the hydration pass render nothing. A container swap re-creates the teleport (like the React/Solid portals). container takes an element, and Vue warns at runtime if it gets a selector string.
  • The DOM edges follow the state the window renders, and they run as React's commit runs them. The mounted and updated hooks record that state, and every dialog's sequences run in one shared pass once the update has rendered: every release before any start, releases innermost first, exit windows before open sequences, opens outermost first. So a step that closes one dialog and opens another in the same update (a wizard's Next) hands focus over and later returns it to the first trigger, and an exiting layer stays inert while others open or close around it, whichever dialog was declared first. An unmount still releases at once, after the DOM is gone, children first.
  • <KeepAlive>: the adapter pauses the machine (React's <Activity>), and the binding follows. The Portal parks the layers in the cached view (state intact, not painted over the next view). The guard, trap, and lock release. Reactivation restores them, outermost layer first. A dialog mounted into a view that is already deactivated holds still until the view returns, and a part that an activation reaches before it mounts (async under <Suspense>) starts with its own mount.
  • Parts merge attrs through the adapter's mergeProps (inheritAttrs: false). A .capture listener, or a .once one bound from the first render, vetoes like a plain one; a .passive one can't. A part's element is its $el. Content renders a div with the role, not <dialog>. Without a Portal, the exit window hides the layer alone, through dom-dialog's inPlace.
  • Types: defineComponent + h() in .ts (oxc isolated declarations), exported as DefineComponent. Storybook's Meta rejects the bare constructor type that function-signature defineComponent infers.
  • Tooling: TSX tests need Vue's JSX namespace and transform, so packages/vue typechecks as its own project and runs as its own vitest project (@vitejs/plugin-vue-jsx), as Solid does. Nothing here needs TypeScript 6.

Verified

  • pnpm test:ci: vitest 414 + 13 native jest. Vue 116: the React/Solid dialog suite ported with identical names and order (56; the overlapping alertdialog-role test is dropped), Vue-only tests (including a 16-case sibling hand-over matrix, stacked exits, same-depth release order, bundled duplicate copies and KeepAlive restore order, run with browsers' rule that inert elements can't take focus), 2 server-rendering tests, and the hook tests. The Vue suite also passes under --sequence.shuffle.
  • pnpm typecheck (TypeScript 7), pnpm lint, pnpm format:check, pnpm build (publint clean on 19 packages), pnpm changeset status, the Vue Storybook build. Every story mounts and runs in jsdom with zero Vue warnings.
  • pnpm knip: the same as main's failing baseline (packages/native). That finding's list only gains the three new tsconfig paths.
  • React-vs-Vue differential fuzz (seeded scenarios: options, nesting, animation, control, vetoes, Escape and outside presses, exits): 0 divergences over 2,000 seeds, and an independent review's 4,200 seeds found only a pre-existing class where React leaves focus on a lower layer. Sibling hand-over fuzz (1,200 seeds): every remaining divergence is a React layer left un-inert mid-exit, where Vue keeps it inert.
  • Real browser (headless Chrome 154, trusted input): all 12 stories behave like React's, except the one-Escape double close on a default-open nested stack, where Vue is correct (fixed for React in the separate dom-dialog Escape PR). Zero [Vue warn].
  • Pack and consume: the packed packages and the final adapter tarball, installed in a clean project, render on the server, open/trap/restore in jsdom, hand over between siblings, and typecheck (skipLibCheck: false).
  • Vue 3.6.0-rc.10 (scratch, checked one round earlier at fc36865): the suite (111/111), the story checks, the Storybook build, and typecheck pass. Peer floors: the hooks pass on 3.3.13; the dialog needs 3.5 (useId).

Found along the way (not changed here)

  • All substrates: an animated parent closed over an open nested child loses focus in browsers. A fix belongs in openDialogLayer's close edge. Restoring focus into an exiting (inert) layer is the same family.
  • React: two animated layers changing in one update can leave an exiting layer un-inert, depending on declaration order (Solid has the mirror bug). A per-element refcount in dom-overlay's hide tracker fixes it at the source for every substrate. React/Solid could also adopt dom-dialog's inPlace.
  • React and Solid: a Content rendered without a Portal leaves the page root inert/aria-hidden while closed. React: a nested stack mounted open at once logs a spurious openDialogLayer warning.
  • Server markup (all substrates): a defaultOpen dialog's Trigger renders an aria-controls naming a window that only arrives on the client. A core-level "window present" fact would let the Trigger omit it.
  • React and Solid suites: 'renders role=alertdialog when requested' never fails alone, and 'closes on the browser Back instead of navigating' fails under some shuffle seeds. Both are fixed in the Vue suite only.
  • The React/Solid SPECs document a Dialog.Close scope prop that no binding implements, and the React SPEC says Description renders a <p> (it renders a <div>). The Solid scaffold template fails typecheck.

🤖 Generated with Claude Code

ivanbanov and others added 2 commits August 14, 2026 12:23
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 <noreply@anthropic.com>
…ster

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ivanbanov and others added 27 commits October 4, 2026 20:50
main moved 58 commits since this branch: the shared DOM layer
(@dunky.dev/dom-dialog, @dunky.dev/dom-element), the Solid substrate,
the dom-navigation -> browser-navigation rename, restoreFocus, the
forward-navigation reopen, escapeScope and inner-popup handling. Five
config conflicts, each a union of both sides: knip workspaces, the
dev:vue script beside dev:solid, the tsconfig paths, the tsdown
workspace list, and minimumReleaseAgeExclude.

The Vue packages still carry the pre-DOM-layer design here; the
commits that follow rebuild them on top of @dunky.dev/dom-dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 <KeepAlive> deactivation releases them like an unmount and reactivation
arms them again — the adapter pauses a kept-alive machine the same way,
as React's <Activity> 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 <noreply@anthropic.com>
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 <dialog>.

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 <KeepAlive> 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 <noreply@anthropic.com>
The twelve React/Solid stories, one for one — standard, alertDialog,
longContent, loginForm, trigger, controlled, scoped, nested, innerPopup,
closeOnBack, nestedCloseOnBack, containment. None needed to be dropped.

The story is the consumer, and a Vue consumer writes templates, so each
story is a runtime-compiled template (the vue3-vite preset aliases the
compiler build): `v-model:open`, kebab-case emit listeners, template refs,
and the getter form of `initialFocus` read as they would in an SFC. Runtime
templates resolve components by registered name, so the dotted part names
are registered as such. Styles carry explicit units — Vue, unlike React,
appends none to numbers — and the containment story renders its `[inert]`
rule from a function, since a template drops <style> tags.

Storybook: @storybook/vue3-vite on port 6007 (`pnpm dev:vue`), docgen off
(it reads `.vue` files; these parts are `.ts`), and `resolve.dedupe` for a
linked adapter, as in the vitest project.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The vue template predated the adapter's final shape and the repo's
conventions since. It now matches the react/solid templates part for
part — root plus a placeholder Root part, a substrate effects.ts whose
comment points DOM work at @dunky.dev/dom-<name>, a TSX test of the same
three behaviors, a story — in the Vue dialog's idioms: the core callbacks
as emits, `default: undefined` booleans, the DefineComponent export type,
and a runtime-template story.

Verified by stamping a primitive in a scratch copy: the scaffolded vue
package typechecks, lints, formats, and passes its tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings #56: the root devDependency moves to TypeScript 7.0.2, the native
compiler. No conflicts. The Vue sources (`.ts` + `h()`) and the TSX
tests typecheck under it as they did under 6; nothing in the repo now
needs TypeScript's classic JS API — Storybook's docgen is off and
vue-tsc is not used here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Vue joins React and Solid wherever the docs list the substrates: the
README's layer diagram and layout, ARCHITECTURE's substrate examples, and
CONTRIBUTING's Storybook commands — `pnpm dev:vue` on port 6007, plus
the `dev:solid` line that was missing. The `pnpm test` row no longer
claims the suite is react-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The first attempt's changesets described a native <dialog> window and the
pre-DOM-layer hooks. They now describe what ships: Vue 3.5+ for the dialog
(useId) and 3.3+ for the hooks, `v-model:open` as the controlled pair with
the intent wired at its source, absent booleans keeping the core defaults,
the getter form of `initialFocus` in templates, the hydration-safe Portal,
and the hooks' mount and KeepAlive lifecycle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r cleanup

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<Ref<DialogApi>>` stopped
accepting it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The SPECs described mount and unmount but not what a <KeepAlive>
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 <noreply@anthropic.com>
…er render

Two lifecycle corrections to the Dialog:

- A <KeepAlive> 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
… nothing

- 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<HTMLElement>()` 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@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 <noreply@anthropic.com>
…ntent

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 <body>. 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <body>), 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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…ries

- 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 <noreply@anthropic.com>
`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 <noreply@anthropic.com>
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
<body> 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 <noreply@anthropic.com>
`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 <noreply@anthropic.com>
ivanbanov and others added 6 commits October 7, 2026 11:25
A restored <KeepAlive> view's activation reaches every part it holds,
mounted or not: an async component still pending under <Suspense> 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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
Vue's esm-bundler build takes `__VUE_OPTIONS_API__`,
`__VUE_PROD_DEVTOOLS__` and `__VUE_PROD_HYDRATION_MISMATCH_DETAILS__`
from the bundler, and without them logs a notice on every page load.
@vitejs/plugin-vue would define them; @storybook/vue3-vite adds no such
plugin, so the harness's viteFinal defines them.

The Options API is off: the binding, its adapter, the stories, and
Storybook's own Vue renderer use setup() and render functions only, so it
is dead weight here — and leaving it out shows the dialog working in an
app that turns it off. A static Storybook has no production devtools and
no hydration, so both of those are off too.

In the build the flag checks fold away (no `typeof __VUE_*__` left in the
preview bundle); in dev, Vite's client env assigns the flags before the
pre-bundled Vue runs, so the notice no longer fires.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three gaps in the shared pass, from the delta review:

- At one depth, a reopening layer's exit-window release could run before
  a sibling's open-layer release, so the sibling's restore landed inside
  the layer about to take over and its next close lost focus. Open layers
  now release before exit windows lift, each group innermost first.
- Same-depth layers released and reopened in hook order: a restored
  KeepAlive view, whose activated hooks run in reverse, brought two open
  siblings back upside down. Each commit records when its open sequence
  last ran; releases go latest-opened first and opens earliest-opened
  first, a fresh layer last.
- The pending set was module-level, so a page bundling the binding twice
  ran one pass per copy, one copy's open before the other's close. The
  queue now lives on a realm-global keyed by `Symbol.for`, as
  dom-overlay's stack does.

Each gets a test that fails without its fix (the copies test loads a
second module instance through a query import). The reviewer's sibling
fuzz seed 603 now matches React; over 1,200 sibling seeds and 800 main
seeds, no new divergences.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A restored KeepAlive view's activation reaches parts that haven't
mounted yet (async ones pending under <Suspense>). The guard that leaves
them to their own mount had a test only in the scroll-lock hook. Now the
dialog has one — no history entry or lock before a pending dialog shows,
one entry once it mounts — and the focus trap has one: unarmed until the
async component mounts, armed after. Each fails with its guard removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A throwing dismissal listener's error is reported and the dismissal
proceeds in production — unless `app.config.throwUnhandledErrorInProduction`
is set (Vue 3.5+), which rethrows it as development does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant