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 ADDED
@@ -0,0 +1,119 @@
1
+ # better-mui-menu
2
+
3
+ `better-mui-menu` is a tiny component library that supplies a keyboard- and mouse-friendly Material UI menu with unlimited nesting depth. It wraps `@mui/material/Menu` + `Popper` + `Fade` and exposes a simple data-driven API so that a single `items` array can render both leaves and nested submenus.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install better-mui-menu
9
+ ```
10
+
11
+ Because the package delegates rendering to Material UI, make sure the runtime peers are installed as well:
12
+
13
+ ```bash
14
+ npm install @mui/material @emotion/react @emotion/styled @mui/icons-material
15
+ ```
16
+
17
+ The icons dependency is only required when you pass `startIcon`/`endIcon`, but it is part of the peer dependency list so that consumers can supply Material icons without additional typings setup.
18
+
19
+ ## Usage
20
+
21
+ ```tsx
22
+ import { useState } from 'react'
23
+ import Button from '@mui/material/Button'
24
+ import Cloud from '@mui/icons-material/Cloud'
25
+ import Save from '@mui/icons-material/Save'
26
+ import { MultiLevelMenu, type MultiLevelMenuItem } from 'better-mui-menu'
27
+
28
+ const menuItems: MultiLevelMenuItem[] = [
29
+ {
30
+ id: 'save',
31
+ label: 'Save',
32
+ startIcon: Save,
33
+ onClick: () => {
34
+ console.log('Save action')
35
+ },
36
+ },
37
+ {
38
+ type: 'divider',
39
+ },
40
+ {
41
+ label: 'Cloud actions',
42
+ startIcon: Cloud,
43
+ items: [
44
+ {
45
+ label: 'Upload',
46
+ onClick: () => console.log('Upload'),
47
+ },
48
+ {
49
+ label: 'Download',
50
+ onClick: () => console.log('Download'),
51
+ },
52
+ ],
53
+ },
54
+ ]
55
+
56
+ export function FileMenu() {
57
+ const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
58
+
59
+ return (
60
+ <>
61
+ <Button variant='contained' onClick={event => setAnchorEl(event.currentTarget)}>
62
+ Open file menu
63
+ </Button>
64
+ <MultiLevelMenu
65
+ anchorEl={anchorEl}
66
+ open={Boolean(anchorEl)}
67
+ onClose={() => setAnchorEl(null)}
68
+ items={menuItems}
69
+ />
70
+ </>
71
+ )
72
+ }
73
+ ```
74
+
75
+ ## API
76
+
77
+ ### `MultiLevelMenu` props
78
+
79
+ - `anchorEl: HTMLElement | null` – the anchor element that the root menu positions itself against.
80
+ - `open: boolean` – controlled open state of the root menu.
81
+ - `onClose: () => void` – invoked whenever the menu should close (overlay click, Escape, item selection).
82
+ - `items: MultiLevelMenuItem[]` – the array that drives both leaf menu items and nested branches.
83
+
84
+ ### `MultiLevelMenuItem`
85
+
86
+ `MultiLevelMenuItem` extends `@mui/material/MenuItemProps` (excluding `children`) so you can pass `disabled`, `dense`, `divider`, etc. The shape adds a few helpers:
87
+
88
+ - `type?: 'item' | 'divider'` – set `'divider'` to render a `Divider` instead of a `MenuItem`.
89
+ - `id?: string` – optional string used for ARIA attributes; a stable ID is generated automatically when omitted.
90
+ - `label: ReactNode` – the text or custom node shown inside the menu entry.
91
+ - `startIcon?: SvgIconComponent`, `endIcon?: SvgIconComponent` – render Material icons before/after the label using the shared `MenuItemContent` layout.
92
+ - `items?: MultiLevelMenuItem[]` – nested descriptors that render as a submenu via `NestedMenuItem`.
93
+ - `onClick?: MenuItemProps['onClick']` – clicking a leaf entry automatically propagates the handler and closes the entire menu stack.
94
+
95
+ ## Interactions & accessibility
96
+
97
+ - Nested menus appear in a `Popper` placed `right-start` from the parent trigger and use a shared fade transition (`transitionConfig`).
98
+ - Mouse hover keeps submenus open while the cursor travels between a trigger and its nested popper; moving away closes the submenu.
99
+ - Keyboard navigation supports `ArrowRight`/`Enter`/`Space` to open children, `ArrowLeft` to close, `ArrowUp`/`ArrowDown` to move between entries, and `Escape` to dismiss everything. The component wires the necessary `aria-haspopup`, `aria-controls`, `aria-expanded`, and `aria-labelledby` attributes so that screen readers describe the menu hierarchy.
100
+
101
+ ## Development
102
+
103
+ All development happens under `package/better-mui-menu`:
104
+
105
+ - `npm run dev` – runs `tsup --watch` to rebuild `src` into `dist`.
106
+ - `npm run build` – creates production bundles ready for publication.
107
+ - `npm run test` – executes the Jest suite defined in `src/MultiLevelMenu/MultiLevelMenu.test.tsx`.
108
+
109
+ The root workspace exposes `npm run dev:lib` and `npm run dev:demo` to simultaneously rebuild the library and power the demo app. When you change the menu source, keep `npm run dev` (or `npm run build`) running before refreshing the demo because the Vite app imports the library via its `file:` workspace link.
110
+
111
+ ## Demo
112
+
113
+ `app/demo` is a Vite + React 19 application that consumes the built package. From the repository root run:
114
+
115
+ ```bash
116
+ npm run dev:demo
117
+ ```
118
+
119
+ Check `app/demo/README.md` for more Vite-specific guidance.
package/dist/index.cjs ADDED
@@ -0,0 +1,323 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ default: () => index_default
34
+ });
35
+ module.exports = __toCommonJS(index_exports);
36
+
37
+ // src/MultiLevelMenu/index.tsx
38
+ var import_Menu = __toESM(require("@mui/material/Menu"), 1);
39
+ var import_Divider2 = __toESM(require("@mui/material/Divider"), 1);
40
+ var import_MenuItem2 = __toESM(require("@mui/material/MenuItem"), 1);
41
+ var import_react2 = require("react");
42
+ var import_material3 = require("@mui/material");
43
+
44
+ // src/MultiLevelMenu/NestedMenuItem.tsx
45
+ var import_react = require("react");
46
+ var import_Fade2 = __toESM(require("@mui/material/Fade"), 1);
47
+ var import_MenuItem = __toESM(require("@mui/material/MenuItem"), 1);
48
+ var import_Divider = __toESM(require("@mui/material/Divider"), 1);
49
+ var import_ArrowRight = __toESM(require("@mui/icons-material/ArrowRight"), 1);
50
+ var import_material2 = require("@mui/material");
51
+
52
+ // src/MultiLevelMenu/common.ts
53
+ var import_material = require("@mui/material");
54
+ var import_Fade = __toESM(require("@mui/material/Fade"), 1);
55
+ var transitionConfig = {
56
+ type: import_Fade.default,
57
+ timeout: { enter: 100, exit: 100 }
58
+ };
59
+ var MenuItemContent = (0, import_material.styled)(import_material.Stack)(({ theme }) => ({
60
+ flexDirection: "row",
61
+ alignItems: "center",
62
+ gap: theme.spacing(1),
63
+ fontSize: theme.typography.body1.fontSize,
64
+ "& .MuiSvgIcon-root": {
65
+ fontSize: theme.typography.body2.fontSize
66
+ },
67
+ padding: 0
68
+ }));
69
+
70
+ // src/MultiLevelMenu/NestedMenuItem.tsx
71
+ var import_jsx_runtime = require("react/jsx-runtime");
72
+ var isNodeInstance = (target) => target instanceof Node;
73
+ var NestedMenuItem = (props) => {
74
+ const {
75
+ id: providedId,
76
+ label,
77
+ startIcon: StartIconComponent,
78
+ parentMenuClose,
79
+ children,
80
+ items,
81
+ endIcon: _,
82
+ ...menuItemProps
83
+ } = props;
84
+ const [subMenuAnchorEl, setSubMenuAnchorEl] = (0, import_react.useState)(null);
85
+ const open = Boolean(subMenuAnchorEl);
86
+ const menuItemRef = (0, import_react.useRef)(null);
87
+ const subMenuRef = (0, import_react.useRef)(null);
88
+ const generatedId = (0, import_react.useId)();
89
+ const menuItemId = providedId ?? `nested-menu-trigger-${generatedId}`;
90
+ const subMenuId = `${menuItemId}-submenu`;
91
+ const handleOpen = (event) => {
92
+ setSubMenuAnchorEl(event.currentTarget);
93
+ };
94
+ const handleClose = (0, import_react.useCallback)(() => {
95
+ setSubMenuAnchorEl(null);
96
+ }, []);
97
+ const renderChildren = import_react.Children.map(children, (child) => {
98
+ if (!(0, import_react.isValidElement)(child)) return child;
99
+ if (child.type === import_MenuItem.default) {
100
+ const childOnClick = child.props.onClick;
101
+ const clonedOnClick = (event) => {
102
+ childOnClick?.(event);
103
+ handleClose();
104
+ parentMenuClose();
105
+ };
106
+ return (0, import_react.cloneElement)(child, { onClick: clonedOnClick });
107
+ }
108
+ return child;
109
+ });
110
+ const renderItemsFromData = () => {
111
+ if (!items || items.length === 0) return null;
112
+ return items.map((item, index) => {
113
+ if (item.type === "divider") {
114
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_Divider.default, {}, `divider-${index}`);
115
+ }
116
+ const {
117
+ type: __,
118
+ items: entryItems,
119
+ startIcon: NestedMenuItemStartIcon,
120
+ endIcon: NestedMenuItemEndIcon,
121
+ label: entryLabel,
122
+ onClick,
123
+ id
124
+ } = item;
125
+ const entryId = id ?? `${menuItemId}-entry-${index}`;
126
+ const entryKey = `nested-entry-${entryId}`;
127
+ const entryLabelValue = entryLabel ?? entryId;
128
+ if (entryItems && entryItems.length > 0) {
129
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
130
+ NestedMenuItem,
131
+ {
132
+ id: entryId,
133
+ label: entryLabelValue,
134
+ startIcon: NestedMenuItemStartIcon,
135
+ endIcon: NestedMenuItemEndIcon,
136
+ parentMenuClose,
137
+ items: entryItems
138
+ },
139
+ entryKey
140
+ );
141
+ }
142
+ const handleItemClick = (event) => {
143
+ onClick?.(event);
144
+ handleClose();
145
+ parentMenuClose();
146
+ };
147
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_MenuItem.default, { onClick: handleItemClick, children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(MenuItemContent, { children: [
148
+ NestedMenuItemStartIcon ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(NestedMenuItemStartIcon, {}) : null,
149
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material2.Typography, { sx: { flex: 1 }, children: entryLabelValue }),
150
+ NestedMenuItemEndIcon ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(NestedMenuItemEndIcon, {}) : null
151
+ ] }) }, entryKey);
152
+ });
153
+ };
154
+ const renderedSubMenuItems = items && items.length > 0 ? renderItemsFromData() : renderChildren;
155
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
156
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
157
+ import_MenuItem.default,
158
+ {
159
+ "data-testid": `${menuItemId}-trigger`,
160
+ id: menuItemId,
161
+ ref: menuItemRef,
162
+ onMouseEnter: handleOpen,
163
+ onMouseLeave: (e) => {
164
+ if (isNodeInstance(e.relatedTarget) && subMenuRef.current?.contains(e.relatedTarget)) return;
165
+ handleClose();
166
+ },
167
+ onKeyDown: (e) => {
168
+ e.preventDefault();
169
+ if (e.key === "ArrowLeft") {
170
+ handleClose();
171
+ }
172
+ if (e.key === "ArrowRight" || e.key === "Enter" || e.key === " ") {
173
+ handleOpen(e);
174
+ }
175
+ },
176
+ "aria-haspopup": "menu",
177
+ "aria-controls": subMenuId,
178
+ "aria-expanded": open ? "true" : void 0,
179
+ ...menuItemProps,
180
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(MenuItemContent, { children: [
181
+ StartIconComponent ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(StartIconComponent, {}) : null,
182
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material2.Typography, { sx: { flex: 1 }, children: label }),
183
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_ArrowRight.default, {})
184
+ ] })
185
+ }
186
+ ),
187
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
188
+ import_material2.Popper,
189
+ {
190
+ "data-testid": `${menuItemId}-submenu`,
191
+ open,
192
+ ref: subMenuRef,
193
+ anchorEl: subMenuAnchorEl,
194
+ transition: true,
195
+ sx: { zIndex: (t) => t.zIndex.modal + 1 },
196
+ placement: "right-start",
197
+ onKeyDown: (e) => {
198
+ if (e.key === "ArrowLeft") {
199
+ e.preventDefault();
200
+ handleClose();
201
+ menuItemRef.current?.focus();
202
+ }
203
+ if (e.key === "ArrowUp" || e.key === "ArrowDown") {
204
+ e.preventDefault();
205
+ e.stopPropagation();
206
+ }
207
+ },
208
+ onMouseLeave: (e) => {
209
+ if (isNodeInstance(e.relatedTarget) && menuItemRef.current?.contains(e.relatedTarget)) return;
210
+ handleClose();
211
+ },
212
+ children: ({ TransitionProps }) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_Fade2.default, { ...TransitionProps, timeout: transitionConfig.timeout, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
213
+ import_material2.Paper,
214
+ {
215
+ elevation: 2,
216
+ sx: {
217
+ borderRadius: 1,
218
+ bgcolor: "background.default"
219
+ },
220
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
221
+ import_material2.MenuList,
222
+ {
223
+ autoFocusItem: true,
224
+ id: subMenuId,
225
+ "aria-labelledby": menuItemId,
226
+ role: "menu",
227
+ onKeyDown: (e) => {
228
+ if (e.key === "ArrowLeft") {
229
+ const { nativeEvent } = e;
230
+ if (nativeEvent.__nestedMenuArrowLeftHandled) {
231
+ return;
232
+ }
233
+ if (!subMenuRef.current?.contains(e.target)) {
234
+ return;
235
+ }
236
+ nativeEvent.__nestedMenuArrowLeftHandled = true;
237
+ e.preventDefault();
238
+ e.stopPropagation();
239
+ nativeEvent.stopImmediatePropagation();
240
+ handleClose();
241
+ menuItemRef.current?.focus();
242
+ }
243
+ },
244
+ children: renderedSubMenuItems
245
+ }
246
+ )
247
+ }
248
+ ) })
249
+ }
250
+ )
251
+ ] });
252
+ };
253
+
254
+ // src/MultiLevelMenu/index.tsx
255
+ var import_jsx_runtime2 = require("react/jsx-runtime");
256
+ function MultiLevelMenu({ anchorEl, open, onClose, items }) {
257
+ const generatedMenuId = (0, import_react2.useId)();
258
+ const renderedMenuEntries = items.map((item, index) => {
259
+ if (item.type === "divider") {
260
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_Divider2.default, {}, `divider-${index}`);
261
+ }
262
+ const {
263
+ type: _,
264
+ items: nestedItems,
265
+ startIcon: StartIconComponent,
266
+ endIcon: EndIconComponent,
267
+ label,
268
+ onClick,
269
+ id,
270
+ ...menuItemProps
271
+ } = item;
272
+ const entryId = id ?? `${generatedMenuId}-entry-${index}`;
273
+ const itemKey = `menu-item-id:${entryId}`;
274
+ const displayLabel = label ?? entryId;
275
+ if (nestedItems && nestedItems.length > 0) {
276
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
277
+ NestedMenuItem,
278
+ {
279
+ id: entryId,
280
+ label: displayLabel,
281
+ startIcon: StartIconComponent,
282
+ endIcon: EndIconComponent,
283
+ parentMenuClose: onClose,
284
+ items: nestedItems
285
+ },
286
+ itemKey
287
+ );
288
+ }
289
+ const handleItemClick = (event) => {
290
+ onClick?.(event);
291
+ onClose();
292
+ };
293
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_MenuItem2.default, { onClick: handleItemClick, ...menuItemProps, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(MenuItemContent, { children: [
294
+ StartIconComponent ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(StartIconComponent, {}) : null,
295
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_material3.Typography, { sx: { flex: 1 }, children: displayLabel }),
296
+ EndIconComponent ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(EndIconComponent, {}) : null
297
+ ] }) }, itemKey);
298
+ });
299
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
300
+ import_Menu.default,
301
+ {
302
+ "data-testid": "root-menu",
303
+ anchorEl,
304
+ open,
305
+ onClose,
306
+ onKeyDown: (e) => {
307
+ if (e.key === "Escape" && open) onClose();
308
+ },
309
+ transitionDuration: transitionConfig.timeout,
310
+ slots: { transition: transitionConfig.type },
311
+ slotProps: {
312
+ list: {
313
+ "aria-labelledby": "icon-menu-button"
314
+ }
315
+ },
316
+ children: renderedMenuEntries
317
+ }
318
+ );
319
+ }
320
+
321
+ // src/index.ts
322
+ var index_default = MultiLevelMenu;
323
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/MultiLevelMenu/index.tsx","../src/MultiLevelMenu/NestedMenuItem.tsx","../src/MultiLevelMenu/common.ts"],"sourcesContent":["import { MultiLevelMenu } from './MultiLevelMenu'\n\nexport type { MultiLevelMenuItem } from './MultiLevelMenu/types'\nexport default MultiLevelMenu","import Menu from '@mui/material/Menu';\nimport Divider from '@mui/material/Divider';\nimport MenuItem from '@mui/material/MenuItem';\nimport React, { useId } from 'react';\nimport { Typography } from '@mui/material';\nimport { NestedMenuItem } from './NestedMenuItem';\nimport type { MultiLevelMenuItem } from './types';\nimport { MenuItemContent, transitionConfig } from './common';\n\ntype Props = {\n anchorEl: null | HTMLElement;\n open: boolean;\n onClose: () => void;\n items: MultiLevelMenuItem[];\n};\n\nexport function MultiLevelMenu({ anchorEl, open, onClose, items }: Props) {\n const generatedMenuId = useId();\n\n const renderedMenuEntries = items.map((item, index) => {\n if (item.type === 'divider') {\n return <Divider key={`divider-${index}`} />;\n }\n\n const {\n type: _,\n items: nestedItems,\n startIcon: StartIconComponent,\n endIcon: EndIconComponent,\n label,\n onClick,\n id,\n ...menuItemProps\n } = item;\n const entryId = id ?? `${generatedMenuId}-entry-${index}`;\n const itemKey = `menu-item-id:${entryId}`;\n const displayLabel = label ?? entryId;\n\n if (nestedItems && nestedItems.length > 0) {\n return (\n <NestedMenuItem\n key={itemKey}\n id={entryId}\n label={displayLabel}\n startIcon={StartIconComponent}\n endIcon={EndIconComponent}\n parentMenuClose={onClose}\n items={nestedItems}\n />\n );\n }\n\n const handleItemClick = (event: React.MouseEvent<HTMLLIElement>) => {\n onClick?.(event);\n onClose();\n };\n\n return (\n <MenuItem key={itemKey} onClick={handleItemClick} {...menuItemProps}>\n <MenuItemContent>\n {StartIconComponent ? <StartIconComponent /> : null}\n <Typography sx={{ flex: 1 }}>{displayLabel}</Typography>\n {EndIconComponent ? <EndIconComponent /> : null}\n </MenuItemContent>\n </MenuItem>\n );\n });\n\n return (\n <Menu\n data-testid='root-menu'\n anchorEl={anchorEl}\n open={open}\n onClose={onClose}\n onKeyDown={e => {\n if (e.key === 'Escape' && open) onClose();\n }}\n transitionDuration={transitionConfig.timeout}\n slots={{ transition: transitionConfig.type }}\n slotProps={{\n list: {\n 'aria-labelledby': 'icon-menu-button',\n },\n }}\n >\n {renderedMenuEntries}\n </Menu>\n );\n}\n","import type { FC, ReactNode, MouseEvent, KeyboardEvent } from 'react';\nimport { Children, cloneElement, isValidElement, useCallback, useId, useRef, useState } from 'react';\nimport Fade from '@mui/material/Fade';\nimport type { MenuItemProps } from '@mui/material/MenuItem';\nimport MenuItem from '@mui/material/MenuItem';\nimport Divider from '@mui/material/Divider';\nimport ArrowRightIcon from '@mui/icons-material/ArrowRight';\nimport type { SvgIconComponent } from '@mui/icons-material';\nimport { MenuList, Paper, Popper, Typography } from '@mui/material';\nimport type { MultiLevelMenuItem } from './types';\nimport { MenuItemContent, transitionConfig } from './common';\n\ntype NestedMenuItemProps = MenuItemProps & {\n label: ReactNode;\n startIcon?: SvgIconComponent;\n endIcon?: SvgIconComponent;\n parentMenuClose: () => void;\n children?: ReactNode;\n items?: MultiLevelMenuItem[];\n};\n\nconst isNodeInstance = (target: EventTarget | null): target is Node => target instanceof Node;\n\nexport const NestedMenuItem: FC<NestedMenuItemProps> = props => {\n const {\n id: providedId,\n label,\n startIcon: StartIconComponent,\n parentMenuClose,\n children,\n items,\n endIcon: _,\n ...menuItemProps\n } = props;\n const [subMenuAnchorEl, setSubMenuAnchorEl] = useState<null | HTMLElement>(null);\n const open = Boolean(subMenuAnchorEl);\n const menuItemRef = useRef<HTMLLIElement>(null);\n const subMenuRef = useRef<HTMLDivElement>(null);\n const generatedId = useId();\n const menuItemId = providedId ?? `nested-menu-trigger-${generatedId}`;\n const subMenuId = `${menuItemId}-submenu`;\n\n const handleOpen = (event: MouseEvent<HTMLLIElement> | KeyboardEvent<HTMLLIElement>) => {\n setSubMenuAnchorEl(event.currentTarget);\n };\n\n const handleClose = useCallback(() => {\n setSubMenuAnchorEl(null);\n }, []);\n\n const renderChildren = Children.map(children, child => {\n if (!isValidElement(child)) return child;\n\n // Ensure we only process MUI MenuItem children\n if (child.type === MenuItem) {\n const childOnClick = (child.props as MenuItemProps).onClick;\n // Merge any user-defined click logic with the submenu closing behavior.\n const clonedOnClick = (event: MouseEvent<HTMLLIElement>) => {\n childOnClick?.(event);\n handleClose(); // Close the submenu\n parentMenuClose();\n };\n return cloneElement(child, { onClick: clonedOnClick } as Partial<MenuItemProps>);\n }\n return child;\n });\n\n const renderItemsFromData = () => {\n if (!items || items.length === 0) return null;\n\n return items.map((item, index) => {\n if (item.type === 'divider') {\n\n return <Divider key={`divider-${index}`} />;\n }\n\n const {\n type: __,\n items: entryItems,\n startIcon: NestedMenuItemStartIcon,\n endIcon: NestedMenuItemEndIcon,\n label: entryLabel,\n onClick,\n id,\n } = item;\n const entryId = id ?? `${menuItemId}-entry-${index}`;\n const entryKey = `nested-entry-${entryId}`;\n const entryLabelValue = entryLabel ?? entryId;\n\n if (entryItems && entryItems.length > 0) {\n return (\n <NestedMenuItem\n key={entryKey}\n id={entryId}\n label={entryLabelValue}\n startIcon={NestedMenuItemStartIcon}\n endIcon={NestedMenuItemEndIcon}\n parentMenuClose={parentMenuClose}\n items={entryItems}\n />\n );\n }\n\n const handleItemClick = (event: MouseEvent<HTMLLIElement>) => {\n onClick?.(event);\n handleClose();\n parentMenuClose();\n };\n\n return (\n <MenuItem key={entryKey} onClick={handleItemClick}>\n <MenuItemContent>\n {NestedMenuItemStartIcon ? <NestedMenuItemStartIcon /> : null}\n <Typography sx={{ flex: 1 }}>{entryLabelValue}</Typography>\n {NestedMenuItemEndIcon ? <NestedMenuItemEndIcon /> : null}\n </MenuItemContent>\n </MenuItem>\n );\n });\n };\n\n const renderedSubMenuItems = items && items.length > 0 ? renderItemsFromData() : renderChildren;\n\n return (\n <>\n <MenuItem\n data-testid={`${menuItemId}-trigger`}\n id={menuItemId}\n ref={menuItemRef}\n onMouseEnter={handleOpen}\n onMouseLeave={e => {\n // CRITICAL FEATURE:\n // Checking whether cursor left the menu item onto the related menu. If so, do not close.\n // TODO(ege): There can be a timeout here before we execute closing to improve UX - in case user is not very precise with mouse.\n if (isNodeInstance(e.relatedTarget) && subMenuRef.current?.contains(e.relatedTarget)) return;\n // If the cursor leaves to anywhere else, close the submenu.\n handleClose();\n }}\n onKeyDown={e => {\n e.preventDefault();\n if (e.key === 'ArrowLeft') {\n handleClose();\n }\n if (e.key === 'ArrowRight' || e.key === 'Enter' || e.key === ' ') {\n handleOpen(e);\n }\n }}\n aria-haspopup='menu'\n aria-controls={subMenuId}\n aria-expanded={open ? 'true' : undefined}\n {...menuItemProps}\n >\n <MenuItemContent>\n {StartIconComponent ? <StartIconComponent /> : null}\n <Typography sx={{ flex: 1 }}>{label}</Typography>\n <ArrowRightIcon />\n </MenuItemContent>\n </MenuItem>\n\n <Popper\n data-testid={`${menuItemId}-submenu`}\n open={open}\n ref={subMenuRef}\n anchorEl={subMenuAnchorEl}\n transition\n sx={{ zIndex: t => t.zIndex.modal + 1 }}\n placement='right-start'\n onKeyDown={e => {\n if (e.key === 'ArrowLeft') {\n e.preventDefault();\n handleClose();\n menuItemRef.current?.focus();\n }\n if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {\n e.preventDefault();\n e.stopPropagation();\n }\n }}\n onMouseLeave={e => {\n // CRITICAL FEATURE:\n // Checking whether cursor left the submenu onto the related trigger item. If so, do not close.\n // TODO(ege): There can be a timeout here before we execute closing to improve UX - in case user is not very precise with mouse.\n if (isNodeInstance(e.relatedTarget) && menuItemRef.current?.contains(e.relatedTarget)) return;\n // If the cursor leaves to anywhere else, close the submenu.\n handleClose();\n }}\n >\n {({ TransitionProps }) => (\n <Fade {...TransitionProps} timeout={transitionConfig.timeout}>\n <Paper\n elevation={2}\n sx={{\n borderRadius: 1,\n bgcolor: 'background.default',\n }}\n >\n <MenuList\n autoFocusItem\n id={subMenuId}\n aria-labelledby={menuItemId}\n role='menu'\n onKeyDown={e => {\n if (e.key === 'ArrowLeft') {\n const { nativeEvent } = e as KeyboardEvent<HTMLUListElement> & {\n nativeEvent: KeyboardEvent & {\n // our custom flag to avoid duplicate handling in nested menus\n __nestedMenuArrowLeftHandled?: boolean;\n };\n };\n if (nativeEvent.__nestedMenuArrowLeftHandled) {\n // return early if we have already handled this event in a nested menu\n // prevents duplicate logic when the browser bubbles the same keypress through multiple nodes\n return;\n }\n if (!subMenuRef.current?.contains(e.target as Node)) {\n // confirm the event’s target is still inside this submenu;\n // if the keypress originated elsewhere, we don’t continue so other menus can handle it\n return;\n }\n // Mark this event as handled to prevent parent menus from also processing it\n nativeEvent.__nestedMenuArrowLeftHandled = true;\n e.preventDefault();\n e.stopPropagation();\n // immediately halt any other listeners so we have exclusive control now\n nativeEvent.stopImmediatePropagation();\n // close the sub menu\n handleClose();\n // move focus back to the parent MenuItem, letting the user continue navigation up the menu hierarchy\n menuItemRef.current?.focus();\n }\n }}\n >\n {renderedSubMenuItems}\n </MenuList>\n </Paper>\n </Fade>\n )}\n </Popper>\n </>\n );\n};\n","import { Stack, styled } from '@mui/material';\nimport Fade from '@mui/material/Fade';\n\nexport const transitionConfig = {\n type: Fade,\n timeout: { enter: 100, exit: 100 },\n};\n\nexport const MenuItemContent = styled(Stack)(({ theme }) => ({\n flexDirection: 'row',\n alignItems: 'center',\n gap: theme.spacing(1),\n fontSize: theme.typography.body1.fontSize,\n '& .MuiSvgIcon-root': {\n fontSize: theme.typography.body2.fontSize,\n },\n padding: 0,\n}));\n"],"mappings":"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","names":["import_Divider","import_MenuItem","import_react","import_material","import_Fade","import_material","Fade","MenuItem","Divider","ArrowRightIcon","Fade","import_jsx_runtime","Divider","MenuItem","Menu"]}
@@ -0,0 +1,26 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import { ReactNode } from 'react';
3
+ import { MenuItemProps } from '@mui/material/MenuItem';
4
+ import { SvgIconComponent } from '@mui/icons-material';
5
+
6
+ type MultiLevelMenuItemBase = {
7
+ type: 'divider';
8
+ } | {
9
+ type?: 'item';
10
+ id?: string;
11
+ label: ReactNode;
12
+ startIcon?: SvgIconComponent;
13
+ endIcon?: SvgIconComponent;
14
+ items?: MultiLevelMenuItem[];
15
+ };
16
+ type MultiLevelMenuItem = MultiLevelMenuItemBase & Omit<MenuItemProps, 'children'>;
17
+
18
+ type Props = {
19
+ anchorEl: null | HTMLElement;
20
+ open: boolean;
21
+ onClose: () => void;
22
+ items: MultiLevelMenuItem[];
23
+ };
24
+ declare function MultiLevelMenu({ anchorEl, open, onClose, items }: Props): react_jsx_runtime.JSX.Element;
25
+
26
+ export { type MultiLevelMenuItem, MultiLevelMenu as default };
@@ -0,0 +1,26 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import { ReactNode } from 'react';
3
+ import { MenuItemProps } from '@mui/material/MenuItem';
4
+ import { SvgIconComponent } from '@mui/icons-material';
5
+
6
+ type MultiLevelMenuItemBase = {
7
+ type: 'divider';
8
+ } | {
9
+ type?: 'item';
10
+ id?: string;
11
+ label: ReactNode;
12
+ startIcon?: SvgIconComponent;
13
+ endIcon?: SvgIconComponent;
14
+ items?: MultiLevelMenuItem[];
15
+ };
16
+ type MultiLevelMenuItem = MultiLevelMenuItemBase & Omit<MenuItemProps, 'children'>;
17
+
18
+ type Props = {
19
+ anchorEl: null | HTMLElement;
20
+ open: boolean;
21
+ onClose: () => void;
22
+ items: MultiLevelMenuItem[];
23
+ };
24
+ declare function MultiLevelMenu({ anchorEl, open, onClose, items }: Props): react_jsx_runtime.JSX.Element;
25
+
26
+ export { type MultiLevelMenuItem, MultiLevelMenu as default };