@sproutsocial/seeds-react-menu 1.10.9 → 1.11.1

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 (42) hide show
  1. package/.turbo/turbo-build.log +17 -11
  2. package/CHANGELOG.md +13 -0
  3. package/dist/esm/v2/index.js +1 -1
  4. package/dist/esm/v2/index.js.map +1 -1
  5. package/dist/esm/v3/index.js +800 -0
  6. package/dist/esm/v3/index.js.map +1 -0
  7. package/dist/index.d.mts +22 -22
  8. package/dist/index.d.ts +22 -22
  9. package/dist/v2/index.js +1 -1
  10. package/dist/v2/index.js.map +1 -1
  11. package/dist/v3/index.d.mts +295 -0
  12. package/dist/v3/index.d.ts +295 -0
  13. package/dist/v3/index.js +868 -0
  14. package/dist/v3/index.js.map +1 -0
  15. package/package.json +9 -3
  16. package/src/v2/Common-V2/ComboboxContent.tsx +1 -1
  17. package/src/v3/Common/ComboboxItem.tsx +82 -0
  18. package/src/v3/Common/ComboboxStyles.tsx +475 -0
  19. package/src/v3/Common/SelectGroup.tsx +28 -0
  20. package/src/v3/Common/SelectIcons.tsx +26 -0
  21. package/src/v3/Common/SelectItem.tsx +108 -0
  22. package/src/v3/Common/SelectStyles.tsx +126 -0
  23. package/src/v3/Common/VirtualizedComboboxList.tsx +200 -0
  24. package/src/v3/Common/groupItems.ts +12 -0
  25. package/src/v3/Common/types.ts +3 -0
  26. package/src/v3/ModalStories.stories.tsx +181 -0
  27. package/src/v3/MultiCombobox.stories.tsx +322 -0
  28. package/src/v3/MultiCombobox.tsx +562 -0
  29. package/src/v3/MultiSearchableSelect.stories.tsx +389 -0
  30. package/src/v3/MultiSearchableSelect.tsx +545 -0
  31. package/src/v3/MultiSelect.stories.tsx +300 -0
  32. package/src/v3/MultiSelect.tsx +498 -0
  33. package/src/v3/SeedsPortal.tsx +35 -0
  34. package/src/v3/SingleCombobox.stories.tsx +169 -0
  35. package/src/v3/SingleCombobox.tsx +304 -0
  36. package/src/v3/SingleSearchableSelect.stories.tsx +239 -0
  37. package/src/v3/SingleSearchableSelect.tsx +319 -0
  38. package/src/v3/SingleSelect.stories.tsx +227 -0
  39. package/src/v3/SingleSelect.tsx +245 -0
  40. package/src/v3/index.ts +7 -0
  41. package/src/v3/storyData.tsx +117 -0
  42. package/tsup.config.ts +1 -0
@@ -0,0 +1,545 @@
1
+ import * as React from "react";
2
+ import { Combobox } from "@base-ui/react/combobox";
3
+ import { useVirtualizer } from "@tanstack/react-virtual";
4
+ import VirtualizedComboboxList from "./Common/VirtualizedComboboxList";
5
+ import { useSeedsPortalContainer } from "./SeedsPortal";
6
+ import ComboboxItem from "./Common/ComboboxItem";
7
+ import type { DataWithId } from "./Common/types";
8
+ import { groupItems } from "./Common/groupItems";
9
+ import { Field } from "./Common/SelectStyles";
10
+ import {
11
+ ComboboxGroup,
12
+ ComboboxGroupLabel,
13
+ ComboboxSeparator,
14
+ ComboboxPositioner,
15
+ ComboboxEmpty,
16
+ ComboboxStatus,
17
+ ComboboxGroupHeaderItem,
18
+ ComboboxGroupHeaderCheckbox,
19
+ ComboboxSelectAllItem,
20
+ SearchableSelectTokenTrigger,
21
+ SearchableSelectPlaceholder,
22
+ SearchableSelectTriggerIcon,
23
+ SearchableSelectPopup,
24
+ SearchableSelectSearchContainer,
25
+ SearchableSelectInput,
26
+ SearchableSelectList,
27
+ } from "./Common/ComboboxStyles";
28
+ import { ClearIcon, StyledChevron, ChevronIcon } from "./Common/SelectIcons";
29
+ import styled from "styled-components";
30
+
31
+ // ─── Styled components ────────────────────────────────────────────────────────
32
+
33
+ const SelectionText = styled.span`
34
+ flex: 1;
35
+ overflow: hidden;
36
+ text-overflow: ellipsis;
37
+ white-space: nowrap;
38
+ `;
39
+
40
+ // ─── Sentinels ────────────────────────────────────────────────────────────────
41
+
42
+ interface GroupHeaderSentinel {
43
+ __groupHeader: true;
44
+ groupName: string;
45
+ }
46
+
47
+ function makeGroupHeaderSentinel(groupName: string): GroupHeaderSentinel {
48
+ return { __groupHeader: true, groupName };
49
+ }
50
+
51
+ function isGroupHeaderSentinel(v: unknown): v is GroupHeaderSentinel {
52
+ return typeof v === "object" && v !== null && "__groupHeader" in v;
53
+ }
54
+
55
+ interface SelectAllSentinel {
56
+ __selectAll: true;
57
+ }
58
+
59
+ const SELECT_ALL_SENTINEL: SelectAllSentinel = { __selectAll: true };
60
+
61
+ function isSelectAllSentinel(v: unknown): v is SelectAllSentinel {
62
+ return typeof v === "object" && v !== null && "__selectAll" in v;
63
+ }
64
+
65
+ // ─── Props ────────────────────────────────────────────────────────────────────
66
+
67
+ interface MultiSearchableSelectBaseProps {
68
+ id?: string;
69
+ placeholder?: string;
70
+ searchPlaceholder?: string;
71
+ emptyText?: string;
72
+ isLoading?: boolean;
73
+ loadingText?: string;
74
+ }
75
+
76
+ interface MultiSearchableSelectDataProps<T extends DataWithId>
77
+ extends MultiSearchableSelectBaseProps {
78
+ data: T[];
79
+ itemToString: (item: T) => string;
80
+ renderItem?: (item: T) => React.ReactNode;
81
+ customRenderSelections?: (item: T) => string;
82
+ renderSelection?: (items: T[]) => React.ReactNode;
83
+ inputType?: "icon" | "checkbox" | "none";
84
+ maxSelections?: number;
85
+ selectedItemIds?: Array<T["id"]>;
86
+ defaultSelectedItemIds?: Array<T["id"]>;
87
+ onSelectedItemIdsChange?: (ids: Array<T["id"]>) => void;
88
+ groupBy?: (item: T) => string;
89
+ renderGroupHeading?: (label: string) => React.ReactNode;
90
+ selectHeadings?: boolean;
91
+ selectAll?: boolean;
92
+ isVirtualized?: boolean;
93
+ removeSelectedItems?: boolean;
94
+ children?: never;
95
+ }
96
+
97
+ interface MultiSearchableSelectChildrenProps
98
+ extends MultiSearchableSelectBaseProps {
99
+ children: React.ReactNode;
100
+ selectedItemIds?: Array<string | number>;
101
+ defaultSelectedItemIds?: Array<string | number>;
102
+ onSelectedItemIdsChange?: (ids: Array<string | number>) => void;
103
+ data?: never;
104
+ itemToString?: never;
105
+ renderItem?: never;
106
+ customRenderSelections?: never;
107
+ renderSelection?: never;
108
+ inputType?: never;
109
+ maxSelections?: never;
110
+ groupBy?: never;
111
+ renderGroupHeading?: never;
112
+ selectHeadings?: never;
113
+ selectAll?: never;
114
+ isVirtualized?: never;
115
+ removeSelectedItems?: never;
116
+ }
117
+
118
+ export type MultiSearchableSelectProps<T extends DataWithId> =
119
+ | MultiSearchableSelectDataProps<T>
120
+ | MultiSearchableSelectChildrenProps;
121
+
122
+ // ─── Component ────────────────────────────────────────────────────────────────
123
+
124
+ export default function MultiSearchableSelect<T extends DataWithId>(
125
+ props: MultiSearchableSelectProps<T>
126
+ ) {
127
+ const {
128
+ id,
129
+ placeholder = "Select...",
130
+ searchPlaceholder = "Search...",
131
+ emptyText = "No results found.",
132
+ isLoading = false,
133
+ loadingText = "Loading...",
134
+ } = props;
135
+
136
+ const isChildrenMode = "children" in props && props.children != null;
137
+
138
+ const data = isChildrenMode
139
+ ? ([] as T[])
140
+ : (props as MultiSearchableSelectDataProps<T>).data;
141
+ const itemToString = isChildrenMode
142
+ ? (_item: T) => ""
143
+ : (props as MultiSearchableSelectDataProps<T>).itemToString;
144
+ const renderItem = isChildrenMode
145
+ ? undefined
146
+ : (props as MultiSearchableSelectDataProps<T>).renderItem;
147
+ const customRenderSelections = isChildrenMode
148
+ ? undefined
149
+ : (props as MultiSearchableSelectDataProps<T>).customRenderSelections;
150
+ const maxSelections = isChildrenMode
151
+ ? undefined
152
+ : (props as MultiSearchableSelectDataProps<T>).maxSelections;
153
+ const renderSelection = isChildrenMode
154
+ ? undefined
155
+ : (props as MultiSearchableSelectDataProps<T>).renderSelection;
156
+ const removeSelectedItems = isChildrenMode
157
+ ? false
158
+ : (props as MultiSearchableSelectDataProps<T>).removeSelectedItems ?? false;
159
+ const inputType = isChildrenMode
160
+ ? "checkbox"
161
+ : removeSelectedItems
162
+ ? "none"
163
+ : (props as MultiSearchableSelectDataProps<T>).inputType ?? "checkbox";
164
+ const groupBy = isChildrenMode
165
+ ? undefined
166
+ : (props as MultiSearchableSelectDataProps<T>).groupBy;
167
+ const renderGroupHeading = isChildrenMode
168
+ ? undefined
169
+ : (props as MultiSearchableSelectDataProps<T>).renderGroupHeading;
170
+ const selectHeadings = isChildrenMode
171
+ ? false
172
+ : (props as MultiSearchableSelectDataProps<T>).selectHeadings ?? false;
173
+ const selectAll = isChildrenMode
174
+ ? false
175
+ : (props as MultiSearchableSelectDataProps<T>).selectAll ?? false;
176
+ const isVirtualized = isChildrenMode
177
+ ? false
178
+ : (props as MultiSearchableSelectDataProps<T>).isVirtualized ?? false;
179
+ const children = isChildrenMode
180
+ ? (props as MultiSearchableSelectChildrenProps).children
181
+ : undefined;
182
+
183
+ const selectedItemIds = props.selectedItemIds;
184
+ const defaultSelectedItemIds = props.defaultSelectedItemIds;
185
+ const onSelectedItemIdsChange = props.onSelectedItemIdsChange;
186
+
187
+ const { containerRef, portalContainer } = useSeedsPortalContainer();
188
+ const [open, setOpen] = React.useState(false);
189
+ const virtualizerRef = React.useRef<ReturnType<
190
+ typeof useVirtualizer<HTMLDivElement, Element>
191
+ > | null>(null);
192
+
193
+ const isControlled = selectedItemIds !== undefined;
194
+
195
+ const idsToItems = React.useCallback(
196
+ (ids: Array<T["id"]>): T[] =>
197
+ ids
198
+ .map((id) => data.find((d) => d.id === id))
199
+ .filter((d): d is T => d != null),
200
+ [data]
201
+ );
202
+
203
+ const [internalValue, setInternalValue] = React.useState<T[]>(() =>
204
+ defaultSelectedItemIds
205
+ ? idsToItems(defaultSelectedItemIds as Array<T["id"]>)
206
+ : []
207
+ );
208
+
209
+ const value = isControlled
210
+ ? idsToItems(selectedItemIds as Array<T["id"]>)
211
+ : internalValue;
212
+
213
+ const visibleData = React.useMemo(
214
+ () =>
215
+ removeSelectedItems
216
+ ? data.filter((item) => !value.some((v) => v.id === item.id))
217
+ : data,
218
+ [data, value, removeSelectedItems]
219
+ );
220
+
221
+ const groups = React.useMemo(
222
+ () => (groupBy ? groupItems(visibleData, groupBy) : null),
223
+ [visibleData, groupBy]
224
+ );
225
+
226
+ // ─── Sentinel flat list ───────────────────────────────────────────────────
227
+
228
+ type FlatRow = T | GroupHeaderSentinel | SelectAllSentinel;
229
+
230
+ const flatItems = React.useMemo<FlatRow[] | null>(() => {
231
+ if (!groups || (!selectHeadings && !selectAll)) return null;
232
+ const rows: FlatRow[] = [];
233
+ if (selectAll) rows.push(SELECT_ALL_SENTINEL);
234
+ for (const group of groups) {
235
+ rows.push(makeGroupHeaderSentinel(group.value));
236
+ rows.push(...group.items);
237
+ }
238
+ return rows;
239
+ }, [groups, selectHeadings, selectAll]);
240
+
241
+ function setSelectedItems(items: T[]) {
242
+ if (!isControlled) setInternalValue(items);
243
+ if (onSelectedItemIdsChange)
244
+ onSelectedItemIdsChange(items.map((item) => item.id));
245
+ }
246
+
247
+ function handleValueChange(next: FlatRow[]) {
248
+ const selectAllClicked = next.filter(isSelectAllSentinel);
249
+ const groupSentinels = next.filter(isGroupHeaderSentinel);
250
+ const realItems = next.filter(
251
+ (v): v is T => !isGroupHeaderSentinel(v) && !isSelectAllSentinel(v)
252
+ );
253
+
254
+ if (selectAllClicked.length > 0) {
255
+ const allSelected = data.every((d) =>
256
+ realItems.some((s) => s.id === d.id)
257
+ );
258
+ setSelectedItems(allSelected ? [] : [...data]);
259
+ return;
260
+ }
261
+
262
+ if (groupSentinels.length === 0) {
263
+ setSelectedItems(realItems);
264
+ return;
265
+ }
266
+
267
+ let updated = [...realItems];
268
+ for (const sentinel of groupSentinels) {
269
+ const group = groups?.find((g) => g.value === sentinel.groupName);
270
+ if (!group) continue;
271
+ const allSelected = group.items.every((item) =>
272
+ updated.some((s) => s.id === item.id)
273
+ );
274
+ if (allSelected) {
275
+ updated = updated.filter(
276
+ (s) => !group.items.some((item) => item.id === s.id)
277
+ );
278
+ } else {
279
+ const missing = group.items.filter(
280
+ (item) => !updated.some((s) => s.id === item.id)
281
+ );
282
+ updated = [...updated, ...missing];
283
+ }
284
+ }
285
+ setSelectedItems(updated);
286
+ }
287
+
288
+ function handleSimpleValueChange(newItems: T[]) {
289
+ setSelectedItems(newItems);
290
+ }
291
+
292
+ // ─── Render sentinel row ──────────────────────────────────────────────────
293
+
294
+ function renderSentinelRow(row: FlatRow) {
295
+ if (isSelectAllSentinel(row)) {
296
+ const totalCount = data.length;
297
+ const selectedCount = value.length;
298
+ const state: "none" | "partial" | "all" =
299
+ selectedCount === 0
300
+ ? "none"
301
+ : selectedCount === totalCount
302
+ ? "all"
303
+ : "partial";
304
+ return (
305
+ <ComboboxSelectAllItem key="__selectAll" value={row}>
306
+ <ComboboxGroupHeaderCheckbox $state={state} id="__selectAll" />
307
+ Select all
308
+ </ComboboxSelectAllItem>
309
+ );
310
+ }
311
+
312
+ if (isGroupHeaderSentinel(row)) {
313
+ const group = groups?.find((g) => g.value === row.groupName);
314
+ const groupItemsList = group?.items ?? [];
315
+ const selectedCount = groupItemsList.filter((item) =>
316
+ value.some((s) => s.id === item.id)
317
+ ).length;
318
+ const state: "none" | "partial" | "all" =
319
+ selectedCount === 0
320
+ ? "none"
321
+ : selectedCount === groupItemsList.length
322
+ ? "all"
323
+ : "partial";
324
+ return (
325
+ <ComboboxGroupHeaderItem key={`__header-${row.groupName}`} value={row}>
326
+ <ComboboxGroupHeaderCheckbox
327
+ $state={state}
328
+ id={`__header-${row.groupName}`}
329
+ />
330
+ {renderGroupHeading
331
+ ? renderGroupHeading(row.groupName)
332
+ : row.groupName}
333
+ </ComboboxGroupHeaderItem>
334
+ );
335
+ }
336
+
337
+ const item = row as T;
338
+ return (
339
+ <ComboboxItem
340
+ key={item.id}
341
+ value={item}
342
+ itemId={String(item.id)}
343
+ label={itemToString(item)}
344
+ inputType={inputType}
345
+ renderItem={
346
+ renderItem ? () => renderItem(item) : () => itemToString(item)
347
+ }
348
+ />
349
+ );
350
+ }
351
+
352
+ const rootItems =
353
+ flatItems ?? groups ?? (isChildrenMode ? undefined : visibleData);
354
+ const hasSentinels = selectAll || selectHeadings;
355
+ const rootValue = hasSentinels
356
+ ? ([...value] as FlatRow[])
357
+ : (value as unknown as T[]);
358
+
359
+ return (
360
+ <Field>
361
+ <div ref={containerRef} style={{ position: "relative" }} />
362
+ <Combobox.Root
363
+ multiple
364
+ id={id}
365
+ virtualized={isVirtualized}
366
+ open={open}
367
+ onOpenChange={setOpen}
368
+ onItemHighlighted={
369
+ isVirtualized
370
+ ? (item, { reason, index }) => {
371
+ const virt = virtualizerRef.current;
372
+ if (!item || !virt) return;
373
+ const isStart = index === 0;
374
+ const isEnd = index === virt.options.count - 1;
375
+ if (
376
+ reason === "none" ||
377
+ (reason === "keyboard" && (isStart || isEnd))
378
+ ) {
379
+ queueMicrotask(() => {
380
+ virt.scrollToIndex(index, {
381
+ align: isEnd ? "start" : "end",
382
+ });
383
+ });
384
+ }
385
+ }
386
+ : undefined
387
+ }
388
+ value={rootValue}
389
+ onValueChange={
390
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
391
+ (hasSentinels ? handleValueChange : handleSimpleValueChange) as any
392
+ }
393
+ itemToStringLabel={(item: FlatRow | null) => {
394
+ if (!item || isGroupHeaderSentinel(item) || isSelectAllSentinel(item))
395
+ return "";
396
+ return itemToString(item as T);
397
+ }}
398
+ isItemEqualToValue={(a: FlatRow, b: FlatRow) => {
399
+ if (isGroupHeaderSentinel(a) && isGroupHeaderSentinel(b))
400
+ return a.groupName === b.groupName;
401
+ if (isSelectAllSentinel(a) && isSelectAllSentinel(b)) return true;
402
+ if (
403
+ !isGroupHeaderSentinel(a) &&
404
+ !isSelectAllSentinel(a) &&
405
+ !isGroupHeaderSentinel(b) &&
406
+ !isSelectAllSentinel(b)
407
+ )
408
+ return (a as T).id === (b as T).id;
409
+ return false;
410
+ }}
411
+ items={rootItems}
412
+ >
413
+ <SearchableSelectTokenTrigger id={id}>
414
+ {renderSelection ? (
415
+ renderSelection(value)
416
+ ) : value.length > 0 ? (
417
+ (() => {
418
+ const visibleItems =
419
+ maxSelections != null ? value.slice(0, maxSelections) : value;
420
+ const overflowCount =
421
+ maxSelections != null && value.length > maxSelections
422
+ ? value.length - maxSelections
423
+ : 0;
424
+ const text = visibleItems
425
+ .map((item) =>
426
+ customRenderSelections
427
+ ? customRenderSelections(item)
428
+ : itemToString(item)
429
+ )
430
+ .join(", ");
431
+ return (
432
+ <SelectionText>
433
+ {text}
434
+ {overflowCount > 0 ? `, +${overflowCount}` : ""}
435
+ </SelectionText>
436
+ );
437
+ })()
438
+ ) : (
439
+ <SearchableSelectPlaceholder>
440
+ {placeholder}
441
+ </SearchableSelectPlaceholder>
442
+ )}
443
+ <SearchableSelectTriggerIcon style={{ marginLeft: "auto" }}>
444
+ <StyledChevron data-chevron>
445
+ <ChevronIcon />
446
+ </StyledChevron>
447
+ </SearchableSelectTriggerIcon>
448
+ </SearchableSelectTokenTrigger>
449
+
450
+ <Combobox.Portal container={portalContainer}>
451
+ <ComboboxPositioner sideOffset={4}>
452
+ <SearchableSelectPopup
453
+ aria-busy={isLoading || undefined}
454
+ style={{ display: "flex", flexDirection: "column" }}
455
+ >
456
+ <SearchableSelectSearchContainer>
457
+ <SearchableSelectInput
458
+ placeholder={searchPlaceholder}
459
+ autoFocus
460
+ />
461
+ </SearchableSelectSearchContainer>
462
+ <ComboboxStatus>{isLoading ? loadingText : null}</ComboboxStatus>
463
+ <ComboboxEmpty>{isLoading ? null : emptyText}</ComboboxEmpty>
464
+ <SearchableSelectList
465
+ style={
466
+ isVirtualized
467
+ ? {
468
+ padding: 0,
469
+ flex: 1,
470
+ minHeight: 0,
471
+ display: "flex",
472
+ flexDirection: "column",
473
+ }
474
+ : undefined
475
+ }
476
+ >
477
+ {isVirtualized ? (
478
+ <VirtualizedComboboxList
479
+ open={open}
480
+ virtualizerRef={virtualizerRef}
481
+ value={value}
482
+ groups={groups}
483
+ data={visibleData}
484
+ itemToString={itemToString}
485
+ inputType={inputType}
486
+ renderItem={renderItem}
487
+ renderGroupHeading={renderGroupHeading}
488
+ />
489
+ ) : children ? (
490
+ children
491
+ ) : flatItems ? (
492
+ (row: FlatRow) => renderSentinelRow(row)
493
+ ) : groups ? (
494
+ (group: { value: string; items: T[] }, index: number) => (
495
+ <React.Fragment key={group.value}>
496
+ <ComboboxGroup items={group.items}>
497
+ <ComboboxGroupLabel>
498
+ {renderGroupHeading
499
+ ? renderGroupHeading(group.value)
500
+ : group.value}
501
+ </ComboboxGroupLabel>
502
+ <Combobox.Collection>
503
+ {(item: T) => (
504
+ <ComboboxItem
505
+ key={item.id}
506
+ value={item}
507
+ itemId={String(item.id)}
508
+ label={itemToString(item)}
509
+ inputType={inputType}
510
+ renderItem={
511
+ renderItem
512
+ ? () => renderItem(item)
513
+ : () => itemToString(item)
514
+ }
515
+ />
516
+ )}
517
+ </Combobox.Collection>
518
+ </ComboboxGroup>
519
+ {index < groups.length - 1 && <ComboboxSeparator />}
520
+ </React.Fragment>
521
+ )
522
+ ) : (
523
+ (item: T) => (
524
+ <ComboboxItem
525
+ key={item.id}
526
+ value={item}
527
+ itemId={String(item.id)}
528
+ label={itemToString(item)}
529
+ inputType={inputType}
530
+ renderItem={
531
+ renderItem
532
+ ? () => renderItem(item)
533
+ : () => itemToString(item)
534
+ }
535
+ />
536
+ )
537
+ )}
538
+ </SearchableSelectList>
539
+ </SearchableSelectPopup>
540
+ </ComboboxPositioner>
541
+ </Combobox.Portal>
542
+ </Combobox.Root>
543
+ </Field>
544
+ );
545
+ }