@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,453 @@
1
+ import { useState, useMemo } from "react";
2
+ import { useCombobox, useMultipleSelection } from "downshift";
3
+ import { Label } from "@sproutsocial/seeds-react-label";
4
+ import { TokenInput } from "@sproutsocial/seeds-react-token-input";
5
+ // This will eventually be imported from seeds-react-popout
6
+ import { Popout } from "../Common-V2";
7
+ import {
8
+ isGroupHeading,
9
+ isToggleItem,
10
+ menuAnimationConfig,
11
+ getFilteredItems,
12
+ groupItemsWithHeadings,
13
+ useHighlightedIndex,
14
+ createItemToStringForDownshift,
15
+ ComboboxContent,
16
+ } from "../Common-V2";
17
+ import {
18
+ type DataWithId,
19
+ type GroupedItem,
20
+ type ToggleItem,
21
+ type GroupHeading,
22
+ type BaseMultiAutocompleteProps,
23
+ } from "../Common-V2";
24
+
25
+ function defaultReducer(state, actionAndChanges) {
26
+ const { changes, type } = actionAndChanges;
27
+
28
+ switch (type) {
29
+ case useCombobox.stateChangeTypes.InputKeyDownEnter:
30
+ case useCombobox.stateChangeTypes.ItemClick:
31
+ // If the item is already selected, don't set highlighted index to 0
32
+ // We don't have access to selectedItems in the reducer
33
+ return {
34
+ ...changes,
35
+ isOpen: true, // keep the menu open after selection.
36
+ highlightedIndex: 0, // with the first option highlighted.
37
+ };
38
+ default:
39
+ return changes;
40
+ }
41
+ }
42
+
43
+ export interface MultiAutocompleteProps<T extends DataWithId>
44
+ extends BaseMultiAutocompleteProps<T> {}
45
+
46
+ export function MultiAutocomplete<T extends DataWithId>({
47
+ id,
48
+ data,
49
+ selectedItemIds: controlledSelectedItemIds,
50
+ inputValue: controlledInputValue,
51
+ isOpen: controlledIsOpen,
52
+ itemToKey = (item: T) => item?.id,
53
+ itemToString,
54
+ itemToSearchString,
55
+ renderItem,
56
+ groupBy,
57
+ renderGroupHeading,
58
+ isVirtualized = false,
59
+ showSelectedItems = false,
60
+ selectHeadings = false,
61
+ selectAllItem,
62
+ EmptyState,
63
+ placeholder,
64
+ onSelectedItemIdsChange,
65
+ onInputValueChange,
66
+ onIsOpenChange,
67
+ stateReducer = defaultReducer,
68
+ menuProps,
69
+ inputType = "checkbox",
70
+ }: MultiAutocompleteProps<T>) {
71
+ const [uncontrolledInputValue, setUncontrolledInputValue] =
72
+ useState<string>("");
73
+ const [uncontrolledSelectedItemIds, setUncontrolledSelectedItemIds] =
74
+ useState<T["id"][]>([]);
75
+ const [uncontrolledIsOpen, setUncontrolledIsOpen] = useState<boolean>(false);
76
+
77
+ // Use controlled selectedItemIds if provided, otherwise use internal state
78
+ const isSelectedItemIdsControlled = controlledSelectedItemIds !== undefined;
79
+ const selectedItemIds = isSelectedItemIdsControlled
80
+ ? controlledSelectedItemIds
81
+ : uncontrolledSelectedItemIds;
82
+
83
+ // Convert selectedItemIds to selectedItems for internal use
84
+ const selectedItems = useMemo<T[]>(() => {
85
+ return data.filter((item) => selectedItemIds.includes(itemToKey(item)));
86
+ }, [selectedItemIds, data, itemToKey]);
87
+
88
+ // Use controlled inputValue if provided, otherwise use internal state
89
+ const isInputValueControlled = controlledInputValue !== undefined;
90
+ const inputValue = isInputValueControlled
91
+ ? controlledInputValue
92
+ : uncontrolledInputValue;
93
+
94
+ // Use controlled isOpen if provided, otherwise use internal state
95
+ const isIsOpenControlled = controlledIsOpen !== undefined;
96
+ const isOpen = isIsOpenControlled ? controlledIsOpen : uncontrolledIsOpen;
97
+
98
+ const updateSelectedItems = (newSelectedItems: T[]) => {
99
+ const newSelectedItemIds = newSelectedItems.map((item) => itemToKey(item));
100
+ if (!isSelectedItemIdsControlled) {
101
+ setUncontrolledSelectedItemIds(newSelectedItemIds);
102
+ }
103
+ onSelectedItemIdsChange?.(newSelectedItemIds);
104
+ };
105
+
106
+ const updateInputValue = (newInputValue: string) => {
107
+ if (!isInputValueControlled) {
108
+ setUncontrolledInputValue(newInputValue);
109
+ }
110
+ onInputValueChange?.(newInputValue);
111
+ };
112
+
113
+ const updateIsOpen = (newIsOpen: boolean) => {
114
+ if (!isIsOpenControlled) {
115
+ setUncontrolledIsOpen(newIsOpen);
116
+ }
117
+ onIsOpenChange?.(newIsOpen);
118
+ };
119
+
120
+ // Use itemToSearchString if provided, otherwise fallback to itemToString
121
+ const searchStringFn =
122
+ itemToSearchString ?? ((item: T) => itemToString(item));
123
+
124
+ const filteredItems = useMemo<T[]>(
125
+ () =>
126
+ getFilteredItems(data, inputValue, searchStringFn, itemToString, {
127
+ selectedItems,
128
+ showSelectedItems,
129
+ }),
130
+ [data, selectedItems, inputValue, searchStringFn, showSelectedItems]
131
+ );
132
+
133
+ const items = useMemo<GroupedItem<T>[]>(() => {
134
+ let listItems: GroupedItem<T>[];
135
+
136
+ // If groupBy is provided and selectHeadings is true we need to add group heading items to the list
137
+ if (groupBy) {
138
+ // Group first, then add selectAllItem at the beginning
139
+ // If headings are not selectable we will just reorder the items
140
+ // If headings are selectable we will add them to the list
141
+ listItems = groupItemsWithHeadings(
142
+ filteredItems,
143
+ groupBy,
144
+ data,
145
+ selectHeadings
146
+ );
147
+ } else {
148
+ listItems = filteredItems;
149
+ }
150
+
151
+ // Add selectAllItem at the beginning if provided
152
+ if (selectAllItem) {
153
+ return [selectAllItem, ...listItems];
154
+ }
155
+
156
+ return listItems;
157
+ }, [filteredItems, groupBy, selectAllItem, selectHeadings, data]);
158
+
159
+ // Handler for heading clicks - selects/deselects all items in a group
160
+ const handleHeadingClick = useMemo(() => {
161
+ if (!selectHeadings || !groupBy) {
162
+ return undefined;
163
+ }
164
+
165
+ return (groupLabel: string, allSelected: boolean) => {
166
+ // Find all items in this group from the full data array
167
+ const groupItems = data.filter((item) => groupBy(item) === groupLabel);
168
+
169
+ if (allSelected) {
170
+ // Deselect all items in the group
171
+ const newSelectedItems = selectedItems.filter(
172
+ (item) => !groupItems.includes(item)
173
+ );
174
+ updateSelectedItems(newSelectedItems);
175
+ } else {
176
+ // Select all items in the group (add them if not already selected)
177
+ const newSelectedItems = [...selectedItems];
178
+ for (const groupItem of groupItems) {
179
+ if (!newSelectedItems.includes(groupItem)) {
180
+ newSelectedItems.push(groupItem);
181
+ }
182
+ }
183
+ updateSelectedItems(newSelectedItems);
184
+ }
185
+ };
186
+ }, [selectHeadings, groupBy, data, selectedItems, updateSelectedItems]);
187
+
188
+ const { getSelectedItemProps, getDropdownProps, removeSelectedItem } =
189
+ useMultipleSelection<T>({
190
+ selectedItems,
191
+ onStateChange({ selectedItems: newSelectedItems, type }) {
192
+ switch (type) {
193
+ case useMultipleSelection.stateChangeTypes
194
+ .SelectedItemKeyDownBackspace:
195
+ case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownDelete:
196
+ case useMultipleSelection.stateChangeTypes.DropdownKeyDownBackspace:
197
+ case useMultipleSelection.stateChangeTypes.FunctionRemoveSelectedItem:
198
+ if (newSelectedItems !== undefined) {
199
+ updateSelectedItems(newSelectedItems);
200
+ }
201
+ break;
202
+ default:
203
+ break;
204
+ }
205
+ },
206
+ });
207
+
208
+ // Filter out headings for downshift unless selectHeadings is true
209
+ const itemsForCombobox = useMemo<(T | ToggleItem | GroupHeading)[]>(() => {
210
+ if (selectHeadings) {
211
+ // Include all items (headings, toggle items, and data items)
212
+ return items;
213
+ }
214
+ return items.filter(
215
+ (item): item is T | ToggleItem => !isGroupHeading(item)
216
+ );
217
+ }, [items, selectHeadings]);
218
+
219
+ // Wrapper function to handle ToggleItem and GroupHeading separately
220
+ const itemToStringForDownshift = useMemo(
221
+ () => createItemToStringForDownshift(itemToString),
222
+ [itemToString]
223
+ );
224
+
225
+ const comboboxProps = useCombobox<T | ToggleItem | GroupHeading>({
226
+ inputId: id,
227
+ items: itemsForCombobox,
228
+ itemToString: itemToStringForDownshift,
229
+ defaultHighlightedIndex: 0, // after selection, highlight the first item.
230
+ selectedItem: null,
231
+ inputValue,
232
+ isOpen,
233
+ stateReducer,
234
+ // Could maybe let devs override this too?
235
+ onStateChange({
236
+ inputValue: newInputValue,
237
+ type,
238
+ selectedItem: newSelectedItem,
239
+ isOpen: newIsOpen,
240
+ }) {
241
+ // Handle isOpen changes for any state change type
242
+ if (newIsOpen !== undefined) {
243
+ updateIsOpen(newIsOpen);
244
+ }
245
+
246
+ switch (type) {
247
+ case useCombobox.stateChangeTypes.InputKeyDownEnter:
248
+ case useCombobox.stateChangeTypes.ItemClick:
249
+ if (newSelectedItem) {
250
+ // Handle selectAllItem selection
251
+ if (
252
+ selectAllItem &&
253
+ isToggleItem(newSelectedItem) &&
254
+ newSelectedItem.id === selectAllItem.id
255
+ ) {
256
+ // Check if all items are selected (excluding selectAllItem itself)
257
+ const allSelected =
258
+ data.length > 0 &&
259
+ data.every((item) => selectedItems.includes(item));
260
+ if (allSelected) {
261
+ // Deselect all items
262
+ updateSelectedItems([]);
263
+ } else {
264
+ // Select all items
265
+ updateSelectedItems([...data]);
266
+ }
267
+ updateInputValue("");
268
+ } else if (
269
+ selectHeadings &&
270
+ groupBy &&
271
+ isGroupHeading(newSelectedItem)
272
+ ) {
273
+ // Handle heading selection - select/deselect all items in the group
274
+ const groupLabel = newSelectedItem.label;
275
+ const groupItems = data.filter(
276
+ (item) => groupBy(item) === groupLabel
277
+ );
278
+ const allSelected =
279
+ groupItems.length > 0 &&
280
+ groupItems.every((item) => selectedItems.includes(item));
281
+
282
+ if (allSelected) {
283
+ // Deselect all items in the group
284
+ updateSelectedItems(
285
+ selectedItems.filter((item) => !groupItems.includes(item))
286
+ );
287
+ } else {
288
+ // Select all items in the group
289
+ const newItems = [
290
+ ...selectedItems.filter((item) => !groupItems.includes(item)),
291
+ ...groupItems,
292
+ ];
293
+ updateSelectedItems(newItems);
294
+ }
295
+ updateInputValue("");
296
+ } else if (
297
+ !isGroupHeading(newSelectedItem) &&
298
+ !isToggleItem(newSelectedItem)
299
+ ) {
300
+ // Only handle regular data items
301
+ const itemIndex = selectedItems.findIndex(
302
+ (item) => itemToKey(item) === itemToKey(newSelectedItem as T)
303
+ );
304
+ if (itemIndex >= 0) {
305
+ updateSelectedItems(
306
+ selectedItems.filter((_, index) => index !== itemIndex)
307
+ );
308
+ } else {
309
+ updateSelectedItems([...selectedItems, newSelectedItem as T]);
310
+ }
311
+ updateInputValue("");
312
+ }
313
+ }
314
+ break;
315
+
316
+ case useCombobox.stateChangeTypes.InputChange:
317
+ updateInputValue(newInputValue ?? "");
318
+ break;
319
+
320
+ default:
321
+ break;
322
+ }
323
+ },
324
+ });
325
+ const {
326
+ isOpen: comboboxIsOpen,
327
+ getToggleButtonProps,
328
+ getLabelProps,
329
+ getMenuProps,
330
+ getInputProps,
331
+ highlightedIndex: comboboxHighlightedIndex,
332
+ getItemProps,
333
+ selectedItem,
334
+ } = comboboxProps;
335
+
336
+ // Map the highlighted index from combobox (which only knows about items) to the full items array (which includes headings)
337
+ const highlightedIndex = useHighlightedIndex(
338
+ items,
339
+ itemsForCombobox,
340
+ comboboxHighlightedIndex,
341
+ !!groupBy,
342
+ selectHeadings ?? false
343
+ );
344
+
345
+ const inputProps = getInputProps({ preventKeyAction: isOpen });
346
+ // Downshift adds aria-labelledby by default, which can conflict with our Label usage
347
+ inputProps["aria-labelledby"] = undefined;
348
+ if (!comboboxIsOpen) {
349
+ // Remove aria-controls when the menu is closed to avoid referencing a non-existent element
350
+ // @ts-expect-error downshift types require aria-controls even though it is not required
351
+ inputProps["aria-controls"] = undefined;
352
+ }
353
+
354
+ const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
355
+ // Call any provided onBlur handler
356
+ if (inputProps.onBlur) {
357
+ inputProps.onBlur(e);
358
+ }
359
+ };
360
+
361
+ const inputPropsToSpread = { ...inputProps };
362
+ // Remove onBlur from inputProps
363
+ // The blur event needs added to the root of token input
364
+ delete inputPropsToSpread.onBlur;
365
+
366
+ return (
367
+ <Popout
368
+ open={comboboxIsOpen}
369
+ onOpenChange={updateIsOpen}
370
+ onOpenAutoFocus={(e) => {
371
+ // Prevent default auto-focus behavior
372
+ e.preventDefault();
373
+ }}
374
+ onCloseAutoFocus={(e) => {
375
+ // Prevent default auto-focus behavior
376
+ e.preventDefault();
377
+ }}
378
+ animationConfig={menuAnimationConfig}
379
+ content={
380
+ <ComboboxContent
381
+ items={items}
382
+ getMenuProps={getMenuProps}
383
+ getItemProps={(props: { index: number }) => {
384
+ // Only apply getItemProps to actual items, not headings or ToggleItems
385
+ const item = items[props.index];
386
+ if (isToggleItem(item)) {
387
+ // Handle ToggleItem clicks manually
388
+ return {
389
+ onClick: () => {
390
+ const allSelected =
391
+ data.length > 0 &&
392
+ data.every((item) => selectedItems.includes(item));
393
+ if (allSelected) {
394
+ updateSelectedItems([]);
395
+ } else {
396
+ updateSelectedItems([...data]);
397
+ }
398
+ },
399
+ };
400
+ }
401
+ const dataItem = item as T;
402
+ // Find the index in itemsForCombobox
403
+ const itemIndex = itemsForCombobox.indexOf(dataItem);
404
+ if (itemIndex === -1) {
405
+ return {};
406
+ }
407
+ return getItemProps({ item: dataItem, index: itemIndex });
408
+ }}
409
+ selectedItems={selectedItems}
410
+ highlightedIndex={highlightedIndex ?? -1}
411
+ renderItem={renderItem}
412
+ renderGroupHeading={renderGroupHeading}
413
+ EmptyState={EmptyState}
414
+ itemToKey={itemToKey}
415
+ itemToString={itemToString}
416
+ inputType={inputType}
417
+ selectHeadings={selectHeadings}
418
+ onHeadingClick={handleHeadingClick}
419
+ groupBy={groupBy}
420
+ allData={data}
421
+ selectAllItem={selectAllItem}
422
+ isVirtualized={isVirtualized}
423
+ {...menuProps}
424
+ />
425
+ }
426
+ >
427
+ {/* @ts-expect-error onClick types to not match tokeninput */}
428
+ <TokenInput
429
+ placeholder={placeholder}
430
+ inputProps={{
431
+ ...inputPropsToSpread,
432
+ autoComplete: "off", // Override with "off" to prevent browser autocomplete
433
+ }}
434
+ tokens={selectedItems.map((item) => ({
435
+ id: String(itemToKey(item)),
436
+ value: itemToString(item),
437
+ }))}
438
+ onRemoveToken={(tokenId) => {
439
+ const itemToRemove = selectedItems.find(
440
+ (item) => String(itemToKey(item)) === tokenId
441
+ );
442
+ if (itemToRemove) {
443
+ removeSelectedItem(itemToRemove);
444
+ }
445
+ }}
446
+ onClickToken={(e) => {
447
+ e.stopPropagation();
448
+ }}
449
+ onBlur={handleBlur}
450
+ />
451
+ </Popout>
452
+ );
453
+ }