Skip to content

fix(dialog): non-modal coexistence, drag-safe outside presses, accessible-name warning - #54

Open
ivanbanov wants to merge 2 commits into
mainfrom
fix/dialog-outside-press-and-a11y
Open

fix(dialog): non-modal coexistence, drag-safe outside presses, accessible-name warning#54
ivanbanov wants to merge 2 commits into
mainfrom
fix/dialog-outside-press-and-a11y

Conversation

@ivanbanov

Copy link
Copy Markdown
Member

Three dialog fixes from a behavior audit against the SPECs. Each decision lives once in @dunky.dev/dom-dialog; the React and Solid bindings only plumb refs and lifecycles.

1 · A non-modal dialog no longer blocks the page

Dialog.Viewport is a full-coverage layer, so even with no backdrop rendered it swallowed every press aimed at the page beneath — the page element never received it, and the dialog dismissed instead, against the core contract that a non-modal dialog coexists with the page. While modal={false} the Viewport now renders pointer-events: none and the window pointer-events: auto (the consumer's own style wins over both). Detection moves with it: a transparent Viewport never receives the press it used to detect, so watchOutsidePress observes at the document level — the only vantage point that sees both the portaled dialog and the page — with the same refusals as the element path: topmost dialog only, the window excepted, and the trigger excepted so its own press stays a plain toggle instead of a close-and-reopen.

2 · A text-selection drag no longer dismisses

A mousedown inside the window with a mouseup outside it makes the browser fire click on their common ancestor — the viewport or backdrop — indistinguishable from a genuine outside press by the click's target alone. Selecting text across the window's edge closed the dialog and lost the form state with it. Where a press began now decides: trackPressOrigin captures each press's origin at pointerdown, before the collapse, and acceptsBackdropPress / acceptsViewportPress take a startedInside argument and refuse a gesture that started inside the window.

3 · A nameless dialog warns

The core contract — a rendered Title, or aria-label/aria-labelledby on Content, never neither (APG: dialogs require an accessible name) — was documented but unenforced. openDialogLayer now checks the window's own attributes on open and warns with the fix, the same loud treatment the stranded-focus miss already gets. The check reads the DOM rather than the machine's Title-presence flag (a label may arrive through a prop spread), defers a macrotask so a rendered Title's registration commits first, and is cancelled by the close.

Cross-scope

  • SPECs: the behavior contract (drag rule, trigger exception, non-modal coexistence) went to the core SPEC; mechanics to dom-dialog; part defaults to both substrate SPECs. closeOnInteractOutside descriptions updated to match the new mechanism.
  • Stories: the containment story's nested non-modal panel gains correct light-dismiss; its copy now says so, mirrored in both substrates.
  • Tests: 27 new — drag scenarios (viewport, backdrop, document), the non-modal suite (fall-through styles, document dismiss, trigger single-toggle), trackPressOrigin / watchOutsidePress units, and the name-warning cases. Two test-infra bugs fixed along the way: the name-check timer leaked past close (now cleared in the disposer), and both substrate test files accumulated console.warn spy history across tests (now restoreAllMocks per test, matching the dom file's convention).
  • Native: untouched — it already satisfies the drag rule structurally (a Pressable's press must start and end on it) and solved the fall-through with pointerEvents="box-none".
  • Changesets: one per story — dom-dialog minor ×2 + patch, react/solid-dialog patch.

Gate: 308 tests across 16 files, typecheck (base + solid), oxlint, oxfmt — all clean.

🤖 Generated with Claude Code

ivanbanov and others added 2 commits August 27, 2026 19:12
…arning

Three dialog fixes, decided once in dom-dialog and inherited by both DOM
substrates:

- A non-modal Viewport no longer swallows presses aimed at the page: it
  renders pointer-events: none (the window stays auto), and outside
  presses move to a document-level watch — topmost-only, window and
  trigger excepted — since a transparent Viewport can't detect them
  itself.
- A text-selection drag no longer dismisses: trackPressOrigin captures
  where a press began at pointerdown, before the browser collapses the
  click target to the common ancestor, and the press gates refuse a
  gesture that started inside the window.
- A dialog that resolves no accessible name warns on open — the core
  contract (Title, or aria-label/aria-labelledby, never neither) was
  documented but unenforced. Deferred a macrotask so a rendered Title's
  registration commits first; cancelled by close.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ss parts

The drag guard and the document-level outside-press watch were born in
dom-dialog but nothing about them is dialog's: every light-dismissable
layer needs both. Pre-positioned where the next primitive will look —

- trackPressOrigin -> @dunky.dev/dom-press-origin, a new util. It imports
  nothing; the origin answer feeds any outside-press decision.
- watchOutsidePress -> @dunky.dev/dom-overlay, next to the stack it
  queries (topmost-only is its gating), with layer-generic vocabulary
  (element, not content).

viewportPointerEvents / contentPointerEvents stay in dom-dialog: they are
values for dialog parts. The substrates now import the utils directly, per
the boundary; dom-dialog re-exports nothing it doesn't own.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ivanbanov

Copy link
Copy Markdown
Member Author

Follow-up commit: the reusable parts moved to their long-term homes before review — trackPressOrigin is now its own util (@dunky.dev/dom-press-origin, new package: every light-dismissable layer needs the drag guard, and it imports nothing), and watchOutsidePress moved into @dunky.dev/dom-overlay, next to the layer stack it queries, with layer-generic vocabulary (element, not content). viewportPointerEvents/contentPointerEvents stay in dom-dialog — they are values for dialog parts. Substrates import the utils directly per the boundary; changesets and SPECs updated (new package SPEC included). Gates re-run: 308 tests across 18 files, typecheck, lint, format, and pnpm build (publint) all green.

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