Skip to content

Commit d8327b5

Browse files
committed
MenubarSubmenu: define interfaces, no-verify
1 parent 632c11a commit d8327b5

File tree

1 file changed

+128
-130
lines changed

1 file changed

+128
-130
lines changed

client/components/Menubar/MenubarSubmenu.tsx

Lines changed: 128 additions & 130 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
// https://blog.logrocket.com/building-accessible-menubar-component-react
22

33
import classNames from 'classnames';
4-
import PropTypes from 'prop-types';
54
import React, {
65
useState,
76
useEffect,
@@ -18,6 +17,12 @@ import {
1817
} from './contexts';
1918
import 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+
2126
export 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+
226237
function 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-
463461
export default MenubarSubmenu;

0 commit comments

Comments
 (0)