diff --git a/.changeset/dom-dialog.md b/.changeset/dom-dialog.md new file mode 100644 index 0000000..6b09b3a --- /dev/null +++ b/.changeset/dom-dialog.md @@ -0,0 +1,40 @@ +--- +'@dunky.dev/dom-dialog': minor +'@dunky.dev/react-dialog': patch +'@dunky.dev/solid-dialog': patch +--- + +New package: `@dunky.dev/dom-dialog`, the framework-free DOM half of the +Dialog. The React and Solid bindings had grown two copies of the same +document-level code — the Escape listener, the ordered focus/stack sequence +around the open edge, the exit window, the session-history guard, the +outside-press gating — differing only in which lifecycle scheduled them. That +duplication is the drift risk the architecture exists to remove, and it would +have been copied a third time for Vue. + +Both bindings now contribute only their host's lifecycle: + +```ts +// before — the same twenty lines in every DOM substrate +const previous = document.activeElement +const unregister = registerLayer({ id, depth, element: content, modal, backdrop }) +const target = initialFocus ?? getInitialFocus(content) +target.focus({ preventScroll: true }) +// ... + +// after +return openDialogLayer(content, { id, depth, modal, backdrop, initialFocus }) +``` + +The ordering that made those sequences correct — the stack joins before focus +moves in, and releases the layers beneath before focus moves back out — is now +stated and tested in one place rather than re-derived per substrate. + +No consumer-visible behavior changes in either binding; this is an internal +extraction. `@dunky.dev/dom-dialog` is published because the bindings depend on +it at runtime, and a substrate outside this repo can build on it directly. + +This also establishes `packages/dom/components/` as a layer: a DOM package +scoped to one primitive, which may import that primitive's core package and any +DOM util, but never a framework. `pnpm scaffold ` stamps one for every new +primitive. diff --git a/.changeset/solid-dialog.md b/.changeset/solid-dialog.md new file mode 100644 index 0000000..1c3fc7d --- /dev/null +++ b/.changeset/solid-dialog.md @@ -0,0 +1,37 @@ +--- +'@dunky.dev/solid-dialog': minor +--- + +New substrate: the Solid binding for `@dunky.dev/dialog`, targeting Solid 2.0 +(peers: `solid-js` and `@solidjs/web` at `^2.0.0-rc.1`; 1.x is unsupported — +the binding stands on 2.0's primitives). The same compound anatomy and +behavior contract as the React binding — one core machine, a new host — +delivered in Solid's native shape: the connected api is a fine-grained store, +so a machine transition updates exactly the bindings that changed, and the +core options are plain reactive props (per the controlled contract a +dismissal on a controlled dialog reports nothing — decide it at its source in +the dismissal callbacks, which carry `preventDefault()` for the veto). + +```tsx +import { Dialog } from '@dunky.dev/solid-dialog' +; setOpen(false)}> + Open + + + + + Title + Description + Close + + + + +``` + +`Content`'s `initialFocus` accepts an element or an accessor resolved at open +time — the Solid idiom for a ref variable that fills during render, so +`initialFocus={() => cancelButton}` works. 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/solid-hooks.md b/.changeset/solid-hooks.md new file mode 100644 index 0000000..a06afcf --- /dev/null +++ b/.changeset/solid-hooks.md @@ -0,0 +1,14 @@ +--- +'@dunky.dev/solid-use-focus-trap': minor +'@dunky.dev/solid-use-scroll-lock': minor +--- + +New substrate: the Solid lifecycle wrappers over the framework-free DOM utils, +mirroring the React hooks one-for-one and targeting Solid 2.0 (peer +`solid-js@^2.0.0-rc.1`). `useFocusTrap(target, options?)` takes an accessor +for the container (a plain ref variable fills during render, so the trap arms +on mount and re-arms when a reactive accessor yields a new element); +`useScrollLock(locked?, target?)` accepts a `MaybeAccessor` 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 +primitives own only the lifecycle. diff --git a/.changeset/state-machine-0-3-3.md b/.changeset/state-machine-0-3-3.md new file mode 100644 index 0000000..c53a603 --- /dev/null +++ b/.changeset/state-machine-0-3-3.md @@ -0,0 +1,18 @@ +--- +'@dunky.dev/controllable': patch +'@dunky.dev/dialog': patch +'@dunky.dev/native-dialog': patch +'@dunky.dev/react-dialog': patch +--- + +Update the state-machine packages to the 2026-08-22 release: runtime `0.3.3`, +bindings `0.4.1`, utils `0.4.0`, and the React (`0.3.4`), Solid (`0.3.0`), and +native (`0.4.0`) adapters. + +Every range moves together on purpose. The published adapters pin the runtime +exactly (`@dunky.dev/state-machine: 0.3.3`), so a package left on an older +caret would have pulled a second physical copy of the runtime into a consumer's +install — the dependency diamond `ARCHITECTURE.md` warns about, where anything +identity-sensitive (a singleton, a `WeakMap`, module-level state) silently stops +agreeing across the two copies. `@dunky.dev/controllable` was the oldest +offender, still on `^0.1.0`; the tree now resolves to a single runtime. diff --git a/AGENTS.md b/AGENTS.md index eb35b3c..4e94b33 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,11 +25,12 @@ editing files in that scope — it overrides anything here for that scope ## Scopes -| Scope | Path | What it is | -| ---------- | ------------------------- | -------------------------------------------------------- | -| Core | `packages/core/**` | Framework-free state machines, one package per primitive | -| DOM | `packages/dom/**` | Framework-free DOM utilities, one package per util | -| Substrates | `packages//**` | Thin host bindings (e.g. `packages/react`) | +| Scope | Path | What it is | +| ---------- | ---------------------------- | --------------------------------------------------------- | +| Core | `packages/core/**` | Framework-free state machines, one package per primitive | +| DOM utils | `packages/dom/utils/**` | Framework-free DOM utilities, one package per util | +| DOM parts | `packages/dom/components/**` | Framework-free DOM half of one primitive, shared by hosts | +| Substrates | `packages//**` | Thin host bindings (e.g. `packages/react`) | Some changes are cross-scope. Check what else your change touches before calling it done. @@ -48,13 +49,21 @@ architecture: - **Dependency direction is one-way.** A substrate package imports its core counterpart, its substrate's state-machine adapter - (`@dunky.dev/-state-machine`), its own hooks, and the DOM utils — - nothing else from this repo. A core package imports only the state-machine - runtime, the agnostic bindings vocabulary + (`@dunky.dev/-state-machine`), its own hooks, the DOM utils, and — + if it's a DOM host — its primitive's DOM component package + (`@dunky.dev/dom-`). Nothing else from this repo. A core package + imports only the state-machine runtime, the agnostic bindings vocabulary (`@dunky.dev/state-machine` + `@dunky.dev/state-machine-bindings`), and the machine utils under `core/utils`. A machine util imports only the runtime; - a DOM util imports nothing from this repo; a substrate hook imports only - the DOM util it wraps. + a DOM util imports nothing from this repo; a DOM component imports its core + counterpart and the DOM utils, never a framework; a substrate hook imports + only the DOM util it wraps. +- **DOM behavior is written once too.** Logic that is DOM-specific but not + framework-specific — a document listener, an ordered focus/stack sequence — + belongs in `dom/components/`, not copied across substrates. A DOM + binding contributes its host's lifecycle and nothing else. Before writing an + effect body in a substrate, ask whether the other DOM substrates would write + the same one. - **Primitives are independent.** No cross-imports between primitives. If two need to share logic, that's a design decision — a new package — never a cross-import. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 12b2643..e232527 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -28,8 +28,11 @@ packages/ | +- dom/ | +- utils/ framework-free DOM utilities, one package per util -| +- focus-trap/ @dunky.dev/dom-focus-trap -| +- scroll-lock/ @dunky.dev/dom-scroll-lock +| | +- focus-trap/ @dunky.dev/dom-focus-trap +| | +- scroll-lock/ @dunky.dev/dom-scroll-lock +| | +- ... +| +- components/ framework-free DOM half of a primitive +| +- dialog/ @dunky.dev/dom-dialog | +- ... | +- / any future host, same shape @@ -57,6 +60,15 @@ scroll locking — lives once as a framework-free util under `dom/utils/`; each substrate wraps what needs a lifecycle in a thin hook under its own `hooks/` folder. A new substrate reuses all of it and only writes the wrappers. +DOM logic that belongs to **one** primitive but to **every** DOM substrate — +the dialog's Escape listener, the ordered sequence around its open and exit +edges — lives under `dom/components/` instead. A util is primitive-agnostic +and imports nothing from the repo; a component package is the opposite, and +may import the primitive's core package and any DOM util. Both are equally +framework-free. The split matters as substrates multiply: React, Solid, and +Vue differ in how they schedule an effect, not in what the effect does, so the +what is written once and each binding contributes only its lifecycle. + Machine logic that several primitives need — the controlled/uncontrolled machinery (`@dunky.dev/controllable`) — lives the same way under `core/utils/`: substrate-free helpers a core machine composes into its @@ -103,12 +115,15 @@ level down. Internal infra uses the `@dunky-dev` scope; published packages use The rules, stated as imports: - A substrate package imports its core counterpart, its substrate's - state-machine adapter, its own hooks, and the DOM utils — nothing else from - this repo. + state-machine adapter, its own hooks, the DOM utils, and — for a DOM + substrate — its primitive's `dom/components` package. Nothing else from this + repo. - A core package imports only the state-machine runtime and the agnostic bindings vocabulary. - A DOM util imports nothing from this repo; a substrate hook imports only the DOM util it wraps. +- A `dom/components` package imports its core counterpart and the DOM utils — + never a framework, and never another primitive. - Primitives are independent of each other. If two need to share logic, that sharing is a design decision (a new package), never a cross-import. @@ -163,7 +178,9 @@ packages/// @dunky.dev/- context.ts compound context: the root provides { api, machine } use-.ts the machine owner: wraps the adapter's useMachine (create once, option re-sync, effects), mints ids - effects.ts ComponentEffects: prop-driven / document-level work + effects.ts ComponentEffects: prop-driven / document-level work — + only where the host has no shared package to take them + from (a DOM substrate uses @dunky.dev/dom-) .tsx root + parts: wires behavior onto host elements, via the adapter's normalize + mergeProps tests/ diff --git a/knip.config.ts b/knip.config.ts index d17eefc..9f7fa4c 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -17,6 +17,17 @@ const config: KnipConfig = { 'packages/react/*': { entry: ['stories/*.stories.tsx'], }, + // knip's storybook plugin doesn't know the community solid framework; + // jest-dom is loaded via a setup file vite-plugin-solid injects. + 'packages/solid': { + entry: ['.storybook/main.ts', '.storybook/manager.ts'], + ignoreDependencies: ['@testing-library/jest-dom'], + }, + 'packages/solid/*': { + entry: ['stories/*.stories.tsx'], + // The babel presets are referenced as strings in tsdown.config.ts. + ignoreDependencies: ['babel-preset-solid', '@babel/preset-typescript'], + }, }, } diff --git a/package.json b/package.json index c625454..8e47258 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "test:native": "pnpm --filter @dunky-dev/native test", "test:ci": "vitest run && pnpm test:native", "build": "tsdown", - "typecheck": "tsc --noEmit", + "typecheck": "tsc --noEmit && tsc --noEmit -p packages/solid", "lint": "oxlint --ignore-pattern '.worktrees' .", "format": "oxfmt .", "format:check": "oxfmt --check .", @@ -17,6 +17,7 @@ "scaffold": "node scripts/scaffold.ts", "dev": "pnpm dev:react", "dev:react": "pnpm --filter @dunky-dev/react dev", + "dev:solid": "pnpm --filter @dunky-dev/solid dev", "dev:expo": "pnpm --filter @dunky-dev/native dev", "dev:ios": "pnpm --filter @dunky-dev/native ondevice:ios", "dev:android": "pnpm --filter @dunky-dev/native ondevice:android", diff --git a/packages/core/dialog/package.json b/packages/core/dialog/package.json index 0df5913..ee13e47 100644 --- a/packages/core/dialog/package.json +++ b/packages/core/dialog/package.json @@ -37,7 +37,7 @@ }, "dependencies": { "@dunky.dev/controllable": "workspace:*", - "@dunky.dev/state-machine": "^0.3.2", - "@dunky.dev/state-machine-bindings": "^0.3.2" + "@dunky.dev/state-machine": "^0.3.3", + "@dunky.dev/state-machine-bindings": "^0.4.1" } } diff --git a/packages/core/utils/controllable/package.json b/packages/core/utils/controllable/package.json index 905b371..cecb281 100644 --- a/packages/core/utils/controllable/package.json +++ b/packages/core/utils/controllable/package.json @@ -36,6 +36,6 @@ "build": "tsdown" }, "dependencies": { - "@dunky.dev/state-machine": "^0.1.0" + "@dunky.dev/state-machine": "^0.3.3" } } diff --git a/packages/dom/components/dialog/SPEC.md b/packages/dom/components/dialog/SPEC.md new file mode 100644 index 0000000..3009b2c --- /dev/null +++ b/packages/dom/components/dialog/SPEC.md @@ -0,0 +1,130 @@ +# SPEC / DOM / Dialog + +## Overview + +The DOM half of the Dialog, shared by every DOM substrate — React, Solid, and +whatever comes next. Behavior is [`@dunky.dev/dialog`](../../../core/dialog/SPEC.md)'s; +this package owns the part of the wiring that is specific to the document but +not to any framework: the Escape listener, the focus and stack sequences around +the open and exit edges, the session-history guard, and the gating that decides +which press counts as an outside interaction. + +It sits between the DOM utils and the substrate bindings: + +``` + @dunky.dev/dialog core behavior (no DOM) + | + v + @dunky.dev/dom-dialog this package -- DOM, no framework + | ^ + | +--------- @dunky.dev/dom-overlay, -navigation, -focus-trap + v + @dunky.dev/-dialog +``` + +A `dom/utils/*` package is primitive-agnostic and imports nothing from the +repo. A `dom/components/*` package is the opposite: it is about exactly one +primitive, so it may import that primitive's core package and any DOM util. +What it must not do is import a framework, or another primitive. + +Substrate bindings are the only consumers. Each one supplies its host's +lifecycle — an effect, a `createEffect`, a `watchEffect` — and calls into +these; none of them re-derives the order or the conditions. + +## Behavior + +### Document-level effects + +`domDialogEffects` is the core's substrate-free effect list plus the Escape +listener, as the same plain-data tuples the core defines. A substrate passes +the list to its adapter's `useMachine` untouched. + +Escape is bound on the document in the capture phase, not on a part: it must +answer wherever focus is. It closes only the topmost layer, so a nested stack +unwinds one dialog per press, and it offers the consumer's `onEscapeKeyDown` a +veto through `preventDefault` before it moves the machine. + +### The open edge + +`openDialogLayer` runs one ordered sequence and returns its exact inverse: + +1. remember what had focus, +2. join the shared layer stack (which re-syncs assistive-tech containment), +3. move focus to the consumer's `initialFocus`, or the overlay's own choice, +4. fall back to the dialog window when that target refuses focus. + +The disposer releases the stack **before** restoring focus. Both orders are +load-bearing: the stack must exist before focus moves in, and the layers +beneath must be un-inerted before focus can land on one of them. + +Every focus move passes `preventScroll` — the scroll lock has already frozen +the surface, so scrolling it would jump the view on open and again on close. + +The edge is the machine's `open` state, not mount/unmount: an animated dialog +stays mounted through `closing`, and the stack, containment, and focus all +release the moment the exit starts. + +### The exit window + +`startExitWindow` covers the tail of an animated close, when the dialog is +mounted but no longer open. The layer has already left the stack, so the page +beneath is live again; the still-painting layer is taken out of interaction +and watched for the end of its visual, which the substrate forwards to the +machine as `exit.complete`. The disposer undoes both — it is the reopen +interrupt as much as the final unmount. + +### Back navigation + +`guardBackNavigation` plants the session-history entry that turns the host's +Back into a dismissal. It wires mechanics only: whether the dialog may close, +whether the consumer vetoed, and whether a controlled dialog followed are all +the core's answers, read back as "is it still open". + +### Outside presses + +A press dismisses only when it is genuinely outside and genuinely this +dialog's to answer: + +- `acceptsBackdropPress` — the topmost dialog of a stack answers, nobody else. +- `acceptsViewportPress` — content presses bubble to the viewport, so the + press must have started on the viewport itself, and then the same topmost + rule applies. + +### Focus trap + +`dialogTrapOptions` is the trap configuration the substrate hands to its +`trapFocus` wrapper: a modal dialog traps while it is topmost, and the Close +part is the cycle's last stop wherever it renders. + +## API + +| Export | Description | +| ------------------------------------- | ---------------------------------------------------------------------- | +| `domDialogEffects` | Core effects + the document Escape listener, as `DialogEffect` tuples. | +| `openDialogLayer(content, options)` | The open sequence; returns the close sequence. | +| `startExitWindow(content, options)` | Hides and watches the still-painting layer; returns the undo. | +| `guardBackNavigation(options)` | Arms the history guard; returns the release. | +| `acceptsBackdropPress(id)` | Whether a backdrop press is this dialog's outside interaction. | +| `acceptsViewportPress(id, event)` | Same for the viewport, ignoring presses that bubbled from the content. | +| `dialogTrapOptions(machine, closeId)` | `TrapFocusOptions` for the dialog window. | + +## Constraints + +- No framework import, ever — that is the whole point of the layer. +- No decisions of its own. Anything a substrate could answer differently + belongs in the core machine; what lives here is only the DOM realization of + a decision already made. +- Every entry point returns its own disposer, and the disposer undoes exactly + what the call did — substrate lifecycles differ, so nothing may rely on a + particular teardown order between calls. +- Reads that must stay live (`modal`, the topmost check, the Close id) are + taken as the machine or as accessors, never snapshotted at call time. + +## Internals + +| Position | Why | +| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| The open edge is one call, not a `registerLayer` + focus pair | The two orders (join before focus in, release before focus out) are the contract; splitting them puts that ordering back in every substrate, where it drifted before. | +| `dialogTrapOptions` takes the machine rather than plain values | `modal` and the layer id are read per Tab press. Snapshotting them freezes the trap against a context the machine still owns. | +| `closeId` is an accessor while the machine is not | The machine instance is stable; the connected api that carries the ids is re-created per render. | +| Press gating takes a structural `{ target, currentTarget }` | React's synthetic event and Solid's native one share only that shape; requiring either would drag a framework type into this layer. | diff --git a/packages/dom/components/dialog/package.json b/packages/dom/components/dialog/package.json new file mode 100644 index 0000000..ed1fe60 --- /dev/null +++ b/packages/dom/components/dialog/package.json @@ -0,0 +1,47 @@ +{ + "name": "@dunky.dev/dom-dialog", + "version": "0.0.0", + "description": "Framework-free DOM behavior for @dunky.dev/dialog: the document-level effects, the open and exit sequences, and the outside-press gating every DOM substrate shares.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/dunky-dev/ui.git", + "directory": "packages/dom/components/dialog" + }, + "files": [ + "dist", + "src", + "SPEC.md" + ], + "type": "module", + "sideEffects": false, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "publishConfig": { + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "access": "public" + }, + "scripts": { + "build": "tsdown" + }, + "dependencies": { + "@dunky.dev/dialog": "workspace:*", + "@dunky.dev/dom-focus-trap": "workspace:*", + "@dunky.dev/dom-navigation": "workspace:*", + "@dunky.dev/dom-overlay": "workspace:*" + }, + "devDependencies": { + "@dunky.dev/state-machine": "^0.3.3" + } +} diff --git a/packages/dom/components/dialog/src/back-navigation.ts b/packages/dom/components/dialog/src/back-navigation.ts new file mode 100644 index 0000000..2ed9b4e --- /dev/null +++ b/packages/dom/components/dialog/src/back-navigation.ts @@ -0,0 +1,21 @@ +import { interceptBackNavigation } from '@dunky.dev/dom-navigation' + +export interface BackNavigationGuardOptions { + /** The api's `backNavigate` — every decision (gate, veto, controlled) is the core's. */ + backNavigate: () => void + /** Whether the machine is still open after `backNavigate` ran. */ + isOpen: () => boolean +} + +/** + * closeOnBack: while open, a guard entry in the session history turns the + * host's Back into a dismissal instead of a navigation. This only wires the + * web mechanics — whether the dialog actually closed is the machine's answer, + * and a decline re-arms the guard. + */ +export function guardBackNavigation(options: BackNavigationGuardOptions): () => void { + return interceptBackNavigation(() => { + options.backNavigate() + return !options.isOpen() + }) +} diff --git a/packages/react/dialog/src/effects.ts b/packages/dom/components/dialog/src/effects.ts similarity index 61% rename from packages/react/dialog/src/effects.ts rename to packages/dom/components/dialog/src/effects.ts index d6a6167..60c149d 100644 --- a/packages/react/dialog/src/effects.ts +++ b/packages/dom/components/dialog/src/effects.ts @@ -1,12 +1,6 @@ -import type { ComponentEffect } from '@dunky.dev/react-state-machine' -import type { DialogMachine, DialogOptions } from '@dunky.dev/dialog' -import { dialogEffects } from '@dunky.dev/dialog' +import { dialogEffects, type DialogEffect } from '@dunky.dev/dialog' import { isTopmostLayer } from '@dunky.dev/dom-overlay' -// Substrate effects: the core's substrate-free list (the controlled-open -// echo) plus the document-level work only this host can own. -type DialogEffect = ComponentEffect - // Escape is a document-level concern, not a part's — it must work wherever // focus is. const trackEscape: DialogEffect = [ @@ -25,4 +19,9 @@ const trackEscape: DialogEffect = [ ['onEscapeKeyDown'], ] -export const reactDialogEffects: DialogEffect[] = [...dialogEffects, trackEscape] +/** + * The core's substrate-free effects plus the document-level work every DOM + * host owns. A DOM substrate passes this list to its adapter's `useMachine` + * as-is; the tuple shape is structurally the adapter's `ComponentEffect`. + */ +export const domDialogEffects: DialogEffect[] = [...dialogEffects, trackEscape] diff --git a/packages/dom/components/dialog/src/exit-window.ts b/packages/dom/components/dialog/src/exit-window.ts new file mode 100644 index 0000000..26ae75b --- /dev/null +++ b/packages/dom/components/dialog/src/exit-window.ts @@ -0,0 +1,29 @@ +import { hideExitingLayer, watchExitAnimation } from '@dunky.dev/dom-overlay' + +export interface ExitWindowOptions { + /** The portal container the layer sits in; `null` means the page body. */ + container?: HTMLElement | null + /** The layer's backdrop, portalled alongside the content. */ + backdrop?: Element | null + /** Forwarded to the machine as `exit.complete`. */ + onComplete: () => void +} + +/** + * The exit window: a dialog rendered while not open is `closing`. It has + * already left the stack, so hide the still-painting layer from interaction + * and report when its visual is done. The returned disposer is the reopen + * interrupt (and the final unmount) undoing both. + */ +export function startExitWindow(content: HTMLElement, options: ExitWindowOptions): () => void { + const undoHide = hideExitingLayer( + content, + options.container ?? document.body, + options.backdrop ?? null, + ) + const cancelWatch = watchExitAnimation(content, options.onComplete) + return () => { + cancelWatch() + undoHide() + } +} diff --git a/packages/dom/components/dialog/src/focus-trap.ts b/packages/dom/components/dialog/src/focus-trap.ts new file mode 100644 index 0000000..e5ab97f --- /dev/null +++ b/packages/dom/components/dialog/src/focus-trap.ts @@ -0,0 +1,20 @@ +import type { DialogMachine } from '@dunky.dev/dialog' +import type { TrapFocusOptions } from '@dunky.dev/dom-focus-trap' +import { isTopmostLayer } from '@dunky.dev/dom-overlay' + +/** + * The trap configuration for a dialog window, for whichever hook the substrate + * wraps `trapFocus` in. Both getters are read per Tab press — so they follow + * the machine and the stack without re-binding the listener — which is why + * `closeId` is an accessor: the api it comes from is re-created per render. + */ +export function dialogTrapOptions(machine: DialogMachine, closeId: () => string): TrapFocusOptions { + return { + // Only a modal dialog traps, and only while topmost — a nested dialog + // owns focus while open. + enabled: () => machine.context.modal && isTopmostLayer(machine.context.id), + // The Close part is the cycle's last stop wherever it renders (core + // SPEC); found by its derived id. + last: () => document.getElementById(closeId()), + } +} diff --git a/packages/dom/components/dialog/src/index.ts b/packages/dom/components/dialog/src/index.ts new file mode 100644 index 0000000..795b741 --- /dev/null +++ b/packages/dom/components/dialog/src/index.ts @@ -0,0 +1,6 @@ +export { domDialogEffects } from './effects' +export { openDialogLayer, type OpenDialogLayerOptions } from './open-layer' +export { startExitWindow, type ExitWindowOptions } from './exit-window' +export { guardBackNavigation, type BackNavigationGuardOptions } from './back-navigation' +export { acceptsBackdropPress, acceptsViewportPress } from './press' +export { dialogTrapOptions } from './focus-trap' diff --git a/packages/dom/components/dialog/src/open-layer.ts b/packages/dom/components/dialog/src/open-layer.ts new file mode 100644 index 0000000..d9728b6 --- /dev/null +++ b/packages/dom/components/dialog/src/open-layer.ts @@ -0,0 +1,45 @@ +import { getInitialFocus, registerLayer } from '@dunky.dev/dom-overlay' + +export interface OpenDialogLayerOptions { + /** The machine's layer id — what the stack and the press gating key on. */ + id: string + depth: number + modal: boolean + /** Resolves the layer's own backdrop; see `Layer.backdrop` in dom-overlay. */ + backdrop: () => Element | null + /** The consumer's `initialFocus`, already resolved. @default the window */ + initialFocus?: HTMLElement | null +} + +/** + * The open edge of a dialog: join the stack, then move focus in. The returned + * disposer is the close edge — release the stack, then move focus back. + * + * The order matters in both directions and is the reason this is one call + * rather than two: the stack must join before focus moves in, and on close it + * must release the layers beneath (un-inert them) before focus can move back + * out to one of them. + */ +export function openDialogLayer(content: HTMLElement, options: OpenDialogLayerOptions): () => void { + const previous = document.activeElement + const unregister = registerLayer({ + id: options.id, + depth: options.depth, + element: content, + modal: options.modal, + backdrop: options.backdrop, + }) + + // preventScroll everywhere: the scroll lock already froze the surface, so + // moving focus must not scroll it — otherwise opening jumps the (top-of- + // container) dialog into view and closing jumps back to the trigger. + const target = options.initialFocus ?? getInitialFocus(content) + target.focus({ preventScroll: true }) + // A target that can't take focus (disabled, hidden) falls back to the panel. + if (document.activeElement !== target) content.focus({ preventScroll: true }) + + return () => { + unregister() + if (previous instanceof HTMLElement) previous.focus({ preventScroll: true }) + } +} diff --git a/packages/dom/components/dialog/src/press.ts b/packages/dom/components/dialog/src/press.ts new file mode 100644 index 0000000..5301984 --- /dev/null +++ b/packages/dom/components/dialog/src/press.ts @@ -0,0 +1,27 @@ +import { isTopmostLayer } from '@dunky.dev/dom-overlay' + +// The parts of a DOM press event these predicates read — narrower than the +// host's synthetic event type, so React and Solid both satisfy it. +interface PressTarget { + target: EventTarget | null + currentTarget: EventTarget | null +} + +/** + * Whether a backdrop press is this dialog's outside interaction. Only the + * topmost dialog of a stack answers one — a nested stack dismisses one layer + * at a time, the same rule Escape follows. + */ +export function acceptsBackdropPress(id: string): boolean { + return isTopmostLayer(id) +} + +/** + * Whether a viewport press is this dialog's outside interaction. Content + * presses bubble up to the viewport, so only a press that started on the + * viewport itself counts — and then only for the topmost dialog. + */ +export function acceptsViewportPress(id: string, event: PressTarget): boolean { + if (event.target !== event.currentTarget) return false + return isTopmostLayer(id) +} diff --git a/packages/dom/components/dialog/tests/dialog.test.ts b/packages/dom/components/dialog/tests/dialog.test.ts new file mode 100644 index 0000000..94a909d --- /dev/null +++ b/packages/dom/components/dialog/tests/dialog.test.ts @@ -0,0 +1,269 @@ +// @vitest-environment jsdom +// The DOM half of the Dialog, driven directly — no substrate, no framework. +import { afterEach, describe, expect, it, vi } from 'vitest' +import { machine, type Machine } from '@dunky.dev/state-machine' +import { dialogMachine } from '@dunky.dev/dialog' +import type { + DialogContext, + DialogMachineEvent, + DialogOptions, + DialogStateName, +} from '@dunky.dev/dialog' +import { registerLayer } from '@dunky.dev/dom-overlay' +import { + acceptsBackdropPress, + acceptsViewportPress, + dialogTrapOptions, + domDialogEffects, + openDialogLayer, + startExitWindow, +} from '@dunky.dev/dom-dialog' + +type DialogService = Machine + +const build = (options: DialogOptions = {}): DialogService => { + const service = machine(dialogMachine({ id: 'dlg', ...options })) + service.start() + return service +} + +// The Escape listener is the last effect in the list; the ones before it are +// the core's, covered by the core's own tests. +const armEscape = (service: DialogService, props: DialogOptions = {}): (() => void) => { + const [effect] = domDialogEffects[ + domDialogEffects.length - 1 + ] as (typeof domDialogEffects)[number] + return effect(service, props) ?? ((): void => {}) +} + +const pressEscape = (): boolean => + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', cancelable: true })) + +// The layer stack is a realm-global that outlives a test — every registration +// has to be undone or the next test inherits a stale topmost. +const registered: (() => void)[] = [] + +// A layer, mounted and registered, standing in for a rendered dialog window. +const mountLayer = (id: string, depth: number, html = ''): HTMLElement => { + const content = document.createElement('div') + content.tabIndex = -1 + content.innerHTML = html + document.body.append(content) + registered.push(registerLayer({ id, depth, element: content, modal: true, backdrop: () => null })) + return content +} + +afterEach(() => { + while (registered.length > 0) (registered.pop() as () => void)() + document.body.innerHTML = '' + vi.restoreAllMocks() + vi.useRealTimers() +}) + +describe('domDialogEffects — Escape', () => { + it('closes the dialog through the machine', () => { + const service = build({ defaultOpen: true }) + mountLayer('dlg', 1) + armEscape(service) + + pressEscape() + expect(service.matches('open')).toBe(false) + }) + + it('offers the consumer a veto before the machine moves', () => { + const service = build({ defaultOpen: true }) + mountLayer('dlg', 1) + armEscape(service, { onEscapeKeyDown: event => event.preventDefault?.() }) + + pressEscape() + expect(service.matches('open')).toBe(true) + }) + + it('is ignored by a dialog that is not topmost — one layer per press', () => { + const service = build({ defaultOpen: true }) + mountLayer('dlg', 1) + mountLayer('above', 2) + armEscape(service) + + pressEscape() + expect(service.matches('open')).toBe(true) + }) + + it('detaches its listener on dispose', () => { + const service = build({ defaultOpen: true }) + mountLayer('dlg', 1) + armEscape(service)() + + pressEscape() + expect(service.matches('open')).toBe(true) + }) +}) + +describe('openDialogLayer', () => { + const options = { id: 'dlg', depth: 1, modal: true, backdrop: () => null } + + // Mounts a dialog window and opens it, tracking the close so a test that + // never calls it still leaves the stack clean. Closing twice is a no-op. + const open = ( + html: string, + extra: Partial & { initialFocus?: HTMLElement | null } = {}, + ): { content: HTMLElement; close: () => void } => { + const content = document.createElement('div') + content.tabIndex = -1 + content.innerHTML = html + document.body.append(content) + + const dispose = openDialogLayer(content, { ...options, ...extra }) + let closed = false + const close = (): void => { + if (closed) return + closed = true + dispose() + } + registered.push(close) + return { content, close } + } + + it('moves focus to the first form field, without scrolling the locked surface', () => { + const content = document.createElement('div') + content.tabIndex = -1 + content.innerHTML = '' + document.body.append(content) + const field = document.getElementById('field') as HTMLInputElement + const focus = vi.spyOn(field, 'focus') + + registered.push(openDialogLayer(content, options)) + + expect(document.activeElement).toBe(field) + expect(focus).toHaveBeenCalledWith({ preventScroll: true }) + }) + + it('honors an explicit initialFocus over the overlay default', () => { + const content = document.createElement('div') + content.tabIndex = -1 + content.innerHTML = '' + document.body.append(content) + const pick = content.querySelector('#pick') as HTMLButtonElement + + registered.push(openDialogLayer(content, { ...options, initialFocus: pick })) + + expect(document.activeElement).toBe(pick) + }) + + it('falls back to the dialog window when the target refuses focus', () => { + const content = document.createElement('div') + content.tabIndex = -1 + content.innerHTML = '' + document.body.append(content) + const field = content.querySelector('#field') as HTMLInputElement + + registered.push(openDialogLayer(content, { ...options, initialFocus: field })) + + expect(document.activeElement).toBe(content) + }) + + it('restores focus to whatever held it before the dialog opened', () => { + const trigger = document.createElement('button') + document.body.append(trigger) + trigger.focus() + + open('').close() + + expect(document.activeElement).toBe(trigger) + }) + + it('releases the layer beneath before focus returns to it', () => { + // The ordering contract. jsdom doesn't enforce `inert`, so a focus + // assertion wouldn't discriminate — observe the order directly instead: + // by the time focus is restored, the layer below must already be free. + const below = mountLayer('below', 1, '') + const beneath = below.querySelector('#beneath') as HTMLButtonElement + beneath.focus() + + const { close } = open('', { depth: 2 }) + expect(below.hasAttribute('inert')).toBe(true) + + let inertWhenRestored: boolean | undefined + vi.spyOn(beneath, 'focus').mockImplementation(() => { + inertWhenRestored = below.hasAttribute('inert') + }) + close() + + expect(inertWhenRestored).toBe(false) + }) +}) + +describe('startExitWindow', () => { + const mountExiting = (): HTMLElement => { + const content = document.createElement('div') + document.body.append(content) + return content + } + + it('takes the still-painting layer out of interaction and reports its end', () => { + const content = mountExiting() + const onComplete = vi.fn() + startExitWindow(content, { onComplete }) + + expect(content.hasAttribute('inert')).toBe(true) + content.dispatchEvent(new Event('transitionend')) + expect(onComplete).toHaveBeenCalledTimes(1) + }) + + it('undoes the hide and stops watching when the exit is interrupted', () => { + const content = mountExiting() + const onComplete = vi.fn() + startExitWindow(content, { onComplete })() + + expect(content.hasAttribute('inert')).toBe(false) + content.dispatchEvent(new Event('transitionend')) + expect(onComplete).not.toHaveBeenCalled() + }) +}) + +describe('outside-press gating', () => { + it('lets only the topmost dialog answer a backdrop press', () => { + mountLayer('dlg', 1) + expect(acceptsBackdropPress('dlg')).toBe(true) + + mountLayer('above', 2) + expect(acceptsBackdropPress('dlg')).toBe(false) + }) + + it('ignores a viewport press that bubbled up from the content', () => { + mountLayer('dlg', 1) + const viewport = document.createElement('div') + const content = document.createElement('div') + + expect(acceptsViewportPress('dlg', { target: viewport, currentTarget: viewport })).toBe(true) + expect(acceptsViewportPress('dlg', { target: content, currentTarget: viewport })).toBe(false) + }) +}) + +describe('dialogTrapOptions', () => { + it('traps only while modal and topmost', () => { + const service = build({ defaultOpen: true }) + mountLayer('dlg', 1) + const { enabled } = dialogTrapOptions(service, () => 'dlg-close') + + expect(enabled?.()).toBe(true) + mountLayer('above', 2) + expect(enabled?.()).toBe(false) + }) + + it('never traps a non-modal dialog', () => { + const service = build({ defaultOpen: true, modal: false }) + mountLayer('dlg', 1) + const { enabled } = dialogTrapOptions(service, () => 'dlg-close') + + expect(enabled?.()).toBe(false) + }) + + it('resolves Close as the cycle’s last stop, wherever it renders', () => { + const service = build({ defaultOpen: true }) + mountLayer('dlg', 1, '') + const { last } = dialogTrapOptions(service, () => 'dlg-close') + + expect(last?.()).toBe(document.getElementById('dlg-close')) + }) +}) diff --git a/packages/native/dialog/package.json b/packages/native/dialog/package.json index f47069c..238b711 100644 --- a/packages/native/dialog/package.json +++ b/packages/native/dialog/package.json @@ -35,7 +35,7 @@ }, "dependencies": { "@dunky.dev/dialog": "workspace:*", - "@dunky.dev/native-state-machine": "^0.3.2" + "@dunky.dev/native-state-machine": "^0.4.0" }, "devDependencies": { "@testing-library/react-native": "^13.3.3", diff --git a/packages/react/dialog/package.json b/packages/react/dialog/package.json index 2febb6d..bda251a 100644 --- a/packages/react/dialog/package.json +++ b/packages/react/dialog/package.json @@ -37,9 +37,8 @@ }, "dependencies": { "@dunky.dev/dialog": "workspace:*", - "@dunky.dev/dom-navigation": "workspace:*", - "@dunky.dev/dom-overlay": "workspace:*", - "@dunky.dev/react-state-machine": "^0.3.2", + "@dunky.dev/dom-dialog": "workspace:*", + "@dunky.dev/react-state-machine": "^0.3.4", "@dunky.dev/react-use-focus-trap": "workspace:*", "@dunky.dev/react-use-scroll-lock": "workspace:*" }, diff --git a/packages/react/dialog/src/dialog.tsx b/packages/react/dialog/src/dialog.tsx index b37a89f..726bca5 100644 --- a/packages/react/dialog/src/dialog.tsx +++ b/packages/react/dialog/src/dialog.tsx @@ -16,14 +16,14 @@ import { useFocusTrap } from '@dunky.dev/react-use-focus-trap' import { useScrollLock } from '@dunky.dev/react-use-scroll-lock' import type { DialogOptions } from '@dunky.dev/dialog' -import { interceptBackNavigation } from '@dunky.dev/dom-navigation' import { - getInitialFocus, - hideExitingLayer, - isTopmostLayer, - registerLayer, - watchExitAnimation, -} from '@dunky.dev/dom-overlay' + acceptsBackdropPress, + acceptsViewportPress, + dialogTrapOptions, + guardBackNavigation, + openDialogLayer, + startExitWindow, +} from '@dunky.dev/dom-dialog' import { mergeProps, normalize } from '@dunky.dev/react-state-machine' import { DialogContext, useDialogContext } from './context' import { useDialog } from './use-dialog' @@ -52,16 +52,13 @@ export const Dialog: ((props: DialogProps) => ReactNode) & Parts = ({ children, const apiRef = useRef(api) apiRef.current = api - // closeOnBack: while open, a guard entry in the session history turns the - // host's Back into a dismissal instead of a navigation. Every decision - // (gate, veto, controlled) lives in the core's backNavigate; this effect - // only wires the web mechanics. It lives on the root — the guard concerns - // the dialog's openness, not any rendered part. + // The guard lives on the root — it concerns the dialog's openness, not any + // rendered part. useEffect(() => { if (!api.open || !machine.context.closeOnBack) return - return interceptBackNavigation(() => { - apiRef.current.backNavigate() - return !machine.matches('open') + return guardBackNavigation({ + backNavigate: () => apiRef.current.backNavigate(), + isOpen: () => machine.matches('open'), }) }, [api.open, machine]) @@ -133,9 +130,8 @@ export const Backdrop: PartComponent = forw const merged = mergeProps(props, { ...bindings, - // Only the topmost dialog of a stack answers an outside press. onClick: (event: MouseEvent) => { - if (isTopmostLayer(machine.context.id)) onClick?.(event) + if (acceptsBackdropPress(machine.context.id)) onClick?.(event) }, }) @@ -162,13 +158,8 @@ export const Viewport: PartComponent = forw const merged = mergeProps(props, { ...bindings, - // Content presses bubble up here — only a press that started on the - // viewport itself is an outside interaction, and only the topmost dialog - // of a stack answers it. onClick: (event: MouseEvent) => { - if (event.target !== event.currentTarget) return - if (!isTopmostLayer(machine.context.id)) return - onClick?.(event) + if (acceptsViewportPress(machine.context.id, event)) onClick?.(event) }, }) @@ -196,53 +187,31 @@ export const Content: PartComponent = forwar initialFocusRef.current = initialFocus // The machine's `open` state is the edge, not mount/unmount — an animated - // dialog stays mounted through `closing`, and the stack, containment, and - // focus must release the moment the exit starts, not when it finishes. - // One effect keeps the ordering right both ways: the stack joins before focus - // moves in, and on close it must release the layers beneath (un-inert them) - // before focus can move back out to one of them. + // dialog stays mounted through `closing`. The sequence and its inverse are + // the DOM package's; this effect only ties them to React's lifecycle. useEffect(() => { const content = contentRef.current if (!api.open || content === null) return - const previous = document.activeElement - const unregister = registerLayer({ + return openDialogLayer(content, { id: machine.context.id, depth, - element: content, modal: machine.context.modal, backdrop: () => backdropRef.current, + initialFocus: initialFocusRef.current?.current, }) - - // preventScroll everywhere: the scroll lock already froze the surface, so - // moving focus must not scroll it — otherwise opening jumps the (top-of- - // container) dialog into view and closing jumps back to the trigger. - const target = initialFocusRef.current?.current ?? getInitialFocus(content) - target.focus({ preventScroll: true }) - // A target that can't take focus (disabled, hidden) falls back to the panel. - if (document.activeElement !== target) content.focus({ preventScroll: true }) - - return () => { - unregister() - if (previous instanceof HTMLElement) previous.focus({ preventScroll: true }) - } }, [api.open, machine, depth, backdropRef]) - // The exit window: Content rendered while not open only happens in - // `closing`. The layer has already released everything above, so hide the - // still-painting layer from interaction and report when its visual is done; - // the effect's cleanup is the reopen interrupt (and final unmount) undoing - // both. + // Content rendered while not open only happens in `closing`. useEffect(() => { const content = contentRef.current if (api.open || content === null) return - const undoHide = hideExitingLayer(content, container ?? document.body, backdropRef.current) - const cancelWatch = watchExitAnimation(content, () => machine.send({ type: 'exit.complete' })) - return () => { - cancelWatch() - undoHide() - } + return startExitWindow(content, { + container, + backdrop: backdropRef.current, + onComplete: () => machine.send({ type: 'exit.complete' }), + }) }, [api.open, machine, container, backdropRef]) // The lock spans the whole mount — through `closing` too: releasing it @@ -251,14 +220,10 @@ export const Content: PartComponent = forwar // dialog locks the body. useScrollLock(machine.context.modal, container) - useFocusTrap(contentRef, { - // Only a modal dialog traps, and only while topmost — a nested dialog - // owns focus while open. - enabled: () => machine.context.modal && isTopmostLayer(machine.context.id), - // The Close part is the cycle's last stop wherever it renders (core - // SPEC); found by its derived id. - last: () => document.getElementById(api.ids.close), - }) + useFocusTrap( + contentRef, + dialogTrapOptions(machine, () => api.ids.close), + ) // A neutral element with the role, not : the window is the initial // focus target, so it carries tabindex — which HTML forbids on — diff --git a/packages/react/dialog/src/use-dialog.ts b/packages/react/dialog/src/use-dialog.ts index 74b3aa3..d8cb69d 100644 --- a/packages/react/dialog/src/use-dialog.ts +++ b/packages/react/dialog/src/use-dialog.ts @@ -2,14 +2,13 @@ import { useId } from 'react' import { useMachine } from '@dunky.dev/react-state-machine' import { dialogMachine, dialogConnect } from '@dunky.dev/dialog' import type { DialogApi, DialogMachine, DialogOptions } from '@dunky.dev/dialog' - -import { reactDialogEffects } from './effects' +import { domDialogEffects } from '@dunky.dev/dom-dialog' export function useDialog(options: DialogOptions): { api: DialogApi; machine: DialogMachine } { const id = useId() // `?? id` (not spread order): an explicit `id={undefined}` must not knock out // the generated fallback — ids also key the dialog stack, so they must exist. - return useMachine(dialogMachine, dialogConnect, reactDialogEffects, { + return useMachine(dialogMachine, dialogConnect, domDialogEffects, { ...options, id: options.id ?? id, }) diff --git a/packages/solid/.storybook/main.ts b/packages/solid/.storybook/main.ts new file mode 100644 index 0000000..e27ae7a --- /dev/null +++ b/packages/solid/.storybook/main.ts @@ -0,0 +1,15 @@ +import type { StorybookConfig } from 'storybook-solidjs-vite' + +const config: StorybookConfig = { + stories: ['../**/*.stories.@(ts|tsx)'], + framework: 'storybook-solidjs-vite', + core: { + disableTelemetry: true, + disableWhatsNewNotifications: true, + }, + features: { + sidebarOnboardingChecklist: false, + }, +} + +export default config diff --git a/packages/solid/.storybook/manager.ts b/packages/solid/.storybook/manager.ts new file mode 100644 index 0000000..3bfb5c2 --- /dev/null +++ b/packages/solid/.storybook/manager.ts @@ -0,0 +1,14 @@ +import { addons } from 'storybook/manager-api' +import { create } from 'storybook/theming' + +addons.setConfig({ + showToolbar: true, + layoutCustomisations: { + showPanel: () => false, + }, + theme: create({ + base: 'light', + brandTitle: 'dunky', + brandUrl: './', + }), +}) diff --git a/packages/solid/dialog/README.md b/packages/solid/dialog/README.md new file mode 100644 index 0000000..98d00f3 --- /dev/null +++ b/packages/solid/dialog/README.md @@ -0,0 +1,41 @@ +# @dunky.dev/solid-dialog + +Solid 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). +Solid-specific surface: [SPEC.md](./SPEC.md). + +## Install + +```sh +npm install @dunky.dev/solid-dialog +``` + +## Usage + +```tsx +import { Dialog } from '@dunky.dev/solid-dialog' + +function ConfirmDelete() { + return ( + + Delete... + + + + + Delete file? + This cannot be undone. + + Cancel + + + + + ) +} +``` diff --git a/packages/solid/dialog/SPEC.md b/packages/solid/dialog/SPEC.md new file mode 100644 index 0000000..f8f6c1b --- /dev/null +++ b/packages/solid/dialog/SPEC.md @@ -0,0 +1,177 @@ +# SPEC / Solid / Dialog + +The Solid 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/solid-dialog +``` + +## Usage + +```tsx +import { Dialog } from '@dunky.dev/solid-dialog' +; + Open + + + + + Title + Description + Close + + + + +``` + +Solid-specific notes on top of the core contract: + +- **`Portal`** teleports the layers to `document.body`, or to a `container` + you supply. Nothing is kept mounted while closed; an `animated` dialog + stays mounted through the core contract's `closing` state so its exit can + play — see the exit-animation note below. When scoped to a + `container`, the scroll lock applies to that container instead of the page, + and the backdrop/viewport must be positioned `absolute` (not `fixed`) so the + overlay pins to the container. Because an `absolute` overlay can't stay fixed + inside a scrolling element, a scoped container that needs a scrollable + background should be a non-scrolling positioned boundary wrapping an inner + scroller — portal into the boundary; the overlay fills its visible box and + the backdrop blocks the scroller behind it (see the `scoped` story). + Swapping `container` while the dialog is open re-creates the portal on the + new target (the host portal's mount is fixed at creation). +- **`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 an accessor resolved at + open time — the Solid idiom for a ref variable that fills during render: + pass `initialFocus={() => cancelButton}`. +- **`Backdrop`** renders nothing when the dialog is non-modal (`modal={false}`), + per the core parts contract. +- **Exit animation** (`animated`): style the exit on the parts' + `data-state="closing"` — a CSS transition or animation on **Content** (the + element carrying the state, not a descendant) is what signals completion; + a missing exit style falls back to a short ceiling, and + `prefers-reduced-motion` skips the wait entirely. The exit is cosmetic: + focus, the dialog stack, and page interaction release the moment closing + starts, and the still-painting layer is made `inert` until it unmounts. + Enter needs no state — the parts mount straight into `data-state="open"`, + so a CSS animation (or a transition via `@starting-style`) plays from + mount. +- **Back navigation** (`closeOnBack`): opening plants a guard entry in the + session history, so the browser's Back closes the dialog instead of leaving + the page — one layer per press in a nested stack, per the core contract. A + dialog closed any other way consumes its entry, leaving nothing to swallow + a later Back; an entry buried under in-app navigation while the dialog is + open is left alone (Back then both navigates and closes the dialog). +- Everything ships headless, per the core contract's + [Internals](../../core/dialog/SPEC.md#internals). + +## API + +### `Dialog` + +The root: owns open/close state, renders no DOM. Accepts the core +`DialogOptions`. + +| Prop | Type | Default | Description | +| ------------------------ | --------------------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| `open` | `boolean` | — | Controlled open state — the dialog follows it alone. Back to `undefined` hands the state over, uncontrolled in place. | +| `defaultOpen` | `boolean` | `false` | Initial open state for the uncontrolled dialog. | +| `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). | +| `onBackNavigation` | `(event?) => void` | — | Fired before a back-navigation dismissal; `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 (`createUniqueId`) | Base id for the parts; per-part ids are derived from it. | +| `children` | `JSX.Element` | — | The dialog's parts. | + +### `Dialog.Trigger` + +Opens the dialog; focus returns here on close. + +| Prop | Type | Default | Description | +| ---------- | -------------------------- | ------- | ------------------------------------- | +| `...props` | `ComponentProps<'button'>` | — | Forwarded to the rendered ` + ) +} + +// ============================================================================= +// — teleports the layers out of the tree while open +// ============================================================================= + +export interface DialogPortalProps { + children?: JSX.Element + /** The element to portal into. @default document.body */ + container?: HTMLElement | null +} + +export const Portal: Component = props => { + const context = useDialogContext() + if (isServer) return null + return ( + // `mounted`, not `open`: an animated dialog stays mounted through + // `closing` so its exit visual can play. + + {/* keyed: the host portal's mount is fixed at creation, so a container + swap re-creates the portal on the new target. */} + + {mount => ( + + {/* Re-provide the context with the scoped container (null = page + body) so Content locks the right scroll surface. */} + props.container ?? null }}> + {props.children} + + + )} + + + ) +} + +// ============================================================================= +// — the layer behind the dialog window +// ============================================================================= + +export interface DialogBackdropProps extends ComponentProps<'div'> {} + +export const Backdrop: Component = props => { + const { api, machine, backdropRef } = useDialogContext() + const rest = omit(props, 'ref', 'children') + onSettled(() => () => (backdropRef.current = null)) + + const bindings = (): Record => { + const { onClick, ...attrs } = normalize(api.parts.backdrop) as { + onClick?: (event: MouseEvent) => void + } & Record + return { + ...attrs, + onClick: (event: MouseEvent) => { + if (acceptsBackdropPress(machine.context.id)) onClick?.(event) + }, + } + } + + return ( + // Only a modal dialog dims the page — non-modal coexists with it. + +
(rest, bindings())} + ref={element => { + backdropRef.current = element + applyConsumerRef(props.ref, element) + }} + > + {props.children} +
+
+ ) +} + +// ============================================================================= +// — the positioning + scroll layer around the dialog window +// ============================================================================= + +export interface DialogViewportProps extends ComponentProps<'div'> {} + +export const Viewport: Component = props => { + const { api, machine } = useDialogContext() + const rest = omit(props, 'children') + + const bindings = (): Record => { + const { onClick, ...attrs } = normalize(api.parts.viewport) as { + onClick?: (event: MouseEvent) => void + } & Record + return { + ...attrs, + onClick: (event: MouseEvent) => { + if (acceptsViewportPress(machine.context.id, event)) onClick?.(event) + }, + } + } + + return
(rest, bindings())}>{props.children}
+} + +// ============================================================================= +// — the dialog window: focus moves in on open, restores on +// close, traps while modal +// ============================================================================= + +export interface DialogContentProps extends ComponentProps<'div'> { + /** The element to focus when the dialog opens — an element, or an accessor + * resolved at open time. @default the dialog window */ + initialFocus?: HTMLElement | (() => HTMLElement | null | undefined) +} + +const resolveInitialFocus = (value: DialogContentProps['initialFocus']): HTMLElement | null => + (typeof value === 'function' ? value() : value) ?? null + +export const Content: Component = props => { + const { api, machine, depth, container, backdropRef } = useDialogContext() + const rest = omit(props, 'ref', 'initialFocus', 'children') + let contentEl: HTMLDivElement | undefined + + // The `open` state is the edge, not mount/unmount: an animated dialog stays + // mounted through `closing`. The sequence and its inverse are the DOM + // package's; this effect only ties them to Solid's lifecycle. + createEffect( + () => api.open, + open => { + const content = contentEl + if (!open || content === undefined) return + + return openDialogLayer(content, { + id: machine.context.id, + depth, + modal: machine.context.modal, + backdrop: () => backdropRef.current, + initialFocus: untrack(() => resolveInitialFocus(props.initialFocus)), + }) + }, + ) + + // Mounted while not open only happens in `closing`. + createEffect( + () => api.open, + open => { + const content = contentEl + if (open || content === undefined) return + + return untrack(() => + startExitWindow(content, { + container: container(), + backdrop: backdropRef.current, + onComplete: () => machine.send({ type: 'exit.complete' }), + }), + ) + }, + ) + + // The lock spans the whole mount — through `closing` too: releasing it + // mid-exit would reflow the page under the still-painting layer. + useScrollLock(() => machine.context.modal, container) + + useFocusTrap( + () => contentEl ?? null, + dialogTrapOptions(machine, () => api.ids.close), + ) + + // A neutral element with the role, not : the window carries + // tabindex (forbidden on ), and this contract doesn't use + // showModal() — see SPEC.md. + return ( +
(rest, normalize(api.parts.content))} + ref={element => { + contentEl = element + applyConsumerRef(props.ref, element) + }} + > + {props.children} +
+ ) +} + +// ============================================================================= +// — the dialog's accessible name +// ============================================================================= + +export interface DialogTitleProps extends ComponentProps<'h2'> {} + +export const Title: Component = props => { + const { api, machine } = useDialogContext() + const rest = omit(props, 'children') + + // onSettled: the machine starts on the root's settle, which runs first. + onSettled(() => { + machine.send({ type: 'part.presence', part: 'title', present: true }) + return () => machine.send({ type: 'part.presence', part: 'title', present: false }) + }) + + return ( +

(rest, normalize(api.parts.title))}>{props.children}

+ ) +} + +// ============================================================================= +// — the dialog's accessible description +// ============================================================================= + +export interface DialogDescriptionProps extends ComponentProps<'div'> {} + +export const Description: Component = props => { + const { api, machine } = useDialogContext() + const rest = omit(props, 'children') + + onSettled(() => { + machine.send({ type: 'part.presence', part: 'description', present: true }) + return () => machine.send({ type: 'part.presence', part: 'description', present: false }) + }) + + return ( +
(rest, normalize(api.parts.description))}> + {props.children} +
+ ) +} + +// ============================================================================= +// — the visible in-dialog close affordance +// ============================================================================= + +export interface DialogCloseProps extends ComponentProps<'button'> {} + +export const Close: Component = props => { + const { api } = useDialogContext() + const rest = omit(props, 'children') + return ( + + ) +} + +// Parts +// ----------------------------------------------------------------------------- + +export 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 +} + +Dialog.Trigger = Trigger +Dialog.Portal = Portal +Dialog.Backdrop = Backdrop +Dialog.Viewport = Viewport +Dialog.Content = Content +Dialog.Title = Title +Dialog.Description = Description +Dialog.Close = Close diff --git a/packages/solid/dialog/src/index.ts b/packages/solid/dialog/src/index.ts new file mode 100644 index 0000000..84274ef --- /dev/null +++ b/packages/solid/dialog/src/index.ts @@ -0,0 +1,13 @@ +export { + Dialog, + type DialogProps, + type DialogTriggerProps, + type DialogPortalProps, + type DialogBackdropProps, + type DialogViewportProps, + type DialogContentProps, + type DialogTitleProps, + type DialogDescriptionProps, + type DialogCloseProps, +} from './dialog' +export type { DialogCallbacks, DialogOptions, DialogRole } from '@dunky.dev/dialog' diff --git a/packages/solid/dialog/src/use-dialog.ts b/packages/solid/dialog/src/use-dialog.ts new file mode 100644 index 0000000..20d2b58 --- /dev/null +++ b/packages/solid/dialog/src/use-dialog.ts @@ -0,0 +1,17 @@ +import { createUniqueId, merge } from 'solid-js' +import { useMachine } from '@dunky.dev/solid-state-machine' +import { dialogMachine, dialogConnect } from '@dunky.dev/dialog' +import type { DialogApi, DialogMachine, DialogOptions } from '@dunky.dev/dialog' +import { domDialogEffects } from '@dunky.dev/dom-dialog' + +export function useDialog(options: DialogOptions): { api: DialogApi; machine: DialogMachine } { + const id = createUniqueId() + // `?? id` via a live getter: an explicit `id={undefined}` must not knock out + // the generated fallback — ids also key the dialog stack. + const props = merge(options, { + get id() { + return options.id ?? id + }, + }) + return useMachine(dialogMachine, dialogConnect, domDialogEffects, props) +} diff --git a/packages/solid/dialog/stories/dialog.stories.tsx b/packages/solid/dialog/stories/dialog.stories.tsx new file mode 100644 index 0000000..2fea7aa --- /dev/null +++ b/packages/solid/dialog/stories/dialog.stories.tsx @@ -0,0 +1,458 @@ +import { createSignal, Repeat } from 'solid-js' +import type { JSX } from '@solidjs/web' +import type { Meta, StoryObj } from 'storybook-solidjs-vite' +import { Dialog } from '@dunky.dev/solid-dialog' + +const meta: Meta = { + title: 'Primitives/Dialog', + component: Dialog, +} + +export default meta +type StoryType = StoryObj + +// The primitive ships headless — the story is the consumer, so it brings the +// styles. `data-state` on every part is the real styling hook. +const backdrop: JSX.CSSProperties = { + position: 'fixed', + inset: 0, + background: 'rgba(0, 0, 0, 0.4)', +} +const viewport: JSX.CSSProperties = { + position: 'fixed', + inset: 0, + display: 'flex', + overflow: 'auto', + padding: '24px', +} +const content: JSX.CSSProperties = { + // `margin: auto` inside the viewport's flex box does the centering; + // `relative` makes the corner Close button pin to the window, not the page. + 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)', +} +const actions: JSX.CSSProperties = { + display: 'flex', + 'justify-content': 'flex-end', + gap: '8px', + 'margin-top': '16px', +} +const closeIcon: JSX.CSSProperties = { + 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, +} +const field: JSX.CSSProperties = { + display: 'flex', + 'flex-direction': 'column', + gap: '4px', + 'margin-top': '12px', +} +const input: JSX.CSSProperties = { + 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. +const scopedBoundary: JSX.CSSProperties = { + position: 'relative', + height: '320px', + overflow: 'hidden', + border: '1px solid #ccc', + 'border-radius': '8px', +} +const scopedScroller: JSX.CSSProperties = { + height: '100%', + overflow: 'auto', + padding: '16px', + 'box-sizing': 'border-box', +} +const scopedBackdrop: JSX.CSSProperties = { ...backdrop, position: 'absolute' } +const scopedViewport: JSX.CSSProperties = { ...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. +const closableContent: JSX.CSSProperties = { ...content, position: 'relative' } + +const CloseButton = () => ( + + × + +) + +export const standard: StoryType = { + render: () => ( + + 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. + + + + + + ), +} + +// The action row is the consumer's: Cancel/Delete do their work and close +// through state, so their Tab order is plain DOM order. Per the APG, a dialog +// confirming a destructive step starts focus on the least destructive action — +// `initialFocus` points at Cancel. +const AlertDialog = () => { + const [open, setOpen] = createSignal(true) + let cancel: HTMLButtonElement | undefined + return ( + setOpen(false)} + > + setOpen(true)}>Delete board + + + + cancel}> + Delete board? + + This permanently deletes the board and its content for every member. This can't be + undone. An outside press does not dismiss an alert dialog — choose an action. + +
+ + +
+
+
+
+
+ ) +} + +export const alertDialog: StoryType = { + render: () => , +} + +export const longContent: StoryType = { + render: () => ( + + Open terms + + + + + + Terms of service + + Content taller than the screen scrolls within the viewport layer. + + + {index => ( +

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

+ )} +
+
+
+
+
+ ), +} + +export const loginForm: StoryType = { + render: () => ( + + Sign in + + + + + + Sign in + + Focus moves to the first field on open, and stays trapped inside while the dialog is + open. + +
{ + event.preventDefault() + }} + > + + +
+ +
+
+
+
+
+
+ ), +} + +export const trigger: StoryType = { + render: () => ( + + Open dialog + + + + + + Closed by default + Only the trigger renders until it is pressed. + + + + + ), +} + +// The consumer owns `open`; a controlled dialog never moves on its own, so +// every dismissal is decided at its source. +const ControlledDialog = () => { + const [open, setOpen] = createSignal(false) + return ( + <> + + setOpen(false)}> + + + + + Controlled + + The consumer owns `open`; dismissals are decided at their source. + +
+ +
+
+
+
+
+ + ) +} + +export const controlled: StoryType = { + render: () => , +} + +// The boundary element fills its ref during render, before the dialog's +// effects run — the portal reads a real element the moment it opens, so an +// open dialog never falls back to document.body. +const ScopedDialog = () => { + const [boundary, setBoundary] = createSignal(null) + return ( +
+
+ + {index => ( +

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

+ )} +
+ + 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. + + + + + +
+
+ ) +} + +export const scoped: StoryType = { + render: () => , +} + +// "Close all" is consumer-side for now — `Close scope="stack"` is spec-only, so +// the three layers are controlled and one handler drops them together. And a +// controlled dialog never moves on its own: each layer decides its dismissals +// at the source — its Trigger handler, its own action buttons, and the +// dismissal callbacks (`onEscapeKeyDown` / `onInteractOutside`) — per the +// controlled contract; `onOpenChange` only reports changes that actually +// happened. +const NestedDialogs = () => { + const [outerOpen, setOuterOpen] = createSignal(true) + const [innerOpen, setInnerOpen] = createSignal(false) + const [innermostOpen, setInnermostOpen] = createSignal(false) + const closeAll = () => { + setInnermostOpen(false) + setInnerOpen(false) + setOuterOpen(false) + } + return ( + setOuterOpen(false)} + onInteractOutside={() => setOuterOpen(false)} + > + setOuterOpen(true)}>Open outer + + + + + Outer dialog + + Escape and outside presses dismiss the topmost dialog only — the stack unwinds one + layer at a time. + + setInnerOpen(false)} + onInteractOutside={() => setInnerOpen(false)} + > + setInnerOpen(true)}>Open inner + + + + + Inner dialog + + While open, everything beneath — including the outer dialog — is inert and + hidden from assistive tech. + + setInnermostOpen(false)} + onInteractOutside={() => setInnermostOpen(false)} + > + setInnermostOpen(true)}> + Open innermost + + + + + + Innermost dialog + + Three layers deep. Escape and Close dismiss this layer only; Close all + unwinds the whole stack at once. + +
+ + +
+
+
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+ ) +} + +export const nested: StoryType = { + render: () => , +} + +// closeOnBack turns the host's Back into a dismissal: while the dialog is open, +// a guard entry sits in the session history, so the browser's Back closes the +// dialog instead of leaving the page — what mobile users expect from a +// full-screen overlay. The canvas has no browser chrome, so the in-dialog +// button stands in for a real Back press by calling `history.back()`. +export const closeOnBack: StoryType = { + render: () => ( + + 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. + +
+ +
+
+
+
+
+ ), +} diff --git a/packages/solid/dialog/tests/dialog.test.tsx b/packages/solid/dialog/tests/dialog.test.tsx new file mode 100644 index 0000000..6477ad9 --- /dev/null +++ b/packages/solid/dialog/tests/dialog.test.tsx @@ -0,0 +1,654 @@ +// @vitest-environment jsdom +// The Solid edge of the Dialog — behavior only; the machine's own contract is +// covered in @dunky.dev/dialog's tests. +import { createSignal, flush } from 'solid-js' +import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Dialog, type DialogProps } from '@dunky.dev/solid-dialog' + +const DefaultDialog = (props: DialogProps) => ( + + Trigger + + + + + Title + Description + + Close + + + + +) + +// Solid 2.0 defers store commits to the microtask queue — flush after every +// interaction before reading the tree. +const press = (element: HTMLElement): void => { + element.click() + flush() +} + +const openDialog = (): void => { + press(screen.getByText('Trigger')) +} + +const pressEscape = (): void => { + fireEvent.keyDown(document.body, { key: 'Escape' }) + flush() +} + +// Auto-cleanup needs vitest globals; this repo runs with globals: false. +afterEach(cleanup) + +describe('Dialog', () => { + describe('open / close', () => { + it('opens on trigger press and closes on close press', () => { + render(() => ) + 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(() => ) + expect(screen.queryByRole('dialog')).not.toBeNull() + }) + + it('fires onOpenChange with the new value on open and close', () => { + const onOpenChange = vi.fn() + render(() => ) + + openDialog() + expect(onOpenChange).toHaveBeenLastCalledWith(true) + + press(screen.getByText('Close')) + expect(onOpenChange).toHaveBeenLastCalledWith(false) + }) + }) + + describe('escape key', () => { + it('closes on Escape', () => { + render(() => ) + pressEscape() + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('stays open when closeOnEscape=false', () => { + render(() => ) + pressEscape() + expect(screen.queryByRole('dialog')).not.toBeNull() + }) + + it('stays open when onEscapeKeyDown prevents default', () => { + const onEscapeKeyDown = vi.fn(event => event.preventDefault()) + render(() => ) + pressEscape() + expect(onEscapeKeyDown).toHaveBeenCalledTimes(1) + expect(screen.queryByRole('dialog')).not.toBeNull() + }) + }) + + describe('outside interaction', () => { + it('closes on backdrop press', () => { + render(() => ) + 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(() => ) + 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(() => ) + 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(() => ) + 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(() => ) + press(screen.getByTestId('backdrop')) + expect(screen.queryByRole('alertdialog')).not.toBeNull() + }) + + it('closes on a press on the viewport around the content', () => { + render(() => ) + press(screen.getByTestId('viewport')) + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('does not close when a press inside the content bubbles to the viewport', () => { + render(() => ) + press(screen.getByText('Action')) + expect(screen.queryByRole('dialog')).not.toBeNull() + }) + + it('renders no backdrop when modal=false', () => { + render(() => ) + expect(screen.queryByTestId('backdrop')).toBeNull() + }) + }) + + describe('controlled open', () => { + it('follows the open prop in both directions', () => { + const [open, setOpen] = createSignal(false) + render(() => ) + expect(screen.queryByRole('dialog')).toBeNull() + + setOpen(true) + flush() + expect(screen.queryByRole('dialog')).not.toBeNull() + + setOpen(false) + flush() + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('a dismissal neither closes nor fires onOpenChange — nothing changed', () => { + const onOpenChange = vi.fn() + render(() => ) + 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(() => ) + openDialog() + expect(onOpenChange).not.toHaveBeenCalled() + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('reports a prop-driven change through onOpenChange', () => { + const onOpenChange = vi.fn() + const [open, setOpen] = createSignal(false) + render(() => ) + setOpen(true) + flush() + 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', () => { + const ControlledStack = () => { + const [outerOpen, setOuterOpen] = createSignal(true) + const [innerOpen, setInnerOpen] = createSignal(false) + return ( + setOuterOpen(false)} + > + + + + Outer + setInnerOpen(false)} + > + setInnerOpen(true)}>Open inner + + + + Inner + setInnerOpen(false)}> + Close inner + + + + + + + + + + ) + } + + render(() => ) + 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', () => { + const onOpenChange = vi.fn() + const [open, setOpen] = createSignal(true) + render(() => ) + setOpen(undefined) + flush() + 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(() => ) + 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(() => ) + 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(() => ) + 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(() => ( + + + content + + + )) + 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(() => ) + expect(screen.queryByRole('alertdialog')).not.toBeNull() + }) + + it('omits aria-modal when modal=false', () => { + render(() => ) + 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(() => ) + const trigger = screen.getByText('Trigger') + trigger.focus() + + openDialog() + expect(document.activeElement).toBe(screen.getByRole('dialog')) + + pressEscape() + expect(document.activeElement).toBe(trigger) + }) + + // 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(() => ) + flush() + + expect(focusSpy).toHaveBeenCalledWith({ preventScroll: true }) + focusSpy.mockRestore() + }) + + it('moves focus to the first form field when the dialog contains one', () => { + render(() => ( + + + + + + + + + )) + expect(document.activeElement).toBe(screen.getByLabelText('Name')) + }) + + it('wraps Tab from the last focusable to the first', () => { + render(() => ) + const dialog = screen.getByRole('dialog') + + screen.getByText('Close').focus() + fireEvent.keyDown(dialog, { key: 'Tab' }) + expect(document.activeElement).toBe(screen.getByText('Action')) + }) + + it('wraps Shift+Tab from the first focusable to the last', () => { + render(() => ) + const dialog = screen.getByRole('dialog') + + screen.getByText('Action').focus() + fireEvent.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(() => ( + + + + + Close + + + + + + )) + const dialog = screen.getByRole('dialog') + + dialog.focus() // the dialog window — where focus opens + fireEvent.keyDown(dialog, { key: 'Tab' }) + expect(document.activeElement).toBe(screen.getByText('Content')) // not Close + + dialog.focus() + fireEvent.keyDown(dialog, { key: 'Tab', shiftKey: true }) + expect(document.activeElement).toBe(screen.getByText('Close')) // last, backward + }) + + const InitialFocusDialog = (props: { disabled?: boolean }) => { + let initialFocus: HTMLInputElement | undefined + return ( + + + + initialFocus}> + (initialFocus = el)} + disabled={props.disabled} + aria-label='Name' + /> + + + + + ) + } + + it('moves focus to the initialFocus element on open', () => { + render(() => ) + expect(document.activeElement).toBe(screen.getByLabelText('Name')) + }) + + it('falls back to the dialog panel when the initialFocus target cannot take focus', () => { + render(() => ) + expect(document.activeElement).toBe(screen.getByRole('dialog')) + }) + }) + + describe('scroll lock', () => { + it('locks body scroll while a modal dialog is open', () => { + render(() => ) + openDialog() + expect(document.body.style.overflow).toBe('hidden') + + pressEscape() + expect(document.body.style.overflow).not.toBe('hidden') + }) + + it('does not lock scroll when modal=false', () => { + render(() => ) + expect(document.body.style.overflow).not.toBe('hidden') + }) + + it('locks the portal container, not the body, when scoped', () => { + const panel = document.createElement('div') + document.body.append(panel) + + render(() => ( + + + content + + + )) + + expect(panel.style.overflow).toBe('hidden') + expect(document.body.style.overflow).not.toBe('hidden') + + pressEscape() + expect(panel.style.overflow).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 }) + }) + + it('closes on the browser Back instead of navigating', async () => { + const before: unknown = window.history.state + render(() => ) + openDialog() + expect(window.history.state).not.toEqual(before) // the guard entry is planted + + const pop = nextPop() + window.history.back() + await pop + flush() + 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(() => ) + flush() + 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(() => ) + flush() + expect(window.history.state).toEqual(before) + }) + }) + + describe('exit animation', () => { + const fireTransitionEnd = (element: Element): void => { + element.dispatchEvent(new Event('transitionend', { bubbles: true })) + flush() + } + + it('stays mounted through the exit and unmounts when its transition ends', () => { + render(() => ) + 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(() => ) + 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(() => ) + 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', () => { + const NestedDialog = (props: DialogProps) => ( + + + + + + Outer + + + + + + Inner + + + + + + + + + ) + + it('Escape dismisses the topmost dialog only, one layer per press', () => { + render(() => ) + 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('hides the dialog beneath the topmost from assistive tech and makes it inert', () => { + render(() => ) + 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(() => ) + 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(() => ) + 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(() => ) + 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', () => { + const [open, setOpen] = createSignal(true) + const { container } = render(() => ) + expect(screen.queryByText('Inner')).not.toBeNull() + expect(container.hasAttribute('inert')).toBe(true) + + setOpen(false) + flush() + expect(screen.queryByText('Outer')).toBeNull() + expect(screen.queryByText('Inner')).toBeNull() + expect(document.body.style.overflow).not.toBe('hidden') + expect(container.hasAttribute('aria-hidden')).toBe(false) + expect(container.hasAttribute('inert')).toBe(false) + }) + }) +}) diff --git a/packages/solid/dialog/tsdown.config.ts b/packages/solid/dialog/tsdown.config.ts new file mode 100644 index 0000000..ff9c219 --- /dev/null +++ b/packages/solid/dialog/tsdown.config.ts @@ -0,0 +1,19 @@ +import { babel } from '@rollup/plugin-babel' +import { defineConfig } from 'tsdown' + +// Solid JSX needs Solid's own compiler (babel-preset-solid) — rolldown/oxc +// only know React-shaped JSX. Presets apply last-to-first: TypeScript strips +// types keeping the JSX, then the Solid preset compiles it. Everything else +// inherits the root config. +export default defineConfig({ + plugins: [ + babel({ + babelHelpers: 'bundled', + extensions: ['.tsx'], + presets: [ + ['babel-preset-solid'], + ['@babel/preset-typescript', { isTSX: true, allExtensions: true }], + ], + }), + ], +}) diff --git a/packages/solid/hooks/use-focus-trap/README.md b/packages/solid/hooks/use-focus-trap/README.md new file mode 100644 index 0000000..9a8441a --- /dev/null +++ b/packages/solid/hooks/use-focus-trap/README.md @@ -0,0 +1,28 @@ +# @dunky.dev/solid-use-focus-trap + +Solid binding for [`@dunky.dev/dom-focus-trap`](../../../dom/utils/focus-trap): +`useFocusTrap(target)` traps Tab / Shift+Tab within the accessed container +while the owner lives. The trap behavior itself is framework-free — this +primitive only owns the Solid lifecycle. + +## Install + +```sh +npm install @dunky.dev/solid-use-focus-trap +``` + +## Usage + +```tsx +import { useFocusTrap } from '@dunky.dev/solid-use-focus-trap' + +function Dialog() { + let panel: HTMLDivElement | undefined + useFocusTrap(() => panel ?? null, { enabled: () => isTopmost(panel) }) + return ( +
+ ... +
+ ) +} +``` diff --git a/packages/solid/hooks/use-focus-trap/SPEC.md b/packages/solid/hooks/use-focus-trap/SPEC.md new file mode 100644 index 0000000..fdf2822 --- /dev/null +++ b/packages/solid/hooks/use-focus-trap/SPEC.md @@ -0,0 +1,52 @@ +# SPEC / Solid / useFocusTrap + +The Solid binding of the +[DOM focus-trap spec](../../../dom/utils/focus-trap/SPEC.md) — the trap +behavior is framework-free; this primitive owns only the Solid lifecycle. + +## Install + +```sh +npm install @dunky.dev/solid-use-focus-trap +``` + +## Usage + +```tsx +import { isTopmostLayer } from '@dunky.dev/dom-overlay' +import { useFocusTrap } from '@dunky.dev/solid-use-focus-trap' + +function DialogContent(props: { id: string }) { + let panel: HTMLDivElement | undefined + // `enabled` follows runtime state — here, only the overlay stack's + // topmost layer traps. + useFocusTrap(() => panel ?? null, { enabled: () => isTopmostLayer(props.id) }) + return ( +
+ ... +
+ ) +} +``` + +Solid-specific notes on top of the DOM contract: + +- The target is an accessor, not a ref object: call the primitive from the + component that renders the container. A plain ref variable fills during + render, before effects run, so the trap binds when the component mounts + and releases when its owner is disposed. A reactive accessor (a signal) + re-arms the trap on a new element. +- Options are read through the closure on each Tab press, so inline + `enabled` / `last` 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 owner. + +| Param | Type | Default | Description | +| --------- | ---------------------------------------- | ------- | -------------------------------------------------------------------------------------- | +| `target` | `() => HTMLElement \| null \| undefined` | — | Accessor 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/solid/hooks/use-focus-trap/package.json b/packages/solid/hooks/use-focus-trap/package.json new file mode 100644 index 0000000..98776de --- /dev/null +++ b/packages/solid/hooks/use-focus-trap/package.json @@ -0,0 +1,49 @@ +{ + "name": "@dunky.dev/solid-use-focus-trap", + "version": "0.0.0", + "description": "Solid binding for @dunky.dev/dom-focus-trap.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/dunky-dev/ui.git", + "directory": "packages/solid/hooks/use-focus-trap" + }, + "files": [ + "dist", + "src", + "SPEC.md" + ], + "type": "module", + "sideEffects": false, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "publishConfig": { + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "access": "public" + }, + "scripts": { + "build": "tsdown" + }, + "dependencies": { + "@dunky.dev/dom-focus-trap": "workspace:*" + }, + "devDependencies": { + "@solidjs/testing-library": "^1.0.0-beta.2", + "@solidjs/web": "^2.0.0-rc.1", + "solid-js": "^2.0.0-rc.1" + }, + "peerDependencies": { + "solid-js": "^2.0.0-rc.1" + } +} diff --git a/packages/solid/hooks/use-focus-trap/src/index.ts b/packages/solid/hooks/use-focus-trap/src/index.ts new file mode 100644 index 0000000..52559c7 --- /dev/null +++ b/packages/solid/hooks/use-focus-trap/src/index.ts @@ -0,0 +1 @@ +export { useFocusTrap, type UseFocusTrapOptions } from './use-focus-trap' diff --git a/packages/solid/hooks/use-focus-trap/src/use-focus-trap.ts b/packages/solid/hooks/use-focus-trap/src/use-focus-trap.ts new file mode 100644 index 0000000..9d711fe --- /dev/null +++ b/packages/solid/hooks/use-focus-trap/src/use-focus-trap.ts @@ -0,0 +1,31 @@ +import { createEffect, untrack } from 'solid-js' +import { trapFocus } from '@dunky.dev/dom-focus-trap' +import type { TrapFocusOptions } from '@dunky.dev/dom-focus-trap' + +export interface UseFocusTrapOptions extends TrapFocusOptions {} + +/** + * Traps Tab / Shift+Tab within `target` while it holds an element — the Solid + * lifecycle around `trapFocus`. Arms when the target yields an element, + * releases on dispose, re-arms when a reactive accessor yields a new one. + */ +export function useFocusTrap( + target: () => HTMLElement | null | undefined, + options: UseFocusTrapOptions = {}, +): void { + // The compute tracks a reactive target; the apply re-reads it fresh — + // compute runs eagerly at creation, before a plain ref variable fills. + // Options are read per Tab press, so inline `enabled` / `last` stay live + // without re-binding the listener. + createEffect( + () => target(), + () => { + const container = untrack(target) + if (container == null) return + return trapFocus(container, { + enabled: () => options.enabled?.() !== false, + last: () => options.last?.() ?? null, + }) + }, + ) +} diff --git a/packages/solid/hooks/use-focus-trap/tests/use-focus-trap.test.tsx b/packages/solid/hooks/use-focus-trap/tests/use-focus-trap.test.tsx new file mode 100644 index 0000000..5fdd108 --- /dev/null +++ b/packages/solid/hooks/use-focus-trap/tests/use-focus-trap.test.tsx @@ -0,0 +1,54 @@ +// @vitest-environment jsdom +// The Solid lifecycle around @dunky.dev/dom-focus-trap — the wrap/no-op/enabled +// behavior itself is covered in the util's own tests. +import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library' +import { afterEach, describe, expect, it } from 'vitest' +import { useFocusTrap } from '@dunky.dev/solid-use-focus-trap' + +function Trap(props: { enabled?: () => boolean }) { + let target: HTMLDivElement | undefined + // The closure defers the props read to each Tab press. + useFocusTrap(() => target ?? null, { enabled: () => props.enabled?.() !== false }) + return ( +
(target = el)} tabindex={-1} data-testid='container'> + + +
+ ) +} + +// fireEvent returns false when a handler called preventDefault. +const pressTab = (): boolean => fireEvent.keyDown(screen.getByTestId('container'), { key: 'Tab' }) + +// Auto-cleanup needs vitest globals; this repo runs with globals: false. +afterEach(cleanup) + +describe('useFocusTrap', () => { + it('traps while mounted and releases on unmount', () => { + const { unmount } = render(() => ) + 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( + container.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }), + ), + ).toBe(true) + }) + + it('forwards enabled() to the trap without re-binding', () => { + render(() => false} />) + const last = screen.getByText('last') + last.focus() + + expect(pressTab()).toBe(true) + expect(document.activeElement).toBe(last) + }) +}) diff --git a/packages/solid/hooks/use-scroll-lock/README.md b/packages/solid/hooks/use-scroll-lock/README.md new file mode 100644 index 0000000..d2c6450 --- /dev/null +++ b/packages/solid/hooks/use-scroll-lock/README.md @@ -0,0 +1,25 @@ +# @dunky.dev/solid-use-scroll-lock + +Solid binding for [`@dunky.dev/dom-scroll-lock`](../../../dom/utils/scroll-lock): +`useScrollLock(locked, target?)` locks scrolling while the owner 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 primitive only owns the Solid lifecycle. + +## Install + +```sh +npm install @dunky.dev/solid-use-scroll-lock +``` + +## Usage + +```tsx +import { useScrollLock } from '@dunky.dev/solid-use-scroll-lock' + +// Rendered while a modal layer is open, e.g. +function ModalPanel() { + useScrollLock() // the page behind can't scroll while mounted + return
...
+} +``` diff --git a/packages/solid/hooks/use-scroll-lock/SPEC.md b/packages/solid/hooks/use-scroll-lock/SPEC.md new file mode 100644 index 0000000..9c2a98f --- /dev/null +++ b/packages/solid/hooks/use-scroll-lock/SPEC.md @@ -0,0 +1,45 @@ +# SPEC / Solid / useScrollLock + +The Solid binding of the +[DOM scroll-lock spec](../../../dom/utils/scroll-lock/SPEC.md) — the lock +behavior is framework-free; this primitive owns only the Solid lifecycle. + +## Install + +```sh +npm install @dunky.dev/solid-use-scroll-lock +``` + +## Usage + +```tsx +import { useScrollLock } from '@dunky.dev/solid-use-scroll-lock' + +// Rendered while a modal layer is open, e.g. +function ModalPanel() { + useScrollLock() // the page behind can't scroll while mounted + return
...
+} +``` + +Solid-specific notes on top of the DOM contract: + +- The lock holds while the owner lives and `locked` resolves true; disposal + or turning `locked` off releases it. Both parameters accept a + `MaybeAccessor` — a static value or an accessor — so the lock tracks + reactive state: a `target` change releases the old container and locks + the new one. +- 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 owner. + +| Param | Type | Default | Description | +| -------- | ------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------------- | +| `locked` | `MaybeAccessor` | `true` | Whether the lock is held. | +| `target` | `MaybeAccessor` | the page body | The scroll container to lock (e.g. a scoped surface locks its own container, not the page). | diff --git a/packages/solid/hooks/use-scroll-lock/package.json b/packages/solid/hooks/use-scroll-lock/package.json new file mode 100644 index 0000000..95eb1a9 --- /dev/null +++ b/packages/solid/hooks/use-scroll-lock/package.json @@ -0,0 +1,48 @@ +{ + "name": "@dunky.dev/solid-use-scroll-lock", + "version": "0.0.0", + "description": "Solid binding for @dunky.dev/dom-scroll-lock.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/dunky-dev/ui.git", + "directory": "packages/solid/hooks/use-scroll-lock" + }, + "files": [ + "dist", + "src", + "SPEC.md" + ], + "type": "module", + "sideEffects": false, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "publishConfig": { + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "access": "public" + }, + "scripts": { + "build": "tsdown" + }, + "dependencies": { + "@dunky.dev/dom-scroll-lock": "workspace:*" + }, + "devDependencies": { + "@solidjs/testing-library": "^1.0.0-beta.2", + "solid-js": "^2.0.0-rc.1" + }, + "peerDependencies": { + "solid-js": "^2.0.0-rc.1" + } +} diff --git a/packages/solid/hooks/use-scroll-lock/src/index.ts b/packages/solid/hooks/use-scroll-lock/src/index.ts new file mode 100644 index 0000000..80b363c --- /dev/null +++ b/packages/solid/hooks/use-scroll-lock/src/index.ts @@ -0,0 +1 @@ +export { useScrollLock, type MaybeAccessor } from './use-scroll-lock' diff --git a/packages/solid/hooks/use-scroll-lock/src/use-scroll-lock.ts b/packages/solid/hooks/use-scroll-lock/src/use-scroll-lock.ts new file mode 100644 index 0000000..2cd5bc5 --- /dev/null +++ b/packages/solid/hooks/use-scroll-lock/src/use-scroll-lock.ts @@ -0,0 +1,27 @@ +import { createEffect } from 'solid-js' +import { lockScroll } from '@dunky.dev/dom-scroll-lock' + +/** A static value or an accessor — for parameters that may be reactive. */ +export type MaybeAccessor = T | (() => T) + +function access(value: MaybeAccessor): T { + return typeof value === 'function' ? (value as () => T)() : value +} + +/** + * Locks scrolling while the owner lives and `locked` — the Solid lifecycle + * around `lockScroll`. Targets the page body unless a `target` is given. The + * lock is shared per container: it restores when the last holder releases. + */ +export function useScrollLock( + locked: MaybeAccessor = true, + target?: MaybeAccessor, +): void { + createEffect( + () => [access(locked), target === undefined ? undefined : access(target)] as const, + ([isLocked, container]) => { + if (!isLocked) return + return lockScroll(container ?? undefined) + }, + ) +} diff --git a/packages/solid/hooks/use-scroll-lock/tests/use-scroll-lock.test.ts b/packages/solid/hooks/use-scroll-lock/tests/use-scroll-lock.test.ts new file mode 100644 index 0000000..ee8c88a --- /dev/null +++ b/packages/solid/hooks/use-scroll-lock/tests/use-scroll-lock.test.ts @@ -0,0 +1,20 @@ +// @vitest-environment jsdom +import { renderHook } from '@solidjs/testing-library' +import { describe, expect, it } from 'vitest' +import { useScrollLock } from '@dunky.dev/solid-use-scroll-lock' + +describe('useScrollLock', () => { + it('locks body scroll while mounted and releases on unmount', () => { + const { cleanup } = renderHook(() => useScrollLock()) + expect(document.body.style.overflow).toBe('hidden') + + cleanup() + expect(document.body.style.overflow).toBe('') + }) + + it('does not lock when locked=false', () => { + const { cleanup } = renderHook(() => useScrollLock(false)) + expect(document.body.style.overflow).toBe('') + cleanup() + }) +}) diff --git a/packages/solid/package.json b/packages/solid/package.json new file mode 100644 index 0000000..0f043de --- /dev/null +++ b/packages/solid/package.json @@ -0,0 +1,20 @@ +{ + "name": "@dunky-dev/solid", + "version": "0.0.0", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "dev": "storybook dev -p 6008 -c .storybook", + "build": "storybook build -c .storybook" + }, + "devDependencies": { + "@solidjs/web": "^2.0.0-rc.1", + "@testing-library/jest-dom": "^6.9.1", + "solid-js": "^2.0.0-rc.1", + "storybook": "^10.5.0", + "storybook-solidjs-vite": "^10.6.0", + "vite": "^8.1.4", + "vite-plugin-solid": "^3.0.0-next.27" + } +} diff --git a/packages/solid/tsconfig.json b/packages/solid/tsconfig.json new file mode 100644 index 0000000..b433f53 --- /dev/null +++ b/packages/solid/tsconfig.json @@ -0,0 +1,12 @@ +{ + // Solid 2.0's web JSX namespace lives in @solidjs/web, not React's — so + // packages/solid typechecks as its own project (the root tsconfig excludes + // it; `pnpm typecheck` runs both). `paths` is inherited from the root file. + "extends": "../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "jsxImportSource": "@solidjs/web" + }, + "include": ["."], + "exclude": ["**/node_modules", "**/dist"] +} diff --git a/packages/solid/vitest.config.ts b/packages/solid/vitest.config.ts new file mode 100644 index 0000000..4584059 --- /dev/null +++ b/packages/solid/vitest.config.ts @@ -0,0 +1,17 @@ +import solid from 'vite-plugin-solid' +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + plugins: [solid()], + resolve: { + // @solidjs/testing-library + the reactive runtime expect these conditions. + conditions: ['development', 'browser'], + }, + test: { + name: 'solid', + globals: false, + // node by default; DOM tests opt into jsdom per-file via `@vitest-environment`. + environment: 'node', + include: ['**/tests/**/*.test.{ts,tsx}'], + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 04333e2..481a383 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,20 +54,39 @@ importers: specifier: workspace:* version: link:../utils/controllable '@dunky.dev/state-machine': - specifier: ^0.3.2 - version: 0.3.2 + specifier: ^0.3.3 + version: 0.3.3 '@dunky.dev/state-machine-bindings': - specifier: ^0.3.2 - version: 0.3.2 + specifier: ^0.4.1 + version: 0.4.1 packages/core/utils/controllable: dependencies: '@dunky.dev/state-machine': - specifier: ^0.1.0 - version: 0.1.0 + specifier: ^0.3.3 + version: 0.3.3 packages/core/utils/overlay: {} + packages/dom/components/dialog: + dependencies: + '@dunky.dev/dialog': + specifier: workspace:* + version: link:../../../core/dialog + '@dunky.dev/dom-focus-trap': + specifier: workspace:* + version: link:../../utils/focus-trap + '@dunky.dev/dom-navigation': + specifier: workspace:* + version: link:../../utils/navigation + '@dunky.dev/dom-overlay': + specifier: workspace:* + version: link:../../utils/overlay + devDependencies: + '@dunky.dev/state-machine': + specifier: ^0.3.3 + version: 0.3.3 + packages/dom/utils/focus-trap: {} packages/dom/utils/navigation: {} @@ -141,8 +160,8 @@ importers: specifier: workspace:* version: link:../../core/dialog '@dunky.dev/native-state-machine': - specifier: ^0.3.2 - version: 0.3.2(react-native@0.86.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3) + specifier: ^0.4.0 + version: 0.4.0(react-native@0.86.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3) devDependencies: '@testing-library/react-native': specifier: ^13.3.3 @@ -189,15 +208,12 @@ importers: '@dunky.dev/dialog': specifier: workspace:* version: link:../../core/dialog - '@dunky.dev/dom-navigation': - specifier: workspace:* - version: link:../../dom/utils/navigation - '@dunky.dev/dom-overlay': + '@dunky.dev/dom-dialog': specifier: workspace:* - version: link:../../dom/utils/overlay + version: link:../../dom/components/dialog '@dunky.dev/react-state-machine': - specifier: ^0.3.2 - version: 0.3.2(react@19.2.7) + specifier: ^0.3.4 + version: 0.3.4(react@19.2.7) '@dunky.dev/react-use-focus-trap': specifier: workspace:* version: link:../hooks/use-focus-trap @@ -265,11 +281,108 @@ importers: specifier: ^19.2.6 version: 19.2.7(react@19.2.7) + packages/solid: + devDependencies: + '@solidjs/web': + specifier: ^2.0.0-rc.1 + version: 2.0.0-rc.1(solid-js@2.0.0-rc.1) + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + solid-js: + specifier: ^2.0.0-rc.1 + version: 2.0.0-rc.1 + storybook: + specifier: ^10.5.0 + version: 10.5.2(@types/react@19.2.17)(prettier@2.8.8)(react@19.2.7) + storybook-solidjs-vite: + specifier: ^10.6.0 + version: 10.6.0(@solidjs/web@2.0.0-rc.1(solid-js@2.0.0-rc.1))(esbuild@0.28.1)(solid-js@2.0.0-rc.1)(storybook@10.5.2(@types/react@19.2.17)(prettier@2.8.8)(react@19.2.7))(typescript@6.0.3)(vite-plugin-solid@3.0.0-next.27(@solidjs/web@2.0.0-rc.1(solid-js@2.0.0-rc.1))(@testing-library/jest-dom@6.9.1)(solid-js@2.0.0-rc.1)(vite@8.1.4(@types/node@22.19.21)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0)))(vite@8.1.4(@types/node@22.19.21)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0)) + vite: + specifier: ^8.1.4 + version: 8.1.4(@types/node@22.19.21)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0) + vite-plugin-solid: + specifier: ^3.0.0-next.27 + version: 3.0.0-next.27(@solidjs/web@2.0.0-rc.1(solid-js@2.0.0-rc.1))(@testing-library/jest-dom@6.9.1)(solid-js@2.0.0-rc.1)(vite@8.1.4(@types/node@22.19.21)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0)) + + packages/solid/dialog: + dependencies: + '@dunky.dev/dialog': + specifier: workspace:* + version: link:../../core/dialog + '@dunky.dev/dom-dialog': + specifier: workspace:* + version: link:../../dom/components/dialog + '@dunky.dev/solid-state-machine': + specifier: ^0.3.0 + version: 0.3.0(solid-js@2.0.0-rc.1) + '@dunky.dev/solid-use-focus-trap': + specifier: workspace:* + version: link:../hooks/use-focus-trap + '@dunky.dev/solid-use-scroll-lock': + specifier: workspace:* + version: link:../hooks/use-scroll-lock + devDependencies: + '@babel/core': + specifier: ^7.28.4 + version: 7.29.7 + '@babel/preset-typescript': + specifier: ^7.27.1 + version: 7.29.7(@babel/core@7.29.7) + '@rollup/plugin-babel': + specifier: ^6.0.4 + version: 6.1.0(@babel/core@7.29.7)(@types/babel__core@7.20.5) + '@solidjs/testing-library': + specifier: ^1.0.0-beta.2 + version: 1.0.0-beta.2(@solidjs/web@2.0.0-rc.1(solid-js@2.0.0-rc.1))(solid-js@2.0.0-rc.1) + '@solidjs/web': + specifier: ^2.0.0-rc.1 + version: 2.0.0-rc.1(solid-js@2.0.0-rc.1) + babel-preset-solid: + specifier: ^2.0.0-rc.1 + version: 2.0.0-rc.1(@babel/core@7.29.7)(solid-js@2.0.0-rc.1) + solid-js: + specifier: ^2.0.0-rc.1 + version: 2.0.0-rc.1 + + packages/solid/hooks/use-focus-trap: + dependencies: + '@dunky.dev/dom-focus-trap': + specifier: workspace:* + version: link:../../../dom/utils/focus-trap + devDependencies: + '@solidjs/testing-library': + specifier: ^1.0.0-beta.2 + version: 1.0.0-beta.2(@solidjs/web@2.0.0-rc.1(solid-js@2.0.0-rc.1))(solid-js@2.0.0-rc.1) + '@solidjs/web': + specifier: ^2.0.0-rc.1 + version: 2.0.0-rc.1(solid-js@2.0.0-rc.1) + solid-js: + specifier: ^2.0.0-rc.1 + version: 2.0.0-rc.1 + + packages/solid/hooks/use-scroll-lock: + dependencies: + '@dunky.dev/dom-scroll-lock': + specifier: workspace:* + version: link:../../../dom/utils/scroll-lock + devDependencies: + '@solidjs/testing-library': + specifier: ^1.0.0-beta.2 + version: 1.0.0-beta.2(@solidjs/web@2.0.0-rc.1(solid-js@2.0.0-rc.1))(solid-js@2.0.0-rc.1) + solid-js: + specifier: ^2.0.0-rc.1 + version: 2.0.0-rc.1 + packages: '@adobe/css-tools@4.5.0': resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + '@asamuzakjp/css-color@3.2.0': resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} @@ -322,6 +435,10 @@ packages: resolution: {integrity: sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==} engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.18.6': + resolution: {integrity: sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==} + engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.29.7': resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} engines: {node: '>=6.9.0'} @@ -819,28 +936,77 @@ packages: resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} engines: {node: '>=18'} - '@dunky.dev/native-state-machine@0.3.2': - resolution: {integrity: sha512-54mLM/aEXyv1mIOXVH71hMEBsYHwaVVjUbH+mSSyp9XZ0qMthth1p99xdPV/juY+Fjd6E53ZITxSIyK5N5AuFA==} + '@dom-expressions/babel-plugin-jsx@0.50.0-next.42': + resolution: {integrity: sha512-ol24x9RW8loPyOTzC/mQzh/zAsrsPxyTG4WRxxRlwzNK2uBWIBftWN5IwmSV51zDQa7JcT6sE6kkXFCLUvsYIQ==} + peerDependencies: + '@babel/core': ^7.20.12 + + '@dom-expressions/babel-plugin-jsx@0.50.0-next.43': + resolution: {integrity: sha512-JMMS/WHcptu2xMQQhUd7P0c0g2RSvvILg5Fj2y48AiWbPylQX8oXvekDTTXOlrebg1qapqMcEnw0OwYxwr7wOw==} + peerDependencies: + '@babel/core': ^7.20.12 + + '@dom-expressions/compiler-darwin-arm64@0.50.0-next.40': + resolution: {integrity: sha512-+3aXdhw4SVt08fvgAsEM56ky/wdCVPXT0Wtxo164Cchgg7sapPsRqo8Gwwn0UU5AhVcEp3CIhdB5+pMoUicKFg==} + cpu: [arm64] + os: [darwin] + + '@dom-expressions/compiler-darwin-x64@0.50.0-next.40': + resolution: {integrity: sha512-Tbjg6ZQEIhKLKGd/Ep6MKqD76arPdV6SE2d3fkrP4qooIv2gYvAkvUw90qNdZxVHcuyjutrbJE0WpXYY9nSIIQ==} + cpu: [x64] + os: [darwin] + + '@dom-expressions/compiler-linux-arm64-gnu@0.50.0-next.40': + resolution: {integrity: sha512-tIJMY8dPyjiYNSL5uc4JA5l88Sw0WoMwoAilqTTHpGYdCL5GwzGq6ZOV1bndw8XKEYFYfnTr276FDHyYpLtRYQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@dom-expressions/compiler-linux-x64-gnu@0.50.0-next.40': + resolution: {integrity: sha512-H/K/3Ykk8aCSNuKDlv/sgbPdSks+zqFxZ4mzqaGJmlDqEfeaWYXan5fsvwQbACdNDJeKoKLO4GhnlnKlKWjiJg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@dom-expressions/compiler-wasm32-wasi@0.50.0-next.40': + resolution: {integrity: sha512-2SAfc35FEvvxkUz2yeh/4aNx0s9waZnce4KS64oUMlVpYEFtNBdfnZKCB/bgzJFvTxXjMs+HcU6OInGltH5jHA==} + engines: {node: '>=14.0.0'} + + '@dom-expressions/compiler-win32-x64-msvc@0.50.0-next.40': + resolution: {integrity: sha512-bBdHMxdfUHtIrS5xrt9udqdGRnGKdYQgKxJNIts+Il3Fs8QGyNwaZpi0tjnlRjYa0+GdWEC3Gw1Pmq/HFXzFeQ==} + cpu: [x64] + os: [win32] + + '@dom-expressions/compiler@0.50.0-next.40': + resolution: {integrity: sha512-RI/kHU+QkLOHo4CQyqLTn9c7sAfl/GEULMsOpS1YqkPJgy7R8MCPWXFN4sI0QDBbM6ePtEyuW+2bsdsXqyxkzQ==} + + '@dunky.dev/native-state-machine@0.4.0': + resolution: {integrity: sha512-gA/VP8WySpakwFhd4jIx49ki1TkIEA24fq68MCPrzNL4sLkg4qD+cusAYcCCveAy8YLT8818lbm51mYs6DoR2w==} peerDependencies: react: '*' react-native: '*' - '@dunky.dev/react-state-machine@0.3.2': - resolution: {integrity: sha512-qVuMc4VZQPEZA86dz7xKLk7zLOWtcn7r35JNhId1gJo6sNhZb2R55sZBDs6b27hx2Rvo2MIyur18NFh/1qGNhA==} + '@dunky.dev/react-state-machine@0.3.4': + resolution: {integrity: sha512-QWhKVDNq6IK+PFLburz8tht1cL71t3FH8MvzmsvwY1kzBXOGNcTXiPQAsKTLti7Jj+f/7oeKjhZLD8FfxedLwg==} peerDependencies: react: '*' - '@dunky.dev/state-machine-bindings@0.3.2': - resolution: {integrity: sha512-ybY6lYWukAB2NByqMrcNpVtZSMjBPnLSC34faL2dSXFJuU52O5AqZodfDTXO0gGBWQWlQBjtnXMqKgYsamI/sA==} + '@dunky.dev/solid-state-machine@0.3.0': + resolution: {integrity: sha512-8KhPSTkhybbyNfP2BcjZjDm7nKE59ezHTJQPkMlWs19wuTUADF8EJj2XoZ0W1PFgvhxsrNkeieAIjeluwC/t7Q==} + peerDependencies: + solid-js: ^2.0.0-rc.1 + + '@dunky.dev/state-machine-bindings@0.4.1': + resolution: {integrity: sha512-j45Os+9gX4icRuNsu6K8aoaicVjObf8YDBCoAu35ogPLubWgkr6a0BzhqWnZRNItCtynIxK7yR5DJIdydYOBgg==} - '@dunky.dev/state-machine-utils@0.3.2': - resolution: {integrity: sha512-A9Fn8dEk9xi0wgv3HLY8wQ2TZMX/HLv/fFQZASB9zG/iKZbga7kOdBuLoABRU5joM6taNXElJdfCk+BIpwbuQw==} + '@dunky.dev/state-machine-dom@0.1.0': + resolution: {integrity: sha512-tVHHfEji/IMozg76vkF40foTdEsHrU+TuJY8lJ5ertkeVKkToFQDIW8Yi1r/DokwE0E0K2Gh5+QarBuTwk/XIw==} - '@dunky.dev/state-machine@0.1.0': - resolution: {integrity: sha512-RaVWP0g5c3qG0bX8npaptziC6P5CCN9+Bd4zbrs5N8VDhUvUKFDltxjixtjbeEltje169w5K7l7zbyQCjCK8bg==} + '@dunky.dev/state-machine-utils@0.4.0': + resolution: {integrity: sha512-UYf4dikzhSiJ5CeTlsRFUgfMrD5d5c+v5mQgrrYcuYgRdFsL2riTtyptOFe3RKlyrdlNzem/isLb9P3VJr9Rsw==} - '@dunky.dev/state-machine@0.3.2': - resolution: {integrity: sha512-DXET0k5zxbTMM2BYPcYww7NdmKH2QePfeVNZmxS5DW2tIbd4HW+7VnPyeiJC18fbkW2w5fIva6BrypJlG1xuJA==} + '@dunky.dev/state-machine@0.3.3': + resolution: {integrity: sha512-pjI8uKEMq4d1scPpzQ+4cDzW76GSX+IQNngtc6OmMjwOFoL1MsKMW+YO096d0o5Kv2PTOmYvGsGAQ7One4UDdw==} '@egjs/hammerjs@2.0.17': resolution: {integrity: sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==} @@ -852,6 +1018,9 @@ packages: '@emnapi/core@1.11.1': resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + '@emnapi/core@1.11.2': + resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} + '@emnapi/core@1.9.2': resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} @@ -861,6 +1030,9 @@ packages: '@emnapi/runtime@1.11.1': resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + '@emnapi/runtime@1.9.2': resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} @@ -2201,6 +2373,19 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@rollup/plugin-babel@6.1.0': + resolution: {integrity: sha512-dFZNuFD2YRcoomP4oYf+DvQNSUA9ih+A3vUqopQx5EdtPGo3WBnQcI/S8pwpz91UsGfL0HsMSOlaMld8HrbubA==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@babel/core': ^7.0.0 + '@types/babel__core': ^7.1.9 + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + '@types/babel__core': + optional: true + rollup: + optional: true + '@rollup/pluginutils@5.4.0': resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==} engines: {node: '>=14.0.0'} @@ -2222,6 +2407,32 @@ packages: '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + '@solidjs/signals@2.0.0-rc.1': + resolution: {integrity: sha512-KQpgUbn9xuzFaXupwej9MvUnQV+H6wcCgvrERf+dygco3T9JWP9S02g/UoYwwmJ6Vh+LE1b82ZlSHYR2Bd1O8A==} + + '@solidjs/testing-library@1.0.0-beta.2': + resolution: {integrity: sha512-TLhQ5IUT/fdDfqa4X2rkQWB28Y+zEwi6mK/TVTeiQlEHG63eK2jfgwNYf2NtQoPh2c3ihLilsCzxABiSTP3JoQ==} + engines: {node: '>= 14'} + peerDependencies: + '@solidjs/web': '>=2.0.0' + solid-js: '>=2.0.0' + + '@solidjs/vite-plugin@3.0.0-next.28': + resolution: {integrity: sha512-P/Xova2R8QoveQ2szrzkHSMPZjIyc5dE4hh2UHNgRW8CC5sSoh8yzPLw7qwvKdE1DydPHUWq/hrRIApkwt+grw==} + peerDependencies: + '@solidjs/web': ^2.0.0-rc.0 + '@testing-library/jest-dom': ^5.16.6 || ^5.17.0 || ^6.* + solid-js: ^2.0.0-rc.0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@testing-library/jest-dom': + optional: true + + '@solidjs/web@2.0.0-rc.1': + resolution: {integrity: sha512-wLuxGtQUxaFfqxqhIUJGGSZB/upd3GzokQRFJKvO7biJGNZLAws+eanMqi0kK2Amg+MZZ7aVyPPKydf8mzdhkg==} + peerDependencies: + solid-js: ^2.0.0-rc.1 + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -2578,16 +2789,27 @@ packages: '@vitest/utils@4.1.9': resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} + '@volar/language-core@2.4.28': + resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} + + '@volar/source-map@2.4.28': + resolution: {integrity: sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==} + + '@volar/typescript@2.4.28': + resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} + '@webcontainer/env@1.1.1': resolution: {integrity: sha512-6aN99yL695Hi9SuIk1oC88l9o0gmxL1nGWWQ/kNy81HigJ0FoaoTXpytCj6ItzgyCEwA9kF1wixsTuv5cjsgng==} '@xmldom/xmldom@0.8.13': resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version '@xmldom/xmldom@0.9.10': resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} engines: {node: '>=14.6'} + deprecated: this version has critical issues, please update to the latest version '@yuku-codegen/binding-darwin-arm64@0.6.1': resolution: {integrity: sha512-LDJtpOKtcv9f3V0eDUwFmmy47t2VC+DAuN+gq80R1IA+fa0d408i6sHsVtt6n+g5rf8f86ySoPSAe94lHt6Ixw==} @@ -2911,6 +3133,24 @@ packages: peerDependencies: '@babel/core': ^7.0.0 + babel-preset-solid@2.0.0-rc.0: + resolution: {integrity: sha512-Ap2/QQY3pICj+Q0VM/RnIOpZo7e6icZnUA0oBJuhqzoCrljqMNo3eFb2OeEa4pUQeFREJOlex4Bt1ggwrcgC8w==} + peerDependencies: + '@babel/core': ^7.0.0 + solid-js: ^2.0.0-rc.0 + peerDependenciesMeta: + solid-js: + optional: true + + babel-preset-solid@2.0.0-rc.1: + resolution: {integrity: sha512-HMefWI9rhIGYmefNCmcX21p7di4UUeuayQceUG1Xi6l0/AEhqi6iqPKMr+vvqP31EkoJU9/EcjIBnqS5vSEEhw==} + peerDependencies: + '@babel/core': ^7.0.0 + solid-js: ^2.0.0-rc.1 + peerDependenciesMeta: + solid-js: + optional: true + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -3828,6 +4068,9 @@ packages: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} + html-entities@2.3.3: + resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -3978,6 +4221,10 @@ packages: resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} engines: {node: '>=4'} + is-what@4.1.16: + resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==} + engines: {node: '>=12.13'} + is-windows@1.0.2: resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} engines: {node: '>=0.10.0'} @@ -4430,6 +4677,10 @@ packages: memoizerific@1.11.3: resolution: {integrity: sha512-/EuHYwAPdLtXwAwSZkh/Gutery6pD2KYd44oQLhAvQp/50mpyduZh8Q7PYHXTCJ+wuXxt7oij2LXyIJOOYFPog==} + merge-anything@5.1.7: + resolution: {integrity: sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ==} + engines: {node: '>=12.13'} + merge-options@3.0.4: resolution: {integrity: sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==} engines: {node: '>=10'} @@ -4769,6 +5020,9 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -5179,6 +5433,16 @@ packages: resolution: {integrity: sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==} engines: {node: '>=0.10.0'} + seroval-plugins@1.5.6: + resolution: {integrity: sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.5.6: + resolution: {integrity: sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA==} + engines: {node: '>=10'} + serve-static@1.16.3: resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} engines: {node: '>= 0.8.0'} @@ -5244,6 +5508,9 @@ packages: resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} engines: {node: '>= 18'} + solid-js@2.0.0-rc.1: + resolution: {integrity: sha512-UD+UfqfiuuOTaDw01YeT+LwsYJC2ilTlMfs6h8EC8FFLmZD0ZjeZIoJXdZEo9uMzIof2tu0Rfh3dnzI7FAmuJQ==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -5309,6 +5576,21 @@ packages: std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + storybook-solidjs-vite@10.6.0: + resolution: {integrity: sha512-/nNRk0D8Uwvqny/DKVNBsCzajjmC//cATifF66ebnpjpRBCwBrflg2ymsGEE0D9LmDsdcMBn1rsoe6Z4C2Jzbw==} + peerDependencies: + '@solidjs/web': ^2.0.0-0 + solid-js: ^1.8.0-0 || ^2.0.0-0 + storybook: ^0.0.0-0 || ^10.0.0 + typescript: ^4.0.0 || ^5.0.0 || ^6.0.0 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + vite-plugin-solid: ^2.0.0-0 || ^3.0.0-0 + peerDependenciesMeta: + '@solidjs/web': + optional: true + typescript: + optional: true + storybook@10.5.0: resolution: {integrity: sha512-dRhM/kSSvHQR8DmZO41v5sJuz9U6zDjjR2gRBTgZN2RBSXbmF0Brvgszrvvxyx2VfxuYKzhB+xumKwWkwlBtig==} hasBin: true @@ -5687,6 +5969,9 @@ packages: typescript: optional: true + validate-html-nesting@1.2.4: + resolution: {integrity: sha512-doQi7e8EJ2OWneSG1aZpJluS6A49aZM0+EICXWKm1i6WvqTLmq0tpUcImc4KTWG50mORO0C4YDBtOCSYvElftw==} + validate-npm-package-name@5.0.1: resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -5695,6 +5980,9 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + vite-plugin-solid@3.0.0-next.27: + resolution: {integrity: sha512-bDzjIIplkSDH73BiGP9pbPR3ZnjeUA18SAYugLhqDCy4u0bl3qdrETstxrXuo2vHXLB0VD9rvOr0iesZBBul4Q==} + vite@8.1.4: resolution: {integrity: sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5738,6 +6026,14 @@ packages: yaml: optional: true + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + vitest@4.1.9: resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -5782,6 +6078,9 @@ packages: vlq@1.0.1: resolution: {integrity: sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==} + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + w3c-xmlserializer@4.0.0: resolution: {integrity: sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==} engines: {node: '>=14'} @@ -5980,6 +6279,11 @@ snapshots: '@adobe/css-tools@4.5.0': {} + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@asamuzakjp/css-color@3.2.0': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) @@ -6076,6 +6380,10 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-imports@7.18.6': + dependencies: + '@babel/types': 7.29.7 + '@babel/helper-module-imports@7.29.7': dependencies: '@babel/traverse': 7.29.7 @@ -6717,33 +7025,96 @@ snapshots: '@csstools/css-tokenizer@3.0.4': {} - '@dunky.dev/native-state-machine@0.3.2(react-native@0.86.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)': + '@dom-expressions/babel-plugin-jsx@0.50.0-next.42(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.18.6 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/types': 7.29.7 + html-entities: 2.3.3 + parse5: 7.3.0 + validate-html-nesting: 1.2.4 + + '@dom-expressions/babel-plugin-jsx@0.50.0-next.43(@babel/core@7.29.7)': dependencies: - '@dunky.dev/react-state-machine': 0.3.2(react@19.2.3) - '@dunky.dev/state-machine': 0.3.2 - '@dunky.dev/state-machine-utils': 0.3.2 + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.18.6 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/types': 7.29.7 + html-entities: 2.3.3 + parse5: 7.3.0 + validate-html-nesting: 1.2.4 + + '@dom-expressions/compiler-darwin-arm64@0.50.0-next.40': + optional: true + + '@dom-expressions/compiler-darwin-x64@0.50.0-next.40': + optional: true + + '@dom-expressions/compiler-linux-arm64-gnu@0.50.0-next.40': + optional: true + + '@dom-expressions/compiler-linux-x64-gnu@0.50.0-next.40': + optional: true + + '@dom-expressions/compiler-wasm32-wasi@0.50.0-next.40': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + optional: true + + '@dom-expressions/compiler-win32-x64-msvc@0.50.0-next.40': + optional: true + + '@dom-expressions/compiler@0.50.0-next.40': + optionalDependencies: + '@dom-expressions/compiler-darwin-arm64': 0.50.0-next.40 + '@dom-expressions/compiler-darwin-x64': 0.50.0-next.40 + '@dom-expressions/compiler-linux-arm64-gnu': 0.50.0-next.40 + '@dom-expressions/compiler-linux-x64-gnu': 0.50.0-next.40 + '@dom-expressions/compiler-wasm32-wasi': 0.50.0-next.40 + '@dom-expressions/compiler-win32-x64-msvc': 0.50.0-next.40 + + '@dunky.dev/native-state-machine@0.4.0(react-native@0.86.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)': + dependencies: + '@dunky.dev/react-state-machine': 0.3.4(react@19.2.3) + '@dunky.dev/state-machine': 0.3.3 + '@dunky.dev/state-machine-bindings': 0.4.1 + '@dunky.dev/state-machine-utils': 0.4.0 react: 19.2.3 react-native: 0.86.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3) - '@dunky.dev/react-state-machine@0.3.2(react@19.2.3)': + '@dunky.dev/react-state-machine@0.3.4(react@19.2.3)': dependencies: - '@dunky.dev/state-machine': 0.3.2 - '@dunky.dev/state-machine-utils': 0.3.2 + '@dunky.dev/state-machine': 0.3.3 + '@dunky.dev/state-machine-dom': 0.1.0 + '@dunky.dev/state-machine-utils': 0.4.0 react: 19.2.3 - '@dunky.dev/react-state-machine@0.3.2(react@19.2.7)': + '@dunky.dev/react-state-machine@0.3.4(react@19.2.7)': dependencies: - '@dunky.dev/state-machine': 0.3.2 - '@dunky.dev/state-machine-utils': 0.3.2 + '@dunky.dev/state-machine': 0.3.3 + '@dunky.dev/state-machine-dom': 0.1.0 + '@dunky.dev/state-machine-utils': 0.4.0 react: 19.2.7 - '@dunky.dev/state-machine-bindings@0.3.2': {} + '@dunky.dev/solid-state-machine@0.3.0(solid-js@2.0.0-rc.1)': + dependencies: + '@dunky.dev/state-machine': 0.3.3 + '@dunky.dev/state-machine-dom': 0.1.0 + '@dunky.dev/state-machine-utils': 0.4.0 + solid-js: 2.0.0-rc.1 - '@dunky.dev/state-machine-utils@0.3.2': {} + '@dunky.dev/state-machine-bindings@0.4.1': {} - '@dunky.dev/state-machine@0.1.0': {} + '@dunky.dev/state-machine-dom@0.1.0': + dependencies: + '@dunky.dev/state-machine-bindings': 0.4.1 - '@dunky.dev/state-machine@0.3.2': {} + '@dunky.dev/state-machine-utils@0.4.0': {} + + '@dunky.dev/state-machine@0.3.3': {} '@egjs/hammerjs@2.0.17': dependencies: @@ -6761,6 +7132,12 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/core@1.11.2': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + '@emnapi/core@1.9.2': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -6777,6 +7154,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.9.2': dependencies: tslib: 2.8.1 @@ -7497,6 +7879,13 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@tybys/wasm-util': 0.10.3 + optional: true + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': dependencies: '@emnapi/core': 1.9.2 @@ -8105,6 +8494,16 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@rollup/plugin-babel@6.1.0(@babel/core@7.29.7)(@types/babel__core@7.20.5)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@rollup/pluginutils': 5.4.0 + optionalDependencies: + '@types/babel__core': 7.20.5 + transitivePeerDependencies: + - supports-color + '@rollup/pluginutils@5.4.0': dependencies: '@types/estree': 1.0.9 @@ -8123,6 +8522,37 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 + '@solidjs/signals@2.0.0-rc.1': {} + + '@solidjs/testing-library@1.0.0-beta.2(@solidjs/web@2.0.0-rc.1(solid-js@2.0.0-rc.1))(solid-js@2.0.0-rc.1)': + dependencies: + '@solidjs/web': 2.0.0-rc.1(solid-js@2.0.0-rc.1) + '@testing-library/dom': 10.4.1 + solid-js: 2.0.0-rc.1 + + '@solidjs/vite-plugin@3.0.0-next.28(@solidjs/web@2.0.0-rc.1(solid-js@2.0.0-rc.1))(@testing-library/jest-dom@6.9.1)(solid-js@2.0.0-rc.1)(vite@8.1.4(@types/node@22.19.21)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@babel/core': 7.29.7 + '@dom-expressions/compiler': 0.50.0-next.40 + '@solidjs/web': 2.0.0-rc.1(solid-js@2.0.0-rc.1) + '@types/babel__core': 7.20.5 + babel-preset-solid: 2.0.0-rc.0(@babel/core@7.29.7)(solid-js@2.0.0-rc.1) + merge-anything: 5.1.7 + solid-js: 2.0.0-rc.1 + vite: 8.1.4(@types/node@22.19.21)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0) + vitefu: 1.1.3(vite@8.1.4(@types/node@22.19.21)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0)) + optionalDependencies: + '@testing-library/jest-dom': 6.9.1 + transitivePeerDependencies: + - supports-color + + '@solidjs/web@2.0.0-rc.1(solid-js@2.0.0-rc.1)': + dependencies: + seroval: 1.5.6 + seroval-plugins: 1.5.6(seroval@1.5.6) + solid-js: 2.0.0-rc.1 + '@standard-schema/spec@1.1.0': {} '@storybook/builder-vite@10.5.0(esbuild@0.28.1)(storybook@10.5.0(@types/react@19.2.17)(prettier@2.8.8)(react@19.2.7))(vite@8.1.4(@types/node@22.19.21)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0))': @@ -8136,6 +8566,17 @@ snapshots: - rollup - webpack + '@storybook/builder-vite@10.5.0(esbuild@0.28.1)(storybook@10.5.2(@types/react@19.2.17)(prettier@2.8.8)(react@19.2.7))(vite@8.1.4(@types/node@22.19.21)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0))': + dependencies: + '@storybook/csf-plugin': 10.5.0(esbuild@0.28.1)(storybook@10.5.2(@types/react@19.2.17)(prettier@2.8.8)(react@19.2.7))(vite@8.1.4(@types/node@22.19.21)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0)) + storybook: 10.5.2(@types/react@19.2.17)(prettier@2.8.8)(react@19.2.7) + ts-dedent: 2.3.0 + vite: 8.1.4(@types/node@22.19.21)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0) + transitivePeerDependencies: + - esbuild + - rollup + - webpack + '@storybook/csf-plugin@10.5.0(esbuild@0.28.1)(storybook@10.5.0(@types/react@19.2.17)(prettier@2.8.8)(react@19.2.7))(vite@8.1.4(@types/node@22.19.21)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0))': dependencies: storybook: 10.5.0(@types/react@19.2.17)(prettier@2.8.8)(react@19.2.7) @@ -8144,6 +8585,14 @@ snapshots: esbuild: 0.28.1 vite: 8.1.4(@types/node@22.19.21)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0) + '@storybook/csf-plugin@10.5.0(esbuild@0.28.1)(storybook@10.5.2(@types/react@19.2.17)(prettier@2.8.8)(react@19.2.7))(vite@8.1.4(@types/node@22.19.21)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0))': + dependencies: + storybook: 10.5.2(@types/react@19.2.17)(prettier@2.8.8)(react@19.2.7) + unplugin: 2.3.11 + optionalDependencies: + esbuild: 0.28.1 + vite: 8.1.4(@types/node@22.19.21)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0) + '@storybook/global@5.0.0': {} '@storybook/icons@2.1.0(react@19.2.3)': @@ -8567,6 +9016,18 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + '@volar/language-core@2.4.28': + dependencies: + '@volar/source-map': 2.4.28 + + '@volar/source-map@2.4.28': {} + + '@volar/typescript@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + path-browserify: 1.0.1 + vscode-uri: 3.1.0 + '@webcontainer/env@1.1.1': {} '@xmldom/xmldom@0.8.13': {} @@ -8890,6 +9351,20 @@ snapshots: babel-plugin-jest-hoist: 29.6.3 babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7) + babel-preset-solid@2.0.0-rc.0(@babel/core@7.29.7)(solid-js@2.0.0-rc.1): + dependencies: + '@babel/core': 7.29.7 + '@dom-expressions/babel-plugin-jsx': 0.50.0-next.42(@babel/core@7.29.7) + optionalDependencies: + solid-js: 2.0.0-rc.1 + + babel-preset-solid@2.0.0-rc.1(@babel/core@7.29.7)(solid-js@2.0.0-rc.1): + dependencies: + '@babel/core': 7.29.7 + '@dom-expressions/babel-plugin-jsx': 0.50.0-next.43(@babel/core@7.29.7) + optionalDependencies: + solid-js: 2.0.0-rc.1 + balanced-match@1.0.2: {} balanced-match@4.0.4: {} @@ -9820,6 +10295,8 @@ snapshots: dependencies: whatwg-encoding: 3.1.1 + html-entities@2.3.3: {} + html-escaper@2.0.2: {} http-errors@2.0.1: @@ -9951,6 +10428,8 @@ snapshots: dependencies: better-path-resolve: 1.0.0 + is-what@4.1.16: {} + is-windows@1.0.2: {} is-wsl@2.2.0: @@ -10642,6 +11121,10 @@ snapshots: dependencies: map-or-similar: 1.5.0 + merge-anything@5.1.7: + dependencies: + is-what: 4.1.16 + merge-options@3.0.4: dependencies: is-plain-obj: 2.1.0 @@ -11133,6 +11616,8 @@ snapshots: parseurl@1.3.3: {} + path-browserify@1.0.1: {} + path-exists@4.0.0: {} path-is-absolute@1.0.1: {} @@ -11646,6 +12131,12 @@ snapshots: serialize-error@2.1.0: {} + seroval-plugins@1.5.6(seroval@1.5.6): + dependencies: + seroval: 1.5.6 + + seroval@1.5.6: {} + serve-static@1.16.3: dependencies: encodeurl: 2.0.0 @@ -11701,6 +12192,13 @@ snapshots: smol-toml@1.6.1: {} + solid-js@2.0.0-rc.1: + dependencies: + '@solidjs/signals': 2.0.0-rc.1 + csstype: 3.2.3 + seroval: 1.5.6 + seroval-plugins: 1.5.6(seroval@1.5.6) + source-map-js@1.2.1: {} source-map-support@0.5.13: @@ -11761,6 +12259,25 @@ snapshots: std-env@4.1.0: {} + storybook-solidjs-vite@10.6.0(@solidjs/web@2.0.0-rc.1(solid-js@2.0.0-rc.1))(esbuild@0.28.1)(solid-js@2.0.0-rc.1)(storybook@10.5.2(@types/react@19.2.17)(prettier@2.8.8)(react@19.2.7))(typescript@6.0.3)(vite-plugin-solid@3.0.0-next.27(@solidjs/web@2.0.0-rc.1(solid-js@2.0.0-rc.1))(@testing-library/jest-dom@6.9.1)(solid-js@2.0.0-rc.1)(vite@8.1.4(@types/node@22.19.21)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0)))(vite@8.1.4(@types/node@22.19.21)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0)): + dependencies: + '@storybook/builder-vite': 10.5.0(esbuild@0.28.1)(storybook@10.5.2(@types/react@19.2.17)(prettier@2.8.8)(react@19.2.7))(vite@8.1.4(@types/node@22.19.21)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0)) + '@storybook/global': 5.0.0 + '@volar/language-core': 2.4.28 + '@volar/typescript': 2.4.28 + semver: 7.8.5 + solid-js: 2.0.0-rc.1 + storybook: 10.5.2(@types/react@19.2.17)(prettier@2.8.8)(react@19.2.7) + vite: 8.1.4(@types/node@22.19.21)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0) + vite-plugin-solid: 3.0.0-next.27(@solidjs/web@2.0.0-rc.1(solid-js@2.0.0-rc.1))(@testing-library/jest-dom@6.9.1)(solid-js@2.0.0-rc.1)(vite@8.1.4(@types/node@22.19.21)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0)) + optionalDependencies: + '@solidjs/web': 2.0.0-rc.1(solid-js@2.0.0-rc.1) + typescript: 6.0.3 + transitivePeerDependencies: + - esbuild + - rollup + - webpack + storybook@10.5.0(@types/react@19.2.17)(prettier@2.8.8)(react@19.2.7): dependencies: '@storybook/global': 5.0.0 @@ -11815,6 +12332,33 @@ snapshots: - react - utf-8-validate + storybook@10.5.2(@types/react@19.2.17)(prettier@2.8.8)(react@19.2.7): + dependencies: + '@storybook/global': 5.0.0 + '@storybook/icons': 2.1.0(react@19.2.7) + '@testing-library/dom': 10.4.1 + '@testing-library/jest-dom': 6.9.1 + '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) + '@vitest/expect': 3.2.4 + '@vitest/spy': 3.2.4 + '@webcontainer/env': 1.1.1 + esbuild: 0.28.1 + jsonc-parser: 3.3.1 + open: 10.2.0 + oxc-parser: 0.127.0 + oxc-resolver: 11.20.0 + recast: 0.23.12 + semver: 7.8.5 + use-sync-external-store: 1.6.0(react@19.2.7) + ws: 8.21.0 + optionalDependencies: + '@types/react': 19.2.17 + prettier: 2.8.8 + transitivePeerDependencies: + - bufferutil + - react + - utf-8-validate + stream-buffers@2.2.0: {} string-argv@0.3.2: {} @@ -12109,10 +12653,22 @@ snapshots: optionalDependencies: typescript: 6.0.3 + validate-html-nesting@1.2.4: {} + validate-npm-package-name@5.0.1: {} vary@1.1.2: {} + vite-plugin-solid@3.0.0-next.27(@solidjs/web@2.0.0-rc.1(solid-js@2.0.0-rc.1))(@testing-library/jest-dom@6.9.1)(solid-js@2.0.0-rc.1)(vite@8.1.4(@types/node@22.19.21)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0)): + dependencies: + '@solidjs/vite-plugin': 3.0.0-next.28(@solidjs/web@2.0.0-rc.1(solid-js@2.0.0-rc.1))(@testing-library/jest-dom@6.9.1)(solid-js@2.0.0-rc.1)(vite@8.1.4(@types/node@22.19.21)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0)) + transitivePeerDependencies: + - '@solidjs/web' + - '@testing-library/jest-dom' + - solid-js + - supports-color + - vite + vite@8.1.4(@types/node@22.19.21)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 @@ -12128,6 +12684,10 @@ snapshots: terser: 5.49.0 yaml: 2.9.0 + vitefu@1.1.3(vite@8.1.4(@types/node@22.19.21)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0)): + optionalDependencies: + vite: 8.1.4(@types/node@22.19.21)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0) + vitest@4.1.9(@types/node@22.19.21)(jsdom@26.1.0)(vite@8.1.4(@types/node@22.19.21)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.9 @@ -12158,6 +12718,8 @@ snapshots: vlq@1.0.1: {} + vscode-uri@3.1.0: {} + w3c-xmlserializer@4.0.0: dependencies: xml-name-validator: 4.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 362af61..56c79a4 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,10 +3,17 @@ packages: allowBuilds: esbuild: true minimumReleaseAgeExclude: - - '@dunky.dev/native-state-machine@0.3.2' - - '@dunky.dev/react-state-machine@0.3.2' - - '@dunky.dev/state-machine-bindings@0.3.2' - - '@dunky.dev/state-machine-utils@0.3.2' - - '@dunky.dev/state-machine@0.3.2' + - '@dunky.dev/native-state-machine@0.4.0' + - '@dunky.dev/react-state-machine@0.3.4' + - '@dunky.dev/solid-state-machine@0.3.0' + - '@dunky.dev/state-machine-bindings@0.4.1' + - '@dunky.dev/state-machine-dom@0.1.0' + - '@dunky.dev/state-machine-utils@0.4.0' + - '@dunky.dev/state-machine@0.3.3' + - '@dom-expressions/babel-plugin-jsx@0.50.0-next.43' + - '@solidjs/signals@2.0.0-rc.1' + - '@solidjs/web@2.0.0-rc.1' + - babel-preset-solid@2.0.0-rc.1 + - solid-js@2.0.0-rc.1 publicHoistPattern: - '*storybook*' diff --git a/scripts/templates/README.md b/scripts/templates/README.md index 9eb7319..631722a 100644 --- a/scripts/templates/README.md +++ b/scripts/templates/README.md @@ -23,6 +23,14 @@ substituted from the kebab-case name argument: Only `__name__` appears in file and directory names (e.g. `src/create-__name__.ts`). +## What gets stamped + +One package per layer the primitive needs: `core/__name__` (the behavior), +`dom/components/__name__` (the DOM-specific, framework-free half every DOM +host shares), and one binding per substrate. A primitive with no DOM host can +delete the `dom` package; a primitive whose DOM work is genuinely per-host +still keeps it, since that is where the next substrate looks first. + ## Adding a substrate A substrate is a folder under `scripts/templates/packages/`, mirroring diff --git a/scripts/templates/packages/dom/components/__name__/SPEC.md b/scripts/templates/packages/dom/components/__name__/SPEC.md new file mode 100644 index 0000000..5afc187 --- /dev/null +++ b/scripts/templates/packages/dom/components/__name__/SPEC.md @@ -0,0 +1,48 @@ +# SPEC / DOM / __Name__ + +## Overview + +The DOM half of the __name__, shared by every DOM substrate. Behavior is +[`@dunky.dev/__name__`](../../../core/__name__/SPEC.md)'s; this package owns the +part of the wiring that is specific to the document but not to any framework — +document listeners, ordered focus or stack sequences, and the predicates a part +consults before forwarding an event. + +It sits between the DOM utils and the substrate bindings: + +``` + @dunky.dev/__name__ core behavior (no DOM) + | + v + @dunky.dev/dom-__name__ this package -- DOM, no framework + | ^ + | +----------- the @dunky.dev/dom-* utils + v + @dunky.dev/-__name__ +``` + +A `dom/utils/*` package is primitive-agnostic and imports nothing from the +repo. A `dom/components/*` package is the opposite: it is about exactly one +primitive, so it may import that primitive's core package and any DOM util. +What it must not do is import a framework, or another primitive. + +TODO(spec): describe what this package owns for the __name__ — one section per +concern, each stating the sequence and why its order is load-bearing. + +## API + +| Export | Description | +| --------------------- | ------------------------------------------------------------------- | +| `dom__Name__Effects` | Core effects + the document-level ones, as effect tuples. | + +## Constraints + +- No framework import, ever — that is the whole point of the layer. +- No decisions of its own. Anything a substrate could answer differently + belongs in the core machine; what lives here is only the DOM realization of + a decision already made. +- Every entry point returns its own disposer, and the disposer undoes exactly + what the call did — substrate lifecycles differ, so nothing may rely on a + particular teardown order between calls. +- Reads that must stay live are taken as the machine or as accessors, never + snapshotted at call time. diff --git a/scripts/templates/packages/dom/components/__name__/package.json b/scripts/templates/packages/dom/components/__name__/package.json new file mode 100644 index 0000000..70ea227 --- /dev/null +++ b/scripts/templates/packages/dom/components/__name__/package.json @@ -0,0 +1,44 @@ +{ + "name": "@dunky.dev/dom-__name__", + "version": "0.0.0", + "description": "Framework-free DOM behavior for @dunky.dev/__name__, shared by every DOM substrate.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/dunky-dev/ui.git", + "directory": "packages/dom/components/__name__" + }, + "files": [ + "dist", + "src", + "SPEC.md" + ], + "type": "module", + "sideEffects": false, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "publishConfig": { + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "access": "public" + }, + "scripts": { + "build": "tsdown" + }, + "dependencies": { + "@dunky.dev/__name__": "workspace:*" + }, + "devDependencies": { + "@dunky.dev/state-machine": "^0.3.3" + } +} diff --git a/scripts/templates/packages/dom/components/__name__/src/effects.ts b/scripts/templates/packages/dom/components/__name__/src/effects.ts new file mode 100644 index 0000000..86b8383 --- /dev/null +++ b/scripts/templates/packages/dom/components/__name__/src/effects.ts @@ -0,0 +1,33 @@ +import type { __Name__Machine, __Name__Options } from '@dunky.dev/__name__' + +// An effect as plain data: a setup/teardown function plus the prop names that +// re-run it. Structurally mirrors every adapter's ComponentEffect tuple, so a +// substrate's useMachine takes the list as-is and drives it with its own +// lifecycle. Once the core package grows its own effects.ts, import +// `__Name__Effect` from there instead of redeclaring it, and spread the core's +// list into the export below. +type __Name__Effect = [ + effect: (machine: __Name__Machine, props: __Name__Options) => (() => void) | void, + deps: (keyof __Name__Options)[], +] + +// Document-level work every DOM host owns, written once. A listener bound to +// `document` or `window` — or anything reading the DOM outside a part's own +// element — belongs here rather than in a substrate: React, Solid, and Vue +// differ in how they schedule the effect, not in what it does. +// +// See @dunky.dev/dom-dialog for a worked example (the Escape listener, the +// open/exit sequences, the outside-press gating). +const trackDocument: __Name__Effect = [ + machine => { + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key !== 'Escape' || machine.context.disabled) return + machine.send({ type: 'SET_DISABLED', disabled: true }) + } + document.addEventListener('keydown', onKeyDown, true) + return () => document.removeEventListener('keydown', onKeyDown, true) + }, + [], +] + +export const dom__Name__Effects: __Name__Effect[] = [trackDocument] diff --git a/scripts/templates/packages/dom/components/__name__/src/index.ts b/scripts/templates/packages/dom/components/__name__/src/index.ts new file mode 100644 index 0000000..63aef38 --- /dev/null +++ b/scripts/templates/packages/dom/components/__name__/src/index.ts @@ -0,0 +1 @@ +export { dom__Name__Effects } from './effects' diff --git a/scripts/templates/packages/dom/components/__name__/tests/__name__.test.ts b/scripts/templates/packages/dom/components/__name__/tests/__name__.test.ts new file mode 100644 index 0000000..55ec5fc --- /dev/null +++ b/scripts/templates/packages/dom/components/__name__/tests/__name__.test.ts @@ -0,0 +1,51 @@ +// @vitest-environment jsdom +// The DOM half of the __name__, driven directly — no substrate, no framework. +import { afterEach, describe, expect, it, vi } from 'vitest' +import { machine, type Machine } from '@dunky.dev/state-machine' +import { __camelName__Machine } from '@dunky.dev/__name__' +import type { + __Name__Context, + __Name__MachineEvent, + __Name__Options, + __Name__StateName, +} from '@dunky.dev/__name__' +import { dom__Name__Effects } from '@dunky.dev/dom-__name__' + +type __Name__Service = Machine<__Name__StateName, __Name__Context, __Name__MachineEvent> + +const build = (options: __Name__Options = {}): __Name__Service => { + const service = machine(__camelName__Machine(options)) + service.start() + return service +} + +// Effects are plain tuples, so a test drives one directly rather than through +// a host lifecycle. Index by position; the disposer is what the substrate's +// cleanup would call. +const arm = (index: number, service: __Name__Service, props: __Name__Options = {}): (() => void) => { + const [effect] = dom__Name__Effects[index] as (typeof dom__Name__Effects)[number] + return effect(service, props) ?? ((): void => {}) +} + +afterEach(() => { + document.body.innerHTML = '' + vi.restoreAllMocks() +}) + +describe('dom__Name__Effects', () => { + it('reacts to the document while armed', () => { + const service = build() + arm(0, service) + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + expect(service.context.disabled).toBe(true) + }) + + it('detaches its listener on dispose', () => { + const service = build() + arm(0, service)() + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + expect(service.context.disabled).toBe(false) + }) +}) diff --git a/scripts/templates/packages/react/__name__/src/effects.ts b/scripts/templates/packages/react/__name__/src/effects.ts index bc6816c..2f1ec9e 100644 --- a/scripts/templates/packages/react/__name__/src/effects.ts +++ b/scripts/templates/packages/react/__name__/src/effects.ts @@ -7,7 +7,8 @@ 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 -// platform APIs also belong here (see the dialog for an example). +// 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 diff --git a/scripts/templates/packages/solid/__name__/README.md b/scripts/templates/packages/solid/__name__/README.md new file mode 100644 index 0000000..18a04ff --- /dev/null +++ b/scripts/templates/packages/solid/__name__/README.md @@ -0,0 +1,29 @@ +# @dunky.dev/solid-__name__ + +Solid 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). +Solid-specific surface: [SPEC.md](./SPEC.md). + +## Install + +```sh +npm install @dunky.dev/solid-__name__ +``` + +## Usage + +```tsx +import { __Name__ } from '@dunky.dev/solid-__name__' + +function Example() { + return ( + <__Name__ disable={() => {}}> + <__Name__.Root>go + + ) +} +``` diff --git a/scripts/templates/packages/solid/__name__/SPEC.md b/scripts/templates/packages/solid/__name__/SPEC.md new file mode 100644 index 0000000..a9cbf96 --- /dev/null +++ b/scripts/templates/packages/solid/__name__/SPEC.md @@ -0,0 +1,32 @@ +# SPEC / Solid / __Name__ + +The Solid 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/solid-__name__ +``` + +## Usage + + +```tsx +import { __Name__ } from "@dunky.dev/solid-__name__"; + +<__Name__ /> +``` + + +## API + + +| Prop | Type | Default | Description | +| --- | --- | --- | --- | +| `prop` | `number` | `1337` | Magical number. | +| `...` | `...` | `...` | ... | diff --git a/scripts/templates/packages/solid/__name__/package.json b/scripts/templates/packages/solid/__name__/package.json new file mode 100644 index 0000000..087070c --- /dev/null +++ b/scripts/templates/packages/solid/__name__/package.json @@ -0,0 +1,55 @@ +{ + "name": "@dunky.dev/solid-__name__", + "version": "0.0.0", + "description": "Solid binding for @dunky.dev/__name__.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/dunky-dev/ui.git", + "directory": "packages/solid/__name__" + }, + "files": [ + "dist", + "src", + "SPEC.md" + ], + "type": "module", + "sideEffects": false, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "publishConfig": { + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "access": "public" + }, + "scripts": { + "build": "tsdown" + }, + "dependencies": { + "@dunky.dev/__name__": "workspace:*", + "@dunky.dev/solid-state-machine": "^0.3.0" + }, + "devDependencies": { + "@babel/core": "^7.28.4", + "@babel/preset-typescript": "^7.27.1", + "@rollup/plugin-babel": "^6.0.4", + "@solidjs/testing-library": "^1.0.0-beta.2", + "@solidjs/web": "^2.0.0-rc.1", + "babel-preset-solid": "^2.0.0-rc.1", + "solid-js": "^2.0.0-rc.1" + }, + "peerDependencies": { + "@solidjs/web": "^2.0.0-rc.1", + "solid-js": "^2.0.0-rc.1" + } +} diff --git a/scripts/templates/packages/solid/__name__/src/__name__.tsx b/scripts/templates/packages/solid/__name__/src/__name__.tsx new file mode 100644 index 0000000..5f0580a --- /dev/null +++ b/scripts/templates/packages/solid/__name__/src/__name__.tsx @@ -0,0 +1,53 @@ +import { omit, type Component, type JSX } from 'solid-js' +import type { ComponentProps } from '@solidjs/web' +import type { __Name__Options } from '@dunky.dev/__name__' + +import { mergeProps, normalize } from '@dunky.dev/solid-state-machine' +import { __Name__Context, use__Name__Context } from './context' +import { use__Name__ } from './use-__name__' + +// Bindings merge inside the JSX spread so they stay reactive. `children` must +// never ride that spread: a re-evaluated spread re-creates the children. +// Every part omits it and renders `{props.children}` explicitly. + +// ============================================================================= +// <__Name__> — root, owns the machine and renders no DOM +// ============================================================================= + +export interface __Name__Props extends __Name__Options { + children?: JSX.Element +} + +export const __Name__: Component<__Name__Props> & Parts = props => { + const options = omit(props, 'children') + const value = use__Name__(options) + return <__Name__Context value={value}>{props.children} +} + +// ============================================================================= +// <__Name__.Root> — placeholder part: wires the root bindings onto an element. +// TODO(spec): replace with one part per piece of the anatomy in SPEC.md. +// ============================================================================= + +export interface __Name__RootProps extends ComponentProps<'button'> {} + +export const Root: Component<__Name__RootProps> = props => { + const { api } = use__Name__Context() + const rest = omit(props, 'children') + return ( + + ) +} + +// Parts +// ----------------------------------------------------------------------------- + +export interface Parts { + Root: typeof Root +} + +__Name__.Root = Root diff --git a/scripts/templates/packages/solid/__name__/src/context.ts b/scripts/templates/packages/solid/__name__/src/context.ts new file mode 100644 index 0000000..667e806 --- /dev/null +++ b/scripts/templates/packages/solid/__name__/src/context.ts @@ -0,0 +1,22 @@ +import { createContext, useContext, type Context } from 'solid-js' +import type { __Name__Api, __Name__Machine } from '@dunky.dev/__name__' + +export interface __Name__ContextValue { + // Fine-grained store proxy: reading a field subscribes to exactly that leaf. + api: __Name__Api + machine: __Name__Machine +} + +// A `null` default: a default-less context throws on any un-provided read; +// the wrapper restores the loud error for parts. +export const __Name__Context: Context<__Name__ContextValue | null> = createContext< + __Name__ContextValue | null +>(null) + +export const use__Name__Context = (): __Name__ContextValue => { + const context = useContext(__Name__Context) + if (context === null) { + throw new Error('__Name__ parts must be rendered within a <__Name__> root') + } + return context +} diff --git a/scripts/templates/packages/solid/__name__/src/effects.ts b/scripts/templates/packages/solid/__name__/src/effects.ts new file mode 100644 index 0000000..f01f770 --- /dev/null +++ b/scripts/templates/packages/solid/__name__/src/effects.ts @@ -0,0 +1,22 @@ +import type { ComponentEffect } from '@dunky.dev/solid-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 createEffect 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/solid/__name__/src/index.ts b/scripts/templates/packages/solid/__name__/src/index.ts new file mode 100644 index 0000000..8488220 --- /dev/null +++ b/scripts/templates/packages/solid/__name__/src/index.ts @@ -0,0 +1,2 @@ +export { __Name__, type __Name__Props, type __Name__RootProps } from './__name__' +export type { __Name__Callbacks, __Name__Options } from '@dunky.dev/__name__' diff --git a/scripts/templates/packages/solid/__name__/src/use-__name__.ts b/scripts/templates/packages/solid/__name__/src/use-__name__.ts new file mode 100644 index 0000000..3e1e4b9 --- /dev/null +++ b/scripts/templates/packages/solid/__name__/src/use-__name__.ts @@ -0,0 +1,14 @@ +import { useMachine } from '@dunky.dev/solid-state-machine' +import { __camelName__Machine, __camelName__Connect } from '@dunky.dev/__name__' +import type { __Name__Options } from '@dunky.dev/__name__' + +import type { __Name__ContextValue } from './context' +import { __camelName__Effects } from './effects' + +/** + * Owns one __name__ machine for the <__Name__> root: created once, options + * stay fresh through the reactive props proxy, api is a fine-grained store. + */ +export function use__Name__(options: __Name__Options): __Name__ContextValue { + return useMachine(__camelName__Machine, __camelName__Connect, __camelName__Effects, options) +} diff --git a/scripts/templates/packages/solid/__name__/stories/__name__.stories.tsx b/scripts/templates/packages/solid/__name__/stories/__name__.stories.tsx new file mode 100644 index 0000000..b2443d4 --- /dev/null +++ b/scripts/templates/packages/solid/__name__/stories/__name__.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from 'storybook-solidjs-vite' +import { __Name__ } from '@dunky.dev/solid-__name__' + +const meta: Meta = { + title: 'Primitives/__Name__', + component: __Name__, +} + +export default meta +type StoryType = StoryObj + +// The primitive ships headless — the story is the consumer, so it brings the +// styles. `data-state` on every part is the real styling hook. +export const standard: StoryType = { + render: () => ( + <__Name__ disable={() => console.log('disabled')}> + <__Name__.Root>go + + ), +} diff --git a/scripts/templates/packages/solid/__name__/tests/__name__.test.tsx b/scripts/templates/packages/solid/__name__/tests/__name__.test.tsx new file mode 100644 index 0000000..8ca4ce0 --- /dev/null +++ b/scripts/templates/packages/solid/__name__/tests/__name__.test.tsx @@ -0,0 +1,43 @@ +// @vitest-environment jsdom +// The Solid edge of the __name__ — behavior only; the machine's own contract +// is covered in @dunky.dev/__name__'s tests. +import { createSignal, flush } from 'solid-js' +import { cleanup, render, screen } from '@solidjs/testing-library' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { __Name__, type __Name__Props } from '@dunky.dev/solid-__name__' + +const Default__Name__ = (props: __Name__Props) => ( + <__Name__ {...props}> + <__Name__.Root>go + +) + +// Auto-cleanup needs vitest globals; this repo runs with globals: false. +afterEach(cleanup) + +describe('__Name__', () => { + it('disables on press', () => { + const disable = vi.fn() + render(() => ) + screen.getByRole('button').click() + expect(disable).toHaveBeenCalledTimes(1) + }) + + it('fires disable when the controlled disabled prop turns on', () => { + const disable = vi.fn() + const [disabled, setDisabled] = createSignal(false) + render(() => ) + expect(disable).not.toHaveBeenCalled() + + setDisabled(true) + flush() // Solid 2.0 defers prop propagation to the microtask queue + expect(disable).toHaveBeenCalledTimes(1) + }) + + it('translates the core bindings onto the element', () => { + render(() => ) + const root = screen.getByRole('button') + expect(root.getAttribute('data-state')).toBe('idle') + expect(root.getAttribute('aria-disabled')).toBe('true') + }) +}) diff --git a/scripts/templates/packages/solid/__name__/tsdown.config.ts b/scripts/templates/packages/solid/__name__/tsdown.config.ts new file mode 100644 index 0000000..ff9c219 --- /dev/null +++ b/scripts/templates/packages/solid/__name__/tsdown.config.ts @@ -0,0 +1,19 @@ +import { babel } from '@rollup/plugin-babel' +import { defineConfig } from 'tsdown' + +// Solid JSX needs Solid's own compiler (babel-preset-solid) — rolldown/oxc +// only know React-shaped JSX. Presets apply last-to-first: TypeScript strips +// types keeping the JSX, then the Solid preset compiles it. Everything else +// inherits the root config. +export default defineConfig({ + plugins: [ + babel({ + babelHelpers: 'bundled', + extensions: ['.tsx'], + presets: [ + ['babel-preset-solid'], + ['@babel/preset-typescript', { isTSX: true, allExtensions: true }], + ], + }), + ], +}) diff --git a/tsconfig.json b/tsconfig.json index 9f55675..6e16afe 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,23 +19,30 @@ "@dunky.dev/dialog": ["./packages/core/dialog/src"], "@dunky.dev/native-dialog": ["./packages/native/dialog/src"], "@dunky.dev/react-dialog": ["./packages/react/dialog/src"], + "@dunky.dev/solid-dialog": ["./packages/solid/dialog/src"], + "@dunky.dev/dom-dialog": ["./packages/dom/components/dialog/src"], "@dunky.dev/dom-overlay": ["./packages/dom/utils/overlay/src"], "@dunky.dev/dom-focus-trap": ["./packages/dom/utils/focus-trap/src"], "@dunky.dev/dom-navigation": ["./packages/dom/utils/navigation/src"], "@dunky.dev/dom-scroll-lock": ["./packages/dom/utils/scroll-lock/src"], "@dunky.dev/react-use-focus-trap": ["./packages/react/hooks/use-focus-trap/src"], - "@dunky.dev/react-use-scroll-lock": ["./packages/react/hooks/use-scroll-lock/src"] + "@dunky.dev/react-use-scroll-lock": ["./packages/react/hooks/use-scroll-lock/src"], + "@dunky.dev/solid-use-focus-trap": ["./packages/solid/hooks/use-focus-trap/src"], + "@dunky.dev/solid-use-scroll-lock": ["./packages/solid/hooks/use-scroll-lock/src"] }, "types": ["@types/node", "vitest/globals"] }, "include": ["./*.ts", "./packages"], // The native Expo shell (entry + .rnstorybook) imports the generated, // gitignored storybook.requires.ts — Metro compiles that glue, tsc would - // only ever see the missing module in CI. + // only ever see the missing module in CI. packages/solid needs Solid's JSX + // namespace (`jsx: preserve` + solid-js import source), so it typechecks as + // its own project — packages/solid/tsconfig.json, run by `pnpm typecheck`. "exclude": [ "**/node_modules", "**/dist", "packages/native/index.ts", - "packages/native/.rnstorybook" + "packages/native/.rnstorybook", + "packages/solid" ] } diff --git a/tsdown.config.ts b/tsdown.config.ts index b4c0c31..c96c0c5 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -13,6 +13,7 @@ export default defineConfig({ 'packages/core/dialog', 'packages/core/utils/controllable', 'packages/core/utils/overlay', + 'packages/dom/components/dialog', 'packages/dom/utils/focus-trap', 'packages/dom/utils/overlay', 'packages/dom/utils/navigation', @@ -21,6 +22,9 @@ export default defineConfig({ 'packages/react/dialog', 'packages/react/hooks/use-focus-trap', 'packages/react/hooks/use-scroll-lock', + 'packages/solid/dialog', + 'packages/solid/hooks/use-focus-trap', + 'packages/solid/hooks/use-scroll-lock', ], entry: ['src/index.ts'], format: ['esm'], diff --git a/vitest.config.ts b/vitest.config.ts index 4905321..9f786a4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,21 +1,31 @@ import { defineConfig } from 'vitest/config' +// Two projects: the Solid tests need vite-plugin-solid's JSX transform, which +// must not rewrite the React `.tsx` tests. The solid project lives with its +// substrate (packages/solid/vitest.config.ts). export default defineConfig({ test: { - globals: false, - environment: 'node', - // scripts/templates holds __name__-tokenized scaffolding stubs — real files, - // but not runnable tests (their imports resolve only once scaffolded). - // .worktrees and .claude/worktrees hold local worktree checkouts; lint - // ignores them, vitest must too. packages/native runs on jest-expo (real - // react-native), not vitest — see packages/native/jest.config.cjs. - exclude: [ - '**/node_modules/**', - '**/dist/**', - 'scripts/templates/**', - 'packages/native/**', - '**/.worktrees/**', - '**/.claude/**', + projects: [ + { + test: { + name: 'default', + globals: false, + environment: 'node', + // scripts/templates holds __name__-tokenized stubs (not runnable), + // .worktrees/.claude hold local checkouts, packages/native runs on + // jest-expo — see packages/native/jest.config.cjs. + exclude: [ + '**/node_modules/**', + '**/dist/**', + 'scripts/templates/**', + 'packages/native/**', + 'packages/solid/**', + '**/.worktrees/**', + '**/.claude/**', + ], + }, + }, + './packages/solid/vitest.config.ts', ], }, })