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/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/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.
+ Delete
+ 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 ``. |
+
+### `Dialog.Portal`
+
+Teleports the layers out of the tree while open; unmounts them while closed.
+
+| Prop | Type | Default | Description |
+| ----------- | --------------------- | --------------- | --------------------------- |
+| `container` | `HTMLElement \| null` | `document.body` | The element to portal into. |
+| `children` | `JSX.Element` | — | The layers to teleport. |
+
+### `Dialog.Backdrop`
+
+The layer behind the dialog window; renders nothing when `modal={false}`.
+
+| Prop | Type | Default | Description |
+| ---------- | ----------------------- | ------- | ---------------------------------- |
+| `...props` | `ComponentProps<'div'>` | — | Forwarded to the rendered ``. |
+
+### `Dialog.Viewport`
+
+The positioning + scroll layer around the dialog window.
+
+| Prop | Type | Default | Description |
+| ---------- | ----------------------- | ------- | ---------------------------------- |
+| `...props` | `ComponentProps<'div'>` | — | Forwarded to the rendered `
`. |
+
+### `Dialog.Content`
+
+The dialog window; renders a `
` with the `dialog` role.
+
+| Prop | Type | Default | Description |
+| -------------- | --------------------------------------------------------- | ----------------- | ------------------------------------------------------------------- |
+| `initialFocus` | `HTMLElement \| (() => HTMLElement \| null \| undefined)` | the dialog window | The element to focus when the dialog opens — resolved at open time. |
+| `...props` | `ComponentProps<'div'>` | — | Forwarded to the rendered `
`. |
+
+### `Dialog.Title`
+
+Names the dialog (wires `aria-labelledby` on Content).
+
+| Prop | Type | Default | Description |
+| ---------- | ---------------------- | ------- | --------------------------------- |
+| `...props` | `ComponentProps<'h2'>` | — | Forwarded to the rendered `
`. |
+
+### `Dialog.Description`
+
+Describes the dialog (wires `aria-describedby` on Content).
+
+| Prop | Type | Default | Description |
+| ---------- | ----------------------- | ------- | ---------------------------------- |
+| `...props` | `ComponentProps<'div'>` | — | Forwarded to the rendered ``. |
+
+### `Dialog.Close`
+
+Dismisses the dialog from inside — the single dismissal affordance (the
+corner `×`), rendered once per dialog and kept the focus cycle's last stop per
+the core contract. Action buttons (Cancel/Confirm) are your own `
`s
+driving state, so they keep their natural Tab order.
+
+| Prop | Type | Default | Description |
+| ---------- | -------------------------- | --------- | -------------------------------------------------------------- |
+| `scope` | `'layer' \| 'stack'` | `'layer'` | Dismiss just its own dialog, or unwind the whole nested stack. |
+| `...props` | `ComponentProps<'button'>` | — | Forwarded to the rendered ``. |
diff --git a/packages/solid/dialog/package.json b/packages/solid/dialog/package.json
new file mode 100644
index 0000000..9302ad9
--- /dev/null
+++ b/packages/solid/dialog/package.json
@@ -0,0 +1,59 @@
+{
+ "name": "@dunky.dev/solid-dialog",
+ "version": "0.0.0",
+ "description": "Solid binding for @dunky.dev/dialog.",
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/dunky-dev/ui.git",
+ "directory": "packages/solid/dialog"
+ },
+ "files": [
+ "dist",
+ "src",
+ "SPEC.md"
+ ],
+ "type": "module",
+ "sideEffects": false,
+ "main": "./src/index.ts",
+ "types": "./src/index.ts",
+ "exports": {
+ ".": "./src/index.ts"
+ },
+ "publishConfig": {
+ "main": "./dist/index.js",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ }
+ },
+ "access": "public"
+ },
+ "scripts": {
+ "build": "tsdown"
+ },
+ "dependencies": {
+ "@dunky.dev/dialog": "workspace:*",
+ "@dunky.dev/dom-navigation": "workspace:*",
+ "@dunky.dev/dom-overlay": "workspace:*",
+ "@dunky.dev/solid-state-machine": "^0.4.0",
+ "@dunky.dev/solid-use-focus-trap": "workspace:*",
+ "@dunky.dev/solid-use-scroll-lock": "workspace:*"
+ },
+ "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/packages/solid/dialog/src/context.ts b/packages/solid/dialog/src/context.ts
new file mode 100644
index 0000000..2d1ebb5
--- /dev/null
+++ b/packages/solid/dialog/src/context.ts
@@ -0,0 +1,29 @@
+import { createContext, useContext, type Accessor, type Context } from 'solid-js'
+import type { DialogApi, DialogMachine } from '@dunky.dev/dialog'
+
+export interface DialogContextValue {
+ // Fine-grained store proxy: reading a field subscribes to exactly that leaf.
+ api: DialogApi
+ machine: DialogMachine
+ // Nesting level (1 = top-level); decides the topmost dialog of a stack.
+ depth: number
+ // The Portal's container (null = page body); an accessor so it stays live.
+ container: Accessor
+ // The rendered Backdrop, shared so Content's stack entry can except it from
+ // the containment. A plain box, not a signal: the layer walk reads it in the
+ // same settle that mounts the backdrop, before a signal write would commit.
+ backdropRef: { current: HTMLDivElement | null }
+}
+
+// A `null` default: a default-less context throws on the root's optional
+// parent lookup; the wrapper restores the loud error for parts.
+export const DialogContext: Context =
+ createContext(null)
+
+export const useDialogContext = (): DialogContextValue => {
+ const context = useContext(DialogContext)
+ if (context === null) {
+ throw new Error('Dialog parts must be rendered within a root')
+ }
+ return context
+}
diff --git a/packages/solid/dialog/src/dialog.tsx b/packages/solid/dialog/src/dialog.tsx
new file mode 100644
index 0000000..1d9fac5
--- /dev/null
+++ b/packages/solid/dialog/src/dialog.tsx
@@ -0,0 +1,380 @@
+import {
+ createEffect,
+ omit,
+ onSettled,
+ untrack,
+ useContext,
+ Show,
+ type Component,
+ type Ref,
+} from 'solid-js'
+import { isServer, Portal as HostPortal, type ComponentProps, type JSX } from '@solidjs/web'
+import { useFocusTrap } from '@dunky.dev/solid-use-focus-trap'
+import { useScrollLock } from '@dunky.dev/solid-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'
+import { mergeProps, normalize } from '@dunky.dev/solid-state-machine'
+import { DialogContext, useDialogContext } from './context'
+import { useDialog } from './use-dialog'
+
+// Bindings merge inside the JSX spread so they stay reactive. `children` must
+// never ride that spread: a re-evaluated spread re-creates the children, and a
+// child that writes to the machine on mount (Title) would loop forever. Every
+// part omits it and renders `{props.children}` explicitly.
+
+// A consumer ref that crossed a component boundary is a function or an array
+// of functions.
+function applyConsumerRef(ref: Ref | undefined, element: T): void {
+ if (typeof ref === 'function') (ref as (element: T) => void)(element)
+ else if (Array.isArray(ref)) for (const entry of ref) applyConsumerRef(entry as Ref, element)
+}
+
+// =============================================================================
+// — root, owns the machine and renders no DOM
+// =============================================================================
+
+export interface DialogProps extends DialogOptions {
+ children?: JSX.Element
+}
+
+export const Dialog: Component & Parts = props => {
+ const options = omit(props, 'children')
+ // Nesting derives from the parent dialog's context (null = top-level).
+ const depth = (useContext(DialogContext)?.depth ?? 0) + 1
+ const { api, machine } = useDialog(options)
+ const backdropRef: { current: HTMLDivElement | null } = { current: null }
+
+ // closeOnBack: while open, a guard entry in the session history turns the
+ // browser's Back into a dismissal. The decision (gate, veto, controlled)
+ // lives in the core's backNavigate; this only wires the web mechanics.
+ createEffect(
+ () => api.open,
+ open => {
+ if (!open || !machine.context.closeOnBack) return
+ return interceptBackNavigation(() => {
+ untrack(() => api.backNavigate())
+ return !machine.matches('open')
+ })
+ },
+ )
+
+ return (
+ null, backdropRef }}>
+ {props.children}
+
+ )
+}
+
+// =============================================================================
+// — toggles the dialog; focus returns here on close
+// =============================================================================
+
+export interface DialogTriggerProps extends ComponentProps<'button'> {}
+
+export const Trigger: Component = props => {
+ const { api } = useDialogContext()
+ const rest = omit(props, 'children')
+ return (
+ ({ type: 'button', ...rest }, normalize(api.parts.trigger))}
+ >
+ {props.children}
+
+ )
+}
+
+// =============================================================================
+// — 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,
+ // Only the topmost dialog of a stack answers an outside press.
+ onClick: (event: MouseEvent) => {
+ if (isTopmostLayer(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,
+ // 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)
+ },
+ }
+ }
+
+ 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`, and the stack, containment, and focus must
+ // release the moment the exit starts. One effect keeps the order right both
+ // ways: the stack joins before focus moves in; on close it releases the
+ // layers beneath before focus moves back out.
+ createEffect(
+ () => api.open,
+ open => {
+ const content = contentEl
+ if (!open || content === undefined) return
+
+ const previous = document.activeElement
+ const unregister = registerLayer({
+ id: machine.context.id,
+ depth,
+ element: content,
+ modal: machine.context.modal,
+ backdrop: () => backdropRef.current,
+ })
+
+ // preventScroll everywhere: moving focus must not scroll the locked
+ // surface, or open/close jumps the view.
+ const target =
+ untrack(() => resolveInitialFocus(props.initialFocus)) ?? getInitialFocus(content)
+ target.focus({ preventScroll: true })
+ // A target that can't take focus falls back to the panel.
+ if (document.activeElement !== target) content.focus({ preventScroll: true })
+
+ return () => {
+ unregister()
+ if (previous instanceof HTMLElement) previous.focus({ preventScroll: true })
+ }
+ },
+ )
+
+ // The exit window: mounted while not open only happens in `closing`. Hide
+ // the still-painting layer and report when its visual is done; the cleanup
+ // is the reopen interrupt (and final unmount) undoing both.
+ createEffect(
+ () => api.open,
+ open => {
+ const content = contentEl
+ if (open || content === undefined) return
+
+ const undoHide = untrack(() =>
+ hideExitingLayer(content, container() ?? document.body, backdropRef.current),
+ )
+ const cancelWatch = watchExitAnimation(content, () => machine.send({ type: 'exit.complete' }))
+ return () => {
+ cancelWatch()
+ undoHide()
+ }
+ },
+ )
+
+ // 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, {
+ // Only a modal dialog traps, and only while topmost.
+ enabled: () => machine.context.modal && isTopmostLayer(machine.context.id),
+ // Close is the cycle's last stop wherever it renders (core SPEC).
+ last: () => document.getElementById(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 (
+ ({ type: 'button', ...rest }, normalize(api.parts.close))}
+ >
+ {props.children}
+
+ )
+}
+
+// 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/effects.ts b/packages/solid/dialog/src/effects.ts
new file mode 100644
index 0000000..f7705e5
--- /dev/null
+++ b/packages/solid/dialog/src/effects.ts
@@ -0,0 +1,25 @@
+import type { ComponentEffect } from '@dunky.dev/solid-state-machine'
+import type { DialogMachine, DialogOptions } from '@dunky.dev/dialog'
+import { dialogEffects } from '@dunky.dev/dialog'
+import { isTopmostLayer } from '@dunky.dev/dom-overlay'
+
+type DialogEffect = ComponentEffect
+
+// Escape is a document-level concern — it must work wherever focus is.
+const trackEscape: DialogEffect = [
+ (machine, props) => {
+ const onKeyDown = (event: KeyboardEvent): void => {
+ if (event.key !== 'Escape' || !machine.matches('open')) return
+ // Only the topmost dialog answers Escape — one layer per press.
+ if (!isTopmostLayer(machine.context.id)) return
+ props.onEscapeKeyDown?.(event)
+ if (!event.defaultPrevented) machine.send({ type: 'escape' })
+ }
+ document.addEventListener('keydown', onKeyDown, true)
+ return () => document.removeEventListener('keydown', onKeyDown, true)
+ },
+ ['onEscapeKeyDown'],
+]
+
+// The core's substrate-free effects plus the document-level work of this host.
+export const solidDialogEffects: DialogEffect[] = [...dialogEffects, trackEscape]
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..79d7034
--- /dev/null
+++ b/packages/solid/dialog/src/use-dialog.ts
@@ -0,0 +1,18 @@
+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 { solidDialogEffects } from './effects'
+
+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, solidDialogEffects, 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.
+
+
+ (cancel = el)} onClick={() => setOpen(false)}>
+ Cancel
+
+ setOpen(false)}>Delete
+
+
+
+
+
+ )
+}
+
+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.
+
+
+
+
+
+
+ ),
+}
+
+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(true)}>Open from outside
+ setOpen(false)}>
+
+
+
+
+ Controlled
+
+ The consumer owns `open`; dismissals are decided at their source.
+
+
+ setOpen(false)}>Close
+
+
+
+
+
+ >
+ )
+}
+
+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.
+
+
+ Close all
+ setInnermostOpen(false)}>Close
+
+
+
+
+
+
+ setInnerOpen(false)}>Close
+
+
+
+
+
+
+ setOuterOpen(false)}>Close
+
+
+
+
+
+ )
+}
+
+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.
+
+
+ window.history.back()}>Simulate browser Back
+
+
+
+
+
+ ),
+}
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
+ Action
+ 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(() => (
+
+
+
+ Action
+
+
+
+
+ ))
+ 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
+ Content
+
+
+
+
+ ))
+ 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'>
+ first
+ last
+
+ )
+}
+
+// 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-workspace.yaml b/pnpm-workspace.yaml
index 362af61..afbd71e 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -8,5 +8,11 @@ minimumReleaseAgeExclude:
- '@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/solid-state-machine@0.4.0'
+ - '@dom-expressions/babel-plugin-jsx@0.50.0-next.43'
+ - '@solidjs/signals@2.0.0-rc.1'
+ - '@solidjs/web@2.0.0-rc.1'
+ - babel-preset-solid@2.0.0-rc.1
+ - solid-js@2.0.0-rc.1
publicHoistPattern:
- '*storybook*'
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..0fcc1db
--- /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.4.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 (
+ ({ type: 'button', ...rest }, normalize(api.parts.root))}
+ >
+ {props.children}
+
+ )
+}
+
+// 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..2d8aed4
--- /dev/null
+++ b/scripts/templates/packages/solid/__name__/src/effects.ts
@@ -0,0 +1,21 @@
+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
+// platform APIs also belong here (see the dialog for an example).
+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..688f1fe 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -19,23 +19,29 @@
"@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-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..7e37841 100644
--- a/tsdown.config.ts
+++ b/tsdown.config.ts
@@ -21,6 +21,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',
],
},
})