Skip to content

Commit 9e0df9e

Browse files
authored
ensure valid Menu accessibility tree (#228)
1 parent 9891fa3 commit 9e0df9e

File tree

4 files changed

+128
-0
lines changed

4 files changed

+128
-0
lines changed

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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,51 @@ describe('Rendering composition', () => {
383383
items.forEach(item => assertMenuItem(item, { tag: 'button' }))
384384
})
385385
)
386+
387+
it(
388+
'should mark all the elements between Menu.Items and Menu.Item with role none',
389+
suppressConsoleLogs(async () => {
390+
render(
391+
<Menu>
392+
<Menu.Button>Trigger</Menu.Button>
393+
<div className="outer">
394+
<Menu.Items>
395+
<div className="py-1 inner">
396+
<Menu.Item as="button">Item A</Menu.Item>
397+
<Menu.Item as="button">Item B</Menu.Item>
398+
</div>
399+
<div className="py-1 inner">
400+
<Menu.Item as="button">Item C</Menu.Item>
401+
<Menu.Item>
402+
<div>
403+
<div className="outer">Item D</div>
404+
</div>
405+
</Menu.Item>
406+
</div>
407+
<div className="py-1 inner">
408+
<form className="inner">
409+
<Menu.Item as="button">Item E</Menu.Item>
410+
</form>
411+
</div>
412+
</Menu.Items>
413+
</div>
414+
</Menu>
415+
)
416+
417+
// Open menu
418+
await click(getMenuButton())
419+
420+
expect.hasAssertions()
421+
422+
document.querySelectorAll('.outer').forEach(element => {
423+
expect(element).not.toHaveAttribute('role', 'none')
424+
})
425+
426+
document.querySelectorAll('.inner').forEach(element => {
427+
expect(element).toHaveAttribute('role', 'none')
428+
})
429+
})
430+
)
386431
})
387432

388433
describe('Keyboard interactions', () => {

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,24 @@ let Items = forwardRefWithAs(function Items<TTag extends ElementType = typeof DE
307307
let id = `headlessui-menu-items-${useId()}`
308308
let searchDisposables = useDisposables()
309309

310+
useIsoMorphicEffect(() => {
311+
let container = state.itemsRef.current
312+
if (!container) return
313+
if (state.menuState !== MenuStates.Open) return
314+
315+
let walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, {
316+
acceptNode(node: HTMLElement) {
317+
if (node.getAttribute('role') === 'menuitem') return NodeFilter.FILTER_REJECT
318+
if (node.hasAttribute('role')) return NodeFilter.FILTER_SKIP
319+
return NodeFilter.FILTER_ACCEPT
320+
},
321+
})
322+
323+
while (walker.nextNode()) {
324+
;(walker.currentNode as HTMLElement).setAttribute('role', 'none')
325+
}
326+
})
327+
310328
let handleKeyDown = useCallback(
311329
(event: ReactKeyboardEvent<HTMLDivElement>) => {
312330
searchDisposables.dispose()

packages/@headlessui-vue/src/components/menu/menu.test.tsx

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -602,6 +602,53 @@ describe('Rendering composition', () => {
602602
)
603603
})
604604
)
605+
606+
it(
607+
'should mark all the elements between Menu.Items and Menu.Item with role none',
608+
suppressConsoleLogs(async () => {
609+
renderTemplate({
610+
template: `
611+
<Menu>
612+
<MenuButton>Trigger</MenuButton>
613+
<div className="outer">
614+
<MenuItems>
615+
<div className="py-1 inner">
616+
<MenuItem as="button">Item A</MenuItem>
617+
<MenuItem as="button">Item B</MenuItem>
618+
</div>
619+
<div className="py-1 inner">
620+
<MenuItem as="button">Item C</MenuItem>
621+
<MenuItem>
622+
<div>
623+
<div className="outer">Item D</div>
624+
</div>
625+
</MenuItem>
626+
</div>
627+
<div className="py-1 inner">
628+
<form className="inner">
629+
<MenuItem as="button">Item E</MenuItem>
630+
</form>
631+
</div>
632+
</MenuItems>
633+
</div>
634+
</Menu>
635+
`,
636+
})
637+
638+
// Open menu
639+
await click(getMenuButton())
640+
641+
expect.hasAssertions()
642+
643+
document.querySelectorAll('.outer').forEach(element => {
644+
expect(element).not.toHaveAttribute('role', 'none')
645+
})
646+
647+
document.querySelectorAll('.inner').forEach(element => {
648+
expect(element).toHaveAttribute('role', 'none')
649+
})
650+
})
651+
)
605652
})
606653

607654
describe('Keyboard interactions', () => {

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,24 @@ export let MenuItems = defineComponent({
278278
let id = `headlessui-menu-items-${useId()}`
279279
let searchDebounce = ref<ReturnType<typeof setTimeout> | null>(null)
280280

281+
watchEffect(() => {
282+
let container = api.itemsRef.value
283+
if (!container) return
284+
if (api.menuState.value !== MenuStates.Open) return
285+
286+
let walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, {
287+
acceptNode(node: HTMLElement) {
288+
if (node.getAttribute('role') === 'menuitem') return NodeFilter.FILTER_REJECT
289+
if (node.hasAttribute('role')) return NodeFilter.FILTER_SKIP
290+
return NodeFilter.FILTER_ACCEPT
291+
},
292+
})
293+
294+
while (walker.nextNode()) {
295+
;(walker.currentNode as HTMLElement).setAttribute('role', 'none')
296+
}
297+
})
298+
281299
function handleKeyDown(event: KeyboardEvent) {
282300
if (searchDebounce.value) clearTimeout(searchDebounce.value)
283301

0 commit comments

Comments
 (0)