Skip to content
Merged
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
8 changes: 3 additions & 5 deletions knip.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,15 @@ const config: KnipConfig = {
'packages/react/*': {
entry: ['stories/*.stories.tsx'],
},
// knip's storybook plugin doesn't know the community solid framework, so
// the harness config is declared an entry by hand. jest-dom is loaded via
// a setup file vite-plugin-solid injects by bare specifier.
// 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 by name (strings) in each package's
// tsdown.config.ts — invisible to import analysis.
// The babel presets are referenced as strings in tsdown.config.ts.
ignoreDependencies: ['babel-preset-solid', '@babel/preset-typescript'],
},
},
Expand Down
25 changes: 8 additions & 17 deletions packages/solid/dialog/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,21 @@ import { createContext, useContext, type Accessor, type Context } from 'solid-js
import type { DialogApi, DialogMachine } from '@dunky.dev/dialog'

export interface DialogContextValue {
// The connected api as a fine-grained store proxy — reading a field in JSX
// or an effect subscribes to exactly that leaf.
// Fine-grained store proxy: reading a field subscribes to exactly that leaf.
api: DialogApi
machine: DialogMachine
// Nesting level (1 = top-level). Decides the topmost dialog of a stack for
// Escape, focus, and assistive-tech containment.
// Nesting level (1 = top-level); decides the topmost dialog of a stack.
depth: number
// The element the Portal teleported into, or null for the page body —
// Content scopes the scroll lock to it. An accessor so the Portal's prop
// stays live: the root provides null; Portal re-provides the context with
// the field filled in.
// The Portal's container (null = page body); an accessor so it stays live.
container: Accessor<HTMLElement | null>
// The rendered Backdrop element, shared because Backdrop and Content are
// sibling parts: Content's stack entry excepts its own backdrop from the
// containment so it stays pressable while its dialog is topmost. A plain
// mutable box, not a signal: the layer walk reads it synchronously inside
// the same settle that mounts the backdrop, before a signal write would
// commit.
// The rendered Backdrop, shared so Content's stack entry can except it from
// the containment. A plain box, not a signal: the layer walk reads it in the
// same settle that mounts the backdrop, before a signal write would commit.
backdropRef: { current: HTMLDivElement | null }
}

// The `null` default keeps the root's parent-lookup non-throwing (depth
// derives from an optional read; a default-less context throws on it); the
// wrapper below restores the loud error for parts, naming the component.
// A `null` default: a default-less context throws on the root's optional
// parent lookup; the wrapper restores the loud error for parts.
export const DialogContext: Context<DialogContextValue | null> =
createContext<DialogContextValue | null>(null)

Expand Down
85 changes: 32 additions & 53 deletions packages/solid/dialog/src/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,13 @@ import { mergeProps, normalize } from '@dunky.dev/solid-state-machine'
import { DialogContext, useDialogContext } from './context'
import { useDialog } from './use-dialog'

// A part's bindings merge INSIDE the JSX spread: the compiler wraps the
// expression in a reactive scope, so a machine transition re-translates it.
// `children` never rides that spread — a re-evaluated spread re-CREATES the
// children it carries, and a child whose lifecycle writes to the machine
// (Title's presence) would then loop machine -> spread -> remount -> machine.
// Every part strips it and renders `{props.children}` explicitly.

// A consumer ref that crossed a component boundary is a setter function or an
// array of them (Solid 2.0 refs are functions; arrays compose); apply it
// alongside the part's own element capture.
// Bindings merge inside the JSX spread so they stay reactive. `children` must
// never ride that spread: a re-evaluated spread re-creates the children, and a
// child that writes to the machine on mount (Title) would loop forever. Every
// part omits it and renders `{props.children}` explicitly.

// A consumer ref that crossed a component boundary is a function or an array
// of functions.
function applyConsumerRef<T>(ref: Ref<T> | undefined, element: T): void {
if (typeof ref === 'function') (ref as (element: T) => void)(element)
else if (Array.isArray(ref)) for (const entry of ref) applyConsumerRef(entry as Ref<T>, element)
Expand All @@ -56,11 +53,8 @@ export const Dialog: Component<DialogProps> & Parts = props => {
const backdropRef: { current: HTMLDivElement | null } = { current: null }

// closeOnBack: while open, a guard entry in the session history turns the
// host's Back into a dismissal instead of a navigation. Every decision
// (gate, veto, controlled) lives in the core's backNavigate; this effect
// only wires the web mechanics. It tracks `api.open` alone — fresh callback
// identities never churn real session-history entries. It lives on the
// root — the guard concerns the dialog's openness, not any rendered part.
// browser's Back into a dismissal. The decision (gate, veto, controlled)
// lives in the core's backNavigate; this only wires the web mechanics.
createEffect(
() => api.open,
open => {
Expand Down Expand Up @@ -111,8 +105,8 @@ export const Portal: Component<DialogPortalProps> = props => {
const context = useDialogContext()
if (isServer) return null
return (
// `mounted`, not `open`: an animated dialog stays in the tree through
// `closing` so its exit visual can play before everything unmounts.
// `mounted`, not `open`: an animated dialog stays mounted through
// `closing` so its exit visual can play.
<Show when={context.api.mounted}>
{/* keyed: the host portal's mount is fixed at creation, so a container
swap re-creates the portal on the new target. */}
Expand Down Expand Up @@ -140,8 +134,6 @@ export interface DialogBackdropProps extends ComponentProps<'div'> {}
export const Backdrop: Component<DialogBackdropProps> = props => {
const { api, machine, backdropRef } = useDialogContext()
const rest = omit(props, 'ref', 'children')
// The shared slot must not outlive the element: the context box lives on
// the root, the element only until this part's owner disposes.
onSettled(() => () => (backdropRef.current = null))

const bindings = (): Record<string, unknown> => {
Expand Down Expand Up @@ -189,9 +181,8 @@ export const Viewport: Component<DialogViewportProps> = props => {
} & Record<string, unknown>
return {
...attrs,
// Content presses bubble up here — only a press that started on the
// viewport itself is an outside interaction, and only the topmost dialog
// of a stack answers it.
// 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
Expand All @@ -210,8 +201,7 @@ export const Viewport: Component<DialogViewportProps> = props => {

export interface DialogContentProps extends ComponentProps<'div'> {
/** The element to focus when the dialog opens — an element, or an accessor
* resolved at open time (the Solid idiom for a ref variable that fills
* during render). @default the dialog window */
* resolved at open time. @default the dialog window */
initialFocus?: HTMLElement | (() => HTMLElement | null | undefined)
}

Expand All @@ -223,14 +213,11 @@ export const Content: Component<DialogContentProps> = props => {
const rest = omit(props, 'ref', 'initialFocus', 'children')
let contentEl: HTMLDivElement | undefined

// The machine's `open` state is the edge, not mount/unmount — an animated
// dialog stays mounted through `closing`, and the stack, containment, and
// focus must release the moment the exit starts, not when it finishes.
// One effect keeps the ordering right both ways: the stack joins before focus
// moves in, and on close it must release the layers beneath (un-inert them)
// before focus can move back out to one of them. Apply-phase reads go
// through untrack — `api.open` is the one edge; the options must not re-run
// the effect.
// 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 => {
Expand All @@ -246,13 +233,12 @@ export const Content: Component<DialogContentProps> = props => {
backdrop: () => backdropRef.current,
})

// preventScroll everywhere: the scroll lock already froze the surface, so
// moving focus must not scroll it — otherwise opening jumps the (top-of-
// container) dialog into view and closing jumps back to the trigger.
// 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 (disabled, hidden) falls back to the panel.
// A target that can't take focus falls back to the panel.
if (document.activeElement !== target) content.focus({ preventScroll: true })

return () => {
Expand All @@ -262,10 +248,9 @@ export const Content: Component<DialogContentProps> = props => {
},
)

// The exit window: Content live while not open only happens in `closing`.
// The layer has already released everything above, so hide the still-painting
// layer from interaction and report when its visual is done; the cleanup is
// the reopen interrupt (and final unmount) undoing both.
// 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 => {
Expand All @@ -284,24 +269,19 @@ export const Content: Component<DialogContentProps> = props => {
)

// The lock spans the whole mount — through `closing` too: releasing it
// mid-exit would bring the scrollbar back and reflow the page under the
// still-painting layer. A scoped dialog locks its portal container; a page
// dialog locks the body.
// 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 — a nested dialog
// owns focus while open.
// Only a modal dialog traps, and only while topmost.
enabled: () => machine.context.modal && isTopmostLayer(machine.context.id),
// The Close part is the cycle's last stop wherever it renders (core
// SPEC); found by its derived id.
// 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 <dialog>: the window is the initial
// focus target, so it carries tabindex — which HTML forbids on <dialog> —
// and the native element only pays off via showModal(), which this contract
// deliberately doesn't use.
// A neutral element with the role, not <dialog>: the window carries
// tabindex (forbidden on <dialog>), and this contract doesn't use
// showModal() — see SPEC.md.
return (
<div
{...mergeProps<DialogContentProps>(rest, normalize(api.parts.content))}
Expand All @@ -325,8 +305,7 @@ export const Title: Component<DialogTitleProps> = props => {
const { api, machine } = useDialogContext()
const rest = omit(props, 'children')

// Presence reports from the settled phase: the machine starts on the root's
// settle, which owner order puts before this one.
// 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 })
Expand Down
9 changes: 3 additions & 6 deletions packages/solid/dialog/src/effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,14 @@ import type { DialogMachine, DialogOptions } from '@dunky.dev/dialog'
import { dialogEffects } from '@dunky.dev/dialog'
import { isTopmostLayer } from '@dunky.dev/dom-overlay'

// Substrate effects: the core's substrate-free list (the controlled-open
// echo) plus the document-level work only this host can own.
type DialogEffect = ComponentEffect<DialogMachine, DialogOptions>

// Escape is a document-level concern, not a part's — it must work wherever
// focus is.
// 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 — a nested stack closes one
// layer at a time.
// 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' })
Expand All @@ -25,4 +21,5 @@ const trackEscape: DialogEffect = [
['onEscapeKeyDown'],
]

// The core's substrate-free effects plus the document-level work of this host.
export const solidDialogEffects: DialogEffect[] = [...dialogEffects, trackEscape]
5 changes: 2 additions & 3 deletions packages/solid/dialog/src/use-dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,8 @@ import { solidDialogEffects } from './effects'

export function useDialog(options: DialogOptions): { api: DialogApi; machine: DialogMachine } {
const id = createUniqueId()
// `?? id` (via a live getter, not merge order): an explicit `id={undefined}`
// must not knock out the generated fallback — ids also key the dialog stack,
// so they must exist. `merge` keeps the rest of the options a reactive proxy.
// `?? 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
Expand Down
4 changes: 2 additions & 2 deletions packages/solid/dialog/tests/dialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ const DefaultDialog = (props: DialogProps) => (
</Dialog>
)

// Solid 2.0 defers store commits + DOM updates to the microtask queue — every
// interaction flushes before the test reads the tree.
// 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()
Expand Down
10 changes: 4 additions & 6 deletions packages/solid/dialog/tsdown.config.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import { babel } from '@rollup/plugin-babel'
import { defineConfig } from 'tsdown'

// Solid JSX needs Solid's own compiler: babel-preset-solid turns JSX into
// reactive templates + effects, which neither oxc nor rolldown's React-shaped
// JSX transform can produce. The plugin transforms .tsx before rolldown's own
// transform sees it; everything else (entry, dts, publint) inherits the root
// config. Babel applies presets last-to-first: TypeScript strips types while
// keeping the JSX (isTSX), then the Solid preset compiles it.
// 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({
Expand Down
14 changes: 6 additions & 8 deletions packages/solid/hooks/use-focus-trap/src/use-focus-trap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,17 @@ export interface UseFocusTrapOptions extends TrapFocusOptions {}

/**
* Traps Tab / Shift+Tab within `target` while it holds an element — the Solid
* lifecycle around `trapFocus`. The trap follows the accessor: it arms when
* the target first yields an element, releases when it clears or the owner is
* disposed, and re-arms on a new element when the accessor is reactive.
* 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 (re-arm on a new element); the apply
// re-reads it fresh — compute runs eagerly at creation, before a plain ref
// variable fills, so binding off the computed value would arm on nothing.
// Options are read through the closure on each Tab press, so inline
// `enabled` / `last` see the latest state without re-binding the listener.
// 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(),
() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ 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 — a direct
// `props.enabled` here would be a top-level reactive read.
// The closure defers the props read to each Tab press.
useFocusTrap(() => target ?? null, { enabled: () => props.enabled?.() !== false })
return (
<div ref={el => (target = el)} tabindex={-1} data-testid='container'>
Expand Down
9 changes: 3 additions & 6 deletions packages/solid/hooks/use-scroll-lock/src/use-scroll-lock.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { createEffect } from 'solid-js'
import { lockScroll } from '@dunky.dev/dom-scroll-lock'

/** A static value or an accessor — the Solid idiom for a parameter that may
* be reactive; resolved fresh inside the tracking scope that reads it. */
/** A static value or an accessor — for parameters that may be reactive. */
export type MaybeAccessor<T> = T | (() => T)

function access<T>(value: MaybeAccessor<T>): T {
Expand All @@ -11,10 +10,8 @@ function access<T>(value: MaybeAccessor<T>): T {

/**
* Locks scrolling while the owner lives and `locked` — the Solid lifecycle
* around `lockScroll`. Targets the page body unless a `target` element is
* given (e.g. a scoped/portaled surface locks its own container, not the
* page). The lock is shared per container: with several holders (e.g. nested
* modal layers), the container is restored only when the last one releases.
* 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<boolean> = true,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
// @vitest-environment jsdom
// The Solid lifecycle around @dunky.dev/dom-scroll-lock — the refcount/restore
// behavior itself is covered in the util's own tests.
import { renderHook } from '@solidjs/testing-library'
import { describe, expect, it } from 'vitest'
import { useScrollLock } from '@dunky.dev/solid-use-scroll-lock'
Expand Down
9 changes: 3 additions & 6 deletions packages/solid/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
{
// The Solid project: JSX is `preserve` + `@solidjs/web` as the import source
// (Solid 2.0 moved the web JSX namespace out of `solid-js`), so these
// packages and their tests see Solid's JSX namespace — not React's, which
// the root sets. The root tsconfig excludes packages/solid; `pnpm typecheck`
// runs this project separately. `paths` is inherited from the root file
// (resolved relative to its location), so no redeclaration.
// 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",
Expand Down
Loading
Loading