Skip to content

Commit 76a0efa

Browse files
committed
♿️(frontend) fix menu semantics and trigger ARIA for screen readers
Dropdown menu exposes proper ARIA, Popover no longer announces as dialog
1 parent 55c5548 commit 76a0efa

2 files changed

Lines changed: 108 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,12 @@
6767

6868
## 0.20.2
6969

70+
### Patch changes
71+
72+
- ♿️(frontend) fix menu semantics and trigger ARIA for screen readers #210
73+
74+
## 0.20.1
75+
7076
### Patch Changes
7177

7278
- 🐛(dropdown) fix to hide icon when no icon prop is passed

src/components/dropdown-menu/DropdownMenu.tsx

Lines changed: 102 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,68 @@ import {
66
Separator,
77
SubmenuTrigger,
88
} from "react-aria-components";
9-
import { MenuItemAction, MenuItemSeparator } from "../menu/types";
10-
import { MenuItemBody } from "../menu/MenuItemBody";
11-
import { DropdownMenuItem } from "./types";
12-
import { Fragment, PropsWithChildren, ReactNode, useId, useRef } from "react";
9+
import { DropdownMenuItem, DropdownMenuOption } from "./types";
10+
import {
11+
Children,
12+
cloneElement,
13+
Fragment,
14+
isValidElement,
15+
PropsWithChildren,
16+
ReactElement,
17+
ReactNode,
18+
useEffect,
19+
useId,
20+
useRef,
21+
} from "react";
22+
import { MenuItemSeparator } from "../menu/types";
1323
import clsx from "clsx";
1424
import { useCunningham } from "@gouvfr-lasuite/cunningham-react";
25+
import { MenuItemBody } from "../menu/MenuItemBody";
1526

1627
const isSeparator = (item: DropdownMenuItem): item is MenuItemSeparator => {
1728
return "type" in item && item.type === "separator";
1829
};
1930

20-
const hasChildren = (item: MenuItemAction): boolean => {
31+
const hasChildren = (item: DropdownMenuOption): boolean => {
2132
return Array.isArray(item.children) && item.children.length > 0;
2233
};
2334

35+
/**
36+
* Walk the React tree to find the first interactive element (button, link,
37+
* or anything with onClick/onPress) and inject ARIA trigger attributes on it.
38+
* This way consumers can wrap their trigger in layout divs without breaking
39+
* the screen reader announcement.
40+
*/
41+
const injectAriaAttrs = (
42+
node: ReactNode,
43+
attrs: Record<string, unknown>,
44+
state: { done: boolean },
45+
): ReactNode => {
46+
if (state.done || !isValidElement(node)) return node;
47+
const element = node as ReactElement<Record<string, unknown>>;
48+
49+
const isInteractive =
50+
element.type === "button" ||
51+
element.type === "a" ||
52+
"onClick" in element.props ||
53+
"onPress" in element.props;
54+
55+
if (isInteractive) {
56+
state.done = true;
57+
return cloneElement(element, attrs);
58+
}
59+
60+
if (element.props.children) {
61+
const newChildren = Children.map(
62+
element.props.children as ReactNode,
63+
(child) => injectAriaAttrs(child, attrs, state),
64+
);
65+
return cloneElement(element, {}, newChildren);
66+
}
67+
68+
return node;
69+
};
70+
2471
export type DropdownMenuProps = {
2572
options: DropdownMenuItem[];
2673
onOpenChange?: (isOpen: boolean) => void;
@@ -44,22 +91,58 @@ export const DropdownMenu = ({
4491
variant = "default",
4592
}: PropsWithChildren<DropdownMenuProps>) => {
4693
const id = useId();
47-
const triggerRef = useRef(null);
94+
const triggerRef = useRef<HTMLDivElement>(null);
95+
const popoverRef = useRef<HTMLDivElement>(null);
4896
const { t } = useCunningham();
49-
const menuClassName = `c__dropdown-menu${
50-
variant === "tiny" ? " c__dropdown-menu--tiny" : ""
51-
}`;
97+
const menuClassName = `c__dropdown-menu${variant === "tiny" ? " c__dropdown-menu--tiny" : ""}`;
5298
const onOpenChangeHandler = (isOpen: boolean) => {
5399
onOpenChange?.(isOpen);
54100
};
55101

56-
const getAriaLabel = (option: MenuItemAction): string => {
102+
const getAriaLabel = (option: DropdownMenuOption): string => {
57103
if (option.opensInNewWindow) {
58104
return option.label + t("components.menu.newWindowLabelSuffix");
59105
}
60106
return option.label;
61107
};
62108

109+
// React Aria's Popover forces role="dialog" on the overlay, but for a
110+
// dropdown menu that's wrong, screen readers announce "dialogue" instead
111+
// of just letting the menu speak for itself. We patch it out with a
112+
// MutationObserver so it gets removed even if React Aria re-applies it.
113+
114+
useEffect(() => {
115+
const node = popoverRef.current;
116+
if (!node) return;
117+
118+
const removeDialogRole = () => {
119+
if (node.getAttribute("role") === "dialog") {
120+
node.removeAttribute("role");
121+
}
122+
};
123+
124+
removeDialogRole();
125+
const observer = new MutationObserver(removeDialogRole);
126+
observer.observe(node, {
127+
attributes: true,
128+
attributeFilter: ["role"],
129+
});
130+
131+
return () => observer.disconnect();
132+
}, [isOpen]);
133+
134+
// Inject aria-expanded / aria-haspopup / aria-controls on the actual
135+
// trigger button, not the wrapper div
136+
const childWithAria = injectAriaAttrs(
137+
children,
138+
{
139+
"aria-expanded": isOpen,
140+
"aria-haspopup": "menu" as const,
141+
"aria-controls": isOpen ? `${id}-menu` : undefined,
142+
},
143+
{ done: false },
144+
);
145+
63146
const renderMenuItems = (items: DropdownMenuItem[]) =>
64147
items.map((option, index) => {
65148
if (isSeparator(option)) {
@@ -145,21 +228,25 @@ export const DropdownMenu = ({
145228
e.preventDefault();
146229
}}
147230
>
148-
{children}
231+
{childWithAria}
149232
</div>
150233

151234
<Popover
235+
ref={popoverRef}
152236
triggerRef={triggerRef}
153-
style={{
154-
marginTop: "0px",
155-
}}
237+
style={{ marginTop: "0px" }}
156238
isOpen={isOpen}
157239
shouldFlip
158240
containerPadding={16}
159241
shouldCloseOnInteractOutside={shouldCloseOnInteractOutside}
160242
onOpenChange={onOpenChangeHandler}
161243
>
162-
<Menu className={menuClassName} aria-labelledby={id} autoFocus="first">
244+
<Menu
245+
id={`${id}-menu`}
246+
className={menuClassName}
247+
aria-labelledby={id}
248+
autoFocus="first"
249+
>
163250
{topMessage && (
164251
<Header
165252
className="c__dropdown-menu-item-top-message"

0 commit comments

Comments
 (0)