@@ -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" ;
1323import clsx from "clsx" ;
1424import { useCunningham } from "@gouvfr-lasuite/cunningham-react" ;
25+ import { MenuItemBody } from "../menu/MenuItemBody" ;
1526
1627const 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+
2471export 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