better-mui-menu 1.0.2 → 1.0.4
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/LICENSE +21 -0
- package/dist/index.cjs +35 -25
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +12 -2
- package/dist/index.js.map +1 -1
- package/package.json +11 -8
- package/jest.config.cjs +0 -15
- package/jest.setup.ts +0 -1
- package/src/Menu/Menu.test.tsx +0 -369
- package/src/Menu/NestedMenuItem.tsx +0 -241
- package/src/Menu/common.ts +0 -18
- package/src/Menu/index.tsx +0 -89
- package/src/Menu/types.ts +0 -16
- package/src/index.ts +0 -4
- package/tsconfig.jest.json +0 -10
- package/tsup.config.ts +0 -16
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 eggei
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/index.cjs
CHANGED
|
@@ -46,7 +46,17 @@ var import_react = require("react");
|
|
|
46
46
|
var import_Fade2 = __toESM(require("@mui/material/Fade"), 1);
|
|
47
47
|
var import_MenuItem = __toESM(require("@mui/material/MenuItem"), 1);
|
|
48
48
|
var import_Divider = __toESM(require("@mui/material/Divider"), 1);
|
|
49
|
-
|
|
49
|
+
|
|
50
|
+
// ../../node_modules/@mui/icons-material/esm/utils/createSvgIcon.js
|
|
51
|
+
var import_utils = require("@mui/material/utils");
|
|
52
|
+
|
|
53
|
+
// ../../node_modules/@mui/icons-material/esm/ArrowRight.js
|
|
54
|
+
var import_jsx_runtime = require("react/jsx-runtime");
|
|
55
|
+
var ArrowRight_default = (0, import_utils.createSvgIcon)(/* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", {
|
|
56
|
+
d: "m10 17 5-5-5-5z"
|
|
57
|
+
}), "ArrowRight");
|
|
58
|
+
|
|
59
|
+
// src/Menu/NestedMenuItem.tsx
|
|
50
60
|
var import_material2 = require("@mui/material");
|
|
51
61
|
|
|
52
62
|
// src/Menu/common.ts
|
|
@@ -68,7 +78,7 @@ var MenuItemContent = (0, import_material.styled)(import_material.Stack)(({ them
|
|
|
68
78
|
}));
|
|
69
79
|
|
|
70
80
|
// src/Menu/NestedMenuItem.tsx
|
|
71
|
-
var
|
|
81
|
+
var import_jsx_runtime2 = require("react/jsx-runtime");
|
|
72
82
|
var isNodeInstance = (target) => target instanceof Node;
|
|
73
83
|
var NestedMenuItem = (props) => {
|
|
74
84
|
const {
|
|
@@ -111,7 +121,7 @@ var NestedMenuItem = (props) => {
|
|
|
111
121
|
if (!items || items.length === 0) return null;
|
|
112
122
|
return items.map((item, index) => {
|
|
113
123
|
if (item.type === "divider") {
|
|
114
|
-
return /* @__PURE__ */ (0,
|
|
124
|
+
return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_Divider.default, {}, `divider-${index}`);
|
|
115
125
|
}
|
|
116
126
|
const {
|
|
117
127
|
type: __,
|
|
@@ -126,7 +136,7 @@ var NestedMenuItem = (props) => {
|
|
|
126
136
|
const entryKey = `nested-entry-${entryId}`;
|
|
127
137
|
const entryLabelValue = entryLabel ?? entryId;
|
|
128
138
|
if (entryItems && entryItems.length > 0) {
|
|
129
|
-
return /* @__PURE__ */ (0,
|
|
139
|
+
return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
130
140
|
NestedMenuItem,
|
|
131
141
|
{
|
|
132
142
|
id: entryId,
|
|
@@ -144,16 +154,16 @@ var NestedMenuItem = (props) => {
|
|
|
144
154
|
handleClose();
|
|
145
155
|
parentMenuClose();
|
|
146
156
|
};
|
|
147
|
-
return /* @__PURE__ */ (0,
|
|
148
|
-
NestedMenuItemStartIcon ? /* @__PURE__ */ (0,
|
|
149
|
-
/* @__PURE__ */ (0,
|
|
150
|
-
NestedMenuItemEndIcon ? /* @__PURE__ */ (0,
|
|
157
|
+
return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_MenuItem.default, { onClick: handleItemClick, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(MenuItemContent, { children: [
|
|
158
|
+
NestedMenuItemStartIcon ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(NestedMenuItemStartIcon, {}) : null,
|
|
159
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_material2.Typography, { sx: { flex: 1 }, children: entryLabelValue }),
|
|
160
|
+
NestedMenuItemEndIcon ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(NestedMenuItemEndIcon, {}) : null
|
|
151
161
|
] }) }, entryKey);
|
|
152
162
|
});
|
|
153
163
|
};
|
|
154
164
|
const renderedSubMenuItems = items && items.length > 0 ? renderItemsFromData() : renderChildren;
|
|
155
|
-
return /* @__PURE__ */ (0,
|
|
156
|
-
/* @__PURE__ */ (0,
|
|
165
|
+
return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
|
|
166
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
157
167
|
import_MenuItem.default,
|
|
158
168
|
{
|
|
159
169
|
"data-testid": `${menuItemId}-trigger`,
|
|
@@ -177,14 +187,14 @@ var NestedMenuItem = (props) => {
|
|
|
177
187
|
"aria-controls": subMenuId,
|
|
178
188
|
"aria-expanded": open ? "true" : void 0,
|
|
179
189
|
...menuItemProps,
|
|
180
|
-
children: /* @__PURE__ */ (0,
|
|
181
|
-
StartIconComponent ? /* @__PURE__ */ (0,
|
|
182
|
-
/* @__PURE__ */ (0,
|
|
183
|
-
/* @__PURE__ */ (0,
|
|
190
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(MenuItemContent, { children: [
|
|
191
|
+
StartIconComponent ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(StartIconComponent, {}) : null,
|
|
192
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_material2.Typography, { sx: { flex: 1 }, children: label }),
|
|
193
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(ArrowRight_default, {})
|
|
184
194
|
] })
|
|
185
195
|
}
|
|
186
196
|
),
|
|
187
|
-
/* @__PURE__ */ (0,
|
|
197
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
188
198
|
import_material2.Popper,
|
|
189
199
|
{
|
|
190
200
|
"data-testid": `${menuItemId}-submenu`,
|
|
@@ -209,7 +219,7 @@ var NestedMenuItem = (props) => {
|
|
|
209
219
|
if (isNodeInstance(e.relatedTarget) && menuItemRef.current?.contains(e.relatedTarget)) return;
|
|
210
220
|
handleClose();
|
|
211
221
|
},
|
|
212
|
-
children: ({ TransitionProps }) => /* @__PURE__ */ (0,
|
|
222
|
+
children: ({ TransitionProps }) => /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_Fade2.default, { ...TransitionProps, timeout: transitionConfig.timeout, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
213
223
|
import_material2.Paper,
|
|
214
224
|
{
|
|
215
225
|
elevation: 2,
|
|
@@ -217,7 +227,7 @@ var NestedMenuItem = (props) => {
|
|
|
217
227
|
borderRadius: 1,
|
|
218
228
|
bgcolor: "background.default"
|
|
219
229
|
},
|
|
220
|
-
children: /* @__PURE__ */ (0,
|
|
230
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
221
231
|
import_material2.MenuList,
|
|
222
232
|
{
|
|
223
233
|
autoFocusItem: true,
|
|
@@ -252,12 +262,12 @@ var NestedMenuItem = (props) => {
|
|
|
252
262
|
};
|
|
253
263
|
|
|
254
264
|
// src/Menu/index.tsx
|
|
255
|
-
var
|
|
265
|
+
var import_jsx_runtime3 = require("react/jsx-runtime");
|
|
256
266
|
function Menu({ anchorEl, open, onClose, items }) {
|
|
257
267
|
const generatedMenuId = (0, import_react2.useId)();
|
|
258
268
|
const renderedMenuEntries = items.map((item, index) => {
|
|
259
269
|
if (item.type === "divider") {
|
|
260
|
-
return /* @__PURE__ */ (0,
|
|
270
|
+
return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_Divider2.default, {}, `divider-${index}`);
|
|
261
271
|
}
|
|
262
272
|
const {
|
|
263
273
|
type: _,
|
|
@@ -273,7 +283,7 @@ function Menu({ anchorEl, open, onClose, items }) {
|
|
|
273
283
|
const itemKey = `menu-item-id:${entryId}`;
|
|
274
284
|
const displayLabel = label ?? entryId;
|
|
275
285
|
if (nestedItems && nestedItems.length > 0) {
|
|
276
|
-
return /* @__PURE__ */ (0,
|
|
286
|
+
return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
|
|
277
287
|
NestedMenuItem,
|
|
278
288
|
{
|
|
279
289
|
id: entryId,
|
|
@@ -290,13 +300,13 @@ function Menu({ anchorEl, open, onClose, items }) {
|
|
|
290
300
|
onClick?.(event);
|
|
291
301
|
onClose();
|
|
292
302
|
};
|
|
293
|
-
return /* @__PURE__ */ (0,
|
|
294
|
-
StartIconComponent ? /* @__PURE__ */ (0,
|
|
295
|
-
/* @__PURE__ */ (0,
|
|
296
|
-
EndIconComponent ? /* @__PURE__ */ (0,
|
|
303
|
+
return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_MenuItem2.default, { onClick: handleItemClick, ...menuItemProps, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(MenuItemContent, { children: [
|
|
304
|
+
StartIconComponent ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(StartIconComponent, {}) : null,
|
|
305
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_material3.Typography, { sx: { flex: 1 }, children: displayLabel }),
|
|
306
|
+
EndIconComponent ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(EndIconComponent, {}) : null
|
|
297
307
|
] }) }, itemKey);
|
|
298
308
|
});
|
|
299
|
-
return /* @__PURE__ */ (0,
|
|
309
|
+
return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
|
|
300
310
|
import_Menu.default,
|
|
301
311
|
{
|
|
302
312
|
"data-testid": "root-menu",
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/Menu/index.tsx","../src/Menu/NestedMenuItem.tsx","../src/Menu/common.ts"],"sourcesContent":["import { Menu } from './Menu'\n\nexport type { MenuItem } from './Menu/types'\nexport default Menu\n","import MuiMenu from '@mui/material/Menu';\nimport Divider from '@mui/material/Divider';\nimport MuiMenuItem from '@mui/material/MenuItem';\nimport React, { useId } from 'react';\nimport { Typography } from '@mui/material';\nimport { NestedMenuItem } from './NestedMenuItem';\nimport type { MenuItem } from './types';\nimport { MenuItemContent, transitionConfig } from './common';\n\ntype Props = {\n anchorEl: null | HTMLElement;\n open: boolean;\n onClose: () => void;\n items: MenuItem[];\n};\n\nexport function Menu({ 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 <MuiMenuItem 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 </MuiMenuItem>\n );\n });\n\n return (\n <MuiMenu\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 </MuiMenu>\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 MuiMenuItem 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 { MenuItem } 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?: MenuItem[];\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 === MuiMenuItem) {\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 <MuiMenuItem key={entryKey} onClick={handleItemClick}>\n <MenuItemContent>\n {NestedMenuItemStartIcon ? <NestedMenuItemStartIcon /> : null}\n <Typography sx={{ flex: 1 }}>{entryLabelValue}</Typography>\n {NestedMenuItemEndIcon ? <NestedMenuItemEndIcon /> : null}\n </MenuItemContent>\n </MuiMenuItem>\n );\n });\n };\n\n const renderedSubMenuItems = items && items.length > 0 ? renderItemsFromData() : renderChildren;\n\n return (\n <>\n <MuiMenuItem\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 </MuiMenuItem>\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","MuiMenuItem","Divider","ArrowRightIcon","Fade","import_jsx_runtime","Divider","MuiMenuItem","MuiMenu"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/Menu/index.tsx","../src/Menu/NestedMenuItem.tsx","../../../node_modules/@mui/icons-material/esm/utils/createSvgIcon.js","../../../node_modules/@mui/icons-material/esm/ArrowRight.js","../src/Menu/common.ts"],"sourcesContent":["import { Menu } from './Menu'\n\nexport type { MenuItem } from './Menu/types'\nexport default Menu\n","import MuiMenu from '@mui/material/Menu';\nimport Divider from '@mui/material/Divider';\nimport MuiMenuItem from '@mui/material/MenuItem';\nimport React, { useId } from 'react';\nimport { Typography } from '@mui/material';\nimport { NestedMenuItem } from './NestedMenuItem';\nimport type { MenuItem } from './types';\nimport { MenuItemContent, transitionConfig } from './common';\n\ntype Props = {\n anchorEl: null | HTMLElement;\n open: boolean;\n onClose: () => void;\n items: MenuItem[];\n};\n\nexport function Menu({ 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 <MuiMenuItem 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 </MuiMenuItem>\n );\n });\n\n return (\n <MuiMenu\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 </MuiMenu>\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 MuiMenuItem 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 { MenuItem } 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?: MenuItem[];\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 === MuiMenuItem) {\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 <MuiMenuItem key={entryKey} onClick={handleItemClick}>\n <MenuItemContent>\n {NestedMenuItemStartIcon ? <NestedMenuItemStartIcon /> : null}\n <Typography sx={{ flex: 1 }}>{entryLabelValue}</Typography>\n {NestedMenuItemEndIcon ? <NestedMenuItemEndIcon /> : null}\n </MenuItemContent>\n </MuiMenuItem>\n );\n });\n };\n\n const renderedSubMenuItems = items && items.length > 0 ? renderItemsFromData() : renderChildren;\n\n return (\n <>\n <MuiMenuItem\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 </MuiMenuItem>\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","'use client';\n\nexport { createSvgIcon as default } from '@mui/material/utils';","\"use client\";\n\nimport createSvgIcon from \"./utils/createSvgIcon.js\";\nimport { jsx as _jsx } from \"react/jsx-runtime\";\nexport default createSvgIcon(/*#__PURE__*/_jsx(\"path\", {\n d: \"m10 17 5-5-5-5z\"\n}), 'ArrowRight');","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","_jsx","import_material","Fade","import_jsx_runtime","MuiMenuItem","Divider","Fade","import_jsx_runtime","Divider","MuiMenuItem","MuiMenu"]}
|
package/dist/index.js
CHANGED
|
@@ -10,7 +10,17 @@ import { Children, cloneElement, isValidElement, useCallback, useId, useRef, use
|
|
|
10
10
|
import Fade2 from "@mui/material/Fade";
|
|
11
11
|
import MuiMenuItem from "@mui/material/MenuItem";
|
|
12
12
|
import Divider from "@mui/material/Divider";
|
|
13
|
-
|
|
13
|
+
|
|
14
|
+
// ../../node_modules/@mui/icons-material/esm/utils/createSvgIcon.js
|
|
15
|
+
import { createSvgIcon } from "@mui/material/utils";
|
|
16
|
+
|
|
17
|
+
// ../../node_modules/@mui/icons-material/esm/ArrowRight.js
|
|
18
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
19
|
+
var ArrowRight_default = createSvgIcon(/* @__PURE__ */ _jsx("path", {
|
|
20
|
+
d: "m10 17 5-5-5-5z"
|
|
21
|
+
}), "ArrowRight");
|
|
22
|
+
|
|
23
|
+
// src/Menu/NestedMenuItem.tsx
|
|
14
24
|
import { MenuList, Paper, Popper, Typography } from "@mui/material";
|
|
15
25
|
|
|
16
26
|
// src/Menu/common.ts
|
|
@@ -144,7 +154,7 @@ var NestedMenuItem = (props) => {
|
|
|
144
154
|
children: /* @__PURE__ */ jsxs(MenuItemContent, { children: [
|
|
145
155
|
StartIconComponent ? /* @__PURE__ */ jsx(StartIconComponent, {}) : null,
|
|
146
156
|
/* @__PURE__ */ jsx(Typography, { sx: { flex: 1 }, children: label }),
|
|
147
|
-
/* @__PURE__ */ jsx(
|
|
157
|
+
/* @__PURE__ */ jsx(ArrowRight_default, {})
|
|
148
158
|
] })
|
|
149
159
|
}
|
|
150
160
|
),
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/Menu/index.tsx","../src/Menu/NestedMenuItem.tsx","../src/Menu/common.ts","../src/index.ts"],"sourcesContent":["import MuiMenu from '@mui/material/Menu';\nimport Divider from '@mui/material/Divider';\nimport MuiMenuItem from '@mui/material/MenuItem';\nimport React, { useId } from 'react';\nimport { Typography } from '@mui/material';\nimport { NestedMenuItem } from './NestedMenuItem';\nimport type { MenuItem } from './types';\nimport { MenuItemContent, transitionConfig } from './common';\n\ntype Props = {\n anchorEl: null | HTMLElement;\n open: boolean;\n onClose: () => void;\n items: MenuItem[];\n};\n\nexport function Menu({ 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 <MuiMenuItem 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 </MuiMenuItem>\n );\n });\n\n return (\n <MuiMenu\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 </MuiMenu>\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 MuiMenuItem 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 { MenuItem } 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?: MenuItem[];\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 === MuiMenuItem) {\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 <MuiMenuItem key={entryKey} onClick={handleItemClick}>\n <MenuItemContent>\n {NestedMenuItemStartIcon ? <NestedMenuItemStartIcon /> : null}\n <Typography sx={{ flex: 1 }}>{entryLabelValue}</Typography>\n {NestedMenuItemEndIcon ? <NestedMenuItemEndIcon /> : null}\n </MenuItemContent>\n </MuiMenuItem>\n );\n });\n };\n\n const renderedSubMenuItems = items && items.length > 0 ? renderItemsFromData() : renderChildren;\n\n return (\n <>\n <MuiMenuItem\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 </MuiMenuItem>\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","import { Menu } from './Menu'\n\nexport type { MenuItem } from './Menu/types'\nexport default Menu\n"],"mappings":"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","names":["Divider","MuiMenuItem","useId","Typography","Fade","Fade","jsx","jsxs","useId","Divider","MuiMenuItem","Typography"]}
|
|
1
|
+
{"version":3,"sources":["../src/Menu/index.tsx","../src/Menu/NestedMenuItem.tsx","../../../node_modules/@mui/icons-material/esm/utils/createSvgIcon.js","../../../node_modules/@mui/icons-material/esm/ArrowRight.js","../src/Menu/common.ts","../src/index.ts"],"sourcesContent":["import MuiMenu from '@mui/material/Menu';\nimport Divider from '@mui/material/Divider';\nimport MuiMenuItem from '@mui/material/MenuItem';\nimport React, { useId } from 'react';\nimport { Typography } from '@mui/material';\nimport { NestedMenuItem } from './NestedMenuItem';\nimport type { MenuItem } from './types';\nimport { MenuItemContent, transitionConfig } from './common';\n\ntype Props = {\n anchorEl: null | HTMLElement;\n open: boolean;\n onClose: () => void;\n items: MenuItem[];\n};\n\nexport function Menu({ 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 <MuiMenuItem 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 </MuiMenuItem>\n );\n });\n\n return (\n <MuiMenu\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 </MuiMenu>\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 MuiMenuItem 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 { MenuItem } 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?: MenuItem[];\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 === MuiMenuItem) {\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 <MuiMenuItem key={entryKey} onClick={handleItemClick}>\n <MenuItemContent>\n {NestedMenuItemStartIcon ? <NestedMenuItemStartIcon /> : null}\n <Typography sx={{ flex: 1 }}>{entryLabelValue}</Typography>\n {NestedMenuItemEndIcon ? <NestedMenuItemEndIcon /> : null}\n </MenuItemContent>\n </MuiMenuItem>\n );\n });\n };\n\n const renderedSubMenuItems = items && items.length > 0 ? renderItemsFromData() : renderChildren;\n\n return (\n <>\n <MuiMenuItem\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 </MuiMenuItem>\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","'use client';\n\nexport { createSvgIcon as default } from '@mui/material/utils';","\"use client\";\n\nimport createSvgIcon from \"./utils/createSvgIcon.js\";\nimport { jsx as _jsx } from \"react/jsx-runtime\";\nexport default createSvgIcon(/*#__PURE__*/_jsx(\"path\", {\n d: \"m10 17 5-5-5-5z\"\n}), 'ArrowRight');","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","import { Menu } from './Menu'\n\nexport type { MenuItem } from './Menu/types'\nexport default Menu\n"],"mappings":";AAAA,OAAO,aAAa;AACpB,OAAOA,cAAa;AACpB,OAAOC,kBAAiB;AACxB,SAAgB,SAAAC,cAAa;AAC7B,SAAS,cAAAC,mBAAkB;;;ACH3B,SAAS,UAAU,cAAc,gBAAgB,aAAa,OAAO,QAAQ,gBAAgB;AAC7F,OAAOC,WAAU;AAEjB,OAAO,iBAAiB;AACxB,OAAO,aAAa;;;ACHpB,SAA0B,qBAAe;;;ACCzC,SAAS,OAAO,YAAY;AAC5B,IAAO,qBAAQ,cAA2B,qBAAK,QAAQ;AAAA,EACrD,GAAG;AACL,CAAC,GAAG,YAAY;;;AFEhB,SAAS,UAAU,OAAO,QAAQ,kBAAkB;;;AGRpD,SAAS,OAAO,cAAc;AAC9B,OAAO,UAAU;AAEV,IAAM,mBAAmB;AAAA,EAC9B,MAAM;AAAA,EACN,SAAS,EAAE,OAAO,KAAK,MAAM,IAAI;AACnC;AAEO,IAAM,kBAAkB,OAAO,KAAK,EAAE,CAAC,EAAE,MAAM,OAAO;AAAA,EAC3D,eAAe;AAAA,EACf,YAAY;AAAA,EACZ,KAAK,MAAM,QAAQ,CAAC;AAAA,EACpB,UAAU,MAAM,WAAW,MAAM;AAAA,EACjC,sBAAsB;AAAA,IACpB,UAAU,MAAM,WAAW,MAAM;AAAA,EACnC;AAAA,EACA,SAAS;AACX,EAAE;;;AHwDa,SAmDX,UAnDW,KAsCL,YAtCK;AApDf,IAAM,iBAAiB,CAAC,WAA+C,kBAAkB;AAElF,IAAM,iBAA0C,WAAS;AAC9D,QAAM;AAAA,IACJ,IAAI;AAAA,IACJ;AAAA,IACA,WAAW;AAAA,IACX;AAAA,IACA;AAAA,IACA;AAAA,IACA,SAAS;AAAA,IACT,GAAG;AAAA,EACL,IAAI;AACJ,QAAM,CAAC,iBAAiB,kBAAkB,IAAI,SAA6B,IAAI;AAC/E,QAAM,OAAO,QAAQ,eAAe;AACpC,QAAM,cAAc,OAAsB,IAAI;AAC9C,QAAM,aAAa,OAAuB,IAAI;AAC9C,QAAM,cAAc,MAAM;AAC1B,QAAM,aAAa,cAAc,uBAAuB,WAAW;AACnE,QAAM,YAAY,GAAG,UAAU;AAE/B,QAAM,aAAa,CAAC,UAAoE;AACtF,uBAAmB,MAAM,aAAa;AAAA,EACxC;AAEA,QAAM,cAAc,YAAY,MAAM;AACpC,uBAAmB,IAAI;AAAA,EACzB,GAAG,CAAC,CAAC;AAEL,QAAM,iBAAiB,SAAS,IAAI,UAAU,WAAS;AACrD,QAAI,CAAC,eAAe,KAAK,EAAG,QAAO;AAGnC,QAAI,MAAM,SAAS,aAAa;AAC9B,YAAM,eAAgB,MAAM,MAAwB;AAEpD,YAAM,gBAAgB,CAAC,UAAqC;AAC1D,uBAAe,KAAK;AACpB,oBAAY;AACZ,wBAAgB;AAAA,MAClB;AACA,aAAO,aAAa,OAAO,EAAE,SAAS,cAAc,CAA2B;AAAA,IACjF;AACA,WAAO;AAAA,EACT,CAAC;AAED,QAAM,sBAAsB,MAAM;AAChC,QAAI,CAAC,SAAS,MAAM,WAAW,EAAG,QAAO;AAEzC,WAAO,MAAM,IAAI,CAAC,MAAM,UAAU;AAChC,UAAI,KAAK,SAAS,WAAW;AAE3B,eAAO,oBAAC,aAAa,WAAW,KAAK,EAAI;AAAA,MAC3C;AAEA,YAAM;AAAA,QACJ,MAAM;AAAA,QACN,OAAO;AAAA,QACP,WAAW;AAAA,QACX,SAAS;AAAA,QACT,OAAO;AAAA,QACP;AAAA,QACA;AAAA,MACF,IAAI;AACJ,YAAM,UAAU,MAAM,GAAG,UAAU,UAAU,KAAK;AAClD,YAAM,WAAW,gBAAgB,OAAO;AACxC,YAAM,kBAAkB,cAAc;AAEtC,UAAI,cAAc,WAAW,SAAS,GAAG;AACvC,eACE;AAAA,UAAC;AAAA;AAAA,YAEC,IAAI;AAAA,YACJ,OAAO;AAAA,YACP,WAAW;AAAA,YACX,SAAS;AAAA,YACT;AAAA,YACA,OAAO;AAAA;AAAA,UANF;AAAA,QAOP;AAAA,MAEJ;AAEA,YAAM,kBAAkB,CAAC,UAAqC;AAC5D,kBAAU,KAAK;AACf,oBAAY;AACZ,wBAAgB;AAAA,MAClB;AAEA,aACE,oBAAC,eAA2B,SAAS,iBACnC,+BAAC,mBACE;AAAA,kCAA0B,oBAAC,2BAAwB,IAAK;AAAA,QACzD,oBAAC,cAAW,IAAI,EAAE,MAAM,EAAE,GAAI,2BAAgB;AAAA,QAC7C,wBAAwB,oBAAC,yBAAsB,IAAK;AAAA,SACvD,KALgB,QAMlB;AAAA,IAEJ,CAAC;AAAA,EACH;AAEA,QAAM,uBAAuB,SAAS,MAAM,SAAS,IAAI,oBAAoB,IAAI;AAEjF,SACE,iCACE;AAAA;AAAA,MAAC;AAAA;AAAA,QACC,eAAa,GAAG,UAAU;AAAA,QAC1B,IAAI;AAAA,QACJ,KAAK;AAAA,QACL,cAAc;AAAA,QACd,cAAc,OAAK;AAIjB,cAAI,eAAe,EAAE,aAAa,KAAK,WAAW,SAAS,SAAS,EAAE,aAAa,EAAG;AAEtF,sBAAY;AAAA,QACd;AAAA,QACA,WAAW,OAAK;AACd,YAAE,eAAe;AACjB,cAAI,EAAE,QAAQ,aAAa;AACzB,wBAAY;AAAA,UACd;AACA,cAAI,EAAE,QAAQ,gBAAgB,EAAE,QAAQ,WAAW,EAAE,QAAQ,KAAK;AAChE,uBAAW,CAAC;AAAA,UACd;AAAA,QACF;AAAA,QACA,iBAAc;AAAA,QACd,iBAAe;AAAA,QACf,iBAAe,OAAO,SAAS;AAAA,QAC9B,GAAG;AAAA,QAEJ,+BAAC,mBACE;AAAA,+BAAqB,oBAAC,sBAAmB,IAAK;AAAA,UAC/C,oBAAC,cAAW,IAAI,EAAE,MAAM,EAAE,GAAI,iBAAM;AAAA,UACpC,oBAAC,sBAAe;AAAA,WAClB;AAAA;AAAA,IACF;AAAA,IAEA;AAAA,MAAC;AAAA;AAAA,QACC,eAAa,GAAG,UAAU;AAAA,QAC1B;AAAA,QACA,KAAK;AAAA,QACL,UAAU;AAAA,QACV,YAAU;AAAA,QACV,IAAI,EAAE,QAAQ,OAAK,EAAE,OAAO,QAAQ,EAAE;AAAA,QACtC,WAAU;AAAA,QACV,WAAW,OAAK;AACd,cAAI,EAAE,QAAQ,aAAa;AACzB,cAAE,eAAe;AACjB,wBAAY;AACZ,wBAAY,SAAS,MAAM;AAAA,UAC7B;AACA,cAAI,EAAE,QAAQ,aAAa,EAAE,QAAQ,aAAa;AAChD,cAAE,eAAe;AACjB,cAAE,gBAAgB;AAAA,UACpB;AAAA,QACF;AAAA,QACA,cAAc,OAAK;AAIjB,cAAI,eAAe,EAAE,aAAa,KAAK,YAAY,SAAS,SAAS,EAAE,aAAa,EAAG;AAEvF,sBAAY;AAAA,QACd;AAAA,QAEC,WAAC,EAAE,gBAAgB,MAClB,oBAACC,OAAA,EAAM,GAAG,iBAAiB,SAAS,iBAAiB,SACnD;AAAA,UAAC;AAAA;AAAA,YACC,WAAW;AAAA,YACX,IAAI;AAAA,cACF,cAAc;AAAA,cACd,SAAS;AAAA,YACX;AAAA,YAEA;AAAA,cAAC;AAAA;AAAA,gBACC,eAAa;AAAA,gBACb,IAAI;AAAA,gBACJ,mBAAiB;AAAA,gBACjB,MAAK;AAAA,gBACL,WAAW,OAAK;AACd,sBAAI,EAAE,QAAQ,aAAa;AACzB,0BAAM,EAAE,YAAY,IAAI;AAMxB,wBAAI,YAAY,8BAA8B;AAG5C;AAAA,oBACF;AACA,wBAAI,CAAC,WAAW,SAAS,SAAS,EAAE,MAAc,GAAG;AAGnD;AAAA,oBACF;AAEA,gCAAY,+BAA+B;AAC3C,sBAAE,eAAe;AACjB,sBAAE,gBAAgB;AAElB,gCAAY,yBAAyB;AAErC,gCAAY;AAEZ,gCAAY,SAAS,MAAM;AAAA,kBAC7B;AAAA,gBACF;AAAA,gBAEC;AAAA;AAAA,YACH;AAAA;AAAA,QACF,GACF;AAAA;AAAA,IAEJ;AAAA,KACF;AAEJ;;;AD3Na,gBAAAC,MAsCL,QAAAC,aAtCK;AALN,SAAS,KAAK,EAAE,UAAU,MAAM,SAAS,MAAM,GAAU;AAC9D,QAAM,kBAAkBC,OAAM;AAE9B,QAAM,sBAAsB,MAAM,IAAI,CAAC,MAAM,UAAU;AACrD,QAAI,KAAK,SAAS,WAAW;AAC3B,aAAO,gBAAAF,KAACG,UAAA,IAAa,WAAW,KAAK,EAAI;AAAA,IAC3C;AAEA,UAAM;AAAA,MACJ,MAAM;AAAA,MACN,OAAO;AAAA,MACP,WAAW;AAAA,MACX,SAAS;AAAA,MACT;AAAA,MACA;AAAA,MACA;AAAA,MACA,GAAG;AAAA,IACL,IAAI;AACJ,UAAM,UAAU,MAAM,GAAG,eAAe,UAAU,KAAK;AACvD,UAAM,UAAU,gBAAgB,OAAO;AACvC,UAAM,eAAe,SAAS;AAE9B,QAAI,eAAe,YAAY,SAAS,GAAG;AACzC,aACE,gBAAAH;AAAA,QAAC;AAAA;AAAA,UAEC,IAAI;AAAA,UACJ,OAAO;AAAA,UACP,WAAW;AAAA,UACX,SAAS;AAAA,UACT,iBAAiB;AAAA,UACjB,OAAO;AAAA;AAAA,QANF;AAAA,MAOP;AAAA,IAEJ;AAEA,UAAM,kBAAkB,CAAC,UAA2C;AAClE,gBAAU,KAAK;AACf,cAAQ;AAAA,IACV;AAEA,WACE,gBAAAA,KAACI,cAAA,EAA0B,SAAS,iBAAkB,GAAG,eACvD,0BAAAH,MAAC,mBACE;AAAA,2BAAqB,gBAAAD,KAAC,sBAAmB,IAAK;AAAA,MAC/C,gBAAAA,KAACK,aAAA,EAAW,IAAI,EAAE,MAAM,EAAE,GAAI,wBAAa;AAAA,MAC1C,mBAAmB,gBAAAL,KAAC,oBAAiB,IAAK;AAAA,OAC7C,KALgB,OAMlB;AAAA,EAEJ,CAAC;AAED,SACE,gBAAAA;AAAA,IAAC;AAAA;AAAA,MACC,eAAY;AAAA,MACZ;AAAA,MACA;AAAA,MACA;AAAA,MACA,WAAW,OAAK;AACd,YAAI,EAAE,QAAQ,YAAY,KAAM,SAAQ;AAAA,MAC1C;AAAA,MACA,oBAAoB,iBAAiB;AAAA,MACrC,OAAO,EAAE,YAAY,iBAAiB,KAAK;AAAA,MAC3C,WAAW;AAAA,QACT,MAAM;AAAA,UACJ,mBAAmB;AAAA,QACrB;AAAA,MACF;AAAA,MAEC;AAAA;AAAA,EACH;AAEJ;;;AKrFA,IAAO,gBAAQ;","names":["Divider","MuiMenuItem","useId","Typography","Fade","Fade","jsx","jsxs","useId","Divider","MuiMenuItem","Typography"]}
|
package/package.json
CHANGED
|
@@ -8,9 +8,12 @@
|
|
|
8
8
|
"bugs": {
|
|
9
9
|
"url": "https://github.com/eggei/better-mui-menu/issues"
|
|
10
10
|
},
|
|
11
|
-
"version": "1.0.
|
|
11
|
+
"version": "1.0.4",
|
|
12
12
|
"type": "module",
|
|
13
13
|
"sideEffects": false,
|
|
14
|
+
"files": [
|
|
15
|
+
"dist"
|
|
16
|
+
],
|
|
14
17
|
"main": "./dist/index.cjs",
|
|
15
18
|
"module": "./dist/index.js",
|
|
16
19
|
"types": "./dist/index.d.ts",
|
|
@@ -43,7 +46,7 @@
|
|
|
43
46
|
"accessibility"
|
|
44
47
|
],
|
|
45
48
|
"author": "eggei",
|
|
46
|
-
"license": "
|
|
49
|
+
"license": "MIT",
|
|
47
50
|
"devDependencies": {
|
|
48
51
|
"@emotion/react": "^11.14.0",
|
|
49
52
|
"@emotion/styled": "^11.14.1",
|
|
@@ -72,11 +75,11 @@
|
|
|
72
75
|
"typescript": "^5.9.3"
|
|
73
76
|
},
|
|
74
77
|
"peerDependencies": {
|
|
75
|
-
"@
|
|
76
|
-
"@emotion/
|
|
77
|
-
"@
|
|
78
|
-
"@
|
|
79
|
-
"
|
|
80
|
-
"
|
|
78
|
+
"@mui/material": "^5.0.0 || ^6.0.0 || ^7.0.0",
|
|
79
|
+
"@emotion/react": "^11.5.0",
|
|
80
|
+
"@emotion/styled": "^11.3.0",
|
|
81
|
+
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
|
82
|
+
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
|
83
|
+
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
|
81
84
|
}
|
|
82
85
|
}
|
package/jest.config.cjs
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
module.exports = {
|
|
2
|
-
preset: 'ts-jest/presets/default-esm',
|
|
3
|
-
testEnvironment: 'jsdom',
|
|
4
|
-
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
|
5
|
-
testMatch: ['<rootDir>/src/**/*.test.{ts,tsx}'],
|
|
6
|
-
moduleNameMapper: {
|
|
7
|
-
'\\.(css|less|sass|scss)$': 'identity-obj-proxy'
|
|
8
|
-
},
|
|
9
|
-
watchman: false,
|
|
10
|
-
transform: {
|
|
11
|
-
'^.+\\.(ts|tsx)$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.jest.json' }]
|
|
12
|
-
},
|
|
13
|
-
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
|
|
14
|
-
extensionsToTreatAsEsm: ['.ts', '.tsx']
|
|
15
|
-
};
|
package/jest.setup.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import '@testing-library/jest-dom';
|
package/src/Menu/Menu.test.tsx
DELETED
|
@@ -1,369 +0,0 @@
|
|
|
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 { MenuItem } from './types';
|
|
14
|
-
import { Menu } 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: MenuItem[] = [
|
|
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: MenuItem[] }) {
|
|
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
|
-
<Menu 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('Menu', () => {
|
|
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: MenuItem[] = [
|
|
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
|
-
});
|
|
@@ -1,241 +0,0 @@
|
|
|
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 MuiMenuItem 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 { MenuItem } 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?: MenuItem[];
|
|
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 === MuiMenuItem) {
|
|
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
|
-
<MuiMenuItem key={entryKey} onClick={handleItemClick}>
|
|
112
|
-
<MenuItemContent>
|
|
113
|
-
{NestedMenuItemStartIcon ? <NestedMenuItemStartIcon /> : null}
|
|
114
|
-
<Typography sx={{ flex: 1 }}>{entryLabelValue}</Typography>
|
|
115
|
-
{NestedMenuItemEndIcon ? <NestedMenuItemEndIcon /> : null}
|
|
116
|
-
</MenuItemContent>
|
|
117
|
-
</MuiMenuItem>
|
|
118
|
-
);
|
|
119
|
-
});
|
|
120
|
-
};
|
|
121
|
-
|
|
122
|
-
const renderedSubMenuItems = items && items.length > 0 ? renderItemsFromData() : renderChildren;
|
|
123
|
-
|
|
124
|
-
return (
|
|
125
|
-
<>
|
|
126
|
-
<MuiMenuItem
|
|
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
|
-
</MuiMenuItem>
|
|
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
|
-
};
|
package/src/Menu/common.ts
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
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
|
-
}));
|
package/src/Menu/index.tsx
DELETED
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
import MuiMenu from '@mui/material/Menu';
|
|
2
|
-
import Divider from '@mui/material/Divider';
|
|
3
|
-
import MuiMenuItem from '@mui/material/MenuItem';
|
|
4
|
-
import React, { useId } from 'react';
|
|
5
|
-
import { Typography } from '@mui/material';
|
|
6
|
-
import { NestedMenuItem } from './NestedMenuItem';
|
|
7
|
-
import type { MenuItem } from './types';
|
|
8
|
-
import { MenuItemContent, transitionConfig } from './common';
|
|
9
|
-
|
|
10
|
-
type Props = {
|
|
11
|
-
anchorEl: null | HTMLElement;
|
|
12
|
-
open: boolean;
|
|
13
|
-
onClose: () => void;
|
|
14
|
-
items: MenuItem[];
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
export function Menu({ anchorEl, open, onClose, items }: Props) {
|
|
18
|
-
const generatedMenuId = useId();
|
|
19
|
-
|
|
20
|
-
const renderedMenuEntries = items.map((item, index) => {
|
|
21
|
-
if (item.type === 'divider') {
|
|
22
|
-
return <Divider key={`divider-${index}`} />;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const {
|
|
26
|
-
type: _,
|
|
27
|
-
items: nestedItems,
|
|
28
|
-
startIcon: StartIconComponent,
|
|
29
|
-
endIcon: EndIconComponent,
|
|
30
|
-
label,
|
|
31
|
-
onClick,
|
|
32
|
-
id,
|
|
33
|
-
...menuItemProps
|
|
34
|
-
} = item;
|
|
35
|
-
const entryId = id ?? `${generatedMenuId}-entry-${index}`;
|
|
36
|
-
const itemKey = `menu-item-id:${entryId}`;
|
|
37
|
-
const displayLabel = label ?? entryId;
|
|
38
|
-
|
|
39
|
-
if (nestedItems && nestedItems.length > 0) {
|
|
40
|
-
return (
|
|
41
|
-
<NestedMenuItem
|
|
42
|
-
key={itemKey}
|
|
43
|
-
id={entryId}
|
|
44
|
-
label={displayLabel}
|
|
45
|
-
startIcon={StartIconComponent}
|
|
46
|
-
endIcon={EndIconComponent}
|
|
47
|
-
parentMenuClose={onClose}
|
|
48
|
-
items={nestedItems}
|
|
49
|
-
/>
|
|
50
|
-
);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const handleItemClick = (event: React.MouseEvent<HTMLLIElement>) => {
|
|
54
|
-
onClick?.(event);
|
|
55
|
-
onClose();
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
return (
|
|
59
|
-
<MuiMenuItem key={itemKey} onClick={handleItemClick} {...menuItemProps}>
|
|
60
|
-
<MenuItemContent>
|
|
61
|
-
{StartIconComponent ? <StartIconComponent /> : null}
|
|
62
|
-
<Typography sx={{ flex: 1 }}>{displayLabel}</Typography>
|
|
63
|
-
{EndIconComponent ? <EndIconComponent /> : null}
|
|
64
|
-
</MenuItemContent>
|
|
65
|
-
</MuiMenuItem>
|
|
66
|
-
);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
return (
|
|
70
|
-
<MuiMenu
|
|
71
|
-
data-testid='root-menu'
|
|
72
|
-
anchorEl={anchorEl}
|
|
73
|
-
open={open}
|
|
74
|
-
onClose={onClose}
|
|
75
|
-
onKeyDown={e => {
|
|
76
|
-
if (e.key === 'Escape' && open) onClose();
|
|
77
|
-
}}
|
|
78
|
-
transitionDuration={transitionConfig.timeout}
|
|
79
|
-
slots={{ transition: transitionConfig.type }}
|
|
80
|
-
slotProps={{
|
|
81
|
-
list: {
|
|
82
|
-
'aria-labelledby': 'icon-menu-button',
|
|
83
|
-
},
|
|
84
|
-
}}
|
|
85
|
-
>
|
|
86
|
-
{renderedMenuEntries}
|
|
87
|
-
</MuiMenu>
|
|
88
|
-
);
|
|
89
|
-
}
|
package/src/Menu/types.ts
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import type { ReactNode } from 'react';
|
|
2
|
-
import type { MenuItemProps as MuiMenuItemProps } from '@mui/material/MenuItem';
|
|
3
|
-
import type { SvgIconComponent } from '@mui/icons-material';
|
|
4
|
-
|
|
5
|
-
export type MenuItemBase =
|
|
6
|
-
| { type: 'divider' }
|
|
7
|
-
| {
|
|
8
|
-
type?: 'item';
|
|
9
|
-
id?: string;
|
|
10
|
-
label: ReactNode;
|
|
11
|
-
startIcon?: SvgIconComponent;
|
|
12
|
-
endIcon?: SvgIconComponent;
|
|
13
|
-
items?: MenuItem[];
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
export type MenuItem = MenuItemBase & Omit<MuiMenuItemProps, 'children'>;
|
package/src/index.ts
DELETED
package/tsconfig.jest.json
DELETED
package/tsup.config.ts
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import { defineConfig } from 'tsup'
|
|
2
|
-
|
|
3
|
-
export default defineConfig({
|
|
4
|
-
entry: ['src/index.ts'],
|
|
5
|
-
format: ['esm', 'cjs'],
|
|
6
|
-
dts: true,
|
|
7
|
-
clean: true,
|
|
8
|
-
sourcemap: true,
|
|
9
|
-
external: [
|
|
10
|
-
'react',
|
|
11
|
-
'react-dom',
|
|
12
|
-
'@mui/material',
|
|
13
|
-
'@emotion/react',
|
|
14
|
-
'@emotion/styled'
|
|
15
|
-
]
|
|
16
|
-
})
|