@sproutsocial/seeds-react-menu 1.12.1 → 1.12.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutsocial/seeds-react-menu",
3
- "version": "1.12.1",
3
+ "version": "1.12.3",
4
4
  "description": "Seeds React Menu",
5
5
  "author": "Sprout Social, Inc.",
6
6
  "license": "MIT",
@@ -37,17 +37,17 @@
37
37
  "dependencies": {
38
38
  "@radix-ui/react-popover": "^1.1.2",
39
39
  "@sproutsocial/seeds-react-box": "^1.1.16",
40
- "@sproutsocial/seeds-react-button": "^2.0.4",
41
- "@sproutsocial/seeds-react-checkbox": "^1.3.32",
42
- "@sproutsocial/seeds-react-icon": "^2.3.6",
43
- "@sproutsocial/seeds-react-input": "^1.5.15",
40
+ "@sproutsocial/seeds-react-button": "^2.0.5",
41
+ "@sproutsocial/seeds-react-checkbox": "^1.3.33",
42
+ "@sproutsocial/seeds-react-icon": "^2.3.7",
43
+ "@sproutsocial/seeds-react-input": "^1.5.16",
44
44
  "@sproutsocial/seeds-react-label": "^1.0.16",
45
- "@sproutsocial/seeds-react-popout": "^2.4.37",
45
+ "@sproutsocial/seeds-react-popout": "^2.4.38",
46
46
  "@sproutsocial/seeds-react-radio": "^1.3.16",
47
- "@sproutsocial/seeds-react-switch": "^1.2.31",
47
+ "@sproutsocial/seeds-react-switch": "^1.2.32",
48
48
  "@sproutsocial/seeds-react-system-props": "^3.0.2",
49
49
  "@sproutsocial/seeds-react-theme": "^3.6.2",
50
- "@sproutsocial/seeds-react-token-input": "^1.4.37",
50
+ "@sproutsocial/seeds-react-token-input": "^1.4.38",
51
51
  "@sproutsocial/seeds-react-utilities": "^4.3.0",
52
52
  "@base-ui/react": "^1.3.0",
53
53
  "@tanstack/react-virtual": "^3.13.12",
@@ -57,7 +57,7 @@
57
57
  "styled-system": "^5.1.5"
58
58
  },
59
59
  "devDependencies": {
60
- "@sproutsocial/seeds-react-tooltip": "^1.1.19",
60
+ "@sproutsocial/seeds-react-tooltip": "^1.1.20",
61
61
  "@sproutsocial/eslint-config-seeds": "*",
62
62
  "@sproutsocial/seeds-react-testing-library": "*",
63
63
  "@sproutsocial/seeds-testing": "*",
@@ -8,7 +8,11 @@ import {
8
8
  ComboboxSelectAllItem,
9
9
  } from "./ComboboxStyles";
10
10
  import type { DataWithId } from "./types";
11
- import { isPlaceholderSentinel } from "./sentinels";
11
+ import {
12
+ isPlaceholderSentinel,
13
+ isCreatableSentinel,
14
+ type CreatableSentinel,
15
+ } from "./sentinels";
12
16
 
13
17
  export const ITEM_HEIGHT = 32;
14
18
 
@@ -29,7 +33,11 @@ export function isSelectAllSentinel(v: unknown): v is SelectAllSentinel {
29
33
  return typeof v === "object" && v !== null && "__selectAll" in v;
30
34
  }
31
35
 
32
- export type FlatSentinelRow<T> = T | GroupHeaderSentinel | SelectAllSentinel;
36
+ export type FlatSentinelRow<T> =
37
+ | T
38
+ | GroupHeaderSentinel
39
+ | SelectAllSentinel
40
+ | CreatableSentinel;
33
41
 
34
42
  interface VirtualizedComboboxListProps<T extends DataWithId> {
35
43
  open: boolean;
@@ -47,6 +55,12 @@ interface VirtualizedComboboxListProps<T extends DataWithId> {
47
55
  inputType: "icon" | "radio" | "checkbox" | "none";
48
56
  renderItem?: (item: T) => React.ReactNode;
49
57
  renderGroupHeading?: (label: string) => React.ReactNode;
58
+ renderCreatableItem?: (
59
+ sentinel: CreatableSentinel,
60
+ style: React.CSSProperties,
61
+ index: number,
62
+ measureRef: (el: Element | null) => void
63
+ ) => React.ReactNode;
50
64
  }
51
65
 
52
66
  export default function VirtualizedComboboxList<T extends DataWithId>({
@@ -59,6 +73,7 @@ export default function VirtualizedComboboxList<T extends DataWithId>({
59
73
  inputType,
60
74
  renderItem,
61
75
  renderGroupHeading,
76
+ renderCreatableItem,
62
77
  }: VirtualizedComboboxListProps<T>) {
63
78
  type Row = FlatSentinelRow<T>;
64
79
 
@@ -197,6 +212,18 @@ export default function VirtualizedComboboxList<T extends DataWithId>({
197
212
  );
198
213
  }
199
214
 
215
+ if (isCreatableSentinel(row)) {
216
+ if (renderCreatableItem) {
217
+ return renderCreatableItem(
218
+ row,
219
+ baseStyle,
220
+ virtualItem.index,
221
+ virtualizer.measureElement
222
+ );
223
+ }
224
+ return null;
225
+ }
226
+
200
227
  const item = row as T;
201
228
  return (
202
229
  <ComboboxItem
@@ -15,3 +15,16 @@ export function makePlaceholderSentinel(label: string): PlaceholderSentinel {
15
15
  export function isPlaceholderSentinel(v: unknown): v is PlaceholderSentinel {
16
16
  return typeof v === "object" && v !== null && "__placeholder" in v;
17
17
  }
18
+
19
+ export interface CreatableSentinel {
20
+ __creatable: true;
21
+ label: string;
22
+ }
23
+
24
+ export function makeCreatableSentinel(label: string): CreatableSentinel {
25
+ return { __creatable: true, label };
26
+ }
27
+
28
+ export function isCreatableSentinel(v: unknown): v is CreatableSentinel {
29
+ return typeof v === "object" && v !== null && "__creatable" in v;
30
+ }
@@ -8,6 +8,7 @@ import {
8
8
  largeBookSet,
9
9
  itemToString,
10
10
  renderItem,
11
+ type Book,
11
12
  } from "./storyData";
12
13
 
13
14
  const meta: Meta<typeof MultiCombobox> = {
@@ -421,3 +422,36 @@ export const CustomSearch: Story = {
421
422
  </div>
422
423
  ),
423
424
  };
425
+
426
+ export const Creatable: Story = {
427
+ name: "Creatable",
428
+ render: () => {
429
+ const [bookList, setBookList] = React.useState<Book[]>(books);
430
+
431
+ return (
432
+ <div style={{ width: "300px" }}>
433
+ <FormField label="Pick or create books">
434
+ {({ id }) => (
435
+ <MultiCombobox
436
+ id={id}
437
+ data={bookList}
438
+ itemToString={itemToString}
439
+ placeholder="Search or create..."
440
+ isVirtualized
441
+ creatable
442
+ onCreate={(value) => {
443
+ const newBook: Book = {
444
+ id: `book-custom-${value.toLowerCase().replace(/\s+/g, "-")}`,
445
+ title: value,
446
+ author: "Unknown",
447
+ };
448
+ setBookList((prev) => [...prev, newBook]);
449
+ return newBook;
450
+ }}
451
+ />
452
+ )}
453
+ </FormField>
454
+ </div>
455
+ );
456
+ },
457
+ };
@@ -6,6 +6,11 @@ import { useSeedsPortalContainer } from "./SeedsPortal";
6
6
  import ComboboxItem from "./Common/ComboboxItem";
7
7
  import type { DataWithId } from "./Common/types";
8
8
  import { groupItems } from "./Common/groupItems";
9
+ import {
10
+ isCreatableSentinel,
11
+ makeCreatableSentinel,
12
+ type CreatableSentinel,
13
+ } from "./Common/sentinels";
9
14
  import { Field } from "./Common/SelectStyles";
10
15
  import {
11
16
  ComboboxTokenInputGroup,
@@ -68,6 +73,7 @@ interface MultiComboboxBaseProps {
68
73
  filter?: ((item: unknown, query: string) => boolean) | null;
69
74
  inputValue?: string;
70
75
  onInputValueChange?: (value: string) => void;
76
+ "aria-describedby"?: string;
71
77
  }
72
78
 
73
79
  interface MultiComboboxDataProps<T extends DataWithId>
@@ -88,6 +94,8 @@ interface MultiComboboxDataProps<T extends DataWithId>
88
94
  isVirtualized?: boolean;
89
95
  removeSelectedItems?: boolean;
90
96
  maxTokens?: number;
97
+ creatable?: boolean;
98
+ onCreate?: (value: string) => T;
91
99
  children?: never;
92
100
  }
93
101
 
@@ -107,6 +115,8 @@ interface MultiComboboxChildrenProps extends MultiComboboxBaseProps {
107
115
  selectHeadings?: never;
108
116
  selectAll?: never;
109
117
  removeSelectedItems?: never;
118
+ creatable?: never;
119
+ onCreate?: never;
110
120
  }
111
121
 
112
122
  export type MultiComboboxProps<T extends DataWithId> =
@@ -132,6 +142,7 @@ export function MultiCombobox<T extends DataWithId>(
132
142
  inputValue,
133
143
  onInputValueChange,
134
144
  } = props;
145
+ const ariaDescribedBy = props["aria-describedby"];
135
146
 
136
147
  const isChildrenMode = "children" in props && props.children != null;
137
148
 
@@ -179,6 +190,12 @@ export function MultiCombobox<T extends DataWithId>(
179
190
  const isVirtualized = isChildrenMode
180
191
  ? false
181
192
  : (props as MultiComboboxDataProps<T>).isVirtualized ?? false;
193
+ const creatableProp = isChildrenMode
194
+ ? false
195
+ : (props as MultiComboboxDataProps<T>).creatable ?? false;
196
+ const onCreateProp = isChildrenMode
197
+ ? undefined
198
+ : (props as MultiComboboxDataProps<T>).onCreate;
182
199
  const children = isChildrenMode
183
200
  ? (props as MultiComboboxChildrenProps).children
184
201
  : undefined;
@@ -189,6 +206,8 @@ export function MultiCombobox<T extends DataWithId>(
189
206
 
190
207
  const { containerRef, portalContainer } = useSeedsPortalContainer();
191
208
  const [open, setOpen] = React.useState(false);
209
+ const [internalInputValue, setInternalInputValue] = React.useState("");
210
+ const skipNextInputChangeRef = React.useRef(false);
192
211
  const virtualizerRef = React.useRef<ReturnType<
193
212
  typeof useVirtualizer<HTMLDivElement, Element>
194
213
  > | null>(null);
@@ -226,11 +245,29 @@ export function MultiCombobox<T extends DataWithId>(
226
245
  [visibleData, groupBy]
227
246
  );
228
247
 
248
+ const currentInputValue = inputValue ?? internalInputValue;
249
+
250
+ const creatableSentinel = React.useMemo<CreatableSentinel | null>(() => {
251
+ if (!creatableProp) return null;
252
+ const trimmed = currentInputValue.trim();
253
+ if (!trimmed) return null;
254
+ const lowered = trimmed.toLocaleLowerCase();
255
+ const exactExists = data.some(
256
+ (d) => itemToString(d).trim().toLocaleLowerCase() === lowered
257
+ );
258
+ if (exactExists) return null;
259
+ return makeCreatableSentinel(trimmed);
260
+ }, [creatableProp, currentInputValue, data, itemToString]);
261
+
229
262
  // ─── Sentinel flat list ───────────────────────────────────────────────────
230
263
  // Used when selectHeadings or selectAll. selectAll implies selectHeadings —
231
264
  // group headers are always selectable when the list is flattened.
232
265
 
233
- type FlatRow = T | GroupHeaderSentinel | SelectAllSentinel;
266
+ type FlatRow =
267
+ | T
268
+ | GroupHeaderSentinel
269
+ | SelectAllSentinel
270
+ | CreatableSentinel;
234
271
 
235
272
  const flatItems = React.useMemo<FlatRow[] | null>(() => {
236
273
  if (!selectHeadings && !selectAll) return null;
@@ -255,6 +292,22 @@ export function MultiCombobox<T extends DataWithId>(
255
292
  }
256
293
 
257
294
  function handleValueChange(next: FlatRow[]) {
295
+ const creatableClicked = next.find(isCreatableSentinel);
296
+ if (creatableClicked) {
297
+ const newItem = onCreateProp?.(creatableClicked.label);
298
+ const realItems = next.filter(
299
+ (v): v is T =>
300
+ !isGroupHeaderSentinel(v) &&
301
+ !isSelectAllSentinel(v) &&
302
+ !isCreatableSentinel(v)
303
+ );
304
+ setSelectedItems(newItem ? [...realItems, newItem] : realItems);
305
+ skipNextInputChangeRef.current = true;
306
+ setInternalInputValue("");
307
+ onInputValueChange?.("");
308
+ return;
309
+ }
310
+
258
311
  const selectAllClicked = next.filter(isSelectAllSentinel);
259
312
  const groupSentinels = next.filter(isGroupHeaderSentinel);
260
313
  const realItems = next.filter(
@@ -295,8 +348,18 @@ export function MultiCombobox<T extends DataWithId>(
295
348
  setSelectedItems(updated);
296
349
  }
297
350
 
298
- function handleSimpleValueChange(newItems: T[]) {
299
- setSelectedItems(newItems);
351
+ function handleSimpleValueChange(newItems: FlatRow[]) {
352
+ const creatableClicked = newItems.find(isCreatableSentinel);
353
+ if (creatableClicked) {
354
+ const newItem = onCreateProp?.(creatableClicked.label);
355
+ const realItems = newItems.filter((v): v is T => !isCreatableSentinel(v));
356
+ setSelectedItems(newItem ? [...realItems, newItem] : realItems);
357
+ skipNextInputChangeRef.current = true;
358
+ setInternalInputValue("");
359
+ onInputValueChange?.("");
360
+ return;
361
+ }
362
+ setSelectedItems(newItems as T[]);
300
363
  }
301
364
 
302
365
  function removeItem(item: T, e: React.MouseEvent) {
@@ -350,6 +413,10 @@ export function MultiCombobox<T extends DataWithId>(
350
413
  );
351
414
  }
352
415
 
416
+ if (isCreatableSentinel(row)) {
417
+ return renderCreatableItem(row);
418
+ }
419
+
353
420
  const item = row as T;
354
421
  return (
355
422
  <ComboboxItem
@@ -366,6 +433,33 @@ export function MultiCombobox<T extends DataWithId>(
366
433
  );
367
434
  }
368
435
 
436
+ function renderCreatableItem(
437
+ sentinel: CreatableSentinel,
438
+ style?: React.CSSProperties,
439
+ index?: number,
440
+ measureRef?: (el: Element | null) => void
441
+ ) {
442
+ return (
443
+ <ComboboxItem
444
+ key="__creatable"
445
+ value={sentinel}
446
+ itemId="__creatable"
447
+ label={`Create "${sentinel.label}"`}
448
+ inputType="none"
449
+ renderItem={() => (
450
+ <>
451
+ <Icon name="plus-outline" size="mini" />
452
+ {`Create "${sentinel.label}"`}
453
+ </>
454
+ )}
455
+ style={style}
456
+ index={index}
457
+ data-index={index}
458
+ ref={measureRef}
459
+ />
460
+ );
461
+ }
462
+
369
463
  // ─── Items passed to Combobox.Root ────────────────────────────────────────
370
464
  // When selectHeadings: flatItems (flat array of group header sentinels + items).
371
465
  // When selectAll only: prepend SELECT_ALL_SENTINEL to data so Base UI tracks
@@ -373,8 +467,12 @@ export function MultiCombobox<T extends DataWithId>(
373
467
  // The sentinel is never in flatItems — it is always rendered manually above
374
468
  // Combobox.List, so we must not prepend it when flatItems is used.
375
469
 
376
- const rootItems =
470
+ const baseItems =
377
471
  flatItems ?? groups ?? (isChildrenMode ? undefined : visibleData);
472
+ const rootItems =
473
+ creatableSentinel && Array.isArray(baseItems)
474
+ ? ([...baseItems, creatableSentinel] as FlatRow[])
475
+ : baseItems;
378
476
 
379
477
  const hasSentinels = selectAll || selectHeadings;
380
478
 
@@ -395,9 +493,41 @@ export function MultiCombobox<T extends DataWithId>(
395
493
  if (!nextOpen && eventDetails.reason === "item-press") return;
396
494
  setOpen(nextOpen);
397
495
  }}
398
- filter={filter}
399
- inputValue={inputValue}
400
- onInputValueChange={onInputValueChange}
496
+ filter={
497
+ creatableProp
498
+ ? (item: unknown, query: string) => {
499
+ if (isCreatableSentinel(item)) return true;
500
+ if (filter) return filter(item, query);
501
+ // Default Base UI filter: match against itemToStringLabel
502
+ if (isGroupHeaderSentinel(item) || isSelectAllSentinel(item))
503
+ return true;
504
+ const label = itemToString(item as T);
505
+ return label.toLowerCase().includes(query.toLowerCase());
506
+ }
507
+ : filter
508
+ }
509
+ inputValue={
510
+ creatableProp ? currentInputValue : inputValue ?? internalInputValue
511
+ }
512
+ onInputValueChange={(v, eventDetails) => {
513
+ // Preserve search query when an item is selected. Base UI fires
514
+ // 'input-clear' on item-press; ignore it while the popup stays open.
515
+ if (eventDetails.reason === "input-clear" && open) return;
516
+
517
+ if (creatableProp) {
518
+ if (skipNextInputChangeRef.current) {
519
+ skipNextInputChangeRef.current = false;
520
+ return;
521
+ }
522
+ setInternalInputValue(v);
523
+ onInputValueChange?.(v);
524
+ } else {
525
+ if (inputValue === undefined) {
526
+ setInternalInputValue(v);
527
+ }
528
+ onInputValueChange?.(v);
529
+ }
530
+ }}
401
531
  onItemHighlighted={
402
532
  isVirtualized
403
533
  ? (item, { reason, index }) => {
@@ -423,20 +553,31 @@ export function MultiCombobox<T extends DataWithId>(
423
553
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
424
554
  (hasSentinels ? handleValueChange : handleSimpleValueChange) as any
425
555
  }
426
- itemToStringLabel={(item: FlatRow | null) => {
427
- if (!item || isGroupHeaderSentinel(item) || isSelectAllSentinel(item))
556
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
557
+ itemToStringLabel={(item: any) => {
558
+ if (
559
+ !item ||
560
+ isGroupHeaderSentinel(item) ||
561
+ isSelectAllSentinel(item) ||
562
+ isCreatableSentinel(item)
563
+ )
428
564
  return "";
429
565
  return itemToString(item as T);
430
566
  }}
431
- isItemEqualToValue={(a: FlatRow, b: FlatRow) => {
567
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
568
+ isItemEqualToValue={(a: any, b: any) => {
569
+ if (isCreatableSentinel(a) && isCreatableSentinel(b))
570
+ return a.label === b.label;
432
571
  if (isGroupHeaderSentinel(a) && isGroupHeaderSentinel(b))
433
572
  return a.groupName === b.groupName;
434
573
  if (isSelectAllSentinel(a) && isSelectAllSentinel(b)) return true;
435
574
  if (
436
575
  !isGroupHeaderSentinel(a) &&
437
576
  !isSelectAllSentinel(a) &&
577
+ !isCreatableSentinel(a) &&
438
578
  !isGroupHeaderSentinel(b) &&
439
- !isSelectAllSentinel(b)
579
+ !isSelectAllSentinel(b) &&
580
+ !isCreatableSentinel(b)
440
581
  )
441
582
  return (a as T).id === (b as T).id;
442
583
  return false;
@@ -475,6 +616,7 @@ export function MultiCombobox<T extends DataWithId>(
475
616
  <ComboboxTokenInput
476
617
  id={id}
477
618
  placeholder={value.length > 0 ? "" : placeholder}
619
+ aria-describedby={ariaDescribedBy}
478
620
  />
479
621
  <ComboboxActionButtons>
480
622
  <ComboboxClear aria-label="Clear all">
@@ -525,57 +667,83 @@ export function MultiCombobox<T extends DataWithId>(
525
667
  inputType={inputType}
526
668
  renderItem={renderItem}
527
669
  renderGroupHeading={renderGroupHeading}
670
+ renderCreatableItem={
671
+ creatableProp
672
+ ? (sentinel, style, index, measureRef) =>
673
+ renderCreatableItem(
674
+ sentinel,
675
+ style,
676
+ index,
677
+ measureRef
678
+ )
679
+ : undefined
680
+ }
528
681
  />
529
682
  ) : children ? (
530
683
  children
531
684
  ) : flatItems ? (
532
685
  (row: FlatRow) => renderSentinelRow(row)
533
686
  ) : groups ? (
534
- (group: { value: string; items: T[] }, index: number) => (
535
- <React.Fragment key={group.value}>
536
- <ComboboxGroup items={group.items}>
537
- <ComboboxGroupLabel>
538
- {renderGroupHeading
539
- ? renderGroupHeading(group.value)
540
- : group.value}
541
- </ComboboxGroupLabel>
542
- <Combobox.Collection>
543
- {(item: T) => (
544
- <ComboboxItem
545
- key={item.id}
546
- value={item}
547
- itemId={String(item.id)}
548
- label={itemToString(item)}
549
- disabled={item.disabled}
550
- inputType={inputType}
551
- renderItem={
552
- renderItem
553
- ? () => renderItem(item)
554
- : () => itemToString(item)
555
- }
556
- />
557
- )}
558
- </Combobox.Collection>
559
- </ComboboxGroup>
560
- {index < groups.length - 1 && <ComboboxSeparator />}
561
- </React.Fragment>
562
- )
687
+ (
688
+ group: { value: string; items: T[] } | CreatableSentinel,
689
+ index: number
690
+ ) => {
691
+ if (isCreatableSentinel(group)) {
692
+ return renderCreatableItem(group);
693
+ }
694
+ const g = group as { value: string; items: T[] };
695
+ return (
696
+ <React.Fragment key={g.value}>
697
+ <ComboboxGroup items={g.items}>
698
+ <ComboboxGroupLabel>
699
+ {renderGroupHeading
700
+ ? renderGroupHeading(g.value)
701
+ : g.value}
702
+ </ComboboxGroupLabel>
703
+ <Combobox.Collection>
704
+ {(item: T) => (
705
+ <ComboboxItem
706
+ key={item.id}
707
+ value={item}
708
+ itemId={String(item.id)}
709
+ label={itemToString(item)}
710
+ disabled={item.disabled}
711
+ inputType={inputType}
712
+ renderItem={
713
+ renderItem
714
+ ? () => renderItem(item)
715
+ : () => itemToString(item)
716
+ }
717
+ />
718
+ )}
719
+ </Combobox.Collection>
720
+ </ComboboxGroup>
721
+ {index < groups.length - 1 && <ComboboxSeparator />}
722
+ </React.Fragment>
723
+ );
724
+ }
563
725
  ) : (
564
- (item: T) => (
565
- <ComboboxItem
566
- key={item.id}
567
- value={item}
568
- itemId={String(item.id)}
569
- label={itemToString(item)}
570
- disabled={item.disabled}
571
- inputType={inputType}
572
- renderItem={
573
- renderItem
574
- ? () => renderItem(item)
575
- : () => itemToString(item)
576
- }
577
- />
578
- )
726
+ (item: T | CreatableSentinel) => {
727
+ if (isCreatableSentinel(item)) {
728
+ return renderCreatableItem(item);
729
+ }
730
+ const dataItem = item as T;
731
+ return (
732
+ <ComboboxItem
733
+ key={dataItem.id}
734
+ value={dataItem}
735
+ itemId={String(dataItem.id)}
736
+ label={itemToString(dataItem)}
737
+ disabled={dataItem.disabled}
738
+ inputType={inputType}
739
+ renderItem={
740
+ renderItem
741
+ ? () => renderItem(dataItem)
742
+ : () => itemToString(dataItem)
743
+ }
744
+ />
745
+ );
746
+ }
579
747
  )}
580
748
  </ComboboxList>
581
749
  </ComboboxPopup>