fix(react-dialog): render Content as a div with the dialog role - #41
Merged
Conversation
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>
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 #40 — the first fix found by applying
ACCESSIBILITY.mdto existing code.The break
Dialog.Contentrendered the native<dialog>element withtabindex="-1". HTML states: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 thedialog(oralertdialog) role instead. The native element would only pay off throughshowModal(), 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 incore/dialog/SPEC.mdInternals). 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 inreact/dialog/SPEC.md, deep-linked to the HTML rule.Consumer-visible, hence a minor changeset:
HTMLDivElement, and...propsacceptComponentProps<'div'>dialog { ... }should target the part or its role<dialog>resets (position: static,border: none) are no longer neededAlso here
mergePropsno longer needs assertions. It infersPropsfrom its first argument, so a literal default widened tostringand neededas constto 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 toposition: static. Nowrelative, so it pins to the window.Verified
137 vitest tests,13 native jest tests,tsc --noEmit,oxlint,oxfmt --check— all pass. The test that assertedtagName === 'DIALOG'now asserts the behavior that mattered: the window is a scripted focus target outside the tab order.pnpm knipfails, identically on a clean tree — pre-existing, aboutpackages/native/jest.config.cjs.Not in this PR
Two further findings from the same audit, both in the focus trap:
FOCUSABLE_SELECTORomitsiframe/object/embed/summary(embedded content becomes keyboard-unreachable, WCAG 2.1.1), and it matchesdisplay:nonedescendants (focus lands nowhere). Separate PR — they're a behavior change to a shared DOM util, not a conformance fix.🤖 Generated with Claude Code