@sproutsocial/seeds-react-menu 1.12.3 → 1.15.10
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 +12 -12
- package/CHANGELOG.md +193 -0
- package/dist/esm/v3/index.js +726 -186
- package/dist/esm/v3/index.js.map +1 -1
- package/dist/index.d.mts +22 -22
- package/dist/index.d.ts +22 -22
- package/dist/v3/index.d.mts +199 -50
- package/dist/v3/index.d.ts +199 -50
- package/dist/v3/index.js +732 -185
- package/dist/v3/index.js.map +1 -1
- package/package.json +14 -14
- package/src/v3/ActionMenu.stories.tsx +260 -0
- package/src/v3/ActionMenu.tsx +345 -0
- package/src/v3/ActionMenuStyles.tsx +154 -0
- package/src/v3/Common/ComboboxStyles.tsx +41 -7
- package/src/v3/Common/SelectStyles.tsx +20 -6
- 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/ModalStories.stories.tsx +159 -0
- package/src/v3/MultiCombobox.stories.tsx +157 -0
- package/src/v3/MultiCombobox.tsx +28 -8
- package/src/v3/MultiSearchableSelect.stories.tsx +42 -0
- package/src/v3/MultiSearchableSelect.tsx +11 -6
- package/src/v3/MultiSelect.stories.tsx +40 -0
- package/src/v3/MultiSelect.tsx +12 -3
- package/src/v3/SingleCombobox.stories.tsx +36 -0
- package/src/v3/SingleCombobox.tsx +17 -7
- package/src/v3/SingleSearchableSelect.stories.tsx +38 -0
- package/src/v3/SingleSearchableSelect.tsx +15 -7
- package/src/v3/SingleSelect.stories.tsx +36 -0
- package/src/v3/SingleSelect.tsx +12 -3
- package/src/v3/__tests__/MenuButton.test.tsx +80 -0
- package/src/v3/index.ts +2 -0
|
@@ -36,6 +36,25 @@ export const Basic: Story = {
|
|
|
36
36
|
),
|
|
37
37
|
};
|
|
38
38
|
|
|
39
|
+
export const Invalid: Story = {
|
|
40
|
+
name: "Invalid",
|
|
41
|
+
render: () => (
|
|
42
|
+
<div style={{ width: "300px" }}>
|
|
43
|
+
<FormField label="Pick a book" isInvalid error="Please pick a book.">
|
|
44
|
+
{({ id, isInvalid }) => (
|
|
45
|
+
<SingleCombobox
|
|
46
|
+
id={id}
|
|
47
|
+
isInvalid={isInvalid}
|
|
48
|
+
data={books}
|
|
49
|
+
itemToString={itemToString}
|
|
50
|
+
placeholder="Search books..."
|
|
51
|
+
/>
|
|
52
|
+
)}
|
|
53
|
+
</FormField>
|
|
54
|
+
</div>
|
|
55
|
+
),
|
|
56
|
+
};
|
|
57
|
+
|
|
39
58
|
export const CustomRenderItem: Story = {
|
|
40
59
|
name: "Custom render item",
|
|
41
60
|
render: () => (
|
|
@@ -192,6 +211,23 @@ export const Virtualized: Story = {
|
|
|
192
211
|
),
|
|
193
212
|
};
|
|
194
213
|
|
|
214
|
+
export const Inline: Story = {
|
|
215
|
+
name: "Inline (fullWidth=false)",
|
|
216
|
+
render: () => (
|
|
217
|
+
<FormField label="Pick a book">
|
|
218
|
+
{({ id }) => (
|
|
219
|
+
<SingleCombobox
|
|
220
|
+
id={id}
|
|
221
|
+
data={books}
|
|
222
|
+
itemToString={itemToString}
|
|
223
|
+
placeholder="Search books..."
|
|
224
|
+
fullWidth={false}
|
|
225
|
+
/>
|
|
226
|
+
)}
|
|
227
|
+
</FormField>
|
|
228
|
+
),
|
|
229
|
+
};
|
|
230
|
+
|
|
195
231
|
export const CustomSearch: Story = {
|
|
196
232
|
name: "Custom search (title + author)",
|
|
197
233
|
render: () => (
|
|
@@ -2,6 +2,7 @@ import * as React from "react";
|
|
|
2
2
|
import { Combobox } from "@base-ui/react/combobox";
|
|
3
3
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
4
4
|
import { useSeedsPortalContainer } from "./SeedsPortal";
|
|
5
|
+
import { useSuppressEscapeClear } from "./Common/useSuppressEscapeClear";
|
|
5
6
|
import ComboboxItem from "./Common/ComboboxItem";
|
|
6
7
|
import VirtualizedComboboxList from "./Common/VirtualizedComboboxList";
|
|
7
8
|
import type { DataWithId } from "./Common/types";
|
|
@@ -32,6 +33,9 @@ interface SingleComboboxBaseProps {
|
|
|
32
33
|
disabled?: boolean;
|
|
33
34
|
filter?: ((item: unknown, query: string) => boolean) | null;
|
|
34
35
|
"aria-describedby"?: string;
|
|
36
|
+
isInvalid?: boolean;
|
|
37
|
+
required?: boolean;
|
|
38
|
+
fullWidth?: boolean;
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
interface SingleComboboxDataBaseProps<T extends DataWithId>
|
|
@@ -89,8 +93,10 @@ export function SingleCombobox<T extends DataWithId>(
|
|
|
89
93
|
placeholder = "Search...",
|
|
90
94
|
emptyText = "No results found.",
|
|
91
95
|
filter,
|
|
96
|
+
fullWidth = true,
|
|
92
97
|
} = props;
|
|
93
98
|
const ariaDescribedBy = props["aria-describedby"];
|
|
99
|
+
const isInvalid = props.isInvalid;
|
|
94
100
|
|
|
95
101
|
const isChildrenMode = "children" in props && props.children != null;
|
|
96
102
|
|
|
@@ -135,6 +141,8 @@ export function SingleCombobox<T extends DataWithId>(
|
|
|
135
141
|
typeof useVirtualizer<HTMLDivElement, Element>
|
|
136
142
|
> | null>(null);
|
|
137
143
|
|
|
144
|
+
const onInputKeyDownCapture = useSuppressEscapeClear(open);
|
|
145
|
+
|
|
138
146
|
const isControlled = selectedItemId !== undefined;
|
|
139
147
|
|
|
140
148
|
const idToItem = React.useCallback(
|
|
@@ -168,7 +176,7 @@ export function SingleCombobox<T extends DataWithId>(
|
|
|
168
176
|
);
|
|
169
177
|
|
|
170
178
|
return (
|
|
171
|
-
<Field>
|
|
179
|
+
<Field $fullWidth={fullWidth}>
|
|
172
180
|
<div ref={containerRef} style={{ position: "relative" }} />
|
|
173
181
|
<Combobox.Root
|
|
174
182
|
id={id}
|
|
@@ -191,13 +199,10 @@ export function SingleCombobox<T extends DataWithId>(
|
|
|
191
199
|
if (!item || !virt) return;
|
|
192
200
|
const isStart = index === 0;
|
|
193
201
|
const isEnd = index === virt.options.count - 1;
|
|
194
|
-
if (
|
|
195
|
-
reason === "none" ||
|
|
196
|
-
(reason === "keyboard" && (isStart || isEnd))
|
|
197
|
-
) {
|
|
202
|
+
if (reason === "keyboard" && (isStart || isEnd)) {
|
|
198
203
|
queueMicrotask(() => {
|
|
199
204
|
virt.scrollToIndex(index, {
|
|
200
|
-
align:
|
|
205
|
+
align: isStart ? "start" : "end",
|
|
201
206
|
});
|
|
202
207
|
});
|
|
203
208
|
}
|
|
@@ -205,11 +210,16 @@ export function SingleCombobox<T extends DataWithId>(
|
|
|
205
210
|
: undefined
|
|
206
211
|
}
|
|
207
212
|
>
|
|
208
|
-
<ComboboxInputGroup
|
|
213
|
+
<ComboboxInputGroup
|
|
214
|
+
$isInvalid={isInvalid}
|
|
215
|
+
onKeyDownCapture={onInputKeyDownCapture}
|
|
216
|
+
>
|
|
209
217
|
<ComboboxInput
|
|
210
218
|
placeholder={placeholder}
|
|
211
219
|
id={id}
|
|
212
220
|
aria-describedby={ariaDescribedBy}
|
|
221
|
+
aria-invalid={isInvalid || undefined}
|
|
222
|
+
aria-required={props.required || undefined}
|
|
213
223
|
/>
|
|
214
224
|
<ComboboxActionButtons>
|
|
215
225
|
<ComboboxClear aria-label="Clear selection">
|
|
@@ -37,6 +37,26 @@ export const Basic: Story = {
|
|
|
37
37
|
),
|
|
38
38
|
};
|
|
39
39
|
|
|
40
|
+
export const Invalid: Story = {
|
|
41
|
+
name: "Invalid",
|
|
42
|
+
render: () => (
|
|
43
|
+
<div style={{ width: "300px" }}>
|
|
44
|
+
<FormField label="Pick a book" isInvalid error="Please pick a book.">
|
|
45
|
+
{({ id, isInvalid }) => (
|
|
46
|
+
<SingleSearchableSelect
|
|
47
|
+
id={id}
|
|
48
|
+
isInvalid={isInvalid}
|
|
49
|
+
data={books}
|
|
50
|
+
itemToString={itemToString}
|
|
51
|
+
placeholder="Select a book"
|
|
52
|
+
searchPlaceholder="Search books..."
|
|
53
|
+
/>
|
|
54
|
+
)}
|
|
55
|
+
</FormField>
|
|
56
|
+
</div>
|
|
57
|
+
),
|
|
58
|
+
};
|
|
59
|
+
|
|
40
60
|
export const CustomRenderItem: Story = {
|
|
41
61
|
name: "Custom render item",
|
|
42
62
|
render: () => (
|
|
@@ -304,6 +324,24 @@ export const LoadingWithToggle: Story = {
|
|
|
304
324
|
},
|
|
305
325
|
};
|
|
306
326
|
|
|
327
|
+
export const Inline: Story = {
|
|
328
|
+
name: "Inline (fullWidth=false)",
|
|
329
|
+
render: () => (
|
|
330
|
+
<FormField label="Pick a book">
|
|
331
|
+
{({ id }) => (
|
|
332
|
+
<SingleSearchableSelect
|
|
333
|
+
id={id}
|
|
334
|
+
data={books}
|
|
335
|
+
itemToString={itemToString}
|
|
336
|
+
placeholder="Select a book"
|
|
337
|
+
searchPlaceholder="Search books..."
|
|
338
|
+
fullWidth={false}
|
|
339
|
+
/>
|
|
340
|
+
)}
|
|
341
|
+
</FormField>
|
|
342
|
+
),
|
|
343
|
+
};
|
|
344
|
+
|
|
307
345
|
export const CustomSearch: Story = {
|
|
308
346
|
name: "Custom search (title + author)",
|
|
309
347
|
render: () => (
|
|
@@ -42,6 +42,9 @@ interface SingleSearchableSelectBaseProps {
|
|
|
42
42
|
disabled?: boolean;
|
|
43
43
|
filter?: ((item: unknown, query: string) => boolean) | null;
|
|
44
44
|
"aria-describedby"?: string;
|
|
45
|
+
isInvalid?: boolean;
|
|
46
|
+
required?: boolean;
|
|
47
|
+
fullWidth?: boolean;
|
|
45
48
|
}
|
|
46
49
|
|
|
47
50
|
interface SingleSearchableSelectDataBaseProps<T extends DataWithId>
|
|
@@ -106,8 +109,10 @@ export function SingleSearchableSelect<T extends DataWithId>(
|
|
|
106
109
|
isLoading = false,
|
|
107
110
|
loadingText = "Loading...",
|
|
108
111
|
filter,
|
|
112
|
+
fullWidth = true,
|
|
109
113
|
} = props;
|
|
110
114
|
const ariaDescribedBy = props["aria-describedby"];
|
|
115
|
+
const isInvalid = props.isInvalid;
|
|
111
116
|
|
|
112
117
|
const isChildrenMode = "children" in props && props.children != null;
|
|
113
118
|
|
|
@@ -190,7 +195,7 @@ export function SingleSearchableSelect<T extends DataWithId>(
|
|
|
190
195
|
);
|
|
191
196
|
|
|
192
197
|
return (
|
|
193
|
-
<Field>
|
|
198
|
+
<Field $fullWidth={fullWidth}>
|
|
194
199
|
<div ref={containerRef} style={{ position: "relative" }} />
|
|
195
200
|
<Combobox.Root
|
|
196
201
|
id={id}
|
|
@@ -224,13 +229,10 @@ export function SingleSearchableSelect<T extends DataWithId>(
|
|
|
224
229
|
if (!item || !virt) return;
|
|
225
230
|
const isStart = index === 0;
|
|
226
231
|
const isEnd = index === virt.options.count - 1;
|
|
227
|
-
if (
|
|
228
|
-
reason === "none" ||
|
|
229
|
-
(reason === "keyboard" && (isStart || isEnd))
|
|
230
|
-
) {
|
|
232
|
+
if (reason === "keyboard" && (isStart || isEnd)) {
|
|
231
233
|
queueMicrotask(() => {
|
|
232
234
|
virt.scrollToIndex(index, {
|
|
233
|
-
align:
|
|
235
|
+
align: isStart ? "start" : "end",
|
|
234
236
|
});
|
|
235
237
|
});
|
|
236
238
|
}
|
|
@@ -238,7 +240,13 @@ export function SingleSearchableSelect<T extends DataWithId>(
|
|
|
238
240
|
: undefined
|
|
239
241
|
}
|
|
240
242
|
>
|
|
241
|
-
<SearchableSelectTrigger
|
|
243
|
+
<SearchableSelectTrigger
|
|
244
|
+
id={id}
|
|
245
|
+
aria-describedby={ariaDescribedBy}
|
|
246
|
+
aria-invalid={isInvalid || undefined}
|
|
247
|
+
aria-required={props.required || undefined}
|
|
248
|
+
$isInvalid={isInvalid}
|
|
249
|
+
>
|
|
242
250
|
<SearchableSelectPlaceholder>
|
|
243
251
|
<Combobox.Value placeholder={placeholder}>
|
|
244
252
|
{value
|
|
@@ -37,6 +37,25 @@ export const Basic: Story = {
|
|
|
37
37
|
),
|
|
38
38
|
};
|
|
39
39
|
|
|
40
|
+
export const Invalid: Story = {
|
|
41
|
+
name: "Invalid",
|
|
42
|
+
render: () => (
|
|
43
|
+
<div style={{ width: "300px" }}>
|
|
44
|
+
<FormField label="Pick a book" isInvalid error="Please pick a book.">
|
|
45
|
+
{({ id, isInvalid }) => (
|
|
46
|
+
<SingleSelect
|
|
47
|
+
id={id}
|
|
48
|
+
isInvalid={isInvalid}
|
|
49
|
+
data={books}
|
|
50
|
+
itemToString={itemToString}
|
|
51
|
+
placeholder="Select a book"
|
|
52
|
+
/>
|
|
53
|
+
)}
|
|
54
|
+
</FormField>
|
|
55
|
+
</div>
|
|
56
|
+
),
|
|
57
|
+
};
|
|
58
|
+
|
|
40
59
|
export const Empty: Story = {
|
|
41
60
|
name: "Empty",
|
|
42
61
|
render: () => (
|
|
@@ -259,6 +278,23 @@ export const DisabledItems: Story = {
|
|
|
259
278
|
),
|
|
260
279
|
};
|
|
261
280
|
|
|
281
|
+
export const Inline: Story = {
|
|
282
|
+
name: "Inline (fullWidth=false)",
|
|
283
|
+
render: () => (
|
|
284
|
+
<FormField label="Pick a book">
|
|
285
|
+
{({ id }) => (
|
|
286
|
+
<SingleSelect
|
|
287
|
+
id={id}
|
|
288
|
+
data={books}
|
|
289
|
+
itemToString={itemToString}
|
|
290
|
+
placeholder="Select a book"
|
|
291
|
+
fullWidth={false}
|
|
292
|
+
/>
|
|
293
|
+
)}
|
|
294
|
+
</FormField>
|
|
295
|
+
),
|
|
296
|
+
};
|
|
297
|
+
|
|
262
298
|
export const Children: Story = {
|
|
263
299
|
name: "Children API",
|
|
264
300
|
render: () => (
|
package/src/v3/SingleSelect.tsx
CHANGED
|
@@ -40,6 +40,9 @@ interface SingleSelectBaseProps {
|
|
|
40
40
|
includePlaceholderItem?: boolean;
|
|
41
41
|
disabled?: boolean;
|
|
42
42
|
"aria-describedby"?: string;
|
|
43
|
+
isInvalid?: boolean;
|
|
44
|
+
required?: boolean;
|
|
45
|
+
fullWidth?: boolean;
|
|
43
46
|
}
|
|
44
47
|
|
|
45
48
|
interface SingleSelectDataProps<T extends DataWithId>
|
|
@@ -80,8 +83,9 @@ export type SingleSelectProps<T extends DataWithId> =
|
|
|
80
83
|
export function SingleSelect<T extends DataWithId>(
|
|
81
84
|
props: SingleSelectProps<T>
|
|
82
85
|
) {
|
|
83
|
-
const { id, placeholder, includePlaceholderItem } = props;
|
|
86
|
+
const { id, placeholder, includePlaceholderItem, fullWidth = true } = props;
|
|
84
87
|
const ariaDescribedBy = props["aria-describedby"];
|
|
88
|
+
const isInvalid = props.isInvalid;
|
|
85
89
|
|
|
86
90
|
const isChildrenMode = "children" in props && props.children != null;
|
|
87
91
|
|
|
@@ -159,7 +163,7 @@ export function SingleSelect<T extends DataWithId>(
|
|
|
159
163
|
}
|
|
160
164
|
|
|
161
165
|
return (
|
|
162
|
-
<Field>
|
|
166
|
+
<Field $fullWidth={fullWidth}>
|
|
163
167
|
<div ref={containerRef} style={{ position: "relative" }} />
|
|
164
168
|
<Select.Root
|
|
165
169
|
items={items}
|
|
@@ -168,7 +172,12 @@ export function SingleSelect<T extends DataWithId>(
|
|
|
168
172
|
onValueChange={handleValueChange}
|
|
169
173
|
id={id}
|
|
170
174
|
>
|
|
171
|
-
<SelectTrigger
|
|
175
|
+
<SelectTrigger
|
|
176
|
+
aria-describedby={ariaDescribedBy}
|
|
177
|
+
aria-invalid={isInvalid || undefined}
|
|
178
|
+
aria-required={props.required || undefined}
|
|
179
|
+
$isInvalid={isInvalid}
|
|
180
|
+
>
|
|
172
181
|
<StyledValue placeholder={placeholder}>
|
|
173
182
|
{renderSelection && selectedItem
|
|
174
183
|
? renderSelection(selectedItem)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import {
|
|
3
|
+
fireEvent,
|
|
4
|
+
render,
|
|
5
|
+
screen,
|
|
6
|
+
} from "@sproutsocial/seeds-react-testing-library";
|
|
7
|
+
import { MenuButton } from "../MenuButton";
|
|
8
|
+
import { MenuItem } from "../ActionMenu";
|
|
9
|
+
|
|
10
|
+
describe("MenuButton", () => {
|
|
11
|
+
const renderMenuButton = (
|
|
12
|
+
props: Partial<React.ComponentProps<typeof MenuButton>> = {}
|
|
13
|
+
) => {
|
|
14
|
+
const onMainClick = jest.fn();
|
|
15
|
+
render(
|
|
16
|
+
<MenuButton
|
|
17
|
+
appearance="primary"
|
|
18
|
+
menuButtonAriaLabel="More actions"
|
|
19
|
+
onClick={onMainClick}
|
|
20
|
+
actionMenu={
|
|
21
|
+
<>
|
|
22
|
+
<MenuItem onClick={jest.fn()}>Compose</MenuItem>
|
|
23
|
+
<MenuItem onClick={jest.fn()}>View Messages</MenuItem>
|
|
24
|
+
</>
|
|
25
|
+
}
|
|
26
|
+
{...props}
|
|
27
|
+
>
|
|
28
|
+
Open Menu
|
|
29
|
+
</MenuButton>
|
|
30
|
+
);
|
|
31
|
+
return { onMainClick };
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
test("renders the main button with its children", () => {
|
|
35
|
+
renderMenuButton();
|
|
36
|
+
expect(
|
|
37
|
+
screen.getByRole("button", { name: "Open Menu" })
|
|
38
|
+
).toBeInTheDocument();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("renders a separate trigger button with an accessible label", () => {
|
|
42
|
+
renderMenuButton();
|
|
43
|
+
const trigger = screen.getByRole("button", { name: "More actions" });
|
|
44
|
+
expect(trigger).toBeInTheDocument();
|
|
45
|
+
// Two distinct buttons: the main action and the menu trigger.
|
|
46
|
+
expect(screen.getAllByRole("button")).toHaveLength(2);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("uses a custom trigger aria-label when provided", () => {
|
|
50
|
+
renderMenuButton({ menuButtonAriaLabel: "Save options" });
|
|
51
|
+
expect(
|
|
52
|
+
screen.getByRole("button", { name: "Save options" })
|
|
53
|
+
).toBeInTheDocument();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("marks the trigger as open so the chevron can rotate", () => {
|
|
57
|
+
renderMenuButton();
|
|
58
|
+
const trigger = screen.getByRole("button", { name: "More actions" });
|
|
59
|
+
// The chevron rotation is driven purely by the `data-popup-open` attribute
|
|
60
|
+
// base-ui sets on the trigger while the menu is open (jsdom does not compute
|
|
61
|
+
// the resulting CSS transform, so assert the driving attribute instead).
|
|
62
|
+
expect(trigger).not.toHaveAttribute("data-popup-open");
|
|
63
|
+
fireEvent.click(trigger);
|
|
64
|
+
expect(trigger).toHaveAttribute("data-popup-open");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("fires the main button onClick without opening the menu", () => {
|
|
68
|
+
const { onMainClick } = renderMenuButton();
|
|
69
|
+
fireEvent.click(screen.getByRole("button", { name: "Open Menu" }));
|
|
70
|
+
expect(onMainClick).toHaveBeenCalledTimes(1);
|
|
71
|
+
// The menu items live behind the trigger and are not rendered until it opens.
|
|
72
|
+
expect(screen.queryByText("Compose")).not.toBeInTheDocument();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("does not fire the main onClick when disabled", () => {
|
|
76
|
+
const { onMainClick } = renderMenuButton({ disabled: true });
|
|
77
|
+
fireEvent.click(screen.getByRole("button", { name: "Open Menu" }));
|
|
78
|
+
expect(onMainClick).not.toHaveBeenCalled();
|
|
79
|
+
});
|
|
80
|
+
});
|