@sproutsocial/seeds-react-menu 1.12.3 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutsocial/seeds-react-menu",
3
- "version": "1.12.3",
3
+ "version": "1.13.0",
4
4
  "description": "Seeds React Menu",
5
5
  "author": "Sprout Social, Inc.",
6
6
  "license": "MIT",
@@ -0,0 +1,260 @@
1
+ import React from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react";
3
+ import { action } from "@storybook/addon-actions";
4
+ import { Icon } from "@sproutsocial/seeds-react-icon";
5
+ import { Box } from "@sproutsocial/seeds-react-box";
6
+ import { Button } from "@sproutsocial/seeds-react-button";
7
+ import {
8
+ ActionMenu,
9
+ MenuGroup,
10
+ MenuItem,
11
+ MenuRadioGroup,
12
+ MenuSub,
13
+ MenuSubTrigger,
14
+ } from "./ActionMenu";
15
+
16
+ const meta: Meta<typeof ActionMenu> = {
17
+ title: "Really Under Development/V3/ActionMenu",
18
+ component: ActionMenu,
19
+ decorators: [
20
+ (Story) => (
21
+ <Box>
22
+ <Story />
23
+ </Box>
24
+ ),
25
+ ],
26
+ };
27
+
28
+ export default meta;
29
+ type Story = StoryObj<typeof ActionMenu>;
30
+
31
+ // ─── Basic ────────────────────────────────────────────────────────────────────
32
+
33
+ export const Basic: Story = {
34
+ name: "Basic",
35
+ render: () => (
36
+ <ActionMenu trigger={<Button appearance="secondary">Open Menu</Button>}>
37
+ <MenuItem onClick={action("Compose")}>Compose</MenuItem>
38
+ <MenuItem onClick={action("View Messages")}>View Messages</MenuItem>
39
+ <MenuItem onClick={action("Analyze post")}>Analyze post</MenuItem>
40
+ </ActionMenu>
41
+ ),
42
+ };
43
+
44
+ // ─── With Groups ──────────────────────────────────────────────────────────────
45
+
46
+ export const WithGroups: Story = {
47
+ name: "With Groups",
48
+ render: () => (
49
+ <ActionMenu trigger={<Button appearance="secondary">Open Menu</Button>}>
50
+ <MenuGroup label="Actions" showSeparator>
51
+ <MenuItem onClick={action("Compose")}>Compose</MenuItem>
52
+ <MenuItem onClick={action("View Messages")}>View Messages</MenuItem>
53
+ </MenuGroup>
54
+ <MenuGroup label="Settings">
55
+ <MenuItem onClick={action("Settings")}>Settings</MenuItem>
56
+ <MenuItem onClick={action("Help")}>Help</MenuItem>
57
+ </MenuGroup>
58
+ </ActionMenu>
59
+ ),
60
+ };
61
+
62
+ // ─── With Icons ───────────────────────────────────────────────────────────────
63
+
64
+ export const WithIcons: Story = {
65
+ name: "With Icons",
66
+ render: () => (
67
+ <ActionMenu trigger={<Button appearance="secondary">Open Menu</Button>}>
68
+ <MenuItem onClick={action("Compose")}>
69
+ <Box display="flex" alignItems="center" gap="300">
70
+ <Icon name="pencil-to-square-outline" aria-hidden />
71
+ Compose
72
+ </Box>
73
+ </MenuItem>
74
+ <MenuItem onClick={action("View Messages")}>
75
+ <Box display="flex" alignItems="center" gap="300">
76
+ <Icon name="envelope-outline" aria-hidden />
77
+ View Messages
78
+ </Box>
79
+ </MenuItem>
80
+ <MenuItem href="https://sproutsocial.com" target="_blank">
81
+ <Box display="flex" alignItems="center" gap="300">
82
+ <Icon name="circle-question-outline" aria-hidden />
83
+ Visit Help Center
84
+ <Icon
85
+ name="arrow-right-up"
86
+ aria-label="open in a new window"
87
+ style={{ marginLeft: "auto" }}
88
+ />
89
+ </Box>
90
+ </MenuItem>
91
+ </ActionMenu>
92
+ ),
93
+ };
94
+
95
+ // ─── With Disabled Items ──────────────────────────────────────────────────────
96
+
97
+ export const WithDisabledItems: Story = {
98
+ name: "With Disabled Items",
99
+ render: () => (
100
+ <ActionMenu trigger={<Button appearance="secondary">Open Menu</Button>}>
101
+ <MenuItem onClick={action("Compose")}>Compose</MenuItem>
102
+ <MenuItem onClick={action("View Messages")} disabled>
103
+ View Messages (disabled)
104
+ </MenuItem>
105
+ <MenuItem onClick={action("Analyze post")}>Analyze post</MenuItem>
106
+ </ActionMenu>
107
+ ),
108
+ };
109
+
110
+ // ─── Checkbox Items ───────────────────────────────────────────────────────────
111
+
112
+ export const CheckboxItems: Story = {
113
+ name: "Checkbox Items",
114
+ render: () => {
115
+ const [highContrast, setHighContrast] = React.useState(false);
116
+ const [darkMode, setDarkMode] = React.useState(false);
117
+ const [notifications, setNotifications] = React.useState(true);
118
+ return (
119
+ <ActionMenu trigger={<Button appearance="secondary">Preferences</Button>}>
120
+ <MenuItem
121
+ type="checkbox"
122
+ checked={highContrast}
123
+ onCheckedChange={setHighContrast}
124
+ >
125
+ High contrast
126
+ </MenuItem>
127
+ <MenuItem
128
+ type="checkbox"
129
+ checked={darkMode}
130
+ onCheckedChange={setDarkMode}
131
+ >
132
+ Dark mode
133
+ </MenuItem>
134
+ <MenuItem
135
+ type="checkbox"
136
+ checked={notifications}
137
+ onCheckedChange={setNotifications}
138
+ >
139
+ Notifications
140
+ </MenuItem>
141
+ </ActionMenu>
142
+ );
143
+ },
144
+ };
145
+
146
+ // ─── Radio Items ──────────────────────────────────────────────────────────────
147
+
148
+ export const RadioItems: Story = {
149
+ name: "Radio Items",
150
+ render: () => {
151
+ const [theme, setTheme] = React.useState("system");
152
+ return (
153
+ <ActionMenu trigger={<Button appearance="secondary">Theme</Button>}>
154
+ <MenuGroup label="Appearance">
155
+ <MenuRadioGroup value={theme} onValueChange={setTheme}>
156
+ <MenuItem type="radio" value="light">
157
+ Light
158
+ </MenuItem>
159
+ <MenuItem type="radio" value="dark">
160
+ Dark
161
+ </MenuItem>
162
+ <MenuItem type="radio" value="system">
163
+ System default
164
+ </MenuItem>
165
+ </MenuRadioGroup>
166
+ </MenuGroup>
167
+ </ActionMenu>
168
+ );
169
+ },
170
+ };
171
+
172
+ // ─── Controlled Open ──────────────────────────────────────────────────────────
173
+
174
+ export const ControlledOpen: Story = {
175
+ name: "Controlled Open",
176
+ render: () => {
177
+ const [open, setOpen] = React.useState(false);
178
+ return (
179
+ <Box>
180
+ <Button appearance="primary" onClick={() => setOpen(true)} mb={300}>
181
+ Open programmatically
182
+ </Button>
183
+ <ActionMenu
184
+ trigger={<Button appearance="secondary">Menu</Button>}
185
+ open={open}
186
+ onOpenChange={(isOpen) => {
187
+ setOpen(isOpen);
188
+ }}
189
+ >
190
+ <MenuItem onClick={action("Compose")}>Compose</MenuItem>
191
+ <MenuItem onClick={action("View Messages")}>View Messages</MenuItem>
192
+ <MenuItem onClick={action("Analyze post")}>Analyze post</MenuItem>
193
+ </ActionMenu>
194
+ </Box>
195
+ );
196
+ },
197
+ };
198
+
199
+ // ─── Nested Submenu ───────────────────────────────────────────────────────────
200
+
201
+ export const NestedSubmenu: Story = {
202
+ name: "Nested Submenu",
203
+ render: () => (
204
+ <ActionMenu trigger={<Button appearance="secondary">Open Menu</Button>}>
205
+ <MenuItem onClick={action("Compose")}>Compose</MenuItem>
206
+ <MenuItem onClick={action("View Messages")}>View Messages</MenuItem>
207
+ <MenuSub trigger={<MenuSubTrigger>Share</MenuSubTrigger>}>
208
+ <MenuItem onClick={action("Share to Twitter")}>Twitter</MenuItem>
209
+ <MenuItem onClick={action("Share to LinkedIn")}>LinkedIn</MenuItem>
210
+ <MenuSub trigger={<MenuSubTrigger>More platforms</MenuSubTrigger>}>
211
+ <MenuItem onClick={action("Share to Facebook")}>Facebook</MenuItem>
212
+ <MenuItem onClick={action("Share to Instagram")}>Instagram</MenuItem>
213
+ </MenuSub>
214
+ </MenuSub>
215
+ <MenuItem onClick={action("Delete")} disabled>
216
+ Delete
217
+ </MenuItem>
218
+ </ActionMenu>
219
+ ),
220
+ };
221
+
222
+ // ─── Mixed Item Types ─────────────────────────────────────────────────────────
223
+
224
+ export const MixedItemTypes: Story = {
225
+ name: "Mixed Item Types",
226
+ render: () => {
227
+ const [notifications, setNotifications] = React.useState(true);
228
+ const [theme, setTheme] = React.useState("system");
229
+ return (
230
+ <ActionMenu trigger={<Button appearance="secondary">Settings</Button>}>
231
+ <MenuGroup label="Actions" showSeparator>
232
+ <MenuItem onClick={action("Edit profile")}>Edit profile</MenuItem>
233
+ <MenuItem onClick={action("Manage team")}>Manage team</MenuItem>
234
+ </MenuGroup>
235
+ <MenuGroup label="Preferences" showSeparator>
236
+ <MenuItem
237
+ type="checkbox"
238
+ checked={notifications}
239
+ onCheckedChange={setNotifications}
240
+ >
241
+ Email notifications
242
+ </MenuItem>
243
+ </MenuGroup>
244
+ <MenuGroup label="Appearance">
245
+ <MenuRadioGroup value={theme} onValueChange={setTheme}>
246
+ <MenuItem type="radio" value="light">
247
+ Light
248
+ </MenuItem>
249
+ <MenuItem type="radio" value="dark">
250
+ Dark
251
+ </MenuItem>
252
+ <MenuItem type="radio" value="system">
253
+ System
254
+ </MenuItem>
255
+ </MenuRadioGroup>
256
+ </MenuGroup>
257
+ </ActionMenu>
258
+ );
259
+ },
260
+ };
@@ -0,0 +1,345 @@
1
+ import * as React from "react";
2
+ import { Menu, type MenuRootChangeEventDetails } from "@base-ui/react/menu";
3
+ import Radio from "@sproutsocial/seeds-react-radio";
4
+ import Checkbox from "@sproutsocial/seeds-react-checkbox";
5
+ import { Icon } from "@sproutsocial/seeds-react-icon";
6
+ import { useSeedsPortalContainer } from "./SeedsPortal";
7
+ import {
8
+ ActionMenuTrigger,
9
+ ActionMenuPositioner,
10
+ ActionMenuPopup,
11
+ ActionMenuGroup,
12
+ ActionMenuGroupLabel,
13
+ ActionMenuSeparator,
14
+ StyledMenuItem,
15
+ StyledCheckboxItem,
16
+ StyledCheckboxItemIndicator,
17
+ StyledRadioItem,
18
+ StyledRadioItemIndicator,
19
+ StyledLinkItem,
20
+ StyledSubmenuTrigger,
21
+ } from "./ActionMenuStyles";
22
+
23
+ // ─── ActionMenu ───────────────────────────────────────────────────────────────
24
+
25
+ export interface ActionMenuProps {
26
+ /** The button element that opens the menu */
27
+ trigger: React.ReactElement;
28
+ /** Menu items and groups */
29
+ children: React.ReactNode;
30
+ /** Controlled open state */
31
+ open?: boolean;
32
+ /** Uncontrolled default open state */
33
+ defaultOpen?: boolean;
34
+ /** Called when open state changes */
35
+ onOpenChange?: (
36
+ open: boolean,
37
+ eventDetails: MenuRootChangeEventDetails
38
+ ) => void;
39
+ /** Disable the trigger */
40
+ disabled?: boolean;
41
+ }
42
+
43
+ export function ActionMenu({
44
+ trigger,
45
+ children,
46
+ open,
47
+ defaultOpen,
48
+ onOpenChange,
49
+ disabled,
50
+ }: ActionMenuProps) {
51
+ const { containerRef, portalContainer } = useSeedsPortalContainer();
52
+
53
+ return (
54
+ <Menu.Root
55
+ open={open}
56
+ defaultOpen={defaultOpen}
57
+ onOpenChange={onOpenChange}
58
+ >
59
+ <div ref={containerRef} style={{ position: "relative" }} />
60
+ <ActionMenuTrigger render={trigger} disabled={disabled} />
61
+ <Menu.Portal container={portalContainer}>
62
+ <ActionMenuPositioner sideOffset={8}>
63
+ <ActionMenuPopup>{children}</ActionMenuPopup>
64
+ </ActionMenuPositioner>
65
+ </Menu.Portal>
66
+ </Menu.Root>
67
+ );
68
+ }
69
+
70
+ // ─── MenuGroup ────────────────────────────────────────────────────────────────
71
+
72
+ export interface MenuGroupProps {
73
+ children: React.ReactNode;
74
+ /** Optional group label shown above items */
75
+ label?: string;
76
+ /** Render a separator line after this group */
77
+ showSeparator?: boolean;
78
+ }
79
+
80
+ export function MenuGroup({
81
+ children,
82
+ label,
83
+ showSeparator = false,
84
+ }: MenuGroupProps) {
85
+ return (
86
+ <>
87
+ <ActionMenuGroup>
88
+ {label && <ActionMenuGroupLabel>{label}</ActionMenuGroupLabel>}
89
+ {children}
90
+ </ActionMenuGroup>
91
+ {showSeparator && <ActionMenuSeparator />}
92
+ </>
93
+ );
94
+ }
95
+
96
+ // ─── MenuRadioGroup ───────────────────────────────────────────────────────────
97
+
98
+ export interface MenuRadioGroupProps {
99
+ children: React.ReactNode;
100
+ /** Controlled selected value */
101
+ value?: string;
102
+ /** Uncontrolled default value */
103
+ defaultValue?: string;
104
+ /** Called when selected value changes */
105
+ onValueChange?: (value: string) => void;
106
+ }
107
+
108
+ export function MenuRadioGroup({
109
+ children,
110
+ value,
111
+ defaultValue,
112
+ onValueChange,
113
+ }: MenuRadioGroupProps) {
114
+ return (
115
+ <Menu.RadioGroup
116
+ value={value}
117
+ defaultValue={defaultValue}
118
+ onValueChange={onValueChange}
119
+ >
120
+ {children}
121
+ </Menu.RadioGroup>
122
+ );
123
+ }
124
+
125
+ // ─── MenuItem ─────────────────────────────────────────────────────────────────
126
+
127
+ interface MenuItemActionProps {
128
+ type?: "action";
129
+ children: React.ReactNode;
130
+ onClick?: React.MouseEventHandler<HTMLElement>;
131
+ disabled?: boolean;
132
+ /** Close the menu when this item is clicked. Defaults to true. */
133
+ closeOnClick?: boolean;
134
+ /** Render as a link */
135
+ href?: string;
136
+ target?: string;
137
+ }
138
+
139
+ interface MenuItemCheckboxProps {
140
+ type: "checkbox";
141
+ children: React.ReactNode;
142
+ /** Controlled checked state */
143
+ checked?: boolean;
144
+ /** Uncontrolled default checked state */
145
+ defaultChecked?: boolean;
146
+ onCheckedChange?: (checked: boolean) => void;
147
+ disabled?: boolean;
148
+ /** Close the menu when clicked. Defaults to false for checkbox items. */
149
+ closeOnClick?: boolean;
150
+ id?: string;
151
+ }
152
+
153
+ interface MenuItemRadioProps {
154
+ type: "radio";
155
+ /** Value submitted to the parent MenuRadioGroup */
156
+ value: string;
157
+ children: React.ReactNode;
158
+ disabled?: boolean;
159
+ /** Close the menu when clicked. Defaults to false for radio items. */
160
+ closeOnClick?: boolean;
161
+ }
162
+
163
+ export type MenuItemProps =
164
+ | MenuItemActionProps
165
+ | MenuItemCheckboxProps
166
+ | MenuItemRadioProps;
167
+
168
+ export function MenuItem(props: MenuItemProps) {
169
+ const generatedId = React.useId();
170
+
171
+ if (props.type === "checkbox") {
172
+ const {
173
+ children,
174
+ checked,
175
+ defaultChecked,
176
+ onCheckedChange,
177
+ disabled,
178
+ closeOnClick = false,
179
+ id,
180
+ } = props;
181
+ const itemId = id ?? `checkbox-menu-item-${generatedId}`;
182
+ return (
183
+ <StyledCheckboxItem
184
+ checked={checked}
185
+ defaultChecked={defaultChecked}
186
+ onCheckedChange={onCheckedChange}
187
+ disabled={disabled}
188
+ closeOnClick={closeOnClick}
189
+ >
190
+ <StyledCheckboxItemIndicator
191
+ keepMounted
192
+ render={(_indicatorProps, state) => (
193
+ <Checkbox
194
+ checked={state.checked}
195
+ onChange={() => {}}
196
+ tabIndex={-1}
197
+ id={itemId}
198
+ name="menu-checkbox-item"
199
+ />
200
+ )}
201
+ />
202
+ {children}
203
+ </StyledCheckboxItem>
204
+ );
205
+ }
206
+
207
+ if (props.type === "radio") {
208
+ const { children, value, disabled, closeOnClick = false } = props;
209
+ return (
210
+ <StyledRadioItem
211
+ value={value}
212
+ disabled={disabled}
213
+ closeOnClick={closeOnClick}
214
+ >
215
+ <StyledRadioItemIndicator
216
+ keepMounted
217
+ render={(_indicatorProps, state) => (
218
+ <Radio
219
+ checked={state.checked}
220
+ onChange={() => {}}
221
+ tabIndex={-1}
222
+ id={`radio-menu-item-${value}`}
223
+ name="menu-radio-item"
224
+ />
225
+ )}
226
+ />
227
+ {children}
228
+ </StyledRadioItem>
229
+ );
230
+ }
231
+
232
+ // Default: action item
233
+ const {
234
+ children,
235
+ onClick,
236
+ disabled,
237
+ closeOnClick = true,
238
+ href,
239
+ target,
240
+ } = props as MenuItemActionProps;
241
+
242
+ if (href) {
243
+ return (
244
+ <StyledLinkItem href={href} target={target} closeOnClick={closeOnClick}>
245
+ {children}
246
+ </StyledLinkItem>
247
+ );
248
+ }
249
+
250
+ return (
251
+ <StyledMenuItem
252
+ onClick={onClick}
253
+ disabled={disabled}
254
+ closeOnClick={closeOnClick}
255
+ >
256
+ {children}
257
+ </StyledMenuItem>
258
+ );
259
+ }
260
+
261
+ // ─── MenuSub ──────────────────────────────────────────────────────────────────
262
+
263
+ export interface MenuSubProps {
264
+ /** The MenuSubTrigger element that opens the submenu */
265
+ trigger: React.ReactNode;
266
+ /** Submenu items */
267
+ children: React.ReactNode;
268
+ /** Controlled open state */
269
+ open?: boolean;
270
+ /** Uncontrolled default open state */
271
+ defaultOpen?: boolean;
272
+ /** Called when open state changes */
273
+ onOpenChange?: (
274
+ open: boolean,
275
+ eventDetails: MenuRootChangeEventDetails
276
+ ) => void;
277
+ /**
278
+ * When true, pressing Escape closes the entire menu tree.
279
+ * When false (default), Escape only closes the submenu.
280
+ */
281
+ closeParentOnEsc?: boolean;
282
+ }
283
+
284
+ export function MenuSub({
285
+ trigger,
286
+ children,
287
+ open,
288
+ defaultOpen,
289
+ onOpenChange,
290
+ closeParentOnEsc = false,
291
+ }: MenuSubProps) {
292
+ const { containerRef, portalContainer } = useSeedsPortalContainer();
293
+
294
+ return (
295
+ <Menu.SubmenuRoot
296
+ open={open}
297
+ defaultOpen={defaultOpen}
298
+ onOpenChange={onOpenChange}
299
+ closeParentOnEsc={closeParentOnEsc}
300
+ >
301
+ <div ref={containerRef} style={{ position: "relative" }} />
302
+ {trigger}
303
+ <Menu.Portal container={portalContainer}>
304
+ <ActionMenuPositioner sideOffset={getOffset} alignOffset={getOffset}>
305
+ <ActionMenuPopup>{children}</ActionMenuPopup>
306
+ </ActionMenuPositioner>
307
+ </Menu.Portal>
308
+ </Menu.SubmenuRoot>
309
+ );
310
+ }
311
+ function getOffset({ side }: { side: Menu.Positioner.Props["side"] }) {
312
+ return side === "top" || side === "bottom" ? 4 : -8;
313
+ }
314
+
315
+ // ─── MenuSubTrigger ───────────────────────────────────────────────────────────
316
+
317
+ export interface MenuSubTriggerProps {
318
+ children: React.ReactNode;
319
+ disabled?: boolean;
320
+ /** Custom label for keyboard text navigation */
321
+ label?: string;
322
+ /**
323
+ * Whether the submenu opens on hover.
324
+ * @default true
325
+ */
326
+ openOnHover?: boolean;
327
+ }
328
+
329
+ export function MenuSubTrigger({
330
+ children,
331
+ disabled,
332
+ label,
333
+ openOnHover = true,
334
+ }: MenuSubTriggerProps) {
335
+ return (
336
+ <StyledSubmenuTrigger
337
+ disabled={disabled}
338
+ label={label}
339
+ openOnHover={openOnHover}
340
+ >
341
+ {children}
342
+ <Icon name="chevron-right-outline" fixedWidth aria-hidden />
343
+ </StyledSubmenuTrigger>
344
+ );
345
+ }