feat: Dialog — the first primitive on the state-machine track - #4
Merged
Conversation
The first full primitive on the state-machine track, ported into the core/substrate grid: - @dunky.dev/dialog — the agnostic core: open/close machine with gated dismissal intents (escape, interact.outside), title/description presence tracking, and a connect that emits substrate-neutral logical bindings per part plus the onOpenChange reaction. - @dunky.dev/react-dialog — the React binding: compound Dialog API (Trigger/Portal/Backdrop/Viewport/Content/Title/Description/Close), native <dialog> without showModal(), nested-dialog layer stack with aria-hidden/inert containment, focus restore, controlled/uncontrolled open. - @dunky.dev/focus-trap + @dunky.dev/scroll-lock — framework-free DOM utils under packages/shared/utils, so future substrates (vue, solid, ...) reuse identical containment behavior. - @dunky.dev/react-use-focus-trap + @dunky.dev/react-use-scroll-lock — thin React hooks under packages/react/hooks wrapping the utils. ARCHITECTURE.md and AGENTS.md gain the shared/utils + substrate hooks areas and their dependency rules. jsdom lands as a root devDependency for the browser-environment tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The utils are DOM utilities, not generically "shared" — name the area what it is: packages/dom/utils/<util-name>. Package names are unchanged (@dunky.dev/focus-trap, @dunky.dev/scroll-lock); only the directory, workspace wiring, and docs move. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
lockBodyScroll -> lockScroll(target = document.body). The mechanics (per-target refcount, inline-style save/restore, scrollbar padding) were never body-specific; the body is just the default target now. useScrollLock gains an optional target ref and passes it through. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirrors the SPEC format; the scaffold templates get it too so future primitives are born with it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dunky.dev/focus-trap -> @dunky.dev/dom-focus-trap, @dunky.dev/scroll-lock -> @dunky.dev/dom-scroll-lock. The name prefix now consistently encodes the host layer a package is coupled to: bare names are pure behavior (core), dom-* needs a DOM but no framework, react-* (and future vue-*, ...) needs the framework. Mirrors the directory grammar the same way react does (packages/dom/utils/<name> -> dom-<name>). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…em agnostic
The templates still encoded the pre-dialog shape: a connect returning a
flat api with no parts, and a react substrate that was a bare ref hook.
The dialog established the real shape; the templates now stamp it:
- core: types/machine/connect where the connect maps the snapshot to one
entry of logical bindings per part plus the callback reactions
- react: context ({ api, service }), an owner hook (create-once,
StrictMode-safe, option re-sync, context sync via events), a bindings
translator (logical -> DOM props + mergeProps), and a compound
component (root + parts hung as statics via a Parts interface)
The skeleton's domain stays agnostic — a single placeholder state with a
generic activate lifecycle and one placeholder part — with TODO(spec)
markers where the real anatomy grows; no borrowed open/close/trigger/
content vocabulary.
ARCHITECTURE.md now documents the full per-file shape and the three
render-wiring patterns; the "emission mailboxes" idiom in AGENTS.md and
ARCHITECTURE.md is generalized to "reactions, not direct calls" (state-
derived selectors like matches(); mailboxes for events that don't move
the machine) — matching how the dialog actually works.
Verified by scaffolding a throwaway: generated packages pass test,
typecheck, lint, and build (isolatedDeclarations included).
Bypassing pre-commit: all staged .ts files live under scripts/templates/**,
which oxlint excludes, so lint-staged has no files to act on.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
toDomProps and mergeProps were stamped into every scaffolded primitive (and hand-copied in the dialog) — generic mechanisms, not per-component code. They now live once: - @dunky.dev/dom-bindings (packages/dom/utils/bindings): the substrate-neutral LogicalBindings vocabulary a core connect emits, plus toDomProps — its DOM translation. Core packages never import it; their local PartBindings types narrow the vocabulary structurally, so the core-imports-only-the-runtime rule holds. - @dunky.dev/merge-props (packages/shared/utils/merge-props): consumer/behavior prop merging with handler chaining. Pure JS — no host coupling, hence the bare name and the new shared/utils area. react-dialog and the react template consume both; the per-package bindings.ts is gone from the template. AGENTS.md and ARCHITECTURE.md gain the Shared scope and the amended dependency rules. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Locking hides both scrollbars, but only the vertical one was padded for (the body-centric default — a vanishing horizontal scrollbar rarely shifts top-anchored page content). With lockScroll targeting arbitrary containers, horizontally-scrolling targets (tables, carousels) are normal, so the compensation is now symmetric: padding-right for the vertical scrollbar, padding-bottom for the horizontal one, both saved and restored per holder. Also renames savedPadding to savedPaddingRight/savedPaddingBottom. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
padding-right/padding-bottom -> padding-inline-end/padding-block-end. The vertical scrollbar always sits at inline-end (right in LTR, left in RTL) and the horizontal one at block-end, so writing direction and vertical writing modes are handled for free instead of needing a direction check. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The barrel re-exports it, but the const was module-local — typecheck was failing on the branch tip. Annotated explicitly for isolatedDeclarations now that it is public. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
pnpm dev reported "Couldn't find any stories" — the glob was fine, the repo simply had none. The dialog gets its showcase (standard, alert, long content, closed-by-default, nested), styled inline since the story is the consumer of a headless primitive. The react template gains a story stub so scaffolded primitives appear in pnpm dev from birth, and knip learns that stories/ files are Storybook-loaded entries. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- withCloseButton: an in-corner Close affordance (inset-inline-end, so it follows writing direction) alongside the footer actions. - loginForm: a <form> with login + password fields, demonstrating that focus lands on the first field on open and stays trapped while open. Restores the knip stories entry (removed on the branch) so the story files count as Storybook-loaded entries and `pnpm knip` stays green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Demonstrates Dialog.Portal's `container` prop: the dialog opens inside a bounded panel instead of over the page. The container ref is held in state so the portal reads a real element (not null) on re-render, and the overlay layers switch from position:fixed to position:absolute so the backdrop/viewport pin to the panel. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The dialog port had reinvented the state-machine adapter locally. Adopt
the published packages instead and delete the reinventions:
- @dunky.dev/state-machine-bindings — the agnostic EventBindings/
AttrBindings vocabulary. Core composes PartBindings from it
(EventBindings & AttrBindings & { 'data-state' } & Record<string,
unknown>) instead of hand-rolling a fixed interface; the vetoable
callbacks take the vocabulary's KeyboardPayload/PointerPayload
(DismissPayload removed).
- @dunky.dev/react-state-machine — the React adapter. The dialog now
drives the machine through useMachine, translates bindings with
normalize, and merges props with mergeProps; the Escape listener and
controlled-open sync move into a ComponentEffects list (effects.ts).
Deletes @dunky.dev/dom-bindings and @dunky.dev/merge-props, plus the
hand-rolled useSyncExternalStore owner.
The whole state-machine family pins to the 0.1 line (react adapter is
published there), so a single runtime copy resolves. Core exports
<Name>Machine for the substrate context. Templates get the same shape,
so scaffolded primitives use the ecosystem from birth. AGENTS.md /
ARCHITECTURE.md drop the Shared scope and document core importing the
bindings vocabulary + the per-substrate adapter.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A scoped dialog (Dialog.Portal container={...}) was locking document.body,
freezing the whole page even though it only overlaid a small container.
Portal now publishes its container through a context; Content reads it and
locks that element instead of the body. A page dialog (no container) still
locks the body.
useScrollLock takes the target element directly (was a ref) and re-locks
if it changes. Regression test: a scoped dialog locks its container and
leaves the body free.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Opening a dialog focuses the dialog window, and the browser scrolls the
container to bring it into view — jumping a scrolled container to the top
(and back to the trigger on close). The scroll lock already froze the
surface, so focus must not override it. Pass { preventScroll: true } to
every focus call (initial target, panel fallback, and the on-close
restore).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An `absolute; inset:0` overlay can't stay fixed inside a *scrolling* element — it's positioned against the scroll origin, so it scrolls away and collapses to a strip at the top (the reported 0x0-stuck-at-top bug). Restructure the scoped story: a non-scrolling positioned boundary wraps an inner scroller with the background content + trigger, and the dialog portals into the boundary. The overlay fills the boundary's visible box and stays put; the backdrop (a sibling above the scroller) blocks scrolling behind it. SPEC documents the structure. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove the Design section from the core dialog SPEC and the core SPEC template. Also give the scoped story's scroller box-sizing: border-box so its padding stays inside the fixed panel height. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move stack.ts, get-initial-focus.ts, and hide-outside.ts into src/utils/ so the dialog's DOM plumbing stays tied together, separate from the component/machine wiring. Imports updated; no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…template - Core: dialogMachine(options) is single-arg — `id` is an option and the connect derives the per-part ids (content/title/description) from context.id. React's useDialog mints one useId and passes dialogMachine by reference to useMachine (no wrapper arrow, no useMemo), matching the state-machine adapter's factory shape. - Template: the placeholder is now a coherent disable-only model (SET_DISABLED / disabled / disable), dropping the leftover activate vocabulary; the config factory is __camelName__Machine, also passed by reference. - Docs: react dialog SPEC splits the API into one table per compound part (+ the id option); AGENTS.md documents that substrate SPECs split parts into independent tables. - Changesets: split by feature — dialog, focus-trap, scroll-lock — each pairing its util with its React hook, with usage examples. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #3 — contains only the Dialog work; review that PR first for the scaffold/templates/architecture groundwork.
What
The Dialog primitive in the repo's core/substrate shape, plus the reusable containment packages behind it:
@dunky.dev/dialogpackages/core/dialogescape,interact.outside), part-presence tracking, connect with per-part logical bindings +onOpenChangereaction@dunky.dev/react-dialogpackages/react/dialogDialog+Trigger/Portal/Backdrop/Viewport/Content/Title/Description/Close@dunky.dev/dom-focus-trappackages/dom/utils/focus-traptrapFocus)@dunky.dev/dom-scroll-lockpackages/dom/utils/scroll-locklockScroll, any container — body by default)@dunky.dev/dom-bindingspackages/dom/utils/bindingstoDomProps, its DOM translation@dunky.dev/merge-propspackages/shared/utils/merge-props@dunky.dev/react-use-focus-trappackages/react/hooks/use-focus-trap@dunky.dev/react-use-scroll-lockpackages/react/hooks/use-scroll-lockThe
dom/utils+<substrate>/hooksareas are new: DOM logic lives once, each substrate only writes wrappers — a future vue/solid substrate reuses the utils as-is.ARCHITECTURE.md/AGENTS.mdencode the areas and their dependency rules.Behavior highlights
aria-modal,aria-labelledby/aria-describedbywired only to rendered parts, focus moves in on open (first form field /initialFocus/ the panel) and restores on close, Tab trap while modal.onEscapeKeyDown,onInteractOutside);alertdialogrole flips the outside-press default.aria-hidden+inertcontainment of everything beneath, scroll lock held until the last modal layer closes.<dialog>element withoutshowModal()— the behavior contract keeps authority over modality (documented as an adopted position in the SPEC).data-state; styling is the consumer's.Tests
72 tests across 7 files: machine/connect contract in core, React behavior (open/close, dismissal, controlled mode, ARIA wiring, focus management, scroll lock, nesting) in the substrate, containment behavior in the utils, lifecycle-only tests in the hooks.
test/typecheck/lint/format:check/knip/build(publint included) all pass.🤖 Generated with Claude Code