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