Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
40 changes: 40 additions & 0 deletions .changeset/vue-dialog.md
Original file line number Diff line number Diff line change
@@ -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
<script setup lang="ts">
import { Dialog } from '@dunky.dev/vue-dialog'
</script>

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

`Content`'s `initialFocus` accepts a `MaybeRefOrGetter<HTMLElement | null>` β€”
the Vue idiom for "resolve at open time", so template refs that fill after
setup work. Everything else follows the core spec: native `<dialog>` 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`.
12 changes: 12 additions & 0 deletions .changeset/vue-hooks.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions knip.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ const config: KnipConfig = {
'packages/react/*': {
entry: ['stories/*.stories.tsx'],
},
'packages/vue/*': {
entry: ['stories/*.stories.ts'],
},
},
}

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions packages/vue/.storybook/main.ts
Original file line number Diff line number Diff line change
@@ -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
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>Cancel</Dialog.Close>
</Dialog.Content>
</Dialog.Viewport>
</Dialog.Portal>
</Dialog>
</template>
```
185 changes: 185 additions & 0 deletions packages/vue/dialog/SPEC.md
Original file line number Diff line number Diff line change
@@ -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
<script setup lang="ts">
import { Dialog } from '@dunky.dev/vue-dialog'
</script>

<template>
<Dialog>
<Dialog.Trigger>Open</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Backdrop />
<Dialog.Viewport>
<Dialog.Content>
<Dialog.Title>Title</Dialog.Title>
<Dialog.Description>Description</Dialog.Description>
<Dialog.Close>Close</Dialog.Close>
</Dialog.Content>
</Dialog.Viewport>
</Dialog.Portal>
</Dialog>
</template>
```

The parts hang off the root β€” `<Dialog.Trigger>` resolves through
`<script setup>`'s dot-notation component access, so one import brings the
whole compound.

Vue-specific notes on top of the core contract:

- **Callbacks are emits.** The core's `onOpenChange` is the `update:open`
emit, so `v-model:open` is the controlled contract's native shape: the
dialog follows the `open` prop alone and reports real transitions through
`update:open`. The dismissal callbacks are emits too (`@escape-key-down`,
`@interact-outside`, `@back-navigation`); their payloads carry
`preventDefault()`, and listeners run synchronously, so the veto contract
holds unchanged. Per the controlled contract, a dismissal on a controlled
dialog emits no `update:open` β€” decide it at its source, in the dismissal
emit.
- **`Portal`** teleports the layers to `document.body`, or to a `container`
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. 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 the native `<dialog>` element, always with the `open`
attribute since it only mounts while the dialog is open. It is shown without
`showModal()` on purpose: modality, dismissal, and focus stay driven by the
core contract, consistent across browsers, instead of splitting authority
with the browser's built-in dialog behavior.
- **`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'
`data-state="closing"` β€” a CSS transition or animation on **Content** (the
element carrying the state, not a descendant) is what signals completion;
a missing exit style falls back to a short ceiling, and
`prefers-reduced-motion` skips the wait entirely. The exit is cosmetic:
focus, the dialog stack, and page interaction release the moment closing
starts, and the still-painting layer is made `inert` until it unmounts.
Enter needs no state β€” the parts mount straight into `data-state="open"`,
so a CSS animation (or a transition via `@starting-style`) plays from
mount.
- **Back navigation** (`closeOnBack`): opening plants a guard entry in the
session history, so the browser's Back closes the dialog instead of leaving
the page β€” one layer per press in a nested stack, per the core contract. A
dialog closed any other way consumes its entry, leaving nothing to swallow
a later Back; an entry buried under in-app navigation while the dialog is
open is left alone (Back then both navigates and closes the dialog).
- Everything ships headless, per the core contract's
[Internals](../../core/dialog/SPEC.md#internals).

## API

### `Dialog`

The root: owns open/close state, renders no DOM. Accepts the core
`DialogOptions` as props; the core callbacks are emits.

| Prop | Type | Default | Description |
| ------------------------ | --------------------------- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `open` | `boolean` | β€” | Controlled open state (`v-model:open`) β€” the dialog follows it alone. Back to `undefined` hands the state over, uncontrolled in place. |
| `defaultOpen` | `boolean` | `false` | Initial open state for the uncontrolled dialog. |
| `modal` | `boolean` | `true` | `aria-modal`, focus trap, scroll lock, backdrop. |
| `role` | `'dialog' \| 'alertdialog'` | `'dialog'` | The ARIA pattern. |
| `closeOnEscape` | `boolean` | `true` | Whether Escape closes the dialog. |
| `escapeScope` | `'layer' \| 'stack'` | `'layer'` | How far an allowed Escape reaches: this dialog, or its whole stack. |
| `closeOnInteractOutside` | `boolean` | `true` β€” `false` for `role="alertdialog"` | Whether pressing the backdrop/viewport closes the dialog. |
| `animated` | `boolean` | `false` | Keeps the dialog mounted through `data-state="closing"` while its exit animation plays. |
| `closeOnBack` | `boolean` | `false` | The browser's Back closes the open dialog instead of navigating (a guard entry in the session history). |
| `id` | `string` | auto (`useId`) | Base id for the parts; per-part ids are derived from it. |

| Emit | Payload | Description |
| ----------------- | ------------------------ | ------------------------------------------------------------------------- |
| `update:open` | `open: boolean` | Fired on every open/close transition with the new value (`v-model:open`). |
| `escapeKeyDown` | `event: KeyboardEvent` | Fired before an Escape dismissal; `preventDefault()` vetoes. |
| `interactOutside` | `event?: PointerPayload` | Fired before an outside-press dismissal; `preventDefault()` vetoes. |
| `backNavigation` | `event?` | Fired before a back-navigation dismissal; `preventDefault()` vetoes. |

### `Dialog.Trigger`

Opens the dialog; focus returns here on close.

| Prop | Type | Default | Description |
| ---------- | --------------------- | ------- | ------------------------------------- |
| `...attrs` | `<button>` attributes | β€” | Forwarded to the rendered `<button>`. |

### `Dialog.Portal`

Teleports the layers out of the tree while open; unmounts them while closed.

| Prop | Type | Default | Description |
| ----------- | --------------------- | --------------- | --------------------------- |
| `container` | `HTMLElement \| null` | `document.body` | The element to portal into. |

### `Dialog.Backdrop`

The layer behind the dialog window; renders nothing when `:modal="false"`.

| Prop | Type | Default | Description |
| ---------- | ------------------ | ------- | ---------------------------------- |
| `...attrs` | `<div>` attributes | β€” | Forwarded to the rendered `<div>`. |

### `Dialog.Viewport`

The positioning + scroll layer around the dialog window.

| Prop | Type | Default | Description |
| ---------- | ------------------ | ------- | ---------------------------------- |
| `...attrs` | `<div>` attributes | β€” | Forwarded to the rendered `<div>`. |

### `Dialog.Content`

The dialog window; renders the native `<dialog>`.

| Prop | Type | Default | Description |
| -------------- | --------------------------------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------- |
| `initialFocus` | `MaybeRefOrGetter<HTMLElement \| null>` | the dialog window | The element to focus when the dialog opens. A ref or getter is read at open time, so late-filling template refs work. |
| `...attrs` | `<dialog>` attributes | β€” | Forwarded to the rendered `<dialog>`. |

### `Dialog.Title`

Names the dialog (wires `aria-labelledby` on Content).

| Prop | Type | Default | Description |
| ---------- | ----------------- | ------- | --------------------------------- |
| `...attrs` | `<h2>` attributes | β€” | Forwarded to the rendered `<h2>`. |

### `Dialog.Description`

Describes the dialog (wires `aria-describedby` on Content).

| Prop | Type | Default | Description |
| ---------- | ------------------ | ------- | ---------------------------------- |
| `...attrs` | `<div>` attributes | β€” | Forwarded to the rendered `<div>`. |

### `Dialog.Close`

Dismisses the dialog from inside β€” the single dismissal affordance (the
corner `Γ—`), rendered once per dialog and kept the focus cycle's last stop per
the core contract. Action buttons (Cancel/Confirm) are your own `<button>`s
driving state, so they keep their natural Tab order.

| Prop | Type | Default | Description |
| ---------- | --------------------- | --------- | -------------------------------------------------------------- |
| `scope` | `'layer' \| 'stack'` | `'layer'` | Dismiss just its own dialog, or unwind the whole nested stack. |
| `...attrs` | `<button>` attributes | β€” | Forwarded to the rendered `<button>`. |
53 changes: 53 additions & 0 deletions packages/vue/dialog/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
{
"name": "@dunky.dev/vue-dialog",
"version": "0.0.0",
"description": "Vue binding for @dunky.dev/dialog.",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/dunky-dev/ui.git",
"directory": "packages/vue/dialog"
},
"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/dialog": "workspace:*",
"@dunky.dev/dom-navigation": "workspace:*",
"@dunky.dev/dom-overlay": "workspace:*",
"@dunky.dev/vue-state-machine": "^0.4.0",
"@dunky.dev/vue-use-focus-trap": "workspace:*",
"@dunky.dev/vue-use-scroll-lock": "workspace:*"
},
"devDependencies": {
"@testing-library/vue": "^8.1.0",
"vue": "^3.5.41"
},
"peerDependencies": {
"vue": "*"
}
}
Loading
Loading