diff --git a/.changeset/util-package-specs.md b/.changeset/util-package-specs.md new file mode 100644 index 0000000..f75d919 --- /dev/null +++ b/.changeset/util-package-specs.md @@ -0,0 +1,8 @@ +--- +--- + +Add `SPEC.md` to the eight util and hook packages that lacked one — the two +core utils (`controllable`, `overlay`), the four DOM utils (`focus-trap`, +`navigation`, `overlay`, `scroll-lock`), and the two React hooks +(`use-focus-trap`, `use-scroll-lock`). Docs only; no package changes, so this +changeset carries no version bump. diff --git a/packages/core/utils/controllable/SPEC.md b/packages/core/utils/controllable/SPEC.md new file mode 100644 index 0000000..64d9a10 --- /dev/null +++ b/packages/core/utils/controllable/SPEC.md @@ -0,0 +1,78 @@ +# SPEC / Controllable + +## Reference + +- **Prior art**: the controlled/uncontrolled duality of native form elements + (`value` / `defaultValue`), as adopted across Radix, Base UI, and Ark. +- **State machine**: helpers for `@dunky.dev/state-machine` machines — this + package expands into transitions; it is not a machine of its own. + +## Overview + +The controlled contract, written once: the machinery a primitive uses so a +consumer can own one of its values from outside (a dialog's `open`, a future +popover's, a tooltip's). Every primitive with an ownable value declares its +intents through this package, so "controlled" means exactly the same thing +across the codebase — and both modes share one transition table and one set +of guards. + +## Behavior + +The contract it encodes: + +- **Uncontrolled**, an intent event (close, escape, trigger press) takes its + transition directly — the machine owns the value. +- **Controlled**, the machine never moves on its own. An intent only records + itself; the substrate echoes the consumer's prop as `controlled.sync`, and + a matching echo is the only thing that transitions the machine. The + consumer vetoes by ignoring the intent. +- A **behavior gate** (e.g. `closeOnEscape`) applies in both modes — who owns + the value never changes whether an intent is allowed. +- **Controlled-ness follows the prop live**: an `undefined` echo hands the + value back to the machine right where it stands; a value takes control. + Every echo — matching, opposite, or `undefined` — re-derives who owns the + value. +- Every declared intent lands in the **`intent` slot** as a fresh token, so a + reaction on the slot fires even when the same intent repeats — the request + channel for machines that expose one (e.g. the dialog's stack-scoped + close). +- A change callback binds to the **state**, not to intents: it fires exactly + when the value actually changes, never for an intent that changed nothing. + +## API + +| Export | Description | +| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `controllable(value)` | Seeds a `Controllable` context slice from the consumer's option — `undefined` means uncontrolled. | +| `Controllable` | The slice: `controlled` (who owns the value right now) and `intent` (the last declared intent, a fresh token per write). | +| `ControlledSync` | The echo event a substrate sends on prop change; `undefined` = the prop is gone. | +| `intent(key, { guard?, target, value })` | Expands an intent event into its controlled/uncontrolled candidates — first guard wins. | +| `syncControlled(key, { value, target })` | The full `controlled.sync` handling for one state: a matching echo moves to `target`; every echo re-derives controlled-ness. | + +Both helpers infer their generics from a typed `guard`; an unguarded call has +nothing to infer from, so each carries `.as()` to pin +the types once (the `setup.as` idiom): + +```ts +escape: intent('open', { guard: canEscape, target: 'closed', value: false }) + +const intend = intent.as() +close: intend('open', { target: 'closed', value: false }) +``` + +## Constraints + +- A controlled machine transitions only on `controlled.sync` — never on an + intent, whatever the intent's source. +- The `intent` slot drives no callback and takes no transition of its own; + it is a record, written fresh on every declaration. +- Dismissal decisions stay at their source — the event-level callbacks and + the consumer's own handlers — not in this package. + +## Internals + +| Position | Why | +| --------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| An intent expands to two candidates, first guard wins: controlled writes only `intent`, uncontrolled also transitions | Both modes share one transition table and one set of guards — the modes can't drift apart. | +| `intent` is written as a fresh token even for a repeated value | A mailbox reaction must fire on every declaration, not only on value change. | +| `.as()` is type-level only — the same implementation with generics pinned | Inference needs a typed guard; pinning keeps unguarded call sites just as safe with no runtime cost. | diff --git a/packages/core/utils/overlay/SPEC.md b/packages/core/utils/overlay/SPEC.md new file mode 100644 index 0000000..49a17bb --- /dev/null +++ b/packages/core/utils/overlay/SPEC.md @@ -0,0 +1,54 @@ +# SPEC / Overlay + +## Reference + +- **W3C grounding**: [WAI-ARIA 1.2 `aria-modal`](https://www.w3.org/TR/wai-aria-1.2/#aria-modal) + — the modal window is the only content exposed to the user, which is what + makes "who is topmost" a question every overlay must answer the same way. +- **Prior art**: the dismissable-layer stacks in Radix and Zag. + +## Overview + +The agnostic half of overlay coordination. The overlay family — dialog, +drawer, popover, menu, combobox — shares one problem: when overlays stack, +which layer is topmost? The topmost owns Escape, the focus trap, and (when +modal) assistive-tech containment. This package is the registry and the +topmost decision, with no host assumptions — it knows nothing about how a +layer is drawn or how containment is applied. A host realization extends +each layer with a payload — the element or view — and applies its own +containment as the stack shifts; +[`@dunky.dev/dom-overlay`](../../../dom/utils/overlay/SPEC.md) is the DOM +one. + +## Behavior + +- **Topmost** is the deepest-nested layer — highest `depth` — with open + order breaking ties at the same depth. Depth, not registration or document + order, decides: a host may insert a nested layer before its parent (React + portals do), inverting document order relative to nesting. +- **One stack per host.** A running app is browser or native, never both, so + each host binding creates a single stack every primitive registers into. + The shared instance is what makes one Escape close exactly one layer, even + across different primitives. +- Registering returns a disposer; an empty stack has no topmost. + +## API + +| Export | Description | +| ----------------------- | ------------------------------------------------------------ | +| `createLayerStack()` | A fresh stack — one per host binding, not per primitive. | +| `OverlayLayer` | What every layer carries: `id` and `depth` (1 = top-level). | +| `LayerStack` | `register(layer)` -> disposer, `topmost()`, `isTopmost(id)`. | + +## Constraints + +- Host-free: no DOM, no framework, no timing assumptions. +- The stack tracks and resolves; it never acts — Escape handling, trapping, + and containment belong to the layers and the host realization. + +## Internals + +| Position | Why | +| --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| `depth` is carried by the layer, not derived by the stack | Only the primitive knows its nesting; the stack has no host to ask, and document order lies under portals. | +| Topmost is a linear scan, not a maintained order | Stacks hold a handful of layers; scanning beats keeping an order coherent across out-of-order removals. | diff --git a/packages/dom/utils/focus-trap/SPEC.md b/packages/dom/utils/focus-trap/SPEC.md new file mode 100644 index 0000000..0868574 --- /dev/null +++ b/packages/dom/utils/focus-trap/SPEC.md @@ -0,0 +1,56 @@ +# SPEC / DOM / Focus trap + +## Reference + +- **W3C pattern**: the [APG modal-dialog keyboard interaction](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/#keyboardinteraction) + Tab/Shift+Tab contract — this package is its DOM mechanics; the policy + (when a trap applies, who traps in a stack) stays with the caller. + +## Overview + +Framework-free Tab / Shift+Tab containment for a DOM subtree. One keydown +listener on a container steps focus through the cycle itself — it does not +merely guard the edges — so a logical order can diverge from DOM order (a +dialog's Close rendered first but cycling last) without native tabbing +breaking the cycle mid-way. Substrate hooks wrap it (e.g. +`@dunky.dev/react-use-focus-trap`) so every framework inherits identical +containment. + +## Behavior + +- Every Tab press moves focus one step through the container's focusables in + DOM order, wrapping at both ends; Shift+Tab steps backward. Focus never + tabs out. +- `last` resolves the cycle's final stop: the element is sorted after + everything else, wherever it renders. +- Off-cycle focus — the container itself, or a scripted `tabindex="-1"` + target — re-enters the cycle at the edge the direction implies: Tab to the + first focusable, Shift+Tab to the last. +- With no focusables inside, Tab is a no-op; focus stays where it is. +- `enabled` and `last` are re-evaluated on every press, so trapping follows + runtime state — e.g. only the topmost layer of a stack traps. +- A focusable is an element matching `FOCUSABLE_SELECTOR` whose `tabIndex` + is not negative. + +## API + +| Export | Description | +| -------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `trapFocus(container, options?)` | Attaches the trap; returns a release that removes the listener. | +| `TrapFocusOptions` | `enabled?: () => boolean` (default: always) and `last?: () => HTMLElement \| null` (default: DOM order decides). | +| `getFocusables(container)` | The container's focusables, in DOM order. | +| `FOCUSABLE_SELECTOR` | The focusability selector, exported for callers with their own scanning. | + +## Constraints + +- While enabled, the trap owns the whole step, not just the wrap: the Tab is + `preventDefault()`-ed and focus is moved by hand. A disabled trap prevents + nothing and lets Tab through. +- Focusability is selector-based, not visibility-probed. + +## Internals + +| Position | Why | +| --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| Focus is stepped manually on every press, not only at the edges | The `last` re-ordering makes the logical cycle diverge from DOM order, so native tabbing can't be trusted mid-cycle. | +| Selector-based focusability, no visibility probing | `offsetParent` is always `null` in jsdom, and the trapped subtree is visible whenever the trap runs. | diff --git a/packages/dom/utils/navigation/SPEC.md b/packages/dom/utils/navigation/SPEC.md new file mode 100644 index 0000000..8044afe --- /dev/null +++ b/packages/dom/utils/navigation/SPEC.md @@ -0,0 +1,63 @@ +# SPEC / DOM / Navigation + +## Overview + +Framework-free browser-navigation helpers. Today that is one: +`interceptBackNavigation`, the web mechanics behind a layer's Back dismissal +(the dialog contract's `closeOnBack`) — a guard entry planted in the session +history so the browser's Back closes an overlaid layer (dialog, drawer, +sheet) instead of leaving the page. + +## Behavior + +- **Arming**: registering plants a guard entry in the session history — or + adopts a still-current entry no live guard owns (see the + release/re-register window below). +- **A Back press pops exactly one entry**, so only the interceptor whose + entry vanished answers; guards beneath see their entry still current and + stay armed. Stacked layers unwind one per press with no cross-layer + bookkeeping. A multi-entry jump (`history.go(-n)`) unwinds every guard the + traversal crossed, topmost first. +- **`onBack` returns whether the layer actually closed.** A decline — + vetoed, or a controlled layer whose consumer hasn't followed — re-arms the + guard entry, so the next Back reaches the same layer again. +- **Release** (the layer closed by any other means) consumes a still-current + guard entry so it can't swallow the next Back. An entry buried under later + in-app navigation is unreachable and left alone — Back then both navigates + and closes the layer. +- **Release then re-register in the same synchronous turn** nets out to zero + traversals: the re-register adopts the entry in place, and the deferred + consumption finds it no longer owned and queues nothing. +- **Self-caused pops** — a release consuming its own entry — report through + the same `popstate` as a user's Back; they are counted and never read as + one. + +### Reload + +The guard entry survives a reload; the layer's open-state doesn't, leaving a +dead same-URL entry the first Back appears to spend on nothing. That is out +of this package's scope by design: on reload only the host knows whether the +layer should reopen. A layer that must survive reload (or be shareable, or +reopen on Forward) keeps its open-state in the URL and derives itself from +it — Back then closes for free and needs no interceptor. + +## API + +| Export | Description | +| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `interceptBackNavigation(onBack)` | Arms a guard; `onBack` fires when the user pops it and returns whether the layer closed. Returns the release for a layer closed by other means. | + +## Constraints + +- One shared registry and one `popstate` listener module-wide — the + one-pop-one-guard ordering is the whole unwinding contract. +- The listener detaches only when nothing is left to hear: no guards and no + in-flight self-caused pop. + +## Internals + +| Position | Why | +| ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| One registry + one listener across every layer | A Back pops one entry; only the guard whose entry vanished may answer — that ordering is what unwinds stacks one press at a time with no cross-layer bookkeeping. | +| Consumption is deferred a microtask | A queued `history.back()` is not reliably delivered once another entry is pushed before it lands; letting a same-turn re-register adopt the entry removes the race instead of compensating for it. | +| Self-caused pops are counted, and re-arm a live guard whose entry they consumed | The browser reports them through the same `popstate` as a user's Back; uncounted, one release would unwind another layer. | diff --git a/packages/dom/utils/overlay/SPEC.md b/packages/dom/utils/overlay/SPEC.md new file mode 100644 index 0000000..6dcfff7 --- /dev/null +++ b/packages/dom/utils/overlay/SPEC.md @@ -0,0 +1,85 @@ +# SPEC / DOM / Overlay + +## Overview + +The DOM realization of [`@dunky.dev/overlay`](../../../core/utils/overlay/SPEC.md): +one shared module owning what every DOM substrate's overlay — dialog, drawer, +popover, menu, combobox — must agree on. Three concerns live here: + +- **The layer stack** — the DOM side of the shared stack, plus assistive-tech + containment as it shifts. +- **Initial focus** — where focus moves when an overlay opens. +- **The exit window** — the cosmetic tail of an animated close. + +Substrate bindings wrap this (e.g. `@dunky.dev/react-dialog`), so overlays +from different substrates on the same page stack, hide, and unwind correctly +against each other. + +## Behavior + +### Stack and containment + +- Every open overlay registers with its element, modality, depth, and — when + it has one — its backdrop. Topmost follows the core stack's rule. +- While a modal layer is topmost, everything outside its subtree is hidden + from assistive tech and taken out of pointer and keyboard reach + (`aria-hidden` + `inert` on the siblings of its ancestor path). The + layer's own backdrop — rendered outside the content's subtree yet part of + the layer — stays pressable so an outside press can still dismiss. +- Containment re-syncs on every stack change: a nested layer hides the one + beneath it, and closing it restores the layer. Restoring removes exactly + what was added — an `aria-hidden` or `inert` the author already set stays + theirs, untouched in both directions. +- The backdrop is resolved through a getter, not a snapshot: a re-hide (a + layer above closing) sees the element current at that moment. + +### Initial focus + +The strict rule is only that focus moves into the overlay: an overlay that +collects input starts at its first form field (input, select, textarea); any +other content keeps focus on the overlay window itself. + +### The exit window + +A closing overlay has already left the stack — the page beneath is live +again — but keeps painting until its exit visual finishes: + +- `hideExitingLayer` takes the still-painting layer (the content's outermost + portalled ancestor below the boundary, plus its backdrop) out of + interaction with `aria-hidden` + `inert`, returning an undo for the reopen + interrupt. +- `watchExitAnimation` reports the end of the exit visual once — the + substrate forwards it to the machine as `exit.complete`. Completion is the + element's own `transitionend` / `animationend` (bubbled ends from + descendants don't count), immediate under `prefers-reduced-motion`, or a + fallback ceiling so a missing exit style can't hang the close. + +## API + +| Export | Description | +| ------------------------------------------------ | ------------------------------------------------------------------------------------ | +| `registerLayer(layer)` | Joins the shared stack and syncs containment; returns the disposer that restores it. | +| `Layer` | `OverlayLayer` + `element`, `modal`, and an optional `backdrop` getter. | +| `isTopmostLayer(id)` | Whether the layer owns Escape and the focus trap right now. | +| `getInitialFocus(content)` | The element to focus on open: first form field, else the overlay window itself. | +| `hideExitingLayer(content, boundary, backdrop?)` | Inerts the still-painting layer for the exit window; returns the undo. | +| `watchExitAnimation(element, onComplete)` | Reports the exit visual's end once; returns the cancel. | + +## Constraints + +- One store for the whole page, even when the module is loaded more than + once — every copy must rendezvous on the same stack, or their topmost + decisions drift apart. +- Containment hides one target at a time, and its undo removes exactly what + it added. +- Content-less tags (`script`, `style`, `link`, `template`) are never + hidden. + +## Internals + +| Position | Why | +| -------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| The store anchors on a realm-global keyed by `Symbol.for`, resolved lazily | A monorepo or micro-frontend can load duplicate copies of this module; separate stores drift apart (the duplicate-singleton bug class of radix-ui/primitives#2815). Lazy keeps `sideEffects: false` honest. | +| Containment re-runs from scratch on every stack change | Undo-then-rehide is idempotent and order-free; incremental patching would have to reason about interleaved opens and closes. | +| Containment sync guards on `element.isConnected` | At teardown the content may already be detached; hiding against a dead node would leak the undo. | +| Completion is the element's own end event, first one wins | A transition ends once per property and descendants bubble theirs; the exit belongs to the element carrying `data-state`, styled to finish as one piece. | diff --git a/packages/dom/utils/scroll-lock/SPEC.md b/packages/dom/utils/scroll-lock/SPEC.md new file mode 100644 index 0000000..c230a86 --- /dev/null +++ b/packages/dom/utils/scroll-lock/SPEC.md @@ -0,0 +1,46 @@ +# SPEC / DOM / Scroll lock + +## Overview + +Framework-free, reference-counted scroll lock for any scroll container — the +page body by default. It is the mechanics behind the overlay scroll contract +(the page behind a modal layer doesn't scroll, and hiding its scrollbar +doesn't shift the layout); substrate hooks wrap it (e.g. +`@dunky.dev/react-use-scroll-lock`) so every framework inherits identical +behavior. + +## Behavior + +- **One shared lock per container.** The first holder saves the target's + inline style state and the last release restores it, so overlapping + holders — nested modal layers — can release in any order. +- **No layout shift.** Locking pads for the vanished scrollbars using + logical properties: the vertical scrollbar always sits at `inline-end` + (right in LTR, left in RTL) and the horizontal one at `block-end`, so + writing direction is handled for free. A zero-width scrollbar (overlay + scrollbars, or none) adds no padding. +- The body's scrollbars live on the viewport and are measured from the + window; any other container owns its scrollbars, measured from its own + boxes (borders excluded). +- A release is idempotent — releasing twice can't double-decrement another + holder's count. + +## API + +| Export | Description | +| --------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `lockScroll(target?)` | Locks `target` (default `document.body`); returns the release. The target restores when its last holder releases. | + +## Constraints + +- Restore returns the target's inline style to exactly what the first + holder saw. +- The registry is shared across duplicate copies of the module on the same + page. + +## Internals + +| Position | Why | +| ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| The registry anchors on a realm-global keyed by `Symbol.for`, resolved lazily | Duplicate module copies (monorepo, micro-frontend) would double-lock or leak the lock — the duplicate-singleton bug class of radix-ui/primitives#2815. Lazy keeps `sideEffects: false` honest. | +| Restore removes an originally-unset longhand instead of assigning `''` | Assigning `''` doesn't clear a longhand in jsdom's CSSOM. | diff --git a/packages/react/hooks/use-focus-trap/SPEC.md b/packages/react/hooks/use-focus-trap/SPEC.md new file mode 100644 index 0000000..b4341a9 --- /dev/null +++ b/packages/react/hooks/use-focus-trap/SPEC.md @@ -0,0 +1,49 @@ +# SPEC / React / useFocusTrap + +The React binding of the +[DOM focus-trap spec](../../../dom/utils/focus-trap/SPEC.md) — the trap +behavior is framework-free; this hook owns only the React lifecycle. + +## Install + +```sh +npm install @dunky.dev/react-use-focus-trap +``` + +## Usage + +```tsx +import { useRef } from 'react' +import { isTopmostLayer } from '@dunky.dev/dom-overlay' +import { useFocusTrap } from '@dunky.dev/react-use-focus-trap' + +function DialogContent({ id }: { id: string }) { + const panelRef = useRef(null) + // `enabled` follows runtime state — here, only the overlay stack's + // topmost layer traps. + useFocusTrap(panelRef, { enabled: () => isTopmostLayer(id) }) + return ... +} +``` + +React-specific notes on top of the DOM contract: + +- Call it from the component that renders the container, so both mount + together: the trap binds once, when the target first exists, and releases + on unmount. A ref that only gains its element on a later render doesn't + re-arm the trap. +- Options are read through a ref, not the effect's closure: inline + `enabled` / `last` closures don't re-bind the listener on every render, + yet each Tab press sees the latest render's values — the per-press + re-evaluation the DOM contract promises. + +## API + +### `useFocusTrap(target, options?)` + +Returns nothing — the trap lives and dies with the component. + +| Param | Type | Default | Description | +| --------- | -------------------------------- | ------- | -------------------------------------------------------------------------------------- | +| `target` | `RefObject` | — | The container to trap Tab / Shift+Tab within. | +| `options` | `UseFocusTrapOptions` | `{}` | The DOM trap's options: `enabled?: () => boolean`, `last?: () => HTMLElement \| null`. | diff --git a/packages/react/hooks/use-scroll-lock/SPEC.md b/packages/react/hooks/use-scroll-lock/SPEC.md new file mode 100644 index 0000000..4cbe1bc --- /dev/null +++ b/packages/react/hooks/use-scroll-lock/SPEC.md @@ -0,0 +1,43 @@ +# SPEC / React / useScrollLock + +The React binding of the +[DOM scroll-lock spec](../../../dom/utils/scroll-lock/SPEC.md) — the lock +behavior is framework-free; this hook owns only the React lifecycle. + +## Install + +```sh +npm install @dunky.dev/react-use-scroll-lock +``` + +## Usage + +```tsx +import { useScrollLock } from '@dunky.dev/react-use-scroll-lock' + +// Rendered while a modal layer is open, e.g. {open && } +function ModalPanel() { + useScrollLock() // the page behind can't scroll while mounted + return
...
+} +``` + +React-specific notes on top of the DOM contract: + +- The lock holds while the component is mounted and `locked` is true; + unmounting or turning `locked` off releases it. 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 mounted lockers (nested modal layers) hold one lock, + and the container restores when the last unmounts. + +## API + +### `useScrollLock(locked?, target?)` + +Returns nothing — the lock lives and dies with the component. + +| Param | Type | Default | Description | +| -------- | --------------------- | ------------- | ------------------------------------------------------------------------------------------- | +| `locked` | `boolean` | `true` | Whether the lock is held. | +| `target` | `HTMLElement \| null` | the page body | The scroll container to lock (e.g. a scoped surface locks its own container, not the page). |