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.
- package/README.md +119 -0
- package/dist/index.cjs +323 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +26 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +290 -0
- package/dist/index.js.map +1 -0
- package/jest.config.cjs +15 -0
- package/jest.setup.ts +1 -0
- package/package.json +61 -0
- package/scripts/ensure-version-tag.mjs +54 -0
- package/src/MultiLevelMenu/MultiLevelMenu.test.tsx +369 -0
- package/src/MultiLevelMenu/NestedMenuItem.tsx +241 -0
- package/src/MultiLevelMenu/common.ts +18 -0
- package/src/MultiLevelMenu/index.tsx +89 -0
- package/src/MultiLevelMenu/types.ts +16 -0
- package/src/index.ts +4 -0
- package/tsconfig.jest.json +10 -0
- package/tsup.config.ts +16 -0
|
@@ -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
|
+
}));
|