Repository navigation
Conversation
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>
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>
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-dialogand 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 (MaybeRefOrGetterinputs).@dunky-dev/vueStorybook 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, sopnpm scaffold <name>stamps Vue too.@dunky.dev/dom-dialog'sstartExitWindowtakesinPlace(with the layer'sviewport) 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-machineis not on npm yet. The packages depend on its first publish (^0.3.0, with'@dunky.dev/vue-state-machine@0.3.0'inminimumReleaseAgeExclude). Locally it resolves through an uncommittedfile: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, runpnpm install, and commit the lockfile.Vue-specific decisions
default: undefined. An absent boolean keeps the core default instead of Vue'sfalsecast, so an absentopenstays uncontrolled andmodaldefaults on. Anullopen(Vue's "no value") also counts as absent.onOpenChangeisupdate:open, sov-model:openbinds the controlled pair. The dismissals areescape-key-down,interact-outside,back-navigation,forward-navigation. Listeners run synchronously, sopreventDefault()still vetoes. Per the core's controlled contract,update:openreports 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.useId()with theoptions.id ?? idrule, stable across hydration.initialFocus/restoreFocustake aMaybeRefOrGetterof 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".containerswap re-creates the teleport (like the React/Solid portals).containertakes an element, and Vue warns at runtime if it gets a selector string.<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.mergeProps(inheritAttrs: false). A.capturelistener, or a.onceone bound from the first render, vetoes like a plain one; a.passiveone can't. A part's element is its$el. Content renders adivwith the role, not<dialog>. Without a Portal, the exit window hides the layer alone, through dom-dialog'sinPlace.defineComponent+h()in.ts(oxc isolated declarations), exported asDefineComponent. Storybook'sMetarejects the bare constructor type that function-signaturedefineComponentinfers.packages/vuetypechecks 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.[Vue warn].skipLibCheck: false).useId).Found along the way (not changed here)
openDialogLayer's close edge. Restoring focus into an exiting (inert) layer is the same family.inPlace.inert/aria-hiddenwhile closed. React: a nested stack mounted open at once logs a spuriousopenDialogLayerwarning.defaultOpendialog's Trigger renders anaria-controlsnaming a window that only arrives on the client. A core-level "window present" fact would let the Trigger omit it.Dialog.Closescopeprop 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