@sproutsocial/seeds-react-menu 1.13.0 → 1.16.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.13.0",
3
+ "version": "1.16.0",
4
4
  "description": "Seeds React Menu",
5
5
  "author": "Sprout Social, Inc.",
6
6
  "license": "MIT",
@@ -36,18 +36,18 @@
36
36
  },
37
37
  "dependencies": {
38
38
  "@radix-ui/react-popover": "^1.1.2",
39
- "@sproutsocial/seeds-react-box": "^1.1.16",
40
- "@sproutsocial/seeds-react-button": "^2.0.5",
41
- "@sproutsocial/seeds-react-checkbox": "^1.3.33",
42
- "@sproutsocial/seeds-react-icon": "^2.3.7",
43
- "@sproutsocial/seeds-react-input": "^1.5.16",
44
- "@sproutsocial/seeds-react-label": "^1.0.16",
45
- "@sproutsocial/seeds-react-popout": "^2.4.38",
46
- "@sproutsocial/seeds-react-radio": "^1.3.16",
47
- "@sproutsocial/seeds-react-switch": "^1.2.32",
48
- "@sproutsocial/seeds-react-system-props": "^3.0.2",
49
- "@sproutsocial/seeds-react-theme": "^3.6.2",
50
- "@sproutsocial/seeds-react-token-input": "^1.4.38",
39
+ "@sproutsocial/seeds-react-box": "^1.1.21",
40
+ "@sproutsocial/seeds-react-button": "^2.2.1",
41
+ "@sproutsocial/seeds-react-checkbox": "^1.3.41",
42
+ "@sproutsocial/seeds-react-icon": "^2.4.0",
43
+ "@sproutsocial/seeds-react-input": "^1.5.26",
44
+ "@sproutsocial/seeds-react-label": "^1.0.22",
45
+ "@sproutsocial/seeds-react-popout": "^2.5.9",
46
+ "@sproutsocial/seeds-react-radio": "^1.3.22",
47
+ "@sproutsocial/seeds-react-switch": "^1.2.39",
48
+ "@sproutsocial/seeds-react-system-props": "^3.1.1",
49
+ "@sproutsocial/seeds-react-theme": "^4.1.0",
50
+ "@sproutsocial/seeds-react-token-input": "^1.4.48",
51
51
  "@sproutsocial/seeds-react-utilities": "^4.3.0",
52
52
  "@base-ui/react": "^1.3.0",
53
53
  "@tanstack/react-virtual": "^3.13.12",
@@ -57,7 +57,7 @@
57
57
  "styled-system": "^5.1.5"
58
58
  },
59
59
  "devDependencies": {
60
- "@sproutsocial/seeds-react-tooltip": "^1.1.20",
60
+ "@sproutsocial/seeds-react-tooltip": "^1.1.27",
61
61
  "@sproutsocial/eslint-config-seeds": "*",
62
62
  "@sproutsocial/seeds-react-testing-library": "*",
63
63
  "@sproutsocial/seeds-testing": "*",
@@ -9,16 +9,21 @@ export const ActionMenuPositioner = styled(Menu.Positioner)`
9
9
  z-index: 7;
10
10
  `;
11
11
 
12
- export const ActionMenuPopup = styled(Menu.Popup)`
12
+ // Opt out of Base UI Drawer's swipe-to-dismiss pointer capture when the menu
13
+ // is rendered inside a Drawer. Without this, the Drawer's viewport captures
14
+ // pointermove/up on portaled popups and MenuItem clicks never fire.
15
+ export const ActionMenuPopup = styled(Menu.Popup).attrs({
16
+ "data-base-ui-swipe-ignore": "",
17
+ })`
13
18
  font-family: ${({ theme }) => theme.fontFamily};
14
19
  background: ${({ theme }) => theme.colors.container.background.base};
15
20
  border: 1px solid ${({ theme }) => theme.colors.container.border.base};
16
21
  border-radius: ${({ theme }) => theme.radii[500]};
17
22
  box-shadow: ${({ theme }) => theme.shadows.low};
18
23
  outline: none;
19
- max-height: min(var(--base-ui-available-height, 400px), 400px);
24
+ max-height: min(var(--available-height, 400px), 400px);
20
25
  overflow-y: auto;
21
- padding: ${({ theme }) => theme.space[200]};
26
+ padding: ${({ theme }) => theme.space[300]};
22
27
 
23
28
  &[data-open] {
24
29
  animation: action-menu-popup-in 120ms ease-out;
@@ -56,18 +61,14 @@ export const ActionMenuGroup = styled(Menu.Group)`
56
61
  `;
57
62
 
58
63
  export const ActionMenuGroupLabel = styled(Menu.GroupLabel)`
59
- padding: ${({ theme }) =>
60
- `${theme.space[300]} ${theme.space[300]} ${theme.space[200]}`};
64
+ padding: ${({ theme }) => `${theme.space[300]}`};
61
65
  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
+ color: ${({ theme }) => theme.colors.text.body};
66
67
  `;
67
68
 
68
69
  export const ActionMenuSeparator = styled(Menu.Separator)`
69
70
  height: 1px;
70
- margin: ${({ theme }) => `${theme.space[200]} ${theme.space[300]}`};
71
+ margin: ${({ theme }) => `${theme.space[300]} -${theme.space[300]}`};
71
72
  background: ${({ theme }) => theme.colors.container.border.base};
72
73
  `;
73
74
 
@@ -1,7 +1,16 @@
1
1
  import { Combobox } from "@base-ui/react/combobox";
2
- import styled from "styled-components";
2
+ import styled, { css } from "styled-components";
3
3
  import { focusRing } from "@sproutsocial/seeds-react-mixins";
4
4
  import Checkbox from "@sproutsocial/seeds-react-checkbox";
5
+ import { StyledChevron } from "./SelectIcons";
6
+
7
+ const invalidStyles = css`
8
+ border-color: ${({ theme }) => theme.colors.form.border.error};
9
+
10
+ ${StyledChevron} {
11
+ color: ${({ theme }) => theme.colors.icon.error};
12
+ }
13
+ `;
5
14
 
6
15
  export const ComboboxLabel = styled.label`
7
16
  font-size: ${({ theme }) => theme.typography[200].fontSize};
@@ -9,7 +18,9 @@ export const ComboboxLabel = styled.label`
9
18
  color: ${({ theme }) => theme.colors.text.body};
10
19
  `;
11
20
 
12
- export const ComboboxInputGroup = styled(Combobox.InputGroup)`
21
+ export const ComboboxInputGroup = styled(Combobox.InputGroup)<{
22
+ $isInvalid?: boolean;
23
+ }>`
13
24
  display: flex;
14
25
  align-items: center;
15
26
  min-width: 160px;
@@ -30,6 +41,8 @@ export const ComboboxInputGroup = styled(Combobox.InputGroup)`
30
41
  opacity: 0.4;
31
42
  cursor: not-allowed;
32
43
  }
44
+
45
+ ${({ $isInvalid }) => $isInvalid && invalidStyles}
33
46
  `;
34
47
 
35
48
  export const ComboboxInput = styled(Combobox.Input)`
@@ -81,7 +94,7 @@ export const ComboboxActionButtons = styled.div`
81
94
  gap: ${({ theme }) => theme.space[100]};
82
95
  `;
83
96
 
84
- export const ComboboxClear = styled(Combobox.Clear)`
97
+ export const ComboboxClear = styled(Combobox.Clear).attrs({ tabIndex: 0 })`
85
98
  display: flex;
86
99
  align-items: center;
87
100
  justify-content: center;
@@ -98,6 +111,10 @@ export const ComboboxClear = styled(Combobox.Clear)`
98
111
  color: ${({ theme }) => theme.colors.text.body};
99
112
  }
100
113
 
114
+ &:focus-visible {
115
+ ${focusRing}
116
+ }
117
+
101
118
  &[data-empty] {
102
119
  display: none;
103
120
  }
@@ -133,7 +150,7 @@ export const ComboboxPopup = styled(Combobox.Popup)`
133
150
  box-shadow: ${({ theme }) => theme.shadows.medium};
134
151
  outline: none;
135
152
  width: 100%;
136
- max-height: min(var(--base-ui-available-height, 400px), 400px);
153
+ max-height: min(var(--available-height, 400px), 400px);
137
154
  overflow-y: auto;
138
155
  padding: 0;
139
156
 
@@ -271,6 +288,12 @@ export const ComboboxGroupHeaderItem = styled(Combobox.Item)`
271
288
  background: ${({ theme }) => theme.colors.listItem.background.hover};
272
289
  color: ${({ theme }) => theme.colors.text.body};
273
290
  }
291
+
292
+ &[data-disabled] {
293
+ opacity: 0.4;
294
+ cursor: not-allowed;
295
+ pointer-events: none;
296
+ }
274
297
  `;
275
298
 
276
299
  export const ComboboxSelectAllItem = styled(Combobox.Item)`
@@ -315,9 +338,12 @@ export const ComboboxToken = styled(Combobox.Chips)`
315
338
  display: flex;
316
339
  width: 100%;
317
340
  flex-wrap: wrap;
341
+ gap: ${({ theme }) => theme.space[200]};
318
342
  `;
319
343
 
320
- export const ComboboxTokenInputGroup = styled(Combobox.InputGroup)`
344
+ export const ComboboxTokenInputGroup = styled(Combobox.InputGroup)<{
345
+ $isInvalid?: boolean;
346
+ }>`
321
347
  display: flex;
322
348
  align-items: center;
323
349
  flex-wrap: wrap;
@@ -341,6 +367,8 @@ export const ComboboxTokenInputGroup = styled(Combobox.InputGroup)`
341
367
  opacity: 0.4;
342
368
  cursor: not-allowed;
343
369
  }
370
+
371
+ ${({ $isInvalid }) => $isInvalid && invalidStyles}
344
372
  `;
345
373
 
346
374
  export const Token = styled.span`
@@ -375,11 +403,17 @@ export const TokenRemove = styled.button`
375
403
  cursor: pointer;
376
404
  padding: 0;
377
405
  line-height: 1;
406
+
407
+ &:focus-visible {
408
+ ${focusRing}
409
+ }
378
410
  `;
379
411
 
380
412
  // Searchable select components
381
413
 
382
- export const SearchableSelectTrigger = styled(Combobox.Trigger)`
414
+ export const SearchableSelectTrigger = styled(Combobox.Trigger)<{
415
+ $isInvalid?: boolean;
416
+ }>`
383
417
  display: flex;
384
418
  align-items: center;
385
419
  justify-content: space-between;
@@ -411,6 +445,8 @@ export const SearchableSelectTrigger = styled(Combobox.Trigger)`
411
445
  opacity: 0.4;
412
446
  cursor: not-allowed;
413
447
  }
448
+
449
+ ${({ $isInvalid }) => $isInvalid && invalidStyles}
414
450
  `;
415
451
 
416
452
  export const SearchableSelectTriggerIcon = styled(Combobox.Icon)`
@@ -464,7 +500,9 @@ export const SearchableSelectList = styled(Combobox.List)`
464
500
  }
465
501
  `;
466
502
 
467
- export const SearchableSelectTokenTrigger = styled(Combobox.Trigger)`
503
+ export const SearchableSelectTokenTrigger = styled(Combobox.Trigger)<{
504
+ $isInvalid?: boolean;
505
+ }>`
468
506
  display: flex;
469
507
  align-items: center;
470
508
  flex-wrap: wrap;
@@ -500,4 +538,6 @@ export const SearchableSelectTokenTrigger = styled(Combobox.Trigger)`
500
538
  opacity: 0.4;
501
539
  cursor: not-allowed;
502
540
  }
541
+
542
+ ${({ $isInvalid }) => $isInvalid && invalidStyles}
503
543
  `;
@@ -1,6 +1,15 @@
1
1
  import { Select } from "@base-ui/react/select";
2
- import styled from "styled-components";
2
+ import styled, { css } from "styled-components";
3
3
  import { focusRing } from "@sproutsocial/seeds-react-mixins";
4
+ import { StyledChevron } from "./SelectIcons";
5
+
6
+ const invalidStyles = css`
7
+ border-color: ${({ theme }) => theme.colors.form.border.error};
8
+
9
+ ${StyledChevron} {
10
+ color: ${({ theme }) => theme.colors.icon.error};
11
+ }
12
+ `;
4
13
 
5
14
  export const Field = styled.div<{ $fullWidth?: boolean }>`
6
15
  display: ${({ $fullWidth }) =>
@@ -16,7 +25,9 @@ export const SelectLabel = styled(Select.Label)`
16
25
  color: ${({ theme }) => theme.colors.text.body};
17
26
  `;
18
27
 
19
- export const SelectTrigger = styled(Select.Trigger)`
28
+ export const SelectTrigger = styled(Select.Trigger)<{
29
+ $isInvalid?: boolean;
30
+ }>`
20
31
  display: flex;
21
32
  align-items: center;
22
33
  justify-content: space-between;
@@ -51,6 +62,8 @@ export const SelectTrigger = styled(Select.Trigger)`
51
62
  opacity: 0.4;
52
63
  cursor: not-allowed;
53
64
  }
65
+
66
+ ${({ $isInvalid }) => $isInvalid && invalidStyles}
54
67
  `;
55
68
 
56
69
  export const SelectValue = styled(Select.Value)`
@@ -97,7 +110,7 @@ export const SelectPopup = styled(Select.Popup)`
97
110
  border-radius: ${({ theme }) => theme.radii[500]};
98
111
  box-shadow: ${({ theme }) => theme.shadows.low};
99
112
  outline: none;
100
- max-height: min(var(--base-ui-available-height, 400px), 400px);
113
+ max-height: min(var(--available-height, 400px), 400px);
101
114
  overflow-y: auto;
102
115
  padding: ${({ theme }) => theme.space[200]};
103
116
 
@@ -55,6 +55,7 @@ interface VirtualizedComboboxListProps<T extends DataWithId> {
55
55
  inputType: "icon" | "radio" | "checkbox" | "none";
56
56
  renderItem?: (item: T) => React.ReactNode;
57
57
  renderGroupHeading?: (label: string) => React.ReactNode;
58
+ isGroupHeaderDisabled?: (groupName: string) => boolean;
58
59
  renderCreatableItem?: (
59
60
  sentinel: CreatableSentinel,
60
61
  style: React.CSSProperties,
@@ -73,6 +74,7 @@ export default function VirtualizedComboboxList<T extends DataWithId>({
73
74
  inputType,
74
75
  renderItem,
75
76
  renderGroupHeading,
77
+ isGroupHeaderDisabled,
76
78
  renderCreatableItem,
77
79
  }: VirtualizedComboboxListProps<T>) {
78
80
  type Row = FlatSentinelRow<T>;
@@ -198,6 +200,7 @@ export default function VirtualizedComboboxList<T extends DataWithId>({
198
200
  index={virtualItem.index}
199
201
  data-index={virtualItem.index}
200
202
  value={row}
203
+ disabled={isGroupHeaderDisabled?.(row.groupName)}
201
204
  style={baseStyle}
202
205
  ref={virtualizer.measureElement}
203
206
  >
@@ -0,0 +1,20 @@
1
+ import * as React from "react";
2
+
3
+ // When the popup is closed, Base UI's ComboboxInput onKeyDown handler clears
4
+ // both the typed input value and the committed selection on Escape (an
5
+ // ARIA-permitted but destructive interpretation of the combobox pattern). We
6
+ // suppress that path by stopping the synthetic event in capture phase before
7
+ // it reaches the input's React bubble-phase handler. The popup-open Escape
8
+ // (handled by Base UI's document listener in useDismiss) and the parent
9
+ // Modal/Dialog Escape (handled by Radix's document-capture listener, which
10
+ // runs before any React synthetic dispatch) are both unaffected.
11
+ export function useSuppressEscapeClear(open: boolean) {
12
+ return React.useCallback(
13
+ (event: React.KeyboardEvent) => {
14
+ if (event.key === "Escape" && !open) {
15
+ event.stopPropagation();
16
+ }
17
+ },
18
+ [open]
19
+ );
20
+ }
@@ -0,0 +1,118 @@
1
+ import React from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react";
3
+ import { action } from "@storybook/addon-actions";
4
+ import { Box } from "@sproutsocial/seeds-react-box";
5
+ import { MenuButton } from "./MenuButton";
6
+ import { MenuItem } from "./ActionMenu";
7
+
8
+ const meta: Meta<typeof MenuButton> = {
9
+ title: "Really Under Development/V3/MenuButton",
10
+ component: MenuButton,
11
+ decorators: [
12
+ (Story) => (
13
+ <Box>
14
+ <Story />
15
+ </Box>
16
+ ),
17
+ ],
18
+ };
19
+
20
+ export default meta;
21
+ type Story = StoryObj<typeof MenuButton>;
22
+
23
+ // ─── Basic ────────────────────────────────────────────────────────────────────
24
+
25
+ export const Basic: Story = {
26
+ name: "Basic",
27
+ render: () => (
28
+ <MenuButton
29
+ menuButtonAriaLabel="More compose options"
30
+ actionMenu={
31
+ <>
32
+ <MenuItem onClick={action("Compose")}>Compose</MenuItem>
33
+ <MenuItem onClick={action("View Messages")}>View Messages</MenuItem>
34
+ <MenuItem onClick={action("Analyze post")}>Analyze post</MenuItem>
35
+ </>
36
+ }
37
+ onClick={action("Open Menu")}
38
+ >
39
+ Open Menu
40
+ </MenuButton>
41
+ ),
42
+ };
43
+
44
+ // ─── Primary ──────────────────────────────────────────────────────────────────
45
+
46
+ export const Primary: Story = {
47
+ name: "Primary",
48
+ render: () => {
49
+ // The dropdown selects the action; the main button reflects and performs it.
50
+ const actions = [
51
+ { label: "Save as Draft", run: action("Save as Draft") },
52
+ { label: "Save and Activate", run: action("Save and Activate") },
53
+ ];
54
+ const [selected, setSelected] = React.useState(actions[0]);
55
+
56
+ return (
57
+ <MenuButton
58
+ appearance="primary"
59
+ menuButtonAriaLabel="More save options"
60
+ actionMenu={
61
+ <>
62
+ {actions.map((a) => (
63
+ <MenuItem key={a.label} onClick={() => setSelected(a)}>
64
+ {a.label}
65
+ </MenuItem>
66
+ ))}
67
+ </>
68
+ }
69
+ onClick={(e) => selected.run(e)}
70
+ >
71
+ {selected.label}
72
+ </MenuButton>
73
+ );
74
+ },
75
+ };
76
+
77
+ // ─── Secondary ────────────────────────────────────────────────────────────────
78
+
79
+ export const Secondary: Story = {
80
+ name: "Secondary",
81
+ render: () => (
82
+ <MenuButton
83
+ appearance="secondary"
84
+ menuButtonAriaLabel="More actions"
85
+ actionMenu={
86
+ <>
87
+ <MenuItem onClick={action("Compose")}>Compose</MenuItem>
88
+ <MenuItem onClick={action("View Messages")}>View Messages</MenuItem>
89
+ </>
90
+ }
91
+ onClick={action("Open Menu")}
92
+ >
93
+ Open Menu
94
+ </MenuButton>
95
+ ),
96
+ };
97
+
98
+ // ─── Disabled ─────────────────────────────────────────────────────────────────
99
+
100
+ export const Disabled: Story = {
101
+ name: "Disabled",
102
+ render: () => (
103
+ <MenuButton
104
+ appearance="primary"
105
+ disabled
106
+ menuButtonAriaLabel="More actions"
107
+ actionMenu={
108
+ <>
109
+ <MenuItem onClick={action("Compose")}>Compose</MenuItem>
110
+ <MenuItem onClick={action("View Messages")}>View Messages</MenuItem>
111
+ </>
112
+ }
113
+ onClick={action("Open Menu")}
114
+ >
115
+ Open Menu
116
+ </MenuButton>
117
+ ),
118
+ };
@@ -0,0 +1,68 @@
1
+ import * as React from "react";
2
+ import { type TypeButtonProps } from "@sproutsocial/seeds-react-button";
3
+ import { Icon } from "@sproutsocial/seeds-react-icon";
4
+ import { ActionMenu } from "./ActionMenu";
5
+ import {
6
+ MenuButtonContainer,
7
+ MainButton,
8
+ TriggerButton,
9
+ } from "./MenuButtonStyles";
10
+
11
+ // ─── MenuButton ─────────────────────────────────────────────────────────────
12
+
13
+ export interface MenuButtonProps extends TypeButtonProps {
14
+ /**
15
+ * Menu item content rendered inside the dropdown half — the children of an
16
+ * ActionMenu (MenuItems / MenuGroups).
17
+ */
18
+ actionMenu: React.ReactNode;
19
+ /**
20
+ * Accessible label for the chevron-only trigger button. Required so consumers
21
+ * supply a localizable label rather than relying on a hardcoded fallback.
22
+ */
23
+ menuButtonAriaLabel: string;
24
+ }
25
+
26
+ /**
27
+ * A "split button": a primary Button rendered immediately next to an ActionMenu
28
+ * whose trigger is an identical Button showing only a chevron-down icon. The
29
+ * two read as one control — the main button's right corners and the trigger's
30
+ * left corners are squared off with a 1px gap between them.
31
+ */
32
+ export function MenuButton({
33
+ actionMenu,
34
+ appearance = "primary",
35
+ size,
36
+ disabled,
37
+ menuButtonAriaLabel,
38
+ children,
39
+ ...rest
40
+ }: MenuButtonProps) {
41
+ return (
42
+ <MenuButtonContainer>
43
+ <MainButton
44
+ appearance={appearance}
45
+ size={size}
46
+ disabled={disabled}
47
+ {...rest}
48
+ >
49
+ {children}
50
+ </MainButton>
51
+ <ActionMenu
52
+ disabled={disabled}
53
+ trigger={
54
+ <TriggerButton
55
+ appearance={appearance}
56
+ size={size}
57
+ disabled={disabled}
58
+ ariaLabel={menuButtonAriaLabel}
59
+ >
60
+ <Icon name="chevron-down-outline" fixedWidth aria-hidden />
61
+ </TriggerButton>
62
+ }
63
+ >
64
+ {actionMenu}
65
+ </ActionMenu>
66
+ </MenuButtonContainer>
67
+ );
68
+ }
@@ -0,0 +1,38 @@
1
+ import styled from "styled-components";
2
+ import { Button } from "@sproutsocial/seeds-react-button";
3
+
4
+ // A 1px gap separates the two halves of the split button (fixed pixel value
5
+ // per the design, not a theme token).
6
+ export const MenuButtonContainer = styled.div`
7
+ display: inline-flex;
8
+ gap: 1px;
9
+ `;
10
+
11
+ // Square off the inner (right) corners of the main button so it reads as one
12
+ // control with the trigger. The `&&` specificity bump beats the base Button
13
+ // Container's own `border-radius` (equal-specificity, source-order dependent).
14
+ export const MainButton = styled(Button)`
15
+ && {
16
+ border-top-right-radius: 0;
17
+ border-bottom-right-radius: 0;
18
+ }
19
+ `;
20
+
21
+ // Square off the inner (left) corners of the chevron trigger button. The
22
+ // chevron rotates 180° while the menu is open: base-ui's Menu.Trigger sets
23
+ // `data-popup-open` on this element (the base Button spreads it onto its root
24
+ // DOM node), mirroring the open-state selector convention in ActionMenuStyles.
25
+ export const TriggerButton = styled(Button)`
26
+ && {
27
+ border-top-left-radius: 0;
28
+ border-bottom-left-radius: 0;
29
+ }
30
+
31
+ .Icon-svg {
32
+ transition: transform ${({ theme }) => theme.duration.fast} ease;
33
+ }
34
+
35
+ &[data-popup-open] .Icon-svg {
36
+ transform: rotate(180deg);
37
+ }
38
+ `;