@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,362 @@
1
+ import { useState, useMemo, useEffect } from "react";
2
+ import { useCombobox } 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 } from "../Common-V2";
8
+ import {
9
+ isGroupHeading,
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 BaseSingleAutocompleteProps,
22
+ } from "../Common-V2";
23
+
24
+ function defaultReducer(state, actionAndChanges) {
25
+ const { changes, type } = actionAndChanges;
26
+
27
+ switch (type) {
28
+ case useCombobox.stateChangeTypes.InputKeyDownEnter:
29
+ case useCombobox.stateChangeTypes.ItemClick:
30
+ return {
31
+ ...changes,
32
+ isOpen: false, // close the menu after selection for single select
33
+ highlightedIndex: 0, // reset highlight
34
+ };
35
+ default:
36
+ return changes;
37
+ }
38
+ }
39
+
40
+ export interface SingleAutocompleteProps<T extends DataWithId>
41
+ extends BaseSingleAutocompleteProps<T> {
42
+ includePlaceholderItem?: boolean;
43
+ }
44
+
45
+ export function SingleAutocomplete<T extends DataWithId>({
46
+ id,
47
+ data,
48
+ selectedItemId: controlledSelectedItemId,
49
+ inputValue: controlledInputValue,
50
+ isOpen: controlledIsOpen,
51
+ itemToKey = (item: T) => item?.id,
52
+ itemToString,
53
+ itemToSearchString,
54
+ renderItem,
55
+ groupBy,
56
+ renderGroupHeading,
57
+ isVirtualized = false,
58
+ EmptyState,
59
+ placeholder,
60
+ onSelectedItemIdChange,
61
+ onInputValueChange,
62
+ onIsOpenChange,
63
+ stateReducer = defaultReducer,
64
+ menuProps,
65
+ inputType = "icon",
66
+ includePlaceholderItem = false,
67
+ }: SingleAutocompleteProps<T>) {
68
+ const [uncontrolledInputValue, setUncontrolledInputValue] =
69
+ useState<string>("");
70
+ const [uncontrolledSelectedItemId, setUncontrolledSelectedItemId] = useState<
71
+ T["id"] | null
72
+ >(null);
73
+ const [uncontrolledIsOpen, setUncontrolledIsOpen] = useState<boolean>(false);
74
+
75
+ // Use controlled selectedItemId if provided, otherwise use internal state
76
+ const isSelectedItemIdControlled = controlledSelectedItemId !== undefined;
77
+ const selectedItemId = isSelectedItemIdControlled
78
+ ? controlledSelectedItemId
79
+ : uncontrolledSelectedItemId;
80
+
81
+ // Convert selectedItemId to selectedItem for internal use
82
+ const selectedItem = useMemo<T | null>(() => {
83
+ if (selectedItemId === null || selectedItemId === undefined) {
84
+ return null;
85
+ }
86
+ return data.find((item) => itemToKey(item) === selectedItemId) ?? null;
87
+ }, [selectedItemId, data, itemToKey]);
88
+
89
+ // Use controlled inputValue if provided, otherwise use internal state
90
+ const isInputValueControlled = controlledInputValue !== undefined;
91
+ const inputValue = isInputValueControlled
92
+ ? controlledInputValue
93
+ : uncontrolledInputValue;
94
+
95
+ // Use controlled isOpen if provided, otherwise use internal state
96
+ const isIsOpenControlled = controlledIsOpen !== undefined;
97
+ const isOpen = isIsOpenControlled ? controlledIsOpen : uncontrolledIsOpen;
98
+
99
+ // TODO: This does not actually update the input value when the selected item changes externally
100
+ // Sync inputValue with selectedItem when selectedItem changes externally and inputValue is not controlled
101
+ useEffect(() => {
102
+ if (selectedItem && !isOpen) {
103
+ // Only update if menu is closed - when open, user might be typing
104
+ const selectedItemString = itemToString(selectedItem);
105
+ if (
106
+ !isInputValueControlled &&
107
+ uncontrolledInputValue !== selectedItemString
108
+ ) {
109
+ setUncontrolledInputValue(selectedItemString);
110
+ } else if (isInputValueControlled && inputValue !== selectedItemString) {
111
+ updateInputValue(selectedItemString);
112
+ }
113
+ }
114
+ }, [
115
+ selectedItem,
116
+ isOpen,
117
+ isInputValueControlled,
118
+ itemToString,
119
+ uncontrolledInputValue,
120
+ ]);
121
+
122
+ const updateSelectedItem = (newSelectedItem: T | null) => {
123
+ const newSelectedItemId = newSelectedItem
124
+ ? itemToKey(newSelectedItem)
125
+ : null;
126
+ if (!isSelectedItemIdControlled) {
127
+ setUncontrolledSelectedItemId(newSelectedItemId);
128
+ }
129
+ onSelectedItemIdChange?.(newSelectedItemId);
130
+ };
131
+
132
+ const updateInputValue = (newInputValue: string) => {
133
+ if (!isInputValueControlled) {
134
+ setUncontrolledInputValue(newInputValue);
135
+ }
136
+ onInputValueChange?.(newInputValue);
137
+ };
138
+
139
+ const updateIsOpen = (newIsOpen: boolean) => {
140
+ if (!isIsOpenControlled) {
141
+ setUncontrolledIsOpen(newIsOpen);
142
+ }
143
+ onIsOpenChange?.(newIsOpen);
144
+ };
145
+
146
+ // Use itemToSearchString if provided, otherwise fallback to itemToString
147
+ const searchStringFn =
148
+ itemToSearchString ?? ((item: T) => itemToString(item));
149
+
150
+ const filteredItems = useMemo<T[]>(
151
+ () =>
152
+ getFilteredItems(data, inputValue, searchStringFn, itemToString, {
153
+ selectedItem,
154
+ }),
155
+ [data, selectedItem, inputValue, searchStringFn, itemToString]
156
+ );
157
+
158
+ const placeholderItem: ToggleItem = {
159
+ id: "clear-selection",
160
+ label: placeholder,
161
+ };
162
+
163
+ const items = useMemo<GroupedItem<T>[]>(() => {
164
+ let groupedItems: GroupedItem<T>[];
165
+
166
+ if (groupBy) {
167
+ groupedItems = groupItemsWithHeadings(
168
+ filteredItems,
169
+ groupBy,
170
+ data,
171
+ isVirtualized
172
+ );
173
+ } else {
174
+ groupedItems = filteredItems;
175
+ }
176
+
177
+ // Add placeholder item at the start if needed
178
+ groupedItems = includePlaceholderItem
179
+ ? [placeholderItem, ...groupedItems]
180
+ : groupedItems;
181
+
182
+ return groupedItems;
183
+ }, [
184
+ filteredItems,
185
+ placeholder,
186
+ groupBy,
187
+ data,
188
+ includePlaceholderItem,
189
+ isVirtualized,
190
+ ]);
191
+
192
+ // Filter out headings for downshift
193
+ const itemsForCombobox = useMemo<(T | ToggleItem)[]>(() => {
194
+ return items.filter(
195
+ (item): item is T | ToggleItem => !isGroupHeading(item)
196
+ );
197
+ }, [items]);
198
+
199
+ // Wrapper function to handle ToggleItem separately
200
+ const itemToStringForDownshift = useMemo(
201
+ () => createItemToStringForDownshift(itemToString),
202
+ [itemToString]
203
+ );
204
+
205
+ const comboboxProps = useCombobox<T | ToggleItem>({
206
+ inputId: id,
207
+ items: itemsForCombobox,
208
+ itemToString: itemToStringForDownshift,
209
+ defaultHighlightedIndex: 0,
210
+ selectedItem: selectedItem,
211
+ inputValue,
212
+ isOpen,
213
+ stateReducer,
214
+ onStateChange({
215
+ inputValue: newInputValue,
216
+ type,
217
+ selectedItem: newSelectedItem,
218
+ isOpen: newIsOpen,
219
+ }) {
220
+ // Handle isOpen changes for any state change type
221
+ if (
222
+ newIsOpen !== undefined &&
223
+ type !== useCombobox.stateChangeTypes.InputBlur
224
+ ) {
225
+ updateIsOpen(newIsOpen);
226
+ }
227
+
228
+ switch (type) {
229
+ case useCombobox.stateChangeTypes.InputKeyDownEnter:
230
+ case useCombobox.stateChangeTypes.ItemClick:
231
+ if (newSelectedItem) {
232
+ updateSelectedItem(newSelectedItem as T);
233
+ if (newSelectedItem.id === "clear-selection") {
234
+ // Clear input if placeholder item is selected
235
+ updateInputValue("");
236
+ return;
237
+ }
238
+ // Set input to selected item's string representation
239
+ updateInputValue(itemToString(newSelectedItem as T));
240
+ }
241
+ break;
242
+
243
+ case useCombobox.stateChangeTypes.InputChange:
244
+ updateInputValue(newInputValue ?? "");
245
+ if (newInputValue === "" && selectedItem && !isInputValueControlled) {
246
+ // If the input is cleared and a selected item is present, clear the selection
247
+ // This is to ensure that the input value is cleared when the selected item is cleared
248
+ updateSelectedItem(null);
249
+ }
250
+ break;
251
+
252
+ default:
253
+ break;
254
+ }
255
+ },
256
+ });
257
+ const {
258
+ isOpen: comboboxIsOpen,
259
+ getToggleButtonProps,
260
+ getLabelProps,
261
+ getMenuProps,
262
+ getInputProps,
263
+ highlightedIndex: comboboxHighlightedIndex,
264
+ getItemProps,
265
+ selectedItem: comboboxSelectedItem,
266
+ } = comboboxProps;
267
+
268
+ // Map the highlighted index from combobox (which only knows about items) to the full items array (which includes headings)
269
+ const highlightedIndex = useHighlightedIndex(
270
+ items,
271
+ itemsForCombobox,
272
+ comboboxHighlightedIndex,
273
+ !!groupBy,
274
+ false // Single select components hardcode selectHeadings to false
275
+ );
276
+
277
+ const inputProps = getInputProps();
278
+ // Downshift adds aria-labelledby by default, which can conflict with our Label usage
279
+ inputProps["aria-labelledby"] = undefined;
280
+ if (!comboboxIsOpen) {
281
+ // Remove aria-controls when the menu is closed to avoid referencing a non-existent element
282
+ // @ts-expect-error downshift types require aria-controls even though it is not required
283
+ inputProps["aria-controls"] = undefined;
284
+ }
285
+ const { ref, id: inputId, ...restProps } = inputProps as any;
286
+
287
+ const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
288
+ // Call any provided onBlur handler
289
+ if (inputProps.onBlur) {
290
+ inputProps.onBlur(e);
291
+ }
292
+ // Close the menu on blur
293
+ updateIsOpen(false);
294
+ };
295
+
296
+ return (
297
+ <Popout
298
+ open={comboboxIsOpen}
299
+ onOpenChange={updateIsOpen}
300
+ animationConfig={menuAnimationConfig}
301
+ onOpenAutoFocus={(e) => {
302
+ // Prevent default auto-focus behavior
303
+ e.preventDefault();
304
+ }}
305
+ onCloseAutoFocus={(e) => {
306
+ // Prevent default auto-focus behavior
307
+ e.preventDefault();
308
+ }}
309
+ content={
310
+ <ComboboxContent
311
+ items={items}
312
+ getMenuProps={getMenuProps}
313
+ getItemProps={(props: { index: number }) => {
314
+ // Only apply getItemProps to actual items, not headings or ToggleItems
315
+ const item = items[props.index];
316
+ const dataItem = item as T;
317
+ // Find the index in itemsForCombobox
318
+ const itemIndex = itemsForCombobox.indexOf(dataItem);
319
+ if (itemIndex === -1) {
320
+ return {};
321
+ }
322
+ return getItemProps({ item: dataItem, index: itemIndex });
323
+ }}
324
+ selectedItems={selectedItem ? [selectedItem] : []}
325
+ highlightedIndex={highlightedIndex ?? -1}
326
+ renderItem={renderItem}
327
+ renderGroupHeading={renderGroupHeading}
328
+ EmptyState={EmptyState}
329
+ itemToKey={itemToKey}
330
+ itemToString={itemToString}
331
+ inputType={inputType}
332
+ selectHeadings={false}
333
+ groupBy={groupBy}
334
+ isVirtualized={isVirtualized}
335
+ placeholderItem={includePlaceholderItem ? placeholderItem : undefined}
336
+ {...menuProps}
337
+ />
338
+ }
339
+ >
340
+ <Input
341
+ id={id}
342
+ inputProps={{
343
+ ...restProps,
344
+ autoComplete: "off",
345
+ onBlur: handleBlur,
346
+ }}
347
+ innerRef={ref}
348
+ placeholder={placeholder}
349
+ elemAfter={!includePlaceholderItem ? <Input.ClearButton /> : undefined}
350
+ clearButtonLabel="Clear selection"
351
+ onClear={() => {
352
+ // Note, by default the input clear button re-focuses the input
353
+ // We don't want to do that, but that would require a breaking change to Input
354
+ updateSelectedItem(null);
355
+ updateInputValue("");
356
+ }}
357
+ {...restProps}
358
+ onBlur={handleBlur}
359
+ />
360
+ </Popout>
361
+ );
362
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./SingleAutocomplete";
2
+ export * from "./MultiAutocomplete";