Skip to content

feat: Dialog — the first primitive on the state-machine track - #4

Merged
ivanbanov merged 20 commits into
feat/scaffold-scriptfrom
feat/dialog
Jul 18, 2026
Merged

feat: Dialog — the first primitive on the state-machine track#4
ivanbanov merged 20 commits into
feat/scaffold-scriptfrom
feat/dialog

Conversation

@ivanbanov

@ivanbanov ivanbanov commented Jul 17, 2026

Copy link
Copy Markdown
Member

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:

Package Where What
@dunky.dev/dialog packages/core/dialog Agnostic behavior: open/close machine, gated dismissal (escape, interact.outside), part-presence tracking, connect with per-part logical bindings + onOpenChange reaction
@dunky.dev/react-dialog packages/react/dialog Compound component: Dialog + Trigger/Portal/Backdrop/Viewport/Content/Title/Description/Close
@dunky.dev/dom-focus-trap packages/dom/utils/focus-trap Framework-free Tab containment (trapFocus)
@dunky.dev/dom-scroll-lock packages/dom/utils/scroll-lock Framework-free, reference-counted scroll lock (lockScroll, any container — body by default)
@dunky.dev/dom-bindings packages/dom/utils/bindings The logical-bindings vocabulary + toDomProps, its DOM translation
@dunky.dev/merge-props packages/shared/utils/merge-props Consumer/behavior prop merging with handler chaining
@dunky.dev/react-use-focus-trap packages/react/hooks/use-focus-trap Thin React lifecycle over the util
@dunky.dev/react-use-scroll-lock packages/react/hooks/use-scroll-lock Thin React lifecycle over the util

The dom/utils + <substrate>/hooks areas are new: DOM logic lives once, each substrate only writes wrappers — a future vue/solid substrate reuses the utils as-is. ARCHITECTURE.md / AGENTS.md encode the areas and their dependency rules.

Behavior highlights

  • WAI-ARIA APG modal dialog: aria-modal, aria-labelledby/aria-describedby wired only to rendered parts, focus moves in on open (first form field / initialFocus / the panel) and restores on close, Tab trap while modal.
  • Configurable dismissal with per-occurrence vetoes (onEscapeKeyDown, onInteractOutside); alertdialog role flips the outside-press default.
  • Nested dialogs: per-layer independence, topmost-only Escape/outside-press/trap, aria-hidden + inert containment of everything beneath, scroll lock held until the last modal layer closes.
  • Native <dialog> element without showModal() — the behavior contract keeps authority over modality (documented as an adopted position in the SPEC).
  • Headless: parts carry behavior + 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

ivanbanov and others added 4 commits July 17, 2026 19:47
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>
ivanbanov and others added 16 commits July 17, 2026 22:52
@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>
@ivanbanov
ivanbanov merged commit 1817598 into feat/scaffold-script Jul 18, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant