better-mui-menu 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,369 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ /// <reference types="@testing-library/jest-dom" />
5
+ import type { MouseEvent } from 'react';
6
+ import { useState } from 'react';
7
+ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
8
+ import userEvent from '@testing-library/user-event';
9
+ import Cloud from '@mui/icons-material/Cloud';
10
+ import ContentCopy from '@mui/icons-material/ContentCopy';
11
+ import ContentCut from '@mui/icons-material/ContentCut';
12
+ import ContentPaste from '@mui/icons-material/ContentPaste';
13
+ import type { MultiLevelMenuItem } from './types';
14
+ import { MultiLevelMenu } from './index';
15
+
16
+ const buildMenuItems = () => {
17
+ const spies = {
18
+ cut: jest.fn(),
19
+ google: jest.fn(),
20
+ deep: jest.fn(),
21
+ };
22
+
23
+ const items: MultiLevelMenuItem[] = [
24
+ {
25
+ id: 'cut',
26
+ label: 'Cut',
27
+ startIcon: ContentCut,
28
+ onClick: spies.cut,
29
+ },
30
+ {
31
+ id: 'web-clipboard',
32
+ label: 'Web Clipboard',
33
+ startIcon: Cloud,
34
+ items: [
35
+ {
36
+ id: 'google-cloud',
37
+ label: 'Google Cloud',
38
+ startIcon: ContentCopy,
39
+ onClick: spies.google,
40
+ },
41
+ {
42
+ id: 'deep-options',
43
+ label: 'Deep Options',
44
+ startIcon: ContentPaste,
45
+ items: [
46
+ {
47
+ id: 'deep-item',
48
+ label: 'Deep Item',
49
+ startIcon: Cloud,
50
+ onClick: spies.deep,
51
+ },
52
+ ],
53
+ },
54
+ ],
55
+ },
56
+ ];
57
+
58
+ return { items, spies };
59
+ };
60
+
61
+ function MenuWithTrigger({ items }: { items: MultiLevelMenuItem[] }) {
62
+ const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
63
+
64
+ const handleOpen = (event: MouseEvent<HTMLButtonElement>) => {
65
+ setAnchorEl(event.currentTarget);
66
+ };
67
+
68
+ const handleClose = () => {
69
+ setAnchorEl(null);
70
+ };
71
+
72
+ return (
73
+ <>
74
+ <button
75
+ type='button'
76
+ aria-controls={anchorEl ? 'icon-menu' : undefined}
77
+ aria-haspopup='true'
78
+ aria-expanded={anchorEl ? 'true' : undefined}
79
+ onClick={handleOpen}
80
+ >
81
+ Menu Actions
82
+ </button>
83
+ <MultiLevelMenu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleClose} items={items} />
84
+ </>
85
+ );
86
+ }
87
+
88
+ const setupMenu = () => {
89
+ const user = userEvent.setup();
90
+ const { items, spies } = buildMenuItems();
91
+ render(<MenuWithTrigger items={items} />);
92
+ const toggleButton = screen.getByRole('button', { name: /menu actions/i });
93
+ return { user, toggleButton, spies };
94
+ };
95
+
96
+ describe('MultiLevelMenu', () => {
97
+ it('closes the root menu after selecting a leaf action', async () => {
98
+ const { user, toggleButton, spies } = setupMenu();
99
+ await user.click(toggleButton);
100
+ await waitFor(() => {
101
+ expect(toggleButton).toHaveAttribute('aria-expanded', 'true');
102
+ });
103
+
104
+ const cutItem = await screen.findByRole('menuitem', { name: 'Cut' });
105
+ await user.click(cutItem);
106
+
107
+ expect(spies.cut).toHaveBeenCalledTimes(1);
108
+ await waitFor(() => {
109
+ expect(toggleButton).not.toHaveAttribute('aria-expanded');
110
+ });
111
+ });
112
+
113
+ it('handles actions when callers omit explicit ids', async () => {
114
+ const user = userEvent.setup();
115
+ const spies = {
116
+ copy: jest.fn(),
117
+ nested: jest.fn(),
118
+ };
119
+ const items: MultiLevelMenuItem[] = [
120
+ {
121
+ label: 'Copy',
122
+ startIcon: ContentCopy,
123
+ onClick: spies.copy,
124
+ },
125
+ {
126
+ label: 'More options',
127
+ startIcon: Cloud,
128
+ items: [
129
+ {
130
+ label: 'Nested action',
131
+ startIcon: ContentCopy,
132
+ onClick: spies.nested,
133
+ },
134
+ ],
135
+ },
136
+ ];
137
+
138
+ render(<MenuWithTrigger items={items} />);
139
+ const toggleButton = screen.getByRole('button', { name: /menu actions/i });
140
+ await user.click(toggleButton);
141
+
142
+ const copyItem = await screen.findByRole('menuitem', { name: 'Copy' });
143
+ await user.click(copyItem);
144
+
145
+ expect(spies.copy).toHaveBeenCalledTimes(1);
146
+ await waitFor(() => {
147
+ expect(toggleButton).not.toHaveAttribute('aria-expanded');
148
+ });
149
+
150
+ await user.click(toggleButton);
151
+ const nestedTrigger = await screen.findByRole('menuitem', { name: 'More options' });
152
+ await user.hover(nestedTrigger);
153
+ const nestedAction = await screen.findByRole('menuitem', { name: 'Nested action' });
154
+ await user.click(nestedAction);
155
+
156
+ expect(spies.nested).toHaveBeenCalledTimes(1);
157
+ await waitFor(() => {
158
+ expect(toggleButton).not.toHaveAttribute('aria-expanded');
159
+ });
160
+ });
161
+
162
+ it('invokes a nested menu action and closes both menus', async () => {
163
+ const { user, toggleButton, spies } = setupMenu();
164
+ await user.click(toggleButton);
165
+
166
+ const nestedTrigger = await screen.findByRole('menuitem', { name: 'Web Clipboard' });
167
+ await user.hover(nestedTrigger);
168
+
169
+ const googleItem = await screen.findByRole('menuitem', { name: 'Google Cloud' });
170
+ await user.click(googleItem);
171
+
172
+ expect(spies.google).toHaveBeenCalledTimes(1);
173
+ await waitFor(() => {
174
+ expect(screen.queryByRole('menu', { name: 'Web Clipboard' })).not.toBeInTheDocument();
175
+ });
176
+ expect(toggleButton).not.toHaveAttribute('aria-expanded');
177
+ });
178
+
179
+ it('fires deep-nested actions and closes the root menu', async () => {
180
+ const { user, toggleButton, spies } = setupMenu();
181
+ await user.click(toggleButton);
182
+
183
+ const nestedTrigger = await screen.findByRole('menuitem', { name: 'Web Clipboard' });
184
+ await user.hover(nestedTrigger);
185
+
186
+ const deepTrigger = await screen.findByRole('menuitem', { name: 'Deep Options' });
187
+ await user.hover(deepTrigger);
188
+
189
+ const deepItem = await screen.findByRole('menuitem', { name: 'Deep Item' });
190
+ await user.click(deepItem);
191
+
192
+ expect(spies.deep).toHaveBeenCalledTimes(1);
193
+ await waitFor(() => {
194
+ expect(screen.queryByRole('menu', { name: 'Web Clipboard' })).not.toBeInTheDocument();
195
+ });
196
+ expect(toggleButton).not.toHaveAttribute('aria-expanded');
197
+ });
198
+
199
+ it('keeps the submenu open when the mouse leaves the trigger toward the submenu (critical feature)', async () => {
200
+ const { user, toggleButton } = setupMenu();
201
+ await user.click(toggleButton);
202
+
203
+ const nestedTrigger = await screen.findByRole('menuitem', { name: 'Web Clipboard' });
204
+ await user.hover(nestedTrigger);
205
+
206
+ const nestedMenu = await screen.findByRole('menu', { name: 'Web Clipboard' });
207
+ fireEvent.mouseLeave(nestedTrigger, { relatedTarget: nestedMenu });
208
+ fireEvent.mouseLeave(nestedMenu, { relatedTarget: nestedTrigger });
209
+
210
+ expect(nestedMenu).toBeVisible();
211
+ });
212
+
213
+ it('closes the submenu when the mouse leaves away from the trigger (critical Popper behavior)', async () => {
214
+ const { user, toggleButton } = setupMenu();
215
+ await user.click(toggleButton);
216
+
217
+ const nestedTrigger = await screen.findByRole('menuitem', { name: 'Web Clipboard' });
218
+ await user.hover(nestedTrigger);
219
+
220
+ const nestedMenu = await screen.findByRole('menu', { name: 'Web Clipboard' });
221
+ fireEvent.mouseLeave(nestedMenu, { relatedTarget: document.body });
222
+
223
+ await waitFor(() => {
224
+ expect(screen.queryByRole('menu', { name: 'Web Clipboard' })).not.toBeInTheDocument();
225
+ });
226
+ expect(nestedTrigger).not.toHaveAttribute('aria-expanded');
227
+ });
228
+
229
+ describe('Accessibility features', () => {
230
+ it('exposes ARIA relationships so screen readers can describe nested menus', async () => {
231
+ const { user, toggleButton } = setupMenu();
232
+ await user.click(toggleButton);
233
+
234
+ const nestedTrigger = await screen.findByRole('menuitem', { name: 'Web Clipboard' });
235
+ expect(nestedTrigger).toHaveAttribute('aria-haspopup', 'menu');
236
+ expect(nestedTrigger).toHaveAttribute('aria-controls', 'web-clipboard-submenu');
237
+ expect(nestedTrigger.id).toBe('web-clipboard');
238
+ expect(nestedTrigger).not.toHaveAttribute('aria-expanded');
239
+
240
+ await user.hover(nestedTrigger);
241
+ const nestedMenu = await screen.findByRole('menu', { name: 'Web Clipboard' });
242
+ expect(nestedMenu).toHaveAttribute('id', 'web-clipboard-submenu');
243
+ expect(nestedMenu).toHaveAttribute('aria-labelledby', nestedTrigger.id);
244
+ expect(nestedMenu).toHaveAttribute('role', 'menu');
245
+ expect(nestedTrigger).toHaveAttribute('aria-expanded', 'true');
246
+ });
247
+
248
+ it('supports keyboard navigation so focus stays with the active branch', async () => {
249
+ const { user, toggleButton } = setupMenu();
250
+ await user.click(toggleButton);
251
+
252
+ const nestedTrigger = await screen.findByRole('menuitem', { name: 'Web Clipboard' });
253
+ await act(() => {
254
+ nestedTrigger.focus();
255
+ });
256
+
257
+ await user.keyboard('{ArrowRight}');
258
+ const firstNestedItem = await screen.findByRole('menuitem', { name: 'Google Cloud' });
259
+ await waitFor(() => {
260
+ expect(firstNestedItem).toHaveFocus();
261
+ });
262
+
263
+ await user.keyboard('{ArrowLeft}');
264
+ await waitFor(() => {
265
+ expect(screen.queryByRole('menu', { name: 'Web Clipboard' })).not.toBeInTheDocument();
266
+ expect(nestedTrigger).toHaveFocus();
267
+ });
268
+
269
+ await user.keyboard('{Enter}');
270
+ const reopenedItem = await screen.findByRole('menuitem', { name: 'Google Cloud' });
271
+ await waitFor(() => {
272
+ expect(reopenedItem).toHaveFocus();
273
+ });
274
+ });
275
+
276
+ describe('Keyboard actions & Focus management', () => {
277
+ it('traversing down to deepest level and back with keyboard', async () => {
278
+ const { user, toggleButton } = setupMenu();
279
+ await user.click(toggleButton);
280
+ await waitFor(async () => {
281
+ expect(screen.getByTestId('root-menu')).toBeVisible();
282
+ // jump in the menu
283
+ await user.keyboard('{ArrowDown}');
284
+ });
285
+
286
+ // Step 1: Open Web Clipboard submenu
287
+ await user.keyboard('{ArrowDown}{ArrowDown}{ArrowRight}');
288
+ await waitFor(() => {
289
+ expect(screen.getByTestId('web-clipboard-submenu')).toBeVisible();
290
+ });
291
+ expect(screen.getByRole('menuitem', { name: 'Google Cloud' })).toHaveFocus();
292
+
293
+ // Step 2: Open Deep Options submenu
294
+ await user.keyboard('{ArrowDown}{ArrowRight}');
295
+ await waitFor(() => {
296
+ expect(screen.getByRole('menu', { name: 'Deep Options' })).toBeVisible();
297
+ });
298
+ expect(screen.getByRole('menuitem', { name: 'Deep Item' })).toHaveFocus();
299
+
300
+ // Step 3: Close Deep Options submenu
301
+ await user.keyboard('{ArrowLeft}');
302
+ const deepOptionsTrigger = screen.getByRole('menuitem', { name: 'Deep Options' });
303
+ await waitFor(() => {
304
+ expect(screen.queryByRole('menu', { name: 'Deep Options' })).not.toBeInTheDocument();
305
+ expect(deepOptionsTrigger).toHaveFocus();
306
+ });
307
+
308
+ // Step 4: Close Web Clipboard submenu
309
+ await user.keyboard('{ArrowLeft}');
310
+ const webClipboardTrigger = screen.getByRole('menuitem', { name: 'Web Clipboard' });
311
+ await waitFor(() => {
312
+ expect(screen.queryByRole('menu', { name: 'Web Clipboard' })).not.toBeInTheDocument();
313
+ expect(webClipboardTrigger).toHaveFocus();
314
+ });
315
+ });
316
+
317
+ it('traversing down to deepest level and back using mouse', async () => {
318
+ const { user, toggleButton } = setupMenu();
319
+ await user.click(toggleButton);
320
+ await waitFor(() => {
321
+ expect(screen.getByTestId('root-menu')).toBeVisible();
322
+ });
323
+ // Step 1: Open Web Clipboard submenu
324
+ const webClipboardTrigger = screen.getByRole('menuitem', { name: 'Web Clipboard' });
325
+ await user.hover(webClipboardTrigger);
326
+ await waitFor(() => {
327
+ expect(screen.getByTestId('web-clipboard-submenu')).toBeVisible();
328
+ });
329
+
330
+ // Step 2: Open Deep Options submenu
331
+ const deepOptionsTrigger = screen.getByRole('menuitem', { name: 'Deep Options' });
332
+ await user.hover(deepOptionsTrigger);
333
+ await waitFor(() => {
334
+ expect(screen.getByRole('menu', { name: 'Deep Options' })).toBeVisible();
335
+ });
336
+
337
+ // Step 3: Go in the deep options menu
338
+ const deepItem = screen.getByRole('menuitem', { name: 'Deep Item' });
339
+ await user.hover(deepItem);
340
+
341
+ // Step 4: Hover deep options trigger and expect submenu to not close
342
+ await user.hover(deepOptionsTrigger);
343
+ expect(screen.getByRole('menu', { name: 'Deep Options' })).toBeVisible();
344
+
345
+ // Step 5: Hover Google Cloud trigger and expect deep options submenu to close
346
+ const googleCloudTrigger = screen.getByRole('menuitem', { name: 'Google Cloud' });
347
+ await user.hover(googleCloudTrigger);
348
+ await waitFor(() => {
349
+ expect(screen.queryByRole('menu', { name: 'Deep Options' })).not.toBeInTheDocument();
350
+ });
351
+
352
+ // Step 6: Hover Cut trigger and expect Web Clipboard submenu to close
353
+ const cutTrigger = screen.getByRole('menuitem', { name: 'Cut' });
354
+ await user.hover(cutTrigger);
355
+ await waitFor(() => {
356
+ expect(screen.queryByRole('menu', { name: 'Web Clipboard' })).not.toBeInTheDocument();
357
+ });
358
+
359
+ // Finally, click out side and expect root menu to close
360
+ const backdrop = document.querySelector('[class*="MuiBackdrop-root"]');
361
+ if (!backdrop) throw new Error('Backdrop not found');
362
+ await user.click(backdrop);
363
+ await waitFor(() => {
364
+ expect(screen.queryByTestId('root-menu')).not.toBeInTheDocument();
365
+ });
366
+ });
367
+ });
368
+ });
369
+ });
@@ -0,0 +1,241 @@
1
+ import type { FC, ReactNode, MouseEvent, KeyboardEvent } from 'react';
2
+ import { Children, cloneElement, isValidElement, useCallback, useId, useRef, useState } from 'react';
3
+ import Fade from '@mui/material/Fade';
4
+ import type { MenuItemProps } from '@mui/material/MenuItem';
5
+ import MenuItem from '@mui/material/MenuItem';
6
+ import Divider from '@mui/material/Divider';
7
+ import ArrowRightIcon from '@mui/icons-material/ArrowRight';
8
+ import type { SvgIconComponent } from '@mui/icons-material';
9
+ import { MenuList, Paper, Popper, Typography } from '@mui/material';
10
+ import type { MultiLevelMenuItem } from './types';
11
+ import { MenuItemContent, transitionConfig } from './common';
12
+
13
+ type NestedMenuItemProps = MenuItemProps & {
14
+ label: ReactNode;
15
+ startIcon?: SvgIconComponent;
16
+ endIcon?: SvgIconComponent;
17
+ parentMenuClose: () => void;
18
+ children?: ReactNode;
19
+ items?: MultiLevelMenuItem[];
20
+ };
21
+
22
+ const isNodeInstance = (target: EventTarget | null): target is Node => target instanceof Node;
23
+
24
+ export const NestedMenuItem: FC<NestedMenuItemProps> = props => {
25
+ const {
26
+ id: providedId,
27
+ label,
28
+ startIcon: StartIconComponent,
29
+ parentMenuClose,
30
+ children,
31
+ items,
32
+ endIcon: _,
33
+ ...menuItemProps
34
+ } = props;
35
+ const [subMenuAnchorEl, setSubMenuAnchorEl] = useState<null | HTMLElement>(null);
36
+ const open = Boolean(subMenuAnchorEl);
37
+ const menuItemRef = useRef<HTMLLIElement>(null);
38
+ const subMenuRef = useRef<HTMLDivElement>(null);
39
+ const generatedId = useId();
40
+ const menuItemId = providedId ?? `nested-menu-trigger-${generatedId}`;
41
+ const subMenuId = `${menuItemId}-submenu`;
42
+
43
+ const handleOpen = (event: MouseEvent<HTMLLIElement> | KeyboardEvent<HTMLLIElement>) => {
44
+ setSubMenuAnchorEl(event.currentTarget);
45
+ };
46
+
47
+ const handleClose = useCallback(() => {
48
+ setSubMenuAnchorEl(null);
49
+ }, []);
50
+
51
+ const renderChildren = Children.map(children, child => {
52
+ if (!isValidElement(child)) return child;
53
+
54
+ // Ensure we only process MUI MenuItem children
55
+ if (child.type === MenuItem) {
56
+ const childOnClick = (child.props as MenuItemProps).onClick;
57
+ // Merge any user-defined click logic with the submenu closing behavior.
58
+ const clonedOnClick = (event: MouseEvent<HTMLLIElement>) => {
59
+ childOnClick?.(event);
60
+ handleClose(); // Close the submenu
61
+ parentMenuClose();
62
+ };
63
+ return cloneElement(child, { onClick: clonedOnClick } as Partial<MenuItemProps>);
64
+ }
65
+ return child;
66
+ });
67
+
68
+ const renderItemsFromData = () => {
69
+ if (!items || items.length === 0) return null;
70
+
71
+ return items.map((item, index) => {
72
+ if (item.type === 'divider') {
73
+
74
+ return <Divider key={`divider-${index}`} />;
75
+ }
76
+
77
+ const {
78
+ type: __,
79
+ items: entryItems,
80
+ startIcon: NestedMenuItemStartIcon,
81
+ endIcon: NestedMenuItemEndIcon,
82
+ label: entryLabel,
83
+ onClick,
84
+ id,
85
+ } = item;
86
+ const entryId = id ?? `${menuItemId}-entry-${index}`;
87
+ const entryKey = `nested-entry-${entryId}`;
88
+ const entryLabelValue = entryLabel ?? entryId;
89
+
90
+ if (entryItems && entryItems.length > 0) {
91
+ return (
92
+ <NestedMenuItem
93
+ key={entryKey}
94
+ id={entryId}
95
+ label={entryLabelValue}
96
+ startIcon={NestedMenuItemStartIcon}
97
+ endIcon={NestedMenuItemEndIcon}
98
+ parentMenuClose={parentMenuClose}
99
+ items={entryItems}
100
+ />
101
+ );
102
+ }
103
+
104
+ const handleItemClick = (event: MouseEvent<HTMLLIElement>) => {
105
+ onClick?.(event);
106
+ handleClose();
107
+ parentMenuClose();
108
+ };
109
+
110
+ return (
111
+ <MenuItem key={entryKey} onClick={handleItemClick}>
112
+ <MenuItemContent>
113
+ {NestedMenuItemStartIcon ? <NestedMenuItemStartIcon /> : null}
114
+ <Typography sx={{ flex: 1 }}>{entryLabelValue}</Typography>
115
+ {NestedMenuItemEndIcon ? <NestedMenuItemEndIcon /> : null}
116
+ </MenuItemContent>
117
+ </MenuItem>
118
+ );
119
+ });
120
+ };
121
+
122
+ const renderedSubMenuItems = items && items.length > 0 ? renderItemsFromData() : renderChildren;
123
+
124
+ return (
125
+ <>
126
+ <MenuItem
127
+ data-testid={`${menuItemId}-trigger`}
128
+ id={menuItemId}
129
+ ref={menuItemRef}
130
+ onMouseEnter={handleOpen}
131
+ onMouseLeave={e => {
132
+ // CRITICAL FEATURE:
133
+ // Checking whether cursor left the menu item onto the related menu. If so, do not close.
134
+ // TODO(ege): There can be a timeout here before we execute closing to improve UX - in case user is not very precise with mouse.
135
+ if (isNodeInstance(e.relatedTarget) && subMenuRef.current?.contains(e.relatedTarget)) return;
136
+ // If the cursor leaves to anywhere else, close the submenu.
137
+ handleClose();
138
+ }}
139
+ onKeyDown={e => {
140
+ e.preventDefault();
141
+ if (e.key === 'ArrowLeft') {
142
+ handleClose();
143
+ }
144
+ if (e.key === 'ArrowRight' || e.key === 'Enter' || e.key === ' ') {
145
+ handleOpen(e);
146
+ }
147
+ }}
148
+ aria-haspopup='menu'
149
+ aria-controls={subMenuId}
150
+ aria-expanded={open ? 'true' : undefined}
151
+ {...menuItemProps}
152
+ >
153
+ <MenuItemContent>
154
+ {StartIconComponent ? <StartIconComponent /> : null}
155
+ <Typography sx={{ flex: 1 }}>{label}</Typography>
156
+ <ArrowRightIcon />
157
+ </MenuItemContent>
158
+ </MenuItem>
159
+
160
+ <Popper
161
+ data-testid={`${menuItemId}-submenu`}
162
+ open={open}
163
+ ref={subMenuRef}
164
+ anchorEl={subMenuAnchorEl}
165
+ transition
166
+ sx={{ zIndex: t => t.zIndex.modal + 1 }}
167
+ placement='right-start'
168
+ onKeyDown={e => {
169
+ if (e.key === 'ArrowLeft') {
170
+ e.preventDefault();
171
+ handleClose();
172
+ menuItemRef.current?.focus();
173
+ }
174
+ if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
175
+ e.preventDefault();
176
+ e.stopPropagation();
177
+ }
178
+ }}
179
+ onMouseLeave={e => {
180
+ // CRITICAL FEATURE:
181
+ // Checking whether cursor left the submenu onto the related trigger item. If so, do not close.
182
+ // TODO(ege): There can be a timeout here before we execute closing to improve UX - in case user is not very precise with mouse.
183
+ if (isNodeInstance(e.relatedTarget) && menuItemRef.current?.contains(e.relatedTarget)) return;
184
+ // If the cursor leaves to anywhere else, close the submenu.
185
+ handleClose();
186
+ }}
187
+ >
188
+ {({ TransitionProps }) => (
189
+ <Fade {...TransitionProps} timeout={transitionConfig.timeout}>
190
+ <Paper
191
+ elevation={2}
192
+ sx={{
193
+ borderRadius: 1,
194
+ bgcolor: 'background.default',
195
+ }}
196
+ >
197
+ <MenuList
198
+ autoFocusItem
199
+ id={subMenuId}
200
+ aria-labelledby={menuItemId}
201
+ role='menu'
202
+ onKeyDown={e => {
203
+ if (e.key === 'ArrowLeft') {
204
+ const { nativeEvent } = e as KeyboardEvent<HTMLUListElement> & {
205
+ nativeEvent: KeyboardEvent & {
206
+ // our custom flag to avoid duplicate handling in nested menus
207
+ __nestedMenuArrowLeftHandled?: boolean;
208
+ };
209
+ };
210
+ if (nativeEvent.__nestedMenuArrowLeftHandled) {
211
+ // return early if we have already handled this event in a nested menu
212
+ // prevents duplicate logic when the browser bubbles the same keypress through multiple nodes
213
+ return;
214
+ }
215
+ if (!subMenuRef.current?.contains(e.target as Node)) {
216
+ // confirm the event’s target is still inside this submenu;
217
+ // if the keypress originated elsewhere, we don’t continue so other menus can handle it
218
+ return;
219
+ }
220
+ // Mark this event as handled to prevent parent menus from also processing it
221
+ nativeEvent.__nestedMenuArrowLeftHandled = true;
222
+ e.preventDefault();
223
+ e.stopPropagation();
224
+ // immediately halt any other listeners so we have exclusive control now
225
+ nativeEvent.stopImmediatePropagation();
226
+ // close the sub menu
227
+ handleClose();
228
+ // move focus back to the parent MenuItem, letting the user continue navigation up the menu hierarchy
229
+ menuItemRef.current?.focus();
230
+ }
231
+ }}
232
+ >
233
+ {renderedSubMenuItems}
234
+ </MenuList>
235
+ </Paper>
236
+ </Fade>
237
+ )}
238
+ </Popper>
239
+ </>
240
+ );
241
+ };
@@ -0,0 +1,18 @@
1
+ import { Stack, styled } from '@mui/material';
2
+ import Fade from '@mui/material/Fade';
3
+
4
+ export const transitionConfig = {
5
+ type: Fade,
6
+ timeout: { enter: 100, exit: 100 },
7
+ };
8
+
9
+ export const MenuItemContent = styled(Stack)(({ theme }) => ({
10
+ flexDirection: 'row',
11
+ alignItems: 'center',
12
+ gap: theme.spacing(1),
13
+ fontSize: theme.typography.body1.fontSize,
14
+ '& .MuiSvgIcon-root': {
15
+ fontSize: theme.typography.body2.fontSize,
16
+ },
17
+ padding: 0,
18
+ }));