@sproutsocial/seeds-react-menu 1.12.2 → 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.
@@ -0,0 +1,153 @@
1
+ import { Menu } from "@base-ui/react/menu";
2
+ import styled, { css } from "styled-components";
3
+ import { focusRing } from "@sproutsocial/seeds-react-mixins";
4
+
5
+ export const ActionMenuTrigger = styled(Menu.Trigger)``;
6
+
7
+ export const ActionMenuPositioner = styled(Menu.Positioner)`
8
+ min-width: var(--anchor-width);
9
+ z-index: 7;
10
+ `;
11
+
12
+ export const ActionMenuPopup = styled(Menu.Popup)`
13
+ font-family: ${({ theme }) => theme.fontFamily};
14
+ background: ${({ theme }) => theme.colors.container.background.base};
15
+ border: 1px solid ${({ theme }) => theme.colors.container.border.base};
16
+ border-radius: ${({ theme }) => theme.radii[500]};
17
+ box-shadow: ${({ theme }) => theme.shadows.low};
18
+ outline: none;
19
+ max-height: min(var(--base-ui-available-height, 400px), 400px);
20
+ overflow-y: auto;
21
+ padding: ${({ theme }) => theme.space[200]};
22
+
23
+ &[data-open] {
24
+ animation: action-menu-popup-in 120ms ease-out;
25
+ }
26
+
27
+ &[data-closed] {
28
+ animation: action-menu-popup-out 100ms ease-in;
29
+ }
30
+
31
+ @keyframes action-menu-popup-in {
32
+ from {
33
+ opacity: 0;
34
+ transform: scale(0.95);
35
+ }
36
+ to {
37
+ opacity: 1;
38
+ transform: scale(1);
39
+ }
40
+ }
41
+
42
+ @keyframes action-menu-popup-out {
43
+ from {
44
+ opacity: 1;
45
+ transform: scale(1);
46
+ }
47
+ to {
48
+ opacity: 0;
49
+ transform: scale(0.95);
50
+ }
51
+ }
52
+ `;
53
+
54
+ export const ActionMenuGroup = styled(Menu.Group)`
55
+ display: block;
56
+ `;
57
+
58
+ export const ActionMenuGroupLabel = styled(Menu.GroupLabel)`
59
+ padding: ${({ theme }) =>
60
+ `${theme.space[300]} ${theme.space[300]} ${theme.space[200]}`};
61
+ font-size: ${({ theme }) => theme.typography[100].fontSize};
62
+ font-weight: ${({ theme }) => theme.fontWeights.semibold};
63
+ text-transform: uppercase;
64
+ letter-spacing: 0.05em;
65
+ color: ${({ theme }) => theme.colors.text.subtext};
66
+ `;
67
+
68
+ export const ActionMenuSeparator = styled(Menu.Separator)`
69
+ height: 1px;
70
+ margin: ${({ theme }) => `${theme.space[200]} ${theme.space[300]}`};
71
+ background: ${({ theme }) => theme.colors.container.border.base};
72
+ `;
73
+
74
+ const menuItemStyles = css`
75
+ box-sizing: border-box;
76
+ display: block;
77
+ width: 100%;
78
+ border-radius: ${({ theme }) => theme.radii[500]};
79
+ background-color: ${({ theme }) => theme.colors.listItem.background.base};
80
+ border: 1px solid ${({ theme }) => theme.colors.listItem.background.base};
81
+ color: ${({ theme }) => theme.colors.text.body};
82
+ font-family: ${({ theme }) => theme.fontFamily};
83
+ font-weight: ${({ theme }) => theme.fontWeights.normal};
84
+ text-align: left;
85
+ text-decoration: none;
86
+ padding: ${({ theme }) => theme.space[300]};
87
+ outline: 0;
88
+ ${({ theme }) => theme.typography[200]};
89
+ transition: all ${({ theme }) => theme.duration.fast};
90
+ cursor: pointer;
91
+ user-select: none;
92
+
93
+ &[data-highlighted] {
94
+ border-color: ${({ theme }) => theme.colors.listItem.border.base};
95
+ background-color: ${({ theme }) => theme.colors.listItem.background.hover};
96
+ color: ${({ theme }) => theme.colors.text.body};
97
+ text-decoration: none;
98
+ }
99
+
100
+ &[data-disabled] {
101
+ cursor: not-allowed;
102
+ opacity: 0.4;
103
+ }
104
+ `;
105
+
106
+ export const StyledMenuItem = styled(Menu.Item)`
107
+ ${menuItemStyles}
108
+ `;
109
+
110
+ export const StyledCheckboxItem = styled(Menu.CheckboxItem)`
111
+ ${menuItemStyles}
112
+ display: flex;
113
+ align-items: center;
114
+ gap: ${({ theme }) => theme.space[300]};
115
+ `;
116
+
117
+ export const StyledRadioItem = styled(Menu.RadioItem)`
118
+ ${menuItemStyles}
119
+ display: flex;
120
+ align-items: center;
121
+ gap: ${({ theme }) => theme.space[300]};
122
+ `;
123
+
124
+ export const StyledCheckboxItemIndicator = styled(Menu.CheckboxItemIndicator)`
125
+ display: flex;
126
+ align-items: center;
127
+ width: 16px;
128
+ color: ${({ theme }) => theme.colors.text.body};
129
+ `;
130
+
131
+ export const StyledRadioItemIndicator = styled(Menu.RadioItemIndicator)`
132
+ display: flex;
133
+ align-items: center;
134
+ width: 16px;
135
+ color: ${({ theme }) => theme.colors.text.body};
136
+ `;
137
+
138
+ export const StyledSubmenuTrigger = styled(Menu.SubmenuTrigger)`
139
+ ${menuItemStyles}
140
+ display: flex;
141
+ align-items: center;
142
+ gap: ${({ theme }) => theme.space[300]};
143
+ justify-content: space-between;
144
+
145
+ &[data-popup-open] {
146
+ border-color: ${({ theme }) => theme.colors.listItem.border.base};
147
+ background-color: ${({ theme }) => theme.colors.listItem.background.hover};
148
+ }
149
+ `;
150
+
151
+ export const StyledLinkItem = styled(Menu.LinkItem)`
152
+ ${menuItemStyles}
153
+ `;
@@ -2,11 +2,12 @@ import { Select } from "@base-ui/react/select";
2
2
  import styled from "styled-components";
3
3
  import { focusRing } from "@sproutsocial/seeds-react-mixins";
4
4
 
5
- export const Field = styled.div`
6
- display: flex;
5
+ export const Field = styled.div<{ $fullWidth?: boolean }>`
6
+ display: ${({ $fullWidth }) =>
7
+ $fullWidth === false ? "inline-flex" : "flex"};
7
8
  flex-direction: column;
8
9
  gap: ${({ theme }) => theme.space[200]};
9
- width: 100%;
10
+ ${({ $fullWidth }) => $fullWidth !== false && "width: 100%;"}
10
11
  `;
11
12
 
12
13
  export const SelectLabel = styled(Select.Label)`
@@ -1,5 +1,6 @@
1
1
  import React from "react";
2
2
  import type { Meta, StoryObj } from "@storybook/react";
3
+ import { action } from "@storybook/addon-actions";
3
4
  import { FormField } from "@sproutsocial/seeds-react-form-field";
4
5
  import { Button } from "@sproutsocial/seeds-react-button";
5
6
  import { Input } from "@sproutsocial/seeds-react-input";
@@ -16,6 +17,13 @@ import { SingleCombobox } from "./SingleCombobox";
16
17
  import { MultiCombobox } from "./MultiCombobox";
17
18
  import { SingleSearchableSelect } from "./SingleSearchableSelect";
18
19
  import { MultiSearchableSelect } from "./MultiSearchableSelect";
20
+ import {
21
+ ActionMenu,
22
+ MenuGroup,
23
+ MenuItem,
24
+ MenuSub,
25
+ MenuSubTrigger,
26
+ } from "./ActionMenu";
19
27
  import { books, itemToString } from "./storyData";
20
28
 
21
29
  // ─── Shared content ───────────────────────────────────────────────────────────
@@ -179,3 +187,154 @@ export const V2Modal: Story = {
179
187
  </>
180
188
  ),
181
189
  };
190
+
191
+ // ─── Action Menu in Modal ─────────────────────────────────────────────────────
192
+
193
+ export const ActionMenuInV1Modal: Story = {
194
+ name: "Action Menu in V1 Modal",
195
+ render: () => {
196
+ const [isOpen, setIsOpen] = React.useState(false);
197
+ return (
198
+ <>
199
+ <Button appearance="primary" onClick={() => setIsOpen(true)}>
200
+ Open V1 Modal
201
+ </Button>
202
+ <ModalV1
203
+ appElementSelector="#root"
204
+ isOpen={isOpen}
205
+ onClose={() => setIsOpen(false)}
206
+ closeButtonLabel="Close"
207
+ label="Action Menu in V1 Modal"
208
+ >
209
+ <ModalV1.Header title="" subtitle="" bordered>
210
+ Actions
211
+ </ModalV1.Header>
212
+ <ModalV1.Content>
213
+ <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
214
+ <ActionMenu
215
+ trigger={<Button appearance="secondary">Basic Menu</Button>}
216
+ >
217
+ <MenuItem onClick={action("Compose")}>Compose</MenuItem>
218
+ <MenuItem onClick={action("View Messages")}>
219
+ View Messages
220
+ </MenuItem>
221
+ <MenuItem onClick={action("Analyze post")}>
222
+ Analyze post
223
+ </MenuItem>
224
+ </ActionMenu>
225
+
226
+ <ActionMenu
227
+ trigger={
228
+ <Button appearance="secondary">Menu with Groups</Button>
229
+ }
230
+ >
231
+ <MenuGroup label="Actions" showSeparator>
232
+ <MenuItem onClick={action("Edit")}>Edit</MenuItem>
233
+ <MenuItem onClick={action("Duplicate")}>Duplicate</MenuItem>
234
+ </MenuGroup>
235
+ <MenuGroup label="Danger">
236
+ <MenuItem onClick={action("Delete")} disabled>
237
+ Delete
238
+ </MenuItem>
239
+ </MenuGroup>
240
+ </ActionMenu>
241
+
242
+ <ActionMenu
243
+ trigger={
244
+ <Button appearance="secondary">Menu with Submenu</Button>
245
+ }
246
+ >
247
+ <MenuItem onClick={action("Compose")}>Compose</MenuItem>
248
+ <MenuSub trigger={<MenuSubTrigger>Share</MenuSubTrigger>}>
249
+ <MenuItem onClick={action("Share to Twitter")}>
250
+ Twitter
251
+ </MenuItem>
252
+ <MenuItem onClick={action("Share to LinkedIn")}>
253
+ LinkedIn
254
+ </MenuItem>
255
+ <MenuSub
256
+ trigger={<MenuSubTrigger>More platforms</MenuSubTrigger>}
257
+ >
258
+ <MenuItem onClick={action("Share to Facebook")}>
259
+ Facebook
260
+ </MenuItem>
261
+ <MenuItem onClick={action("Share to Instagram")}>
262
+ Instagram
263
+ </MenuItem>
264
+ </MenuSub>
265
+ </MenuSub>
266
+ <MenuItem onClick={action("Delete")} disabled>
267
+ Delete
268
+ </MenuItem>
269
+ </ActionMenu>
270
+ </div>
271
+ </ModalV1.Content>
272
+ <ModalV1.Footer>Footer</ModalV1.Footer>
273
+ </ModalV1>
274
+ </>
275
+ );
276
+ },
277
+ };
278
+
279
+ export const ActionMenuInModal: Story = {
280
+ name: "Action Menu in V2 Modal",
281
+ render: () => (
282
+ <ModalV2
283
+ title="Actions"
284
+ modalTrigger={<Button appearance="primary">Open Modal</Button>}
285
+ closeButtonAriaLabel="Close"
286
+ >
287
+ <ModalBody>
288
+ <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
289
+ <ActionMenu
290
+ trigger={<Button appearance="secondary">Basic Menu</Button>}
291
+ >
292
+ <MenuItem onClick={action("Compose")}>Compose</MenuItem>
293
+ <MenuItem onClick={action("View Messages")}>View Messages</MenuItem>
294
+ <MenuItem onClick={action("Analyze post")}>Analyze post</MenuItem>
295
+ </ActionMenu>
296
+
297
+ <ActionMenu
298
+ trigger={<Button appearance="secondary">Menu with Groups</Button>}
299
+ >
300
+ <MenuGroup label="Actions" showSeparator>
301
+ <MenuItem onClick={action("Edit")}>Edit</MenuItem>
302
+ <MenuItem onClick={action("Duplicate")}>Duplicate</MenuItem>
303
+ </MenuGroup>
304
+ <MenuGroup label="Danger">
305
+ <MenuItem onClick={action("Delete")} disabled>
306
+ Delete
307
+ </MenuItem>
308
+ </MenuGroup>
309
+ </ActionMenu>
310
+
311
+ <ActionMenu
312
+ trigger={<Button appearance="secondary">Menu with Submenu</Button>}
313
+ >
314
+ <MenuItem onClick={action("Compose")}>Compose</MenuItem>
315
+ <MenuSub trigger={<MenuSubTrigger>Share</MenuSubTrigger>}>
316
+ <MenuItem onClick={action("Share to Twitter")}>Twitter</MenuItem>
317
+ <MenuItem onClick={action("Share to LinkedIn")}>
318
+ LinkedIn
319
+ </MenuItem>
320
+ <MenuSub
321
+ trigger={<MenuSubTrigger>More platforms</MenuSubTrigger>}
322
+ >
323
+ <MenuItem onClick={action("Share to Facebook")}>
324
+ Facebook
325
+ </MenuItem>
326
+ <MenuItem onClick={action("Share to Instagram")}>
327
+ Instagram
328
+ </MenuItem>
329
+ </MenuSub>
330
+ </MenuSub>
331
+ <MenuItem onClick={action("Delete")} disabled>
332
+ Delete
333
+ </MenuItem>
334
+ </ActionMenu>
335
+ </div>
336
+ </ModalBody>
337
+ <ModalFooter cancelButton={<Button>Close</Button>} />
338
+ </ModalV2>
339
+ ),
340
+ };
@@ -396,6 +396,23 @@ export const VirtualizedSelectAll: Story = {
396
396
  ),
397
397
  };
398
398
 
399
+ export const Inline: Story = {
400
+ name: "Inline (fullWidth=false)",
401
+ render: () => (
402
+ <FormField label="Pick books">
403
+ {({ id }) => (
404
+ <MultiCombobox
405
+ id={id}
406
+ data={books}
407
+ itemToString={itemToString}
408
+ placeholder="Search books..."
409
+ fullWidth={false}
410
+ />
411
+ )}
412
+ </FormField>
413
+ ),
414
+ };
415
+
399
416
  export const CustomSearch: Story = {
400
417
  name: "Custom search (title + author)",
401
418
  render: () => (
@@ -74,6 +74,7 @@ interface MultiComboboxBaseProps {
74
74
  inputValue?: string;
75
75
  onInputValueChange?: (value: string) => void;
76
76
  "aria-describedby"?: string;
77
+ fullWidth?: boolean;
77
78
  }
78
79
 
79
80
  interface MultiComboboxDataProps<T extends DataWithId>
@@ -141,6 +142,7 @@ export function MultiCombobox<T extends DataWithId>(
141
142
  filter,
142
143
  inputValue,
143
144
  onInputValueChange,
145
+ fullWidth = true,
144
146
  } = props;
145
147
  const ariaDescribedBy = props["aria-describedby"];
146
148
 
@@ -481,7 +483,7 @@ export function MultiCombobox<T extends DataWithId>(
481
483
  : (value as unknown as T[]);
482
484
 
483
485
  return (
484
- <Field>
486
+ <Field $fullWidth={fullWidth}>
485
487
  <div ref={containerRef} style={{ position: "relative" }} />
486
488
  <Combobox.Root
487
489
  multiple
@@ -506,19 +508,28 @@ export function MultiCombobox<T extends DataWithId>(
506
508
  }
507
509
  : filter
508
510
  }
509
- inputValue={creatableProp ? currentInputValue : inputValue}
510
- onInputValueChange={
511
- creatableProp
512
- ? (v) => {
513
- if (skipNextInputChangeRef.current) {
514
- skipNextInputChangeRef.current = false;
515
- return;
516
- }
517
- setInternalInputValue(v);
518
- onInputValueChange?.(v);
519
- }
520
- : onInputValueChange
511
+ inputValue={
512
+ creatableProp ? currentInputValue : inputValue ?? internalInputValue
521
513
  }
514
+ onInputValueChange={(v, eventDetails) => {
515
+ // Preserve search query when an item is selected. Base UI fires
516
+ // 'input-clear' on item-press; ignore it while the popup stays open.
517
+ if (eventDetails.reason === "input-clear" && open) return;
518
+
519
+ if (creatableProp) {
520
+ if (skipNextInputChangeRef.current) {
521
+ skipNextInputChangeRef.current = false;
522
+ return;
523
+ }
524
+ setInternalInputValue(v);
525
+ onInputValueChange?.(v);
526
+ } else {
527
+ if (inputValue === undefined) {
528
+ setInternalInputValue(v);
529
+ }
530
+ onInputValueChange?.(v);
531
+ }
532
+ }}
522
533
  onItemHighlighted={
523
534
  isVirtualized
524
535
  ? (item, { reason, index }) => {
@@ -413,6 +413,24 @@ export const LoadingWithToggle: Story = {
413
413
  },
414
414
  };
415
415
 
416
+ export const Inline: Story = {
417
+ name: "Inline (fullWidth=false)",
418
+ render: () => (
419
+ <FormField label="Pick books">
420
+ {({ id }) => (
421
+ <MultiSearchableSelect
422
+ id={id}
423
+ data={books}
424
+ itemToString={itemToString}
425
+ placeholder="Select books"
426
+ searchPlaceholder="Search books..."
427
+ fullWidth={false}
428
+ />
429
+ )}
430
+ </FormField>
431
+ ),
432
+ };
433
+
416
434
  export const CustomSearch: Story = {
417
435
  name: "Custom search (title + author)",
418
436
  render: () => (