@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.
Files changed (35) hide show
  1. package/.turbo/turbo-build.log +12 -12
  2. package/CHANGELOG.md +193 -0
  3. package/dist/esm/v3/index.js +726 -186
  4. package/dist/esm/v3/index.js.map +1 -1
  5. package/dist/index.d.mts +22 -22
  6. package/dist/index.d.ts +22 -22
  7. package/dist/v3/index.d.mts +199 -50
  8. package/dist/v3/index.d.ts +199 -50
  9. package/dist/v3/index.js +732 -185
  10. package/dist/v3/index.js.map +1 -1
  11. package/package.json +14 -14
  12. package/src/v3/ActionMenu.stories.tsx +260 -0
  13. package/src/v3/ActionMenu.tsx +345 -0
  14. package/src/v3/ActionMenuStyles.tsx +154 -0
  15. package/src/v3/Common/ComboboxStyles.tsx +41 -7
  16. package/src/v3/Common/SelectStyles.tsx +20 -6
  17. package/src/v3/Common/useSuppressEscapeClear.ts +20 -0
  18. package/src/v3/MenuButton.stories.tsx +118 -0
  19. package/src/v3/MenuButton.tsx +68 -0
  20. package/src/v3/MenuButtonStyles.tsx +38 -0
  21. package/src/v3/ModalStories.stories.tsx +159 -0
  22. package/src/v3/MultiCombobox.stories.tsx +157 -0
  23. package/src/v3/MultiCombobox.tsx +28 -8
  24. package/src/v3/MultiSearchableSelect.stories.tsx +42 -0
  25. package/src/v3/MultiSearchableSelect.tsx +11 -6
  26. package/src/v3/MultiSelect.stories.tsx +40 -0
  27. package/src/v3/MultiSelect.tsx +12 -3
  28. package/src/v3/SingleCombobox.stories.tsx +36 -0
  29. package/src/v3/SingleCombobox.tsx +17 -7
  30. package/src/v3/SingleSearchableSelect.stories.tsx +38 -0
  31. package/src/v3/SingleSearchableSelect.tsx +15 -7
  32. package/src/v3/SingleSelect.stories.tsx +36 -0
  33. package/src/v3/SingleSelect.tsx +12 -3
  34. package/src/v3/__tests__/MenuButton.test.tsx +80 -0
  35. package/src/v3/index.ts +2 -0
@@ -1,5 +1,6 @@
1
1
  import React from "react";
2
2
  import type { Meta, StoryObj } from "@storybook/react";
3
+ import { action } from "@storybook/addon-actions";
3
4
  import { FormField } from "@sproutsocial/seeds-react-form-field";
4
5
  import { Button } from "@sproutsocial/seeds-react-button";
5
6
  import { Input } from "@sproutsocial/seeds-react-input";
@@ -16,6 +17,13 @@ import { SingleCombobox } from "./SingleCombobox";
16
17
  import { MultiCombobox } from "./MultiCombobox";
17
18
  import { SingleSearchableSelect } from "./SingleSearchableSelect";
18
19
  import { MultiSearchableSelect } from "./MultiSearchableSelect";
20
+ import {
21
+ ActionMenu,
22
+ MenuGroup,
23
+ MenuItem,
24
+ MenuSub,
25
+ MenuSubTrigger,
26
+ } from "./ActionMenu";
19
27
  import { books, itemToString } from "./storyData";
20
28
 
21
29
  // ─── Shared content ───────────────────────────────────────────────────────────
@@ -179,3 +187,154 @@ export const V2Modal: Story = {
179
187
  </>
180
188
  ),
181
189
  };
190
+
191
+ // ─── Action Menu in Modal ─────────────────────────────────────────────────────
192
+
193
+ export const ActionMenuInV1Modal: Story = {
194
+ name: "Action Menu in V1 Modal",
195
+ render: () => {
196
+ const [isOpen, setIsOpen] = React.useState(false);
197
+ return (
198
+ <>
199
+ <Button appearance="primary" onClick={() => setIsOpen(true)}>
200
+ Open V1 Modal
201
+ </Button>
202
+ <ModalV1
203
+ appElementSelector="#root"
204
+ isOpen={isOpen}
205
+ onClose={() => setIsOpen(false)}
206
+ closeButtonLabel="Close"
207
+ label="Action Menu in V1 Modal"
208
+ >
209
+ <ModalV1.Header title="" subtitle="" bordered>
210
+ Actions
211
+ </ModalV1.Header>
212
+ <ModalV1.Content>
213
+ <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
214
+ <ActionMenu
215
+ trigger={<Button appearance="secondary">Basic Menu</Button>}
216
+ >
217
+ <MenuItem onClick={action("Compose")}>Compose</MenuItem>
218
+ <MenuItem onClick={action("View Messages")}>
219
+ View Messages
220
+ </MenuItem>
221
+ <MenuItem onClick={action("Analyze post")}>
222
+ Analyze post
223
+ </MenuItem>
224
+ </ActionMenu>
225
+
226
+ <ActionMenu
227
+ trigger={
228
+ <Button appearance="secondary">Menu with Groups</Button>
229
+ }
230
+ >
231
+ <MenuGroup label="Actions" showSeparator>
232
+ <MenuItem onClick={action("Edit")}>Edit</MenuItem>
233
+ <MenuItem onClick={action("Duplicate")}>Duplicate</MenuItem>
234
+ </MenuGroup>
235
+ <MenuGroup label="Danger">
236
+ <MenuItem onClick={action("Delete")} disabled>
237
+ Delete
238
+ </MenuItem>
239
+ </MenuGroup>
240
+ </ActionMenu>
241
+
242
+ <ActionMenu
243
+ trigger={
244
+ <Button appearance="secondary">Menu with Submenu</Button>
245
+ }
246
+ >
247
+ <MenuItem onClick={action("Compose")}>Compose</MenuItem>
248
+ <MenuSub trigger={<MenuSubTrigger>Share</MenuSubTrigger>}>
249
+ <MenuItem onClick={action("Share to Twitter")}>
250
+ Twitter
251
+ </MenuItem>
252
+ <MenuItem onClick={action("Share to LinkedIn")}>
253
+ LinkedIn
254
+ </MenuItem>
255
+ <MenuSub
256
+ trigger={<MenuSubTrigger>More platforms</MenuSubTrigger>}
257
+ >
258
+ <MenuItem onClick={action("Share to Facebook")}>
259
+ Facebook
260
+ </MenuItem>
261
+ <MenuItem onClick={action("Share to Instagram")}>
262
+ Instagram
263
+ </MenuItem>
264
+ </MenuSub>
265
+ </MenuSub>
266
+ <MenuItem onClick={action("Delete")} disabled>
267
+ Delete
268
+ </MenuItem>
269
+ </ActionMenu>
270
+ </div>
271
+ </ModalV1.Content>
272
+ <ModalV1.Footer>Footer</ModalV1.Footer>
273
+ </ModalV1>
274
+ </>
275
+ );
276
+ },
277
+ };
278
+
279
+ export const ActionMenuInModal: Story = {
280
+ name: "Action Menu in V2 Modal",
281
+ render: () => (
282
+ <ModalV2
283
+ title="Actions"
284
+ modalTrigger={<Button appearance="primary">Open Modal</Button>}
285
+ closeButtonAriaLabel="Close"
286
+ >
287
+ <ModalBody>
288
+ <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
289
+ <ActionMenu
290
+ trigger={<Button appearance="secondary">Basic Menu</Button>}
291
+ >
292
+ <MenuItem onClick={action("Compose")}>Compose</MenuItem>
293
+ <MenuItem onClick={action("View Messages")}>View Messages</MenuItem>
294
+ <MenuItem onClick={action("Analyze post")}>Analyze post</MenuItem>
295
+ </ActionMenu>
296
+
297
+ <ActionMenu
298
+ trigger={<Button appearance="secondary">Menu with Groups</Button>}
299
+ >
300
+ <MenuGroup label="Actions" showSeparator>
301
+ <MenuItem onClick={action("Edit")}>Edit</MenuItem>
302
+ <MenuItem onClick={action("Duplicate")}>Duplicate</MenuItem>
303
+ </MenuGroup>
304
+ <MenuGroup label="Danger">
305
+ <MenuItem onClick={action("Delete")} disabled>
306
+ Delete
307
+ </MenuItem>
308
+ </MenuGroup>
309
+ </ActionMenu>
310
+
311
+ <ActionMenu
312
+ trigger={<Button appearance="secondary">Menu with Submenu</Button>}
313
+ >
314
+ <MenuItem onClick={action("Compose")}>Compose</MenuItem>
315
+ <MenuSub trigger={<MenuSubTrigger>Share</MenuSubTrigger>}>
316
+ <MenuItem onClick={action("Share to Twitter")}>Twitter</MenuItem>
317
+ <MenuItem onClick={action("Share to LinkedIn")}>
318
+ LinkedIn
319
+ </MenuItem>
320
+ <MenuSub
321
+ trigger={<MenuSubTrigger>More platforms</MenuSubTrigger>}
322
+ >
323
+ <MenuItem onClick={action("Share to Facebook")}>
324
+ Facebook
325
+ </MenuItem>
326
+ <MenuItem onClick={action("Share to Instagram")}>
327
+ Instagram
328
+ </MenuItem>
329
+ </MenuSub>
330
+ </MenuSub>
331
+ <MenuItem onClick={action("Delete")} disabled>
332
+ Delete
333
+ </MenuItem>
334
+ </ActionMenu>
335
+ </div>
336
+ </ModalBody>
337
+ <ModalFooter cancelButton={<Button>Close</Button>} />
338
+ </ModalV2>
339
+ ),
340
+ };
@@ -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,
@@ -301,6 +307,28 @@ export const MaxTokens: Story = {
301
307
  ),
302
308
  };
303
309
 
310
+ export const LargeBookSetWithMaxTokens: Story = {
311
+ name: "Large book set with max tokens",
312
+ render: () => (
313
+ <div style={{ width: "300px" }}>
314
+ <FormField label="Pick books">
315
+ {({ id }) => (
316
+ <MultiCombobox
317
+ id={id}
318
+ data={largeBookSet}
319
+ itemToString={itemToString}
320
+ renderItem={renderItem}
321
+ placeholder="Search books..."
322
+ isVirtualized
323
+ selectAll
324
+ maxTokens={5}
325
+ />
326
+ )}
327
+ </FormField>
328
+ </div>
329
+ ),
330
+ };
331
+
304
332
  export const Loading: Story = {
305
333
  name: "Loading",
306
334
  render: () => (
@@ -396,6 +424,23 @@ export const VirtualizedSelectAll: Story = {
396
424
  ),
397
425
  };
398
426
 
427
+ export const Inline: Story = {
428
+ name: "Inline (fullWidth=false)",
429
+ render: () => (
430
+ <FormField label="Pick books">
431
+ {({ id }) => (
432
+ <MultiCombobox
433
+ id={id}
434
+ data={books}
435
+ itemToString={itemToString}
436
+ placeholder="Search books..."
437
+ fullWidth={false}
438
+ />
439
+ )}
440
+ </FormField>
441
+ ),
442
+ };
443
+
399
444
  export const CustomSearch: Story = {
400
445
  name: "Custom search (title + author)",
401
446
  render: () => (
@@ -455,3 +500,115 @@ export const Creatable: Story = {
455
500
  );
456
501
  },
457
502
  };
503
+
504
+ // Story to help visualize what the combobox component looks like when it's invalid within a FormField component
505
+ export const Invalid: Story = {
506
+ name: "Invalid",
507
+ render: () => (
508
+ <div style={{ width: "300px" }}>
509
+ <FormField
510
+ label="Pick books"
511
+ isInvalid
512
+ error="Please pick at least one book."
513
+ >
514
+ {({ id, isInvalid }) => (
515
+ <MultiCombobox
516
+ id={id}
517
+ isInvalid={isInvalid}
518
+ data={books}
519
+ itemToString={itemToString}
520
+ placeholder="Search books..."
521
+ />
522
+ )}
523
+ </FormField>
524
+ </div>
525
+ ),
526
+ };
527
+
528
+ // Story to help visualize what the combobox component looks like when it's required within a FormField component
529
+ export const Required: Story = {
530
+ name: "Required",
531
+ render: () => (
532
+ <div style={{ width: "300px" }}>
533
+ <FormField label="Pick books" required>
534
+ {({ id, required }) => (
535
+ <MultiCombobox
536
+ id={id}
537
+ required={required}
538
+ data={books}
539
+ itemToString={itemToString}
540
+ placeholder="Search books..."
541
+ />
542
+ )}
543
+ </FormField>
544
+ </div>
545
+ ),
546
+ };
547
+
548
+ export const LargeBookSetWithMaxTokensInModal: Story = {
549
+ name: "Large book set with max tokens (in modal)",
550
+ render: () => (
551
+ <ModalV2
552
+ title="Pick some books"
553
+ modalTrigger={<Button appearance="primary">Open Modal</Button>}
554
+ closeButtonAriaLabel="Close"
555
+ >
556
+ <ModalBody>
557
+ <div style={{ width: "300px" }}>
558
+ <FormField label="Pick books">
559
+ {({ id }) => (
560
+ <MultiCombobox
561
+ id={id}
562
+ data={largeBookSet}
563
+ itemToString={itemToString}
564
+ renderItem={renderItem}
565
+ placeholder="Search books..."
566
+ isVirtualized
567
+ selectAll
568
+ maxTokens={5}
569
+ />
570
+ )}
571
+ </FormField>
572
+ </div>
573
+ </ModalBody>
574
+ <ModalFooter cancelButton={<Button>Cancel</Button>} />
575
+ </ModalV2>
576
+ ),
577
+ };
578
+
579
+ // Verifies the a11y fix for Escape key containment. Open the modal, open the
580
+ // combobox dropdown, then press Escape: the dropdown should close and focus
581
+ // should return to the input — the modal should stay open. Pressing Escape
582
+ // again (with the dropdown closed) should close the modal.
583
+ export const EscapeKeyInModal: Story = {
584
+ name: "Escape Key in Modal (a11y)",
585
+ render: () => (
586
+ <ModalV2
587
+ title="Pick some books"
588
+ modalTrigger={<Button appearance="primary">Open Modal</Button>}
589
+ closeButtonAriaLabel="Close"
590
+ >
591
+ <ModalBody>
592
+ <div style={{ width: "300px", padding: "16px 0" }}>
593
+ <FormField label="Pick books">
594
+ {({ id }) => (
595
+ <MultiCombobox
596
+ id={id}
597
+ data={books}
598
+ itemToString={itemToString}
599
+ placeholder="Search books..."
600
+ />
601
+ )}
602
+ </FormField>
603
+ <p style={{ marginTop: 16, fontSize: 14 }}>
604
+ <strong>To verify:</strong> open the combobox, press{" "}
605
+ <kbd>Escape</kbd>. The dropdown should close and focus should return
606
+ to the input. The modal should remain open. Press <kbd>Escape</kbd>{" "}
607
+ again to close the modal.
608
+ </p>
609
+ </div>
610
+ </ModalBody>
611
+ <ModalFooter cancelButton={<Button>Cancel</Button>} />
612
+ </ModalV2>
613
+ ),
614
+ };
@@ -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,9 @@ interface MultiComboboxBaseProps {
74
75
  inputValue?: string;
75
76
  onInputValueChange?: (value: string) => void;
76
77
  "aria-describedby"?: string;
78
+ isInvalid?: boolean;
79
+ required?: boolean;
80
+ fullWidth?: boolean;
77
81
  }
78
82
 
79
83
  interface MultiComboboxDataProps<T extends DataWithId>
@@ -141,8 +145,10 @@ export function MultiCombobox<T extends DataWithId>(
141
145
  filter,
142
146
  inputValue,
143
147
  onInputValueChange,
148
+ fullWidth = true,
144
149
  } = props;
145
150
  const ariaDescribedBy = props["aria-describedby"];
151
+ const isInvalid = props.isInvalid;
146
152
 
147
153
  const isChildrenMode = "children" in props && props.children != null;
148
154
 
@@ -211,6 +217,9 @@ export function MultiCombobox<T extends DataWithId>(
211
217
  const virtualizerRef = React.useRef<ReturnType<
212
218
  typeof useVirtualizer<HTMLDivElement, Element>
213
219
  > | null>(null);
220
+ const inputRef = React.useRef<HTMLInputElement>(null);
221
+
222
+ const onInputKeyDownCapture = useSuppressEscapeClear(open);
214
223
 
215
224
  const isControlled = selectedItemIds !== undefined;
216
225
 
@@ -366,6 +375,7 @@ export function MultiCombobox<T extends DataWithId>(
366
375
  e.preventDefault();
367
376
  e.stopPropagation();
368
377
  setSelectedItems(value.filter((v) => v.id !== item.id));
378
+ inputRef.current?.focus();
369
379
  }
370
380
 
371
381
  // ─── Render sentinel item ─────────────────────────────────────────────────
@@ -481,7 +491,7 @@ export function MultiCombobox<T extends DataWithId>(
481
491
  : (value as unknown as T[]);
482
492
 
483
493
  return (
484
- <Field>
494
+ <Field $fullWidth={fullWidth}>
485
495
  <div ref={containerRef} style={{ position: "relative" }} />
486
496
  <Combobox.Root
487
497
  multiple
@@ -490,7 +500,10 @@ export function MultiCombobox<T extends DataWithId>(
490
500
  open={open}
491
501
  disabled={props.disabled}
492
502
  onOpenChange={(nextOpen, eventDetails) => {
493
- if (!nextOpen && eventDetails.reason === "item-press") return;
503
+ if (!nextOpen && eventDetails.reason === "item-press") {
504
+ eventDetails.cancel();
505
+ return;
506
+ }
494
507
  setOpen(nextOpen);
495
508
  }}
496
509
  filter={
@@ -535,13 +548,14 @@ export function MultiCombobox<T extends DataWithId>(
535
548
  if (!item || !virt) return;
536
549
  const isStart = index === 0;
537
550
  const isEnd = index === virt.options.count - 1;
538
- if (
539
- reason === "none" ||
540
- (reason === "keyboard" && (isStart || isEnd))
541
- ) {
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)) {
542
556
  queueMicrotask(() => {
543
557
  virt.scrollToIndex(index, {
544
- align: isEnd ? "start" : "end",
558
+ align: isStart ? "start" : "end",
545
559
  });
546
560
  });
547
561
  }
@@ -584,7 +598,10 @@ export function MultiCombobox<T extends DataWithId>(
584
598
  }}
585
599
  items={rootItems}
586
600
  >
587
- <ComboboxTokenInputGroup>
601
+ <ComboboxTokenInputGroup
602
+ $isInvalid={isInvalid}
603
+ onKeyDownCapture={onInputKeyDownCapture}
604
+ >
588
605
  <ComboboxToken>
589
606
  {renderSelection
590
607
  ? renderSelection(value)
@@ -614,9 +631,12 @@ export function MultiCombobox<T extends DataWithId>(
614
631
  );
615
632
  })()}
616
633
  <ComboboxTokenInput
634
+ ref={inputRef}
617
635
  id={id}
618
636
  placeholder={value.length > 0 ? "" : placeholder}
619
637
  aria-describedby={ariaDescribedBy}
638
+ aria-invalid={isInvalid || undefined}
639
+ aria-required={props.required || undefined}
620
640
  />
621
641
  <ComboboxActionButtons>
622
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: () => (
@@ -413,6 +437,24 @@ export const LoadingWithToggle: Story = {
413
437
  },
414
438
  };
415
439
 
440
+ export const Inline: Story = {
441
+ name: "Inline (fullWidth=false)",
442
+ render: () => (
443
+ <FormField label="Pick books">
444
+ {({ id }) => (
445
+ <MultiSearchableSelect
446
+ id={id}
447
+ data={books}
448
+ itemToString={itemToString}
449
+ placeholder="Select books"
450
+ searchPlaceholder="Search books..."
451
+ fullWidth={false}
452
+ />
453
+ )}
454
+ </FormField>
455
+ ),
456
+ };
457
+
416
458
  export const CustomSearch: Story = {
417
459
  name: "Custom search (title + author)",
418
460
  render: () => (
@@ -80,6 +80,9 @@ 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;
85
+ fullWidth?: boolean;
83
86
  }
84
87
 
85
88
  interface MultiSearchableSelectDataProps<T extends DataWithId>
@@ -145,8 +148,10 @@ export function MultiSearchableSelect<T extends DataWithId>(
145
148
  isLoading = false,
146
149
  loadingText = "Loading...",
147
150
  filter,
151
+ fullWidth = true,
148
152
  } = props;
149
153
  const ariaDescribedBy = props["aria-describedby"];
154
+ const isInvalid = props.isInvalid;
150
155
 
151
156
  const isChildrenMode = "children" in props && props.children != null;
152
157
 
@@ -461,7 +466,7 @@ export function MultiSearchableSelect<T extends DataWithId>(
461
466
  : (value as unknown as T[]);
462
467
 
463
468
  return (
464
- <Field>
469
+ <Field $fullWidth={fullWidth}>
465
470
  <div ref={containerRef} style={{ position: "relative" }} />
466
471
  <Combobox.Root
467
472
  multiple
@@ -500,13 +505,10 @@ export function MultiSearchableSelect<T extends DataWithId>(
500
505
  if (!item || !virt) return;
501
506
  const isStart = index === 0;
502
507
  const isEnd = index === virt.options.count - 1;
503
- if (
504
- reason === "none" ||
505
- (reason === "keyboard" && (isStart || isEnd))
506
- ) {
508
+ if (reason === "keyboard" && (isStart || isEnd)) {
507
509
  queueMicrotask(() => {
508
510
  virt.scrollToIndex(index, {
509
- align: isEnd ? "start" : "end",
511
+ align: isStart ? "start" : "end",
510
512
  });
511
513
  });
512
514
  }
@@ -552,6 +554,9 @@ export function MultiSearchableSelect<T extends DataWithId>(
552
554
  <SearchableSelectTokenTrigger
553
555
  id={id}
554
556
  aria-describedby={ariaDescribedBy}
557
+ aria-invalid={isInvalid || undefined}
558
+ aria-required={props.required || undefined}
559
+ $isInvalid={isInvalid}
555
560
  >
556
561
  {renderSelection ? (
557
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: () => (
@@ -266,6 +289,23 @@ export const RemoveSelectedItems: Story = {
266
289
  ),
267
290
  };
268
291
 
292
+ export const Inline: Story = {
293
+ name: "Inline (fullWidth=false)",
294
+ render: () => (
295
+ <FormField label="Pick books">
296
+ {({ id }) => (
297
+ <MultiSelect
298
+ id={id}
299
+ data={books}
300
+ itemToString={itemToString}
301
+ placeholder="Select books..."
302
+ fullWidth={false}
303
+ />
304
+ )}
305
+ </FormField>
306
+ ),
307
+ };
308
+
269
309
  export const Children: Story = {
270
310
  name: "Children API",
271
311
  render: () => (
@@ -44,6 +44,9 @@ interface MultiSelectBaseProps {
44
44
  placeholder?: string;
45
45
  disabled?: boolean;
46
46
  "aria-describedby"?: string;
47
+ isInvalid?: boolean;
48
+ required?: boolean;
49
+ fullWidth?: boolean;
47
50
  }
48
51
 
49
52
  interface MultiSelectDataProps<T extends DataWithId>
@@ -88,8 +91,9 @@ export type MultiSelectProps<T extends DataWithId> =
88
91
  // ─── Component ────────────────────────────────────────────────────────────────
89
92
 
90
93
  export function MultiSelect<T extends DataWithId>(props: MultiSelectProps<T>) {
91
- const { id, placeholder = "Select..." } = props;
94
+ const { id, placeholder = "Select...", fullWidth = true } = props;
92
95
  const ariaDescribedBy = props["aria-describedby"];
96
+ const isInvalid = props.isInvalid;
93
97
 
94
98
  const isChildrenMode = "children" in props && props.children != null;
95
99
 
@@ -171,7 +175,7 @@ export function MultiSelect<T extends DataWithId>(props: MultiSelectProps<T>) {
171
175
  }
172
176
 
173
177
  return (
174
- <Field>
178
+ <Field $fullWidth={fullWidth}>
175
179
  <div ref={containerRef} style={{ position: "relative" }} />
176
180
  <Select.Root
177
181
  multiple
@@ -181,7 +185,12 @@ export function MultiSelect<T extends DataWithId>(props: MultiSelectProps<T>) {
181
185
  disabled={props.disabled}
182
186
  id={id}
183
187
  >
184
- <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
+ >
185
194
  <SelectValue>
186
195
  {() => {
187
196
  if (renderSelection) return renderSelection(selectedItems);