Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/content-role-dialog-element.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@dunky.dev/react-dialog': minor
---

`Dialog.Content` now renders a `<div>` carrying the `dialog` (or `alertdialog`) role instead of the native `<dialog>` 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 `<dialog>` resets (`position: static`, `border: none`) are no longer needed in consumer styles.
35 changes: 10 additions & 25 deletions packages/native/dialog/src/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,7 @@ export const Trigger: PartComponent<DialogTriggerProps, View> = forwardRef<
DialogTriggerProps
>((props, forwardedRef) => {
const { api } = useDialogContext()
const merged = mergeProps(
props as Record<string, unknown>,
normalize(api.parts.trigger),
) as PressableProps
const merged = mergeProps<DialogTriggerProps>(props, normalize(api.parts.trigger))
return <Pressable {...merged} ref={forwardedRef} />
})

Expand Down Expand Up @@ -111,10 +108,7 @@ export const Backdrop: PartComponent<DialogBackdropProps, View> = 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<string, unknown>,
normalize(api.parts.backdrop),
) as PressableProps
const merged = mergeProps<DialogBackdropProps>(props, normalize(api.parts.backdrop))
return <Pressable {...merged} ref={forwardedRef} />
})

Expand All @@ -129,13 +123,13 @@ export const Viewport: PartComponent<DialogViewportProps, View> = forwardRef<
DialogViewportProps
>((props, forwardedRef) => {
const { api } = useDialogContext()
const merged = mergeProps(
const merged = mergeProps<DialogViewportProps>(
// `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<string, unknown>) },
{ pointerEvents: 'box-none', ...props },
normalize(api.parts.viewport),
) as ViewProps
)
return <View {...merged} ref={forwardedRef} />
})

Expand All @@ -150,14 +144,14 @@ export const Content: PartComponent<DialogContentProps, View> = forwardRef<
DialogContentProps
>((props, forwardedRef) => {
const { api, machine } = useDialogContext()
const merged = mergeProps(props as Record<string, unknown>, {
const merged = mergeProps<DialogContentProps>(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 <View {...merged} ref={forwardedRef} />
})

Expand All @@ -176,10 +170,7 @@ export const Title: PartComponent<DialogTitleProps, Text> = forwardRef<Text, Dia
return () => machine.send({ type: 'part.presence', part: 'title', present: false })
}, [machine])

const merged = mergeProps(
props as Record<string, unknown>,
normalize(api.parts.title),
) as TextProps
const merged = mergeProps<DialogTitleProps>(props, normalize(api.parts.title))
return <Text {...merged} ref={forwardedRef} />
},
)
Expand All @@ -201,10 +192,7 @@ export const Description: PartComponent<DialogDescriptionProps, Text> = forwardR
return () => machine.send({ type: 'part.presence', part: 'description', present: false })
}, [machine])

const merged = mergeProps(
props as Record<string, unknown>,
normalize(api.parts.description),
) as TextProps
const merged = mergeProps<DialogDescriptionProps>(props, normalize(api.parts.description))
return <Text {...merged} ref={forwardedRef} />
})

Expand All @@ -217,10 +205,7 @@ export interface DialogCloseProps extends PressableProps {}
export const Close: PartComponent<DialogCloseProps, View> = forwardRef<View, DialogCloseProps>(
(props, forwardedRef) => {
const { api } = useDialogContext()
const merged = mergeProps(
props as Record<string, unknown>,
normalize(api.parts.close),
) as PressableProps
const merged = mergeProps<DialogCloseProps>(props, normalize(api.parts.close))
return <Pressable {...merged} ref={forwardedRef} />
},
)
Expand Down
20 changes: 13 additions & 7 deletions packages/react/dialog/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<dialog>` 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 `<div>` carrying the `dialog` (or `alertdialog`)
role, not the native `<dialog>` 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'
Expand Down Expand Up @@ -130,12 +136,12 @@ The positioning + scroll layer around the dialog window.

### `Dialog.Content`

The dialog window; renders the native `<dialog>`.
The dialog window; renders a `<div>` with the `dialog` role.

| Prop | Type | Default | Description |
| -------------- | -------------------------------- | ----------------- | ------------------------------------------- |
| `initialFocus` | `RefObject<HTMLElement \| null>` | the dialog window | The element to focus when the dialog opens. |
| `...props` | `ComponentProps<'dialog'>` | — | Forwarded to the rendered `<dialog>`. |
| `...props` | `ComponentProps<'div'>` | — | Forwarded to the rendered `<div>`. |

### `Dialog.Title`

Expand Down
42 changes: 23 additions & 19 deletions packages/react/dialog/src/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,10 @@ export const Trigger: PartComponent<DialogTriggerProps, HTMLButtonElement> = for
DialogTriggerProps
>((props, forwardedRef) => {
const { api } = useDialogContext()
const merged = mergeProps({ type: 'button' as const, ...props }, normalize(api.parts.trigger))
const merged = mergeProps<DialogTriggerProps>(
{ type: 'button', ...props },
normalize(api.parts.trigger),
)
return <button {...merged} ref={forwardedRef} />
})

Expand Down Expand Up @@ -128,7 +131,7 @@ export const Backdrop: PartComponent<DialogBackdropProps, HTMLDivElement> = forw
onClick?: (event: MouseEvent<HTMLDivElement>) => void
} & Record<string, unknown>

const merged = mergeProps(props as Record<string, unknown>, {
const merged = mergeProps<DialogBackdropProps>(props, {
...bindings,
// Only the topmost dialog of a stack answers an outside press.
onClick: (event: MouseEvent<HTMLDivElement>) => {
Expand Down Expand Up @@ -157,7 +160,7 @@ export const Viewport: PartComponent<DialogViewportProps, HTMLDivElement> = forw
onClick?: (event: MouseEvent<HTMLDivElement>) => void
} & Record<string, unknown>

const merged = mergeProps(props as Record<string, unknown>, {
const merged = mergeProps<DialogViewportProps>(props, {
...bindings,
// Content presses bubble up here — only a press that started on the
// viewport itself is an outside interaction, and only the topmost dialog
Expand All @@ -177,18 +180,18 @@ export const Viewport: PartComponent<DialogViewportProps, HTMLDivElement> = forw
// close, traps while modal
// =============================================================================

export interface DialogContentProps extends ComponentPropsWithoutRef<'dialog'> {
export interface DialogContentProps extends ComponentPropsWithoutRef<'div'> {
/** The element to focus when the dialog opens. @default the dialog window */
initialFocus?: RefObject<HTMLElement | null>
}

export const Content: PartComponent<DialogContentProps, HTMLDialogElement> = forwardRef<
HTMLDialogElement,
export const Content: PartComponent<DialogContentProps, HTMLDivElement> = forwardRef<
HTMLDivElement,
DialogContentProps
>(({ initialFocus, ...props }, forwardedRef) => {
const { api, machine, depth, container, backdropRef } = useDialogContext()
const contentRef = useRef<HTMLDialogElement>(null)
useImperativeHandle(forwardedRef, () => contentRef.current as HTMLDialogElement)
const contentRef = useRef<HTMLDivElement>(null)
useImperativeHandle(forwardedRef, () => contentRef.current as HTMLDivElement)
const initialFocusRef = useRef(initialFocus)
initialFocusRef.current = initialFocus

Expand Down Expand Up @@ -257,15 +260,13 @@ export const Content: PartComponent<DialogContentProps, HTMLDialogElement> = for
last: () => document.getElementById(api.ids.close),
})

const merged = mergeProps(props as Record<string, unknown>, {
...normalize(api.parts.content),
// The native <dialog> is display:none without `open`; Content only mounts
// while the dialog occupies the tree (open or mid-exit), so the attribute
// is unconditionally true.
open: true,
})
// A neutral element with the role, not <dialog>: the window is the initial
// focus target, so it carries tabindex — which HTML forbids on <dialog> —
// and the native element only pays off via showModal(), which this contract
// deliberately doesn't use.
const merged = mergeProps<DialogContentProps>(props, normalize(api.parts.content))

return <dialog {...merged} ref={contentRef} />
return <div {...merged} ref={contentRef} />
})

// =============================================================================
Expand All @@ -285,7 +286,7 @@ export const Title: PartComponent<DialogTitleProps, HTMLHeadingElement> = forwar
return () => machine.send({ type: 'part.presence', part: 'title', present: false })
}, [machine])

const merged = mergeProps(props as Record<string, unknown>, normalize(api.parts.title))
const merged = mergeProps<DialogTitleProps>(props, normalize(api.parts.title))
return <h2 {...merged} ref={forwardedRef} />
})

Expand All @@ -306,7 +307,7 @@ export const Description: PartComponent<DialogDescriptionProps, HTMLDivElement>
return () => machine.send({ type: 'part.presence', part: 'description', present: false })
}, [machine])

const merged = mergeProps(props as Record<string, unknown>, normalize(api.parts.description))
const merged = mergeProps<DialogDescriptionProps>(props, normalize(api.parts.description))
return <div {...merged} ref={forwardedRef} />
})

Expand All @@ -321,7 +322,10 @@ export const Close: PartComponent<DialogCloseProps, HTMLButtonElement> = forward
DialogCloseProps
>((props, forwardedRef) => {
const { api } = useDialogContext()
const merged = mergeProps({ type: 'button' as const, ...props }, normalize(api.parts.close))
const merged = mergeProps<DialogCloseProps>(
{ type: 'button', ...props },
normalize(api.parts.close),
)
return <button {...merged} ref={forwardedRef} />
})

Expand Down
6 changes: 3 additions & 3 deletions packages/react/dialog/stories/dialog.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ const viewport: CSSProperties = {
padding: 24,
}
const content: CSSProperties = {
// Reset the UA <dialog> styles so the viewport's flex centering owns position.
position: 'static',
border: 'none',
// `margin: auto` inside the viewport's flex box does the centering;
// `relative` makes the corner Close button pin to the window, not the page.
position: 'relative',
margin: 'auto',
maxWidth: 480,
padding: 24,
Expand Down
8 changes: 5 additions & 3 deletions packages/react/dialog/tests/dialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -258,11 +258,13 @@ describe('Dialog', () => {
expect(trigger.getAttribute('aria-controls')).toBe(screen.getByRole('dialog').id)
})

it('renders the native dialog element, marked open', () => {
// The window takes initial focus, so it carries tabindex — which HTML
// forbids on <dialog>. Hence a neutral element with an explicit role.
it('renders the dialog window as a scripted focus target outside the tab order', () => {
render(<DefaultDialog defaultOpen />)
const dialog = screen.getByRole('dialog')
expect(dialog.tagName).toBe('DIALOG')
expect(dialog.hasAttribute('open')).toBe(true)
expect(dialog.tagName).not.toBe('DIALOG')
expect(dialog.tabIndex).toBe(-1)
})

it('content is labelled by the Title and described by the Description', () => {
Expand Down
Loading