@sproutsocial/seeds-react-menu 1.13.0 → 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 +11 -11
- package/CHANGELOG.md +183 -0
- package/dist/esm/v3/index.js +372 -186
- package/dist/esm/v3/index.js.map +1 -1
- package/dist/v3/index.d.mts +49 -6
- package/dist/v3/index.d.ts +49 -6
- package/dist/v3/index.js +370 -183
- 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 +41 -7
- package/src/v3/Common/SelectStyles.tsx +16 -3
- 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 +140 -0
- package/src/v3/MultiCombobox.tsx +25 -7
- 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/index.ts +1 -0
package/src/v3/MultiCombobox.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { Combobox } from "@base-ui/react/combobox";
|
|
|
3
3
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
4
4
|
import VirtualizedComboboxList from "./Common/VirtualizedComboboxList";
|
|
5
5
|
import { useSeedsPortalContainer } from "./SeedsPortal";
|
|
6
|
+
import { useSuppressEscapeClear } from "./Common/useSuppressEscapeClear";
|
|
6
7
|
import ComboboxItem from "./Common/ComboboxItem";
|
|
7
8
|
import type { DataWithId } from "./Common/types";
|
|
8
9
|
import { groupItems } from "./Common/groupItems";
|
|
@@ -74,6 +75,8 @@ interface MultiComboboxBaseProps {
|
|
|
74
75
|
inputValue?: string;
|
|
75
76
|
onInputValueChange?: (value: string) => void;
|
|
76
77
|
"aria-describedby"?: string;
|
|
78
|
+
isInvalid?: boolean;
|
|
79
|
+
required?: boolean;
|
|
77
80
|
fullWidth?: boolean;
|
|
78
81
|
}
|
|
79
82
|
|
|
@@ -145,6 +148,7 @@ export function MultiCombobox<T extends DataWithId>(
|
|
|
145
148
|
fullWidth = true,
|
|
146
149
|
} = props;
|
|
147
150
|
const ariaDescribedBy = props["aria-describedby"];
|
|
151
|
+
const isInvalid = props.isInvalid;
|
|
148
152
|
|
|
149
153
|
const isChildrenMode = "children" in props && props.children != null;
|
|
150
154
|
|
|
@@ -213,6 +217,9 @@ export function MultiCombobox<T extends DataWithId>(
|
|
|
213
217
|
const virtualizerRef = React.useRef<ReturnType<
|
|
214
218
|
typeof useVirtualizer<HTMLDivElement, Element>
|
|
215
219
|
> | null>(null);
|
|
220
|
+
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
221
|
+
|
|
222
|
+
const onInputKeyDownCapture = useSuppressEscapeClear(open);
|
|
216
223
|
|
|
217
224
|
const isControlled = selectedItemIds !== undefined;
|
|
218
225
|
|
|
@@ -368,6 +375,7 @@ export function MultiCombobox<T extends DataWithId>(
|
|
|
368
375
|
e.preventDefault();
|
|
369
376
|
e.stopPropagation();
|
|
370
377
|
setSelectedItems(value.filter((v) => v.id !== item.id));
|
|
378
|
+
inputRef.current?.focus();
|
|
371
379
|
}
|
|
372
380
|
|
|
373
381
|
// ─── Render sentinel item ─────────────────────────────────────────────────
|
|
@@ -492,7 +500,10 @@ export function MultiCombobox<T extends DataWithId>(
|
|
|
492
500
|
open={open}
|
|
493
501
|
disabled={props.disabled}
|
|
494
502
|
onOpenChange={(nextOpen, eventDetails) => {
|
|
495
|
-
if (!nextOpen && eventDetails.reason === "item-press")
|
|
503
|
+
if (!nextOpen && eventDetails.reason === "item-press") {
|
|
504
|
+
eventDetails.cancel();
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
496
507
|
setOpen(nextOpen);
|
|
497
508
|
}}
|
|
498
509
|
filter={
|
|
@@ -537,13 +548,14 @@ export function MultiCombobox<T extends DataWithId>(
|
|
|
537
548
|
if (!item || !virt) return;
|
|
538
549
|
const isStart = index === 0;
|
|
539
550
|
const isEnd = index === virt.options.count - 1;
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
551
|
+
// Only handle keyboard wrap-around: when navigation loops from
|
|
552
|
+
// the last item to the first (or vice versa), the wrapped item
|
|
553
|
+
// is outside the virtualized window and standard scroll-into-
|
|
554
|
+
// view can't find it.
|
|
555
|
+
if (reason === "keyboard" && (isStart || isEnd)) {
|
|
544
556
|
queueMicrotask(() => {
|
|
545
557
|
virt.scrollToIndex(index, {
|
|
546
|
-
align:
|
|
558
|
+
align: isStart ? "start" : "end",
|
|
547
559
|
});
|
|
548
560
|
});
|
|
549
561
|
}
|
|
@@ -586,7 +598,10 @@ export function MultiCombobox<T extends DataWithId>(
|
|
|
586
598
|
}}
|
|
587
599
|
items={rootItems}
|
|
588
600
|
>
|
|
589
|
-
<ComboboxTokenInputGroup
|
|
601
|
+
<ComboboxTokenInputGroup
|
|
602
|
+
$isInvalid={isInvalid}
|
|
603
|
+
onKeyDownCapture={onInputKeyDownCapture}
|
|
604
|
+
>
|
|
590
605
|
<ComboboxToken>
|
|
591
606
|
{renderSelection
|
|
592
607
|
? renderSelection(value)
|
|
@@ -616,9 +631,12 @@ export function MultiCombobox<T extends DataWithId>(
|
|
|
616
631
|
);
|
|
617
632
|
})()}
|
|
618
633
|
<ComboboxTokenInput
|
|
634
|
+
ref={inputRef}
|
|
619
635
|
id={id}
|
|
620
636
|
placeholder={value.length > 0 ? "" : placeholder}
|
|
621
637
|
aria-describedby={ariaDescribedBy}
|
|
638
|
+
aria-invalid={isInvalid || undefined}
|
|
639
|
+
aria-required={props.required || undefined}
|
|
622
640
|
/>
|
|
623
641
|
<ComboboxActionButtons>
|
|
624
642
|
<ComboboxClear aria-label="Clear all">
|
|
@@ -37,6 +37,30 @@ 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
|
|
45
|
+
label="Pick books"
|
|
46
|
+
isInvalid
|
|
47
|
+
error="Please pick at least one book."
|
|
48
|
+
>
|
|
49
|
+
{({ id, isInvalid }) => (
|
|
50
|
+
<MultiSearchableSelect
|
|
51
|
+
id={id}
|
|
52
|
+
isInvalid={isInvalid}
|
|
53
|
+
data={books}
|
|
54
|
+
itemToString={itemToString}
|
|
55
|
+
placeholder="Select books"
|
|
56
|
+
searchPlaceholder="Search books..."
|
|
57
|
+
/>
|
|
58
|
+
)}
|
|
59
|
+
</FormField>
|
|
60
|
+
</div>
|
|
61
|
+
),
|
|
62
|
+
};
|
|
63
|
+
|
|
40
64
|
export const CustomRenderItem: Story = {
|
|
41
65
|
name: "Custom render item",
|
|
42
66
|
render: () => (
|
|
@@ -80,6 +80,8 @@ interface MultiSearchableSelectBaseProps {
|
|
|
80
80
|
disabled?: boolean;
|
|
81
81
|
filter?: ((item: unknown, query: string) => boolean) | null;
|
|
82
82
|
"aria-describedby"?: string;
|
|
83
|
+
isInvalid?: boolean;
|
|
84
|
+
required?: boolean;
|
|
83
85
|
fullWidth?: boolean;
|
|
84
86
|
}
|
|
85
87
|
|
|
@@ -149,6 +151,7 @@ export function MultiSearchableSelect<T extends DataWithId>(
|
|
|
149
151
|
fullWidth = true,
|
|
150
152
|
} = props;
|
|
151
153
|
const ariaDescribedBy = props["aria-describedby"];
|
|
154
|
+
const isInvalid = props.isInvalid;
|
|
152
155
|
|
|
153
156
|
const isChildrenMode = "children" in props && props.children != null;
|
|
154
157
|
|
|
@@ -502,13 +505,10 @@ export function MultiSearchableSelect<T extends DataWithId>(
|
|
|
502
505
|
if (!item || !virt) return;
|
|
503
506
|
const isStart = index === 0;
|
|
504
507
|
const isEnd = index === virt.options.count - 1;
|
|
505
|
-
if (
|
|
506
|
-
reason === "none" ||
|
|
507
|
-
(reason === "keyboard" && (isStart || isEnd))
|
|
508
|
-
) {
|
|
508
|
+
if (reason === "keyboard" && (isStart || isEnd)) {
|
|
509
509
|
queueMicrotask(() => {
|
|
510
510
|
virt.scrollToIndex(index, {
|
|
511
|
-
align:
|
|
511
|
+
align: isStart ? "start" : "end",
|
|
512
512
|
});
|
|
513
513
|
});
|
|
514
514
|
}
|
|
@@ -554,6 +554,9 @@ export function MultiSearchableSelect<T extends DataWithId>(
|
|
|
554
554
|
<SearchableSelectTokenTrigger
|
|
555
555
|
id={id}
|
|
556
556
|
aria-describedby={ariaDescribedBy}
|
|
557
|
+
aria-invalid={isInvalid || undefined}
|
|
558
|
+
aria-required={props.required || undefined}
|
|
559
|
+
$isInvalid={isInvalid}
|
|
557
560
|
>
|
|
558
561
|
{renderSelection ? (
|
|
559
562
|
renderSelection(value)
|
|
@@ -37,6 +37,29 @@ 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
|
|
45
|
+
label="Pick books"
|
|
46
|
+
isInvalid
|
|
47
|
+
error="Please pick at least one book."
|
|
48
|
+
>
|
|
49
|
+
{({ id, isInvalid }) => (
|
|
50
|
+
<MultiSelect
|
|
51
|
+
id={id}
|
|
52
|
+
isInvalid={isInvalid}
|
|
53
|
+
data={books}
|
|
54
|
+
itemToString={itemToString}
|
|
55
|
+
placeholder="Select books..."
|
|
56
|
+
/>
|
|
57
|
+
)}
|
|
58
|
+
</FormField>
|
|
59
|
+
</div>
|
|
60
|
+
),
|
|
61
|
+
};
|
|
62
|
+
|
|
40
63
|
export const CustomRenderItem: Story = {
|
|
41
64
|
name: "Custom render item",
|
|
42
65
|
render: () => (
|
package/src/v3/MultiSelect.tsx
CHANGED
|
@@ -44,6 +44,8 @@ interface MultiSelectBaseProps {
|
|
|
44
44
|
placeholder?: string;
|
|
45
45
|
disabled?: boolean;
|
|
46
46
|
"aria-describedby"?: string;
|
|
47
|
+
isInvalid?: boolean;
|
|
48
|
+
required?: boolean;
|
|
47
49
|
fullWidth?: boolean;
|
|
48
50
|
}
|
|
49
51
|
|
|
@@ -91,6 +93,7 @@ export type MultiSelectProps<T extends DataWithId> =
|
|
|
91
93
|
export function MultiSelect<T extends DataWithId>(props: MultiSelectProps<T>) {
|
|
92
94
|
const { id, placeholder = "Select...", fullWidth = true } = props;
|
|
93
95
|
const ariaDescribedBy = props["aria-describedby"];
|
|
96
|
+
const isInvalid = props.isInvalid;
|
|
94
97
|
|
|
95
98
|
const isChildrenMode = "children" in props && props.children != null;
|
|
96
99
|
|
|
@@ -182,7 +185,12 @@ export function MultiSelect<T extends DataWithId>(props: MultiSelectProps<T>) {
|
|
|
182
185
|
disabled={props.disabled}
|
|
183
186
|
id={id}
|
|
184
187
|
>
|
|
185
|
-
<SelectTrigger
|
|
188
|
+
<SelectTrigger
|
|
189
|
+
aria-describedby={ariaDescribedBy}
|
|
190
|
+
aria-invalid={isInvalid || undefined}
|
|
191
|
+
aria-required={props.required || undefined}
|
|
192
|
+
$isInvalid={isInvalid}
|
|
193
|
+
>
|
|
186
194
|
<SelectValue>
|
|
187
195
|
{() => {
|
|
188
196
|
if (renderSelection) return renderSelection(selectedItems);
|
|
@@ -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: () => (
|
|
@@ -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,8 @@ 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;
|
|
35
38
|
fullWidth?: boolean;
|
|
36
39
|
}
|
|
37
40
|
|
|
@@ -93,6 +96,7 @@ export function SingleCombobox<T extends DataWithId>(
|
|
|
93
96
|
fullWidth = true,
|
|
94
97
|
} = props;
|
|
95
98
|
const ariaDescribedBy = props["aria-describedby"];
|
|
99
|
+
const isInvalid = props.isInvalid;
|
|
96
100
|
|
|
97
101
|
const isChildrenMode = "children" in props && props.children != null;
|
|
98
102
|
|
|
@@ -137,6 +141,8 @@ export function SingleCombobox<T extends DataWithId>(
|
|
|
137
141
|
typeof useVirtualizer<HTMLDivElement, Element>
|
|
138
142
|
> | null>(null);
|
|
139
143
|
|
|
144
|
+
const onInputKeyDownCapture = useSuppressEscapeClear(open);
|
|
145
|
+
|
|
140
146
|
const isControlled = selectedItemId !== undefined;
|
|
141
147
|
|
|
142
148
|
const idToItem = React.useCallback(
|
|
@@ -193,13 +199,10 @@ export function SingleCombobox<T extends DataWithId>(
|
|
|
193
199
|
if (!item || !virt) return;
|
|
194
200
|
const isStart = index === 0;
|
|
195
201
|
const isEnd = index === virt.options.count - 1;
|
|
196
|
-
if (
|
|
197
|
-
reason === "none" ||
|
|
198
|
-
(reason === "keyboard" && (isStart || isEnd))
|
|
199
|
-
) {
|
|
202
|
+
if (reason === "keyboard" && (isStart || isEnd)) {
|
|
200
203
|
queueMicrotask(() => {
|
|
201
204
|
virt.scrollToIndex(index, {
|
|
202
|
-
align:
|
|
205
|
+
align: isStart ? "start" : "end",
|
|
203
206
|
});
|
|
204
207
|
});
|
|
205
208
|
}
|
|
@@ -207,11 +210,16 @@ export function SingleCombobox<T extends DataWithId>(
|
|
|
207
210
|
: undefined
|
|
208
211
|
}
|
|
209
212
|
>
|
|
210
|
-
<ComboboxInputGroup
|
|
213
|
+
<ComboboxInputGroup
|
|
214
|
+
$isInvalid={isInvalid}
|
|
215
|
+
onKeyDownCapture={onInputKeyDownCapture}
|
|
216
|
+
>
|
|
211
217
|
<ComboboxInput
|
|
212
218
|
placeholder={placeholder}
|
|
213
219
|
id={id}
|
|
214
220
|
aria-describedby={ariaDescribedBy}
|
|
221
|
+
aria-invalid={isInvalid || undefined}
|
|
222
|
+
aria-required={props.required || undefined}
|
|
215
223
|
/>
|
|
216
224
|
<ComboboxActionButtons>
|
|
217
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: () => (
|
|
@@ -42,6 +42,8 @@ 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;
|
|
45
47
|
fullWidth?: boolean;
|
|
46
48
|
}
|
|
47
49
|
|
|
@@ -110,6 +112,7 @@ export function SingleSearchableSelect<T extends DataWithId>(
|
|
|
110
112
|
fullWidth = true,
|
|
111
113
|
} = props;
|
|
112
114
|
const ariaDescribedBy = props["aria-describedby"];
|
|
115
|
+
const isInvalid = props.isInvalid;
|
|
113
116
|
|
|
114
117
|
const isChildrenMode = "children" in props && props.children != null;
|
|
115
118
|
|
|
@@ -226,13 +229,10 @@ export function SingleSearchableSelect<T extends DataWithId>(
|
|
|
226
229
|
if (!item || !virt) return;
|
|
227
230
|
const isStart = index === 0;
|
|
228
231
|
const isEnd = index === virt.options.count - 1;
|
|
229
|
-
if (
|
|
230
|
-
reason === "none" ||
|
|
231
|
-
(reason === "keyboard" && (isStart || isEnd))
|
|
232
|
-
) {
|
|
232
|
+
if (reason === "keyboard" && (isStart || isEnd)) {
|
|
233
233
|
queueMicrotask(() => {
|
|
234
234
|
virt.scrollToIndex(index, {
|
|
235
|
-
align:
|
|
235
|
+
align: isStart ? "start" : "end",
|
|
236
236
|
});
|
|
237
237
|
});
|
|
238
238
|
}
|
|
@@ -240,7 +240,13 @@ export function SingleSearchableSelect<T extends DataWithId>(
|
|
|
240
240
|
: undefined
|
|
241
241
|
}
|
|
242
242
|
>
|
|
243
|
-
<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
|
+
>
|
|
244
250
|
<SearchableSelectPlaceholder>
|
|
245
251
|
<Combobox.Value placeholder={placeholder}>
|
|
246
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: () => (
|
package/src/v3/SingleSelect.tsx
CHANGED
|
@@ -40,6 +40,8 @@ interface SingleSelectBaseProps {
|
|
|
40
40
|
includePlaceholderItem?: boolean;
|
|
41
41
|
disabled?: boolean;
|
|
42
42
|
"aria-describedby"?: string;
|
|
43
|
+
isInvalid?: boolean;
|
|
44
|
+
required?: boolean;
|
|
43
45
|
fullWidth?: boolean;
|
|
44
46
|
}
|
|
45
47
|
|
|
@@ -83,6 +85,7 @@ export function SingleSelect<T extends DataWithId>(
|
|
|
83
85
|
) {
|
|
84
86
|
const { id, placeholder, includePlaceholderItem, fullWidth = true } = props;
|
|
85
87
|
const ariaDescribedBy = props["aria-describedby"];
|
|
88
|
+
const isInvalid = props.isInvalid;
|
|
86
89
|
|
|
87
90
|
const isChildrenMode = "children" in props && props.children != null;
|
|
88
91
|
|
|
@@ -169,7 +172,12 @@ export function SingleSelect<T extends DataWithId>(
|
|
|
169
172
|
onValueChange={handleValueChange}
|
|
170
173
|
id={id}
|
|
171
174
|
>
|
|
172
|
-
<SelectTrigger
|
|
175
|
+
<SelectTrigger
|
|
176
|
+
aria-describedby={ariaDescribedBy}
|
|
177
|
+
aria-invalid={isInvalid || undefined}
|
|
178
|
+
aria-required={props.required || undefined}
|
|
179
|
+
$isInvalid={isInvalid}
|
|
180
|
+
>
|
|
173
181
|
<StyledValue placeholder={placeholder}>
|
|
174
182
|
{renderSelection && selectedItem
|
|
175
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
|
+
});
|
package/src/v3/index.ts
CHANGED