@recursica/mui-adapter 0.27.0 → 0.28.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.
@@ -12,6 +12,7 @@ import type { Meta, StoryObj } from "@storybook/react";
12
12
  import { Menu, MenuItem, MenuDivider } from "./Menu";
13
13
  import { Button } from "../Button";
14
14
  import { ListSubheader } from "@mui/material";
15
+ import styles from "./Menu.module.css";
15
16
 
16
17
  const SettingsIcon = (props: React.ComponentProps<"svg">) => (
17
18
  <svg
@@ -123,6 +124,56 @@ const ArrowsIcon = (props: React.ComponentProps<"svg">) => (
123
124
  </svg>
124
125
  );
125
126
 
127
+ const ChevronRightIcon = (props: React.ComponentProps<"svg">) => (
128
+ <svg
129
+ xmlns="http://www.w3.org/2000/svg"
130
+ width="14"
131
+ height="14"
132
+ viewBox="0 0 24 24"
133
+ fill="none"
134
+ stroke="currentColor"
135
+ strokeWidth="2"
136
+ strokeLinecap="round"
137
+ strokeLinejoin="round"
138
+ {...props}
139
+ >
140
+ <polyline points="9 18 15 12 9 6" />
141
+ </svg>
142
+ );
143
+
144
+ // mui-adapter's Menu is monolithic (see IMPLEMENTATION_NOTES.md) — there is no
145
+ // Menu.Sub composable, so a submenu is just a MenuItem that anchors its own
146
+ // nested Menu, mirroring how the top-level trigger manages its own anchorEl.
147
+ interface SubMenuTriggerProps {
148
+ label: string;
149
+ children: React.ReactNode;
150
+ }
151
+
152
+ const SubMenuTrigger = ({ label, children }: SubMenuTriggerProps) => {
153
+ const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
154
+
155
+ return (
156
+ <>
157
+ <MenuItem onClick={(event) => setAnchorEl(event.currentTarget)}>
158
+ {label}
159
+ <ChevronRightIcon
160
+ className={styles.chevron}
161
+ style={{ marginLeft: "auto", paddingLeft: 8 }}
162
+ />
163
+ </MenuItem>
164
+ <Menu
165
+ anchorEl={anchorEl}
166
+ open={Boolean(anchorEl)}
167
+ onClose={() => setAnchorEl(null)}
168
+ anchorOrigin={{ vertical: "top", horizontal: "right" }}
169
+ transformOrigin={{ vertical: "top", horizontal: "left" }}
170
+ >
171
+ {children}
172
+ </Menu>
173
+ </>
174
+ );
175
+ };
176
+
126
177
  type MenuStoryArgs = Record<string, unknown>;
127
178
 
128
179
  const meta: Meta = {
@@ -225,9 +276,84 @@ export const WithDisabledItems: Story = {
225
276
  },
226
277
  };
227
278
 
228
- export const HoverTrigger: Story = {
279
+ export const WithSubmenus: Story = {
229
280
  render: (args) => (
230
281
  <InteractiveMenu {...args}>
282
+ <MenuItem>Dashboard</MenuItem>
283
+ <SubMenuTrigger label="Products">
284
+ <MenuItem>All products</MenuItem>
285
+ <MenuItem>Categories</MenuItem>
286
+ <MenuItem>Tags</MenuItem>
287
+ </SubMenuTrigger>
288
+ <SubMenuTrigger label="Orders">
289
+ <MenuItem>Open</MenuItem>
290
+ <MenuItem>Completed</MenuItem>
291
+ <MenuItem>Cancelled</MenuItem>
292
+ </SubMenuTrigger>
293
+ </InteractiveMenu>
294
+ ),
295
+ args: {
296
+ opened: true,
297
+ // Match mantine's explicit `width: 200` for this story so the row has room for the
298
+ // "Products"/"Orders" label plus the submenu chevron without clipping it.
299
+ slotProps: { paper: { style: { minWidth: 200 } } },
300
+ },
301
+ };
302
+
303
+ // mui-adapter's Menu has no native hover-trigger support (unlike Mantine's `trigger` prop),
304
+ // so this story implements open-on-hover itself: hovering the target opens the menu, and a
305
+ // short close delay (mirroring Mantine's `closeDelay`) keeps it open while the pointer moves
306
+ // from the target into the dropdown, since MUI's Menu renders in a portal outside the
307
+ // wrapper's DOM subtree.
308
+ const HoverMenu = ({ children, ...args }: InteractiveMenuProps) => {
309
+ const [open, setOpen] = useState(false);
310
+ const buttonRef = useRef<HTMLButtonElement>(null);
311
+ const closeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(
312
+ undefined,
313
+ );
314
+
315
+ const cancelClose = () => {
316
+ if (closeTimer.current) clearTimeout(closeTimer.current);
317
+ };
318
+ const scheduleClose = () => {
319
+ closeTimer.current = setTimeout(() => setOpen(false), 300);
320
+ };
321
+
322
+ return (
323
+ <div>
324
+ <Button
325
+ ref={buttonRef}
326
+ variant="solid"
327
+ onClick={() => setOpen((prev) => !prev)}
328
+ onMouseEnter={() => {
329
+ cancelClose();
330
+ setOpen(true);
331
+ }}
332
+ onMouseLeave={scheduleClose}
333
+ >
334
+ Hover or Click
335
+ </Button>
336
+ <Menu
337
+ {...args}
338
+ anchorEl={buttonRef.current}
339
+ open={open}
340
+ onClose={() => setOpen(false)}
341
+ slotProps={{
342
+ paper: {
343
+ onMouseEnter: cancelClose,
344
+ onMouseLeave: scheduleClose,
345
+ },
346
+ }}
347
+ >
348
+ {children}
349
+ </Menu>
350
+ </div>
351
+ );
352
+ };
353
+
354
+ export const HoverTrigger: Story = {
355
+ render: (args) => (
356
+ <HoverMenu {...args}>
231
357
  <MenuItem>
232
358
  <SettingsIcon style={{ marginRight: 8 }} /> Settings
233
359
  </MenuItem>
@@ -237,9 +363,6 @@ export const HoverTrigger: Story = {
237
363
  <MenuItem>
238
364
  <ImageIcon style={{ marginRight: 8 }} /> Gallery
239
365
  </MenuItem>
240
- </InteractiveMenu>
366
+ </HoverMenu>
241
367
  ),
242
- args: {
243
- opened: true,
244
- },
245
368
  };
@@ -38,7 +38,7 @@ export const Menu = forwardRef<HTMLDivElement, MenuProps>(function Menu(
38
38
  <MuiMenu
39
39
  ref={ref}
40
40
  {...(sanitizedProps as MuiMenuProps)}
41
- className={`${styles.dropdown} ${className || ""}`}
41
+ className={className}
42
42
  classes={mergedClassNames}
43
43
  />
44
44
  );