From ed44ec236463378c20f935225f61bb38543ce6ed Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Mon, 5 Oct 2026 15:33:48 +0200 Subject: [PATCH 01/18] feat(svelte): the Svelte substrate harness and workspace wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit packages/svelte is the private @dunky-dev/svelte harness for Svelte 5: Storybook on port 6009 (@storybook/svelte-vite + Svelte CSF, `pnpm dev:svelte`), two vitest projects — DOM through svelteTesting() and jsdom, and SSR through svelte/server, which needs Svelte's server runtime where the DOM project resolves the browser one — and svelte-check for the typecheck: tsc can't read .svelte, so the root tsconfig excludes the directory like packages/solid and `pnpm typecheck` runs svelte-check after tsc. The Svelte packages build outside tsdown: they ship uncompiled through @sveltejs/package for the consumer's Svelte to compile (ARCHITECTURE.md records the exception). `pnpm build` chains `pnpm build:svelte` after tsdown, so changeset:publish ships them too. svelte-check and the package build drive TypeScript's classic API, which TypeScript 7 dropped, so the Svelte packages hold a local typescript@^6 while the root stays on 7. oxfmt can't parse .svelte yet, so lint-staged runs only oxlint (which reads the script blocks) on staged .svelte files. knip takes the Svelte stories as entries; .svelte-kit (svelte-package's scratch copy) is ignored. The lockfile and the pnpm override that links @dunky.dev/svelte-state-machine to its in-progress branch (dunky-dev/state-machine#34) stay uncommitted until the adapter is on npm; its first publish is excluded from the release-age gate here. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 2 ++ .lintstagedrc.ts | 16 +++++++++---- knip.config.ts | 3 +++ package.json | 6 +++-- packages/svelte/.storybook/main.ts | 24 +++++++++++++++++++ packages/svelte/.storybook/manager.ts | 14 +++++++++++ packages/svelte/AGENTS.md | 34 +++++++++++++++++++++++++++ packages/svelte/package.json | 23 ++++++++++++++++++ packages/svelte/tsconfig.json | 15 ++++++++++++ packages/svelte/vitest.config.ts | 19 +++++++++++++++ packages/svelte/vitest.ssr.config.ts | 16 +++++++++++++ pnpm-workspace.yaml | 1 + tsconfig.json | 10 ++++++-- tsdown.config.ts | 4 +++- vitest.config.ts | 11 ++++++--- 15 files changed, 186 insertions(+), 12 deletions(-) create mode 100644 packages/svelte/.storybook/main.ts create mode 100644 packages/svelte/.storybook/manager.ts create mode 100644 packages/svelte/AGENTS.md create mode 100644 packages/svelte/package.json create mode 100644 packages/svelte/tsconfig.json create mode 100644 packages/svelte/vitest.config.ts create mode 100644 packages/svelte/vitest.ssr.config.ts 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 + +
...
+``` 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..0c97dc3 --- /dev/null +++ b/packages/svelte/hooks/use-focus-trap/README.md @@ -0,0 +1,25 @@ +# @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..51b41fc --- /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.40.0" + } +} 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..c4d976a --- /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.40.0" + } +} 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() + }) +}) From d7c8859e1508848f7f50bf85521c0f2f1ed1fbf7 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Mon, 5 Oct 2026 15:34:26 +0200 Subject: [PATCH 03/18] feat(svelte): the Dialog primitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @dunky.dev/svelte-dialog: the compound Dialog for Svelte 5 — Trigger, Portal, Backdrop, Viewport, Content, Title, Description, Close hung off the root as statics (`Component & Parts`, assembled in index.ts since a .svelte file holds one component). Same anatomy and behavior contract as the React and Solid bindings; every DOM sequence comes from @dunky.dev/dom-dialog and every decision from the core machine — the binding adds Svelte's lifecycle only. Svelte-specific decisions the code encodes: - The connected api is a fresh snapshot per machine change, so the open and exit edges key on `$derived(api.open)` and read everything else untracked — a presence change must not re-run the focus sequence. The Portal keys on `mounted` the same way. - Portal mounts the layers as their own tree with mount() and getAllContexts(): context survives, children land in the target with no wrapper element (the containment and exit walks depend on that), a container swap re-mounts, nothing renders on the server, and the layer re-provides the context with its container for the scroll lock. - Ids come from the root's $props.id(), with `options.id ?? id` so an explicit undefined keeps the fallback. - `open` + the onOpenChange callback prop; `open` is not $bindable(): a child can't tell bound from passed, and an unbound write would be a local override — a controlled dialog moving on its own. - Element access is a bindable `ref` on every element part; initialFocus and restoreFocus take an element or a getter, since bind:this fills after initialization. The backdrop handle is a plain box read on demand. Tests: the React/Solid suite ported 1:1 (57 tests, same names and scenarios) through .svelte fixtures, plus the Svelte corners — id minting, a Title reporting before the machine starts (child effects run first), presence churn leaving focus alone, the container swap, bind:ref — and SSR renders of a closed and an open dialog with stable ids. Co-Authored-By: Claude Opus 5.5 --- .changeset/svelte-dialog.md | 46 ++ packages/svelte/dialog/README.md | 42 ++ packages/svelte/dialog/SPEC.md | 239 +++++++ packages/svelte/dialog/package.json | 55 ++ packages/svelte/dialog/src/context.ts | 33 + .../svelte/dialog/src/dialog-backdrop.svelte | 32 + .../svelte/dialog/src/dialog-close.svelte | 14 + .../svelte/dialog/src/dialog-content.svelte | 84 +++ .../dialog/src/dialog-description.svelte | 18 + .../svelte/dialog/src/dialog-layer.svelte | 24 + .../svelte/dialog/src/dialog-portal.svelte | 40 ++ .../svelte/dialog/src/dialog-title.svelte | 20 + .../svelte/dialog/src/dialog-trigger.svelte | 14 + .../svelte/dialog/src/dialog-viewport.svelte | 24 + packages/svelte/dialog/src/dialog.svelte | 54 ++ packages/svelte/dialog/src/index.ts | 48 ++ packages/svelte/dialog/src/types.ts | 61 ++ packages/svelte/dialog/src/use-dialog.ts | 21 + .../svelte/dialog/tests/dialog-svelte.test.ts | 111 +++ .../svelte/dialog/tests/dialog.ssr.test.ts | 23 + packages/svelte/dialog/tests/dialog.test.ts | 649 ++++++++++++++++++ .../tests/fixtures/close-first-dialog.svelte | 14 + .../tests/fixtures/controlled-stack.svelte | 35 + .../tests/fixtures/default-dialog.svelte | 20 + .../tests/fixtures/form-field-dialog.svelte | 12 + .../tests/fixtures/guarded-stack.svelte | 21 + .../fixtures/initial-focus-dialog.svelte | 16 + .../tests/fixtures/nested-dialog.svelte | 26 + .../tests/fixtures/nested-guards.svelte | 20 + .../tests/fixtures/presence-dialog.svelte | 20 + .../dialog/tests/fixtures/ref-dialog.svelte | 29 + .../fixtures/restore-focus-dialog.svelte | 15 + .../tests/fixtures/scoped-dialog.svelte | 11 + .../tests/fixtures/stack-scoped-dialog.svelte | 22 + .../tests/fixtures/untitled-dialog.svelte | 9 + 35 files changed, 1922 insertions(+) create mode 100644 .changeset/svelte-dialog.md create mode 100644 packages/svelte/dialog/README.md create mode 100644 packages/svelte/dialog/SPEC.md create mode 100644 packages/svelte/dialog/package.json create mode 100644 packages/svelte/dialog/src/context.ts create mode 100644 packages/svelte/dialog/src/dialog-backdrop.svelte create mode 100644 packages/svelte/dialog/src/dialog-close.svelte create mode 100644 packages/svelte/dialog/src/dialog-content.svelte create mode 100644 packages/svelte/dialog/src/dialog-description.svelte create mode 100644 packages/svelte/dialog/src/dialog-layer.svelte create mode 100644 packages/svelte/dialog/src/dialog-portal.svelte create mode 100644 packages/svelte/dialog/src/dialog-title.svelte create mode 100644 packages/svelte/dialog/src/dialog-trigger.svelte create mode 100644 packages/svelte/dialog/src/dialog-viewport.svelte create mode 100644 packages/svelte/dialog/src/dialog.svelte create mode 100644 packages/svelte/dialog/src/index.ts create mode 100644 packages/svelte/dialog/src/types.ts create mode 100644 packages/svelte/dialog/src/use-dialog.ts create mode 100644 packages/svelte/dialog/tests/dialog-svelte.test.ts create mode 100644 packages/svelte/dialog/tests/dialog.ssr.test.ts create mode 100644 packages/svelte/dialog/tests/dialog.test.ts create mode 100644 packages/svelte/dialog/tests/fixtures/close-first-dialog.svelte create mode 100644 packages/svelte/dialog/tests/fixtures/controlled-stack.svelte create mode 100644 packages/svelte/dialog/tests/fixtures/default-dialog.svelte create mode 100644 packages/svelte/dialog/tests/fixtures/form-field-dialog.svelte create mode 100644 packages/svelte/dialog/tests/fixtures/guarded-stack.svelte create mode 100644 packages/svelte/dialog/tests/fixtures/initial-focus-dialog.svelte create mode 100644 packages/svelte/dialog/tests/fixtures/nested-dialog.svelte create mode 100644 packages/svelte/dialog/tests/fixtures/nested-guards.svelte create mode 100644 packages/svelte/dialog/tests/fixtures/presence-dialog.svelte create mode 100644 packages/svelte/dialog/tests/fixtures/ref-dialog.svelte create mode 100644 packages/svelte/dialog/tests/fixtures/restore-focus-dialog.svelte create mode 100644 packages/svelte/dialog/tests/fixtures/scoped-dialog.svelte create mode 100644 packages/svelte/dialog/tests/fixtures/stack-scoped-dialog.svelte create mode 100644 packages/svelte/dialog/tests/fixtures/untitled-dialog.svelte diff --git a/.changeset/svelte-dialog.md b/.changeset/svelte-dialog.md new file mode 100644 index 0000000..4ae3a04 --- /dev/null +++ b/.changeset/svelte-dialog.md @@ -0,0 +1,46 @@ +--- +'@dunky.dev/svelte-dialog': minor +--- + +New substrate: the Svelte binding for `@dunky.dev/dialog`, for Svelte 5 (peer +`svelte@^5.40.0`). 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. + +```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/packages/svelte/dialog/README.md b/packages/svelte/dialog/README.md new file mode 100644 index 0000000..a1cdd72 --- /dev/null +++ b/packages/svelte/dialog/README.md @@ -0,0 +1,42 @@ +# @dunky.dev/svelte-dialog + +Svelte binding for [`@dunky.dev/dialog`](../../core/dialog): a compound +component — `Dialog` plus its parts — that drives the framework-free dialog +machine. The root owns the machine; parts translate the core's logical +bindings into DOM attributes and handlers, and wire the DOM-only concerns +(portal, focus trap, scroll lock, layer stack). + +Behavior contract: [`../../core/dialog/SPEC.md`](../../core/dialog/SPEC.md). +Svelte-specific surface: [SPEC.md](./SPEC.md). + +## Install + +```sh +npm install @dunky.dev/svelte-dialog +``` + +The components ship uncompiled, for your Svelte (`^5.40.0`) to compile — any +Svelte-aware bundler setup picks them up. + +## Usage + +```svelte + + + + 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..2c50c29 --- /dev/null +++ b/packages/svelte/dialog/SPEC.md @@ -0,0 +1,239 @@ +# 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.40.0`. + +## Usage + +```svelte + + + + Open + + + + + Title + Description + Close + + + + +``` + +Svelte-specific notes on top of the core contract: + +- **Packaging**: the package ships its components uncompiled — `.svelte` + files and `.svelte.js` rune modules under the `svelte` export condition — + for your own Svelte compiler to compile, client and server alike. 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, 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. Render it ahead of the `Viewport`, in the + anatomy's order: Svelte binds elements after mount in tree order, so the + layer's registration — run by `Content` — only sees a backdrop bound before + it, and a backdrop rendered after the viewport is contained (made inert) + with the rest of the page. +- **Exit animation** (`animated`): style the exit on the parts' + `data-state="closing"` — a CSS transition or animation on **Content** (the + element carrying the state, not a descendant) is what signals completion; + a missing exit style falls back to a short ceiling, and + `prefers-reduced-motion` skips the wait entirely. The exit is cosmetic: + focus, the dialog stack, and page interaction release the moment closing + starts, and the still-painting layer is made `inert` until it unmounts. + Enter needs no state — the parts mount straight into `data-state="open"`, + so a CSS animation (or a transition via `@starting-style`) plays from + mount. +- **Back navigation** (`closeOnBack`): opening plants a guard entry in the + session history, so the browser's Back closes the dialog instead of leaving + the page — one layer per press in a nested stack, per the core contract. A + dialog closed any other way consumes its entry, leaving nothing to swallow + a later Back; an entry buried under in-app navigation while the dialog is + open is left alone (Back then both navigates and closes the dialog). + 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). + +## 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` + +Opens 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..c9a5a4b --- /dev/null +++ b/packages/svelte/dialog/src/dialog-content.svelte @@ -0,0 +1,84 @@ + + + +
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..bf43d1c --- /dev/null +++ b/packages/svelte/dialog/src/dialog-layer.svelte @@ -0,0 +1,24 @@ + + +{@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..df5d6a9 --- /dev/null +++ b/packages/svelte/dialog/src/dialog-portal.svelte @@ -0,0 +1,40 @@ + 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..e8c6c8b --- /dev/null +++ b/packages/svelte/dialog/src/dialog-trigger.svelte @@ -0,0 +1,14 @@ + + + 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..2d3ac37 --- /dev/null +++ b/packages/svelte/dialog/src/index.ts @@ -0,0 +1,48 @@ +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 ``. +export const Dialog: Component & Parts = Object.assign(Root, { + Trigger, + Portal, + Backdrop, + Viewport, + Content, + Title, + Description, + Close, +}) + +export type { + 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/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/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/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/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/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 + + From 9ab8e708e0c1287a8921408c22a8f1fcbf875c4b Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Mon, 5 Oct 2026 15:34:38 +0200 Subject: [PATCH 04/18] docs(stories): the Svelte Dialog story set, mirrored one-for-one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The twelve React/Solid scenarios — standard, alertDialog, longContent, loginForm, trigger, controlled, scoped, nested, innerPopup, closeOnBack, nestedCloseOnBack, containment — as Svelte CSF (@storybook/addon-svelte-csf). Each story is a static `asChild` composition, like the React/Solid `render` functions, and `exportName` keeps the story ids identical across the three Storybooks. A story that needs local state (the controlled, alert, scoped, nested, listbox, and containment ones) is a component in stories/ — Svelte's unit of state — and the shared styles are strings in stories/styles.ts. Every scenario ports. Two Svelte notes: the scoped and containment stories gate their portalled dialog on the container's `bind:this`, which fills after mount (React's shape, since its ref lands in state the same way); and the containment story's `[inert]` rule renders through {@html} — a component '} +
+ 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 (event.key === 'Escape') close() + if (event.key === 'Tab') open = false + }} + > + {#each editors as editor (editor)} +
  • pick(editor)} + onkeydown={event => { + if (event.key === 'Enter' || event.key === ' ') pick(editor) + }} + > + {editor} +
  • + {/each} +
+ {/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); +` From 0d836ac1ba692ca99400b51bb7564881663bdf5a Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Mon, 5 Oct 2026 15:34:47 +0200 Subject: [PATCH 05/18] feat(scaffold): stamp a Svelte binding per primitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/templates/packages/svelte/__name__ mirrors the React and Solid templates in Svelte's shape: a root component that owns the machine and provides the context, a placeholder Root part spreading mergeProps(rest, normalize(api.parts.root)) with a bindable ref, the compound assembled in index.ts, effects + use- over the adapter's useMachine, a Svelte CSF story, and a fixture-based test. The scaffold wires the tsdown workspace only with packages whose build is tsdown, read from the stamped package.json — a Svelte package builds with @sveltejs/package, so it gets its tsconfig path and nothing more. Co-Authored-By: Claude Opus 5.5 --- scripts/scaffold.ts | 11 ++-- scripts/templates/README.md | 3 +- .../packages/svelte/__name__/README.md | 27 ++++++++++ .../packages/svelte/__name__/SPEC.md | 34 ++++++++++++ .../packages/svelte/__name__/package.json | 52 +++++++++++++++++++ .../svelte/__name__/src/__name__-root.svelte | 16 ++++++ .../svelte/__name__/src/__name__.svelte | 11 ++++ .../packages/svelte/__name__/src/context.ts | 21 ++++++++ .../packages/svelte/__name__/src/effects.ts | 22 ++++++++ .../packages/svelte/__name__/src/index.ts | 15 ++++++ .../packages/svelte/__name__/src/types.ts | 14 +++++ .../svelte/__name__/src/use-__name__.ts | 15 ++++++ .../__name__/stories/__name__.stories.svelte | 18 +++++++ .../svelte/__name__/tests/__name__.test.ts | 33 ++++++++++++ .../tests/fixtures/default-__name__.svelte | 9 ++++ 15 files changed, 297 insertions(+), 4 deletions(-) create mode 100644 scripts/templates/packages/svelte/__name__/README.md create mode 100644 scripts/templates/packages/svelte/__name__/SPEC.md create mode 100644 scripts/templates/packages/svelte/__name__/package.json create mode 100644 scripts/templates/packages/svelte/__name__/src/__name__-root.svelte create mode 100644 scripts/templates/packages/svelte/__name__/src/__name__.svelte create mode 100644 scripts/templates/packages/svelte/__name__/src/context.ts create mode 100644 scripts/templates/packages/svelte/__name__/src/effects.ts create mode 100644 scripts/templates/packages/svelte/__name__/src/index.ts create mode 100644 scripts/templates/packages/svelte/__name__/src/types.ts create mode 100644 scripts/templates/packages/svelte/__name__/src/use-__name__.ts create mode 100644 scripts/templates/packages/svelte/__name__/stories/__name__.stories.svelte create mode 100644 scripts/templates/packages/svelte/__name__/tests/__name__.test.ts create mode 100644 scripts/templates/packages/svelte/__name__/tests/fixtures/default-__name__.svelte 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..197874c --- /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.1.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.40.0" + } +} 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..0f3a196 --- /dev/null +++ b/scripts/templates/packages/svelte/__name__/src/__name__-root.svelte @@ -0,0 +1,16 @@ + + + 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..1567df1 --- /dev/null +++ b/scripts/templates/packages/svelte/__name__/src/context.ts @@ -0,0 +1,21 @@ +import { createContext } 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 +} + +const [get__Name__Context, set__Name__Context, has__Name__Context] = + createContext<__Name__ContextValue>() + +export { set__Name__Context } + +export const use__Name__Context = (): __Name__ContextValue => { + if (!has__Name__Context()) { + throw new Error('__Name__ parts must be rendered within a <__Name__> root') + } + return get__Name__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..e49d928 --- /dev/null +++ b/scripts/templates/packages/svelte/__name__/src/index.ts @@ -0,0 +1,15 @@ +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>`. +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 `