1
1
// https://blog.logrocket.com/building-accessible-menubar-component-react
2
2
3
3
import classNames from 'classnames' ;
4
- import PropTypes from 'prop-types' ;
5
4
import React , {
6
5
useState ,
7
6
useEffect ,
@@ -18,11 +17,12 @@ import {
18
17
} from './contexts' ;
19
18
import TriangleIcon from '../../images/down-filled-triangle.svg' ;
20
19
21
- export function useMenuProps ( id ) {
20
+ /* -------------------------------------------------------------------------------------------------
21
+ * useMenuProps
22
+ * -----------------------------------------------------------------------------------------------*/
23
+ export function useMenuProps ( id : string ) {
22
24
const activeMenu = useContext ( MenuOpenContext ) ;
23
-
24
25
const isOpen = id === activeMenu ;
25
-
26
26
const { createMenuHandlers } = useContext ( MenubarContext ) ;
27
27
28
28
const handlers = useMemo ( ( ) => createMenuHandlers ( id ) , [
@@ -36,7 +36,11 @@ export function useMenuProps(id) {
36
36
/* -------------------------------------------------------------------------------------------------
37
37
* MenubarTrigger
38
38
* -----------------------------------------------------------------------------------------------*/
39
-
39
+ interface MenubarTriggerProps
40
+ extends React . ButtonHTMLAttributes < HTMLButtonElement > {
41
+ role ?: string ;
42
+ hasPopup ?: 'menu' | 'listbox' | 'true' ;
43
+ }
40
44
/**
41
45
* MenubarTrigger renders a button that toggles a submenu. It handles keyboard navigation and supports
42
46
* screen readers. It needs to be within a submenu context.
@@ -63,111 +67,107 @@ export function useMenuProps(id) {
63
67
* </li>
64
68
*/
65
69
66
- const MenubarTrigger = React . forwardRef ( ( { role, hasPopup, ...props } , ref ) => {
67
- const {
68
- setActiveIndex,
69
- menuItems,
70
- registerTopLevelItem,
71
- hasFocus
72
- } = useContext ( MenubarContext ) ;
73
- const { id, title, first, last } = useContext ( SubmenuContext ) ;
74
- const { isOpen, handlers } = useMenuProps ( id ) ;
75
-
76
- const handleMouseEnter = ( ) => {
77
- if ( hasFocus ) {
78
- const items = Array . from ( menuItems ) ;
79
- const index = items . findIndex ( ( item ) => item === ref . current ) ;
80
-
81
- if ( index !== - 1 ) {
82
- setActiveIndex ( index ) ;
83
- }
84
- }
85
- } ;
86
-
87
- const handleKeyDown = ( e ) => {
88
- switch ( e . key ) {
89
- case 'ArrowDown' :
90
- if ( ! isOpen ) {
91
- e . preventDefault ( ) ;
92
- e . stopPropagation ( ) ;
93
- first ( ) ;
94
- }
95
- break ;
96
- case 'ArrowUp' :
97
- if ( ! isOpen ) {
98
- e . preventDefault ( ) ;
99
- e . stopPropagation ( ) ;
100
- last ( ) ;
70
+ const MenubarTrigger = React . forwardRef < HTMLButtonElement , MenubarTriggerProps > (
71
+ (
72
+ { role = 'menuitem' , hasPopup = 'menu' , ...props } : MenubarTriggerProps ,
73
+ ref
74
+ ) => {
75
+ const {
76
+ setActiveIndex,
77
+ menuItems,
78
+ registerTopLevelItem,
79
+ hasFocus
80
+ } = useContext ( MenubarContext ) ;
81
+ const { id, title, first, last } = useContext ( SubmenuContext ) ;
82
+ const { isOpen, handlers } = useMenuProps ( id ) ;
83
+
84
+ const handleMouseEnter = ( ) => {
85
+ if ( hasFocus ) {
86
+ const items = Array . from ( menuItems ) ;
87
+ const index = items . findIndex ( ( item ) => item === ref . current ) ;
88
+
89
+ if ( index !== - 1 ) {
90
+ setActiveIndex ( index ) ;
101
91
}
102
- break ;
103
- case 'Enter' :
104
- case ' ' :
105
- if ( ! isOpen ) {
106
- e . preventDefault ( ) ;
107
- e . stopPropagation ( ) ;
108
- first ( ) ;
109
- }
110
- break ;
111
- default :
112
- break ;
113
- }
114
- } ;
115
-
116
- useEffect ( ( ) => {
117
- const unregister = registerTopLevelItem ( ref , id ) ;
118
- return unregister ;
119
- } , [ menuItems , registerTopLevelItem ] ) ;
120
-
121
- return (
122
- < button
123
- { ...props }
124
- { ...handlers }
125
- ref = { ref }
126
- role = { role }
127
- onMouseEnter = { handleMouseEnter }
128
- onKeyDown = { handleKeyDown }
129
- aria-haspopup = { hasPopup }
130
- aria-expanded = { isOpen }
131
- >
132
- < span className = "nav__item-header" > { title } </ span >
133
- < TriangleIcon
134
- className = "nav__item-header-triangle"
135
- focusable = "false"
136
- aria-hidden = "true"
137
- />
138
- </ button >
139
- ) ;
140
- } ) ;
92
+ }
93
+ } ;
141
94
142
- MenubarTrigger . propTypes = {
143
- role : PropTypes . string ,
144
- hasPopup : PropTypes . oneOf ( [ 'menu' , 'listbox' , 'true' ] )
145
- } ;
95
+ const handleKeyDown = ( e : React . KeyboardEvent < HTMLButtonElement > ) => {
96
+ switch ( e . key ) {
97
+ case 'ArrowDown' :
98
+ if ( ! isOpen ) {
99
+ e . preventDefault ( ) ;
100
+ e . stopPropagation ( ) ;
101
+ first ( ) ;
102
+ }
103
+ break ;
104
+ case 'ArrowUp' :
105
+ if ( ! isOpen ) {
106
+ e . preventDefault ( ) ;
107
+ e . stopPropagation ( ) ;
108
+ last ( ) ;
109
+ }
110
+ break ;
111
+ case 'Enter' :
112
+ case ' ' :
113
+ if ( ! isOpen ) {
114
+ e . preventDefault ( ) ;
115
+ e . stopPropagation ( ) ;
116
+ first ( ) ;
117
+ }
118
+ break ;
119
+ default :
120
+ break ;
121
+ }
122
+ } ;
146
123
147
- MenubarTrigger . defaultProps = {
148
- role : 'menuitem' ,
149
- hasPopup : 'menu'
150
- } ;
124
+ useEffect ( ( ) => {
125
+ const unregister = registerTopLevelItem ( ref , id ) ;
126
+ return unregister ;
127
+ } , [ menuItems , registerTopLevelItem ] ) ;
128
+
129
+ return (
130
+ < button
131
+ { ...props }
132
+ { ...handlers }
133
+ ref = { ref }
134
+ role = { role }
135
+ onMouseEnter = { handleMouseEnter }
136
+ onKeyDown = { handleKeyDown }
137
+ aria-haspopup = { hasPopup }
138
+ aria-expanded = { isOpen }
139
+ >
140
+ < span className = "nav__item-header" > { title } </ span >
141
+ < TriangleIcon
142
+ className = "nav__item-header-triangle"
143
+ focusable = "false"
144
+ aria-hidden = "true"
145
+ />
146
+ </ button >
147
+ ) ;
148
+ }
149
+ ) ;
151
150
152
151
/* -------------------------------------------------------------------------------------------------
153
152
* MenubarList
154
153
* -----------------------------------------------------------------------------------------------*/
155
154
155
+ interface MenubarListProps {
156
+ // MenubarItems that should be rendered in the list
157
+ children ?: React . ReactNode ;
158
+ // The ARIA role of the list element
159
+ role ?: 'menu' | 'listbox' ;
160
+ }
161
+
156
162
/**
157
163
* MenubarList renders the container for menu items in a submenu. It provides context and handles ARIA roles.
158
164
*
159
- * @param {Object } props
160
- * @param {React.ReactNode } props.children - MenubarItems that should be rendered in the list
161
- * @param {string } [props.role='menu'] - The ARIA role of the list element
162
- * @returns {JSX.Element }
163
- *
164
165
* @example
165
166
* <MenubarList role={listRole}>
166
167
* ... <MenubarItem> elements
167
168
* </MenubarList>
168
169
*/
169
-
170
- function MenubarList ( { children, role, ...props } ) {
170
+ function MenubarList ( { children, role = 'menu' , ...props } : MenubarListProps ) {
171
171
const { id, title } = useContext ( SubmenuContext ) ;
172
172
173
173
return (
@@ -184,20 +184,18 @@ function MenubarList({ children, role, ...props }) {
184
184
) ;
185
185
}
186
186
187
- MenubarList . propTypes = {
188
- children : PropTypes . node ,
189
- role : PropTypes . oneOf ( [ 'menu' , 'listbox' ] )
190
- } ;
191
-
192
- MenubarList . defaultProps = {
193
- children : null ,
194
- role : 'menu'
195
- } ;
196
-
197
187
/* -------------------------------------------------------------------------------------------------
198
188
* MenubarSubmenu
199
189
* -----------------------------------------------------------------------------------------------*/
200
190
191
+ export interface MenubarSubmenuProps {
192
+ id : string ;
193
+ children ?: React . ReactNode ;
194
+ title : string ;
195
+ triggerRole ?: string ;
196
+ listRole ?: 'menu' | 'listbox' ;
197
+ }
198
+
201
199
/**
202
200
* MenubarSubmenu manages a triggerable submenu within a menubar. It is a compound component
203
201
* that manages the state of the submenu and its items. It also provides keyboard navigation
@@ -219,15 +217,14 @@ MenubarList.defaultProps = {
219
217
* </MenubarSubmenu>
220
218
* </Menubar>
221
219
*/
222
-
223
- function MenubarSubmenu ( {
220
+ export function MenubarSubmenu ( {
224
221
children,
225
222
id,
226
223
title,
227
- triggerRole : customTriggerRole ,
228
- listRole : customListRole ,
224
+ triggerRole : customTriggerRole = 'menuItem' ,
225
+ listRole : customListRole = 'menu' ,
229
226
...props
230
- } ) {
227
+ } : MenubarSubmenuProps ) {
231
228
const { isOpen, handlers } = useMenuProps ( id ) ;
232
229
const [ submenuActiveIndex , setSubmenuActiveIndex ] = useState ( 0 ) ;
233
230
const { setMenuOpen, toggleMenuOpen } = useContext ( MenubarContext ) ;
@@ -442,19 +439,3 @@ function MenubarSubmenu({
442
439
</ SubmenuContext . Provider >
443
440
) ;
444
441
}
445
-
446
- MenubarSubmenu . propTypes = {
447
- id : PropTypes . string . isRequired ,
448
- children : PropTypes . node ,
449
- title : PropTypes . node . isRequired ,
450
- triggerRole : PropTypes . string ,
451
- listRole : PropTypes . string
452
- } ;
453
-
454
- MenubarSubmenu . defaultProps = {
455
- children : null ,
456
- triggerRole : 'menuitem' ,
457
- listRole : 'menu'
458
- } ;
459
-
460
- export default MenubarSubmenu ;
0 commit comments