diff --git a/.changeset/content-role-dialog-element.md b/.changeset/content-role-dialog-element.md new file mode 100644 index 0000000..cd5ffd3 --- /dev/null +++ b/.changeset/content-role-dialog-element.md @@ -0,0 +1,7 @@ +--- +'@dunky.dev/react-dialog': minor +--- + +`Dialog.Content` now renders a `
` carrying the `dialog` (or `alertdialog`) role instead of the native `` element. Consumers styling `dialog { ... }` should target the part directly (or its role), and a forwarded ref is now an `HTMLDivElement`; `...props` accept `ComponentProps<'div'>`. + +The dialog window is the initial focus target — focusable in script, out of the tab order — which needs `tabindex="-1"`, and HTML states that [the `tabindex` attribute must not be specified on `dialog` elements](https://html.spec.whatwg.org/multipage/interactive-elements.html#the-dialog-element). The native element would only pay off through `showModal()`, and this contract deliberately keeps modality, dismissal, and focus in the core machine rather than splitting authority with the browser's built-in behavior — so the element brought nothing but a conformance violation. Nothing about the exposed semantics changes: the same role, `aria-modal`, name, description, and focus behavior as before. UA `` resets (`position: static`, `border: none`) are no longer needed in consumer styles. diff --git a/packages/native/dialog/src/dialog.tsx b/packages/native/dialog/src/dialog.tsx index 146a63a..d74dee5 100644 --- a/packages/native/dialog/src/dialog.tsx +++ b/packages/native/dialog/src/dialog.tsx @@ -48,10 +48,7 @@ export const Trigger: PartComponent = forwardRef< DialogTriggerProps >((props, forwardedRef) => { const { api } = useDialogContext() - const merged = mergeProps( - props as Record, - normalize(api.parts.trigger), - ) as PressableProps + const merged = mergeProps(props, normalize(api.parts.trigger)) return }) @@ -111,10 +108,7 @@ export const Backdrop: PartComponent = forwardRef< // Only a modal dialog dims the app behind — non-modal coexists with it. if (!machine.context.modal) return null - const merged = mergeProps( - props as Record, - normalize(api.parts.backdrop), - ) as PressableProps + const merged = mergeProps(props, normalize(api.parts.backdrop)) return }) @@ -129,13 +123,13 @@ export const Viewport: PartComponent = forwardRef< DialogViewportProps >((props, forwardedRef) => { const { api } = useDialogContext() - const merged = mergeProps( + const merged = mergeProps( // `box-none`: the viewport itself never takes a press, so a press on the // empty area around the window falls through to the Backdrop behind it — // that fall-through is this substrate's viewport-press-counts-as-outside. - { pointerEvents: 'box-none' as const, ...(props as Record) }, + { pointerEvents: 'box-none', ...props }, normalize(api.parts.viewport), - ) as ViewProps + ) return }) @@ -150,14 +144,14 @@ export const Content: PartComponent = forwardRef< DialogContentProps >((props, forwardedRef) => { const { api, machine } = useDialogContext() - const merged = mergeProps(props as Record, { + const merged = mergeProps(props, { ...normalize(api.parts.content), // The host's modal containment for assistive tech (iOS): everything // outside this view stops existing for VoiceOver — the native // aria-modal. The normalize translation has no home for it because only // views, not attributes, carry it. accessibilityViewIsModal: machine.context.modal, - }) as ViewProps + }) return }) @@ -176,10 +170,7 @@ export const Title: PartComponent = forwardRef machine.send({ type: 'part.presence', part: 'title', present: false }) }, [machine]) - const merged = mergeProps( - props as Record, - normalize(api.parts.title), - ) as TextProps + const merged = mergeProps(props, normalize(api.parts.title)) return }, ) @@ -201,10 +192,7 @@ export const Description: PartComponent = forwardR return () => machine.send({ type: 'part.presence', part: 'description', present: false }) }, [machine]) - const merged = mergeProps( - props as Record, - normalize(api.parts.description), - ) as TextProps + const merged = mergeProps(props, normalize(api.parts.description)) return }) @@ -217,10 +205,7 @@ export interface DialogCloseProps extends PressableProps {} export const Close: PartComponent = forwardRef( (props, forwardedRef) => { const { api } = useDialogContext() - const merged = mergeProps( - props as Record, - normalize(api.parts.close), - ) as PressableProps + const merged = mergeProps(props, normalize(api.parts.close)) return }, ) diff --git a/packages/react/dialog/SPEC.md b/packages/react/dialog/SPEC.md index a23d558..eaf2f99 100644 --- a/packages/react/dialog/SPEC.md +++ b/packages/react/dialog/SPEC.md @@ -44,11 +44,17 @@ React-specific notes on top of the core contract: background should be a non-scrolling positioned boundary wrapping an inner scroller — portal into the boundary; the overlay fills its visible box and the backdrop blocks the scroller behind it (see the `scoped` story). -- **`Content`** renders the native `` element, always with the `open` - attribute since it only mounts while the dialog is open. It is shown without - `showModal()` on purpose: modality, dismissal, and focus stay driven by the - core contract, consistent across browsers, instead of splitting authority - with the browser's built-in dialog behavior. +- **`Content`** renders a `
` carrying the `dialog` (or `alertdialog`) + role, not the native `` element. The dialog window is the initial + focus target — focusable in script, out of the tab order — which needs + `tabindex="-1"`, and HTML states that + [the `tabindex` attribute must not be specified on `dialog` elements](https://html.spec.whatwg.org/multipage/interactive-elements.html#the-dialog-element). + The native element would only pay off through `showModal()`, and this + contract deliberately keeps modality, dismissal, and focus with the core + machine rather than splitting authority with the browser's built-in behavior + (see the core spec's Internals). With the role explicit and the element + neutral, there is nothing left to gain and one conformance rule left to + break. - **`Backdrop`** renders nothing when the dialog is non-modal (`modal={false}`), per the core parts contract. - **Exit animation** (`animated`): style the exit on the parts' @@ -130,12 +136,12 @@ The positioning + scroll layer around the dialog window. ### `Dialog.Content` -The dialog window; renders the native ``. +The dialog window; renders a `
` with the `dialog` role. | Prop | Type | Default | Description | | -------------- | -------------------------------- | ----------------- | ------------------------------------------- | | `initialFocus` | `RefObject` | the dialog window | The element to focus when the dialog opens. | -| `...props` | `ComponentProps<'dialog'>` | — | Forwarded to the rendered ``. | +| `...props` | `ComponentProps<'div'>` | — | Forwarded to the rendered `
`. | ### `Dialog.Title` diff --git a/packages/react/dialog/src/dialog.tsx b/packages/react/dialog/src/dialog.tsx index 7f1d39e..b37a89f 100644 --- a/packages/react/dialog/src/dialog.tsx +++ b/packages/react/dialog/src/dialog.tsx @@ -83,7 +83,10 @@ export const Trigger: PartComponent = for DialogTriggerProps >((props, forwardedRef) => { const { api } = useDialogContext() - const merged = mergeProps({ type: 'button' as const, ...props }, normalize(api.parts.trigger)) + const merged = mergeProps( + { type: 'button', ...props }, + normalize(api.parts.trigger), + ) return