Skip to content

fix(react-dialog): render Content as a div with the dialog role - #41

Merged
ivanbanov merged 2 commits into
docs/accessibilityfrom
fix/dialog-content-role
Aug 17, 2026
Merged

fix(react-dialog): render Content as a div with the dialog role#41
ivanbanov merged 2 commits into
docs/accessibilityfrom
fix/dialog-content-role

Conversation

@ivanbanov

Copy link
Copy Markdown
Member

Stacked on #40 — the first fix found by applying ACCESSIBILITY.md to existing code.

The break

Dialog.Content rendered the native <dialog> element with tabindex="-1". HTML states:

The tabindex attribute must not be specified on dialog elements.

The tabindex isn't optional: the dialog window is the initial focus target — focusable in script, out of the tab order — which is the APG behavior the core spec commits to. So the element and the contract were in direct conflict.

The fix

Render a <div> carrying the dialog (or alertdialog) role instead. The native element would only pay off through showModal(), and the core contract deliberately keeps modality, dismissal, and focus in the machine rather than splitting authority with the browser's built-in behavior (already recorded in core/dialog/SPEC.md Internals). With the role explicit and the element neutral, <dialog> brought nothing but a conformance violation.

Exposed semantics are identical: same role, aria-modal, accessible name, description, and focus behavior. The reasoning now lives in react/dialog/SPEC.md, deep-linked to the HTML rule.

Consumer-visible, hence a minor changeset:

  • a forwarded ref is an HTMLDivElement, and ...props accept ComponentProps<'div'>
  • styles targeting dialog { ... } should target the part or its role
  • UA <dialog> resets (position: static, border: none) are no longer needed

Also here

mergeProps no longer needs assertions. It infers Props from its first argument, so a literal default widened to string and needed as const to survive the JSX spread, while every part cast its props in and its result back out. Passing the type argument explicitly (mergeProps<DialogTriggerProps>(...)) keeps the literal narrow — 15 assertions removed across the react and native bindings, no behavior change, and each part now states its props type where the merge happens.

A latent story bug. The corner × was absolutely positioned against the fixed viewport rather than the dialog, because the story reset Content to position: static. Now relative, so it pins to the window.

Verified

137 vitest tests, 13 native jest tests, tsc --noEmit, oxlint, oxfmt --check — all pass. The test that asserted tagName === 'DIALOG' now asserts the behavior that mattered: the window is a scripted focus target outside the tab order.

pnpm knip fails, identically on a clean tree — pre-existing, about packages/native/jest.config.cjs.

Not in this PR

Two further findings from the same audit, both in the focus trap: FOCUSABLE_SELECTOR omits iframe/object/embed/summary (embedded content becomes keyboard-unreachable, WCAG 2.1.1), and it matches display:none descendants (focus lands nowhere). Separate PR — they're a behavior change to a shared DOM util, not a conformance fix.

🤖 Generated with Claude Code

ivanbanov and others added 2 commits August 17, 2026 14:26
HTML states the tabindex attribute must not be specified on dialog
elements, and the dialog window is the initial focus target — focusable
in script, out of the tab order — so it carries tabindex="-1".

The native element would only pay off through showModal(), which this
contract deliberately avoids so modality, dismissal, and focus stay in
the core machine. That left the element with nothing to offer and one
conformance rule to break. Exposed semantics are unchanged: same role,
aria-modal, name, description, focus behavior.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
mergeProps infers Props from its first argument, so a literal default
widened to string and needed `as const` to survive the JSX spread, and
every part cast its props in and its result back out.

Passing the type argument explicitly keeps the literal narrow and makes
each part declare its own props type at the merge: 15 assertions gone
across the react and native bindings, no behavior change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ivanbanov
ivanbanov merged commit a4001ea into main Aug 17, 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