Skip to content

Commit a6a2382

Browse files
Add support for role="alertdialog" to <Dialog> component (#2709)
* WIP * Add warning for unsupported roles to `<Dialog>` * Update assertions * Add test for React * Add support for `role=alertdialog` to Vue --------- Co-authored-by: Adam Wathan <[email protected]>
1 parent fd17c26 commit a6a2382

File tree

6 files changed

+239
-12
lines changed

6 files changed

+239
-12
lines changed

packages/@headlessui-react/src/components/dialog/dialog.test.tsx

+93-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createPortal } from 'react-dom'
22
import React, { createElement, useRef, useState, Fragment, useEffect, useCallback } from 'react'
3-
import { render } from '@testing-library/react'
3+
import { render, screen } from '@testing-library/react'
44

55
import { Dialog } from './dialog'
66
import { Popover } from '../popover/popover'
@@ -101,6 +101,98 @@ describe('Rendering', () => {
101101
})
102102
)
103103

104+
it(
105+
'should be able to explicitly choose role=dialog',
106+
suppressConsoleLogs(async () => {
107+
function Example() {
108+
let [isOpen, setIsOpen] = useState(false)
109+
110+
return (
111+
<>
112+
<button id="trigger" onClick={() => setIsOpen(true)}>
113+
Trigger
114+
</button>
115+
<Dialog open={isOpen} onClose={setIsOpen} role="dialog">
116+
<TabSentinel />
117+
</Dialog>
118+
</>
119+
)
120+
}
121+
render(<Example />)
122+
123+
assertDialog({ state: DialogState.InvisibleUnmounted })
124+
125+
await click(document.getElementById('trigger'))
126+
127+
await nextFrame()
128+
129+
assertDialog({ state: DialogState.Visible, attributes: { role: 'dialog' } })
130+
})
131+
)
132+
133+
it(
134+
'should be able to explicitly choose role=alertdialog',
135+
suppressConsoleLogs(async () => {
136+
function Example() {
137+
let [isOpen, setIsOpen] = useState(false)
138+
139+
return (
140+
<>
141+
<button id="trigger" onClick={() => setIsOpen(true)}>
142+
Trigger
143+
</button>
144+
<Dialog open={isOpen} onClose={setIsOpen} role="alertdialog">
145+
<TabSentinel />
146+
</Dialog>
147+
</>
148+
)
149+
}
150+
render(<Example />)
151+
152+
assertDialog({ state: DialogState.InvisibleUnmounted })
153+
154+
await click(document.getElementById('trigger'))
155+
156+
await nextFrame()
157+
158+
assertDialog({ state: DialogState.Visible, attributes: { role: 'alertdialog' } })
159+
})
160+
)
161+
162+
it(
163+
'should fall back to role=dialog for an invalid role',
164+
suppressConsoleLogs(async () => {
165+
function Example() {
166+
let [isOpen, setIsOpen] = useState(false)
167+
168+
return (
169+
<>
170+
<button id="trigger" onClick={() => setIsOpen(true)}>
171+
Trigger
172+
</button>
173+
<Dialog
174+
open={isOpen}
175+
onClose={setIsOpen}
176+
// @ts-expect-error: We explicitly type role to only accept valid options — but we still want to verify runtime behaviorr
177+
role="foobar"
178+
>
179+
<TabSentinel />
180+
</Dialog>
181+
</>
182+
)
183+
}
184+
render(<Example />)
185+
186+
assertDialog({ state: DialogState.InvisibleUnmounted })
187+
188+
await click(document.getElementById('trigger'))
189+
190+
await nextFrame()
191+
192+
assertDialog({ state: DialogState.Visible, attributes: { role: 'dialog' } })
193+
}, 'warn')
194+
)
195+
104196
it(
105197
'should complain when an `open` prop is provided without an `onClose` prop',
106198
suppressConsoleLogs(async () => {

packages/@headlessui-react/src/components/dialog/dialog.tsx

+21-2
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ let DEFAULT_DIALOG_TAG = 'div' as const
119119
interface DialogRenderPropArg {
120120
open: boolean
121121
}
122-
type DialogPropsWeControl = 'role' | 'aria-describedby' | 'aria-labelledby' | 'aria-modal'
122+
type DialogPropsWeControl = 'aria-describedby' | 'aria-labelledby' | 'aria-modal'
123123

124124
let DialogRenderFeatures = Features.RenderStrategy | Features.Static
125125

@@ -131,6 +131,7 @@ export type DialogProps<TTag extends ElementType> = Props<
131131
open?: boolean
132132
onClose(value: boolean): void
133133
initialFocus?: MutableRefObject<HTMLElement | null>
134+
role?: 'dialog' | 'alertdialog'
134135
__demoMode?: boolean
135136
}
136137
>
@@ -145,11 +146,29 @@ function DialogFn<TTag extends ElementType = typeof DEFAULT_DIALOG_TAG>(
145146
open,
146147
onClose,
147148
initialFocus,
149+
role = 'dialog',
148150
__demoMode = false,
149151
...theirProps
150152
} = props
151153
let [nestedDialogCount, setNestedDialogCount] = useState(0)
152154

155+
let didWarnOnRole = useRef(false)
156+
157+
role = (function () {
158+
if (role === 'dialog' || role === 'alertdialog') {
159+
return role
160+
}
161+
162+
if (!didWarnOnRole.current) {
163+
didWarnOnRole.current = true
164+
console.warn(
165+
`Invalid role [${role}] passed to <Dialog />. Only \`dialog\` and and \`alertdialog\` are supported. Using \`dialog\` instead.`
166+
)
167+
}
168+
169+
return 'dialog'
170+
})()
171+
153172
let usesOpenClosedState = useOpenClosed()
154173
if (open === undefined && usesOpenClosedState !== null) {
155174
// Update the `open` prop based on the open closed state
@@ -339,7 +358,7 @@ function DialogFn<TTag extends ElementType = typeof DEFAULT_DIALOG_TAG>(
339358
let ourProps = {
340359
ref: dialogRef,
341360
id,
342-
role: 'dialog',
361+
role,
343362
'aria-modal': dialogState === DialogStates.Open ? true : undefined,
344363
'aria-labelledby': state.titleId,
345364
'aria-describedby': describedby,

packages/@headlessui-react/src/test-utils/accessibility-assertions.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -1301,11 +1301,11 @@ export function assertDescriptionValue(element: HTMLElement | null, value: strin
13011301
// ---
13021302

13031303
export function getDialog(): HTMLElement | null {
1304-
return document.querySelector('[role="dialog"]')
1304+
return document.querySelector('[role="dialog"],[role="alertdialog"]')
13051305
}
13061306

13071307
export function getDialogs(): HTMLElement[] {
1308-
return Array.from(document.querySelectorAll('[role="dialog"]'))
1308+
return Array.from(document.querySelectorAll('[role="dialog"],[role="alertdialog"]'))
13091309
}
13101310

13111311
export function getDialogTitle(): HTMLElement | null {
@@ -1358,7 +1358,7 @@ export function assertDialog(
13581358

13591359
assertHidden(dialog)
13601360

1361-
expect(dialog).toHaveAttribute('role', 'dialog')
1361+
expect(dialog).toHaveAttribute('role', options.attributes?.['role'] ?? 'dialog')
13621362
expect(dialog).not.toHaveAttribute('aria-modal', 'true')
13631363

13641364
if (options.textContent) expect(dialog).toHaveTextContent(options.textContent)
@@ -1373,7 +1373,7 @@ export function assertDialog(
13731373

13741374
assertVisible(dialog)
13751375

1376-
expect(dialog).toHaveAttribute('role', 'dialog')
1376+
expect(dialog).toHaveAttribute('role', options.attributes?.['role'] ?? 'dialog')
13771377
expect(dialog).toHaveAttribute('aria-modal', 'true')
13781378

13791379
if (options.textContent) expect(dialog).toHaveTextContent(options.textContent)

packages/@headlessui-vue/src/components/dialog/dialog.test.ts

+99
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,105 @@ describe('Rendering', () => {
191191
})
192192
)
193193

194+
it(
195+
'should be able to explicitly choose role=dialog',
196+
suppressConsoleLogs(async () => {
197+
renderTemplate({
198+
template: `
199+
<div>
200+
<button id="trigger" @click="setIsOpen(true)">Trigger</button>
201+
<Dialog :open="isOpen" @close="setIsOpen" class="relative bg-blue-500" role="dialog">
202+
<TabSentinel />
203+
</Dialog>
204+
</div>
205+
`,
206+
setup() {
207+
let isOpen = ref(false)
208+
return {
209+
isOpen,
210+
setIsOpen(value: boolean) {
211+
isOpen.value = value
212+
},
213+
}
214+
},
215+
})
216+
217+
assertDialog({ state: DialogState.InvisibleUnmounted })
218+
219+
await click(document.getElementById('trigger'))
220+
221+
await nextFrame()
222+
223+
assertDialog({ state: DialogState.Visible, attributes: { role: 'dialog' } })
224+
})
225+
)
226+
227+
it(
228+
'should be able to explicitly choose role=alertdialog',
229+
suppressConsoleLogs(async () => {
230+
renderTemplate({
231+
template: `
232+
<div>
233+
<button id="trigger" @click="setIsOpen(true)">Trigger</button>
234+
<Dialog :open="isOpen" @close="setIsOpen" class="relative bg-blue-500" role="alertdialog">
235+
<TabSentinel />
236+
</Dialog>
237+
</div>
238+
`,
239+
setup() {
240+
let isOpen = ref(false)
241+
return {
242+
isOpen,
243+
setIsOpen(value: boolean) {
244+
isOpen.value = value
245+
},
246+
}
247+
},
248+
})
249+
250+
assertDialog({ state: DialogState.InvisibleUnmounted })
251+
252+
await click(document.getElementById('trigger'))
253+
254+
await nextFrame()
255+
256+
assertDialog({ state: DialogState.Visible, attributes: { role: 'alertdialog' } })
257+
})
258+
)
259+
260+
it(
261+
'should fall back to role=dialog for an invalid role',
262+
suppressConsoleLogs(async () => {
263+
renderTemplate({
264+
template: `
265+
<div>
266+
<button id="trigger" @click="setIsOpen(true)">Trigger</button>
267+
<Dialog :open="isOpen" @close="setIsOpen" class="relative bg-blue-500" role="foobar">
268+
<TabSentinel />
269+
</Dialog>
270+
</div>
271+
`,
272+
setup() {
273+
let isOpen = ref(false)
274+
return {
275+
isOpen,
276+
setIsOpen(value: boolean) {
277+
isOpen.value = value
278+
},
279+
}
280+
},
281+
})
282+
283+
assertDialog({ state: DialogState.InvisibleUnmounted })
284+
285+
await click(document.getElementById('trigger'))
286+
287+
await nextFrame()
288+
289+
assertDialog({ state: DialogState.Visible, attributes: { role: 'dialog' } })
290+
})
291+
)
292+
194293
it(
195294
'should complain when an `open` prop is not a boolean',
196295
suppressConsoleLogs(async () => {

packages/@headlessui-vue/src/components/dialog/dialog.ts

+18-1
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ export let Dialog = defineComponent({
7777
open: { type: [Boolean, String], default: Missing },
7878
initialFocus: { type: Object as PropType<HTMLElement | null>, default: null },
7979
id: { type: String, default: () => `headlessui-dialog-${useId()}` },
80+
role: { type: String as PropType<'dialog' | 'alertdialog'>, default: 'dialog' },
8081
},
8182
emits: { close: (_close: boolean) => true },
8283
setup(props, { emit, attrs, slots, expose }) {
@@ -85,6 +86,22 @@ export let Dialog = defineComponent({
8586
ready.value = true
8687
})
8788

89+
let didWarnOnRole = false
90+
let role = computed(() => {
91+
if (props.role === 'dialog' || props.role === 'alertdialog') {
92+
return props.role
93+
}
94+
95+
if (!didWarnOnRole) {
96+
didWarnOnRole = true
97+
console.warn(
98+
`Invalid role [${role}] passed to <Dialog />. Only \`dialog\` and and \`alertdialog\` are supported. Using \`dialog\` instead.`
99+
)
100+
}
101+
102+
return 'dialog'
103+
})
104+
88105
let nestedDialogCount = ref(0)
89106

90107
let usesOpenClosedState = useOpenClosed()
@@ -285,7 +302,7 @@ export let Dialog = defineComponent({
285302
...attrs,
286303
ref: internalDialogRef,
287304
id,
288-
role: 'dialog',
305+
role: role.value,
289306
'aria-modal': dialogState.value === DialogStates.Open ? true : undefined,
290307
'aria-labelledby': titleId.value,
291308
'aria-describedby': describedby.value,

packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -1301,11 +1301,11 @@ export function assertDescriptionValue(element: HTMLElement | null, value: strin
13011301
// ---
13021302

13031303
export function getDialog(): HTMLElement | null {
1304-
return document.querySelector('[role="dialog"]')
1304+
return document.querySelector('[role="dialog"],[role="alertdialog"]')
13051305
}
13061306

13071307
export function getDialogs(): HTMLElement[] {
1308-
return Array.from(document.querySelectorAll('[role="dialog"]'))
1308+
return Array.from(document.querySelectorAll('[role="dialog"],[role="alertdialog"]'))
13091309
}
13101310

13111311
export function getDialogTitle(): HTMLElement | null {
@@ -1358,7 +1358,7 @@ export function assertDialog(
13581358

13591359
assertHidden(dialog)
13601360

1361-
expect(dialog).toHaveAttribute('role', 'dialog')
1361+
expect(dialog).toHaveAttribute('role', options.attributes?.['role'] ?? 'dialog')
13621362
expect(dialog).not.toHaveAttribute('aria-modal', 'true')
13631363

13641364
if (options.textContent) expect(dialog).toHaveTextContent(options.textContent)
@@ -1373,7 +1373,7 @@ export function assertDialog(
13731373

13741374
assertVisible(dialog)
13751375

1376-
expect(dialog).toHaveAttribute('role', 'dialog')
1376+
expect(dialog).toHaveAttribute('role', options.attributes?.['role'] ?? 'dialog')
13771377
expect(dialog).toHaveAttribute('aria-modal', 'true')
13781378

13791379
if (options.textContent) expect(dialog).toHaveTextContent(options.textContent)

0 commit comments

Comments
 (0)