@sproutsocial/seeds-react-menu 1.12.2 → 1.13.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.
@@ -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 ────────────────────────────────────────────────────────
@@ -74,6 +80,7 @@ interface MultiSearchableSelectBaseProps {
74
80
  disabled?: boolean;
75
81
  filter?: ((item: unknown, query: string) => boolean) | null;
76
82
  "aria-describedby"?: string;
83
+ fullWidth?: boolean;
77
84
  }
78
85
 
79
86
  interface MultiSearchableSelectDataProps<T extends DataWithId>
@@ -94,6 +101,8 @@ interface MultiSearchableSelectDataProps<T extends DataWithId>
94
101
  selectAll?: boolean;
95
102
  isVirtualized?: boolean;
96
103
  removeSelectedItems?: boolean;
104
+ creatable?: boolean;
105
+ onCreate?: (value: string) => T;
97
106
  children?: never;
98
107
  }
99
108
 
@@ -116,6 +125,8 @@ interface MultiSearchableSelectChildrenProps
116
125
  selectAll?: never;
117
126
  isVirtualized?: never;
118
127
  removeSelectedItems?: never;
128
+ creatable?: never;
129
+ onCreate?: never;
119
130
  }
120
131
 
121
132
  export type MultiSearchableSelectProps<T extends DataWithId> =
@@ -135,6 +146,7 @@ export function MultiSearchableSelect<T extends DataWithId>(
135
146
  isLoading = false,
136
147
  loadingText = "Loading...",
137
148
  filter,
149
+ fullWidth = true,
138
150
  } = props;
139
151
  const ariaDescribedBy = props["aria-describedby"];
140
152
 
@@ -181,6 +193,12 @@ export function MultiSearchableSelect<T extends DataWithId>(
181
193
  const isVirtualized = isChildrenMode
182
194
  ? false
183
195
  : (props as MultiSearchableSelectDataProps<T>).isVirtualized ?? false;
196
+ const creatableProp = isChildrenMode
197
+ ? false
198
+ : (props as MultiSearchableSelectDataProps<T>).creatable ?? false;
199
+ const onCreateProp = isChildrenMode
200
+ ? undefined
201
+ : (props as MultiSearchableSelectDataProps<T>).onCreate;
184
202
  const children = isChildrenMode
185
203
  ? (props as MultiSearchableSelectChildrenProps).children
186
204
  : undefined;
@@ -191,6 +209,8 @@ export function MultiSearchableSelect<T extends DataWithId>(
191
209
 
192
210
  const { containerRef, portalContainer } = useSeedsPortalContainer();
193
211
  const [open, setOpen] = React.useState(false);
212
+ const [internalInputValue, setInternalInputValue] = React.useState("");
213
+ const skipNextInputChangeRef = React.useRef(false);
194
214
  const virtualizerRef = React.useRef<ReturnType<
195
215
  typeof useVirtualizer<HTMLDivElement, Element>
196
216
  > | null>(null);
@@ -228,9 +248,27 @@ export function MultiSearchableSelect<T extends DataWithId>(
228
248
  [visibleData, groupBy]
229
249
  );
230
250
 
251
+ const currentInputValue = internalInputValue;
252
+
253
+ const creatableSentinel = React.useMemo<CreatableSentinel | null>(() => {
254
+ if (!creatableProp) return null;
255
+ const trimmed = currentInputValue.trim();
256
+ if (!trimmed) return null;
257
+ const lowered = trimmed.toLocaleLowerCase();
258
+ const exactExists = data.some(
259
+ (d) => itemToString(d).trim().toLocaleLowerCase() === lowered
260
+ );
261
+ if (exactExists) return null;
262
+ return makeCreatableSentinel(trimmed);
263
+ }, [creatableProp, currentInputValue, data, itemToString]);
264
+
231
265
  // ─── Sentinel flat list ───────────────────────────────────────────────────
232
266
 
233
- type FlatRow = T | GroupHeaderSentinel | SelectAllSentinel;
267
+ type FlatRow =
268
+ | T
269
+ | GroupHeaderSentinel
270
+ | SelectAllSentinel
271
+ | CreatableSentinel;
234
272
 
235
273
  const flatItems = React.useMemo<FlatRow[] | null>(() => {
236
274
  if (!groups || (!selectHeadings && !selectAll)) return null;
@@ -250,10 +288,28 @@ export function MultiSearchableSelect<T extends DataWithId>(
250
288
  }
251
289
 
252
290
  function handleValueChange(next: FlatRow[]) {
291
+ const creatableClicked = next.find(isCreatableSentinel);
292
+ if (creatableClicked) {
293
+ const newItem = onCreateProp?.(creatableClicked.label);
294
+ const realItems = next.filter(
295
+ (v): v is T =>
296
+ !isGroupHeaderSentinel(v) &&
297
+ !isSelectAllSentinel(v) &&
298
+ !isCreatableSentinel(v)
299
+ );
300
+ setSelectedItems(newItem ? [...realItems, newItem] : realItems);
301
+ skipNextInputChangeRef.current = true;
302
+ setInternalInputValue("");
303
+ return;
304
+ }
305
+
253
306
  const selectAllClicked = next.filter(isSelectAllSentinel);
254
307
  const groupSentinels = next.filter(isGroupHeaderSentinel);
255
308
  const realItems = next.filter(
256
- (v): v is T => !isGroupHeaderSentinel(v) && !isSelectAllSentinel(v)
309
+ (v): v is T =>
310
+ !isGroupHeaderSentinel(v) &&
311
+ !isSelectAllSentinel(v) &&
312
+ !isCreatableSentinel(v)
257
313
  );
258
314
 
259
315
  if (selectAllClicked.length > 0) {
@@ -290,8 +346,17 @@ export function MultiSearchableSelect<T extends DataWithId>(
290
346
  setSelectedItems(updated);
291
347
  }
292
348
 
293
- function handleSimpleValueChange(newItems: T[]) {
294
- setSelectedItems(newItems);
349
+ function handleSimpleValueChange(newItems: FlatRow[]) {
350
+ const creatableClicked = newItems.find(isCreatableSentinel);
351
+ if (creatableClicked) {
352
+ const newItem = onCreateProp?.(creatableClicked.label);
353
+ const realItems = newItems.filter((v): v is T => !isCreatableSentinel(v));
354
+ setSelectedItems(newItem ? [...realItems, newItem] : realItems);
355
+ skipNextInputChangeRef.current = true;
356
+ setInternalInputValue("");
357
+ return;
358
+ }
359
+ setSelectedItems(newItems as T[]);
295
360
  }
296
361
 
297
362
  // ─── Render sentinel row ──────────────────────────────────────────────────
@@ -339,6 +404,10 @@ export function MultiSearchableSelect<T extends DataWithId>(
339
404
  );
340
405
  }
341
406
 
407
+ if (isCreatableSentinel(row)) {
408
+ return renderCreatableItem(row);
409
+ }
410
+
342
411
  const item = row as T;
343
412
  return (
344
413
  <ComboboxItem
@@ -355,21 +424,74 @@ export function MultiSearchableSelect<T extends DataWithId>(
355
424
  );
356
425
  }
357
426
 
358
- const rootItems =
427
+ function renderCreatableItem(
428
+ sentinel: CreatableSentinel,
429
+ style?: React.CSSProperties,
430
+ index?: number,
431
+ measureRef?: (el: Element | null) => void
432
+ ) {
433
+ return (
434
+ <ComboboxItem
435
+ key="__creatable"
436
+ value={sentinel}
437
+ itemId="__creatable"
438
+ label={`Create "${sentinel.label}"`}
439
+ inputType="none"
440
+ renderItem={() => (
441
+ <>
442
+ <Icon name="plus-outline" size="mini" />
443
+ {`Create "${sentinel.label}"`}
444
+ </>
445
+ )}
446
+ style={style}
447
+ index={index}
448
+ data-index={index}
449
+ ref={measureRef}
450
+ />
451
+ );
452
+ }
453
+
454
+ const baseItems =
359
455
  flatItems ?? groups ?? (isChildrenMode ? undefined : visibleData);
456
+ const rootItems =
457
+ creatableSentinel && Array.isArray(baseItems)
458
+ ? ([...baseItems, creatableSentinel] as FlatRow[])
459
+ : baseItems;
360
460
  const hasSentinels = selectAll || selectHeadings;
361
461
  const rootValue = hasSentinels
362
462
  ? ([...value] as FlatRow[])
363
463
  : (value as unknown as T[]);
364
464
 
365
465
  return (
366
- <Field>
466
+ <Field $fullWidth={fullWidth}>
367
467
  <div ref={containerRef} style={{ position: "relative" }} />
368
468
  <Combobox.Root
369
469
  multiple
370
470
  id={id}
371
471
  virtualized={isVirtualized}
372
- filter={filter}
472
+ filter={
473
+ creatableProp
474
+ ? (item: unknown, query: string) => {
475
+ if (isCreatableSentinel(item)) return true;
476
+ if (filter) return filter(item, query);
477
+ if (isGroupHeaderSentinel(item) || isSelectAllSentinel(item))
478
+ return true;
479
+ const label = itemToString(item as T);
480
+ return label.toLowerCase().includes(query.toLowerCase());
481
+ }
482
+ : filter
483
+ }
484
+ inputValue={internalInputValue}
485
+ onInputValueChange={(v, eventDetails) => {
486
+ // Preserve search query when an item is selected. Base UI fires
487
+ // 'input-clear' on item-press; ignore it while the popup stays open.
488
+ if (eventDetails.reason === "input-clear" && open) return;
489
+ if (skipNextInputChangeRef.current) {
490
+ skipNextInputChangeRef.current = false;
491
+ return;
492
+ }
493
+ setInternalInputValue(v);
494
+ }}
373
495
  open={open}
374
496
  disabled={props.disabled}
375
497
  onOpenChange={setOpen}
@@ -398,20 +520,31 @@ export function MultiSearchableSelect<T extends DataWithId>(
398
520
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
399
521
  (hasSentinels ? handleValueChange : handleSimpleValueChange) as any
400
522
  }
401
- itemToStringLabel={(item: FlatRow | null) => {
402
- if (!item || isGroupHeaderSentinel(item) || isSelectAllSentinel(item))
523
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
524
+ itemToStringLabel={(item: any) => {
525
+ if (
526
+ !item ||
527
+ isGroupHeaderSentinel(item) ||
528
+ isSelectAllSentinel(item) ||
529
+ isCreatableSentinel(item)
530
+ )
403
531
  return "";
404
532
  return itemToString(item as T);
405
533
  }}
406
- isItemEqualToValue={(a: FlatRow, b: FlatRow) => {
534
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
535
+ isItemEqualToValue={(a: any, b: any) => {
536
+ if (isCreatableSentinel(a) && isCreatableSentinel(b))
537
+ return a.label === b.label;
407
538
  if (isGroupHeaderSentinel(a) && isGroupHeaderSentinel(b))
408
539
  return a.groupName === b.groupName;
409
540
  if (isSelectAllSentinel(a) && isSelectAllSentinel(b)) return true;
410
541
  if (
411
542
  !isGroupHeaderSentinel(a) &&
412
543
  !isSelectAllSentinel(a) &&
544
+ !isCreatableSentinel(a) &&
413
545
  !isGroupHeaderSentinel(b) &&
414
- !isSelectAllSentinel(b)
546
+ !isSelectAllSentinel(b) &&
547
+ !isCreatableSentinel(b)
415
548
  )
416
549
  return (a as T).id === (b as T).id;
417
550
  return false;
@@ -496,57 +629,83 @@ export function MultiSearchableSelect<T extends DataWithId>(
496
629
  inputType={inputType}
497
630
  renderItem={renderItem}
498
631
  renderGroupHeading={renderGroupHeading}
632
+ renderCreatableItem={
633
+ creatableProp
634
+ ? (sentinel, style, index, measureRef) =>
635
+ renderCreatableItem(
636
+ sentinel,
637
+ style,
638
+ index,
639
+ measureRef
640
+ )
641
+ : undefined
642
+ }
499
643
  />
500
644
  ) : children ? (
501
645
  children
502
646
  ) : flatItems ? (
503
647
  (row: FlatRow) => renderSentinelRow(row)
504
648
  ) : 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
- )
649
+ (
650
+ group: { value: string; items: T[] } | CreatableSentinel,
651
+ index: number
652
+ ) => {
653
+ if (isCreatableSentinel(group)) {
654
+ return renderCreatableItem(group);
655
+ }
656
+ const g = group as { value: string; items: T[] };
657
+ return (
658
+ <React.Fragment key={g.value}>
659
+ <ComboboxGroup items={g.items}>
660
+ <ComboboxGroupLabel>
661
+ {renderGroupHeading
662
+ ? renderGroupHeading(g.value)
663
+ : g.value}
664
+ </ComboboxGroupLabel>
665
+ <Combobox.Collection>
666
+ {(item: T) => (
667
+ <ComboboxItem
668
+ key={item.id}
669
+ value={item}
670
+ itemId={String(item.id)}
671
+ label={itemToString(item)}
672
+ disabled={item.disabled}
673
+ inputType={inputType}
674
+ renderItem={
675
+ renderItem
676
+ ? () => renderItem(item)
677
+ : () => itemToString(item)
678
+ }
679
+ />
680
+ )}
681
+ </Combobox.Collection>
682
+ </ComboboxGroup>
683
+ {index < groups.length - 1 && <ComboboxSeparator />}
684
+ </React.Fragment>
685
+ );
686
+ }
534
687
  ) : (
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
- )
688
+ (item: T | CreatableSentinel) => {
689
+ if (isCreatableSentinel(item)) {
690
+ return renderCreatableItem(item);
691
+ }
692
+ const dataItem = item as T;
693
+ return (
694
+ <ComboboxItem
695
+ key={dataItem.id}
696
+ value={dataItem}
697
+ itemId={String(dataItem.id)}
698
+ label={itemToString(dataItem)}
699
+ disabled={dataItem.disabled}
700
+ inputType={inputType}
701
+ renderItem={
702
+ renderItem
703
+ ? () => renderItem(dataItem)
704
+ : () => itemToString(dataItem)
705
+ }
706
+ />
707
+ );
708
+ }
550
709
  )}
551
710
  </SearchableSelectList>
552
711
  </SearchableSelectPopup>
@@ -266,6 +266,23 @@ export const RemoveSelectedItems: Story = {
266
266
  ),
267
267
  };
268
268
 
269
+ export const Inline: Story = {
270
+ name: "Inline (fullWidth=false)",
271
+ render: () => (
272
+ <FormField label="Pick books">
273
+ {({ id }) => (
274
+ <MultiSelect
275
+ id={id}
276
+ data={books}
277
+ itemToString={itemToString}
278
+ placeholder="Select books..."
279
+ fullWidth={false}
280
+ />
281
+ )}
282
+ </FormField>
283
+ ),
284
+ };
285
+
269
286
  export const Children: Story = {
270
287
  name: "Children API",
271
288
  render: () => (
@@ -44,6 +44,7 @@ interface MultiSelectBaseProps {
44
44
  placeholder?: string;
45
45
  disabled?: boolean;
46
46
  "aria-describedby"?: string;
47
+ fullWidth?: boolean;
47
48
  }
48
49
 
49
50
  interface MultiSelectDataProps<T extends DataWithId>
@@ -88,7 +89,7 @@ export type MultiSelectProps<T extends DataWithId> =
88
89
  // ─── Component ────────────────────────────────────────────────────────────────
89
90
 
90
91
  export function MultiSelect<T extends DataWithId>(props: MultiSelectProps<T>) {
91
- const { id, placeholder = "Select..." } = props;
92
+ const { id, placeholder = "Select...", fullWidth = true } = props;
92
93
  const ariaDescribedBy = props["aria-describedby"];
93
94
 
94
95
  const isChildrenMode = "children" in props && props.children != null;
@@ -171,7 +172,7 @@ export function MultiSelect<T extends DataWithId>(props: MultiSelectProps<T>) {
171
172
  }
172
173
 
173
174
  return (
174
- <Field>
175
+ <Field $fullWidth={fullWidth}>
175
176
  <div ref={containerRef} style={{ position: "relative" }} />
176
177
  <Select.Root
177
178
  multiple
@@ -192,6 +192,23 @@ export const Virtualized: Story = {
192
192
  ),
193
193
  };
194
194
 
195
+ export const Inline: Story = {
196
+ name: "Inline (fullWidth=false)",
197
+ render: () => (
198
+ <FormField label="Pick a book">
199
+ {({ id }) => (
200
+ <SingleCombobox
201
+ id={id}
202
+ data={books}
203
+ itemToString={itemToString}
204
+ placeholder="Search books..."
205
+ fullWidth={false}
206
+ />
207
+ )}
208
+ </FormField>
209
+ ),
210
+ };
211
+
195
212
  export const CustomSearch: Story = {
196
213
  name: "Custom search (title + author)",
197
214
  render: () => (
@@ -32,6 +32,7 @@ interface SingleComboboxBaseProps {
32
32
  disabled?: boolean;
33
33
  filter?: ((item: unknown, query: string) => boolean) | null;
34
34
  "aria-describedby"?: string;
35
+ fullWidth?: boolean;
35
36
  }
36
37
 
37
38
  interface SingleComboboxDataBaseProps<T extends DataWithId>
@@ -89,6 +90,7 @@ export function SingleCombobox<T extends DataWithId>(
89
90
  placeholder = "Search...",
90
91
  emptyText = "No results found.",
91
92
  filter,
93
+ fullWidth = true,
92
94
  } = props;
93
95
  const ariaDescribedBy = props["aria-describedby"];
94
96
 
@@ -168,7 +170,7 @@ export function SingleCombobox<T extends DataWithId>(
168
170
  );
169
171
 
170
172
  return (
171
- <Field>
173
+ <Field $fullWidth={fullWidth}>
172
174
  <div ref={containerRef} style={{ position: "relative" }} />
173
175
  <Combobox.Root
174
176
  id={id}
@@ -304,6 +304,24 @@ export const LoadingWithToggle: Story = {
304
304
  },
305
305
  };
306
306
 
307
+ export const Inline: Story = {
308
+ name: "Inline (fullWidth=false)",
309
+ render: () => (
310
+ <FormField label="Pick a book">
311
+ {({ id }) => (
312
+ <SingleSearchableSelect
313
+ id={id}
314
+ data={books}
315
+ itemToString={itemToString}
316
+ placeholder="Select a book"
317
+ searchPlaceholder="Search books..."
318
+ fullWidth={false}
319
+ />
320
+ )}
321
+ </FormField>
322
+ ),
323
+ };
324
+
307
325
  export const CustomSearch: Story = {
308
326
  name: "Custom search (title + author)",
309
327
  render: () => (
@@ -42,6 +42,7 @@ interface SingleSearchableSelectBaseProps {
42
42
  disabled?: boolean;
43
43
  filter?: ((item: unknown, query: string) => boolean) | null;
44
44
  "aria-describedby"?: string;
45
+ fullWidth?: boolean;
45
46
  }
46
47
 
47
48
  interface SingleSearchableSelectDataBaseProps<T extends DataWithId>
@@ -106,6 +107,7 @@ export function SingleSearchableSelect<T extends DataWithId>(
106
107
  isLoading = false,
107
108
  loadingText = "Loading...",
108
109
  filter,
110
+ fullWidth = true,
109
111
  } = props;
110
112
  const ariaDescribedBy = props["aria-describedby"];
111
113
 
@@ -190,7 +192,7 @@ export function SingleSearchableSelect<T extends DataWithId>(
190
192
  );
191
193
 
192
194
  return (
193
- <Field>
195
+ <Field $fullWidth={fullWidth}>
194
196
  <div ref={containerRef} style={{ position: "relative" }} />
195
197
  <Combobox.Root
196
198
  id={id}
@@ -259,6 +259,23 @@ export const DisabledItems: Story = {
259
259
  ),
260
260
  };
261
261
 
262
+ export const Inline: Story = {
263
+ name: "Inline (fullWidth=false)",
264
+ render: () => (
265
+ <FormField label="Pick a book">
266
+ {({ id }) => (
267
+ <SingleSelect
268
+ id={id}
269
+ data={books}
270
+ itemToString={itemToString}
271
+ placeholder="Select a book"
272
+ fullWidth={false}
273
+ />
274
+ )}
275
+ </FormField>
276
+ ),
277
+ };
278
+
262
279
  export const Children: Story = {
263
280
  name: "Children API",
264
281
  render: () => (
@@ -40,6 +40,7 @@ interface SingleSelectBaseProps {
40
40
  includePlaceholderItem?: boolean;
41
41
  disabled?: boolean;
42
42
  "aria-describedby"?: string;
43
+ fullWidth?: boolean;
43
44
  }
44
45
 
45
46
  interface SingleSelectDataProps<T extends DataWithId>
@@ -80,7 +81,7 @@ export type SingleSelectProps<T extends DataWithId> =
80
81
  export function SingleSelect<T extends DataWithId>(
81
82
  props: SingleSelectProps<T>
82
83
  ) {
83
- const { id, placeholder, includePlaceholderItem } = props;
84
+ const { id, placeholder, includePlaceholderItem, fullWidth = true } = props;
84
85
  const ariaDescribedBy = props["aria-describedby"];
85
86
 
86
87
  const isChildrenMode = "children" in props && props.children != null;
@@ -159,7 +160,7 @@ export function SingleSelect<T extends DataWithId>(
159
160
  }
160
161
 
161
162
  return (
162
- <Field>
163
+ <Field $fullWidth={fullWidth}>
163
164
  <div ref={containerRef} style={{ position: "relative" }} />
164
165
  <Select.Root
165
166
  items={items}
package/src/v3/index.ts CHANGED
@@ -5,3 +5,4 @@ export * from "./MultiCombobox";
5
5
  export * from "./SingleSearchableSelect";
6
6
  export * from "./MultiSearchableSelect";
7
7
  export * from "./Common/ComboboxStyles";
8
+ export * from "./ActionMenu";