@sproutsocial/seeds-react-menu 1.12.2 → 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.2",
3
+ "version": "1.12.3",
4
4
  "description": "Seeds React Menu",
5
5
  "author": "Sprout Social, Inc.",
6
6
  "license": "MIT",
@@ -506,19 +506,28 @@ export function MultiCombobox<T extends DataWithId>(
506
506
  }
507
507
  : filter
508
508
  }
509
- inputValue={creatableProp ? currentInputValue : inputValue}
510
- onInputValueChange={
511
- creatableProp
512
- ? (v) => {
513
- if (skipNextInputChangeRef.current) {
514
- skipNextInputChangeRef.current = false;
515
- return;
516
- }
517
- setInternalInputValue(v);
518
- onInputValueChange?.(v);
519
- }
520
- : onInputValueChange
509
+ inputValue={
510
+ creatableProp ? currentInputValue : inputValue ?? internalInputValue
521
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
+ }}
522
531
  onItemHighlighted={
523
532
  isVirtualized
524
533
  ? (item, { reason, index }) => {
@@ -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 ────────────────────────────────────────────────────────
@@ -94,6 +100,8 @@ interface MultiSearchableSelectDataProps<T extends DataWithId>
94
100
  selectAll?: boolean;
95
101
  isVirtualized?: boolean;
96
102
  removeSelectedItems?: boolean;
103
+ creatable?: boolean;
104
+ onCreate?: (value: string) => T;
97
105
  children?: never;
98
106
  }
99
107
 
@@ -116,6 +124,8 @@ interface MultiSearchableSelectChildrenProps
116
124
  selectAll?: never;
117
125
  isVirtualized?: never;
118
126
  removeSelectedItems?: never;
127
+ creatable?: never;
128
+ onCreate?: never;
119
129
  }
120
130
 
121
131
  export type MultiSearchableSelectProps<T extends DataWithId> =
@@ -181,6 +191,12 @@ export function MultiSearchableSelect<T extends DataWithId>(
181
191
  const isVirtualized = isChildrenMode
182
192
  ? false
183
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;
184
200
  const children = isChildrenMode
185
201
  ? (props as MultiSearchableSelectChildrenProps).children
186
202
  : undefined;
@@ -191,6 +207,8 @@ export function MultiSearchableSelect<T extends DataWithId>(
191
207
 
192
208
  const { containerRef, portalContainer } = useSeedsPortalContainer();
193
209
  const [open, setOpen] = React.useState(false);
210
+ const [internalInputValue, setInternalInputValue] = React.useState("");
211
+ const skipNextInputChangeRef = React.useRef(false);
194
212
  const virtualizerRef = React.useRef<ReturnType<
195
213
  typeof useVirtualizer<HTMLDivElement, Element>
196
214
  > | null>(null);
@@ -228,9 +246,27 @@ export function MultiSearchableSelect<T extends DataWithId>(
228
246
  [visibleData, groupBy]
229
247
  );
230
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
+
231
263
  // ─── Sentinel flat list ───────────────────────────────────────────────────
232
264
 
233
- type FlatRow = T | GroupHeaderSentinel | SelectAllSentinel;
265
+ type FlatRow =
266
+ | T
267
+ | GroupHeaderSentinel
268
+ | SelectAllSentinel
269
+ | CreatableSentinel;
234
270
 
235
271
  const flatItems = React.useMemo<FlatRow[] | null>(() => {
236
272
  if (!groups || (!selectHeadings && !selectAll)) return null;
@@ -250,10 +286,28 @@ export function MultiSearchableSelect<T extends DataWithId>(
250
286
  }
251
287
 
252
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
+
253
304
  const selectAllClicked = next.filter(isSelectAllSentinel);
254
305
  const groupSentinels = next.filter(isGroupHeaderSentinel);
255
306
  const realItems = next.filter(
256
- (v): v is T => !isGroupHeaderSentinel(v) && !isSelectAllSentinel(v)
307
+ (v): v is T =>
308
+ !isGroupHeaderSentinel(v) &&
309
+ !isSelectAllSentinel(v) &&
310
+ !isCreatableSentinel(v)
257
311
  );
258
312
 
259
313
  if (selectAllClicked.length > 0) {
@@ -290,8 +344,17 @@ export function MultiSearchableSelect<T extends DataWithId>(
290
344
  setSelectedItems(updated);
291
345
  }
292
346
 
293
- function handleSimpleValueChange(newItems: T[]) {
294
- 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[]);
295
358
  }
296
359
 
297
360
  // ─── Render sentinel row ──────────────────────────────────────────────────
@@ -339,6 +402,10 @@ export function MultiSearchableSelect<T extends DataWithId>(
339
402
  );
340
403
  }
341
404
 
405
+ if (isCreatableSentinel(row)) {
406
+ return renderCreatableItem(row);
407
+ }
408
+
342
409
  const item = row as T;
343
410
  return (
344
411
  <ComboboxItem
@@ -355,8 +422,39 @@ export function MultiSearchableSelect<T extends DataWithId>(
355
422
  );
356
423
  }
357
424
 
358
- 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 =
359
453
  flatItems ?? groups ?? (isChildrenMode ? undefined : visibleData);
454
+ const rootItems =
455
+ creatableSentinel && Array.isArray(baseItems)
456
+ ? ([...baseItems, creatableSentinel] as FlatRow[])
457
+ : baseItems;
360
458
  const hasSentinels = selectAll || selectHeadings;
361
459
  const rootValue = hasSentinels
362
460
  ? ([...value] as FlatRow[])
@@ -369,7 +467,29 @@ export function MultiSearchableSelect<T extends DataWithId>(
369
467
  multiple
370
468
  id={id}
371
469
  virtualized={isVirtualized}
372
- 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
+ }}
373
493
  open={open}
374
494
  disabled={props.disabled}
375
495
  onOpenChange={setOpen}
@@ -398,20 +518,31 @@ export function MultiSearchableSelect<T extends DataWithId>(
398
518
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
399
519
  (hasSentinels ? handleValueChange : handleSimpleValueChange) as any
400
520
  }
401
- itemToStringLabel={(item: FlatRow | null) => {
402
- 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
+ )
403
529
  return "";
404
530
  return itemToString(item as T);
405
531
  }}
406
- 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;
407
536
  if (isGroupHeaderSentinel(a) && isGroupHeaderSentinel(b))
408
537
  return a.groupName === b.groupName;
409
538
  if (isSelectAllSentinel(a) && isSelectAllSentinel(b)) return true;
410
539
  if (
411
540
  !isGroupHeaderSentinel(a) &&
412
541
  !isSelectAllSentinel(a) &&
542
+ !isCreatableSentinel(a) &&
413
543
  !isGroupHeaderSentinel(b) &&
414
- !isSelectAllSentinel(b)
544
+ !isSelectAllSentinel(b) &&
545
+ !isCreatableSentinel(b)
415
546
  )
416
547
  return (a as T).id === (b as T).id;
417
548
  return false;
@@ -496,57 +627,83 @@ export function MultiSearchableSelect<T extends DataWithId>(
496
627
  inputType={inputType}
497
628
  renderItem={renderItem}
498
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
+ }
499
641
  />
500
642
  ) : children ? (
501
643
  children
502
644
  ) : flatItems ? (
503
645
  (row: FlatRow) => renderSentinelRow(row)
504
646
  ) : groups ? (
505
- (group: { value: string; items: T[] }, index: number) => (
506
- <React.Fragment key={group.value}>
507
- <ComboboxGroup items={group.items}>
508
- <ComboboxGroupLabel>
509
- {renderGroupHeading
510
- ? renderGroupHeading(group.value)
511
- : group.value}
512
- </ComboboxGroupLabel>
513
- <Combobox.Collection>
514
- {(item: T) => (
515
- <ComboboxItem
516
- key={item.id}
517
- value={item}
518
- itemId={String(item.id)}
519
- label={itemToString(item)}
520
- disabled={item.disabled}
521
- inputType={inputType}
522
- renderItem={
523
- renderItem
524
- ? () => renderItem(item)
525
- : () => itemToString(item)
526
- }
527
- />
528
- )}
529
- </Combobox.Collection>
530
- </ComboboxGroup>
531
- {index < groups.length - 1 && <ComboboxSeparator />}
532
- </React.Fragment>
533
- )
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
+ }
534
685
  ) : (
535
- (item: T) => (
536
- <ComboboxItem
537
- key={item.id}
538
- value={item}
539
- itemId={String(item.id)}
540
- label={itemToString(item)}
541
- disabled={item.disabled}
542
- inputType={inputType}
543
- renderItem={
544
- renderItem
545
- ? () => renderItem(item)
546
- : () => itemToString(item)
547
- }
548
- />
549
- )
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
+ }
550
707
  )}
551
708
  </SearchableSelectList>
552
709
  </SearchableSelectPopup>