@sproutsocial/seeds-react-menu 1.7.1 → 1.7.5

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 (47) hide show
  1. package/.turbo/turbo-build.log +19 -11
  2. package/BUNDLE_OPTIMIZATION.md +307 -0
  3. package/CHANGELOG.md +51 -0
  4. package/dist/esm/chunk-ROQATIB7.js +357 -0
  5. package/dist/esm/chunk-ROQATIB7.js.map +1 -0
  6. package/dist/esm/index.js +261 -2271
  7. package/dist/esm/index.js.map +1 -1
  8. package/dist/esm/v2/index.js +2000 -0
  9. package/dist/esm/v2/index.js.map +1 -0
  10. package/dist/index.d.mts +1 -0
  11. package/dist/index.d.ts +1 -0
  12. package/dist/index.js +297 -1989
  13. package/dist/index.js.map +1 -1
  14. package/dist/v2/index.d.mts +96 -0
  15. package/dist/v2/index.d.ts +96 -0
  16. package/dist/v2/index.js +2191 -0
  17. package/dist/v2/index.js.map +1 -0
  18. package/package.json +30 -14
  19. package/src/MenuItem/styles.tsx +7 -0
  20. package/src/v2/Autocomplete/Autocomplete.stories.tsx +609 -0
  21. package/src/v2/Autocomplete/MultiAutocomplete.tsx +453 -0
  22. package/src/v2/Autocomplete/SingleAutocomplete.tsx +362 -0
  23. package/src/v2/Autocomplete/index.ts +2 -0
  24. package/src/v2/Common-V2/ComboboxContent.tsx +448 -0
  25. package/src/v2/Common-V2/MenuGroup.tsx +60 -0
  26. package/src/v2/Common-V2/MenuGroupTypes.ts +30 -0
  27. package/src/v2/Common-V2/MenuHeading.tsx +83 -0
  28. package/src/v2/Common-V2/MenuItem.tsx +138 -0
  29. package/src/v2/Common-V2/Popout.tsx +102 -0
  30. package/src/v2/Common-V2/PopoutTypes.tsx +92 -0
  31. package/src/v2/Common-V2/SelectToggleButton.tsx +99 -0
  32. package/src/v2/Common-V2/index.ts +11 -0
  33. package/src/v2/Common-V2/props.ts +74 -0
  34. package/src/v2/Common-V2/styles.tsx +41 -0
  35. package/src/v2/Common-V2/types.ts +16 -0
  36. package/src/v2/Common-V2/useHighlightedIndex.ts +62 -0
  37. package/src/v2/Common-V2/utils.ts +238 -0
  38. package/src/v2/SearchableSelect/AutocompleteContent.tsx +325 -0
  39. package/src/v2/SearchableSelect/SearchableMultiSelect.tsx +521 -0
  40. package/src/v2/SearchableSelect/SearchableSelect.tsx +389 -0
  41. package/src/v2/SearchableSelect/index.ts +12 -0
  42. package/src/v2/Select/MultiSelect.tsx +404 -0
  43. package/src/v2/Select/Select.stories.tsx +593 -0
  44. package/src/v2/Select/SingleSelect.tsx +272 -0
  45. package/src/v2/Select/index.ts +2 -0
  46. package/src/v2/index.ts +2 -0
  47. package/tsup.config.ts +14 -5
@@ -0,0 +1,521 @@
1
+ import { useState, useMemo } from "react";
2
+ import { useCombobox, useMultipleSelection } from "downshift";
3
+ import type { ReactNode } from "react";
4
+ import { Label } from "@sproutsocial/seeds-react-label";
5
+ import { Input } from "@sproutsocial/seeds-react-input";
6
+ // This will eventually be imported from seeds-react-popout
7
+ import { Popout, SelectToggleButton } from "../Common-V2";
8
+ import {
9
+ VirtualizedAutocompleteContent,
10
+ NonVirtualizedAutocompleteContent,
11
+ } from "./AutocompleteContent";
12
+ import {
13
+ isGroupHeading,
14
+ isToggleItem,
15
+ menuAnimationConfig,
16
+ getFilteredItems,
17
+ groupItemsWithHeadings,
18
+ useHighlightedIndex,
19
+ createItemToStringForDownshift,
20
+ } from "../Common-V2";
21
+ import {
22
+ type DataWithId,
23
+ type GroupedItem,
24
+ type ToggleItem,
25
+ type GroupHeading,
26
+ type BaseMultiAutocompleteProps,
27
+ } from "../Common-V2";
28
+
29
+ function defaultReducer(state, actionAndChanges) {
30
+ const { changes, type } = actionAndChanges;
31
+
32
+ switch (type) {
33
+ case useCombobox.stateChangeTypes.InputKeyDownEnter:
34
+ case useCombobox.stateChangeTypes.ItemClick:
35
+ // If the item is already selected, don't set highlighted index to 0
36
+ // We don't have access to selectedItems in the reducer
37
+ return {
38
+ ...changes,
39
+ isOpen: true, // keep the menu open after selection.
40
+ highlightedIndex: 0, // with the first option highlighted.
41
+ };
42
+ default:
43
+ return changes;
44
+ }
45
+ }
46
+
47
+ export interface SearchableMultiSelectProps<T extends DataWithId>
48
+ extends BaseMultiAutocompleteProps<T> {
49
+ renderSelections?: (items: T[]) => ReactNode;
50
+ name: string;
51
+ triggerButtonProps?: any;
52
+ }
53
+
54
+ export function SearchableMultiSelect<T extends DataWithId>({
55
+ id,
56
+ data,
57
+ selectedItemIds: controlledSelectedItemIds,
58
+ inputValue: controlledInputValue,
59
+ isOpen: controlledIsOpen,
60
+ itemToKey = (item: T) => item?.id,
61
+ itemToString,
62
+ itemToSearchString,
63
+ renderSelections,
64
+ renderItem,
65
+ groupBy,
66
+ renderGroupHeading,
67
+ isVirtualized = false,
68
+ showSelectedItems = false,
69
+ selectHeadings = false,
70
+ selectAllItem,
71
+ EmptyState,
72
+ name,
73
+ placeholder,
74
+ onSelectedItemIdsChange,
75
+ onInputValueChange,
76
+ onIsOpenChange,
77
+ stateReducer = defaultReducer,
78
+ triggerButtonProps,
79
+ }: SearchableMultiSelectProps<T>) {
80
+ const [uncontrolledInputValue, setUncontrolledInputValue] =
81
+ useState<string>("");
82
+ const [uncontrolledSelectedItemIds, setUncontrolledSelectedItemIds] =
83
+ useState<T["id"][]>([]);
84
+ const [uncontrolledIsOpen, setUncontrolledIsOpen] = useState<boolean>(false);
85
+
86
+ // Use controlled selectedItemIds if provided, otherwise use internal state
87
+ const isSelectedItemIdsControlled = controlledSelectedItemIds !== undefined;
88
+ const selectedItemIds = isSelectedItemIdsControlled
89
+ ? controlledSelectedItemIds
90
+ : uncontrolledSelectedItemIds;
91
+
92
+ // Convert selectedItemIds to selectedItems for internal use
93
+ const selectedItems = useMemo<T[]>(() => {
94
+ return data.filter((item) => selectedItemIds.includes(itemToKey(item)));
95
+ }, [selectedItemIds, data, itemToKey]);
96
+
97
+ // Use controlled inputValue if provided, otherwise use internal state
98
+ const isInputValueControlled = controlledInputValue !== undefined;
99
+ const inputValue = isInputValueControlled
100
+ ? controlledInputValue
101
+ : uncontrolledInputValue;
102
+
103
+ // Use controlled isOpen if provided, otherwise use internal state
104
+ const isIsOpenControlled = controlledIsOpen !== undefined;
105
+ const isOpen = isIsOpenControlled ? controlledIsOpen : uncontrolledIsOpen;
106
+
107
+ const updateSelectedItems = (newSelectedItems: T[]) => {
108
+ const newSelectedItemIds = newSelectedItems.map((item) => itemToKey(item));
109
+ if (!isSelectedItemIdsControlled) {
110
+ setUncontrolledSelectedItemIds(newSelectedItemIds);
111
+ }
112
+ onSelectedItemIdsChange?.(newSelectedItemIds);
113
+ };
114
+
115
+ const updateInputValue = (newInputValue: string) => {
116
+ if (!isInputValueControlled) {
117
+ setUncontrolledInputValue(newInputValue);
118
+ }
119
+ onInputValueChange?.(newInputValue);
120
+ };
121
+
122
+ const updateIsOpen = (newIsOpen: boolean) => {
123
+ if (!isIsOpenControlled) {
124
+ setUncontrolledIsOpen(newIsOpen);
125
+ }
126
+ onIsOpenChange?.(newIsOpen);
127
+ };
128
+
129
+ // Use itemToSearchString if provided, otherwise fallback to itemToString
130
+ const searchStringFn =
131
+ itemToSearchString ?? ((item: T) => itemToString(item));
132
+
133
+ const filteredItems = useMemo<T[]>(
134
+ () =>
135
+ getFilteredItems(data, inputValue, searchStringFn, itemToString, {
136
+ selectedItems,
137
+ showSelectedItems,
138
+ }),
139
+ [
140
+ data,
141
+ selectedItems,
142
+ inputValue,
143
+ searchStringFn,
144
+ showSelectedItems,
145
+ itemToString,
146
+ ]
147
+ );
148
+
149
+ const items = useMemo<GroupedItem<T>[]>(() => {
150
+ let groupedItems: GroupedItem<T>[];
151
+
152
+ if (groupBy) {
153
+ // Group first, then add selectAllItem at the beginning
154
+ groupedItems = groupItemsWithHeadings(filteredItems, groupBy, data);
155
+ } else {
156
+ groupedItems = filteredItems;
157
+ }
158
+
159
+ // Add selectAllItem at the beginning if provided
160
+ if (selectAllItem) {
161
+ return [selectAllItem, ...groupedItems];
162
+ }
163
+
164
+ return groupedItems;
165
+ }, [filteredItems, groupBy, selectAllItem, data]);
166
+
167
+ // Handler for heading clicks - selects/deselects all items in a group
168
+ const handleHeadingClick = useMemo(() => {
169
+ if (!selectHeadings || !groupBy) {
170
+ return undefined;
171
+ }
172
+
173
+ return (groupLabel: string, allSelected: boolean) => {
174
+ // Find all items in this group from the full data array
175
+ const groupItems = data.filter((item) => groupBy(item) === groupLabel);
176
+
177
+ if (allSelected) {
178
+ // Deselect all items in the group
179
+ const newSelectedItems = selectedItems.filter(
180
+ (item) => !groupItems.includes(item)
181
+ );
182
+ updateSelectedItems(newSelectedItems);
183
+ } else {
184
+ // Select all items in the group (add them if not already selected)
185
+ const newSelectedItems = [...selectedItems];
186
+ for (const groupItem of groupItems) {
187
+ if (!newSelectedItems.includes(groupItem)) {
188
+ newSelectedItems.push(groupItem);
189
+ }
190
+ }
191
+ updateSelectedItems(newSelectedItems);
192
+ }
193
+ };
194
+ }, [selectHeadings, groupBy, data, selectedItems, updateSelectedItems]);
195
+
196
+ const { getSelectedItemProps, getDropdownProps, removeSelectedItem } =
197
+ useMultipleSelection<T>({
198
+ selectedItems,
199
+ onStateChange({ selectedItems: newSelectedItems, type }) {
200
+ switch (type) {
201
+ case useMultipleSelection.stateChangeTypes
202
+ .SelectedItemKeyDownBackspace:
203
+ case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownDelete:
204
+ case useMultipleSelection.stateChangeTypes.DropdownKeyDownBackspace:
205
+ case useMultipleSelection.stateChangeTypes.FunctionRemoveSelectedItem:
206
+ if (newSelectedItems !== undefined) {
207
+ updateSelectedItems(newSelectedItems);
208
+ }
209
+ break;
210
+ default:
211
+ break;
212
+ }
213
+ },
214
+ });
215
+
216
+ // Filter out headings for downshift unless selectHeadings is true
217
+ const itemsForCombobox = useMemo<(T | ToggleItem | GroupHeading)[]>(() => {
218
+ if (selectHeadings) {
219
+ // Include all items (headings, toggle items, and data items)
220
+ return items;
221
+ }
222
+ return items.filter(
223
+ (item): item is T | ToggleItem => !isGroupHeading(item)
224
+ );
225
+ }, [items, selectHeadings]);
226
+
227
+ // Wrapper function to handle ToggleItem and GroupHeading separately
228
+ const itemToStringForDownshift = useMemo(
229
+ () => createItemToStringForDownshift(itemToString),
230
+ [itemToString]
231
+ );
232
+
233
+ const comboboxProps = useCombobox<T | ToggleItem | GroupHeading>({
234
+ inputId: id,
235
+ items: itemsForCombobox,
236
+ itemToString: itemToStringForDownshift,
237
+ defaultHighlightedIndex: 0,
238
+ selectedItem: null,
239
+ inputValue,
240
+ isOpen,
241
+ stateReducer,
242
+ onStateChange({
243
+ inputValue: newInputValue,
244
+ type,
245
+ selectedItem: newSelectedItem,
246
+ isOpen: newIsOpen,
247
+ }) {
248
+ // Handle isOpen changes for any state change type
249
+ if (
250
+ newIsOpen !== undefined &&
251
+ type !== useCombobox.stateChangeTypes.InputBlur
252
+ ) {
253
+ updateIsOpen(newIsOpen);
254
+ }
255
+
256
+ switch (type) {
257
+ case useCombobox.stateChangeTypes.InputKeyDownEnter:
258
+ case useCombobox.stateChangeTypes.ItemClick:
259
+ if (newSelectedItem) {
260
+ // Handle selectAllItem selection
261
+ if (
262
+ selectAllItem &&
263
+ isToggleItem(newSelectedItem) &&
264
+ newSelectedItem.id === selectAllItem.id
265
+ ) {
266
+ // Check if all items are selected (excluding selectAllItem itself)
267
+ const allSelected =
268
+ data.length > 0 &&
269
+ data.every((item) => selectedItems.includes(item));
270
+ if (allSelected) {
271
+ // Deselect all items
272
+ updateSelectedItems([]);
273
+ } else {
274
+ // Select all items
275
+ updateSelectedItems([...data]);
276
+ }
277
+ updateInputValue("");
278
+ } else if (
279
+ selectHeadings &&
280
+ groupBy &&
281
+ isGroupHeading(newSelectedItem)
282
+ ) {
283
+ // Handle heading selection - select/deselect all items in the group
284
+ const groupLabel = newSelectedItem.label;
285
+ const groupItems = data.filter(
286
+ (item) => groupBy(item) === groupLabel
287
+ );
288
+ const allSelected =
289
+ groupItems.length > 0 &&
290
+ groupItems.every((item) => selectedItems.includes(item));
291
+
292
+ if (allSelected) {
293
+ // Deselect all items in the group
294
+ updateSelectedItems(
295
+ selectedItems.filter((item) => !groupItems.includes(item))
296
+ );
297
+ } else {
298
+ // Select all items in the group
299
+ const newItems = [
300
+ ...selectedItems.filter((item) => !groupItems.includes(item)),
301
+ ...groupItems,
302
+ ];
303
+ updateSelectedItems(newItems);
304
+ }
305
+ updateInputValue("");
306
+ } else if (
307
+ !isGroupHeading(newSelectedItem) &&
308
+ !isToggleItem(newSelectedItem)
309
+ ) {
310
+ // Only handle regular data items
311
+ const itemIndex = selectedItems.findIndex(
312
+ (item) => itemToKey(item) === itemToKey(newSelectedItem as T)
313
+ );
314
+ if (itemIndex >= 0) {
315
+ updateSelectedItems(
316
+ selectedItems.filter((_, index) => index !== itemIndex)
317
+ );
318
+ } else {
319
+ updateSelectedItems([...selectedItems, newSelectedItem as T]);
320
+ }
321
+ updateInputValue("");
322
+ }
323
+ }
324
+ break;
325
+
326
+ case useCombobox.stateChangeTypes.InputChange:
327
+ updateInputValue(newInputValue ?? "");
328
+ break;
329
+
330
+ default:
331
+ break;
332
+ }
333
+ },
334
+ });
335
+ const {
336
+ isOpen: comboboxIsOpen,
337
+ getLabelProps,
338
+ getMenuProps,
339
+ getInputProps,
340
+ highlightedIndex: comboboxHighlightedIndex,
341
+ getItemProps,
342
+ } = comboboxProps;
343
+
344
+ // Map the highlighted index from combobox (which only knows about items) to the full items array (which includes headings)
345
+ const highlightedIndex = useHighlightedIndex(
346
+ items,
347
+ itemsForCombobox,
348
+ comboboxHighlightedIndex,
349
+ !!groupBy,
350
+ selectHeadings ?? false
351
+ );
352
+
353
+ const buttonContent = renderSelections
354
+ ? renderSelections(selectedItems)
355
+ : selectedItems.length > 0
356
+ ? selectedItems.map((item) => itemToString(item)).join(", ")
357
+ : placeholder;
358
+
359
+ // Handle button click to toggle popout
360
+ const handleButtonClick = () => {
361
+ updateIsOpen(!comboboxIsOpen);
362
+ };
363
+
364
+ return (
365
+ <Popout
366
+ open={comboboxIsOpen}
367
+ onOpenChange={updateIsOpen}
368
+ animationConfig={menuAnimationConfig}
369
+ onOpenAutoFocus={(e) => {
370
+ // Prevent default auto-focus behavior - we'll focus the input manually
371
+ e.preventDefault();
372
+ // Focus the input when popout opens
373
+ const inputElement = document.querySelector(
374
+ `input[name="${name}"]`
375
+ ) as HTMLInputElement;
376
+ if (inputElement) {
377
+ setTimeout(() => {
378
+ inputElement.focus();
379
+ }, 0);
380
+ }
381
+ }}
382
+ onCloseAutoFocus={(e) => {
383
+ // Prevent default auto-focus behavior
384
+ e.preventDefault();
385
+ }}
386
+ content={
387
+ <div>
388
+ {/* Input inside the Popout */}
389
+ <div style={{ marginBottom: "8px" }}>
390
+ {(() => {
391
+ const inputProps = getInputProps(
392
+ getDropdownProps({ preventKeyAction: isOpen })
393
+ );
394
+ const { ref, id, ...restProps } = inputProps as any;
395
+ return (
396
+ <Input
397
+ id={id}
398
+ name={name}
399
+ inputProps={{
400
+ ...restProps,
401
+ autoComplete: "off", // Override with "off" to prevent browser autocomplete
402
+ }}
403
+ innerRef={ref}
404
+ placeholder={placeholder}
405
+ />
406
+ );
407
+ })()}
408
+ </div>
409
+ {/* Menu inside the Popout */}
410
+ {isVirtualized ? (
411
+ <VirtualizedAutocompleteContent
412
+ items={items}
413
+ getMenuProps={getMenuProps}
414
+ getItemProps={(props: { index: number }) => {
415
+ // Only apply getItemProps to actual items, not headings or ToggleItems
416
+ const item = items[props.index];
417
+ if (isGroupHeading(item) && !selectHeadings) {
418
+ return {};
419
+ }
420
+ if (isToggleItem(item)) {
421
+ // Handle ToggleItem clicks manually
422
+ return {
423
+ onClick: () => {
424
+ const allSelected =
425
+ data.length > 0 &&
426
+ data.every((item) => selectedItems.includes(item));
427
+ if (allSelected) {
428
+ updateSelectedItems([]);
429
+ } else {
430
+ updateSelectedItems([...data]);
431
+ }
432
+ },
433
+ };
434
+ }
435
+ const dataItem = item as T;
436
+ // Find the index in itemsForCombobox
437
+ const itemIndex = itemsForCombobox.indexOf(dataItem);
438
+ if (itemIndex === -1) {
439
+ return {};
440
+ }
441
+ return getItemProps({ item: dataItem, index: itemIndex });
442
+ }}
443
+ selectedItems={selectedItems}
444
+ highlightedIndex={highlightedIndex ?? -1}
445
+ renderItem={renderItem}
446
+ renderGroupHeading={renderGroupHeading}
447
+ EmptyState={EmptyState}
448
+ itemToKey={itemToKey}
449
+ itemToString={itemToString}
450
+ inputType="checkbox"
451
+ selectHeadings={selectHeadings}
452
+ onHeadingClick={handleHeadingClick}
453
+ groupBy={groupBy}
454
+ allData={data}
455
+ selectAllItem={selectAllItem}
456
+ />
457
+ ) : (
458
+ <NonVirtualizedAutocompleteContent
459
+ items={items}
460
+ getMenuProps={getMenuProps}
461
+ getItemProps={(props: { index: number }) => {
462
+ // Only apply getItemProps to actual items, not headings or ToggleItems
463
+ const item = items[props.index];
464
+ if (isGroupHeading(item) && !selectHeadings) {
465
+ return {};
466
+ }
467
+ if (isToggleItem(item)) {
468
+ // Handle ToggleItem clicks manually
469
+ return {
470
+ onClick: () => {
471
+ const allSelected =
472
+ data.length > 0 &&
473
+ data.every((item) => selectedItems.includes(item));
474
+ if (allSelected) {
475
+ updateSelectedItems([]);
476
+ } else {
477
+ updateSelectedItems([...data]);
478
+ }
479
+ },
480
+ };
481
+ }
482
+ const dataItem = item as T;
483
+ // Find the index in itemsForCombobox
484
+ const itemIndex = itemsForCombobox.indexOf(dataItem);
485
+ if (itemIndex === -1) {
486
+ return {};
487
+ }
488
+ return getItemProps({ item: dataItem, index: itemIndex });
489
+ }}
490
+ selectedItems={selectedItems}
491
+ highlightedIndex={highlightedIndex ?? -1}
492
+ renderItem={renderItem}
493
+ renderGroupHeading={renderGroupHeading}
494
+ EmptyState={EmptyState}
495
+ itemToKey={itemToKey}
496
+ itemToString={itemToString}
497
+ inputType="checkbox"
498
+ selectHeadings={selectHeadings}
499
+ onHeadingClick={handleHeadingClick}
500
+ groupBy={groupBy}
501
+ allData={data}
502
+ selectAllItem={selectAllItem}
503
+ />
504
+ )}
505
+ </div>
506
+ }
507
+ >
508
+ <div>
509
+ <SelectToggleButton
510
+ onClick={handleButtonClick}
511
+ {...triggerButtonProps}
512
+ name={name}
513
+ isOpen={comboboxIsOpen}
514
+ type="button"
515
+ >
516
+ {buttonContent}
517
+ </SelectToggleButton>
518
+ </div>
519
+ </Popout>
520
+ );
521
+ }