11// https://blog.logrocket.com/building-accessible-menubar-component-react
22
33import classNames from 'classnames' ;
4- import PropTypes from 'prop-types' ;
54import React , {
65 useState ,
76 useEffect ,
@@ -18,6 +17,12 @@ import {
1817} from './contexts' ;
1918import TriangleIcon from '../../images/down-filled-triangle.svg' ;
2019
20+ /** Custom subset of valid list item roles for the Menubar list items */
21+ export enum MenubarListItemRole {
22+ MENU = 'menu' ,
23+ LISTBOX = 'listbox'
24+ }
25+
2126export function useMenuProps ( id : string ) {
2227 const activeMenu = useContext ( MenuOpenContext ) ;
2328
@@ -37,15 +42,28 @@ export function useMenuProps(id: string) {
3742 * MenubarTrigger
3843 * -----------------------------------------------------------------------------------------------*/
3944
45+ /** Custom subset of valid values for aria-hasPopup for the MenubarTrigger */
46+ export enum MenubarTriggerAriaHasPopup {
47+ MENU = MenubarListItemRole . MENU ,
48+ LISTBOX = MenubarListItemRole . LISTBOX ,
49+ TRUE = 'true'
50+ }
51+
52+ interface MenubarTriggerProps
53+ extends Omit <
54+ React . ComponentProps < 'button' > ,
55+ 'aria-haspopup' | 'aria-expanded' | 'onMouseEnter' | 'onKeyDown' | 'role'
56+ > {
57+ /** The ARIA role of the trigger button */
58+ role ?: string ;
59+ /** The ARIA property that indicates the presence of a popup */
60+ hasPopup ?: MenubarTriggerAriaHasPopup ;
61+ }
62+
4063/**
4164 * MenubarTrigger renders a button that toggles a submenu. It handles keyboard navigation and supports
4265 * screen readers. It needs to be within a submenu context.
4366 *
44- * @param {Object } props
45- * @param {string } [props.role='menuitem'] - The ARIA role of the trigger button
46- * @param {string } [props.hasPopup='menu'] - The ARIA property that indicates the presence of a popup
47- * @returns {JSX.Element }
48- *
4967 * @example
5068 * <li
5169 * className={classNames('nav__item', isOpen && 'nav__item--open')}
@@ -62,115 +80,110 @@ export function useMenuProps(id: string) {
6280 * ... menubar list
6381 * </li>
6482 */
65-
66- const MenubarTrigger = React . forwardRef <
67- HTMLButtonElement ,
68- { role ?: string ; hasPopup ?: string } & React . ComponentProps < 'button' >
69- > ( ( { role, hasPopup, ...props } , ref ) => {
70- const {
71- setActiveIndex,
72- menuItems,
73- registerTopLevelItem,
74- hasFocus
75- } = useContext ( MenubarContext ) ;
76- const { id, title, first, last } = useContext ( SubmenuContext ) ;
77- const { isOpen, handlers } = useMenuProps ( id ) ;
78-
79- const handleMouseEnter = ( e : React . MouseEvent ) => {
80- if ( hasFocus ) {
81- const items = Array . from ( menuItems ) ;
82- const index = items . findIndex ( ( item ) => item === ref . current ) ;
83-
84- if ( index !== - 1 ) {
85- setActiveIndex ( index ) ;
86- }
87- }
88- } ;
89-
90- const handleKeyDown = ( e : React . KeyboardEvent ) => {
91- switch ( e . key ) {
92- case 'ArrowDown' :
93- if ( ! isOpen ) {
94- e . preventDefault ( ) ;
95- e . stopPropagation ( ) ;
96- first ( ) ;
97- }
98- break ;
99- case 'ArrowUp' :
100- if ( ! isOpen ) {
101- e . preventDefault ( ) ;
102- e . stopPropagation ( ) ;
103- last ( ) ;
104- }
105- break ;
106- case 'Enter' :
107- case ' ' :
108- if ( ! isOpen ) {
109- e . preventDefault ( ) ;
110- e . stopPropagation ( ) ;
111- first ( ) ;
83+ const MenubarTrigger = React . forwardRef < HTMLButtonElement , MenubarTriggerProps > (
84+ (
85+ { role = 'menuitem' , hasPopup = MenubarTriggerAriaHasPopup . MENU , ...props } ,
86+ ref
87+ ) => {
88+ const {
89+ setActiveIndex,
90+ menuItems,
91+ registerTopLevelItem,
92+ hasFocus
93+ } = useContext ( MenubarContext ) ;
94+ const { id, title, first, last } = useContext ( SubmenuContext ) ;
95+ const { isOpen, handlers } = useMenuProps ( id ) ;
96+
97+ const handleMouseEnter = ( e : React . MouseEvent ) => {
98+ if ( hasFocus ) {
99+ const items = Array . from ( menuItems ) ;
100+ const index = items . findIndex ( ( item ) => item === ref . current ) ;
101+
102+ if ( index !== - 1 ) {
103+ setActiveIndex ( index ) ;
112104 }
113- break ;
114- default :
115- break ;
116- }
117- } ;
118-
119- useEffect ( ( ) => {
120- const unregister = registerTopLevelItem ( ref , id ) ;
121- return unregister ;
122- } , [ menuItems , registerTopLevelItem ] ) ;
123-
124- return (
125- < button
126- { ...props }
127- { ...handlers }
128- ref = { ref }
129- role = { role }
130- onMouseEnter = { handleMouseEnter }
131- onKeyDown = { handleKeyDown }
132- aria-haspopup = { hasPopup }
133- aria-expanded = { isOpen }
134- >
135- < span className = "nav__item-header" > { title } </ span >
136- < TriangleIcon
137- className = "nav__item-header-triangle"
138- focusable = "false"
139- aria-hidden = "true"
140- />
141- </ button >
142- ) ;
143- } ) ;
105+ }
106+ } ;
144107
145- MenubarTrigger . propTypes = {
146- role : PropTypes . string ,
147- hasPopup : PropTypes . oneOf ( [ 'menu' , 'listbox' , 'true' ] )
148- } ;
108+ const handleKeyDown = ( e : React . KeyboardEvent ) => {
109+ switch ( e . key ) {
110+ case 'ArrowDown' :
111+ if ( ! isOpen ) {
112+ e . preventDefault ( ) ;
113+ e . stopPropagation ( ) ;
114+ first ( ) ;
115+ }
116+ break ;
117+ case 'ArrowUp' :
118+ if ( ! isOpen ) {
119+ e . preventDefault ( ) ;
120+ e . stopPropagation ( ) ;
121+ last ( ) ;
122+ }
123+ break ;
124+ case 'Enter' :
125+ case ' ' :
126+ if ( ! isOpen ) {
127+ e . preventDefault ( ) ;
128+ e . stopPropagation ( ) ;
129+ first ( ) ;
130+ }
131+ break ;
132+ default :
133+ break ;
134+ }
135+ } ;
149136
150- MenubarTrigger . defaultProps = {
151- role : 'menuitem' ,
152- hasPopup : 'menu'
153- } ;
137+ useEffect ( ( ) => {
138+ const unregister = registerTopLevelItem ( ref , id ) ;
139+ return unregister ;
140+ } , [ menuItems , registerTopLevelItem ] ) ;
141+
142+ return (
143+ < button
144+ { ...props }
145+ { ...handlers }
146+ ref = { ref }
147+ role = { role }
148+ onMouseEnter = { handleMouseEnter }
149+ onKeyDown = { handleKeyDown }
150+ aria-haspopup = { hasPopup }
151+ aria-expanded = { isOpen }
152+ >
153+ < span className = "nav__item-header" > { title } </ span >
154+ < TriangleIcon
155+ className = "nav__item-header-triangle"
156+ focusable = "false"
157+ aria-hidden = "true"
158+ />
159+ </ button >
160+ ) ;
161+ }
162+ ) ;
154163
155164/* -------------------------------------------------------------------------------------------------
156165 * MenubarList
157166 * -----------------------------------------------------------------------------------------------*/
158167
168+ export interface MenubarListProps {
169+ /** MenubarItems that should be rendered in the list */
170+ children ?: React . ReactNode ;
171+ /** The ARIA role of the list element */
172+ role ?: MenubarListItemRole ;
173+ }
174+
159175/**
160176 * MenubarList renders the container for menu items in a submenu. It provides context and handles ARIA roles.
161- *
162- * @param {Object } props
163- * @param {React.ReactNode } props.children - MenubarItems that should be rendered in the list
164- * @param {string } [props.role='menu'] - The ARIA role of the list element
165- * @returns {JSX.Element }
166- *
167177 * @example
168178 * <MenubarList role={listRole}>
169179 * ... <MenubarItem> elements
170180 * </MenubarList>
171181 */
172-
173- function MenubarList ( { children, role, ...props } ) {
182+ function MenubarList ( {
183+ children,
184+ role = MenubarListItemRole . MENU ,
185+ ...props
186+ } : MenubarListProps ) {
174187 const { id, title } = useContext ( SubmenuContext ) ;
175188
176189 return (
@@ -187,16 +200,6 @@ function MenubarList({ children, role, ...props }) {
187200 ) ;
188201}
189202
190- MenubarList . propTypes = {
191- children : PropTypes . node ,
192- role : PropTypes . oneOf ( [ 'menu' , 'listbox' ] )
193- } ;
194-
195- MenubarList . defaultProps = {
196- children : null ,
197- role : 'menu'
198- } ;
199-
200203/* -------------------------------------------------------------------------------------------------
201204 * MenubarSubmenu
202205 * -----------------------------------------------------------------------------------------------*/
@@ -223,14 +226,22 @@ MenubarList.defaultProps = {
223226 * </Menubar>
224227 */
225228
229+ export interface MenubarSubmenuProps {
230+ id : string ;
231+ children ?: React . ReactNode ;
232+ title : string ;
233+ triggerRole ?: string ;
234+ listRole ?: MenubarListItemRole ;
235+ }
236+
226237function MenubarSubmenu ( {
227238 children,
228239 id,
229240 title,
230- triggerRole : customTriggerRole ,
231- listRole : customListRole ,
241+ triggerRole = 'menuitem' ,
242+ listRole = MenubarListItemRole . MENU ,
232243 ...props
233- } ) {
244+ } : MenubarSubmenuProps ) {
234245 const { isOpen, handlers } = useMenuProps ( id ) ;
235246 const [ submenuActiveIndex , setSubmenuActiveIndex ] = useState ( 0 ) ;
236247 const { setMenuOpen, toggleMenuOpen } = useContext ( MenubarContext ) ;
@@ -239,9 +250,10 @@ function MenubarSubmenu({
239250 const buttonRef = useRef < HTMLButtonElement | null > ( null ) ;
240251 const listItemRef = useRef < HTMLLIElement | null > ( null ) ;
241252
242- const triggerRole = customTriggerRole || 'menuitem' ;
243- const listRole = customListRole || 'menu' ;
244- const hasPopup = listRole === 'listbox' ? 'listbox' : 'menu' ;
253+ const hasPopup =
254+ listRole === MenubarListItemRole . LISTBOX
255+ ? MenubarTriggerAriaHasPopup . LISTBOX
256+ : MenubarTriggerAriaHasPopup . MENU ;
245257
246258 const prev = useCallback ( ( ) => {
247259 const newIndex =
@@ -446,18 +458,4 @@ function MenubarSubmenu({
446458 ) ;
447459}
448460
449- MenubarSubmenu . propTypes = {
450- id : PropTypes . string . isRequired ,
451- children : PropTypes . node ,
452- title : PropTypes . node . isRequired ,
453- triggerRole : PropTypes . string ,
454- listRole : PropTypes . string
455- } ;
456-
457- MenubarSubmenu . defaultProps = {
458- children : null ,
459- triggerRole : 'menuitem' ,
460- listRole : 'menu'
461- } ;
462-
463461export default MenubarSubmenu ;
0 commit comments