@apify/ui-library 1.160.0 → 1.161.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/dist/components/button/button.d.ts +1 -1
- package/dist/components/button/button.d.ts.map +1 -1
- package/dist/components/button/button.style.d.ts +1 -0
- package/dist/components/button/button.style.d.ts.map +1 -1
- package/dist/components/button/button.style.js +8 -6
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/split_button/index.d.ts +4 -0
- package/dist/components/split_button/index.d.ts.map +1 -0
- package/dist/components/split_button/split_button.d.ts +35 -0
- package/dist/components/split_button/split_button.d.ts.map +1 -0
- package/dist/components/split_button/split_button.js +89 -0
- package/dist/components/split_button/split_button.styled.d.ts +18 -0
- package/dist/components/split_button/split_button.styled.d.ts.map +1 -0
- package/dist/components/split_button/split_button.styled.js +72 -0
- package/dist/components/split_button/split_button.test_ids.d.ts +3 -0
- package/dist/components/split_button/split_button.test_ids.d.ts.map +1 -0
- package/dist/components/split_button/split_button.test_ids.js +5 -0
- package/dist/components/split_button/split_button.type-test.d.ts +2 -0
- package/dist/components/split_button/split_button.type-test.d.ts.map +1 -0
- package/dist/files_hash_at_build.txt +1 -1
- package/dist/index.js +10 -7
- package/dist/src/components/button/button.d.ts +1 -1
- package/dist/src/components/button/button.d.ts.map +1 -1
- package/dist/src/components/button/button.js +1 -1
- package/dist/src/components/button/button.js.map +1 -1
- package/dist/src/components/button/button.style.d.ts +1 -0
- package/dist/src/components/button/button.style.d.ts.map +1 -1
- package/dist/src/components/button/button.style.js +9 -5
- package/dist/src/components/button/button.style.js.map +1 -1
- package/dist/src/components/index.d.ts +1 -0
- package/dist/src/components/index.d.ts.map +1 -1
- package/dist/src/components/index.js +1 -0
- package/dist/src/components/index.js.map +1 -1
- package/dist/src/components/split_button/index.d.ts +4 -0
- package/dist/src/components/split_button/index.d.ts.map +1 -0
- package/dist/src/components/split_button/index.js +4 -0
- package/dist/src/components/split_button/index.js.map +1 -0
- package/dist/src/components/split_button/split_button.d.ts +35 -0
- package/dist/src/components/split_button/split_button.d.ts.map +1 -0
- package/dist/src/components/split_button/split_button.js +42 -0
- package/dist/src/components/split_button/split_button.js.map +1 -0
- package/dist/src/components/split_button/split_button.styled.d.ts +18 -0
- package/dist/src/components/split_button/split_button.styled.d.ts.map +1 -0
- package/dist/src/components/split_button/split_button.styled.js +72 -0
- package/dist/src/components/split_button/split_button.styled.js.map +1 -0
- package/dist/src/components/split_button/split_button.test_ids.d.ts +3 -0
- package/dist/src/components/split_button/split_button.test_ids.d.ts.map +1 -0
- package/dist/src/components/split_button/split_button.test_ids.js +10 -0
- package/dist/src/components/split_button/split_button.test_ids.js.map +1 -0
- package/dist/src/components/split_button/split_button.type-test.d.ts +2 -0
- package/dist/src/components/split_button/split_button.type-test.d.ts.map +1 -0
- package/dist/src/components/split_button/split_button.type-test.js +16 -0
- package/dist/src/components/split_button/split_button.type-test.js.map +1 -0
- package/dist/src/test_ids.d.ts +1 -0
- package/dist/src/test_ids.d.ts.map +1 -1
- package/dist/src/test_ids.js +1 -0
- package/dist/src/test_ids.js.map +1 -1
- package/dist/test_ids.d.ts +1 -0
- package/dist/test_ids.d.ts.map +1 -1
- package/dist/test_ids.js +3 -2
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/src/components/button/button.stories.tsx +1 -1
- package/src/components/button/button.style.ts +10 -5
- package/src/components/button/button.tsx +1 -0
- package/src/components/icon_button/icon_button.stories.tsx +1 -1
- package/src/components/index.ts +1 -0
- package/src/components/split_button/index.ts +3 -0
- package/src/components/split_button/split_button.stories.tsx +224 -0
- package/src/components/split_button/split_button.styled.ts +76 -0
- package/src/components/split_button/split_button.test_ids.ts +16 -0
- package/src/components/split_button/split_button.tsx +156 -0
- package/src/components/split_button/split_button.type-test.ts +21 -0
- package/src/test_ids.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@apify/ui-library",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.161.0",
|
|
4
4
|
"description": "React UI library used by apify.com",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
"styled-components": "^6.1.19"
|
|
76
76
|
},
|
|
77
77
|
"devDependencies": {
|
|
78
|
-
"@apify-packages/types": "^3.
|
|
78
|
+
"@apify-packages/types": "^3.414.0",
|
|
79
79
|
"@storybook/react-vite": "^10.3.5",
|
|
80
80
|
"@testing-library/react": "^16.3.2",
|
|
81
81
|
"@types/hast": "^3.0.4",
|
|
@@ -95,5 +95,5 @@
|
|
|
95
95
|
"src",
|
|
96
96
|
"style"
|
|
97
97
|
],
|
|
98
|
-
"gitHead": "
|
|
98
|
+
"gitHead": "e1ee58d5ef3459efbbec1018df3f22f3da2ded54"
|
|
99
99
|
}
|
|
@@ -177,6 +177,15 @@ export const BUTTON_SIZE_VARIANTS_CSS: ButtonSizeCombinations = {
|
|
|
177
177
|
},
|
|
178
178
|
};
|
|
179
179
|
|
|
180
|
+
// Extracted so components that fake a disabled look without a native `disabled` attribute
|
|
181
|
+
// (e.g. SplitButton's trigger, which must stay clickable to open the dropdown) can reuse it.
|
|
182
|
+
export const getButtonDisabledStyles = (variant: ButtonVariant = 'primary') => css`
|
|
183
|
+
color: ${theme.color.neutral.textDisabled};
|
|
184
|
+
background-color: ${variant === 'tertiary' ? 'transparent' : theme.color.neutral.disabled};
|
|
185
|
+
border-color: ${variant === 'tertiary' ? 'transparent' : theme.color.neutral.disabled};
|
|
186
|
+
cursor: default;
|
|
187
|
+
`;
|
|
188
|
+
|
|
180
189
|
export const getButtonColorStyles = (variant: ButtonVariant = 'primary', color: ButtonColor = 'default') => {
|
|
181
190
|
const {
|
|
182
191
|
backgroundColor,
|
|
@@ -216,11 +225,7 @@ export const getButtonColorStyles = (variant: ButtonVariant = 'primary', color:
|
|
|
216
225
|
}
|
|
217
226
|
|
|
218
227
|
&:disabled {
|
|
219
|
-
|
|
220
|
-
background-color: ${variant === 'tertiary' ? 'transparent' : theme.color.neutral.disabled};
|
|
221
|
-
border-color: ${variant === 'tertiary' ? 'transparent' : theme.color.neutral.disabled};
|
|
222
|
-
|
|
223
|
-
cursor: default;
|
|
228
|
+
${getButtonDisabledStyles(variant)}
|
|
224
229
|
}
|
|
225
230
|
`;
|
|
226
231
|
};
|
|
@@ -8,7 +8,7 @@ import { BellIcon, DeleteIcon, PeopleIcon } from '@apify/ui-icons';
|
|
|
8
8
|
import { ICON_BUTTON_VARIANTS, IconButton, type IconButtonProps } from './icon_button';
|
|
9
9
|
|
|
10
10
|
export default {
|
|
11
|
-
title: 'UI-Library/IconButton',
|
|
11
|
+
title: 'UI-Library/Buttons/IconButton',
|
|
12
12
|
component: IconButton,
|
|
13
13
|
args: {
|
|
14
14
|
onClick: action('onClick'),
|
package/src/components/index.ts
CHANGED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { action } from 'storybook/actions';
|
|
3
|
+
import { expect, userEvent, within } from 'storybook/test';
|
|
4
|
+
import styled from 'styled-components';
|
|
5
|
+
|
|
6
|
+
import { BuildIcon, PlayIcon } from '@apify/ui-icons';
|
|
7
|
+
|
|
8
|
+
import { SplitButton, type SplitButtonAction } from './';
|
|
9
|
+
|
|
10
|
+
const defaultActions: SplitButtonAction[] = [
|
|
11
|
+
{
|
|
12
|
+
label: 'Run',
|
|
13
|
+
onClick: action('run'),
|
|
14
|
+
trackingId: 'SplitButton-Run',
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
label: 'Run with options',
|
|
18
|
+
onClick: action('run-with-options'),
|
|
19
|
+
description: 'Configure input and run',
|
|
20
|
+
trackingId: 'SplitButton-RunWithOptions',
|
|
21
|
+
},
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const meta: Meta<typeof SplitButton> = {
|
|
25
|
+
title: 'UI-Library/Buttons/SplitButton',
|
|
26
|
+
component: SplitButton,
|
|
27
|
+
parameters: {
|
|
28
|
+
layout: 'padded',
|
|
29
|
+
design: {
|
|
30
|
+
type: 'figma',
|
|
31
|
+
url: 'https://www.figma.com/design/duSsGnk84UMYav8mg8QNgR/%F0%9F%93%96-Shared-library?node-id=5236-101133&t=3UoGyQGiM5U9pP0S-0',
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
decorators: [
|
|
35
|
+
(Story) => (
|
|
36
|
+
<div style={{ paddingBottom: '16rem' }}>
|
|
37
|
+
<Story />
|
|
38
|
+
</div>
|
|
39
|
+
),
|
|
40
|
+
],
|
|
41
|
+
argTypes: {
|
|
42
|
+
variant: {
|
|
43
|
+
control: 'select',
|
|
44
|
+
options: ['primary', 'secondary'],
|
|
45
|
+
},
|
|
46
|
+
size: {
|
|
47
|
+
control: 'select',
|
|
48
|
+
options: ['small', 'medium'],
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
args: {
|
|
52
|
+
actions: defaultActions,
|
|
53
|
+
variant: 'primary',
|
|
54
|
+
size: 'medium',
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export default meta;
|
|
59
|
+
|
|
60
|
+
type Story = StoryObj<typeof SplitButton>;
|
|
61
|
+
|
|
62
|
+
const Wrapper = styled.div`
|
|
63
|
+
display: flex;
|
|
64
|
+
flex-direction: column;
|
|
65
|
+
align-items: flex-start;
|
|
66
|
+
gap: 8px;
|
|
67
|
+
`;
|
|
68
|
+
|
|
69
|
+
const ButtonGrid = styled.div`
|
|
70
|
+
display: flex;
|
|
71
|
+
align-items: center;
|
|
72
|
+
gap: 8px;
|
|
73
|
+
margin-bottom: 16px;
|
|
74
|
+
`;
|
|
75
|
+
|
|
76
|
+
// Per the Figma reference (node-id=5236-101133): every Type (color) supports both Primary/Secondary
|
|
77
|
+
// variants at both sizes ("Compact" in Figma). Hover/Focus/Active are pseudo-states, exercised by
|
|
78
|
+
// interacting with the story rather than rendered as separate cells.
|
|
79
|
+
const ColorSection = ({ color }: { color: 'default' | 'success' | 'danger' }) => {
|
|
80
|
+
const disabledActions = defaultActions.map((thisAction) => ({ ...thisAction, disabled: true }));
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<>
|
|
84
|
+
{(['medium', 'small'] as const).map((size) => (
|
|
85
|
+
<ButtonGrid key={size}>
|
|
86
|
+
<SplitButton actions={defaultActions} color={color} variant="primary" size={size} label="Primary" />
|
|
87
|
+
<SplitButton
|
|
88
|
+
actions={defaultActions}
|
|
89
|
+
color={color}
|
|
90
|
+
variant="secondary"
|
|
91
|
+
size={size}
|
|
92
|
+
label="Secondary"
|
|
93
|
+
/>
|
|
94
|
+
<SplitButton
|
|
95
|
+
actions={disabledActions}
|
|
96
|
+
color={color}
|
|
97
|
+
variant="primary"
|
|
98
|
+
size={size}
|
|
99
|
+
label="Disabled"
|
|
100
|
+
/>
|
|
101
|
+
<SplitButton
|
|
102
|
+
actions={disabledActions}
|
|
103
|
+
color={color}
|
|
104
|
+
variant="secondary"
|
|
105
|
+
size={size}
|
|
106
|
+
label="Disabled"
|
|
107
|
+
/>
|
|
108
|
+
</ButtonGrid>
|
|
109
|
+
))}
|
|
110
|
+
</>
|
|
111
|
+
);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export const Default: Story = {
|
|
115
|
+
render: () => (
|
|
116
|
+
<Wrapper>
|
|
117
|
+
<h4>Default</h4>
|
|
118
|
+
<ColorSection color="default" />
|
|
119
|
+
<h4>Success</h4>
|
|
120
|
+
<ColorSection color="success" />
|
|
121
|
+
<h4>Danger</h4>
|
|
122
|
+
<ColorSection color="danger" />
|
|
123
|
+
</Wrapper>
|
|
124
|
+
),
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export const WithDescriptions: Story = {
|
|
128
|
+
args: {
|
|
129
|
+
actions: [
|
|
130
|
+
{
|
|
131
|
+
label: 'Connect workspace',
|
|
132
|
+
onClick: action('connect-workspace'),
|
|
133
|
+
description: 'Authorize Apify via Slack OAuth',
|
|
134
|
+
trackingId: 'SplitButton-ConnectWorkspace',
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
label: 'Add custom token',
|
|
138
|
+
onClick: action('add-custom-token'),
|
|
139
|
+
description: 'Use a bot token from your Slack app',
|
|
140
|
+
trackingId: 'SplitButton-AddCustomToken',
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export const WithIcons: Story = {
|
|
147
|
+
args: {
|
|
148
|
+
actions: [
|
|
149
|
+
{
|
|
150
|
+
label: 'Run',
|
|
151
|
+
icon: PlayIcon,
|
|
152
|
+
onClick: action('run'),
|
|
153
|
+
trackingId: 'SplitButton-Run',
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
label: 'Build and run',
|
|
157
|
+
icon: BuildIcon,
|
|
158
|
+
onClick: action('build-and-run'),
|
|
159
|
+
description: 'Build the Actor and start a run',
|
|
160
|
+
trackingId: 'SplitButton-BuildAndRun',
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
export const WithDisabledAction: Story = {
|
|
167
|
+
args: {
|
|
168
|
+
actions: [
|
|
169
|
+
{
|
|
170
|
+
label: 'Run',
|
|
171
|
+
onClick: action('run'),
|
|
172
|
+
trackingId: 'SplitButton-Run',
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
label: 'Run with options',
|
|
176
|
+
onClick: action('run-with-options'),
|
|
177
|
+
disabled: true,
|
|
178
|
+
disabledReason: 'Save changes before running',
|
|
179
|
+
trackingId: 'SplitButton-RunWithOptions',
|
|
180
|
+
},
|
|
181
|
+
],
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
export const AllActionsDisabled: Story = {
|
|
186
|
+
args: {
|
|
187
|
+
actions: [
|
|
188
|
+
{
|
|
189
|
+
label: 'Run',
|
|
190
|
+
onClick: action('run'),
|
|
191
|
+
disabled: true,
|
|
192
|
+
disabledReason: 'Actor is still building',
|
|
193
|
+
trackingId: 'SplitButton-Run',
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
label: 'Run with options',
|
|
197
|
+
onClick: action('run-with-options'),
|
|
198
|
+
disabled: true,
|
|
199
|
+
disabledReason: 'Actor is still building',
|
|
200
|
+
trackingId: 'SplitButton-RunWithOptions',
|
|
201
|
+
},
|
|
202
|
+
],
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
export const WithTooltip: Story = {
|
|
207
|
+
args: {
|
|
208
|
+
tooltipProps: {
|
|
209
|
+
content: 'Run this Actor with the current input',
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
export const OpensDropdown: Story = {
|
|
215
|
+
play: async ({ canvasElement }) => {
|
|
216
|
+
const canvas = within(canvasElement);
|
|
217
|
+
const buttons = canvas.getAllByRole('button');
|
|
218
|
+
|
|
219
|
+
await userEvent.click(buttons[1]);
|
|
220
|
+
|
|
221
|
+
const dropdownItems = document.querySelectorAll('[data-test="dropdown-item"]');
|
|
222
|
+
await expect(dropdownItems.length).toBe(defaultActions.length);
|
|
223
|
+
},
|
|
224
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import styled from 'styled-components';
|
|
2
|
+
|
|
3
|
+
import { ChevronDownIcon } from '@apify/ui-icons';
|
|
4
|
+
|
|
5
|
+
import { getButtonDisabledStyles } from '../button';
|
|
6
|
+
|
|
7
|
+
export const splitButtonClassNames = {
|
|
8
|
+
ROOT: 'SplitButton',
|
|
9
|
+
MAIN_ACTION: 'SplitButton-MainAction',
|
|
10
|
+
TRIGGER: 'SplitButton-Trigger',
|
|
11
|
+
// Applied to the trigger when every action is disabled - it must still be clickable
|
|
12
|
+
// (so the dropdown can open), so we fake the disabled look with a class instead of `disabled`.
|
|
13
|
+
DISABLED_LOOK: 'SplitButton-DisabledLook',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const StyledSplitButtonWrapper = styled.span`
|
|
17
|
+
display: inline-flex;
|
|
18
|
+
align-items: center;
|
|
19
|
+
justify-content: center;
|
|
20
|
+
|
|
21
|
+
.${splitButtonClassNames.MAIN_ACTION} {
|
|
22
|
+
border-top-right-radius: 0 !important;
|
|
23
|
+
border-bottom-right-radius: 0 !important;
|
|
24
|
+
border-right: 0;
|
|
25
|
+
|
|
26
|
+
svg {
|
|
27
|
+
height: 16px;
|
|
28
|
+
width: 16px;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/* When the main action has an icon, reduce right padding to visually balance
|
|
32
|
+
the space between the icon and the dropdown trigger button */
|
|
33
|
+
&:has(svg) {
|
|
34
|
+
&.small {
|
|
35
|
+
padding: 0 6px 0 8px;
|
|
36
|
+
}
|
|
37
|
+
&.medium {
|
|
38
|
+
padding: 0 8px 0 12px;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.${splitButtonClassNames.TRIGGER} {
|
|
44
|
+
margin-left: 1px;
|
|
45
|
+
border-top-left-radius: 0 !important;
|
|
46
|
+
border-bottom-left-radius: 0 !important;
|
|
47
|
+
|
|
48
|
+
svg {
|
|
49
|
+
margin-right: 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
&.secondary {
|
|
53
|
+
margin-left: 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
&.small {
|
|
57
|
+
padding: 6px;
|
|
58
|
+
}
|
|
59
|
+
&.medium {
|
|
60
|
+
padding: 8px;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
&.${splitButtonClassNames.DISABLED_LOOK} {
|
|
64
|
+
${getButtonDisabledStyles('primary')}
|
|
65
|
+
|
|
66
|
+
&.tertiary {
|
|
67
|
+
${getButtonDisabledStyles('tertiary')}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
`;
|
|
72
|
+
|
|
73
|
+
export const StyledDropdownChevron = styled(ChevronDownIcon)<{ $isOpen?: boolean }>`
|
|
74
|
+
transform: ${({ $isOpen }) => ($isOpen ? 'rotateX(180deg)' : 'none')};
|
|
75
|
+
transition: transform 120ms ease-in-out;
|
|
76
|
+
`;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Dynamic (not a static `<name>TestIds` map) because the trigger's data-test must stay unique
|
|
2
|
+
// per action set, keyed by the main action's own trackingId.
|
|
3
|
+
//
|
|
4
|
+
// Generic so a known-defined trackingId (e.g. a hardcoded constant in a test) narrows to `string`,
|
|
5
|
+
// while a possibly-missing `SplitButtonAction.trackingId` narrows to `string | undefined` - callers
|
|
6
|
+
// don't need a cast, and a missing trackingId drops the `data-test` attribute entirely instead of
|
|
7
|
+
// rendering the literal string "undefined--trigger".
|
|
8
|
+
export const splitButtonTriggerTestId = <T extends string | undefined>(
|
|
9
|
+
trackingId: T,
|
|
10
|
+
): T extends string ? string : undefined =>
|
|
11
|
+
(trackingId ? `${trackingId}--trigger` : undefined) as T extends string ? string : undefined;
|
|
12
|
+
|
|
13
|
+
export const splitButtonMainActionTestId = <T extends string | undefined>(
|
|
14
|
+
trackingId: T,
|
|
15
|
+
): T extends string ? string : undefined =>
|
|
16
|
+
(trackingId ? `${trackingId}--main-action` : undefined) as T extends string ? string : undefined;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { PopperContentProps } from '@radix-ui/react-popper';
|
|
2
|
+
import clsx from 'clsx';
|
|
3
|
+
import type { ComponentPropsWithoutRef, ElementType, ReactNode } from 'react';
|
|
4
|
+
import { useRef, useState } from 'react';
|
|
5
|
+
import styled from 'styled-components';
|
|
6
|
+
|
|
7
|
+
import type { MarginSpacingProps } from '../box';
|
|
8
|
+
import { Button, type ButtonColor, type ButtonSize, type ButtonVariant } from '../button';
|
|
9
|
+
import { Dropdown, DropdownShell } from '../dropdown';
|
|
10
|
+
import { type TooltipProps, withTooltip } from '../floating';
|
|
11
|
+
import { Text } from '../text';
|
|
12
|
+
import { splitButtonClassNames, StyledDropdownChevron, StyledSplitButtonWrapper } from './split_button.styled';
|
|
13
|
+
import { splitButtonMainActionTestId, splitButtonTriggerTestId } from './split_button.test_ids';
|
|
14
|
+
|
|
15
|
+
export interface SplitButtonAction {
|
|
16
|
+
label: ReactNode;
|
|
17
|
+
onClick: () => void;
|
|
18
|
+
icon?: ElementType;
|
|
19
|
+
description?: ReactNode;
|
|
20
|
+
disabled?: boolean;
|
|
21
|
+
disabledReason?: ReactNode;
|
|
22
|
+
trackingId?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SplitButtonProps
|
|
26
|
+
extends Omit<ComponentPropsWithoutRef<'button'>, 'children' | 'onClick' | 'type'>, MarginSpacingProps {
|
|
27
|
+
color?: ButtonColor;
|
|
28
|
+
variant?: ButtonVariant;
|
|
29
|
+
size?: ButtonSize;
|
|
30
|
+
type?: 'button' | 'submit';
|
|
31
|
+
trackingData?: Record<string, unknown>;
|
|
32
|
+
/** Must contain at least two actions - a single action should just be a regular Button. */
|
|
33
|
+
actions: SplitButtonAction[];
|
|
34
|
+
label?: ReactNode;
|
|
35
|
+
side?: PopperContentProps['side'];
|
|
36
|
+
align?: PopperContentProps['align'];
|
|
37
|
+
width?: string;
|
|
38
|
+
showMainIcon?: boolean;
|
|
39
|
+
tooltipProps?: TooltipProps;
|
|
40
|
+
/** Forwarded to the dropdown's DropdownShell - override when a consumer renders inside a modal/overlay stack. */
|
|
41
|
+
zIndex?: number;
|
|
42
|
+
/** Accessible label for the dropdown trigger button. @default 'More actions' */
|
|
43
|
+
triggerAriaLabel?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const ButtonWrapper = styled.div`
|
|
47
|
+
display: flex;
|
|
48
|
+
align-items: center;
|
|
49
|
+
`;
|
|
50
|
+
|
|
51
|
+
const ButtonWrapperWithTooltip = withTooltip(ButtonWrapper);
|
|
52
|
+
|
|
53
|
+
export const SplitButton = ({
|
|
54
|
+
className,
|
|
55
|
+
actions,
|
|
56
|
+
label = '',
|
|
57
|
+
side = 'top',
|
|
58
|
+
align = 'end',
|
|
59
|
+
width = 'auto',
|
|
60
|
+
showMainIcon = true,
|
|
61
|
+
tooltipProps,
|
|
62
|
+
zIndex,
|
|
63
|
+
triggerAriaLabel = 'More actions',
|
|
64
|
+
...passedProps
|
|
65
|
+
}: SplitButtonProps) => {
|
|
66
|
+
const [actionsVisible, setActionsVisible] = useState(false);
|
|
67
|
+
const triggerRef = useRef<HTMLDivElement>(null);
|
|
68
|
+
|
|
69
|
+
if (actions.length <= 1) {
|
|
70
|
+
throw new Error('SplitButton must always have at least two actions, button with one option is normal button');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const mainAction = actions[0];
|
|
74
|
+
|
|
75
|
+
const wrapperClassNames = clsx(splitButtonClassNames.ROOT, className);
|
|
76
|
+
const mainActionClassNames = clsx(splitButtonClassNames.MAIN_ACTION, passedProps.variant, passedProps.size);
|
|
77
|
+
const allActionsDisabled = !actions.some(({ disabled }) => !disabled);
|
|
78
|
+
// When all actions are disabled, we still want to be able to show the dropdown
|
|
79
|
+
const triggerClassNames = clsx(
|
|
80
|
+
splitButtonClassNames.TRIGGER,
|
|
81
|
+
allActionsDisabled && splitButtonClassNames.DISABLED_LOOK,
|
|
82
|
+
passedProps.variant,
|
|
83
|
+
passedProps.size,
|
|
84
|
+
);
|
|
85
|
+
const buttonMainLabel = label || mainAction.label;
|
|
86
|
+
|
|
87
|
+
const Wrapper = tooltipProps ? ButtonWrapperWithTooltip : ButtonWrapper;
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<StyledSplitButtonWrapper className={wrapperClassNames}>
|
|
91
|
+
<Wrapper ref={triggerRef} tooltipProps={tooltipProps}>
|
|
92
|
+
<Button
|
|
93
|
+
data-test={splitButtonMainActionTestId(mainAction.trackingId)}
|
|
94
|
+
{...passedProps}
|
|
95
|
+
className={mainActionClassNames}
|
|
96
|
+
disabled={mainAction.disabled}
|
|
97
|
+
mr="none"
|
|
98
|
+
onClick={() => {
|
|
99
|
+
mainAction.onClick();
|
|
100
|
+
setActionsVisible(false);
|
|
101
|
+
}}
|
|
102
|
+
trackingId={mainAction.trackingId}
|
|
103
|
+
>
|
|
104
|
+
{mainAction.icon && showMainIcon ? <mainAction.icon /> : null}
|
|
105
|
+
{buttonMainLabel}
|
|
106
|
+
</Button>
|
|
107
|
+
<Button
|
|
108
|
+
{...passedProps}
|
|
109
|
+
className={triggerClassNames}
|
|
110
|
+
ml="none"
|
|
111
|
+
onClick={() => setActionsVisible((prevActionsVisible) => !prevActionsVisible)}
|
|
112
|
+
trackingId={mainAction.trackingId ? `${mainAction.trackingId}-OpenDropdown` : undefined}
|
|
113
|
+
data-test={splitButtonTriggerTestId(mainAction.trackingId)}
|
|
114
|
+
aria-haspopup="menu"
|
|
115
|
+
aria-expanded={actionsVisible}
|
|
116
|
+
aria-label={triggerAriaLabel}
|
|
117
|
+
>
|
|
118
|
+
<StyledDropdownChevron $isOpen={actionsVisible} size="16" />
|
|
119
|
+
</Button>
|
|
120
|
+
</Wrapper>
|
|
121
|
+
|
|
122
|
+
<DropdownShell
|
|
123
|
+
isOpen={actionsVisible}
|
|
124
|
+
onIsOpenChange={setActionsVisible}
|
|
125
|
+
triggerRef={triggerRef}
|
|
126
|
+
width={width}
|
|
127
|
+
zIndex={zIndex}
|
|
128
|
+
contentProps={{
|
|
129
|
+
side,
|
|
130
|
+
align,
|
|
131
|
+
sideOffset: 5,
|
|
132
|
+
}}
|
|
133
|
+
>
|
|
134
|
+
{actions.map((action, index) => (
|
|
135
|
+
<Dropdown.Item
|
|
136
|
+
key={action.trackingId ?? index}
|
|
137
|
+
disabled={action.disabled}
|
|
138
|
+
icon={action.icon && <action.icon size="16" />}
|
|
139
|
+
description={action.description}
|
|
140
|
+
onClick={() => {
|
|
141
|
+
action.onClick();
|
|
142
|
+
setActionsVisible(false);
|
|
143
|
+
}}
|
|
144
|
+
>
|
|
145
|
+
{action.label}
|
|
146
|
+
{action.disabled && !!action.disabledReason && (
|
|
147
|
+
<Text italic size="small">
|
|
148
|
+
{action.disabledReason}
|
|
149
|
+
</Text>
|
|
150
|
+
)}
|
|
151
|
+
</Dropdown.Item>
|
|
152
|
+
))}
|
|
153
|
+
</DropdownShell>
|
|
154
|
+
</StyledSplitButtonWrapper>
|
|
155
|
+
);
|
|
156
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { expectTypeOf } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import type { SplitButtonProps } from './';
|
|
4
|
+
|
|
5
|
+
// Extends the native <button> element - e.g. tabIndex and the full aria-* set.
|
|
6
|
+
expectTypeOf<SplitButtonProps>().toHaveProperty('tabIndex');
|
|
7
|
+
expectTypeOf<SplitButtonProps>().toHaveProperty('aria-label');
|
|
8
|
+
expectTypeOf<SplitButtonProps>().toHaveProperty('aria-controls');
|
|
9
|
+
|
|
10
|
+
// Custom button-ish props layered on top of the native element.
|
|
11
|
+
expectTypeOf<SplitButtonProps>().toHaveProperty('variant');
|
|
12
|
+
expectTypeOf<SplitButtonProps>().toHaveProperty('size');
|
|
13
|
+
|
|
14
|
+
// Own props.
|
|
15
|
+
expectTypeOf<SplitButtonProps>().toHaveProperty('actions');
|
|
16
|
+
expectTypeOf<SplitButtonProps>().toHaveProperty('zIndex');
|
|
17
|
+
|
|
18
|
+
// onClick is managed internally per-action, not exposed on the component itself. children is
|
|
19
|
+
// replaced by `actions`/`label`.
|
|
20
|
+
expectTypeOf<SplitButtonProps>().not.toHaveProperty('onClick');
|
|
21
|
+
expectTypeOf<SplitButtonProps>().not.toHaveProperty('children');
|
package/src/test_ids.ts
CHANGED
|
@@ -16,6 +16,10 @@ export {
|
|
|
16
16
|
selectMultiValueLabelTestId,
|
|
17
17
|
} from './components/select/select.test_ids.js';
|
|
18
18
|
export { spinnerTestIds } from './components/spinner/spinner.test_ids.js';
|
|
19
|
+
export {
|
|
20
|
+
splitButtonMainActionTestId,
|
|
21
|
+
splitButtonTriggerTestId,
|
|
22
|
+
} from './components/split_button/split_button.test_ids.js';
|
|
19
23
|
export { storeActorHeaderTestIds } from './components/store/store_actor_header.test_ids.js';
|
|
20
24
|
export { tableTestIds } from './components/table/table.test_ids.js';
|
|
21
25
|
export { tabTestIds } from './components/tabs/tab.test_ids.js';
|