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. + + Cancel + + + + + ) +} +``` diff --git a/packages/solid/dialog/SPEC.md b/packages/solid/dialog/SPEC.md new file mode 100644 index 0000000..f8f6c1b --- /dev/null +++ b/packages/solid/dialog/SPEC.md @@ -0,0 +1,177 @@ +# SPEC / Solid / Dialog + +The Solid implementation of the [core spec](../../core/dialog/SPEC.md). + +## Docs + +🔗 [`dunky.dev/ui/components/dialog`](https://dunky.dev/ui/components/dialog). + +## Install + +```sh +npm install @dunky.dev/solid-dialog +``` + +## Usage + +```tsx +import { Dialog } from '@dunky.dev/solid-dialog' +; + Open + + + + + Title + Description + Close + + + + +``` + +Solid-specific notes on top of the core contract: + +- **`Portal`** teleports the layers to `document.body`, or to a `container` + you supply. Nothing is kept mounted while closed; an `animated` dialog + stays mounted through the core contract's `closing` state so its exit can + play — see the exit-animation note below. When scoped to a + `container`, the scroll lock applies to that container instead of the page, + and the backdrop/viewport must be positioned `absolute` (not `fixed`) so the + overlay pins to the container. Because an `absolute` overlay can't stay fixed + inside a scrolling element, a scoped container that needs a scrollable + background should be a non-scrolling positioned boundary wrapping an inner + scroller — portal into the boundary; the overlay fills its visible box and + the backdrop blocks the scroller behind it (see the `scoped` story). + Swapping `container` while the dialog is open re-creates the portal on the + new target (the host portal's mount is fixed at creation). +- **`Content`** renders a `
` carrying the `dialog` (or `alertdialog`) + role, not the native `` element. The dialog window is the initial + focus target — focusable in script, out of the tab order — which needs + `tabindex="-1"`, and HTML states that + [the `tabindex` attribute must not be specified on `dialog` elements](https://html.spec.whatwg.org/multipage/interactive-elements.html#the-dialog-element). + The native element would only pay off through `showModal()`, and this + contract deliberately keeps modality, dismissal, and focus with the core + machine rather than splitting authority with the browser's built-in behavior + (see the core spec's Internals). With the role explicit and the element + neutral, there is nothing left to gain and one conformance rule left to + break. +- **`Content`'s `initialFocus`** accepts an element or an accessor resolved at + open time — the Solid idiom for a ref variable that fills during render: + pass `initialFocus={() => cancelButton}`. +- **`Backdrop`** renders nothing when the dialog is non-modal (`modal={false}`), + per the core parts contract. +- **Exit animation** (`animated`): style the exit on the parts' + `data-state="closing"` — a CSS transition or animation on **Content** (the + element carrying the state, not a descendant) is what signals completion; + a missing exit style falls back to a short ceiling, and + `prefers-reduced-motion` skips the wait entirely. The exit is cosmetic: + focus, the dialog stack, and page interaction release the moment closing + starts, and the still-painting layer is made `inert` until it unmounts. + Enter needs no state — the parts mount straight into `data-state="open"`, + so a CSS animation (or a transition via `@starting-style`) plays from + mount. +- **Back navigation** (`closeOnBack`): opening plants a guard entry in the + session history, so the browser's Back closes the dialog instead of leaving + the page — one layer per press in a nested stack, per the core contract. A + dialog closed any other way consumes its entry, leaving nothing to swallow + a later Back; an entry buried under in-app navigation while the dialog is + open is left alone (Back then both navigates and closes the dialog). +- Everything ships headless, per the core contract's + [Internals](../../core/dialog/SPEC.md#internals). + +## API + +### `Dialog` + +The root: owns open/close state, renders no DOM. Accepts the core +`DialogOptions`. + +| Prop | Type | Default | Description | +| ------------------------ | --------------------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| `open` | `boolean` | — | Controlled open state — the dialog follows it alone. Back to `undefined` hands the state over, uncontrolled in place. | +| `defaultOpen` | `boolean` | `false` | Initial open state for the uncontrolled dialog. | +| `onOpenChange` | `(open: boolean) => void` | — | Fired on every open/close transition with the new value. | +| `modal` | `boolean` | `true` | `aria-modal`, focus trap, scroll lock, backdrop. | +| `role` | `'dialog' \| 'alertdialog'` | `'dialog'` | The ARIA pattern. | +| `closeOnEscape` | `boolean` | `true` | Whether Escape closes the dialog. | +| `escapeScope` | `'layer' \| 'stack'` | `'layer'` | How far an allowed Escape reaches: this dialog, or its whole stack. | +| `closeOnInteractOutside` | `boolean` | `true` — `false` for `role="alertdialog"` | Whether pressing the backdrop/viewport closes the dialog. | +| `animated` | `boolean` | `false` | Keeps the dialog mounted through `data-state="closing"` while its exit animation plays. | +| `closeOnBack` | `boolean` | `false` | The browser's Back closes the open dialog instead of navigating (a guard entry in the session history). | +| `onBackNavigation` | `(event?) => void` | — | Fired before a back-navigation dismissal; `preventDefault()` vetoes. | +| `onEscapeKeyDown` | `(event) => void` | — | Fired before an Escape dismissal; `preventDefault()` vetoes. | +| `onInteractOutside` | `(event?) => void` | — | Fired before an outside-press dismissal; `preventDefault()` vetoes. | +| `id` | `string` | auto (`createUniqueId`) | Base id for the parts; per-part ids are derived from it. | +| `children` | `JSX.Element` | — | The dialog's parts. | + +### `Dialog.Trigger` + +Opens the dialog; focus returns here on close. + +| Prop | Type | Default | Description | +| ---------- | -------------------------- | ------- | ------------------------------------- | +| `...props` | `ComponentProps<'button'>` | — | Forwarded to the rendered ` + ) +} + +// ============================================================================= +// — teleports the layers out of the tree while open +// ============================================================================= + +export interface DialogPortalProps { + children?: JSX.Element + /** The element to portal into. @default document.body */ + container?: HTMLElement | null +} + +export const Portal: Component = props => { + const context = useDialogContext() + if (isServer) return null + return ( + // `mounted`, not `open`: an animated dialog stays mounted through + // `closing` so its exit visual can play. + + {/* keyed: the host portal's mount is fixed at creation, so a container + swap re-creates the portal on the new target. */} + + {mount => ( + + {/* Re-provide the context with the scoped container (null = page + body) so Content locks the right scroll surface. */} + props.container ?? null }}> + {props.children} + + + )} + + + ) +} + +// ============================================================================= +// — the layer behind the dialog window +// ============================================================================= + +export interface DialogBackdropProps extends ComponentProps<'div'> {} + +export const Backdrop: Component = props => { + const { api, machine, backdropRef } = useDialogContext() + const rest = omit(props, 'ref', 'children') + onSettled(() => () => (backdropRef.current = null)) + + const bindings = (): Record => { + const { onClick, ...attrs } = normalize(api.parts.backdrop) as { + onClick?: (event: MouseEvent) => void + } & Record + return { + ...attrs, + // 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 ( + + ) +} + +// 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. + +
+ + +
+
+
+
+
+ ) +} + +export const alertDialog: StoryType = { + render: () => , +} + +export const longContent: StoryType = { + render: () => ( + + Open terms + + + + + + Terms of service + + Content taller than the screen scrolls within the viewport layer. + + + {index => ( +

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

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

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

+ )} +
+ + Open in panel + + + + + + Scoped dialog + + Portaled into the panel boundary; the backdrop and viewport are `absolute`, so the + overlay fills the panel's visible box and stays put while the background scrolls + behind it. + + + + + +
+
+ ) +} + +export const scoped: StoryType = { + render: () => , +} + +// "Close all" is consumer-side for now — `Close scope="stack"` is spec-only, so +// the three layers are controlled and one handler drops them together. And a +// controlled dialog never moves on its own: each layer decides its dismissals +// at the source — its Trigger handler, its own action buttons, and the +// dismissal callbacks (`onEscapeKeyDown` / `onInteractOutside`) — per the +// controlled contract; `onOpenChange` only reports changes that actually +// happened. +const NestedDialogs = () => { + const [outerOpen, setOuterOpen] = createSignal(true) + const [innerOpen, setInnerOpen] = createSignal(false) + const [innermostOpen, setInnermostOpen] = createSignal(false) + const closeAll = () => { + setInnermostOpen(false) + setInnerOpen(false) + setOuterOpen(false) + } + return ( + setOuterOpen(false)} + onInteractOutside={() => setOuterOpen(false)} + > + setOuterOpen(true)}>Open outer + + + + + Outer dialog + + Escape and outside presses dismiss the topmost dialog only — the stack unwinds one + layer at a time. + + setInnerOpen(false)} + onInteractOutside={() => setInnerOpen(false)} + > + setInnerOpen(true)}>Open inner + + + + + Inner dialog + + While open, everything beneath — including the outer dialog — is inert and + hidden from assistive tech. + + setInnermostOpen(false)} + onInteractOutside={() => setInnermostOpen(false)} + > + setInnermostOpen(true)}> + Open innermost + + + + + + Innermost dialog + + Three layers deep. Escape and Close dismiss this layer only; Close all + unwinds the whole stack at once. + +
+ + +
+
+
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+ ) +} + +export const nested: StoryType = { + render: () => , +} + +// closeOnBack turns the host's Back into a dismissal: while the dialog is open, +// a guard entry sits in the session history, so the browser's Back closes the +// dialog instead of leaving the page — what mobile users expect from a +// full-screen overlay. The canvas has no browser chrome, so the in-dialog +// button stands in for a real Back press by calling `history.back()`. +export const closeOnBack: StoryType = { + render: () => ( + + Open dialog + + + + + + Rename board + + The browser's Back closes this dialog instead of navigating away. Press Back — or the + button below, which stands in for it here — and the dialog dismisses while the page + stays put. + +
+ +
+
+
+
+
+ ), +} diff --git a/packages/solid/dialog/tests/dialog.test.tsx b/packages/solid/dialog/tests/dialog.test.tsx new file mode 100644 index 0000000..6477ad9 --- /dev/null +++ b/packages/solid/dialog/tests/dialog.test.tsx @@ -0,0 +1,654 @@ +// @vitest-environment jsdom +// The Solid edge of the Dialog — behavior only; the machine's own contract is +// covered in @dunky.dev/dialog's tests. +import { createSignal, flush } from 'solid-js' +import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Dialog, type DialogProps } from '@dunky.dev/solid-dialog' + +const DefaultDialog = (props: DialogProps) => ( + + Trigger + + + + + Title + Description + + Close + + + + +) + +// Solid 2.0 defers store commits to the microtask queue — flush after every +// interaction before reading the tree. +const press = (element: HTMLElement): void => { + element.click() + flush() +} + +const openDialog = (): void => { + press(screen.getByText('Trigger')) +} + +const pressEscape = (): void => { + fireEvent.keyDown(document.body, { key: 'Escape' }) + flush() +} + +// Auto-cleanup needs vitest globals; this repo runs with globals: false. +afterEach(cleanup) + +describe('Dialog', () => { + describe('open / close', () => { + it('opens on trigger press and closes on close press', () => { + render(() => ) + expect(screen.queryByRole('dialog')).toBeNull() + + openDialog() + expect(screen.queryByRole('dialog')).not.toBeNull() + + press(screen.getByText('Close')) + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('renders open when defaultOpen', () => { + render(() => ) + expect(screen.queryByRole('dialog')).not.toBeNull() + }) + + it('fires onOpenChange with the new value on open and close', () => { + const onOpenChange = vi.fn() + render(() => ) + + openDialog() + expect(onOpenChange).toHaveBeenLastCalledWith(true) + + press(screen.getByText('Close')) + expect(onOpenChange).toHaveBeenLastCalledWith(false) + }) + }) + + describe('escape key', () => { + it('closes on Escape', () => { + render(() => ) + pressEscape() + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('stays open when closeOnEscape=false', () => { + render(() => ) + pressEscape() + expect(screen.queryByRole('dialog')).not.toBeNull() + }) + + it('stays open when onEscapeKeyDown prevents default', () => { + const onEscapeKeyDown = vi.fn(event => event.preventDefault()) + render(() => ) + pressEscape() + expect(onEscapeKeyDown).toHaveBeenCalledTimes(1) + expect(screen.queryByRole('dialog')).not.toBeNull() + }) + }) + + describe('outside interaction', () => { + it('closes on backdrop press', () => { + render(() => ) + press(screen.getByTestId('backdrop')) + expect(screen.queryByRole('dialog')).toBeNull() + }) + + // The backdrop is portalled alongside the viewport, outside the content's + // subtree — the containment walk must except it, or `inert` would swallow + // real pointer presses on it (jsdom's .click() bypasses hit-testing, so + // only the attributes can assert this). + it('keeps its own backdrop pressable while the page around it is inert', () => { + const { container } = render(() => ) + expect(container.hasAttribute('inert')).toBe(true) + + const backdrop = screen.getByTestId('backdrop') + expect(backdrop.hasAttribute('aria-hidden')).toBe(false) + expect(backdrop.hasAttribute('inert')).toBe(false) + }) + + it('stays open when closeOnInteractOutside=false', () => { + render(() => ) + press(screen.getByTestId('backdrop')) + expect(screen.queryByRole('dialog')).not.toBeNull() + }) + + it('stays open when onInteractOutside prevents default', () => { + const onInteractOutside = vi.fn(event => event?.preventDefault()) + render(() => ) + press(screen.getByTestId('backdrop')) + expect(onInteractOutside).toHaveBeenCalledTimes(1) + expect(screen.queryByRole('dialog')).not.toBeNull() + }) + + it('alertdialog does not dismiss on backdrop press by default', () => { + render(() => ) + press(screen.getByTestId('backdrop')) + expect(screen.queryByRole('alertdialog')).not.toBeNull() + }) + + it('closes on a press on the viewport around the content', () => { + render(() => ) + press(screen.getByTestId('viewport')) + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('does not close when a press inside the content bubbles to the viewport', () => { + render(() => ) + press(screen.getByText('Action')) + expect(screen.queryByRole('dialog')).not.toBeNull() + }) + + it('renders no backdrop when modal=false', () => { + render(() => ) + expect(screen.queryByTestId('backdrop')).toBeNull() + }) + }) + + describe('controlled open', () => { + it('follows the open prop in both directions', () => { + const [open, setOpen] = createSignal(false) + render(() => ) + expect(screen.queryByRole('dialog')).toBeNull() + + setOpen(true) + flush() + expect(screen.queryByRole('dialog')).not.toBeNull() + + setOpen(false) + flush() + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('a dismissal neither closes nor fires onOpenChange — nothing changed', () => { + const onOpenChange = vi.fn() + render(() => ) + pressEscape() + expect(onOpenChange).not.toHaveBeenCalled() + expect(screen.queryByRole('dialog')).not.toBeNull() + }) + + it('a trigger press neither opens nor fires onOpenChange', () => { + const onOpenChange = vi.fn() + render(() => ) + openDialog() + expect(onOpenChange).not.toHaveBeenCalled() + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('reports a prop-driven change through onOpenChange', () => { + const onOpenChange = vi.fn() + const [open, setOpen] = createSignal(false) + render(() => ) + setOpen(true) + flush() + expect(onOpenChange).toHaveBeenLastCalledWith(true) + expect(onOpenChange).toHaveBeenCalledTimes(1) + }) + + // The controlled contract's consumer side: the dialog never moves on its + // own, so the consumer's own handlers on the parts and the dismissal + // callbacks are what drive the prop. + it('a controlled stack closes through handlers wired at the source', () => { + const ControlledStack = () => { + const [outerOpen, setOuterOpen] = createSignal(true) + const [innerOpen, setInnerOpen] = createSignal(false) + return ( + setOuterOpen(false)} + > + + + + Outer + setInnerOpen(false)} + > + setInnerOpen(true)}>Open inner + + + + Inner + setInnerOpen(false)}> + Close inner + + + + + + + + + + ) + } + + render(() => ) + press(screen.getByText('Open inner')) + expect(screen.queryByText('Inner')).not.toBeNull() + + press(screen.getByText('Close inner')) + expect(screen.queryByText('Inner')).toBeNull() + + press(screen.getByText('Open inner')) + pressEscape() // reaches the topmost layer only + expect(screen.queryByText('Inner')).toBeNull() + expect(screen.queryByText('Outer')).not.toBeNull() + }) + + it('dropping the open prop rewires the dialog uncontrolled where it stands', () => { + const onOpenChange = vi.fn() + const [open, setOpen] = createSignal(true) + render(() => ) + setOpen(undefined) + flush() + expect(screen.queryByRole('dialog')).not.toBeNull() // stays where it was + + pressEscape() // uncontrolled now: dismissal works again + expect(screen.queryByRole('dialog')).toBeNull() + expect(onOpenChange).toHaveBeenLastCalledWith(false) + }) + }) + + describe('aria wiring', () => { + it('trigger exposes the popup relationship', () => { + render(() => ) + const trigger = screen.getByText('Trigger') + expect(trigger.getAttribute('aria-haspopup')).toBe('dialog') + expect(trigger.getAttribute('aria-expanded')).toBe('false') + + openDialog() + expect(trigger.getAttribute('aria-expanded')).toBe('true') + expect(trigger.getAttribute('aria-controls')).toBe(screen.getByRole('dialog').id) + }) + + // The window takes initial focus, so it carries tabindex — which HTML + // forbids on . Hence a neutral element with an explicit role. + it('renders the dialog window as a scripted focus target outside the tab order', () => { + render(() => ) + const dialog = screen.getByRole('dialog') + expect(dialog.tagName).not.toBe('DIALOG') + expect(dialog.tabIndex).toBe(-1) + }) + + it('content is labelled by the Title and described by the Description', () => { + render(() => ) + const dialog = screen.getByRole('dialog', { name: 'Title' }) + expect(dialog.getAttribute('aria-modal')).toBe('true') + + const describedBy = dialog.getAttribute('aria-describedby') + expect(describedBy).not.toBeNull() + expect(document.getElementById(describedBy as string)?.textContent).toBe('Description') + }) + + it('supports aria-label on Content when no Title is rendered', () => { + render(() => ( + + + content + + + )) + const dialog = screen.getByRole('dialog', { name: 'Settings' }) + expect(dialog.hasAttribute('aria-labelledby')).toBe(false) + expect(dialog.hasAttribute('aria-describedby')).toBe(false) + }) + + it('renders role=alertdialog when requested', () => { + render(() => ) + expect(screen.queryByRole('alertdialog')).not.toBeNull() + }) + + it('omits aria-modal when modal=false', () => { + render(() => ) + expect(screen.getByRole('dialog').hasAttribute('aria-modal')).toBe(false) + }) + }) + + describe('focus management', () => { + it('moves focus into the dialog window on open and restores it on close', () => { + render(() => ) + const trigger = screen.getByText('Trigger') + trigger.focus() + + openDialog() + expect(document.activeElement).toBe(screen.getByRole('dialog')) + + pressEscape() + expect(document.activeElement).toBe(trigger) + }) + + // jsdom does no layout, so the scroll jump can't be reproduced — assert the + // mechanism that prevents it: focus never scrolls the locked surface. + it('moves focus without scrolling the locked surface', () => { + const focusSpy = vi.spyOn(HTMLElement.prototype, 'focus') + render(() => ) + flush() + + expect(focusSpy).toHaveBeenCalledWith({ preventScroll: true }) + focusSpy.mockRestore() + }) + + it('moves focus to the first form field when the dialog contains one', () => { + render(() => ( + + + + + + + + + )) + expect(document.activeElement).toBe(screen.getByLabelText('Name')) + }) + + it('wraps Tab from the last focusable to the first', () => { + render(() => ) + const dialog = screen.getByRole('dialog') + + screen.getByText('Close').focus() + fireEvent.keyDown(dialog, { key: 'Tab' }) + expect(document.activeElement).toBe(screen.getByText('Action')) + }) + + it('wraps Shift+Tab from the first focusable to the last', () => { + render(() => ) + const dialog = screen.getByRole('dialog') + + screen.getByText('Action').focus() + fireEvent.keyDown(dialog, { key: 'Tab', shiftKey: true }) + expect(document.activeElement).toBe(screen.getByText('Close')) + }) + + it('keeps Close last in the cycle even when it renders first', () => { + // Close first in the DOM, then content. Tabbing FROM the dialog window + // (off-cycle, where focus lands on open) is the discriminating case: a + // pure forward cycle hides the wrap point, but entry from off-cycle + // reveals whether Close leads (bug) or trails (fixed). + render(() => ( + + + + + Close + + + + + + )) + const dialog = screen.getByRole('dialog') + + dialog.focus() // the dialog window — where focus opens + fireEvent.keyDown(dialog, { key: 'Tab' }) + expect(document.activeElement).toBe(screen.getByText('Content')) // not Close + + dialog.focus() + fireEvent.keyDown(dialog, { key: 'Tab', shiftKey: true }) + expect(document.activeElement).toBe(screen.getByText('Close')) // last, backward + }) + + const InitialFocusDialog = (props: { disabled?: boolean }) => { + let initialFocus: HTMLInputElement | undefined + return ( + + + + initialFocus}> + (initialFocus = el)} + disabled={props.disabled} + aria-label='Name' + /> + + + + + ) + } + + it('moves focus to the initialFocus element on open', () => { + render(() => ) + expect(document.activeElement).toBe(screen.getByLabelText('Name')) + }) + + it('falls back to the dialog panel when the initialFocus target cannot take focus', () => { + render(() => ) + expect(document.activeElement).toBe(screen.getByRole('dialog')) + }) + }) + + describe('scroll lock', () => { + it('locks body scroll while a modal dialog is open', () => { + render(() => ) + openDialog() + expect(document.body.style.overflow).toBe('hidden') + + pressEscape() + expect(document.body.style.overflow).not.toBe('hidden') + }) + + it('does not lock scroll when modal=false', () => { + render(() => ) + expect(document.body.style.overflow).not.toBe('hidden') + }) + + it('locks the portal container, not the body, when scoped', () => { + const panel = document.createElement('div') + document.body.append(panel) + + render(() => ( + + + content + + + )) + + expect(panel.style.overflow).toBe('hidden') + expect(document.body.style.overflow).not.toBe('hidden') + + pressEscape() + expect(panel.style.overflow).not.toBe('hidden') + panel.remove() + }) + }) + + describe('back navigation', () => { + // jsdom's history traversal is asynchronous — await the popstate itself. + const nextPop = (): Promise => + new Promise(resolve => { + window.addEventListener('popstate', () => resolve(), { once: true }) + }) + + it('closes on the browser Back instead of navigating', async () => { + const before: unknown = window.history.state + render(() => ) + openDialog() + expect(window.history.state).not.toEqual(before) // the guard entry is planted + + const pop = nextPop() + window.history.back() + await pop + flush() + expect(screen.queryByRole('dialog')).toBeNull() + expect(window.history.state).toEqual(before) // consumed by the press itself + }) + + it('closing any other way consumes the guard entry', async () => { + const before: unknown = window.history.state + render(() => ) + flush() + expect(window.history.state).not.toEqual(before) + + const pop = nextPop() + pressEscape() + await pop + expect(window.history.state).toEqual(before) // no leftover to swallow a Back + }) + + it('plants no history entry without the flag', () => { + const before: unknown = window.history.state + render(() => ) + flush() + expect(window.history.state).toEqual(before) + }) + }) + + describe('exit animation', () => { + const fireTransitionEnd = (element: Element): void => { + element.dispatchEvent(new Event('transitionend', { bubbles: true })) + flush() + } + + it('stays mounted through the exit and unmounts when its transition ends', () => { + render(() => ) + pressEscape() + + // Mid-exit: still in the tree, styled by data-state, hidden from AT. + const dialog = screen.getByRole('dialog', { hidden: true }) + expect(dialog.getAttribute('data-state')).toBe('closing') + + fireTransitionEnd(dialog) + expect(screen.queryByRole('dialog', { hidden: true })).toBeNull() + }) + + it('releases focus, containment, and interaction the moment the exit starts', () => { + const { container } = render(() => ) + const trigger = screen.getByText('Trigger') + trigger.focus() + openDialog() + expect(container.hasAttribute('inert')).toBe(true) + + pressEscape() + // The page is live and focus is home before the visual finishes… + expect(container.hasAttribute('inert')).toBe(false) + expect(document.activeElement).toBe(trigger) + // …while the still-painting layer is out of the interaction instead. + expect(screen.getByTestId('viewport').hasAttribute('inert')).toBe(true) + expect(screen.getByTestId('backdrop').hasAttribute('inert')).toBe(true) + }) + + it('reopening mid-exit interrupts it and restores the layer', () => { + render(() => ) + openDialog() + pressEscape() + openDialog() + + const dialog = screen.getByRole('dialog') + expect(dialog.getAttribute('data-state')).toBe('open') + expect(screen.getByTestId('viewport').hasAttribute('inert')).toBe(false) + expect(document.activeElement).toBe(dialog) + + // The interrupted exit's end must not close the reopened dialog. + fireTransitionEnd(dialog) + expect(screen.queryByRole('dialog')).not.toBeNull() + }) + }) + + describe('nesting', () => { + const NestedDialog = (props: DialogProps) => ( + + + + + + Outer + + + + + + Inner + + + + + + + + + ) + + it('Escape dismisses the topmost dialog only, one layer per press', () => { + render(() => ) + expect(screen.queryByText('Outer')).not.toBeNull() + expect(screen.queryByText('Inner')).not.toBeNull() + + pressEscape() + expect(screen.queryByText('Inner')).toBeNull() + expect(screen.queryByText('Outer')).not.toBeNull() + + pressEscape() + expect(screen.queryByText('Outer')).toBeNull() + }) + + it('hides the dialog beneath the topmost from assistive tech and makes it inert', () => { + render(() => ) + const outer = screen.getByTestId('outer-viewport') + expect(outer.getAttribute('aria-hidden')).toBe('true') + expect(outer.hasAttribute('inert')).toBe(true) + + const inner = screen.getByTestId('inner-viewport') + expect(inner.hasAttribute('aria-hidden')).toBe(false) + expect(inner.hasAttribute('inert')).toBe(false) + }) + + it("hides the lower dialog's backdrop but never the topmost's own", () => { + render(() => ) + expect(screen.getByTestId('outer-backdrop').hasAttribute('inert')).toBe(true) + expect(screen.getByTestId('inner-backdrop').hasAttribute('inert')).toBe(false) + + pressEscape() // the outer dialog is topmost again — its backdrop re-excepted + expect(screen.getByTestId('outer-backdrop').hasAttribute('inert')).toBe(false) + }) + + it('restores the layer beneath once the top dialog closes', () => { + render(() => ) + expect(screen.getByTestId('outer-viewport').getAttribute('aria-hidden')).toBe('true') + + pressEscape() // close the inner dialog + const outer = screen.getByTestId('outer-viewport') + expect(outer.hasAttribute('aria-hidden')).toBe(false) + expect(outer.hasAttribute('inert')).toBe(false) + }) + + it('ignores an outside press on a lower layer — only the topmost dismisses', () => { + render(() => ) + press(screen.getByTestId('outer-viewport')) + expect(screen.queryByText('Outer')).not.toBeNull() + expect(screen.queryByText('Inner')).not.toBeNull() + + press(screen.getByTestId('inner-viewport')) + expect(screen.queryByText('Inner')).toBeNull() + expect(screen.queryByText('Outer')).not.toBeNull() + }) + + it('cleans up containment and scroll lock when the parent closes over an open child', () => { + const [open, setOpen] = createSignal(true) + const { container } = render(() => ) + expect(screen.queryByText('Inner')).not.toBeNull() + expect(container.hasAttribute('inert')).toBe(true) + + setOpen(false) + flush() + expect(screen.queryByText('Outer')).toBeNull() + expect(screen.queryByText('Inner')).toBeNull() + expect(document.body.style.overflow).not.toBe('hidden') + expect(container.hasAttribute('aria-hidden')).toBe(false) + expect(container.hasAttribute('inert')).toBe(false) + }) + }) +}) diff --git a/packages/solid/dialog/tsdown.config.ts b/packages/solid/dialog/tsdown.config.ts new file mode 100644 index 0000000..ff9c219 --- /dev/null +++ b/packages/solid/dialog/tsdown.config.ts @@ -0,0 +1,19 @@ +import { babel } from '@rollup/plugin-babel' +import { defineConfig } from 'tsdown' + +// Solid JSX needs Solid's own compiler (babel-preset-solid) — rolldown/oxc +// only know React-shaped JSX. Presets apply last-to-first: TypeScript strips +// types keeping the JSX, then the Solid preset compiles it. Everything else +// inherits the root config. +export default defineConfig({ + plugins: [ + babel({ + babelHelpers: 'bundled', + extensions: ['.tsx'], + presets: [ + ['babel-preset-solid'], + ['@babel/preset-typescript', { isTSX: true, allExtensions: true }], + ], + }), + ], +}) diff --git a/packages/solid/hooks/use-focus-trap/README.md b/packages/solid/hooks/use-focus-trap/README.md new file mode 100644 index 0000000..9a8441a --- /dev/null +++ b/packages/solid/hooks/use-focus-trap/README.md @@ -0,0 +1,28 @@ +# @dunky.dev/solid-use-focus-trap + +Solid binding for [`@dunky.dev/dom-focus-trap`](../../../dom/utils/focus-trap): +`useFocusTrap(target)` traps Tab / Shift+Tab within the accessed container +while the owner lives. The trap behavior itself is framework-free — this +primitive only owns the Solid lifecycle. + +## Install + +```sh +npm install @dunky.dev/solid-use-focus-trap +``` + +## Usage + +```tsx +import { useFocusTrap } from '@dunky.dev/solid-use-focus-trap' + +function Dialog() { + let panel: HTMLDivElement | undefined + useFocusTrap(() => panel ?? null, { enabled: () => isTopmost(panel) }) + return ( +
+ ... +
+ ) +} +``` diff --git a/packages/solid/hooks/use-focus-trap/SPEC.md b/packages/solid/hooks/use-focus-trap/SPEC.md new file mode 100644 index 0000000..fdf2822 --- /dev/null +++ b/packages/solid/hooks/use-focus-trap/SPEC.md @@ -0,0 +1,52 @@ +# SPEC / Solid / useFocusTrap + +The Solid binding of the +[DOM focus-trap spec](../../../dom/utils/focus-trap/SPEC.md) — the trap +behavior is framework-free; this primitive owns only the Solid lifecycle. + +## Install + +```sh +npm install @dunky.dev/solid-use-focus-trap +``` + +## Usage + +```tsx +import { isTopmostLayer } from '@dunky.dev/dom-overlay' +import { useFocusTrap } from '@dunky.dev/solid-use-focus-trap' + +function DialogContent(props: { id: string }) { + let panel: HTMLDivElement | undefined + // `enabled` follows runtime state — here, only the overlay stack's + // topmost layer traps. + useFocusTrap(() => panel ?? null, { enabled: () => isTopmostLayer(props.id) }) + return ( +
+ ... +
+ ) +} +``` + +Solid-specific notes on top of the DOM contract: + +- The target is an accessor, not a ref object: call the primitive from the + component that renders the container. A plain ref variable fills during + render, before effects run, so the trap binds when the component mounts + and releases when its owner is disposed. A reactive accessor (a signal) + re-arms the trap on a new element. +- Options are read through the closure on each Tab press, so inline + `enabled` / `last` see the latest state without re-binding the listener — + the per-press re-evaluation the DOM contract promises. + +## API + +### `useFocusTrap(target, options?)` + +Returns nothing — the trap lives and dies with the owner. + +| Param | Type | Default | Description | +| --------- | ---------------------------------------- | ------- | -------------------------------------------------------------------------------------- | +| `target` | `() => HTMLElement \| null \| undefined` | — | Accessor for the container to trap Tab / Shift+Tab within. | +| `options` | `UseFocusTrapOptions` | `{}` | The DOM trap's options: `enabled?: () => boolean`, `last?: () => HTMLElement \| null`. | diff --git a/packages/solid/hooks/use-focus-trap/package.json b/packages/solid/hooks/use-focus-trap/package.json new file mode 100644 index 0000000..98776de --- /dev/null +++ b/packages/solid/hooks/use-focus-trap/package.json @@ -0,0 +1,49 @@ +{ + "name": "@dunky.dev/solid-use-focus-trap", + "version": "0.0.0", + "description": "Solid binding for @dunky.dev/dom-focus-trap.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/dunky-dev/ui.git", + "directory": "packages/solid/hooks/use-focus-trap" + }, + "files": [ + "dist", + "src", + "SPEC.md" + ], + "type": "module", + "sideEffects": false, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "publishConfig": { + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "access": "public" + }, + "scripts": { + "build": "tsdown" + }, + "dependencies": { + "@dunky.dev/dom-focus-trap": "workspace:*" + }, + "devDependencies": { + "@solidjs/testing-library": "^1.0.0-beta.2", + "@solidjs/web": "^2.0.0-rc.1", + "solid-js": "^2.0.0-rc.1" + }, + "peerDependencies": { + "solid-js": "^2.0.0-rc.1" + } +} diff --git a/packages/solid/hooks/use-focus-trap/src/index.ts b/packages/solid/hooks/use-focus-trap/src/index.ts new file mode 100644 index 0000000..52559c7 --- /dev/null +++ b/packages/solid/hooks/use-focus-trap/src/index.ts @@ -0,0 +1 @@ +export { useFocusTrap, type UseFocusTrapOptions } from './use-focus-trap' diff --git a/packages/solid/hooks/use-focus-trap/src/use-focus-trap.ts b/packages/solid/hooks/use-focus-trap/src/use-focus-trap.ts new file mode 100644 index 0000000..9d711fe --- /dev/null +++ b/packages/solid/hooks/use-focus-trap/src/use-focus-trap.ts @@ -0,0 +1,31 @@ +import { createEffect, untrack } from 'solid-js' +import { trapFocus } from '@dunky.dev/dom-focus-trap' +import type { TrapFocusOptions } from '@dunky.dev/dom-focus-trap' + +export interface UseFocusTrapOptions extends TrapFocusOptions {} + +/** + * Traps Tab / Shift+Tab within `target` while it holds an element — the Solid + * lifecycle around `trapFocus`. Arms when the target yields an element, + * releases on dispose, re-arms when a reactive accessor yields a new one. + */ +export function useFocusTrap( + target: () => HTMLElement | null | undefined, + options: UseFocusTrapOptions = {}, +): void { + // The compute tracks a reactive target; the apply re-reads it fresh — + // compute runs eagerly at creation, before a plain ref variable fills. + // Options are read per Tab press, so inline `enabled` / `last` stay live + // without re-binding the listener. + createEffect( + () => target(), + () => { + const container = untrack(target) + if (container == null) return + return trapFocus(container, { + enabled: () => options.enabled?.() !== false, + last: () => options.last?.() ?? null, + }) + }, + ) +} diff --git a/packages/solid/hooks/use-focus-trap/tests/use-focus-trap.test.tsx b/packages/solid/hooks/use-focus-trap/tests/use-focus-trap.test.tsx new file mode 100644 index 0000000..5fdd108 --- /dev/null +++ b/packages/solid/hooks/use-focus-trap/tests/use-focus-trap.test.tsx @@ -0,0 +1,54 @@ +// @vitest-environment jsdom +// The Solid lifecycle around @dunky.dev/dom-focus-trap — the wrap/no-op/enabled +// behavior itself is covered in the util's own tests. +import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library' +import { afterEach, describe, expect, it } from 'vitest' +import { useFocusTrap } from '@dunky.dev/solid-use-focus-trap' + +function Trap(props: { enabled?: () => boolean }) { + let target: HTMLDivElement | undefined + // The closure defers the props read to each Tab press. + useFocusTrap(() => target ?? null, { enabled: () => props.enabled?.() !== false }) + return ( +
(target = el)} tabindex={-1} data-testid='container'> + + +
+ ) +} + +// fireEvent returns false when a handler called preventDefault. +const pressTab = (): boolean => fireEvent.keyDown(screen.getByTestId('container'), { key: 'Tab' }) + +// Auto-cleanup needs vitest globals; this repo runs with globals: false. +afterEach(cleanup) + +describe('useFocusTrap', () => { + it('traps while mounted and releases on unmount', () => { + const { unmount } = render(() => ) + screen.getByText('last').focus() + + expect(pressTab()).toBe(false) + expect(document.activeElement).toBe(screen.getByText('first')) + + const container = screen.getByTestId('container') + screen.getByText('last').focus() + unmount() + // The listener is gone with the unmount — a Tab on the detached container + // is no longer intercepted. + expect( + container.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }), + ), + ).toBe(true) + }) + + it('forwards enabled() to the trap without re-binding', () => { + render(() => false} />) + const last = screen.getByText('last') + last.focus() + + expect(pressTab()).toBe(true) + expect(document.activeElement).toBe(last) + }) +}) diff --git a/packages/solid/hooks/use-scroll-lock/README.md b/packages/solid/hooks/use-scroll-lock/README.md new file mode 100644 index 0000000..d2c6450 --- /dev/null +++ b/packages/solid/hooks/use-scroll-lock/README.md @@ -0,0 +1,25 @@ +# @dunky.dev/solid-use-scroll-lock + +Solid binding for [`@dunky.dev/dom-scroll-lock`](../../../dom/utils/scroll-lock): +`useScrollLock(locked, target?)` locks scrolling while the owner lives — on +the page body, or on the `target` element when one is given (e.g. a scoped +surface locks its own container, not the page). The lock behavior itself is +framework-free — this primitive only owns the Solid lifecycle. + +## Install + +```sh +npm install @dunky.dev/solid-use-scroll-lock +``` + +## Usage + +```tsx +import { useScrollLock } from '@dunky.dev/solid-use-scroll-lock' + +// Rendered while a modal layer is open, e.g. +function ModalPanel() { + useScrollLock() // the page behind can't scroll while mounted + return
...
+} +``` diff --git a/packages/solid/hooks/use-scroll-lock/SPEC.md b/packages/solid/hooks/use-scroll-lock/SPEC.md new file mode 100644 index 0000000..9c2a98f --- /dev/null +++ b/packages/solid/hooks/use-scroll-lock/SPEC.md @@ -0,0 +1,45 @@ +# SPEC / Solid / useScrollLock + +The Solid binding of the +[DOM scroll-lock spec](../../../dom/utils/scroll-lock/SPEC.md) — the lock +behavior is framework-free; this primitive owns only the Solid lifecycle. + +## Install + +```sh +npm install @dunky.dev/solid-use-scroll-lock +``` + +## Usage + +```tsx +import { useScrollLock } from '@dunky.dev/solid-use-scroll-lock' + +// Rendered while a modal layer is open, e.g. +function ModalPanel() { + useScrollLock() // the page behind can't scroll while mounted + return
...
+} +``` + +Solid-specific notes on top of the DOM contract: + +- The lock holds while the owner lives and `locked` resolves true; disposal + or turning `locked` off releases it. Both parameters accept a + `MaybeAccessor` — a static value or an accessor — so the lock tracks + reactive state: a `target` change releases the old container and locks + the new one. +- The DOM contract's shared per-container lock does the multi-holder + arithmetic: several live lockers (nested modal layers) hold one lock, + and the container restores when the last releases. + +## API + +### `useScrollLock(locked?, target?)` + +Returns nothing — the lock lives and dies with the owner. + +| Param | Type | Default | Description | +| -------- | ------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------------- | +| `locked` | `MaybeAccessor` | `true` | Whether the lock is held. | +| `target` | `MaybeAccessor` | the page body | The scroll container to lock (e.g. a scoped surface locks its own container, not the page). | diff --git a/packages/solid/hooks/use-scroll-lock/package.json b/packages/solid/hooks/use-scroll-lock/package.json new file mode 100644 index 0000000..95eb1a9 --- /dev/null +++ b/packages/solid/hooks/use-scroll-lock/package.json @@ -0,0 +1,48 @@ +{ + "name": "@dunky.dev/solid-use-scroll-lock", + "version": "0.0.0", + "description": "Solid binding for @dunky.dev/dom-scroll-lock.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/dunky-dev/ui.git", + "directory": "packages/solid/hooks/use-scroll-lock" + }, + "files": [ + "dist", + "src", + "SPEC.md" + ], + "type": "module", + "sideEffects": false, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "publishConfig": { + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "access": "public" + }, + "scripts": { + "build": "tsdown" + }, + "dependencies": { + "@dunky.dev/dom-scroll-lock": "workspace:*" + }, + "devDependencies": { + "@solidjs/testing-library": "^1.0.0-beta.2", + "solid-js": "^2.0.0-rc.1" + }, + "peerDependencies": { + "solid-js": "^2.0.0-rc.1" + } +} diff --git a/packages/solid/hooks/use-scroll-lock/src/index.ts b/packages/solid/hooks/use-scroll-lock/src/index.ts new file mode 100644 index 0000000..80b363c --- /dev/null +++ b/packages/solid/hooks/use-scroll-lock/src/index.ts @@ -0,0 +1 @@ +export { useScrollLock, type MaybeAccessor } from './use-scroll-lock' diff --git a/packages/solid/hooks/use-scroll-lock/src/use-scroll-lock.ts b/packages/solid/hooks/use-scroll-lock/src/use-scroll-lock.ts new file mode 100644 index 0000000..2cd5bc5 --- /dev/null +++ b/packages/solid/hooks/use-scroll-lock/src/use-scroll-lock.ts @@ -0,0 +1,27 @@ +import { createEffect } from 'solid-js' +import { lockScroll } from '@dunky.dev/dom-scroll-lock' + +/** A static value or an accessor — for parameters that may be reactive. */ +export type MaybeAccessor = T | (() => T) + +function access(value: MaybeAccessor): T { + return typeof value === 'function' ? (value as () => T)() : value +} + +/** + * Locks scrolling while the owner lives and `locked` — the Solid lifecycle + * around `lockScroll`. Targets the page body unless a `target` is given. The + * lock is shared per container: it restores when the last holder releases. + */ +export function useScrollLock( + locked: MaybeAccessor = true, + target?: MaybeAccessor, +): void { + createEffect( + () => [access(locked), target === undefined ? undefined : access(target)] as const, + ([isLocked, container]) => { + if (!isLocked) return + return lockScroll(container ?? undefined) + }, + ) +} diff --git a/packages/solid/hooks/use-scroll-lock/tests/use-scroll-lock.test.ts b/packages/solid/hooks/use-scroll-lock/tests/use-scroll-lock.test.ts new file mode 100644 index 0000000..ee8c88a --- /dev/null +++ b/packages/solid/hooks/use-scroll-lock/tests/use-scroll-lock.test.ts @@ -0,0 +1,20 @@ +// @vitest-environment jsdom +import { renderHook } from '@solidjs/testing-library' +import { describe, expect, it } from 'vitest' +import { useScrollLock } from '@dunky.dev/solid-use-scroll-lock' + +describe('useScrollLock', () => { + it('locks body scroll while mounted and releases on unmount', () => { + const { cleanup } = renderHook(() => useScrollLock()) + expect(document.body.style.overflow).toBe('hidden') + + cleanup() + expect(document.body.style.overflow).toBe('') + }) + + it('does not lock when locked=false', () => { + const { cleanup } = renderHook(() => useScrollLock(false)) + expect(document.body.style.overflow).toBe('') + cleanup() + }) +}) diff --git a/packages/solid/package.json b/packages/solid/package.json new file mode 100644 index 0000000..0f043de --- /dev/null +++ b/packages/solid/package.json @@ -0,0 +1,20 @@ +{ + "name": "@dunky-dev/solid", + "version": "0.0.0", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "dev": "storybook dev -p 6008 -c .storybook", + "build": "storybook build -c .storybook" + }, + "devDependencies": { + "@solidjs/web": "^2.0.0-rc.1", + "@testing-library/jest-dom": "^6.9.1", + "solid-js": "^2.0.0-rc.1", + "storybook": "^10.5.0", + "storybook-solidjs-vite": "^10.6.0", + "vite": "^8.1.4", + "vite-plugin-solid": "^3.0.0-next.27" + } +} diff --git a/packages/solid/tsconfig.json b/packages/solid/tsconfig.json new file mode 100644 index 0000000..b433f53 --- /dev/null +++ b/packages/solid/tsconfig.json @@ -0,0 +1,12 @@ +{ + // Solid 2.0's web JSX namespace lives in @solidjs/web, not React's — so + // packages/solid typechecks as its own project (the root tsconfig excludes + // it; `pnpm typecheck` runs both). `paths` is inherited from the root file. + "extends": "../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "jsxImportSource": "@solidjs/web" + }, + "include": ["."], + "exclude": ["**/node_modules", "**/dist"] +} diff --git a/packages/solid/vitest.config.ts b/packages/solid/vitest.config.ts new file mode 100644 index 0000000..4584059 --- /dev/null +++ b/packages/solid/vitest.config.ts @@ -0,0 +1,17 @@ +import solid from 'vite-plugin-solid' +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + plugins: [solid()], + resolve: { + // @solidjs/testing-library + the reactive runtime expect these conditions. + conditions: ['development', 'browser'], + }, + test: { + name: 'solid', + globals: false, + // node by default; DOM tests opt into jsdom per-file via `@vitest-environment`. + environment: 'node', + include: ['**/tests/**/*.test.{ts,tsx}'], + }, +}) diff --git a/pnpm-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 ( + + ) +} + +// 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', ], }, })