Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
4ea0392
feat(vue): the Vue substrate, opening with Dialog
ivanbanov Aug 14, 2026
14e672c
docs(vue): tighten the comments that outgrew the react binding's regi…
ivanbanov Aug 14, 2026
e57d4b0
Merge origin/main into vue-dialog-next
ivanbanov Oct 4, 2026
4f44bf1
feat(vue): rebuild the focus-trap and scroll-lock hooks on mount
ivanbanov Oct 5, 2026
a8fc96b
feat(vue): rebuild Dialog on the shared DOM layer
ivanbanov Oct 5, 2026
e7264fb
feat(vue): mirror the dialog stories as runtime templates
ivanbanov Oct 5, 2026
b47f76a
chore(scaffold): align the vue template with the react and solid ones
ivanbanov Oct 5, 2026
cd72413
Merge origin/main into vue-dialog-next
ivanbanov Oct 5, 2026
df1c6c0
docs: enumerate the Vue substrate
ivanbanov Oct 5, 2026
913a75f
chore(changeset): rewrite the Vue changesets for the rebuilt substrate
ivanbanov Oct 5, 2026
b19b201
fix(vue): hold the open edge, trap, and lock through Vue 3.6's watche…
ivanbanov Oct 5, 2026
0c6a74e
docs(vue): spec the KeepAlive lifecycle and pin the $el handle
ivanbanov Oct 5, 2026
0cbcbaa
fix(vue): park kept-alive layers instead of unmounting them; open aft…
ivanbanov Oct 5, 2026
039b744
fix(vue): run the exit window in `closing` only
ivanbanov Oct 5, 2026
8669729
fix(vue): key the teleport on its resolved target; an empty ref locks…
ivanbanov Oct 5, 2026
37eff68
fix(vue): hold still when mounted into a deactivated KeepAlive view
ivanbanov Oct 5, 2026
ef5eb35
fix(vue): type the KeepAlive walk structurally for Vue 3.6
ivanbanov Oct 5, 2026
7d22674
chore(vue): depend on the adapter's first publish, ^0.3.0
ivanbanov Oct 5, 2026
282ac14
fix(vue): review findings — teardown order, component refs, inline Co…
ivanbanov Oct 5, 2026
2ee3840
chore(scaffold): type the vue template's parts by their cast alone
ivanbanov Oct 5, 2026
006ab8c
test(vue): press Tab on the page when checking a parked trap
ivanbanov Oct 6, 2026
aef01f4
chore(scaffold): the vue template peers vue ^3.4.0, as its adapter does
ivanbanov Oct 6, 2026
5075767
fix(vue): open and release the layer on the rendered state
ivanbanov Oct 6, 2026
c790c20
fix(vue): realm-safe element refs, a typed container, an inline exit
ivanbanov Oct 6, 2026
b25c2a6
perf(vue): build the dialog options once per change
ivanbanov Oct 6, 2026
aa42647
test(vue): survive a failed test or a shuffle; derive the part regist…
ivanbanov Oct 6, 2026
ffad99b
feat(dom-dialog): bound an in-place layer's exit window to the layer
ivanbanov Oct 7, 2026
d901d86
fix(vue): run every dialog's close before any open in an update
ivanbanov Oct 7, 2026
fe38533
fix(vue): read a null open as absent
ivanbanov Oct 7, 2026
4126d48
fix(vue): start on a view's activation only once mounted
ivanbanov Oct 7, 2026
fc36865
docs(vue): correct the listener, error, and server-markup notes
ivanbanov Oct 7, 2026
f6223bf
chore(vue): define Vue's feature flags for the Storybook harness
ivanbanov Oct 7, 2026
7220d10
fix(vue): order the commit pass by the stack, across bundled copies
ivanbanov Oct 8, 2026
7a123d9
test(vue): cover the mounted guards of the dialog and the focus trap
ivanbanov Oct 8, 2026
f2b57bb
docs(vue): note the production flag that rethrows listener errors
ivanbanov Oct 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/dom-dialog-exit-in-place.md
Original file line number Diff line number Diff line change
@@ -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`.
54 changes: 54 additions & 0 deletions .changeset/vue-dialog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
---
'@dunky.dev/vue-dialog': minor
---

New substrate: the Vue binding for `@dunky.dev/dialog`, targeting Vue 3.5+
(peer `vue@^3.5.0` — the base id comes from `useId`, so server-rendered ids
survive hydration). The same compound anatomy and behavior contract as the
React and Solid bindings — one core machine, a new host — delivered in Vue's
native shape: the core options are props, the core callbacks are emits, and
`v-model:open` binds the controlled pair. Listeners run synchronously, so
`preventDefault()` on a dismissal payload still vetoes it. `update:open`
reports a change, it never requests one: per the controlled contract a
controlled dialog — `v-model:open` included — doesn't move on its own Trigger,
Escape, or outside press, so wire each intent at its source.

```vue
<script setup lang="ts">
import { ref } from 'vue'
import { Dialog } from '@dunky.dev/vue-dialog'

const open = ref(false)
</script>

<template>
<Dialog v-model:open="open" @escape-key-down="open = false">
<Dialog.Trigger @click="open = true">Open</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Backdrop />
<Dialog.Viewport>
<Dialog.Content>
<Dialog.Title>Title</Dialog.Title>
<Dialog.Description>Description</Dialog.Description>
<Dialog.Close @click="open = false">Close</Dialog.Close>
</Dialog.Content>
</Dialog.Viewport>
</Dialog.Portal>
</Dialog>
</template>
```

An uncontrolled `<Dialog>` 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 — `<Dialog>` is modal and uncontrolled, `<Dialog default-open>`
starts open. `Content`'s `initialFocus` and `restoreFocus` accept an element,
a ref, or a getter, resolved at open and close time; a template unwraps refs
before the element mounts, so it passes a getter
(`:initial-focus="() => cancelButton"`). The Portal renders nothing on the
server and teleports once mounted, so hydration matches; under `<KeepAlive>`
a cached dialog parks its layers off the page, state intact, until the view
returns. Everything else
follows the core spec: a `div` window with the dialog role, layer stack with
assistive-tech containment, focus trap with Close as the cycle's last stop,
scroll lock (scoped to the Portal container when given), exit animations
through `data-state="closing"`, and `closeOnBack` with the Forward reopen.
14 changes: 14 additions & 0 deletions .changeset/vue-hooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@dunky.dev/vue-use-focus-trap': minor
'@dunky.dev/vue-use-scroll-lock': minor
---

New substrate: the Vue lifecycle wrappers over the framework-free DOM utils,
mirroring the Solid primitives' shape (peer `vue@^3.3.0`).
`useFocusTrap(target, options?)` and `useScrollLock(locked?, target?)` take a
`MaybeRefOrGetter` — a template ref, a getter, or the element — and follow it
reactively. Both start on mount, when a template ref has filled, and never
run during server rendering; a `<KeepAlive>` deactivation releases them like
an unmount and reactivation picks them back up, as React's `<Activity>` does.
The behavior itself lives in `@dunky.dev/dom-focus-trap` and
`@dunky.dev/dom-scroll-lock` — these composables own only the lifecycle.
8 changes: 4 additions & 4 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ one per host environment — the **substrates**. Behavior cannot drift between
hosts because it exists in exactly one place.

A substrate is any environment a primitive is delivered to: a framework
(react), another framework (solid), or a different host entirely
(react), other frameworks (solid, vue), or a different host entirely
(native). Substrates are cheap by design; the expensive thing — the behavior
— is written once.

Expand Down Expand Up @@ -66,9 +66,9 @@ edges — lives under `dom/components/` instead. A util is primitive-agnostic
and imports nothing from the repo but a smaller util; a component package is
the opposite, and may import the primitive's core package and any DOM util.
Both are equally
framework-free. The split matters as substrates multiply: React and Solid
differ in how they schedule an effect, not in what the effect does, so the
what is written once and each binding contributes only its lifecycle.
framework-free. The split matters as substrates multiply: React, Solid, and
Vue differ in how they schedule an effect, not in what the effect does, so
the what is written once and each binding contributes only its lifecycle.

Machine logic that several primitives need — the controlled/uncontrolled
machinery (`@dunky.dev/controllable`) — lives the same way under
Expand Down
6 changes: 4 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ pnpm install
| Command | What it does |
| ------------------------------ | ---------------------------------------------------------- |
| `pnpm scaffold <name>` | Stamps a new primitive across every substrate |
| `pnpm test` | vitest suite (core + dom + react), watch mode |
| `pnpm test` | vitest suite (core, dom, web bindings), watch mode |
| `pnpm test:native` | The native substrate's jest suite (jest-expo + RNTL) |
| `pnpm test:ci` | Everything once (vitest + native jest) — what CI runs |
| `pnpm typecheck` | `tsc --noEmit` across the whole workspace |
Expand All @@ -36,14 +36,16 @@ pnpm test packages/core/dialog/tests/machine.test.ts

## Storybook

Each UI substrate (React, Solid, ...) is a self-contained package under
Each UI substrate (React, Solid, Vue, ...) is a self-contained package under
`packages/<substrate>` with its own Storybook — the fastest way to see a
change actually render. Every substrate gets an explicit `dev:<substrate>`
script:

```bash
pnpm dev # alias for dev:react (the default substrate)
pnpm dev:react # @dunky-dev/react Storybook at http://localhost:6006
pnpm dev:solid # @dunky-dev/solid Storybook at http://localhost:6008
pnpm dev:vue # @dunky-dev/vue Storybook at http://localhost:6007
pnpm build-storybook # static build of the react substrate's Storybook
```

Expand Down
38 changes: 19 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,23 +25,23 @@ up there with the same behavior and the same a11y.
connect() -> logical bindings
(onPress, role, data-state, ...)
|
+----------------+----------------+
| |
v |
+------------------------+ |
| dom/components/<name> | the DOM half |
| document listeners, | shared by every |
| focus/stack sequence | DOM host |
+-----+------------+-----+ |
| ^ |
| | dom/utils/* |
| | focus-trap, overlay, |
| | scroll-lock, ... |
v v
+-----------+ +-----------+ +-----------+
| react | | solid | | native |
+-----------+ +-----------+ +-----------+
render + host lifecycle only
+----------------+----------------------+
| |
v |
+------------------------+ |
| dom/components/<name> | the DOM half |
| document listeners, | shared by every |
| focus/stack sequence | DOM host |
+-----+------------+-----+ |
| ^ |
| | dom/utils/* |
| | focus-trap, overlay, |
| | scroll-lock, ... |
v v
+-----------+ +-----------+ +-----------+ +-----------+
| react | | solid | | vue | | native |
+-----------+ +-----------+ +-----------+ +-----------+
render + host lifecycle only
```

The engine that runs the core machines lives in its own repo:
Expand All @@ -63,8 +63,8 @@ renders, not how machines execute.
ordered focus sequences), written once and shared by every DOM host.
Published as `@dunky.dev/dom-<name>`.
- **`<substrate>/`** — the render. A thin binding per host (`react/`,
`solid/`, `native/`) that wires the machine to real elements. Published as
`@dunky.dev/<substrate>-<name>` (e.g. `@dunky.dev/react-dialog`).
`solid/`, `vue/`, `native/`) that wires the machine to real elements.
Published as `@dunky.dev/<substrate>-<name>` (e.g. `@dunky.dev/react-dialog`).

The dependency direction is one-way: `substrate -> dom -> core`. A binding
adds no behavior of its own — if a decision is needed, it moves into the core
Expand Down
3 changes: 3 additions & 0 deletions knip.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ const config: KnipConfig = {
// The babel presets are referenced as strings in tsdown.config.ts.
ignoreDependencies: ['babel-preset-solid', '@babel/preset-typescript'],
},
'packages/vue/*': {
entry: ['stories/*.stories.ts'],
},
},
}

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 .",
Expand All @@ -18,6 +18,7 @@
"dev": "pnpm dev:react",
"dev:react": "pnpm --filter @dunky-dev/react dev",
"dev:solid": "pnpm --filter @dunky-dev/solid dev",
"dev:vue": "pnpm --filter @dunky-dev/vue dev",
"dev:expo": "pnpm --filter @dunky-dev/native dev",
"dev:ios": "pnpm --filter @dunky-dev/native ondevice:ios",
"dev:android": "pnpm --filter @dunky-dev/native ondevice:android",
Expand Down
6 changes: 6 additions & 0 deletions packages/dom/components/dialog/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 16 additions & 5 deletions packages/dom/components/dialog/src/exit-window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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()
Expand Down
30 changes: 30 additions & 0 deletions packages/dom/components/dialog/tests/dialog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, HTMLElement> => {
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', () => {
Expand Down
39 changes: 39 additions & 0 deletions packages/vue/.storybook/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { StorybookConfig } from '@storybook/vue3-vite'
import { mergeConfig } from 'vite'

const config: StorybookConfig = {
stories: ['../**/*.stories.@(ts|tsx)'],
framework: {
name: '@storybook/vue3-vite',
// Docgen reads `.vue` files; the parts are `.ts` components.
options: { docgen: false },
},
core: {
disableTelemetry: true,
disableWhatsNewNotifications: true,
},
features: {
sidebarOnboardingChecklist: false,
},
viteFinal: viteConfig =>
mergeConfig(viteConfig, {
// A linked state-machine adapter (cross-repo development) resolves
// `vue` from its own checkout; two copies split the component instance,
// so every import is pinned to this workspace's.
resolve: { dedupe: ['vue'] },
// Vue's esm-bundler build takes its feature flags from the bundler
// (@vitejs/plugin-vue defines them; this framework adds no such plugin)
// and logs a notice on every load without them. The binding, its
// adapter, the stories, and Storybook's renderer use setup() and render
// functions only, so the Options API stays out — which also shows the
// dialog working in an app that turns it off. A static Storybook has no
// production devtools and no hydration.
define: {
__VUE_OPTIONS_API__: 'false',
__VUE_PROD_DEVTOOLS__: 'false',
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false',
},
}),
}

export default config
14 changes: 14 additions & 0 deletions packages/vue/.storybook/manager.ts
Original file line number Diff line number Diff line change
@@ -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: './',
}),
})
41 changes: 41 additions & 0 deletions packages/vue/dialog/README.md
Original file line number Diff line number Diff line change
@@ -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
<script setup lang="ts">
import { Dialog } from '@dunky.dev/vue-dialog'
</script>

<template>
<Dialog>
<Dialog.Trigger>Delete...</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Backdrop />
<Dialog.Viewport>
<Dialog.Content>
<Dialog.Title>Delete file?</Dialog.Title>
<Dialog.Description>This cannot be undone.</Dialog.Description>
<button type="button">Delete</button>
<Dialog.Close aria-label="Close">×</Dialog.Close>
</Dialog.Content>
</Dialog.Viewport>
</Dialog.Portal>
</Dialog>
</template>
```
Loading
Loading