Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .changeset/solid-dialog.md
Original file line number Diff line number Diff line change
@@ -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'
;<Dialog open={open()} onOpenChange={setOpen} onEscapeKeyDown={() => setOpen(false)}>
<Dialog.Trigger>Open</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Backdrop />
<Dialog.Viewport>
<Dialog.Content>
<Dialog.Title>Title</Dialog.Title>
<Dialog.Description>Description</Dialog.Description>
<Dialog.Close>Close</Dialog.Close>
</Dialog.Content>
</Dialog.Viewport>
</Dialog.Portal>
</Dialog>
```

`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`.
14 changes: 14 additions & 0 deletions .changeset/solid-hooks.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 11 additions & 0 deletions knip.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
},
},
}

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,15 @@
"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 .",
"knip": "knip",
"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",
Expand Down
15 changes: 15 additions & 0 deletions packages/solid/.storybook/main.ts
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions packages/solid/.storybook/manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { addons } from 'storybook/manager-api'
import { create } from 'storybook/theming'

addons.setConfig({
showToolbar: true,
layoutCustomisations: {
showPanel: () => false,
},
theme: create({
base: 'light',
brandTitle: 'dunky',
brandUrl: './',
}),
})
41 changes: 41 additions & 0 deletions packages/solid/dialog/README.md
Original file line number Diff line number Diff line change
@@ -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 (
<Dialog>
<Dialog.Trigger>Delete...</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Backdrop />
<Dialog.Viewport>
<Dialog.Content>
<Dialog.Title>Delete file?</Dialog.Title>
<Dialog.Description>This cannot be undone.</Dialog.Description>
<button type='button'>Delete</button>
<Dialog.Close>Cancel</Dialog.Close>
</Dialog.Content>
</Dialog.Viewport>
</Dialog.Portal>
</Dialog>
)
}
```
177 changes: 177 additions & 0 deletions packages/solid/dialog/SPEC.md
Original file line number Diff line number Diff line change
@@ -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'
;<Dialog>
<Dialog.Trigger>Open</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Backdrop />
<Dialog.Viewport>
<Dialog.Content>
<Dialog.Title>Title</Dialog.Title>
<Dialog.Description>Description</Dialog.Description>
<Dialog.Close>Close</Dialog.Close>
</Dialog.Content>
</Dialog.Viewport>
</Dialog.Portal>
</Dialog>
```

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 `<div>` carrying the `dialog` (or `alertdialog`)
role, not the native `<dialog>` 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 `<button>`. |

### `Dialog.Portal`

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

| Prop | Type | Default | Description |
| ----------- | --------------------- | --------------- | --------------------------- |
| `container` | `HTMLElement \| null` | `document.body` | The element to portal into. |
| `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 `<div>`. |

### `Dialog.Viewport`

The positioning + scroll layer around the dialog window.

| Prop | Type | Default | Description |
| ---------- | ----------------------- | ------- | ---------------------------------- |
| `...props` | `ComponentProps<'div'>` | — | Forwarded to the rendered `<div>`. |

### `Dialog.Content`

The dialog window; renders a `<div>` 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 `<div>`. |

### `Dialog.Title`

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

| Prop | Type | Default | Description |
| ---------- | ---------------------- | ------- | --------------------------------- |
| `...props` | `ComponentProps<'h2'>` | — | Forwarded to the rendered `<h2>`. |

### `Dialog.Description`

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

| Prop | Type | Default | Description |
| ---------- | ----------------------- | ------- | ---------------------------------- |
| `...props` | `ComponentProps<'div'>` | — | Forwarded to the rendered `<div>`. |

### `Dialog.Close`

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

| Prop | Type | Default | Description |
| ---------- | -------------------------- | --------- | -------------------------------------------------------------- |
| `scope` | `'layer' \| 'stack'` | `'layer'` | Dismiss just its own dialog, or unwind the whole nested stack. |
| `...props` | `ComponentProps<'button'>` | — | Forwarded to the rendered `<button>`. |
59 changes: 59 additions & 0 deletions packages/solid/dialog/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading
Loading