@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.
@@ -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
+ };
@@ -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 key={`__header-${row.groupName}`} value={row}>
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") return;
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
- if (
541
- reason === "none" ||
542
- (reason === "keyboard" && (isStart || isEnd))
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: isEnd ? "start" : "end",
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: isEnd ? "start" : "end",
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: () => (
@@ -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 aria-describedby={ariaDescribedBy}>
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: isEnd ? "start" : "end",
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: isEnd ? "start" : "end",
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 id={id} aria-describedby={ariaDescribedBy}>
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: () => (