diff --git a/.changeset/svelte-dialog.md b/.changeset/svelte-dialog.md new file mode 100644 index 0000000..c3f0eac --- /dev/null +++ b/.changeset/svelte-dialog.md @@ -0,0 +1,51 @@ +--- +'@dunky.dev/svelte-dialog': minor +--- + +New substrate: the Svelte binding for `@dunky.dev/dialog`, for Svelte 5. The +same compound anatomy and behavior contract as the React and Solid bindings — +one core machine, a new host — in Svelte's native shape: `` with its +parts hanging off it (``, ``, +``, ...), children as snippets, the core options as plain +props, and `onOpenChange` as a callback prop. + +The peer range, `svelte` `>=5.20.0 <5.33.5 || ^5.34.5`, is measured — the +suite runs green on its edges: 5.20 is where `$props.id()` arrived (the +SSR-stable base id), and 5.33.5–5.34.4 kept a spread's stale event handlers +(sveltejs/svelte#16180), which every part's attribute spread would hit. + +```svelte + + + (open = next)} onEscapeKeyDown={() => (open = false)}> + (open = true)}>Open + + + + + Title + Description + (open = false)}>Close + + + + +``` + +The components ship uncompiled — `.svelte` files under the `svelte` export +condition — so your own Svelte compiles them, for the client and for SSR, +instead of a build tied to this repo's Svelte internals. `open` is +deliberately not bindable: a component can't tell `bind:open` from a passed +prop, and backing it would let an unbound controlled dialog move on its own. +Hold the state as above and decide dismissals at their source, per the core +contract. Every element part takes `bind:ref`; `Content`'s `initialFocus` and +`restoreFocus` take an element or a getter (`initialFocus={() => cancelButton}`), +since `bind:this` fills after a component initializes. Everything else follows +the core spec: layer stack with assistive-tech containment, focus trap with +Close as the cycle's last stop, scroll lock (scoped to the Portal container +when given), exit animations through `data-state="closing"`, and +`closeOnBack`. diff --git a/.changeset/svelte-hooks.md b/.changeset/svelte-hooks.md new file mode 100644 index 0000000..d128061 --- /dev/null +++ b/.changeset/svelte-hooks.md @@ -0,0 +1,28 @@ +--- +'@dunky.dev/svelte-use-focus-trap': minor +'@dunky.dev/svelte-use-scroll-lock': minor +--- + +New substrate: the Svelte lifecycle wrappers over the framework-free DOM +utils, in the Solid hooks' shape, for Svelte 5 — the substrate's one peer +range, `svelte` `>=5.20.0 <5.33.5 || ^5.34.5`. Both are rune modules: call +them while a component initializes, like `$effect`. +`useFocusTrap(target, options?)` takes a getter for the container — over +`$state`, a new element re-arms the trap — and `useScrollLock(locked?, target?)` +accepts a `MaybeGetter` 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 packages own only the lifecycle, and ship +uncompiled for your Svelte to compile. + +```svelte + + +
...
+``` diff --git a/.gitignore b/.gitignore index 9af0849..3003676 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ dist .vite storybook-static *.tsbuildinfo +# @sveltejs/package's scratch copy (it stages the build there before dist) +.svelte-kit # generated by the on-device Storybook's metro wrapper (see .rnstorybook/index.ts) storybook.requires.ts diff --git a/.lintstagedrc.ts b/.lintstagedrc.ts index 59e0b21..a7409b8 100644 --- a/.lintstagedrc.ts +++ b/.lintstagedrc.ts @@ -8,12 +8,20 @@ const IGNORED = '/scripts/templates/' const quote = (paths: string[]): string => paths.map(path => JSON.stringify(path)).join(' ') +const checkable = (files: string[]): string[] => files.filter(file => !file.includes(IGNORED)) + const config: Configuration = { '*.{ts,tsx}': files => { - const checkable = files.filter(file => !file.includes(IGNORED)) - if (checkable.length === 0) return [] - const targets = quote(checkable) - return [`oxlint --fix ${targets}`, `oxfmt ${targets}`] + const targets = checkable(files) + if (targets.length === 0) return [] + return [`oxlint --fix ${quote(targets)}`, `oxfmt ${quote(targets)}`] + }, + // oxlint reads a component's + + + Delete... + + + + + Delete file? + This cannot be undone. + + Cancel + + + + +``` diff --git a/packages/svelte/dialog/SPEC.md b/packages/svelte/dialog/SPEC.md new file mode 100644 index 0000000..d3818ac --- /dev/null +++ b/packages/svelte/dialog/SPEC.md @@ -0,0 +1,258 @@ +# SPEC / Svelte / Dialog + +The Svelte 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/svelte-dialog +``` + +Peer: `svelte` `>=5.20.0 <5.33.5 || ^5.34.5` — see [Svelte versions](#svelte-versions). + +## Usage + +```svelte + + + + Open + + + + + Title + Description + Close + + + + +``` + +Svelte-specific notes on top of the core contract: + +- **Packaging**: the package ships its components uncompiled — `.svelte` + files under the `svelte` export condition, beside plain `.js` modules — + for your own Svelte compiler to compile, client and server alike (the + hooks it depends on ship `.svelte.js` rune modules the same way). Svelte's + compiled output targets its internal runtime, which is not a stable API + across versions, so a precompiled build would pin you to one. Any + Svelte-aware bundler setup resolves it (Vite with + `@sveltejs/vite-plugin-svelte`, SvelteKit). +- **`Portal`** teleports the layers to `document.body`, or to a `container` + you supply. Svelte has no portal of its own, so the layers `mount()` as a + separate tree in the target, carrying the surrounding contexts with them — + every part below still finds its dialog (and any context of yours). Nothing + renders there on the server. 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). + Swapping `container` while the dialog is open re-mounts the layers on the + new target. +- **`Content`** renders a `
` carrying the `dialog` (or `alertdialog`) + role, not the native `` element. The dialog window is the initial + focus target — focusable in script, out of the tab order — which needs + `tabindex="-1"`, and HTML states that + [the `tabindex` attribute must not be specified on `dialog` elements](https://html.spec.whatwg.org/multipage/interactive-elements.html#the-dialog-element). + The native element would only pay off through `showModal()`, and this + contract deliberately keeps modality, dismissal, and focus with the core + machine rather than splitting authority with the browser's built-in behavior + (see the core spec's Internals). With the role explicit and the element + neutral, there is nothing left to gain and one conformance rule left to + break. +- **`Content`'s `initialFocus`** accepts an element or a getter resolved at + open time. `bind:this` fills only after the component that declares it has + initialized, so pass a getter — `initialFocus={() => cancelButton}` — or a + `$state` element. `restoreFocus` takes the same shape (exported as + `DialogFocusTarget`), resolved at close time. +- **Element access**: every part that renders an element takes a bindable + `ref` — ``. `bind:this` on a component + yields the component, not its element. +- **Controlled open**: `open` and the `onOpenChange` callback prop, as in the + core contract. `open` is deliberately not `$bindable()`, so `bind:open` is + rejected instead of quietly misbehaving. A component can't tell a bound prop + from a passed one, and backing `bind:open` would mean the dialog writes + `open` itself whenever its state should move. Unbound, Svelte keeps that + write as a local override: a controlled dialog would move on its own, which + the core contract forbids, and the first write to an uncontrolled dialog's + `open` would make it controlled. Hold the state yourself — + `open={isOpen} onOpenChange={next => (isOpen = next)}` — and decide + dismissals at their source, in the dismissal callbacks. +- **Ids**: the base id is the root's `$props.id()` — unique per instance, and + stable from the server render through hydration. An explicit `id` wins; an + explicit `id={undefined}` keeps the generated one. +- **`Backdrop`** renders nothing when the dialog is non-modal (`modal={false}`), + per the core parts contract. Its place among the Portal's parts is free: + Svelte binds elements after mount, in tree order, so `Content` registers the + layer only once the Portal's whole tree is bound. One limit remains: a + `Backdrop` in a Portal of its own, rendered after the `Content`'s Portal, + mounts as a separate tree and binds too late — it is contained (made inert) + with the rest of the page. Keep the backdrop in the same Portal. +- **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. Svelte's own `transition:` / `in:` / `out:` don't run on portalled + parts: the layers mount and unmount as a tree of their own, where an outro + can't hold the unmount, so intros are off too and enter and exit stay + symmetric — animate both through `data-state`. +- **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). + The entry a Back press spends survives in the forward stack, so the + browser's Forward reopens the dialog it closed (`onForwardNavigation` + fires first; `preventDefault()` vetoes, per the core contract). Reopening + through the trigger instead plants a fresh entry — the browser truncates + the spent one, exactly like navigating after a Back. Two web-mechanics + caveat: a controlled dialog's Back-close is completed by the consumer rather + than by the press itself, so its entry is consumed and Forward has nothing to + re-enter. A nested dialog unmounted along with the parent it was opened from + does come back, and so does one whose page reloaded in between — the entry + remembers the dialog's place in the stack, not the instance that planted it. +- Everything ships headless, per the core contract's + [Internals](../../core/dialog/SPEC.md#internals). + +## Svelte versions + +`svelte` `>=5.20.0 <5.33.5 || ^5.34.5` — one range for the dialog and its +hooks, and measured: the suites run green on its edges (5.20.0, 5.33.4, +5.34.5), on 5.40.0 and 5.56.10, and on 5.57.1. + +- **5.20** is where `$props.id()` arrived — the root's SSR-stable base id. + 5.19 fails to compile the root. +- **5.33.5–5.34.4** kept a spread's first non-delegated event handler + ([sveltejs/svelte#16180](https://github.com/sveltejs/svelte/pull/16180)). + Every part spreads your props with its bindings, so a re-rendered `onfocus` + would call a stale closure — the suite catches it on 5.34.0. The adapter, + `@dunky.dev/svelte-state-machine`, excludes the same releases. + +## API + +### `Dialog` + +The root: owns open/close state, renders no DOM. Accepts the core +`DialogOptions`. + +| Prop | Type | Default | Description | +| ------------------------ | --------------------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `open` | `boolean` | — | Controlled open state — the dialog follows it alone. Back to `undefined` hands the state over, uncontrolled in place. Not bindable. | +| `defaultOpen` | `boolean` | `false` | Initial open state for the uncontrolled dialog. | +| `onOpenChange` | `(open: boolean) => void` | — | Fired on every open/close transition with the new value. | +| `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), and Forward reopens what Back closed. | +| `onBackNavigation` | `(event?) => void` | — | Fired before a back-navigation dismissal; `preventDefault()` vetoes. | +| `onForwardNavigation` | `(event?) => void` | — | Fired before a forward-navigation reopen; `preventDefault()` vetoes. | +| `onEscapeKeyDown` | `(event) => void` | — | Fired before an Escape dismissal; `preventDefault()` vetoes. | +| `onInteractOutside` | `(event?) => void` | — | Fired before an outside-press dismissal; `preventDefault()` vetoes. | +| `id` | `string` | auto (`$props.id()`) | Base id for the parts; per-part ids are derived from it. | +| `children` | `Snippet` | — | The dialog's parts. | + +### `Dialog.Trigger` + +Toggles the dialog; focus returns here on close. + +| Prop | Type | Default | Description | +| ---------- | --------------------------- | ------- | ------------------------------------- | +| `ref` | `HTMLButtonElement \| null` | — | Bindable: the rendered ` diff --git a/packages/svelte/dialog/src/dialog-content.svelte b/packages/svelte/dialog/src/dialog-content.svelte new file mode 100644 index 0000000..3b27c5a --- /dev/null +++ b/packages/svelte/dialog/src/dialog-content.svelte @@ -0,0 +1,82 @@ + + + +
ref, bindElement} {...attrs}>{@render children?.()}
diff --git a/packages/svelte/dialog/src/dialog-description.svelte b/packages/svelte/dialog/src/dialog-description.svelte new file mode 100644 index 0000000..1ebe73f --- /dev/null +++ b/packages/svelte/dialog/src/dialog-description.svelte @@ -0,0 +1,18 @@ + + +
{@render children?.()}
diff --git a/packages/svelte/dialog/src/dialog-layer.svelte b/packages/svelte/dialog/src/dialog-layer.svelte new file mode 100644 index 0000000..127b3d7 --- /dev/null +++ b/packages/svelte/dialog/src/dialog-layer.svelte @@ -0,0 +1,33 @@ + + +{@render children?.()} diff --git a/packages/svelte/dialog/src/dialog-portal.svelte b/packages/svelte/dialog/src/dialog-portal.svelte new file mode 100644 index 0000000..edbb1b2 --- /dev/null +++ b/packages/svelte/dialog/src/dialog-portal.svelte @@ -0,0 +1,43 @@ + diff --git a/packages/svelte/dialog/src/dialog-title.svelte b/packages/svelte/dialog/src/dialog-title.svelte new file mode 100644 index 0000000..0d5406a --- /dev/null +++ b/packages/svelte/dialog/src/dialog-title.svelte @@ -0,0 +1,20 @@ + + +

{@render children?.()}

diff --git a/packages/svelte/dialog/src/dialog-trigger.svelte b/packages/svelte/dialog/src/dialog-trigger.svelte new file mode 100644 index 0000000..4ef8692 --- /dev/null +++ b/packages/svelte/dialog/src/dialog-trigger.svelte @@ -0,0 +1,15 @@ + + + diff --git a/packages/svelte/dialog/src/dialog-viewport.svelte b/packages/svelte/dialog/src/dialog-viewport.svelte new file mode 100644 index 0000000..294de0b --- /dev/null +++ b/packages/svelte/dialog/src/dialog-viewport.svelte @@ -0,0 +1,24 @@ + + +
{@render children?.()}
diff --git a/packages/svelte/dialog/src/dialog.svelte b/packages/svelte/dialog/src/dialog.svelte new file mode 100644 index 0000000..48cdd87 --- /dev/null +++ b/packages/svelte/dialog/src/dialog.svelte @@ -0,0 +1,54 @@ + + +{@render children?.()} diff --git a/packages/svelte/dialog/src/index.ts b/packages/svelte/dialog/src/index.ts new file mode 100644 index 0000000..f4ee0ab --- /dev/null +++ b/packages/svelte/dialog/src/index.ts @@ -0,0 +1,50 @@ +import type { Component } from 'svelte' +import Root from './dialog.svelte' +import Backdrop from './dialog-backdrop.svelte' +import Close from './dialog-close.svelte' +import Content from './dialog-content.svelte' +import Description from './dialog-description.svelte' +import Portal from './dialog-portal.svelte' +import Title from './dialog-title.svelte' +import Trigger from './dialog-trigger.svelte' +import Viewport from './dialog-viewport.svelte' +import type { DialogProps } from './types.js' + +interface Parts { + Trigger: typeof Trigger + Portal: typeof Portal + Backdrop: typeof Backdrop + Viewport: typeof Viewport + Content: typeof Content + Title: typeof Title + Description: typeof Description + Close: typeof Close +} + +// One component per file is Svelte's unit, so the compound is assembled here: +// the parts hang off the root as statics, reached as ``. The +// root binds nothing (`''`): `open` is deliberately not bindable (SPEC.md). +export const Dialog: Component & Parts = Object.assign(Root, { + Trigger, + Portal, + Backdrop, + Viewport, + Content, + Title, + Description, + Close, +}) + +export type { + DialogFocusTarget, + DialogProps, + DialogTriggerProps, + DialogPortalProps, + DialogBackdropProps, + DialogViewportProps, + DialogContentProps, + DialogTitleProps, + DialogDescriptionProps, + DialogCloseProps, +} from './types.js' +export type { DialogCallbacks, DialogOptions, DialogRole } from '@dunky.dev/dialog' diff --git a/packages/svelte/dialog/src/types.ts b/packages/svelte/dialog/src/types.ts new file mode 100644 index 0000000..5f96141 --- /dev/null +++ b/packages/svelte/dialog/src/types.ts @@ -0,0 +1,61 @@ +import type { Snippet } from 'svelte' +import type { HTMLAttributes, HTMLButtonAttributes } from 'svelte/elements' +import type { DialogOptions } from '@dunky.dev/dialog' + +export interface DialogProps extends DialogOptions { + /** The dialog's parts. */ + children?: Snippet +} + +export interface DialogTriggerProps extends HTMLButtonAttributes { + /** The rendered ` + +
+
+ +
+
diff --git a/packages/svelte/dialog/stories/close-button.svelte b/packages/svelte/dialog/stories/close-button.svelte new file mode 100644 index 0000000..f336c0c --- /dev/null +++ b/packages/svelte/dialog/stories/close-button.svelte @@ -0,0 +1,6 @@ + + +× diff --git a/packages/svelte/dialog/stories/contained-page.svelte b/packages/svelte/dialog/stories/contained-page.svelte new file mode 100644 index 0000000..365d482 --- /dev/null +++ b/packages/svelte/dialog/stories/contained-page.svelte @@ -0,0 +1,68 @@ + + + +{@html ''} +
+ Page content at the canvas root — a body-level cousin of the dialog's portal. + +
+
+
+ The app branch: the panel portals in here, right beside this article. + +
+
+ + Open dialog + + + + + + Containment + + Everything dimmed is aria-hidden and inert: Tab never reaches it, presses fall flat, + screen readers see only this window. Open the panel — it lands inside the app branch, + and the article beside it stays contained. + + {#if branch} + + Open panel in the app branch + + + + A non-modal layer above the dialog, held out of the containment while its + neighbor article stays in it. Escape closes this layer first. + + + + + {/if} + + + + diff --git a/packages/svelte/dialog/stories/controlled-dialog.svelte b/packages/svelte/dialog/stories/controlled-dialog.svelte new file mode 100644 index 0000000..6f765d5 --- /dev/null +++ b/packages/svelte/dialog/stories/controlled-dialog.svelte @@ -0,0 +1,26 @@ + + + + (open = next)} onInteractOutside={() => (open = false)}> + + + + + Controlled + + The consumer owns `open`; dismissals are decided at their source. + +
+ +
+
+
+
+
diff --git a/packages/svelte/dialog/stories/dialog.stories.svelte b/packages/svelte/dialog/stories/dialog.stories.svelte new file mode 100644 index 0000000..fb342ec --- /dev/null +++ b/packages/svelte/dialog/stories/dialog.stories.svelte @@ -0,0 +1,251 @@ + + +{#snippet historyButtons()} +
+ + +
+{/snippet} + + + + Open dialog + + + + + + Rename board + + The new name is visible to everyone with access to this board. The corner button, + Escape, and an outside press all dismiss. + + + + + + + + + + + + + + Open terms + + + + + + Terms of service + + Content taller than the screen scrolls within the viewport layer. + + {#each { length: 20 }, index} +

+ {index + 1}. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod + tempor incididunt ut labore et dolore magna aliqua. +

+ {/each} +
+
+
+
+
+ + + + Sign in + + + + + + Sign in + + Focus moves to the first field on open, and stays trapped inside while the dialog is + open. + +
event.preventDefault()}> + + +
+ +
+
+
+
+
+
+
+ + + + Open dialog + + + + + + Closed by default + Only the trigger renders until it is pressed. + + + + + + + + + + + + + + + + + + + + + Open dialog + + + + + + Board settings + + Open the listbox, then press Tab and Escape: the popup answers first, the dialog only + once it is closed. + + +
+ +
+
+
+
+
+
+ + + + + Open dialog + + + + + + Rename board + + The browser's Back closes this dialog instead of navigating away. Press Back — or the + button below, which stands in for it here — and the dialog dismisses while the page + stays put. Forward, from the canvas, reopens it. + +
+ +
+
+
+
+
+ +
+ + + + + Open outer + + + + + + Outer dialog + + Two guard entries while both layers are open. Back closes the topmost one first. + + + Open inner + + + + + + Inner dialog + + Back closes this layer and leaves the outer alone; Forward brings it back, + guarded again. + + {@render historyButtons()} + + + + + {@render historyButtons()} + + + + + + + + + + + diff --git a/packages/svelte/dialog/stories/listbox.svelte b/packages/svelte/dialog/stories/listbox.svelte new file mode 100644 index 0000000..a3c957f --- /dev/null +++ b/packages/svelte/dialog/stories/listbox.svelte @@ -0,0 +1,69 @@ + + +
+ + {#if open} + + {/if} +
diff --git a/packages/svelte/dialog/stories/nested-dialogs.svelte b/packages/svelte/dialog/stories/nested-dialogs.svelte new file mode 100644 index 0000000..15f4f1d --- /dev/null +++ b/packages/svelte/dialog/stories/nested-dialogs.svelte @@ -0,0 +1,94 @@ + + + (outerOpen = next)} + onEscapeKeyDown={() => (outerOpen = false)} + onInteractOutside={() => (outerOpen = false)} +> + (outerOpen = true)}>Open outer + + + + + Outer dialog + + Escape and outside presses dismiss the topmost dialog only — the stack unwinds one + layer at a time. + + (innerOpen = next)} + onEscapeKeyDown={() => (innerOpen = false)} + onInteractOutside={() => (innerOpen = false)} + > + (innerOpen = true)}>Open inner + + + + + Inner dialog + + While open, everything beneath — including the outer dialog — is inert and + hidden from assistive tech. + + (innermostOpen = next)} + onEscapeKeyDown={() => (innermostOpen = false)} + onInteractOutside={() => (innermostOpen = false)} + > + (innermostOpen = true)}> + Open innermost + + + + + + Innermost dialog + + Three layers deep. Escape and Close dismiss this layer only; Close all + unwinds the whole stack at once. + +
+ + +
+
+
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
diff --git a/packages/svelte/dialog/stories/scoped-dialog.svelte b/packages/svelte/dialog/stories/scoped-dialog.svelte new file mode 100644 index 0000000..4662a40 --- /dev/null +++ b/packages/svelte/dialog/stories/scoped-dialog.svelte @@ -0,0 +1,44 @@ + + +
+
+ {#each { length: 12 }, index} +

+ {index + 1}. Background content scrolls inside the panel; the trigger sits at the end. +

+ {/each} + {#if boundary} + + Open in panel + + + + + + Scoped dialog + + Portaled into the panel boundary; the backdrop and viewport are `absolute`, so the + overlay fills the panel's visible box and stays put while the background scrolls + behind it. + + + + + + {/if} +
+
diff --git a/packages/svelte/dialog/stories/styles.ts b/packages/svelte/dialog/stories/styles.ts new file mode 100644 index 0000000..cc3d7e4 --- /dev/null +++ b/packages/svelte/dialog/stories/styles.ts @@ -0,0 +1,60 @@ +// The primitive ships headless — the story is the consumer, so it brings the +// styles. `data-state` on every part is the real styling hook. Svelte styles +// are strings; later declarations win, which the variants below lean on. +export const backdrop = 'position: fixed; inset: 0; background: rgba(0, 0, 0, 0.4);' +export const viewport = 'position: fixed; inset: 0; display: flex; overflow: auto; padding: 24px;' +// `margin: auto` inside the viewport's flex box does the centering; +// `relative` makes the corner Close button pin to the window, not the page. +export const content = ` + position: relative; margin: auto; max-width: 480px; padding: 24px; background: white; + border-radius: 8px; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.24); +` +export const actions = 'display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px;' +export const closeIcon = ` + position: absolute; top: 12px; inset-inline-end: 12px; width: 28px; height: 28px; + display: inline-flex; align-items: center; justify-content: center; border: none; + border-radius: 6px; background: transparent; cursor: pointer; font-size: 18px; line-height: 1; +` +export const field = 'display: flex; flex-direction: column; gap: 4px; margin-top: 12px;' +export const input = 'padding: 8px 10px; border: 1px solid #ccc; border-radius: 6px; font: inherit;' + +// A scoped dialog opens inside a container instead of over the whole page: it +// portals into that element, and its overlay layers switch from `fixed` +// (viewport-pinned) to `absolute` (container-pinned). +// +// CSS constraint: an `absolute` overlay can't stay fixed inside a *scrolling* +// element — it's positioned against the scroll origin and scrolls away. So the +// scrollable background goes in an inner scroller, wrapped by a NON-scrolling +// positioned boundary; the overlay pins to the boundary's visible box and the +// backdrop (a sibling on top of the scroller) blocks scrolling behind it. +export const scopedBoundary = ` + position: relative; height: 320px; overflow: hidden; border: 1px solid #ccc; + border-radius: 8px; +` +export const scopedScroller = 'height: 100%; overflow: auto; padding: 16px; box-sizing: border-box;' +export const scopedBackdrop = `${backdrop} position: absolute;` +export const scopedViewport = `${viewport} position: absolute;` + +// Dialog.Close is the dialog's single dismissal affordance — the corner `×`, +// kept the focus cycle's last stop by the core contract. Buttons that act +// (Cancel / Confirm / Delete) are the consumer's own, driving the dialog +// through state — see the alertDialog story. +export const closableContent = `${content} position: relative;` + +export const listbox = ` + position: absolute; top: 100%; left: 0; min-width: 220px; margin: 4px 0 0; padding: 4px; + list-style: none; background: white; border: 1px solid #ccc; border-radius: 6px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.16); +` +export const option = 'padding: 6px 10px; border-radius: 4px; cursor: pointer;' + +// The containment story's app branch: the panel's absolute viewport pins to it. +export const appBranch = ` + position: relative; margin-top: 16px; padding: 16px; border: 1px dashed #999; + border-radius: 8px; +` +export const branchViewport = 'position: absolute; inset: 0; display: flex; padding: 16px;' +export const branchPanel = ` + margin: auto; max-width: 320px; padding: 16px; background: white; border: 1px solid #ccc; + border-radius: 8px; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.24); +` diff --git a/packages/svelte/dialog/tests/dialog-svelte.test.ts b/packages/svelte/dialog/tests/dialog-svelte.test.ts new file mode 100644 index 0000000..1c8a885 --- /dev/null +++ b/packages/svelte/dialog/tests/dialog-svelte.test.ts @@ -0,0 +1,210 @@ +// @vitest-environment jsdom +// The corners Svelte's lifecycle adds on top of the shared suite in +// dialog.test.ts: id minting, mount-effect ordering, the open edge, the +// mount()-based portal, and element access. +import { render, screen } from '@testing-library/svelte' +import { flushSync, hydrate, unmount } from 'svelte' +import { describe, expect, inject, it, onTestFinished, vi } from 'vitest' +import BackdropLastDialog from './fixtures/backdrop-last-dialog.svelte' +import DefaultDialog from './fixtures/default-dialog.svelte' +import HandlerDialog from './fixtures/handler-dialog.svelte' +import { press, pressEscape } from './fixtures/interact' +import PresenceDialog from './fixtures/presence-dialog.svelte' +import RefDialog from './fixtures/ref-dialog.svelte' +import ScopedDialog from './fixtures/scoped-dialog.svelte' +import TransitionDialog from './fixtures/transition-dialog.svelte' +import TypedButtonsDialog from './fixtures/typed-buttons-dialog.svelte' + +describe('Dialog (Svelte)', () => { + describe('ids', () => { + it('names every part after an explicit id', () => { + render(DefaultDialog, { defaultOpen: true, id: 'settings' }) + expect(screen.getByRole('dialog').id).toBe('settings-content') + expect(screen.getByText('Trigger').getAttribute('aria-controls')).toBe('settings-content') + }) + + // `$props.id()` is the fallback; spreading props must not let an explicit + // `undefined` replace it — ids also key the dialog stack. + it('keeps a unique generated id when id is explicitly undefined', () => { + render(DefaultDialog, { defaultOpen: true, id: undefined }) + render(DefaultDialog, { defaultOpen: true, id: undefined }) + const [first, second] = screen.getAllByRole('dialog', { hidden: true }) + expect(first?.id).not.toBe(second?.id) + }) + }) + + describe('part presence', () => { + // A part's mount effect runs before its root's, so it reports to a + // machine that has not started yet. + it('labels Content from a Title that mounts before the machine starts', () => { + render(PresenceDialog) + const dialog = screen.getByRole('dialog', { name: 'Title' }) + expect(document.getElementById(dialog.getAttribute('aria-describedby') as string)).toBe( + screen.getByText('Description'), + ) + }) + + it('drops and restores the references as the parts unmount and remount', async () => { + const { rerender } = render(PresenceDialog) + await rerender({ showTitle: false, showDescription: false }) + const dialog = screen.getByRole('dialog', { name: 'Fallback label' }) + expect(dialog.hasAttribute('aria-labelledby')).toBe(false) + expect(dialog.hasAttribute('aria-describedby')).toBe(false) + + await rerender({ showTitle: true, showDescription: true }) + expect(screen.getByRole('dialog', { name: 'Title' }).hasAttribute('aria-describedby')).toBe( + true, + ) + }) + }) + + describe('open edge', () => { + // The machine publishes a fresh api on every change; only an open change + // may re-run the focus sequence. + it('leaves focus alone when the machine changes without an open change', async () => { + const { rerender } = render(PresenceDialog) + const action = screen.getByText('Action') + action.focus() + + await rerender({ showTitle: false }) + expect(document.activeElement).toBe(action) + }) + }) + + describe('exit window', () => { + // jsdom lets focus land inside an `inert` subtree; a browser refuses it. + // So assert the order: the interrupted exit un-hides its layer before + // the open sequence moves focus in. + it('lifts the exit inertness before a reopen moves focus in', () => { + render(DefaultDialog, { animated: true }) + press(screen.getByText('Trigger')) + pressEscape() + + const viewport = screen.getByTestId('viewport') + const inertAtFocus: boolean[] = [] + const focus = HTMLElement.prototype.focus + const spy = vi.spyOn(HTMLElement.prototype, 'focus').mockImplementation(function ( + this: HTMLElement, + options?: FocusOptions, + ) { + inertAtFocus.push(viewport.hasAttribute('inert')) + focus.call(this, options) + }) + press(screen.getByText('Trigger')) + spy.mockRestore() + + expect(inertAtFocus).toEqual([false]) + }) + }) + + describe('portal', () => { + // `bind:this` fills in tree order; the layer registers only once its + // whole tree is bound, so render order inside the Portal doesn't matter. + it('keeps a Backdrop rendered after the Viewport pressable', () => { + render(BackdropLastDialog) + const backdrop = screen.getByTestId('backdrop') + expect(backdrop.hasAttribute('inert')).toBe(false) + expect(backdrop.hasAttribute('aria-hidden')).toBe(false) + }) + + // The layers mount and unmount as a tree of their own, where an outro + // can't hold the unmount; intros stay off too, so enter and exit agree — + // both are animated through data-state (`animated` for the exit). + it('runs no Svelte transitions on the portalled layers', () => { + const onrun = vi.fn() + render(TransitionDialog, { onrun }) + press(screen.getByText('Trigger')) + expect(screen.queryByRole('dialog')).not.toBeNull() + + pressEscape() + expect(screen.queryByRole('dialog')).toBeNull() + expect(onrun).not.toHaveBeenCalled() + }) + + it('re-mounts the layers in a new container while open', async () => { + const first = document.createElement('div') + const second = document.createElement('div') + document.body.append(first, second) + + const { rerender } = render(ScopedDialog, { container: first }) + await rerender({ container: second }) + + expect(second.contains(screen.getByRole('dialog'))).toBe(true) + expect(first.childElementCount).toBe(0) + expect(first.style.overflowY).not.toBe('hidden') + expect(second.style.overflowY).toBe('hidden') + + pressEscape() + first.remove() + second.remove() + }) + }) + + describe('consumer props', () => { + // Every part spreads your props with its bindings; a handler swapped on + // re-render must reach the element, not stay the first one. + it('forwards the latest non-delegated handler after a re-render', async () => { + const first = vi.fn() + const second = vi.fn() + const { rerender } = render(HandlerDialog, { onfocus: first }) + await rerender({ onfocus: second }) + + screen.getByText('Trigger').focus() + expect(first).not.toHaveBeenCalled() + expect(second).toHaveBeenCalledTimes(1) + }) + + // A `type` the consumer leaves undefined must not erase the default — a + // typeless button inside a form submits it. + it('keeps Trigger and Close type="button" unless given a type', async () => { + const { rerender } = render(TypedButtonsDialog, { type: undefined }) + expect(screen.getByText('Trigger').getAttribute('type')).toBe('button') + expect(screen.getByText('Close').getAttribute('type')).toBe('button') + + await rerender({ type: 'submit' }) + expect(screen.getByText('Trigger').getAttribute('type')).toBe('submit') + expect(screen.getByText('Close').getAttribute('type')).toBe('submit') + }) + }) + + describe('hydration', () => { + // The server render's ids come from `$props.id()`; hydration must adopt + // them, or the trigger's aria-controls would dangle. + it('hydrates the server render onto the same ids', () => { + const warn = vi.spyOn(console, 'warn') + const target = document.createElement('div') + target.innerHTML = inject('defaultOpenServerMarkup') + document.body.append(target) + const serverTrigger = target.querySelector('button') + const serverControls = serverTrigger?.getAttribute('aria-controls') + + const app = hydrate(DefaultDialog, { target, props: { defaultOpen: true } }) + onTestFinished(() => { + void unmount(app) + target.remove() + }) + flushSync() + const trigger = screen.getByText('Trigger') + expect(trigger).toBe(serverTrigger) + expect(screen.getByRole('dialog').id).toBe(serverControls) + expect(trigger.getAttribute('aria-controls')).toBe(serverControls) + expect(warn).not.toHaveBeenCalled() + }) + }) + + describe('element access', () => { + it('binds each part element through bind:ref', () => { + const onrefs = vi.fn() + render(RefDialog, { onrefs }) + const refs = onrefs.mock.lastCall?.[0] as Record + + expect(refs.trigger).toBe(screen.getByText('Trigger')) + expect(refs.content).toBe(screen.getByRole('dialog')) + expect(refs.backdrop).toBe(screen.getByTestId('backdrop')) + expect(refs.viewport).toBe(screen.getByTestId('viewport')) + expect(refs.title).toBe(screen.getByText('Title')) + expect(refs.description).toBe(screen.getByText('Description')) + expect(refs.close).toBe(screen.getByText('Close')) + }) + }) +}) diff --git a/packages/svelte/dialog/tests/dialog.ssr.test.ts b/packages/svelte/dialog/tests/dialog.ssr.test.ts new file mode 100644 index 0000000..a3320a2 --- /dev/null +++ b/packages/svelte/dialog/tests/dialog.ssr.test.ts @@ -0,0 +1,23 @@ +// The server half: `svelte/server` renders with no document, and effects never +// run — so no portal, no listeners, nothing but the markup. +import { render } from 'svelte/server' +import { describe, expect, it } from 'vitest' +import DefaultDialog from './fixtures/default-dialog.svelte' + +describe('Dialog on the server', () => { + it('renders a closed dialog as its trigger alone', () => { + const { body } = render(DefaultDialog) + expect(body).toContain('aria-expanded="false"') + expect(body).not.toContain('role="dialog"') + }) + + // The layers wait for the client, where the Portal has a document to mount + // into; the trigger already names them by an id hydration will reuse. + it('renders an open dialog with ids stable across renders', () => { + const render$ = (): string => render(DefaultDialog, { props: { defaultOpen: true } }).body + const body = render$() + expect(body).toMatch(/aria-controls="[^"]+-content"/) + expect(body).not.toContain('role="dialog"') + expect(render$()).toBe(body) + }) +}) diff --git a/packages/svelte/dialog/tests/dialog.test.ts b/packages/svelte/dialog/tests/dialog.test.ts new file mode 100644 index 0000000..e3012da --- /dev/null +++ b/packages/svelte/dialog/tests/dialog.test.ts @@ -0,0 +1,634 @@ +// @vitest-environment jsdom +// The Svelte edge of the Dialog — behavior only; the machine's own contract is +// covered in @dunky.dev/dialog's tests. +import { cleanup, render, screen } from '@testing-library/svelte' +import { flushSync } from 'svelte' +import { describe, expect, it, vi } from 'vitest' +import CloseFirstDialog from './fixtures/close-first-dialog.svelte' +import ControlledStack from './fixtures/controlled-stack.svelte' +import DefaultDialog from './fixtures/default-dialog.svelte' +import FormFieldDialog from './fixtures/form-field-dialog.svelte' +import GuardedStack from './fixtures/guarded-stack.svelte' +import InitialFocusDialog from './fixtures/initial-focus-dialog.svelte' +import NestedDialog from './fixtures/nested-dialog.svelte' +import NestedGuards from './fixtures/nested-guards.svelte' +import RestoreFocusDialog from './fixtures/restore-focus-dialog.svelte' +import ScopedDialog from './fixtures/scoped-dialog.svelte' +import StackScopedDialog from './fixtures/stack-scoped-dialog.svelte' +import { keyDown, press, pressEscape } from './fixtures/interact' +import UntitledDialog from './fixtures/untitled-dialog.svelte' + +const openDialog = (): void => { + press(screen.getByText('Trigger')) +} + +describe('Dialog', () => { + describe('open / close', () => { + it('opens on trigger press and closes on close press', () => { + render(DefaultDialog) + expect(screen.queryByRole('dialog')).toBeNull() + + openDialog() + expect(screen.queryByRole('dialog')).not.toBeNull() + + press(screen.getByText('Close')) + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('renders open when defaultOpen', () => { + render(DefaultDialog, { defaultOpen: true }) + expect(screen.queryByRole('dialog')).not.toBeNull() + }) + + it('fires onOpenChange with the new value on open and close', () => { + const onOpenChange = vi.fn() + render(DefaultDialog, { onOpenChange }) + + openDialog() + expect(onOpenChange).toHaveBeenLastCalledWith(true) + + press(screen.getByText('Close')) + expect(onOpenChange).toHaveBeenLastCalledWith(false) + }) + }) + + describe('escape key', () => { + it('closes on Escape', () => { + render(DefaultDialog, { defaultOpen: true }) + pressEscape() + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('stays open when closeOnEscape=false', () => { + render(DefaultDialog, { defaultOpen: true, closeOnEscape: false }) + pressEscape() + expect(screen.queryByRole('dialog')).not.toBeNull() + }) + + it('stays open when onEscapeKeyDown prevents default', () => { + const onEscapeKeyDown = vi.fn(event => event.preventDefault()) + render(DefaultDialog, { defaultOpen: true, onEscapeKeyDown }) + pressEscape() + expect(onEscapeKeyDown).toHaveBeenCalledTimes(1) + expect(screen.queryByRole('dialog')).not.toBeNull() + }) + }) + + describe('outside interaction', () => { + it('closes on backdrop press', () => { + render(DefaultDialog, { defaultOpen: true }) + press(screen.getByTestId('backdrop')) + expect(screen.queryByRole('dialog')).toBeNull() + }) + + // The backdrop is portalled alongside the viewport, outside the content's + // subtree — the containment walk must except it, or `inert` would swallow + // real pointer presses on it (jsdom's .click() bypasses hit-testing, so + // only the attributes can assert this). + it('keeps its own backdrop pressable while the page around it is inert', () => { + const { container } = render(DefaultDialog, { defaultOpen: true }) + expect(container.hasAttribute('inert')).toBe(true) + + const backdrop = screen.getByTestId('backdrop') + expect(backdrop.hasAttribute('aria-hidden')).toBe(false) + expect(backdrop.hasAttribute('inert')).toBe(false) + }) + + it('stays open when closeOnInteractOutside=false', () => { + render(DefaultDialog, { defaultOpen: true, closeOnInteractOutside: false }) + press(screen.getByTestId('backdrop')) + expect(screen.queryByRole('dialog')).not.toBeNull() + }) + + it('stays open when onInteractOutside prevents default', () => { + const onInteractOutside = vi.fn(event => event?.preventDefault()) + render(DefaultDialog, { defaultOpen: true, onInteractOutside }) + press(screen.getByTestId('backdrop')) + expect(onInteractOutside).toHaveBeenCalledTimes(1) + expect(screen.queryByRole('dialog')).not.toBeNull() + }) + + it('alertdialog does not dismiss on backdrop press by default', () => { + render(DefaultDialog, { defaultOpen: true, role: 'alertdialog' }) + press(screen.getByTestId('backdrop')) + expect(screen.queryByRole('alertdialog')).not.toBeNull() + }) + + it('closes on a press on the viewport around the content', () => { + render(DefaultDialog, { defaultOpen: true }) + press(screen.getByTestId('viewport')) + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('does not close when a press inside the content bubbles to the viewport', () => { + render(DefaultDialog, { defaultOpen: true }) + press(screen.getByText('Action')) + expect(screen.queryByRole('dialog')).not.toBeNull() + }) + + it('renders no backdrop when modal=false', () => { + render(DefaultDialog, { defaultOpen: true, modal: false }) + expect(screen.queryByTestId('backdrop')).toBeNull() + }) + }) + + describe('controlled open', () => { + it('follows the open prop in both directions', async () => { + const { rerender } = render(DefaultDialog, { open: false }) + expect(screen.queryByRole('dialog')).toBeNull() + + await rerender({ open: true }) + expect(screen.queryByRole('dialog')).not.toBeNull() + + await rerender({ open: false }) + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('a dismissal neither closes nor fires onOpenChange — nothing changed', () => { + const onOpenChange = vi.fn() + render(DefaultDialog, { open: true, onOpenChange }) + pressEscape() + expect(onOpenChange).not.toHaveBeenCalled() + expect(screen.queryByRole('dialog')).not.toBeNull() + }) + + it('a trigger press neither opens nor fires onOpenChange', () => { + const onOpenChange = vi.fn() + render(DefaultDialog, { open: false, onOpenChange }) + openDialog() + expect(onOpenChange).not.toHaveBeenCalled() + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('reports a prop-driven change through onOpenChange', async () => { + const onOpenChange = vi.fn() + const { rerender } = render(DefaultDialog, { open: false, onOpenChange }) + await rerender({ open: true }) + expect(onOpenChange).toHaveBeenLastCalledWith(true) + expect(onOpenChange).toHaveBeenCalledTimes(1) + }) + + // The controlled contract's consumer side: the dialog never moves on its + // own, so the consumer's own handlers on the parts and the dismissal + // callbacks are what drive the prop. + it('a controlled stack closes through handlers wired at the source', () => { + render(ControlledStack) + press(screen.getByText('Open inner')) + expect(screen.queryByText('Inner')).not.toBeNull() + + press(screen.getByText('Close inner')) + expect(screen.queryByText('Inner')).toBeNull() + + press(screen.getByText('Open inner')) + pressEscape() // reaches the topmost layer only + expect(screen.queryByText('Inner')).toBeNull() + expect(screen.queryByText('Outer')).not.toBeNull() + }) + + it('dropping the open prop rewires the dialog uncontrolled where it stands', async () => { + const onOpenChange = vi.fn() + const { rerender } = render(DefaultDialog, { open: true, onOpenChange }) + await rerender({ open: undefined }) + expect(screen.queryByRole('dialog')).not.toBeNull() // stays where it was + + pressEscape() // uncontrolled now: dismissal works again + expect(screen.queryByRole('dialog')).toBeNull() + expect(onOpenChange).toHaveBeenLastCalledWith(false) + }) + }) + + describe('aria wiring', () => { + it('trigger exposes the popup relationship', () => { + render(DefaultDialog) + const trigger = screen.getByText('Trigger') + expect(trigger.getAttribute('aria-haspopup')).toBe('dialog') + expect(trigger.getAttribute('aria-expanded')).toBe('false') + + openDialog() + expect(trigger.getAttribute('aria-expanded')).toBe('true') + expect(trigger.getAttribute('aria-controls')).toBe(screen.getByRole('dialog').id) + }) + + // The window takes initial focus, so it carries tabindex — which HTML + // forbids on . Hence a neutral element with an explicit role. + it('renders the dialog window as a scripted focus target outside the tab order', () => { + render(DefaultDialog, { defaultOpen: true }) + const dialog = screen.getByRole('dialog') + expect(dialog.tagName).not.toBe('DIALOG') + expect(dialog.tabIndex).toBe(-1) + }) + + it('content is labelled by the Title and described by the Description', () => { + render(DefaultDialog, { defaultOpen: true }) + const dialog = screen.getByRole('dialog', { name: 'Title' }) + expect(dialog.getAttribute('aria-modal')).toBe('true') + + const describedBy = dialog.getAttribute('aria-describedby') + expect(describedBy).not.toBeNull() + expect(document.getElementById(describedBy as string)?.textContent).toBe('Description') + }) + + it('supports aria-label on Content when no Title is rendered', () => { + render(UntitledDialog) + const dialog = screen.getByRole('dialog', { name: 'Settings' }) + expect(dialog.hasAttribute('aria-labelledby')).toBe(false) + expect(dialog.hasAttribute('aria-describedby')).toBe(false) + }) + + it('renders role=alertdialog when requested', () => { + render(DefaultDialog, { defaultOpen: true, role: 'alertdialog' }) + expect(screen.queryByRole('alertdialog')).not.toBeNull() + }) + + it('omits aria-modal when modal=false', () => { + render(DefaultDialog, { defaultOpen: true, modal: false }) + expect(screen.getByRole('dialog').hasAttribute('aria-modal')).toBe(false) + }) + }) + + describe('focus management', () => { + it('moves focus into the dialog window on open and restores it on close', () => { + render(DefaultDialog) + const trigger = screen.getByText('Trigger') + trigger.focus() + + openDialog() + expect(document.activeElement).toBe(screen.getByRole('dialog')) + + pressEscape() + expect(document.activeElement).toBe(trigger) + }) + + it('falls back to restoreFocus when nothing meaningful held focus before opening', () => { + render(RestoreFocusDialog) + // A click without a focus move leaves the body focused — nothing + // meaningful for the close to restore to. + openDialog() + + press(screen.getByText('Close')) + expect(document.activeElement).toBe(screen.getByText('Fallback')) + }) + + // jsdom does no layout, so the scroll jump can't be reproduced — assert the + // mechanism that prevents it: focus never scrolls the locked surface. + it('moves focus without scrolling the locked surface', () => { + const focusSpy = vi.spyOn(HTMLElement.prototype, 'focus') + render(DefaultDialog, { defaultOpen: true }) + + expect(focusSpy).toHaveBeenCalledWith({ preventScroll: true }) + focusSpy.mockRestore() + }) + + it('moves focus to the first form field when the dialog contains one', () => { + render(FormFieldDialog) + expect(document.activeElement).toBe(screen.getByLabelText('Name')) + }) + + it('wraps Tab from the last focusable to the first', () => { + render(DefaultDialog, { defaultOpen: true }) + const dialog = screen.getByRole('dialog') + + screen.getByText('Close').focus() + keyDown(dialog, { key: 'Tab' }) + expect(document.activeElement).toBe(screen.getByText('Action')) + }) + + it('wraps Shift+Tab from the first focusable to the last', () => { + render(DefaultDialog, { defaultOpen: true }) + const dialog = screen.getByRole('dialog') + + screen.getByText('Action').focus() + keyDown(dialog, { key: 'Tab', shiftKey: true }) + expect(document.activeElement).toBe(screen.getByText('Close')) + }) + + it('keeps Close last in the cycle even when it renders first', () => { + // Close first in the DOM, then content. Tabbing FROM the dialog window + // (off-cycle, where focus lands on open) is the discriminating case: a + // pure forward cycle hides the wrap point, but entry from off-cycle + // reveals whether Close leads (bug) or trails (fixed). + render(CloseFirstDialog) + const dialog = screen.getByRole('dialog') + + dialog.focus() // the dialog window — where focus opens + keyDown(dialog, { key: 'Tab' }) + expect(document.activeElement).toBe(screen.getByText('Content')) // not Close + + dialog.focus() + keyDown(dialog, { key: 'Tab', shiftKey: true }) + expect(document.activeElement).toBe(screen.getByText('Close')) // last, backward + }) + + it('moves focus to the initialFocus element on open', () => { + render(InitialFocusDialog) + expect(document.activeElement).toBe(screen.getByLabelText('Name')) + }) + + it('falls back to the dialog panel when the initialFocus target cannot take focus', () => { + render(InitialFocusDialog, { disabled: true }) + expect(document.activeElement).toBe(screen.getByRole('dialog')) + }) + }) + + describe('scroll lock', () => { + it('locks body scroll while a modal dialog is open', () => { + render(DefaultDialog) + openDialog() + expect(document.body.style.overflowY).toBe('hidden') + + pressEscape() + expect(document.body.style.overflowY).not.toBe('hidden') + }) + + it('does not lock scroll when modal=false', () => { + render(DefaultDialog, { defaultOpen: true, modal: false }) + expect(document.body.style.overflowY).not.toBe('hidden') + }) + + it('locks the portal container, not the body, when scoped', () => { + const panel = document.createElement('div') + document.body.append(panel) + + render(ScopedDialog, { container: panel }) + + expect(panel.style.overflowY).toBe('hidden') + expect(document.body.style.overflowY).not.toBe('hidden') + + pressEscape() + expect(panel.style.overflowY).not.toBe('hidden') + panel.remove() + }) + }) + + describe('back navigation', () => { + // jsdom's history traversal is asynchronous — await the popstate itself. + const nextPop = (): Promise => + new Promise(resolve => { + window.addEventListener('popstate', () => resolve(), { once: true }) + }) + + // The traversal, then the commit it caused. + const traverse = async (go: () => void): Promise => { + const pop = nextPop() + go() + await pop + flushSync() + } + + it('closes on the browser Back instead of navigating', async () => { + const before: unknown = window.history.state + render(DefaultDialog, { closeOnBack: true }) + openDialog() + expect(window.history.state).not.toEqual(before) // the guard entry is planted + + await traverse(() => window.history.back()) + expect(screen.queryByRole('dialog')).toBeNull() + expect(window.history.state).toEqual(before) // consumed by the press itself + }) + + it('closing any other way consumes the guard entry', async () => { + const before: unknown = window.history.state + render(DefaultDialog, { closeOnBack: true, defaultOpen: true }) + expect(window.history.state).not.toEqual(before) + + const pop = nextPop() + pressEscape() + await pop + expect(window.history.state).toEqual(before) // no leftover to swallow a Back + }) + + it('plants no history entry without the flag', () => { + const before: unknown = window.history.state + render(DefaultDialog, { defaultOpen: true }) + expect(window.history.state).toEqual(before) + }) + + it('the browser Forward reopens what Back closed, guarded again', async () => { + render(DefaultDialog, { closeOnBack: true, defaultOpen: true }) + + await traverse(() => window.history.back()) + expect(screen.queryByRole('dialog')).toBeNull() + + await traverse(() => window.history.forward()) + expect(screen.queryByRole('dialog')).not.toBeNull() + + // The reopened dialog is guarded again: the next Back closes it. + await traverse(() => window.history.back()) + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('Forward does not reopen a dialog closed any other way', async () => { + render(DefaultDialog, { closeOnBack: true, defaultOpen: true }) + + const consume = nextPop() // the released guard consumes its entry + pressEscape() + await consume + + await traverse(() => window.history.forward()) + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('onForwardNavigation preventDefault declines the reopen', async () => { + render(DefaultDialog, { + closeOnBack: true, + defaultOpen: true, + onForwardNavigation: event => event?.preventDefault?.(), + }) + + await traverse(() => window.history.back()) + await traverse(() => window.history.forward()) + expect(screen.queryByRole('dialog')).toBeNull() + + // The decline left the still-watched entry current; disposing consumes + // it — settle that traversal here, not in the next test. + const consume = nextPop() + cleanup() + await consume + }) + + // The nested round-trip: closing the outer takes the inner's whole + // registration with it (unmounted with the content that held it), so the + // inner that comes back with the outer is a different machine. It reopens + // anyway — the entry it lost is still its own ground. + const layers = (): string => + `${screen.queryByLabelText('outer') ? 'O' : '-'}${screen.queryByLabelText('inner') ? 'I' : '-'}` + + it('Back unwinds a nested stack one layer per press and Forward restores it the same way', async () => { + render(NestedGuards) + press(screen.getByText('open inner')) + expect(layers()).toBe('OI') + + await traverse(() => window.history.back()) + expect(layers()).toBe('O-') + await traverse(() => window.history.back()) + expect(layers()).toBe('--') + + await traverse(() => window.history.forward()) + expect(layers()).toBe('O-') + await traverse(() => window.history.forward()) + expect(layers()).toBe('OI') + + // Both layers are armed again; disposing frees their entries one + // traversal at a time — settle both pops here, not in the next test. + const consume = nextPop() + cleanup() + await consume + await nextPop() + }) + + // Both layers guarded and closed in one commit — a "close all" affordance, + // or a route change that takes the whole stack with it. + it('closing a whole stack at once leaves no entry to swallow a later Back', async () => { + const before: unknown = window.history.state + const { rerender } = render(GuardedStack, { open: true }) + expect(window.history.state).not.toEqual(before) + + const consume = nextPop() // the chain spends the entries one pop at a time + await rerender({ open: false }) + await consume + await nextPop() + expect(window.history.state).toEqual(before) + }) + + it('reopening through the trigger plants a fresh guard, truncating the spent entry', async () => { + render(DefaultDialog, { closeOnBack: true, defaultOpen: true }) + + await traverse(() => window.history.back()) + expect(screen.queryByRole('dialog')).toBeNull() + + openDialog() + expect(screen.queryByRole('dialog')).not.toBeNull() + + await traverse(() => window.history.back()) + expect(screen.queryByRole('dialog')).toBeNull() + }) + }) + + describe('exit animation', () => { + const fireTransitionEnd = (element: Element): void => { + element.dispatchEvent(new Event('transitionend', { bubbles: true })) + flushSync() + } + + it('stays mounted through the exit and unmounts when its transition ends', () => { + render(DefaultDialog, { defaultOpen: true, animated: true }) + pressEscape() + + // Mid-exit: still in the tree, styled by data-state, hidden from AT. + const dialog = screen.getByRole('dialog', { hidden: true }) + expect(dialog.getAttribute('data-state')).toBe('closing') + + fireTransitionEnd(dialog) + expect(screen.queryByRole('dialog', { hidden: true })).toBeNull() + }) + + it('releases focus, containment, and interaction the moment the exit starts', () => { + const { container } = render(DefaultDialog, { animated: true }) + const trigger = screen.getByText('Trigger') + trigger.focus() + openDialog() + expect(container.hasAttribute('inert')).toBe(true) + + pressEscape() + // The page is live and focus is home before the visual finishes… + expect(container.hasAttribute('inert')).toBe(false) + expect(document.activeElement).toBe(trigger) + // …while the still-painting layer is out of the interaction instead. + expect(screen.getByTestId('viewport').hasAttribute('inert')).toBe(true) + expect(screen.getByTestId('backdrop').hasAttribute('inert')).toBe(true) + }) + + it('reopening mid-exit interrupts it and restores the layer', () => { + render(DefaultDialog, { animated: true }) + openDialog() + pressEscape() + openDialog() + + const dialog = screen.getByRole('dialog') + expect(dialog.getAttribute('data-state')).toBe('open') + expect(screen.getByTestId('viewport').hasAttribute('inert')).toBe(false) + expect(document.activeElement).toBe(dialog) + + // The interrupted exit's end must not close the reopened dialog. + fireTransitionEnd(dialog) + expect(screen.queryByRole('dialog')).not.toBeNull() + }) + }) + + describe('nesting', () => { + it('Escape dismisses the topmost dialog only, one layer per press', () => { + render(NestedDialog) + expect(screen.queryByText('Outer')).not.toBeNull() + expect(screen.queryByText('Inner')).not.toBeNull() + + pressEscape() + expect(screen.queryByText('Inner')).toBeNull() + expect(screen.queryByText('Outer')).not.toBeNull() + + pressEscape() + expect(screen.queryByText('Outer')).toBeNull() + }) + + it('a stack-scoped Escape on the topmost dialog unwinds every layer', () => { + render(StackScopedDialog) + + pressEscape() + expect(screen.queryByText('Inner')).toBeNull() + expect(screen.queryByText('Outer')).toBeNull() + }) + + it('hides the dialog beneath the topmost from assistive tech and makes it inert', () => { + render(NestedDialog) + const outer = screen.getByTestId('outer-viewport') + expect(outer.getAttribute('aria-hidden')).toBe('true') + expect(outer.hasAttribute('inert')).toBe(true) + + const inner = screen.getByTestId('inner-viewport') + expect(inner.hasAttribute('aria-hidden')).toBe(false) + expect(inner.hasAttribute('inert')).toBe(false) + }) + + it("hides the lower dialog's backdrop but never the topmost's own", () => { + render(NestedDialog) + expect(screen.getByTestId('outer-backdrop').hasAttribute('inert')).toBe(true) + expect(screen.getByTestId('inner-backdrop').hasAttribute('inert')).toBe(false) + + pressEscape() // the outer dialog is topmost again — its backdrop re-excepted + expect(screen.getByTestId('outer-backdrop').hasAttribute('inert')).toBe(false) + }) + + it('restores the layer beneath once the top dialog closes', () => { + render(NestedDialog) + expect(screen.getByTestId('outer-viewport').getAttribute('aria-hidden')).toBe('true') + + pressEscape() // close the inner dialog + const outer = screen.getByTestId('outer-viewport') + expect(outer.hasAttribute('aria-hidden')).toBe(false) + expect(outer.hasAttribute('inert')).toBe(false) + }) + + it('ignores an outside press on a lower layer — only the topmost dismisses', () => { + render(NestedDialog) + press(screen.getByTestId('outer-viewport')) + expect(screen.queryByText('Outer')).not.toBeNull() + expect(screen.queryByText('Inner')).not.toBeNull() + + press(screen.getByTestId('inner-viewport')) + expect(screen.queryByText('Inner')).toBeNull() + expect(screen.queryByText('Outer')).not.toBeNull() + }) + + it('cleans up containment and scroll lock when the parent closes over an open child', async () => { + const { container, rerender } = render(NestedDialog, { open: true }) + expect(screen.queryByText('Inner')).not.toBeNull() + expect(container.hasAttribute('inert')).toBe(true) + + await rerender({ open: false }) + expect(screen.queryByText('Outer')).toBeNull() + expect(screen.queryByText('Inner')).toBeNull() + expect(document.body.style.overflowY).not.toBe('hidden') + expect(container.hasAttribute('aria-hidden')).toBe(false) + expect(container.hasAttribute('inert')).toBe(false) + }) + }) +}) diff --git a/packages/svelte/dialog/tests/fixtures/backdrop-last-dialog.svelte b/packages/svelte/dialog/tests/fixtures/backdrop-last-dialog.svelte new file mode 100644 index 0000000..b04ff92 --- /dev/null +++ b/packages/svelte/dialog/tests/fixtures/backdrop-last-dialog.svelte @@ -0,0 +1,12 @@ + + + + + + content + + + + diff --git a/packages/svelte/dialog/tests/fixtures/close-first-dialog.svelte b/packages/svelte/dialog/tests/fixtures/close-first-dialog.svelte new file mode 100644 index 0000000..26da1e4 --- /dev/null +++ b/packages/svelte/dialog/tests/fixtures/close-first-dialog.svelte @@ -0,0 +1,14 @@ + + + + + + + Close + + + + + diff --git a/packages/svelte/dialog/tests/fixtures/controlled-stack.svelte b/packages/svelte/dialog/tests/fixtures/controlled-stack.svelte new file mode 100644 index 0000000..7b5aaa7 --- /dev/null +++ b/packages/svelte/dialog/tests/fixtures/controlled-stack.svelte @@ -0,0 +1,35 @@ + + + (outerOpen = open)} + onEscapeKeyDown={() => (outerOpen = false)} +> + + + + Outer + (innerOpen = open)} + onEscapeKeyDown={() => (innerOpen = false)} + > + (innerOpen = true)}>Open inner + + + + Inner + (innerOpen = false)}>Close inner + + + + + + + + diff --git a/packages/svelte/dialog/tests/fixtures/default-dialog.svelte b/packages/svelte/dialog/tests/fixtures/default-dialog.svelte new file mode 100644 index 0000000..2a116c4 --- /dev/null +++ b/packages/svelte/dialog/tests/fixtures/default-dialog.svelte @@ -0,0 +1,20 @@ + + + + Trigger + + + + + Title + Description + + Close + + + + diff --git a/packages/svelte/dialog/tests/fixtures/form-field-dialog.svelte b/packages/svelte/dialog/tests/fixtures/form-field-dialog.svelte new file mode 100644 index 0000000..0a8f8d1 --- /dev/null +++ b/packages/svelte/dialog/tests/fixtures/form-field-dialog.svelte @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/packages/svelte/dialog/tests/fixtures/guarded-stack.svelte b/packages/svelte/dialog/tests/fixtures/guarded-stack.svelte new file mode 100644 index 0000000..0a3351a --- /dev/null +++ b/packages/svelte/dialog/tests/fixtures/guarded-stack.svelte @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + diff --git a/packages/svelte/dialog/tests/fixtures/handler-dialog.svelte b/packages/svelte/dialog/tests/fixtures/handler-dialog.svelte new file mode 100644 index 0000000..4452a8d --- /dev/null +++ b/packages/svelte/dialog/tests/fixtures/handler-dialog.svelte @@ -0,0 +1,9 @@ + + + + Trigger + diff --git a/packages/svelte/dialog/tests/fixtures/initial-focus-dialog.svelte b/packages/svelte/dialog/tests/fixtures/initial-focus-dialog.svelte new file mode 100644 index 0000000..f9c70d4 --- /dev/null +++ b/packages/svelte/dialog/tests/fixtures/initial-focus-dialog.svelte @@ -0,0 +1,16 @@ + + + + + + initialFocus}> + + + + + diff --git a/packages/svelte/dialog/tests/fixtures/interact.ts b/packages/svelte/dialog/tests/fixtures/interact.ts new file mode 100644 index 0000000..2dc7030 --- /dev/null +++ b/packages/svelte/dialog/tests/fixtures/interact.ts @@ -0,0 +1,17 @@ +import { flushSync } from 'svelte' + +// Svelte batches updates into a microtask — every interaction flushes before +// the test reads the tree. +export const press = (element: HTMLElement): void => { + element.click() + flushSync() +} + +export const keyDown = (target: EventTarget, init: KeyboardEventInit): void => { + target.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init })) + flushSync() +} + +export const pressEscape = (): void => { + keyDown(document.body, { key: 'Escape' }) +} diff --git a/packages/svelte/dialog/tests/fixtures/nested-dialog.svelte b/packages/svelte/dialog/tests/fixtures/nested-dialog.svelte new file mode 100644 index 0000000..13f3ae4 --- /dev/null +++ b/packages/svelte/dialog/tests/fixtures/nested-dialog.svelte @@ -0,0 +1,26 @@ + + + + + + + + Outer + + + + + + Inner + + + + + + + + diff --git a/packages/svelte/dialog/tests/fixtures/nested-guards.svelte b/packages/svelte/dialog/tests/fixtures/nested-guards.svelte new file mode 100644 index 0000000..1188c86 --- /dev/null +++ b/packages/svelte/dialog/tests/fixtures/nested-guards.svelte @@ -0,0 +1,20 @@ + + + + + + + + open inner + + + + + + + + + + diff --git a/packages/svelte/dialog/tests/fixtures/presence-dialog.svelte b/packages/svelte/dialog/tests/fixtures/presence-dialog.svelte new file mode 100644 index 0000000..6db2071 --- /dev/null +++ b/packages/svelte/dialog/tests/fixtures/presence-dialog.svelte @@ -0,0 +1,20 @@ + + + + + + {#if showTitle} + Title + {/if} + {#if showDescription} + Description + {/if} + + + diff --git a/packages/svelte/dialog/tests/fixtures/ref-dialog.svelte b/packages/svelte/dialog/tests/fixtures/ref-dialog.svelte new file mode 100644 index 0000000..2a3f4e2 --- /dev/null +++ b/packages/svelte/dialog/tests/fixtures/ref-dialog.svelte @@ -0,0 +1,29 @@ + + + + Trigger + + + + + Title + Description + Close + + + + diff --git a/packages/svelte/dialog/tests/fixtures/restore-focus-dialog.svelte b/packages/svelte/dialog/tests/fixtures/restore-focus-dialog.svelte new file mode 100644 index 0000000..f548ca0 --- /dev/null +++ b/packages/svelte/dialog/tests/fixtures/restore-focus-dialog.svelte @@ -0,0 +1,15 @@ + + + + + Trigger + + fallback}> + Close + + + diff --git a/packages/svelte/dialog/tests/fixtures/scoped-dialog.svelte b/packages/svelte/dialog/tests/fixtures/scoped-dialog.svelte new file mode 100644 index 0000000..1799e03 --- /dev/null +++ b/packages/svelte/dialog/tests/fixtures/scoped-dialog.svelte @@ -0,0 +1,11 @@ + + + + + content + + diff --git a/packages/svelte/dialog/tests/fixtures/server-render.ts b/packages/svelte/dialog/tests/fixtures/server-render.ts new file mode 100644 index 0000000..1439989 --- /dev/null +++ b/packages/svelte/dialog/tests/fixtures/server-render.ts @@ -0,0 +1,38 @@ +import { fileURLToPath } from 'node:url' +import { svelte } from '@sveltejs/vite-plugin-svelte' +import { createServer } from 'vite' +import type { TestProject } from 'vitest/node' + +declare module 'vitest' { + export interface ProvidedContext { + /** DefaultDialog's server render, `defaultOpen` — what hydration starts from. */ + defaultOpenServerMarkup: string + } +} + +// A vitest globalSetup for the DOM project: the hydration test needs real +// server markup, but that project resolves Svelte's browser runtime. So the +// render happens here, once, in a Vite SSR graph of its own. +export default async function renderOnServer(project: TestProject): Promise { + const server = await createServer({ + configFile: false, + root: fileURLToPath(new URL('../..', import.meta.url)), + logLevel: 'silent', + appType: 'custom', + plugins: [svelte({ configFile: false })], + server: { middlewareMode: true, hmr: false, ws: false }, + // The workspace packages export TypeScript source and the adapter ships + // rune modules: Vite compiles them rather than Node loading them raw. + ssr: { noExternal: [/^@dunky\.dev\//] }, + }) + try { + const { render } = await server.ssrLoadModule('svelte/server') + const { default: DefaultDialog } = await server.ssrLoadModule( + fileURLToPath(new URL('./default-dialog.svelte', import.meta.url)), + ) + const { body } = render(DefaultDialog, { props: { defaultOpen: true } }) + project.provide('defaultOpenServerMarkup', body) + } finally { + await server.close() + } +} diff --git a/packages/svelte/dialog/tests/fixtures/stack-scoped-dialog.svelte b/packages/svelte/dialog/tests/fixtures/stack-scoped-dialog.svelte new file mode 100644 index 0000000..d4da1e2 --- /dev/null +++ b/packages/svelte/dialog/tests/fixtures/stack-scoped-dialog.svelte @@ -0,0 +1,22 @@ + + + + + + + Outer + + + + + Inner + + + + + + + + diff --git a/packages/svelte/dialog/tests/fixtures/transition-dialog.svelte b/packages/svelte/dialog/tests/fixtures/transition-dialog.svelte new file mode 100644 index 0000000..d4d30e4 --- /dev/null +++ b/packages/svelte/dialog/tests/fixtures/transition-dialog.svelte @@ -0,0 +1,19 @@ + + + + Trigger + +
+ content +
+
+
diff --git a/packages/svelte/dialog/tests/fixtures/typed-buttons-dialog.svelte b/packages/svelte/dialog/tests/fixtures/typed-buttons-dialog.svelte new file mode 100644 index 0000000..15d46bf --- /dev/null +++ b/packages/svelte/dialog/tests/fixtures/typed-buttons-dialog.svelte @@ -0,0 +1,14 @@ + + + + Trigger + + + Close + + + diff --git a/packages/svelte/dialog/tests/fixtures/untitled-dialog.svelte b/packages/svelte/dialog/tests/fixtures/untitled-dialog.svelte new file mode 100644 index 0000000..b22eb65 --- /dev/null +++ b/packages/svelte/dialog/tests/fixtures/untitled-dialog.svelte @@ -0,0 +1,9 @@ + + + + + content + + diff --git a/packages/svelte/hooks/use-focus-trap/README.md b/packages/svelte/hooks/use-focus-trap/README.md new file mode 100644 index 0000000..ae3ca04 --- /dev/null +++ b/packages/svelte/hooks/use-focus-trap/README.md @@ -0,0 +1,29 @@ +# @dunky.dev/svelte-use-focus-trap + +Svelte binding for [`@dunky.dev/dom-focus-trap`](../../../dom/utils/focus-trap): +`useFocusTrap(target)` traps Tab / Shift+Tab within the container the getter +yields, while the calling component lives. The trap behavior itself is +framework-free — this hook only owns the Svelte lifecycle. + +## Install + +```sh +npm install @dunky.dev/svelte-use-focus-trap +``` + +## Usage + +```svelte + + +
...
+``` diff --git a/packages/svelte/hooks/use-focus-trap/SPEC.md b/packages/svelte/hooks/use-focus-trap/SPEC.md new file mode 100644 index 0000000..3dedd0e --- /dev/null +++ b/packages/svelte/hooks/use-focus-trap/SPEC.md @@ -0,0 +1,53 @@ +# SPEC / Svelte / useFocusTrap + +The Svelte binding of the +[DOM focus-trap spec](../../../dom/utils/focus-trap/SPEC.md) — the trap +behavior is framework-free; this hook owns only the Svelte lifecycle. + +## Install + +```sh +npm install @dunky.dev/svelte-use-focus-trap +``` + +## Usage + +```svelte + + +
...
+``` + +Svelte-specific notes on top of the DOM contract: + +- A rune module: call it while a component initializes, like `$effect` + itself (or inside an `$effect.root`). The trap arms after mount — once + `bind:this` has filled — and releases when the component is destroyed. +- The target is a getter, the same shape as the Solid hook's accessor: over + `$state`, a new element re-arms the trap on it; over a plain variable, the + element is read once, after mount. It is a hook rather than an attachment + for that shared shape — and for the dialog, whose trap target is a part it + already renders. +- Options are read per Tab press, so inline `enabled` / `last` getters see the + latest state without re-binding the listener — the per-press re-evaluation + the DOM contract promises. + +## API + +### `useFocusTrap(target, options?)` + +Returns nothing — the trap lives and dies with the calling component. + +| Param | Type | Default | Description | +| --------- | ---------------------------------------- | ------- | -------------------------------------------------------------------------------------- | +| `target` | `() => HTMLElement \| null \| undefined` | — | Getter for the container to trap Tab / Shift+Tab within. | +| `options` | `UseFocusTrapOptions` | `{}` | The DOM trap's options: `enabled?: () => boolean`, `last?: () => HTMLElement \| null`. | diff --git a/packages/svelte/hooks/use-focus-trap/package.json b/packages/svelte/hooks/use-focus-trap/package.json new file mode 100644 index 0000000..97c6540 --- /dev/null +++ b/packages/svelte/hooks/use-focus-trap/package.json @@ -0,0 +1,51 @@ +{ + "name": "@dunky.dev/svelte-use-focus-trap", + "version": "0.0.0", + "description": "Svelte binding for @dunky.dev/dom-focus-trap.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/dunky-dev/ui.git", + "directory": "packages/svelte/hooks/use-focus-trap" + }, + "files": [ + "dist", + "src", + "SPEC.md" + ], + "type": "module", + "sideEffects": false, + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "svelte": "./src/index.ts" + } + }, + "publishConfig": { + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "svelte": "./dist/index.js" + } + }, + "access": "public" + }, + "scripts": { + "build": "svelte-package -i src && publint" + }, + "dependencies": { + "@dunky.dev/dom-focus-trap": "workspace:*" + }, + "devDependencies": { + "@sveltejs/package": "^3.0.0", + "@testing-library/svelte": "^5.4.2", + "publint": "^0.3.21", + "svelte": "^5.57.1", + "typescript": "^6.0.3" + }, + "peerDependencies": { + "svelte": ">=5.20.0 <5.33.5 || ^5.34.5" + } +} diff --git a/packages/svelte/hooks/use-focus-trap/src/index.ts b/packages/svelte/hooks/use-focus-trap/src/index.ts new file mode 100644 index 0000000..cfafa7d --- /dev/null +++ b/packages/svelte/hooks/use-focus-trap/src/index.ts @@ -0,0 +1 @@ +export { useFocusTrap, type UseFocusTrapOptions } from './use-focus-trap.svelte.js' diff --git a/packages/svelte/hooks/use-focus-trap/src/use-focus-trap.svelte.ts b/packages/svelte/hooks/use-focus-trap/src/use-focus-trap.svelte.ts new file mode 100644 index 0000000..6123739 --- /dev/null +++ b/packages/svelte/hooks/use-focus-trap/src/use-focus-trap.svelte.ts @@ -0,0 +1,22 @@ +import { trapFocus, type TrapFocusOptions } from '@dunky.dev/dom-focus-trap' + +export interface UseFocusTrapOptions extends TrapFocusOptions {} + +/** + * Traps Tab / Shift+Tab within `target` while the calling component lives and + * the target yields an element — the Svelte lifecycle around `trapFocus`. + * Arms after mount, once `bind:this` has filled; releases on destroy; re-arms + * when a getter over `$state` yields a new element. + */ +export function useFocusTrap( + target: () => HTMLElement | null | undefined, + options: UseFocusTrapOptions = {}, +): void { + // The target is the only dependency: the trap reads `enabled` / `last` per + // Tab press, so inline getters stay live without re-binding the listener. + $effect(() => { + const container = target() + if (container == null) return + return trapFocus(container, options) + }) +} diff --git a/packages/svelte/hooks/use-focus-trap/tests/fixtures/trap.svelte b/packages/svelte/hooks/use-focus-trap/tests/fixtures/trap.svelte new file mode 100644 index 0000000..8ea2bc0 --- /dev/null +++ b/packages/svelte/hooks/use-focus-trap/tests/fixtures/trap.svelte @@ -0,0 +1,21 @@ + + +{#if swapped} +
+ + +
+{:else} +
+ + +
+{/if} diff --git a/packages/svelte/hooks/use-focus-trap/tests/use-focus-trap.test.ts b/packages/svelte/hooks/use-focus-trap/tests/use-focus-trap.test.ts new file mode 100644 index 0000000..9a7b374 --- /dev/null +++ b/packages/svelte/hooks/use-focus-trap/tests/use-focus-trap.test.ts @@ -0,0 +1,51 @@ +// @vitest-environment jsdom +// The Svelte lifecycle around @dunky.dev/dom-focus-trap — the wrap/no-op/enabled +// behavior itself is covered in the util's own tests. +import { render, screen } from '@testing-library/svelte' +import { describe, expect, it } from 'vitest' +import Trap from './fixtures/trap.svelte' + +const tab = (target: EventTarget): boolean => + target.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }), + ) + +// dispatchEvent returns false when a handler called preventDefault. +const pressTab = (): boolean => tab(screen.getByTestId('container')) + +describe('useFocusTrap', () => { + it('traps while mounted and releases on unmount', () => { + const { unmount } = render(Trap) + screen.getByText('last').focus() + + expect(pressTab()).toBe(false) + expect(document.activeElement).toBe(screen.getByText('first')) + + const container = screen.getByTestId('container') + screen.getByText('last').focus() + unmount() + // The listener is gone with the unmount — a Tab on the detached container + // is no longer intercepted. + expect(tab(container)).toBe(true) + }) + + it('forwards enabled() to the trap without re-binding', () => { + render(Trap, { enabled: () => false }) + const last = screen.getByText('last') + last.focus() + + expect(pressTab()).toBe(true) + expect(document.activeElement).toBe(last) + }) + + // The target is a getter: when the `$state` behind it fills with a new + // element, the trap moves to it. + it('re-arms on the element a reactive target yields next', async () => { + const { rerender } = render(Trap) + await rerender({ swapped: true }) + screen.getByText('second last').focus() + + expect(pressTab()).toBe(false) + expect(document.activeElement).toBe(screen.getByText('second first')) + }) +}) diff --git a/packages/svelte/hooks/use-scroll-lock/README.md b/packages/svelte/hooks/use-scroll-lock/README.md new file mode 100644 index 0000000..42c6d0c --- /dev/null +++ b/packages/svelte/hooks/use-scroll-lock/README.md @@ -0,0 +1,26 @@ +# @dunky.dev/svelte-use-scroll-lock + +Svelte binding for [`@dunky.dev/dom-scroll-lock`](../../../dom/utils/scroll-lock): +`useScrollLock(locked, target?)` locks scrolling while the calling component +lives — on the page body, or on the `target` element when one is given (e.g. a +scoped surface locks its own container, not the page). The lock behavior +itself is framework-free — this hook only owns the Svelte lifecycle. + +## Install + +```sh +npm install @dunky.dev/svelte-use-scroll-lock +``` + +## Usage + +```svelte + + + +
...
+``` diff --git a/packages/svelte/hooks/use-scroll-lock/SPEC.md b/packages/svelte/hooks/use-scroll-lock/SPEC.md new file mode 100644 index 0000000..69a4d96 --- /dev/null +++ b/packages/svelte/hooks/use-scroll-lock/SPEC.md @@ -0,0 +1,51 @@ +# SPEC / Svelte / useScrollLock + +The Svelte binding of the +[DOM scroll-lock spec](../../../dom/utils/scroll-lock/SPEC.md) — the lock +behavior is framework-free; this hook owns only the Svelte lifecycle. + +## Install + +```sh +npm install @dunky.dev/svelte-use-scroll-lock +``` + +## Usage + +```svelte + + + +
...
+``` + +Svelte-specific notes on top of the DOM contract: + +- A rune module: call it while a component initializes, like `$effect` + itself (or inside an `$effect.root`). The lock holds after mount while + `locked` resolves true; destroying the component or turning `locked` off + releases it. +- Both parameters accept a `MaybeGetter` — a static value or a getter — so the + lock tracks reactive state: a getter over `$state` re-runs it, and a + `target` change releases the old container and locks the new one. +- An omitted `target` means the page body; `null` means "no target yet" and + locks nothing — a getter over a `bind:this` element engages the lock once + the node resolves. +- The DOM contract's shared per-container lock does the multi-holder + arithmetic: several live lockers (nested modal layers) hold one lock, and + the container restores when the last releases. + +## API + +### `useScrollLock(locked?, target?)` + +Returns nothing — the lock lives and dies with the calling component. + +| Param | Type | Default | Description | +| -------- | ----------------------------------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------- | +| `locked` | `MaybeGetter` | `true` | Whether the lock is held. | +| `target` | `MaybeGetter` | the page body | The scroll container to lock (e.g. a scoped surface locks its own container, not the page). `null` locks nothing. | diff --git a/packages/svelte/hooks/use-scroll-lock/package.json b/packages/svelte/hooks/use-scroll-lock/package.json new file mode 100644 index 0000000..389f265 --- /dev/null +++ b/packages/svelte/hooks/use-scroll-lock/package.json @@ -0,0 +1,50 @@ +{ + "name": "@dunky.dev/svelte-use-scroll-lock", + "version": "0.0.0", + "description": "Svelte binding for @dunky.dev/dom-scroll-lock.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/dunky-dev/ui.git", + "directory": "packages/svelte/hooks/use-scroll-lock" + }, + "files": [ + "dist", + "src", + "SPEC.md" + ], + "type": "module", + "sideEffects": false, + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "svelte": "./src/index.ts" + } + }, + "publishConfig": { + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "svelte": "./dist/index.js" + } + }, + "access": "public" + }, + "scripts": { + "build": "svelte-package -i src && publint" + }, + "dependencies": { + "@dunky.dev/dom-scroll-lock": "workspace:*" + }, + "devDependencies": { + "@sveltejs/package": "^3.0.0", + "publint": "^0.3.21", + "svelte": "^5.57.1", + "typescript": "^6.0.3" + }, + "peerDependencies": { + "svelte": ">=5.20.0 <5.33.5 || ^5.34.5" + } +} diff --git a/packages/svelte/hooks/use-scroll-lock/src/index.ts b/packages/svelte/hooks/use-scroll-lock/src/index.ts new file mode 100644 index 0000000..f4eac28 --- /dev/null +++ b/packages/svelte/hooks/use-scroll-lock/src/index.ts @@ -0,0 +1 @@ +export { useScrollLock, type MaybeGetter } from './use-scroll-lock.svelte.js' diff --git a/packages/svelte/hooks/use-scroll-lock/src/use-scroll-lock.svelte.ts b/packages/svelte/hooks/use-scroll-lock/src/use-scroll-lock.svelte.ts new file mode 100644 index 0000000..ebdd3df --- /dev/null +++ b/packages/svelte/hooks/use-scroll-lock/src/use-scroll-lock.svelte.ts @@ -0,0 +1,27 @@ +import { lockScroll } from '@dunky.dev/dom-scroll-lock' + +/** A static value or a getter — for parameters that may be reactive. */ +export type MaybeGetter = T | (() => T) + +function access(value: MaybeGetter): T { + return typeof value === 'function' ? (value as () => T)() : value +} + +/** + * Locks scrolling while the calling component lives and `locked` — the Svelte + * lifecycle around `lockScroll`. Targets the page body unless a `target` is + * given. A `null` target means "no target yet" and locks nothing; a getter over + * `$state` engages the lock once the element resolves. The lock is shared per + * container: it restores when the last holder releases. + */ +export function useScrollLock( + locked: MaybeGetter = true, + target?: MaybeGetter, +): void { + $effect(() => { + const isLocked = access(locked) + const container = target === undefined ? undefined : access(target) + if (!isLocked || container === null) return + return lockScroll(container) + }) +} diff --git a/packages/svelte/hooks/use-scroll-lock/tests/use-scroll-lock.svelte.test.ts b/packages/svelte/hooks/use-scroll-lock/tests/use-scroll-lock.svelte.test.ts new file mode 100644 index 0000000..7666cc3 --- /dev/null +++ b/packages/svelte/hooks/use-scroll-lock/tests/use-scroll-lock.svelte.test.ts @@ -0,0 +1,36 @@ +// @vitest-environment jsdom +// The Svelte lifecycle around @dunky.dev/dom-scroll-lock — the refcount/restore +// behavior itself is covered in the util's own tests. +import { flushSync } from 'svelte' +import { describe, expect, it } from 'vitest' +import { useScrollLock } from '@dunky.dev/svelte-use-scroll-lock' + +// A hook's effect needs an owner: an effect root stands in for the component, +// and disposing it stands in for the unmount. +const mountHook = (hook: () => void): (() => void) => { + const dispose = $effect.root(hook) + flushSync() + return dispose +} + +describe('useScrollLock', () => { + it('locks body scroll while mounted and releases on unmount', () => { + const unmount = mountHook(() => useScrollLock()) + expect(document.body.style.overflowY).toBe('hidden') + + unmount() + expect(document.body.style.overflowY).toBe('') + }) + + it('does not lock when locked=false', () => { + const unmount = mountHook(() => useScrollLock(false)) + expect(document.body.style.overflowY).toBe('') + unmount() + }) + + it('locks nothing when target is null — a target not yet resolved', () => { + const unmount = mountHook(() => useScrollLock(true, () => null)) + expect(document.body.style.overflowY).toBe('') + unmount() + }) +}) diff --git a/packages/svelte/package.json b/packages/svelte/package.json new file mode 100644 index 0000000..0c2ec78 --- /dev/null +++ b/packages/svelte/package.json @@ -0,0 +1,23 @@ +{ + "name": "@dunky-dev/svelte", + "version": "0.0.0", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "dev": "storybook dev -p 6009 -c .storybook", + "build": "storybook build -c .storybook", + "typecheck": "svelte-check --tsconfig ./tsconfig.json --output human --fail-on-warnings" + }, + "devDependencies": { + "@storybook/addon-svelte-csf": "^5.1.5", + "@storybook/svelte-vite": "^10.6.1", + "@sveltejs/vite-plugin-svelte": "^7.3.1", + "@testing-library/svelte": "^5.4.2", + "storybook": "^10.6.1", + "svelte": "^5.57.1", + "svelte-check": "^4.7.6", + "typescript": "^6.0.3", + "vite": "^8.1.4" + } +} diff --git a/packages/svelte/tsconfig.json b/packages/svelte/tsconfig.json new file mode 100644 index 0000000..e5f0edd --- /dev/null +++ b/packages/svelte/tsconfig.json @@ -0,0 +1,15 @@ +{ + // .svelte files need svelte-check (tsc can't read them), so packages/svelte + // typechecks as its own project — the root tsconfig excludes it and + // `pnpm typecheck` runs this one through svelte-check. `paths` is inherited + // from the root file. + "extends": "../../tsconfig.json", + "compilerOptions": { + // Svelte's own types for `.svelte` imports and the runes globals. + "types": ["@types/node", "svelte"], + // Svelte 5 type-only imports must be marked so the compiler can drop them. + "verbatimModuleSyntax": true + }, + "include": ["."], + "exclude": ["**/node_modules", "**/dist", "**/.svelte-kit"] +} diff --git a/packages/svelte/vitest.config.ts b/packages/svelte/vitest.config.ts new file mode 100644 index 0000000..66674b9 --- /dev/null +++ b/packages/svelte/vitest.config.ts @@ -0,0 +1,26 @@ +import { svelte } from '@sveltejs/vite-plugin-svelte' +import { svelteTesting } from '@testing-library/svelte/vite' +import { defineConfig } from 'vitest/config' + +// The client half: components compile for the DOM, and svelteTesting resolves +// Svelte's browser runtime and registers the per-test cleanup. Server renders +// need the opposite resolution, so they run in their own project +// (vitest.ssr.config.ts). +export default defineConfig({ + plugins: [svelte({ configFile: false }), svelteTesting()], + test: { + name: 'svelte', + globals: false, + // node by default; DOM tests opt into jsdom per-file via `@vitest-environment`. + environment: 'node', + include: ['**/tests/**/*.test.ts'], + // Server markup for the hydration test, rendered outside this project's + // browser resolution. + globalSetup: ['./dialog/tests/fixtures/server-render.ts'], + // A rune module loaded straight from node_modules would run uncompiled; + // vite-plugin-svelte only inlines the Svelte packages the harness itself + // depends on, and the adapter is a dependency of the packages below it. + server: { deps: { inline: ['@dunky.dev/svelte-state-machine'] } }, + exclude: ['**/node_modules/**', '**/dist/**', '**/tests/**/*.ssr.test.ts'], + }, +}) diff --git a/packages/svelte/vitest.ssr.config.ts b/packages/svelte/vitest.ssr.config.ts new file mode 100644 index 0000000..1d242a8 --- /dev/null +++ b/packages/svelte/vitest.ssr.config.ts @@ -0,0 +1,20 @@ +import { svelte } from '@sveltejs/vite-plugin-svelte' +import { defineConfig } from 'vitest/config' + +// The server half: components compile for `svelte/server`, and Svelte must +// resolve to its server runtime — the client project's browser condition would +// pair server-compiled components with the client runtime. +export default defineConfig({ + plugins: [svelte({ configFile: false })], + test: { + name: 'svelte-ssr', + globals: false, + environment: 'node', + include: ['**/tests/**/*.ssr.test.ts'], + // A rune module loaded straight from node_modules would run uncompiled; + // vite-plugin-svelte only inlines the Svelte packages the harness itself + // depends on, and the adapter is a dependency of the packages below it. + server: { deps: { inline: ['@dunky.dev/svelte-state-machine'] } }, + exclude: ['**/node_modules/**', '**/dist/**'], + }, +}) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 56c79a4..391810f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -10,6 +10,7 @@ minimumReleaseAgeExclude: - '@dunky.dev/state-machine-dom@0.1.0' - '@dunky.dev/state-machine-utils@0.4.0' - '@dunky.dev/state-machine@0.3.3' + - '@dunky.dev/svelte-state-machine@0.3.0' - '@dom-expressions/babel-plugin-jsx@0.50.0-next.43' - '@solidjs/signals@2.0.0-rc.1' - '@solidjs/web@2.0.0-rc.1' diff --git a/scripts/scaffold.ts b/scripts/scaffold.ts index b4657ba..984a46c 100644 --- a/scripts/scaffold.ts +++ b/scripts/scaffold.ts @@ -30,6 +30,9 @@ interface Tokens { interface CreatedPackage { name: string private: boolean + // Built by the root tsdown workspace — not true of every substrate (the + // Svelte packages build with @sveltejs/package). + tsdown: boolean dir: string src: string } @@ -82,6 +85,7 @@ function describeCreatedPackages(jobs: Array<{ dest: string; rel: string }>): Cr return { name: pkg.name, private: pkg.private === true, + tsdown: pkg.scripts?.build === 'tsdown', dir: relative(ROOT, dir), src: relative(ROOT, join(dir, 'src')), } @@ -102,15 +106,16 @@ function wireTsconfig(created: CreatedPackage[]): void { writeFileSync(path, text) } -// The tsdown workspace is an explicit list of publishable packages. Rebuild it as -// a sorted, deduped multiline array (private packages are never published). +// The tsdown workspace is an explicit list of the packages tsdown publishes. +// Rebuild it as a sorted, deduped multiline array (private packages are never +// published; a package whose build isn't tsdown has its own pipeline). function wireTsdown(created: CreatedPackage[]): void { const path = join(ROOT, 'tsdown.config.ts') const text = readFileSync(path, 'utf8') const match = text.match(/workspace:\s*\[([\s\S]*?)\]/) if (!match) fail('could not find the workspace array in tsdown.config.ts') const existing = [...match[1].matchAll(/'([^']+)'/g)].map(m => m[1]) - const additions = created.filter(pkg => !pkg.private).map(pkg => pkg.dir) + const additions = created.filter(pkg => !pkg.private && pkg.tsdown).map(pkg => pkg.dir) const all = [...new Set([...existing, ...additions])].sort() const rebuilt = `workspace: [\n${all.map(entry => ` '${entry}',`).join('\n')}\n ]` writeFileSync(path, text.replace(match[0], rebuilt)) diff --git a/scripts/templates/README.md b/scripts/templates/README.md index 631722a..4f4b156 100644 --- a/scripts/templates/README.md +++ b/scripts/templates/README.md @@ -42,4 +42,5 @@ lands at `packages/native/__name__/`. To add one (say, a native driver group): 2. That's it — the script discovers the created packages from the copied `package.json` files and wires them automatically. A package with `"private": true` is added to tsconfig `paths` but skipped in the tsdown - publish set. + publish set, and so is one whose `build` script isn't `tsdown` — the + Svelte packages build with `@sveltejs/package` (see `ARCHITECTURE.md`). diff --git a/scripts/templates/packages/svelte/__name__/README.md b/scripts/templates/packages/svelte/__name__/README.md new file mode 100644 index 0000000..6de22a3 --- /dev/null +++ b/scripts/templates/packages/svelte/__name__/README.md @@ -0,0 +1,27 @@ +# @dunky.dev/svelte-__name__ + +Svelte binding for [`@dunky.dev/__name__`](../../core/__name__): a compound +component — `__Name__` plus its parts — that drives the framework-free +machine. The root owns the machine; parts translate the core's logical +bindings into DOM attributes and handlers. + +Behavior contract: [`../../core/__name__/SPEC.md`](../../core/__name__/SPEC.md). +Svelte-specific surface: [SPEC.md](./SPEC.md). + +## Install + +```sh +npm install @dunky.dev/svelte-__name__ +``` + +## Usage + +```svelte + + +<__Name__ disable={() => {}}> + <__Name__.Root>go + +``` diff --git a/scripts/templates/packages/svelte/__name__/SPEC.md b/scripts/templates/packages/svelte/__name__/SPEC.md new file mode 100644 index 0000000..971099f --- /dev/null +++ b/scripts/templates/packages/svelte/__name__/SPEC.md @@ -0,0 +1,34 @@ +# SPEC / Svelte / __Name__ + +The Svelte implementation of the [core spec](../../core/__name__/SPEC.md). + +## Docs + +🔗 [`dunky.dev/ui/components/__name__`](https://dunky.dev/ui/components/__name__). + + +## Install + +```sh +npm install @dunky.dev/svelte-__name__ +``` + +## Usage + + +```svelte + + +<__Name__ /> +``` + + +## API + + +| Prop | Type | Default | Description | +| --- | --- | --- | --- | +| `prop` | `number` | `1337` | Magical number. | +| `...` | `...` | `...` | ... | diff --git a/scripts/templates/packages/svelte/__name__/package.json b/scripts/templates/packages/svelte/__name__/package.json new file mode 100644 index 0000000..80e87f4 --- /dev/null +++ b/scripts/templates/packages/svelte/__name__/package.json @@ -0,0 +1,52 @@ +{ + "name": "@dunky.dev/svelte-__name__", + "version": "0.0.0", + "description": "Svelte binding for @dunky.dev/__name__.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/dunky-dev/ui.git", + "directory": "packages/svelte/__name__" + }, + "files": [ + "dist", + "src", + "SPEC.md" + ], + "type": "module", + "sideEffects": false, + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "svelte": "./src/index.ts" + } + }, + "publishConfig": { + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "svelte": "./dist/index.js" + } + }, + "access": "public" + }, + "scripts": { + "build": "svelte-package -i src && publint" + }, + "dependencies": { + "@dunky.dev/__name__": "workspace:*", + "@dunky.dev/svelte-state-machine": "^0.3.0" + }, + "devDependencies": { + "@sveltejs/package": "^3.0.0", + "@testing-library/svelte": "^5.4.2", + "publint": "^0.3.21", + "svelte": "^5.57.1", + "typescript": "^6.0.3" + }, + "peerDependencies": { + "svelte": ">=5.20.0 <5.33.5 || ^5.34.5" + } +} diff --git a/scripts/templates/packages/svelte/__name__/src/__name__-root.svelte b/scripts/templates/packages/svelte/__name__/src/__name__-root.svelte new file mode 100644 index 0000000..27cbf89 --- /dev/null +++ b/scripts/templates/packages/svelte/__name__/src/__name__-root.svelte @@ -0,0 +1,17 @@ + + + diff --git a/scripts/templates/packages/svelte/__name__/src/__name__.svelte b/scripts/templates/packages/svelte/__name__/src/__name__.svelte new file mode 100644 index 0000000..33de0e7 --- /dev/null +++ b/scripts/templates/packages/svelte/__name__/src/__name__.svelte @@ -0,0 +1,11 @@ + + +{@render children?.()} diff --git a/scripts/templates/packages/svelte/__name__/src/context.ts b/scripts/templates/packages/svelte/__name__/src/context.ts new file mode 100644 index 0000000..3eb5957 --- /dev/null +++ b/scripts/templates/packages/svelte/__name__/src/context.ts @@ -0,0 +1,23 @@ +import { getContext, hasContext, setContext } from 'svelte' +import type { __Name__Api, __Name__Machine } from '@dunky.dev/__name__' + +export interface __Name__ContextValue { + // A getter: the connected api is a fresh snapshot per machine change, so a + // destructured read would freeze the first one. + readonly api: __Name__Api + machine: __Name__Machine +} + +// A private key rather than `createContext()`: its `has` only arrived in +// Svelte 5.57, far above the substrate's floor (5.20). +const __camelName__Context = Symbol('__name__') + +export const set__Name__Context = (context: __Name__ContextValue): __Name__ContextValue => + setContext(__camelName__Context, context) + +export const use__Name__Context = (): __Name__ContextValue => { + if (!hasContext(__camelName__Context)) { + throw new Error('__Name__ parts must be rendered within a <__Name__> root') + } + return getContext<__Name__ContextValue>(__camelName__Context) +} diff --git a/scripts/templates/packages/svelte/__name__/src/effects.ts b/scripts/templates/packages/svelte/__name__/src/effects.ts new file mode 100644 index 0000000..0141c51 --- /dev/null +++ b/scripts/templates/packages/svelte/__name__/src/effects.ts @@ -0,0 +1,22 @@ +import type { ComponentEffect } from '@dunky.dev/svelte-state-machine' +import type { __Name__Machine, __Name__Options } from '@dunky.dev/__name__' + +// Substrate effects: prop-driven or platform work the machine can't own. +// useMachine runs one $effect per entry, keyed on the listed prop deps. +type __Name__Effect = ComponentEffect<__Name__Machine, __Name__Options> + +// Config that lives in machine context is synced through events, so guards keep +// working at runtime — the machine never reads props. Document listeners and +// anything else a DOM host would write identically do NOT belong here — they +// live in @dunky.dev/dom-__name__, so every DOM substrate shares one copy. +const syncDisabled: __Name__Effect = [ + (machine, props) => { + const disabled = props.disabled ?? false + if (machine.context.disabled !== disabled) { + machine.send({ type: 'SET_DISABLED', disabled }) + } + }, + ['disabled'], +] + +export const __camelName__Effects: __Name__Effect[] = [syncDisabled] diff --git a/scripts/templates/packages/svelte/__name__/src/index.ts b/scripts/templates/packages/svelte/__name__/src/index.ts new file mode 100644 index 0000000..52fdbe4 --- /dev/null +++ b/scripts/templates/packages/svelte/__name__/src/index.ts @@ -0,0 +1,18 @@ +import type { Component } from 'svelte' +import Primitive from './__name__.svelte' +import Root from './__name__-root.svelte' +import type { __Name__Props } from './types.js' + +interface Parts { + Root: typeof Root +} + +// One component per file is Svelte's unit, so the compound is assembled here: +// the parts hang off the root as statics, reached as `<__Name__.Root>`. The +// root binds nothing (`''`) — widen it only for a prop that can hold a binding. +export const __Name__: Component<__Name__Props, {}, ''> & Parts = Object.assign(Primitive, { + Root, +}) + +export type { __Name__Props, __Name__RootProps } from './types.js' +export type { __Name__Callbacks, __Name__Options } from '@dunky.dev/__name__' diff --git a/scripts/templates/packages/svelte/__name__/src/types.ts b/scripts/templates/packages/svelte/__name__/src/types.ts new file mode 100644 index 0000000..5a19d95 --- /dev/null +++ b/scripts/templates/packages/svelte/__name__/src/types.ts @@ -0,0 +1,14 @@ +import type { Snippet } from 'svelte' +import type { HTMLButtonAttributes } from 'svelte/elements' +import type { __Name__Options } from '@dunky.dev/__name__' + +export interface __Name__Props extends __Name__Options { + /** The __name__'s parts. */ + children?: Snippet +} + +// TODO(spec): one props interface per part of the anatomy in SPEC.md. +export interface __Name__RootProps extends HTMLButtonAttributes { + /** The rendered `