@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
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
3
3
|
import { FormField } from "@sproutsocial/seeds-react-form-field";
|
|
4
|
+
import { Button } from "@sproutsocial/seeds-react-button";
|
|
5
|
+
import {
|
|
6
|
+
ModalV2,
|
|
7
|
+
ModalBody,
|
|
8
|
+
ModalFooter,
|
|
9
|
+
} from "@sproutsocial/seeds-react-modal";
|
|
4
10
|
import { MultiCombobox } from "./MultiCombobox";
|
|
5
11
|
import {
|
|
6
12
|
books,
|
|
@@ -190,6 +196,28 @@ export const SelectableHeadings: Story = {
|
|
|
190
196
|
),
|
|
191
197
|
};
|
|
192
198
|
|
|
199
|
+
export const DisabledGroupHeadings: Story = {
|
|
200
|
+
name: "Disabled group headings",
|
|
201
|
+
render: () => (
|
|
202
|
+
<div style={{ width: "300px" }}>
|
|
203
|
+
<FormField label="Pick books">
|
|
204
|
+
{({ id }) => (
|
|
205
|
+
<MultiCombobox
|
|
206
|
+
id={id}
|
|
207
|
+
data={books}
|
|
208
|
+
itemToString={itemToString}
|
|
209
|
+
renderItem={renderItem}
|
|
210
|
+
placeholder="Search books..."
|
|
211
|
+
groupBy={(book) => book.author}
|
|
212
|
+
selectHeadings
|
|
213
|
+
isGroupHeaderDisabled={(groupName) => groupName === "Harper Lee"}
|
|
214
|
+
/>
|
|
215
|
+
)}
|
|
216
|
+
</FormField>
|
|
217
|
+
</div>
|
|
218
|
+
),
|
|
219
|
+
};
|
|
220
|
+
|
|
193
221
|
export const SelectAll: Story = {
|
|
194
222
|
name: "Select all",
|
|
195
223
|
render: () => (
|
|
@@ -301,6 +329,28 @@ export const MaxTokens: Story = {
|
|
|
301
329
|
),
|
|
302
330
|
};
|
|
303
331
|
|
|
332
|
+
export const LargeBookSetWithMaxTokens: Story = {
|
|
333
|
+
name: "Large book set with max tokens",
|
|
334
|
+
render: () => (
|
|
335
|
+
<div style={{ width: "300px" }}>
|
|
336
|
+
<FormField label="Pick books">
|
|
337
|
+
{({ id }) => (
|
|
338
|
+
<MultiCombobox
|
|
339
|
+
id={id}
|
|
340
|
+
data={largeBookSet}
|
|
341
|
+
itemToString={itemToString}
|
|
342
|
+
renderItem={renderItem}
|
|
343
|
+
placeholder="Search books..."
|
|
344
|
+
isVirtualized
|
|
345
|
+
selectAll
|
|
346
|
+
maxTokens={5}
|
|
347
|
+
/>
|
|
348
|
+
)}
|
|
349
|
+
</FormField>
|
|
350
|
+
</div>
|
|
351
|
+
),
|
|
352
|
+
};
|
|
353
|
+
|
|
304
354
|
export const Loading: Story = {
|
|
305
355
|
name: "Loading",
|
|
306
356
|
render: () => (
|
|
@@ -472,3 +522,115 @@ export const Creatable: Story = {
|
|
|
472
522
|
);
|
|
473
523
|
},
|
|
474
524
|
};
|
|
525
|
+
|
|
526
|
+
// Story to help visualize what the combobox component looks like when it's invalid within a FormField component
|
|
527
|
+
export const Invalid: Story = {
|
|
528
|
+
name: "Invalid",
|
|
529
|
+
render: () => (
|
|
530
|
+
<div style={{ width: "300px" }}>
|
|
531
|
+
<FormField
|
|
532
|
+
label="Pick books"
|
|
533
|
+
isInvalid
|
|
534
|
+
error="Please pick at least one book."
|
|
535
|
+
>
|
|
536
|
+
{({ id, isInvalid }) => (
|
|
537
|
+
<MultiCombobox
|
|
538
|
+
id={id}
|
|
539
|
+
isInvalid={isInvalid}
|
|
540
|
+
data={books}
|
|
541
|
+
itemToString={itemToString}
|
|
542
|
+
placeholder="Search books..."
|
|
543
|
+
/>
|
|
544
|
+
)}
|
|
545
|
+
</FormField>
|
|
546
|
+
</div>
|
|
547
|
+
),
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
// Story to help visualize what the combobox component looks like when it's required within a FormField component
|
|
551
|
+
export const Required: Story = {
|
|
552
|
+
name: "Required",
|
|
553
|
+
render: () => (
|
|
554
|
+
<div style={{ width: "300px" }}>
|
|
555
|
+
<FormField label="Pick books" required>
|
|
556
|
+
{({ id, required }) => (
|
|
557
|
+
<MultiCombobox
|
|
558
|
+
id={id}
|
|
559
|
+
required={required}
|
|
560
|
+
data={books}
|
|
561
|
+
itemToString={itemToString}
|
|
562
|
+
placeholder="Search books..."
|
|
563
|
+
/>
|
|
564
|
+
)}
|
|
565
|
+
</FormField>
|
|
566
|
+
</div>
|
|
567
|
+
),
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
export const LargeBookSetWithMaxTokensInModal: Story = {
|
|
571
|
+
name: "Large book set with max tokens (in modal)",
|
|
572
|
+
render: () => (
|
|
573
|
+
<ModalV2
|
|
574
|
+
title="Pick some books"
|
|
575
|
+
modalTrigger={<Button appearance="primary">Open Modal</Button>}
|
|
576
|
+
closeButtonAriaLabel="Close"
|
|
577
|
+
>
|
|
578
|
+
<ModalBody>
|
|
579
|
+
<div style={{ width: "300px" }}>
|
|
580
|
+
<FormField label="Pick books">
|
|
581
|
+
{({ id }) => (
|
|
582
|
+
<MultiCombobox
|
|
583
|
+
id={id}
|
|
584
|
+
data={largeBookSet}
|
|
585
|
+
itemToString={itemToString}
|
|
586
|
+
renderItem={renderItem}
|
|
587
|
+
placeholder="Search books..."
|
|
588
|
+
isVirtualized
|
|
589
|
+
selectAll
|
|
590
|
+
maxTokens={5}
|
|
591
|
+
/>
|
|
592
|
+
)}
|
|
593
|
+
</FormField>
|
|
594
|
+
</div>
|
|
595
|
+
</ModalBody>
|
|
596
|
+
<ModalFooter cancelButton={<Button>Cancel</Button>} />
|
|
597
|
+
</ModalV2>
|
|
598
|
+
),
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
// Verifies the a11y fix for Escape key containment. Open the modal, open the
|
|
602
|
+
// combobox dropdown, then press Escape: the dropdown should close and focus
|
|
603
|
+
// should return to the input — the modal should stay open. Pressing Escape
|
|
604
|
+
// again (with the dropdown closed) should close the modal.
|
|
605
|
+
export const EscapeKeyInModal: Story = {
|
|
606
|
+
name: "Escape Key in Modal (a11y)",
|
|
607
|
+
render: () => (
|
|
608
|
+
<ModalV2
|
|
609
|
+
title="Pick some books"
|
|
610
|
+
modalTrigger={<Button appearance="primary">Open Modal</Button>}
|
|
611
|
+
closeButtonAriaLabel="Close"
|
|
612
|
+
>
|
|
613
|
+
<ModalBody>
|
|
614
|
+
<div style={{ width: "300px", padding: "16px 0" }}>
|
|
615
|
+
<FormField label="Pick books">
|
|
616
|
+
{({ id }) => (
|
|
617
|
+
<MultiCombobox
|
|
618
|
+
id={id}
|
|
619
|
+
data={books}
|
|
620
|
+
itemToString={itemToString}
|
|
621
|
+
placeholder="Search books..."
|
|
622
|
+
/>
|
|
623
|
+
)}
|
|
624
|
+
</FormField>
|
|
625
|
+
<p style={{ marginTop: 16, fontSize: 14 }}>
|
|
626
|
+
<strong>To verify:</strong> open the combobox, press{" "}
|
|
627
|
+
<kbd>Escape</kbd>. The dropdown should close and focus should return
|
|
628
|
+
to the input. The modal should remain open. Press <kbd>Escape</kbd>{" "}
|
|
629
|
+
again to close the modal.
|
|
630
|
+
</p>
|
|
631
|
+
</div>
|
|
632
|
+
</ModalBody>
|
|
633
|
+
<ModalFooter cancelButton={<Button>Cancel</Button>} />
|
|
634
|
+
</ModalV2>
|
|
635
|
+
),
|
|
636
|
+
};
|
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
|
|
|
@@ -90,6 +93,7 @@ interface MultiComboboxDataProps<T extends DataWithId>
|
|
|
90
93
|
onSelectedItemIdsChange?: (ids: Array<T["id"]>) => void;
|
|
91
94
|
groupBy?: (item: T) => string;
|
|
92
95
|
renderGroupHeading?: (label: string) => React.ReactNode;
|
|
96
|
+
isGroupHeaderDisabled?: (groupName: string) => boolean;
|
|
93
97
|
selectHeadings?: boolean;
|
|
94
98
|
selectAll?: boolean | string;
|
|
95
99
|
isVirtualized?: boolean;
|
|
@@ -113,6 +117,7 @@ interface MultiComboboxChildrenProps extends MultiComboboxBaseProps {
|
|
|
113
117
|
inputType?: never;
|
|
114
118
|
groupBy?: never;
|
|
115
119
|
renderGroupHeading?: never;
|
|
120
|
+
isGroupHeaderDisabled?: never;
|
|
116
121
|
selectHeadings?: never;
|
|
117
122
|
selectAll?: never;
|
|
118
123
|
removeSelectedItems?: never;
|
|
@@ -145,6 +150,7 @@ export function MultiCombobox<T extends DataWithId>(
|
|
|
145
150
|
fullWidth = true,
|
|
146
151
|
} = props;
|
|
147
152
|
const ariaDescribedBy = props["aria-describedby"];
|
|
153
|
+
const isInvalid = props.isInvalid;
|
|
148
154
|
|
|
149
155
|
const isChildrenMode = "children" in props && props.children != null;
|
|
150
156
|
|
|
@@ -180,6 +186,9 @@ export function MultiCombobox<T extends DataWithId>(
|
|
|
180
186
|
const renderGroupHeading = isChildrenMode
|
|
181
187
|
? undefined
|
|
182
188
|
: (props as MultiComboboxDataProps<T>).renderGroupHeading;
|
|
189
|
+
const isGroupHeaderDisabled = isChildrenMode
|
|
190
|
+
? undefined
|
|
191
|
+
: (props as MultiComboboxDataProps<T>).isGroupHeaderDisabled;
|
|
183
192
|
const selectHeadings = isChildrenMode
|
|
184
193
|
? false
|
|
185
194
|
: (props as MultiComboboxDataProps<T>).selectHeadings ?? false;
|
|
@@ -213,6 +222,9 @@ export function MultiCombobox<T extends DataWithId>(
|
|
|
213
222
|
const virtualizerRef = React.useRef<ReturnType<
|
|
214
223
|
typeof useVirtualizer<HTMLDivElement, Element>
|
|
215
224
|
> | null>(null);
|
|
225
|
+
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
226
|
+
|
|
227
|
+
const onInputKeyDownCapture = useSuppressEscapeClear(open);
|
|
216
228
|
|
|
217
229
|
const isControlled = selectedItemIds !== undefined;
|
|
218
230
|
|
|
@@ -331,6 +343,7 @@ export function MultiCombobox<T extends DataWithId>(
|
|
|
331
343
|
|
|
332
344
|
let updated = [...realItems];
|
|
333
345
|
for (const sentinel of groupSentinels) {
|
|
346
|
+
if (isGroupHeaderDisabled?.(sentinel.groupName)) continue;
|
|
334
347
|
const group = groups?.find((g) => g.value === sentinel.groupName);
|
|
335
348
|
if (!group) continue;
|
|
336
349
|
const allSelected = group.items.every((item) =>
|
|
@@ -368,6 +381,7 @@ export function MultiCombobox<T extends DataWithId>(
|
|
|
368
381
|
e.preventDefault();
|
|
369
382
|
e.stopPropagation();
|
|
370
383
|
setSelectedItems(value.filter((v) => v.id !== item.id));
|
|
384
|
+
inputRef.current?.focus();
|
|
371
385
|
}
|
|
372
386
|
|
|
373
387
|
// ─── Render sentinel item ─────────────────────────────────────────────────
|
|
@@ -403,7 +417,11 @@ export function MultiCombobox<T extends DataWithId>(
|
|
|
403
417
|
? "all"
|
|
404
418
|
: "partial";
|
|
405
419
|
return (
|
|
406
|
-
<ComboboxGroupHeaderItem
|
|
420
|
+
<ComboboxGroupHeaderItem
|
|
421
|
+
key={`__header-${row.groupName}`}
|
|
422
|
+
value={row}
|
|
423
|
+
disabled={isGroupHeaderDisabled?.(row.groupName)}
|
|
424
|
+
>
|
|
407
425
|
<ComboboxGroupHeaderCheckbox
|
|
408
426
|
$state={state}
|
|
409
427
|
id={`__header-${row.groupName}`}
|
|
@@ -492,7 +510,10 @@ export function MultiCombobox<T extends DataWithId>(
|
|
|
492
510
|
open={open}
|
|
493
511
|
disabled={props.disabled}
|
|
494
512
|
onOpenChange={(nextOpen, eventDetails) => {
|
|
495
|
-
if (!nextOpen && eventDetails.reason === "item-press")
|
|
513
|
+
if (!nextOpen && eventDetails.reason === "item-press") {
|
|
514
|
+
eventDetails.cancel();
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
496
517
|
setOpen(nextOpen);
|
|
497
518
|
}}
|
|
498
519
|
filter={
|
|
@@ -537,13 +558,14 @@ export function MultiCombobox<T extends DataWithId>(
|
|
|
537
558
|
if (!item || !virt) return;
|
|
538
559
|
const isStart = index === 0;
|
|
539
560
|
const isEnd = index === virt.options.count - 1;
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
561
|
+
// Only handle keyboard wrap-around: when navigation loops from
|
|
562
|
+
// the last item to the first (or vice versa), the wrapped item
|
|
563
|
+
// is outside the virtualized window and standard scroll-into-
|
|
564
|
+
// view can't find it.
|
|
565
|
+
if (reason === "keyboard" && (isStart || isEnd)) {
|
|
544
566
|
queueMicrotask(() => {
|
|
545
567
|
virt.scrollToIndex(index, {
|
|
546
|
-
align:
|
|
568
|
+
align: isStart ? "start" : "end",
|
|
547
569
|
});
|
|
548
570
|
});
|
|
549
571
|
}
|
|
@@ -586,7 +608,10 @@ export function MultiCombobox<T extends DataWithId>(
|
|
|
586
608
|
}}
|
|
587
609
|
items={rootItems}
|
|
588
610
|
>
|
|
589
|
-
<ComboboxTokenInputGroup
|
|
611
|
+
<ComboboxTokenInputGroup
|
|
612
|
+
$isInvalid={isInvalid}
|
|
613
|
+
onKeyDownCapture={onInputKeyDownCapture}
|
|
614
|
+
>
|
|
590
615
|
<ComboboxToken>
|
|
591
616
|
{renderSelection
|
|
592
617
|
? renderSelection(value)
|
|
@@ -616,9 +641,12 @@ export function MultiCombobox<T extends DataWithId>(
|
|
|
616
641
|
);
|
|
617
642
|
})()}
|
|
618
643
|
<ComboboxTokenInput
|
|
644
|
+
ref={inputRef}
|
|
619
645
|
id={id}
|
|
620
646
|
placeholder={value.length > 0 ? "" : placeholder}
|
|
621
647
|
aria-describedby={ariaDescribedBy}
|
|
648
|
+
aria-invalid={isInvalid || undefined}
|
|
649
|
+
aria-required={props.required || undefined}
|
|
622
650
|
/>
|
|
623
651
|
<ComboboxActionButtons>
|
|
624
652
|
<ComboboxClear aria-label="Clear all">
|
|
@@ -669,6 +697,7 @@ export function MultiCombobox<T extends DataWithId>(
|
|
|
669
697
|
inputType={inputType}
|
|
670
698
|
renderItem={renderItem}
|
|
671
699
|
renderGroupHeading={renderGroupHeading}
|
|
700
|
+
isGroupHeaderDisabled={isGroupHeaderDisabled}
|
|
672
701
|
renderCreatableItem={
|
|
673
702
|
creatableProp
|
|
674
703
|
? (sentinel, style, index, measureRef) =>
|
|
@@ -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: () => (
|