@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/.turbo/turbo-build.log +13 -13
- package/CHANGELOG.md +189 -0
- package/dist/esm/v3/index.js +401 -196
- package/dist/esm/v3/index.js.map +1 -1
- package/dist/v3/index.d.mts +51 -6
- package/dist/v3/index.d.ts +51 -6
- package/dist/v3/index.js +399 -193
- package/dist/v3/index.js.map +1 -1
- package/package.json +14 -14
- package/src/v3/ActionMenuStyles.tsx +11 -10
- package/src/v3/Common/ComboboxStyles.tsx +47 -7
- package/src/v3/Common/SelectStyles.tsx +16 -3
- package/src/v3/Common/VirtualizedComboboxList.tsx +3 -0
- package/src/v3/Common/useSuppressEscapeClear.ts +20 -0
- package/src/v3/MenuButton.stories.tsx +118 -0
- package/src/v3/MenuButton.tsx +68 -0
- package/src/v3/MenuButtonStyles.tsx +38 -0
- package/src/v3/MultiCombobox.stories.tsx +162 -0
- package/src/v3/MultiCombobox.tsx +37 -8
- package/src/v3/MultiSearchableSelect.stories.tsx +24 -0
- package/src/v3/MultiSearchableSelect.tsx +8 -5
- package/src/v3/MultiSelect.stories.tsx +23 -0
- package/src/v3/MultiSelect.tsx +9 -1
- package/src/v3/SingleCombobox.stories.tsx +19 -0
- package/src/v3/SingleCombobox.tsx +14 -6
- package/src/v3/SingleSearchableSelect.stories.tsx +20 -0
- package/src/v3/SingleSearchableSelect.tsx +12 -6
- package/src/v3/SingleSelect.stories.tsx +19 -0
- package/src/v3/SingleSelect.tsx +9 -1
- package/src/v3/__tests__/MenuButton.test.tsx +80 -0
- package/src/v3/__tests__/MultiCombobox.test.tsx +91 -0
- package/src/v3/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sproutsocial/seeds-react-menu",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
40
|
-
"@sproutsocial/seeds-react-button": "^2.
|
|
41
|
-
"@sproutsocial/seeds-react-checkbox": "^1.3.
|
|
42
|
-
"@sproutsocial/seeds-react-icon": "^2.
|
|
43
|
-
"@sproutsocial/seeds-react-input": "^1.5.
|
|
44
|
-
"@sproutsocial/seeds-react-label": "^1.0.
|
|
45
|
-
"@sproutsocial/seeds-react-popout": "^2.
|
|
46
|
-
"@sproutsocial/seeds-react-radio": "^1.3.
|
|
47
|
-
"@sproutsocial/seeds-react-switch": "^1.2.
|
|
48
|
-
"@sproutsocial/seeds-react-system-props": "^3.
|
|
49
|
-
"@sproutsocial/seeds-react-theme": "^
|
|
50
|
-
"@sproutsocial/seeds-react-token-input": "^1.4.
|
|
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.
|
|
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
|
-
|
|
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(--
|
|
24
|
+
max-height: min(var(--available-height, 400px), 400px);
|
|
20
25
|
overflow-y: auto;
|
|
21
|
-
padding: ${({ theme }) => theme.space[
|
|
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
|
-
|
|
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[
|
|
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(--
|
|
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(--
|
|
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
|
+
`;
|