@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,389 @@
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, 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 BaseSingleAutocompleteProps,
26
+ } from "../Common-V2";
27
+
28
+ function defaultReducer(state, actionAndChanges) {
29
+ const { changes, type } = actionAndChanges;
30
+
31
+ switch (type) {
32
+ case useCombobox.stateChangeTypes.InputKeyDownEnter:
33
+ case useCombobox.stateChangeTypes.ItemClick:
34
+ return {
35
+ ...changes,
36
+ inputValue: "",
37
+ isOpen: false, // close the menu after selection for single select
38
+ highlightedIndex: 0, // reset highlight
39
+ };
40
+ default:
41
+ return changes;
42
+ }
43
+ }
44
+
45
+ export interface SearchableSelectProps<T extends DataWithId>
46
+ extends BaseSingleAutocompleteProps<T> {
47
+ name: string;
48
+ renderSelection?: (item: T | null) => ReactNode;
49
+ triggerButtonProps?: any;
50
+ }
51
+
52
+ export function SearchableSelect<T extends DataWithId>({
53
+ id,
54
+ data,
55
+ selectedItemId: controlledSelectedItemId,
56
+ inputValue: controlledInputValue,
57
+ isOpen: controlledIsOpen,
58
+ itemToKey = (item: T) => item?.id,
59
+ itemToString,
60
+ itemToSearchString,
61
+ renderSelection,
62
+ renderItem,
63
+ groupBy,
64
+ renderGroupHeading,
65
+ isVirtualized = false,
66
+ EmptyState,
67
+ name,
68
+ placeholder,
69
+ placeholderItem,
70
+ onSelectedItemIdChange,
71
+ onInputValueChange,
72
+ onIsOpenChange,
73
+ stateReducer = defaultReducer,
74
+ triggerButtonProps,
75
+ }: SearchableSelectProps<T>) {
76
+ const [uncontrolledInputValue, setUncontrolledInputValue] =
77
+ useState<string>("");
78
+ const [uncontrolledSelectedItemId, setUncontrolledSelectedItemId] = useState<
79
+ T["id"] | null
80
+ >(null);
81
+ const [uncontrolledIsOpen, setUncontrolledIsOpen] = useState<boolean>(false);
82
+
83
+ // Use controlled selectedItemId if provided, otherwise use internal state
84
+ const isSelectedItemIdControlled = controlledSelectedItemId !== undefined;
85
+ const selectedItemId = isSelectedItemIdControlled
86
+ ? controlledSelectedItemId
87
+ : uncontrolledSelectedItemId;
88
+
89
+ // Convert selectedItemId to selectedItem for internal use
90
+ const selectedItem = useMemo<T | null>(() => {
91
+ if (selectedItemId === null || selectedItemId === undefined) {
92
+ return null;
93
+ }
94
+ return data.find((item) => itemToKey(item) === selectedItemId) ?? null;
95
+ }, [selectedItemId, 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 updateSelectedItem = (newSelectedItem: T | null) => {
108
+ const newSelectedItemId = newSelectedItem
109
+ ? itemToKey(newSelectedItem)
110
+ : null;
111
+ if (!isSelectedItemIdControlled) {
112
+ setUncontrolledSelectedItemId(newSelectedItemId);
113
+ }
114
+ onSelectedItemIdChange?.(newSelectedItemId);
115
+ };
116
+
117
+ const updateInputValue = (newInputValue: string) => {
118
+ if (!isInputValueControlled) {
119
+ setUncontrolledInputValue(newInputValue);
120
+ }
121
+ onInputValueChange?.(newInputValue);
122
+ };
123
+
124
+ const updateIsOpen = (newIsOpen: boolean) => {
125
+ if (!isIsOpenControlled) {
126
+ setUncontrolledIsOpen(newIsOpen);
127
+ }
128
+ onIsOpenChange?.(newIsOpen);
129
+ };
130
+
131
+ // Use itemToSearchString if provided, otherwise fallback to itemToString
132
+ const searchStringFn =
133
+ itemToSearchString ?? ((item: T) => itemToString(item));
134
+
135
+ const filteredItems = useMemo<T[]>(
136
+ () =>
137
+ getFilteredItems(data, inputValue, searchStringFn, itemToString, {
138
+ selectedItem,
139
+ }),
140
+ [data, selectedItem, inputValue, searchStringFn, itemToString]
141
+ );
142
+
143
+ const items = useMemo<GroupedItem<T>[]>(() => {
144
+ let groupedItems: GroupedItem<T>[];
145
+
146
+ if (groupBy) {
147
+ // Group first, then add placeholderItem at the beginning
148
+ groupedItems = groupItemsWithHeadings(filteredItems, groupBy, data);
149
+ } else {
150
+ groupedItems = filteredItems;
151
+ }
152
+
153
+ // Add placeholderItem at the beginning if provided
154
+ if (placeholderItem) {
155
+ return [placeholderItem, ...groupedItems];
156
+ }
157
+
158
+ return groupedItems;
159
+ }, [filteredItems, groupBy, placeholderItem, data]);
160
+
161
+ // Filter out headings for downshift
162
+ const itemsForCombobox = useMemo<(T | ToggleItem)[]>(() => {
163
+ return items.filter(
164
+ (item): item is T | ToggleItem => !isGroupHeading(item)
165
+ );
166
+ }, [items]);
167
+
168
+ // Wrapper function to handle ToggleItem separately
169
+ const itemToStringForDownshift = useMemo(
170
+ () => createItemToStringForDownshift(itemToString),
171
+ [itemToString]
172
+ );
173
+
174
+ const comboboxProps = useCombobox<T | ToggleItem>({
175
+ inputId: id,
176
+ items: itemsForCombobox,
177
+ itemToString: itemToStringForDownshift,
178
+ defaultHighlightedIndex: 0,
179
+ selectedItem: selectedItem,
180
+ inputValue,
181
+ isOpen,
182
+ stateReducer,
183
+ onStateChange({
184
+ inputValue: newInputValue,
185
+ type,
186
+ selectedItem: newSelectedItem,
187
+ isOpen: newIsOpen,
188
+ }) {
189
+ // Handle isOpen changes for any state change type
190
+ if (
191
+ newIsOpen !== undefined &&
192
+ type !== useCombobox.stateChangeTypes.InputBlur
193
+ ) {
194
+ updateIsOpen(newIsOpen);
195
+ }
196
+
197
+ switch (type) {
198
+ case useCombobox.stateChangeTypes.InputKeyDownEnter:
199
+ case useCombobox.stateChangeTypes.ItemClick:
200
+ if (newSelectedItem) {
201
+ updateInputValue("");
202
+ // If placeholderItem is selected, set selectedItem to null
203
+ // Note: placeholderItem won't be in itemsForCombobox, so this check is for safety
204
+ if (
205
+ placeholderItem &&
206
+ isToggleItem(newSelectedItem) &&
207
+ newSelectedItem.id === placeholderItem.id
208
+ ) {
209
+ updateSelectedItem(null);
210
+ } else {
211
+ updateSelectedItem(newSelectedItem as T);
212
+ }
213
+ }
214
+ break;
215
+
216
+ case useCombobox.stateChangeTypes.InputChange:
217
+ updateInputValue(newInputValue ?? "");
218
+ break;
219
+
220
+ default:
221
+ break;
222
+ }
223
+ },
224
+ });
225
+ const {
226
+ isOpen: comboboxIsOpen,
227
+ getLabelProps,
228
+ getMenuProps,
229
+ getInputProps,
230
+ highlightedIndex: comboboxHighlightedIndex,
231
+ getItemProps,
232
+ } = comboboxProps;
233
+
234
+ // Map the highlighted index from combobox (which only knows about items) to the full items array (which includes headings)
235
+ const highlightedIndex = useHighlightedIndex(
236
+ items,
237
+ itemsForCombobox,
238
+ comboboxHighlightedIndex,
239
+ !!groupBy,
240
+ false // Single select components hardcode selectHeadings to false
241
+ );
242
+
243
+ const buttonContent = renderSelection
244
+ ? renderSelection(selectedItem)
245
+ : selectedItem
246
+ ? itemToString(selectedItem)
247
+ : placeholder;
248
+
249
+ // Handle button click to toggle popout
250
+ const handleButtonClick = () => {
251
+ updateIsOpen(!comboboxIsOpen);
252
+ };
253
+
254
+ return (
255
+ <Popout
256
+ open={comboboxIsOpen}
257
+ onOpenChange={updateIsOpen}
258
+ animationConfig={menuAnimationConfig}
259
+ onOpenAutoFocus={(e) => {
260
+ // Prevent default auto-focus behavior - we'll focus the input manually
261
+ e.preventDefault();
262
+ // Focus the input when popout opens
263
+ const inputElement = document.querySelector(
264
+ `input[name="${name}"]`
265
+ ) as HTMLInputElement;
266
+ if (inputElement) {
267
+ setTimeout(() => {
268
+ inputElement.focus();
269
+ }, 0);
270
+ }
271
+ }}
272
+ onCloseAutoFocus={(e) => {
273
+ // Prevent default auto-focus behavior
274
+ e.preventDefault();
275
+ }}
276
+ content={
277
+ <div>
278
+ {/* Input inside the Popout */}
279
+ <div style={{ marginBottom: "8px" }}>
280
+ {(() => {
281
+ const inputProps = getInputProps();
282
+ const { ref, id, ...restProps } = inputProps as any;
283
+ return (
284
+ <Input
285
+ id={id}
286
+ name={name}
287
+ inputProps={{
288
+ autoComplete: "off",
289
+ ...restProps,
290
+ }}
291
+ innerRef={ref}
292
+ placeholder={placeholder}
293
+ />
294
+ );
295
+ })()}
296
+ </div>
297
+ {/* Menu inside the Popout */}
298
+ {isVirtualized ? (
299
+ <VirtualizedAutocompleteContent
300
+ items={items}
301
+ getMenuProps={getMenuProps}
302
+ getItemProps={(props: { index: number }) => {
303
+ // Only apply getItemProps to actual items, not headings or ToggleItems
304
+ const item = items[props.index];
305
+ if (isGroupHeading(item)) {
306
+ return {};
307
+ }
308
+ if (isToggleItem(item)) {
309
+ // Handle ToggleItem (placeholderItem) clicks manually
310
+ return {
311
+ onClick: () => {
312
+ if (placeholderItem && item.id === placeholderItem.id) {
313
+ updateSelectedItem(null);
314
+ }
315
+ },
316
+ };
317
+ }
318
+ const dataItem = item as T;
319
+ // Find the index in itemsForCombobox
320
+ const itemIndex = itemsForCombobox.indexOf(dataItem);
321
+ if (itemIndex === -1) {
322
+ return {};
323
+ }
324
+ return getItemProps({ item: dataItem, index: itemIndex });
325
+ }}
326
+ selectedItems={selectedItem ? [selectedItem] : []}
327
+ highlightedIndex={highlightedIndex ?? -1}
328
+ renderItem={renderItem}
329
+ renderGroupHeading={renderGroupHeading}
330
+ EmptyState={EmptyState}
331
+ itemToKey={itemToKey}
332
+ itemToString={itemToString}
333
+ inputType="radio"
334
+ />
335
+ ) : (
336
+ <NonVirtualizedAutocompleteContent
337
+ items={items}
338
+ getMenuProps={getMenuProps}
339
+ getItemProps={(props: { index: number }) => {
340
+ // Only apply getItemProps to actual items, not headings or ToggleItems
341
+ const item = items[props.index];
342
+ if (isGroupHeading(item)) {
343
+ return {};
344
+ }
345
+ if (isToggleItem(item)) {
346
+ // Handle ToggleItem (placeholderItem) clicks manually
347
+ return {
348
+ onClick: () => {
349
+ if (placeholderItem && item.id === placeholderItem.id) {
350
+ updateSelectedItem(null);
351
+ }
352
+ },
353
+ };
354
+ }
355
+ const dataItem = item as T;
356
+ // Find the index in itemsForCombobox
357
+ const itemIndex = itemsForCombobox.indexOf(dataItem);
358
+ if (itemIndex === -1) {
359
+ return {};
360
+ }
361
+ return getItemProps({ item: dataItem, index: itemIndex });
362
+ }}
363
+ selectedItems={selectedItem ? [selectedItem] : []}
364
+ highlightedIndex={highlightedIndex ?? -1}
365
+ renderItem={renderItem}
366
+ renderGroupHeading={renderGroupHeading}
367
+ EmptyState={EmptyState}
368
+ itemToKey={itemToKey}
369
+ itemToString={itemToString}
370
+ inputType="radio"
371
+ />
372
+ )}
373
+ </div>
374
+ }
375
+ >
376
+ <div>
377
+ <SelectToggleButton
378
+ onClick={handleButtonClick}
379
+ {...triggerButtonProps}
380
+ name={name}
381
+ isOpen={comboboxIsOpen}
382
+ type="button"
383
+ >
384
+ {buttonContent}
385
+ </SelectToggleButton>
386
+ </div>
387
+ </Popout>
388
+ );
389
+ }
@@ -0,0 +1,12 @@
1
+ // These are currently experimental components
2
+ // These use the combobox pattern, but have a "dialog" popup.
3
+ // The dialog content includes an inline autocomplete which is always open.
4
+ // https://www.w3.org/WAI/ARIA/apg/patterns/combobox/
5
+ // Still need to determine wether we should have
6
+ // - The Root component be the "Combobox" and the popup be a dialog with another combobox inside
7
+ // - Or the Root component simply be a "Dialog" with a combobox inside
8
+ // currently it is the latter.
9
+ export { SearchableSelect } from "./SearchableSelect";
10
+ export type { SearchableSelectProps } from "./SearchableSelect";
11
+ export { SearchableMultiSelect } from "./SearchableMultiSelect";
12
+ export type { SearchableMultiSelectProps } from "./SearchableMultiSelect";