@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.
@@ -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
  ComboboxGroup,
@@ -26,6 +31,7 @@ import {
26
31
  SearchableSelectList,
27
32
  } from "./Common/ComboboxStyles";
28
33
  import { ClearIcon, StyledChevron, ChevronIcon } from "./Common/SelectIcons";
34
+ import Icon from "@sproutsocial/seeds-react-icon";
29
35
  import styled from "styled-components";
30
36
 
31
37
  // ─── Styled components ────────────────────────────────────────────────────────
@@ -73,6 +79,7 @@ interface MultiSearchableSelectBaseProps {
73
79
  loadingText?: string;
74
80
  disabled?: boolean;
75
81
  filter?: ((item: unknown, query: string) => boolean) | null;
82
+ "aria-describedby"?: string;
76
83
  }
77
84
 
78
85
  interface MultiSearchableSelectDataProps<T extends DataWithId>
@@ -93,6 +100,8 @@ interface MultiSearchableSelectDataProps<T extends DataWithId>
93
100
  selectAll?: boolean;
94
101
  isVirtualized?: boolean;
95
102
  removeSelectedItems?: boolean;
103
+ creatable?: boolean;
104
+ onCreate?: (value: string) => T;
96
105
  children?: never;
97
106
  }
98
107
 
@@ -115,6 +124,8 @@ interface MultiSearchableSelectChildrenProps
115
124
  selectAll?: never;
116
125
  isVirtualized?: never;
117
126
  removeSelectedItems?: never;
127
+ creatable?: never;
128
+ onCreate?: never;
118
129
  }
119
130
 
120
131
  export type MultiSearchableSelectProps<T extends DataWithId> =
@@ -135,6 +146,7 @@ export function MultiSearchableSelect<T extends DataWithId>(
135
146
  loadingText = "Loading...",
136
147
  filter,
137
148
  } = props;
149
+ const ariaDescribedBy = props["aria-describedby"];
138
150
 
139
151
  const isChildrenMode = "children" in props && props.children != null;
140
152
 
@@ -179,6 +191,12 @@ export function MultiSearchableSelect<T extends DataWithId>(
179
191
  const isVirtualized = isChildrenMode
180
192
  ? false
181
193
  : (props as MultiSearchableSelectDataProps<T>).isVirtualized ?? false;
194
+ const creatableProp = isChildrenMode
195
+ ? false
196
+ : (props as MultiSearchableSelectDataProps<T>).creatable ?? false;
197
+ const onCreateProp = isChildrenMode
198
+ ? undefined
199
+ : (props as MultiSearchableSelectDataProps<T>).onCreate;
182
200
  const children = isChildrenMode
183
201
  ? (props as MultiSearchableSelectChildrenProps).children
184
202
  : undefined;
@@ -189,6 +207,8 @@ export function MultiSearchableSelect<T extends DataWithId>(
189
207
 
190
208
  const { containerRef, portalContainer } = useSeedsPortalContainer();
191
209
  const [open, setOpen] = React.useState(false);
210
+ const [internalInputValue, setInternalInputValue] = React.useState("");
211
+ const skipNextInputChangeRef = React.useRef(false);
192
212
  const virtualizerRef = React.useRef<ReturnType<
193
213
  typeof useVirtualizer<HTMLDivElement, Element>
194
214
  > | null>(null);
@@ -226,9 +246,27 @@ export function MultiSearchableSelect<T extends DataWithId>(
226
246
  [visibleData, groupBy]
227
247
  );
228
248
 
249
+ const currentInputValue = internalInputValue;
250
+
251
+ const creatableSentinel = React.useMemo<CreatableSentinel | null>(() => {
252
+ if (!creatableProp) return null;
253
+ const trimmed = currentInputValue.trim();
254
+ if (!trimmed) return null;
255
+ const lowered = trimmed.toLocaleLowerCase();
256
+ const exactExists = data.some(
257
+ (d) => itemToString(d).trim().toLocaleLowerCase() === lowered
258
+ );
259
+ if (exactExists) return null;
260
+ return makeCreatableSentinel(trimmed);
261
+ }, [creatableProp, currentInputValue, data, itemToString]);
262
+
229
263
  // ─── Sentinel flat list ───────────────────────────────────────────────────
230
264
 
231
- type FlatRow = T | GroupHeaderSentinel | SelectAllSentinel;
265
+ type FlatRow =
266
+ | T
267
+ | GroupHeaderSentinel
268
+ | SelectAllSentinel
269
+ | CreatableSentinel;
232
270
 
233
271
  const flatItems = React.useMemo<FlatRow[] | null>(() => {
234
272
  if (!groups || (!selectHeadings && !selectAll)) return null;
@@ -248,10 +286,28 @@ export function MultiSearchableSelect<T extends DataWithId>(
248
286
  }
249
287
 
250
288
  function handleValueChange(next: FlatRow[]) {
289
+ const creatableClicked = next.find(isCreatableSentinel);
290
+ if (creatableClicked) {
291
+ const newItem = onCreateProp?.(creatableClicked.label);
292
+ const realItems = next.filter(
293
+ (v): v is T =>
294
+ !isGroupHeaderSentinel(v) &&
295
+ !isSelectAllSentinel(v) &&
296
+ !isCreatableSentinel(v)
297
+ );
298
+ setSelectedItems(newItem ? [...realItems, newItem] : realItems);
299
+ skipNextInputChangeRef.current = true;
300
+ setInternalInputValue("");
301
+ return;
302
+ }
303
+
251
304
  const selectAllClicked = next.filter(isSelectAllSentinel);
252
305
  const groupSentinels = next.filter(isGroupHeaderSentinel);
253
306
  const realItems = next.filter(
254
- (v): v is T => !isGroupHeaderSentinel(v) && !isSelectAllSentinel(v)
307
+ (v): v is T =>
308
+ !isGroupHeaderSentinel(v) &&
309
+ !isSelectAllSentinel(v) &&
310
+ !isCreatableSentinel(v)
255
311
  );
256
312
 
257
313
  if (selectAllClicked.length > 0) {
@@ -288,8 +344,17 @@ export function MultiSearchableSelect<T extends DataWithId>(
288
344
  setSelectedItems(updated);
289
345
  }
290
346
 
291
- function handleSimpleValueChange(newItems: T[]) {
292
- setSelectedItems(newItems);
347
+ function handleSimpleValueChange(newItems: FlatRow[]) {
348
+ const creatableClicked = newItems.find(isCreatableSentinel);
349
+ if (creatableClicked) {
350
+ const newItem = onCreateProp?.(creatableClicked.label);
351
+ const realItems = newItems.filter((v): v is T => !isCreatableSentinel(v));
352
+ setSelectedItems(newItem ? [...realItems, newItem] : realItems);
353
+ skipNextInputChangeRef.current = true;
354
+ setInternalInputValue("");
355
+ return;
356
+ }
357
+ setSelectedItems(newItems as T[]);
293
358
  }
294
359
 
295
360
  // ─── Render sentinel row ──────────────────────────────────────────────────
@@ -337,6 +402,10 @@ export function MultiSearchableSelect<T extends DataWithId>(
337
402
  );
338
403
  }
339
404
 
405
+ if (isCreatableSentinel(row)) {
406
+ return renderCreatableItem(row);
407
+ }
408
+
340
409
  const item = row as T;
341
410
  return (
342
411
  <ComboboxItem
@@ -353,8 +422,39 @@ export function MultiSearchableSelect<T extends DataWithId>(
353
422
  );
354
423
  }
355
424
 
356
- const rootItems =
425
+ function renderCreatableItem(
426
+ sentinel: CreatableSentinel,
427
+ style?: React.CSSProperties,
428
+ index?: number,
429
+ measureRef?: (el: Element | null) => void
430
+ ) {
431
+ return (
432
+ <ComboboxItem
433
+ key="__creatable"
434
+ value={sentinel}
435
+ itemId="__creatable"
436
+ label={`Create "${sentinel.label}"`}
437
+ inputType="none"
438
+ renderItem={() => (
439
+ <>
440
+ <Icon name="plus-outline" size="mini" />
441
+ {`Create "${sentinel.label}"`}
442
+ </>
443
+ )}
444
+ style={style}
445
+ index={index}
446
+ data-index={index}
447
+ ref={measureRef}
448
+ />
449
+ );
450
+ }
451
+
452
+ const baseItems =
357
453
  flatItems ?? groups ?? (isChildrenMode ? undefined : visibleData);
454
+ const rootItems =
455
+ creatableSentinel && Array.isArray(baseItems)
456
+ ? ([...baseItems, creatableSentinel] as FlatRow[])
457
+ : baseItems;
358
458
  const hasSentinels = selectAll || selectHeadings;
359
459
  const rootValue = hasSentinels
360
460
  ? ([...value] as FlatRow[])
@@ -367,7 +467,29 @@ export function MultiSearchableSelect<T extends DataWithId>(
367
467
  multiple
368
468
  id={id}
369
469
  virtualized={isVirtualized}
370
- filter={filter}
470
+ filter={
471
+ creatableProp
472
+ ? (item: unknown, query: string) => {
473
+ if (isCreatableSentinel(item)) return true;
474
+ if (filter) return filter(item, query);
475
+ if (isGroupHeaderSentinel(item) || isSelectAllSentinel(item))
476
+ return true;
477
+ const label = itemToString(item as T);
478
+ return label.toLowerCase().includes(query.toLowerCase());
479
+ }
480
+ : filter
481
+ }
482
+ inputValue={internalInputValue}
483
+ onInputValueChange={(v, eventDetails) => {
484
+ // Preserve search query when an item is selected. Base UI fires
485
+ // 'input-clear' on item-press; ignore it while the popup stays open.
486
+ if (eventDetails.reason === "input-clear" && open) return;
487
+ if (skipNextInputChangeRef.current) {
488
+ skipNextInputChangeRef.current = false;
489
+ return;
490
+ }
491
+ setInternalInputValue(v);
492
+ }}
371
493
  open={open}
372
494
  disabled={props.disabled}
373
495
  onOpenChange={setOpen}
@@ -396,27 +518,41 @@ export function MultiSearchableSelect<T extends DataWithId>(
396
518
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
397
519
  (hasSentinels ? handleValueChange : handleSimpleValueChange) as any
398
520
  }
399
- itemToStringLabel={(item: FlatRow | null) => {
400
- if (!item || isGroupHeaderSentinel(item) || isSelectAllSentinel(item))
521
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
522
+ itemToStringLabel={(item: any) => {
523
+ if (
524
+ !item ||
525
+ isGroupHeaderSentinel(item) ||
526
+ isSelectAllSentinel(item) ||
527
+ isCreatableSentinel(item)
528
+ )
401
529
  return "";
402
530
  return itemToString(item as T);
403
531
  }}
404
- isItemEqualToValue={(a: FlatRow, b: FlatRow) => {
532
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
533
+ isItemEqualToValue={(a: any, b: any) => {
534
+ if (isCreatableSentinel(a) && isCreatableSentinel(b))
535
+ return a.label === b.label;
405
536
  if (isGroupHeaderSentinel(a) && isGroupHeaderSentinel(b))
406
537
  return a.groupName === b.groupName;
407
538
  if (isSelectAllSentinel(a) && isSelectAllSentinel(b)) return true;
408
539
  if (
409
540
  !isGroupHeaderSentinel(a) &&
410
541
  !isSelectAllSentinel(a) &&
542
+ !isCreatableSentinel(a) &&
411
543
  !isGroupHeaderSentinel(b) &&
412
- !isSelectAllSentinel(b)
544
+ !isSelectAllSentinel(b) &&
545
+ !isCreatableSentinel(b)
413
546
  )
414
547
  return (a as T).id === (b as T).id;
415
548
  return false;
416
549
  }}
417
550
  items={rootItems}
418
551
  >
419
- <SearchableSelectTokenTrigger id={id}>
552
+ <SearchableSelectTokenTrigger
553
+ id={id}
554
+ aria-describedby={ariaDescribedBy}
555
+ >
420
556
  {renderSelection ? (
421
557
  renderSelection(value)
422
558
  ) : value.length > 0 ? (
@@ -491,57 +627,83 @@ export function MultiSearchableSelect<T extends DataWithId>(
491
627
  inputType={inputType}
492
628
  renderItem={renderItem}
493
629
  renderGroupHeading={renderGroupHeading}
630
+ renderCreatableItem={
631
+ creatableProp
632
+ ? (sentinel, style, index, measureRef) =>
633
+ renderCreatableItem(
634
+ sentinel,
635
+ style,
636
+ index,
637
+ measureRef
638
+ )
639
+ : undefined
640
+ }
494
641
  />
495
642
  ) : children ? (
496
643
  children
497
644
  ) : flatItems ? (
498
645
  (row: FlatRow) => renderSentinelRow(row)
499
646
  ) : groups ? (
500
- (group: { value: string; items: T[] }, index: number) => (
501
- <React.Fragment key={group.value}>
502
- <ComboboxGroup items={group.items}>
503
- <ComboboxGroupLabel>
504
- {renderGroupHeading
505
- ? renderGroupHeading(group.value)
506
- : group.value}
507
- </ComboboxGroupLabel>
508
- <Combobox.Collection>
509
- {(item: T) => (
510
- <ComboboxItem
511
- key={item.id}
512
- value={item}
513
- itemId={String(item.id)}
514
- label={itemToString(item)}
515
- disabled={item.disabled}
516
- inputType={inputType}
517
- renderItem={
518
- renderItem
519
- ? () => renderItem(item)
520
- : () => itemToString(item)
521
- }
522
- />
523
- )}
524
- </Combobox.Collection>
525
- </ComboboxGroup>
526
- {index < groups.length - 1 && <ComboboxSeparator />}
527
- </React.Fragment>
528
- )
647
+ (
648
+ group: { value: string; items: T[] } | CreatableSentinel,
649
+ index: number
650
+ ) => {
651
+ if (isCreatableSentinel(group)) {
652
+ return renderCreatableItem(group);
653
+ }
654
+ const g = group as { value: string; items: T[] };
655
+ return (
656
+ <React.Fragment key={g.value}>
657
+ <ComboboxGroup items={g.items}>
658
+ <ComboboxGroupLabel>
659
+ {renderGroupHeading
660
+ ? renderGroupHeading(g.value)
661
+ : g.value}
662
+ </ComboboxGroupLabel>
663
+ <Combobox.Collection>
664
+ {(item: T) => (
665
+ <ComboboxItem
666
+ key={item.id}
667
+ value={item}
668
+ itemId={String(item.id)}
669
+ label={itemToString(item)}
670
+ disabled={item.disabled}
671
+ inputType={inputType}
672
+ renderItem={
673
+ renderItem
674
+ ? () => renderItem(item)
675
+ : () => itemToString(item)
676
+ }
677
+ />
678
+ )}
679
+ </Combobox.Collection>
680
+ </ComboboxGroup>
681
+ {index < groups.length - 1 && <ComboboxSeparator />}
682
+ </React.Fragment>
683
+ );
684
+ }
529
685
  ) : (
530
- (item: T) => (
531
- <ComboboxItem
532
- key={item.id}
533
- value={item}
534
- itemId={String(item.id)}
535
- label={itemToString(item)}
536
- disabled={item.disabled}
537
- inputType={inputType}
538
- renderItem={
539
- renderItem
540
- ? () => renderItem(item)
541
- : () => itemToString(item)
542
- }
543
- />
544
- )
686
+ (item: T | CreatableSentinel) => {
687
+ if (isCreatableSentinel(item)) {
688
+ return renderCreatableItem(item);
689
+ }
690
+ const dataItem = item as T;
691
+ return (
692
+ <ComboboxItem
693
+ key={dataItem.id}
694
+ value={dataItem}
695
+ itemId={String(dataItem.id)}
696
+ label={itemToString(dataItem)}
697
+ disabled={dataItem.disabled}
698
+ inputType={inputType}
699
+ renderItem={
700
+ renderItem
701
+ ? () => renderItem(dataItem)
702
+ : () => itemToString(dataItem)
703
+ }
704
+ />
705
+ );
706
+ }
545
707
  )}
546
708
  </SearchableSelectList>
547
709
  </SearchableSelectPopup>
@@ -43,6 +43,7 @@ interface MultiSelectBaseProps {
43
43
  id?: string;
44
44
  placeholder?: string;
45
45
  disabled?: boolean;
46
+ "aria-describedby"?: string;
46
47
  }
47
48
 
48
49
  interface MultiSelectDataProps<T extends DataWithId>
@@ -88,6 +89,7 @@ export type MultiSelectProps<T extends DataWithId> =
88
89
 
89
90
  export function MultiSelect<T extends DataWithId>(props: MultiSelectProps<T>) {
90
91
  const { id, placeholder = "Select..." } = props;
92
+ const ariaDescribedBy = props["aria-describedby"];
91
93
 
92
94
  const isChildrenMode = "children" in props && props.children != null;
93
95
 
@@ -179,7 +181,7 @@ export function MultiSelect<T extends DataWithId>(props: MultiSelectProps<T>) {
179
181
  disabled={props.disabled}
180
182
  id={id}
181
183
  >
182
- <SelectTrigger>
184
+ <SelectTrigger aria-describedby={ariaDescribedBy}>
183
185
  <SelectValue>
184
186
  {() => {
185
187
  if (renderSelection) return renderSelection(selectedItems);
@@ -31,6 +31,7 @@ interface SingleComboboxBaseProps {
31
31
  emptyText?: string;
32
32
  disabled?: boolean;
33
33
  filter?: ((item: unknown, query: string) => boolean) | null;
34
+ "aria-describedby"?: string;
34
35
  }
35
36
 
36
37
  interface SingleComboboxDataBaseProps<T extends DataWithId>
@@ -89,6 +90,7 @@ export function SingleCombobox<T extends DataWithId>(
89
90
  emptyText = "No results found.",
90
91
  filter,
91
92
  } = props;
93
+ const ariaDescribedBy = props["aria-describedby"];
92
94
 
93
95
  const isChildrenMode = "children" in props && props.children != null;
94
96
 
@@ -204,7 +206,11 @@ export function SingleCombobox<T extends DataWithId>(
204
206
  }
205
207
  >
206
208
  <ComboboxInputGroup>
207
- <ComboboxInput placeholder={placeholder} id={id} />
209
+ <ComboboxInput
210
+ placeholder={placeholder}
211
+ id={id}
212
+ aria-describedby={ariaDescribedBy}
213
+ />
208
214
  <ComboboxActionButtons>
209
215
  <ComboboxClear aria-label="Clear selection">
210
216
  <ClearIcon />
@@ -41,6 +41,7 @@ interface SingleSearchableSelectBaseProps {
41
41
  loadingText?: string;
42
42
  disabled?: boolean;
43
43
  filter?: ((item: unknown, query: string) => boolean) | null;
44
+ "aria-describedby"?: string;
44
45
  }
45
46
 
46
47
  interface SingleSearchableSelectDataBaseProps<T extends DataWithId>
@@ -106,6 +107,7 @@ export function SingleSearchableSelect<T extends DataWithId>(
106
107
  loadingText = "Loading...",
107
108
  filter,
108
109
  } = props;
110
+ const ariaDescribedBy = props["aria-describedby"];
109
111
 
110
112
  const isChildrenMode = "children" in props && props.children != null;
111
113
 
@@ -236,7 +238,7 @@ export function SingleSearchableSelect<T extends DataWithId>(
236
238
  : undefined
237
239
  }
238
240
  >
239
- <SearchableSelectTrigger id={id}>
241
+ <SearchableSelectTrigger id={id} aria-describedby={ariaDescribedBy}>
240
242
  <SearchableSelectPlaceholder>
241
243
  <Combobox.Value placeholder={placeholder}>
242
244
  {value
@@ -37,6 +37,25 @@ export const Basic: Story = {
37
37
  ),
38
38
  };
39
39
 
40
+ export const Empty: Story = {
41
+ name: "Empty",
42
+ render: () => (
43
+ <div style={{ width: "300px" }}>
44
+ <FormField label="Pick a book">
45
+ {({ id }) => (
46
+ <SingleSelect
47
+ id={"placeholder"}
48
+ disabled
49
+ data={[]}
50
+ itemToString={() => ""}
51
+ placeholder=""
52
+ />
53
+ )}
54
+ </FormField>
55
+ </div>
56
+ ),
57
+ };
58
+
40
59
  export const Disabled: Story = {
41
60
  name: "Disabled",
42
61
  render: () => (
@@ -39,6 +39,7 @@ interface SingleSelectBaseProps {
39
39
  placeholder?: string;
40
40
  includePlaceholderItem?: boolean;
41
41
  disabled?: boolean;
42
+ "aria-describedby"?: string;
42
43
  }
43
44
 
44
45
  interface SingleSelectDataProps<T extends DataWithId>
@@ -80,6 +81,7 @@ export function SingleSelect<T extends DataWithId>(
80
81
  props: SingleSelectProps<T>
81
82
  ) {
82
83
  const { id, placeholder, includePlaceholderItem } = props;
84
+ const ariaDescribedBy = props["aria-describedby"];
83
85
 
84
86
  const isChildrenMode = "children" in props && props.children != null;
85
87
 
@@ -166,7 +168,7 @@ export function SingleSelect<T extends DataWithId>(
166
168
  onValueChange={handleValueChange}
167
169
  id={id}
168
170
  >
169
- <SelectTrigger>
171
+ <SelectTrigger aria-describedby={ariaDescribedBy}>
170
172
  <StyledValue placeholder={placeholder}>
171
173
  {renderSelection && selectedItem
172
174
  ? renderSelection(selectedItem)