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: 8 additions & 0 deletions .changeset/util-package-specs.md
Original file line number Diff line number Diff line change
@@ -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.
78 changes: 78 additions & 0 deletions packages/core/utils/controllable/SPEC.md
Original file line number Diff line number Diff line change
@@ -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<Value>` | The slice: `controlled` (who owns the value right now) and `intent` (the last declared intent, a fresh token per write). |
| `ControlledSync<Value>` | 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<State, Context, Event>()` to pin
the types once (the `setup.as` idiom):

```ts
escape: intent('open', { guard: canEscape, target: 'closed', value: false })

const intend = intent.as<DialogStateName, DialogContext, DialogMachineEvent>()
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. |
54 changes: 54 additions & 0 deletions packages/core/utils/overlay/SPEC.md
Original file line number Diff line number Diff line change
@@ -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<T>()` | A fresh stack — one per host binding, not per primitive. |
| `OverlayLayer` | What every layer carries: `id` and `depth` (1 = top-level). |
| `LayerStack<T>` | `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. |
56 changes: 56 additions & 0 deletions packages/dom/utils/focus-trap/SPEC.md
Original file line number Diff line number Diff line change
@@ -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. |
63 changes: 63 additions & 0 deletions packages/dom/utils/navigation/SPEC.md
Original file line number Diff line number Diff line change
@@ -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. |
Loading
Loading