@sproutsocial/seeds-react-menu 1.10.8 → 1.11.0

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 (50) hide show
  1. package/.turbo/turbo-build.log +21 -15
  2. package/CHANGELOG.md +22 -0
  3. package/dist/esm/index.js +45 -24
  4. package/dist/esm/index.js.map +1 -1
  5. package/dist/esm/v2/index.js +1 -1
  6. package/dist/esm/v2/index.js.map +1 -1
  7. package/dist/esm/v3/index.js +800 -0
  8. package/dist/esm/v3/index.js.map +1 -0
  9. package/dist/index.d.mts +22 -22
  10. package/dist/index.d.ts +22 -22
  11. package/dist/index.js +35 -14
  12. package/dist/index.js.map +1 -1
  13. package/dist/v2/index.js +1 -1
  14. package/dist/v2/index.js.map +1 -1
  15. package/dist/v3/index.d.mts +295 -0
  16. package/dist/v3/index.d.ts +295 -0
  17. package/dist/v3/index.js +868 -0
  18. package/dist/v3/index.js.map +1 -0
  19. package/package.json +7 -1
  20. package/src/MenuGroup/MenuGroup.tsx +16 -3
  21. package/src/MenuGroup/__tests__/MenuGroup.test.tsx +19 -0
  22. package/src/MenuItem/MenuItem.tsx +21 -10
  23. package/src/MenuItem/__tests__/MenuItem.test.tsx +19 -0
  24. package/src/v2/Common-V2/ComboboxContent.tsx +1 -1
  25. package/src/v3/Common/ComboboxItem.tsx +82 -0
  26. package/src/v3/Common/ComboboxStyles.tsx +475 -0
  27. package/src/v3/Common/SelectGroup.tsx +28 -0
  28. package/src/v3/Common/SelectIcons.tsx +26 -0
  29. package/src/v3/Common/SelectItem.tsx +108 -0
  30. package/src/v3/Common/SelectStyles.tsx +126 -0
  31. package/src/v3/Common/VirtualizedComboboxList.tsx +200 -0
  32. package/src/v3/Common/groupItems.ts +12 -0
  33. package/src/v3/Common/types.ts +3 -0
  34. package/src/v3/ModalStories.stories.tsx +181 -0
  35. package/src/v3/MultiCombobox.stories.tsx +322 -0
  36. package/src/v3/MultiCombobox.tsx +562 -0
  37. package/src/v3/MultiSearchableSelect.stories.tsx +389 -0
  38. package/src/v3/MultiSearchableSelect.tsx +545 -0
  39. package/src/v3/MultiSelect.stories.tsx +300 -0
  40. package/src/v3/MultiSelect.tsx +498 -0
  41. package/src/v3/SeedsPortal.tsx +35 -0
  42. package/src/v3/SingleCombobox.stories.tsx +169 -0
  43. package/src/v3/SingleCombobox.tsx +304 -0
  44. package/src/v3/SingleSearchableSelect.stories.tsx +239 -0
  45. package/src/v3/SingleSearchableSelect.tsx +319 -0
  46. package/src/v3/SingleSelect.stories.tsx +227 -0
  47. package/src/v3/SingleSelect.tsx +245 -0
  48. package/src/v3/index.ts +7 -0
  49. package/src/v3/storyData.tsx +117 -0
  50. package/tsup.config.ts +1 -0
@@ -0,0 +1,319 @@
1
+ import * as React from "react";
2
+ import { Combobox } from "@base-ui/react/combobox";
3
+ import { useVirtualizer } from "@tanstack/react-virtual";
4
+ import { useSeedsPortalContainer } from "./SeedsPortal";
5
+ import ComboboxItem from "./Common/ComboboxItem";
6
+ import VirtualizedComboboxList from "./Common/VirtualizedComboboxList";
7
+ import type { DataWithId } from "./Common/types";
8
+ import { groupItems } from "./Common/groupItems";
9
+ import { Field } from "./Common/SelectStyles";
10
+ import {
11
+ ComboboxGroup,
12
+ ComboboxGroupLabel,
13
+ ComboboxSeparator,
14
+ ComboboxPositioner,
15
+ ComboboxEmpty,
16
+ ComboboxStatus,
17
+ SearchableSelectTrigger,
18
+ SearchableSelectTriggerIcon,
19
+ SearchableSelectPlaceholder,
20
+ SearchableSelectPopup,
21
+ SearchableSelectSearchContainer,
22
+ SearchableSelectInput,
23
+ SearchableSelectList,
24
+ } from "./Common/ComboboxStyles";
25
+ import { StyledChevron, ChevronIcon } from "./Common/SelectIcons";
26
+
27
+ // ─── Props ────────────────────────────────────────────────────────────────────
28
+
29
+ interface SingleSearchableSelectBaseProps {
30
+ id?: string;
31
+ placeholder?: string;
32
+ searchPlaceholder?: string;
33
+ emptyText?: string;
34
+ isLoading?: boolean;
35
+ loadingText?: string;
36
+ }
37
+
38
+ interface SingleSearchableSelectDataBaseProps<T extends DataWithId>
39
+ extends SingleSearchableSelectBaseProps {
40
+ data: T[];
41
+ itemToString: (item: T | null) => string;
42
+ renderItem?: (item: T) => React.ReactNode;
43
+ renderSelection?: (item: T) => React.ReactNode;
44
+ inputType?: "icon" | "radio" | "checkbox" | "none";
45
+ selectedItemId?: T["id"] | null;
46
+ defaultSelectedItemId?: T["id"] | null;
47
+ onSelectedItemIdChange?: (id: T["id"] | null) => void;
48
+ children?: never;
49
+ }
50
+
51
+ interface SingleSearchableSelectDataProps<T extends DataWithId>
52
+ extends SingleSearchableSelectDataBaseProps<T> {
53
+ isVirtualized?: false;
54
+ groupBy?: (item: T) => string;
55
+ renderGroupHeading?: (label: string) => React.ReactNode;
56
+ }
57
+
58
+ interface SingleSearchableSelectVirtualizedProps<T extends DataWithId>
59
+ extends SingleSearchableSelectDataBaseProps<T> {
60
+ isVirtualized: true;
61
+ groupBy?: never;
62
+ renderGroupHeading?: never;
63
+ }
64
+
65
+ interface SingleSearchableSelectChildrenProps
66
+ extends SingleSearchableSelectBaseProps {
67
+ children: React.ReactNode;
68
+ selectedItemId?: string | number | null;
69
+ defaultSelectedItemId?: string | number | null;
70
+ onSelectedItemIdChange?: (id: string | number | null) => void;
71
+ data?: never;
72
+ itemToString?: never;
73
+ renderItem?: never;
74
+ renderSelection?: never;
75
+ inputType?: never;
76
+ groupBy?: never;
77
+ renderGroupHeading?: never;
78
+ isVirtualized?: never;
79
+ }
80
+
81
+ export type SingleSearchableSelectProps<T extends DataWithId> =
82
+ | SingleSearchableSelectDataProps<T>
83
+ | SingleSearchableSelectVirtualizedProps<T>
84
+ | SingleSearchableSelectChildrenProps;
85
+
86
+ // ─── Component ────────────────────────────────────────────────────────────────
87
+
88
+ export default function SingleSearchableSelect<T extends DataWithId>(
89
+ props: SingleSearchableSelectProps<T>
90
+ ) {
91
+ const {
92
+ id,
93
+ placeholder = "Select...",
94
+ searchPlaceholder = "Search...",
95
+ emptyText = "No results found.",
96
+ isLoading = false,
97
+ loadingText = "Loading...",
98
+ } = props;
99
+
100
+ const isChildrenMode = "children" in props && props.children != null;
101
+
102
+ type DataProps =
103
+ | SingleSearchableSelectDataProps<T>
104
+ | SingleSearchableSelectVirtualizedProps<T>;
105
+
106
+ const data = isChildrenMode ? ([] as T[]) : (props as DataProps).data;
107
+ const itemToString = isChildrenMode
108
+ ? (_item: T | null) => ""
109
+ : (props as DataProps).itemToString;
110
+ const renderItem = isChildrenMode
111
+ ? undefined
112
+ : (props as DataProps).renderItem;
113
+ const renderSelection = isChildrenMode
114
+ ? undefined
115
+ : (props as DataProps).renderSelection;
116
+ const inputType = isChildrenMode
117
+ ? "icon"
118
+ : (props as DataProps).inputType ?? "icon";
119
+ const isVirtualized = isChildrenMode
120
+ ? false
121
+ : (props as DataProps).isVirtualized ?? false;
122
+ const groupBy = isChildrenMode
123
+ ? undefined
124
+ : isVirtualized
125
+ ? undefined
126
+ : (props as SingleSearchableSelectDataProps<T>).groupBy;
127
+ const renderGroupHeading = isChildrenMode
128
+ ? undefined
129
+ : isVirtualized
130
+ ? undefined
131
+ : (props as SingleSearchableSelectDataProps<T>).renderGroupHeading;
132
+ const children = isChildrenMode
133
+ ? (props as SingleSearchableSelectChildrenProps).children
134
+ : undefined;
135
+
136
+ const selectedItemId = props.selectedItemId;
137
+ const defaultSelectedItemId = props.defaultSelectedItemId;
138
+ const onSelectedItemIdChange = props.onSelectedItemIdChange;
139
+
140
+ const { containerRef, portalContainer } = useSeedsPortalContainer();
141
+ const [open, setOpen] = React.useState(false);
142
+ const virtualizerRef = React.useRef<ReturnType<
143
+ typeof useVirtualizer<HTMLDivElement, Element>
144
+ > | null>(null);
145
+
146
+ const isControlled = selectedItemId !== undefined;
147
+
148
+ const idToItem = React.useCallback(
149
+ (id: T["id"] | null | undefined): T | null => {
150
+ if (id == null) return null;
151
+ return data.find((d) => String(d.id) === String(id)) ?? null;
152
+ },
153
+ [data]
154
+ );
155
+
156
+ const [internalValue, setInternalValue] = React.useState<T | null>(() =>
157
+ idToItem(defaultSelectedItemId as T["id"] | null)
158
+ );
159
+
160
+ const value = isControlled
161
+ ? idToItem(selectedItemId as T["id"] | null)
162
+ : internalValue;
163
+
164
+ function handleValueChange(newItem: T | null) {
165
+ if (!isControlled) setInternalValue(newItem);
166
+ if (onSelectedItemIdChange) {
167
+ onSelectedItemIdChange(newItem ? newItem.id : null);
168
+ }
169
+ }
170
+
171
+ const groups = React.useMemo(
172
+ () => (groupBy ? groupItems(data, groupBy) : null),
173
+ [data, groupBy]
174
+ );
175
+
176
+ return (
177
+ <Field>
178
+ <div ref={containerRef} style={{ position: "relative" }} />
179
+ <Combobox.Root
180
+ id={id}
181
+ value={value}
182
+ onValueChange={handleValueChange}
183
+ itemToStringLabel={itemToString}
184
+ isItemEqualToValue={(a, b) => a != null && b != null && a.id === b.id}
185
+ items={
186
+ isVirtualized ? data : groups ?? (isChildrenMode ? undefined : data)
187
+ }
188
+ virtualized={isVirtualized}
189
+ open={open}
190
+ onOpenChange={setOpen}
191
+ onItemHighlighted={
192
+ isVirtualized
193
+ ? (item, { reason, index }) => {
194
+ const virt = virtualizerRef.current;
195
+ if (!item || !virt) return;
196
+ const isStart = index === 0;
197
+ const isEnd = index === virt.options.count - 1;
198
+ if (
199
+ reason === "none" ||
200
+ (reason === "keyboard" && (isStart || isEnd))
201
+ ) {
202
+ queueMicrotask(() => {
203
+ virt.scrollToIndex(index, {
204
+ align: isEnd ? "start" : "end",
205
+ });
206
+ });
207
+ }
208
+ }
209
+ : undefined
210
+ }
211
+ >
212
+ <SearchableSelectTrigger id={id}>
213
+ <SearchableSelectPlaceholder>
214
+ <Combobox.Value placeholder={placeholder}>
215
+ {value
216
+ ? renderSelection
217
+ ? renderSelection(value)
218
+ : itemToString(value)
219
+ : null}
220
+ </Combobox.Value>
221
+ </SearchableSelectPlaceholder>
222
+ <SearchableSelectTriggerIcon>
223
+ <StyledChevron data-chevron>
224
+ <ChevronIcon />
225
+ </StyledChevron>
226
+ </SearchableSelectTriggerIcon>
227
+ </SearchableSelectTrigger>
228
+
229
+ <Combobox.Portal container={portalContainer}>
230
+ <ComboboxPositioner sideOffset={4}>
231
+ <SearchableSelectPopup
232
+ aria-busy={isLoading || undefined}
233
+ style={{ display: "flex", flexDirection: "column" }}
234
+ >
235
+ <SearchableSelectSearchContainer>
236
+ <SearchableSelectInput
237
+ placeholder={searchPlaceholder}
238
+ autoFocus
239
+ />
240
+ </SearchableSelectSearchContainer>
241
+ <ComboboxStatus>{isLoading ? loadingText : null}</ComboboxStatus>
242
+ <ComboboxEmpty>{isLoading ? null : emptyText}</ComboboxEmpty>
243
+ <SearchableSelectList
244
+ style={
245
+ isVirtualized
246
+ ? {
247
+ padding: 0,
248
+ flex: 1,
249
+ minHeight: 0,
250
+ display: "flex",
251
+ flexDirection: "column",
252
+ }
253
+ : undefined
254
+ }
255
+ >
256
+ {isVirtualized ? (
257
+ <VirtualizedComboboxList
258
+ open={open}
259
+ virtualizerRef={virtualizerRef}
260
+ value={value}
261
+ groups={null}
262
+ data={data}
263
+ itemToString={itemToString}
264
+ inputType={inputType}
265
+ renderItem={renderItem}
266
+ />
267
+ ) : children ? (
268
+ children
269
+ ) : groups ? (
270
+ (group: { value: string; items: T[] }, index: number) => (
271
+ <React.Fragment key={group.value}>
272
+ <ComboboxGroup items={group.items}>
273
+ <ComboboxGroupLabel>
274
+ {renderGroupHeading
275
+ ? renderGroupHeading(group.value)
276
+ : group.value}
277
+ </ComboboxGroupLabel>
278
+ <Combobox.Collection>
279
+ {(item: T) => (
280
+ <ComboboxItem
281
+ key={item.id}
282
+ value={item}
283
+ itemId={String(item.id)}
284
+ label={itemToString(item)}
285
+ inputType={inputType}
286
+ renderItem={
287
+ renderItem
288
+ ? () => renderItem(item)
289
+ : () => itemToString(item)
290
+ }
291
+ />
292
+ )}
293
+ </Combobox.Collection>
294
+ </ComboboxGroup>
295
+ {index < groups.length - 1 && <ComboboxSeparator />}
296
+ </React.Fragment>
297
+ )
298
+ ) : (
299
+ (item: T) => (
300
+ <ComboboxItem
301
+ key={item.id}
302
+ value={item}
303
+ itemId={String(item.id)}
304
+ label={itemToString(item)}
305
+ inputType={inputType}
306
+ renderItem={
307
+ renderItem ? () => renderItem(item) : undefined
308
+ }
309
+ />
310
+ )
311
+ )}
312
+ </SearchableSelectList>
313
+ </SearchableSelectPopup>
314
+ </ComboboxPositioner>
315
+ </Combobox.Portal>
316
+ </Combobox.Root>
317
+ </Field>
318
+ );
319
+ }
@@ -0,0 +1,227 @@
1
+ import React from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react";
3
+ import { FormField } from "@sproutsocial/seeds-react-form-field";
4
+ import SingleSelect from "./SingleSelect";
5
+ import SelectItem from "./Common/SelectItem";
6
+ import SelectGroup from "./Common/SelectGroup";
7
+ import { books, itemToString, renderItem } from "./storyData";
8
+
9
+ const meta: Meta<typeof SingleSelect> = {
10
+ title: "Really Under Development/V3/Single Select",
11
+ component: SingleSelect,
12
+ };
13
+
14
+ export default meta;
15
+ type Story = StoryObj<typeof SingleSelect>;
16
+
17
+ export const Basic: Story = {
18
+ name: "Basic",
19
+ render: () => (
20
+ <div style={{ width: "300px" }}>
21
+ <FormField label="Pick a book">
22
+ {({ id }) => (
23
+ <SingleSelect
24
+ id={id}
25
+ data={books}
26
+ itemToString={itemToString}
27
+ placeholder="Select a book"
28
+ />
29
+ )}
30
+ </FormField>
31
+ </div>
32
+ ),
33
+ };
34
+
35
+ export const IncludePlaceholderItem: Story = {
36
+ name: "Include placeholder item",
37
+ render: () => (
38
+ <div style={{ width: "300px" }}>
39
+ <FormField label="Pick a book">
40
+ {({ id }) => (
41
+ <SingleSelect
42
+ id={id}
43
+ data={books}
44
+ itemToString={itemToString}
45
+ placeholder="Select a book"
46
+ includePlaceholderItem
47
+ />
48
+ )}
49
+ </FormField>
50
+ </div>
51
+ ),
52
+ };
53
+
54
+ export const CustomRenderItem: Story = {
55
+ name: "Custom render item",
56
+ render: () => (
57
+ <div style={{ width: "300px" }}>
58
+ <FormField label="Pick a book">
59
+ {({ id }) => (
60
+ <SingleSelect
61
+ id={id}
62
+ data={books}
63
+ itemToString={itemToString}
64
+ renderItem={renderItem}
65
+ placeholder="Select a book"
66
+ />
67
+ )}
68
+ </FormField>
69
+ </div>
70
+ ),
71
+ };
72
+
73
+ export const Radio: Story = {
74
+ name: "Radio",
75
+ render: () => (
76
+ <div style={{ width: "300px" }}>
77
+ <FormField label="Pick a book">
78
+ {({ id }) => (
79
+ <SingleSelect
80
+ id={id}
81
+ data={books}
82
+ itemToString={itemToString}
83
+ renderItem={renderItem}
84
+ placeholder="Select a book"
85
+ inputType="radio"
86
+ />
87
+ )}
88
+ </FormField>
89
+ </div>
90
+ ),
91
+ };
92
+
93
+ export const Grouped: Story = {
94
+ name: "Grouped",
95
+ render: () => (
96
+ <div style={{ width: "300px" }}>
97
+ <FormField label="Pick a book">
98
+ {({ id }) => (
99
+ <SingleSelect
100
+ id={id}
101
+ data={books}
102
+ itemToString={itemToString}
103
+ renderItem={renderItem}
104
+ placeholder="Select a book"
105
+ groupBy={(book) => book.author}
106
+ />
107
+ )}
108
+ </FormField>
109
+ </div>
110
+ ),
111
+ };
112
+
113
+ export const DefaultSelected: Story = {
114
+ name: "Default selected",
115
+ render: () => (
116
+ <div style={{ width: "300px" }}>
117
+ <FormField label="Pick a book">
118
+ {({ id }) => (
119
+ <SingleSelect
120
+ id={id}
121
+ data={books}
122
+ itemToString={itemToString}
123
+ renderItem={renderItem}
124
+ placeholder="Select a book"
125
+ defaultSelectedItemId="book-3"
126
+ />
127
+ )}
128
+ </FormField>
129
+ </div>
130
+ ),
131
+ };
132
+
133
+ export const Controlled: Story = {
134
+ name: "Controlled",
135
+ render: () => {
136
+ const [selectedId, setSelectedId] = React.useState<string | null>("book-1");
137
+ const selected = books.find((b) => b.id === selectedId);
138
+ return (
139
+ <div
140
+ style={{
141
+ display: "flex",
142
+ flexDirection: "column",
143
+ gap: 16,
144
+ widows: "300px",
145
+ }}
146
+ >
147
+ <FormField label="Pick a book">
148
+ {({ id }) => (
149
+ <SingleSelect
150
+ id={id}
151
+ data={books}
152
+ itemToString={itemToString}
153
+ renderItem={renderItem}
154
+ placeholder="Select a book"
155
+ selectedItemId={selectedId}
156
+ onSelectedItemIdChange={(id: string | null) => setSelectedId(id)}
157
+ />
158
+ )}
159
+ </FormField>
160
+ <div style={{ fontSize: 13 }}>
161
+ Selected: <strong>{selected?.title ?? "none"}</strong>
162
+ </div>
163
+ <div style={{ display: "flex", gap: 8 }}>
164
+ <button onClick={() => setSelectedId(null)}>Clear</button>
165
+ <button onClick={() => setSelectedId("book-5")}>Select 1984</button>
166
+ </div>
167
+ </div>
168
+ );
169
+ },
170
+ };
171
+
172
+ export const CustomSelection: Story = {
173
+ name: "Custom renderSelection",
174
+ render: () => (
175
+ <div style={{ width: "300px" }}>
176
+ <FormField label="Pick a book">
177
+ {({ id }) => (
178
+ <SingleSelect
179
+ id={id}
180
+ data={books}
181
+ itemToString={itemToString}
182
+ renderItem={renderItem}
183
+ placeholder="Select a book"
184
+ renderSelection={(item) => (
185
+ <span>
186
+ <span style={{ fontWeight: "bold" }}>{item.title}</span>
187
+ <span
188
+ style={{ fontSize: "11px", color: "#888", marginLeft: "6px" }}
189
+ >
190
+ by {item.author}
191
+ </span>
192
+ </span>
193
+ )}
194
+ />
195
+ )}
196
+ </FormField>
197
+ </div>
198
+ ),
199
+ };
200
+
201
+ export const Children: Story = {
202
+ name: "Children API",
203
+ render: () => (
204
+ <div style={{ width: "300px" }}>
205
+ <FormField label="Pick a book">
206
+ {({ id }) => (
207
+ <SingleSelect id={id} placeholder="Select a book">
208
+ <SelectGroup label="Tolstoy" showSeparator>
209
+ <SelectItem value="book-2" label="War and Peace" />
210
+ <SelectItem value="book-9" label="Anna Karenina" />
211
+ </SelectGroup>
212
+ <SelectGroup label="Dostoevsky" showSeparator>
213
+ <SelectItem value="book-3" label="The Idiot" />
214
+ <SelectItem value="book-8" label="The Brothers Karamazov" />
215
+ <SelectItem value="book-10" label="Crime and Punishment" />
216
+ </SelectGroup>
217
+ <SelectGroup label="Other">
218
+ <SelectItem value="book-1" label="To Kill a Mockingbird" />
219
+ <SelectItem value="book-5" label="1984" />
220
+ <SelectItem value="book-7" label="Meditations" />
221
+ </SelectGroup>
222
+ </SingleSelect>
223
+ )}
224
+ </FormField>
225
+ </div>
226
+ ),
227
+ };