@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,304 @@
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
+ ComboboxInputGroup,
12
+ ComboboxInput,
13
+ ComboboxActionButtons,
14
+ ComboboxClear,
15
+ ComboboxTrigger,
16
+ ComboboxPositioner,
17
+ ComboboxPopup,
18
+ ComboboxList,
19
+ ComboboxEmpty,
20
+ ComboboxGroup,
21
+ ComboboxGroupLabel,
22
+ ComboboxSeparator,
23
+ } from "./Common/ComboboxStyles";
24
+ import { ClearIcon, StyledChevron, ChevronIcon } from "./Common/SelectIcons";
25
+
26
+ // ─── Props ────────────────────────────────────────────────────────────────────
27
+
28
+ interface SingleComboboxBaseProps {
29
+ id?: string;
30
+ placeholder?: string;
31
+ emptyText?: string;
32
+ }
33
+
34
+ interface SingleComboboxDataBaseProps<T extends DataWithId>
35
+ extends SingleComboboxBaseProps {
36
+ data: T[];
37
+ itemToString: (item: T | null) => string;
38
+ renderItem?: (item: T) => React.ReactNode;
39
+ inputType?: "icon" | "radio" | "checkbox" | "none";
40
+ selectedItemId?: T["id"] | null;
41
+ defaultSelectedItemId?: T["id"] | null;
42
+ onSelectedItemIdChange?: (id: T["id"] | null) => void;
43
+ children?: never;
44
+ }
45
+
46
+ interface SingleComboboxDataProps<T extends DataWithId>
47
+ extends SingleComboboxDataBaseProps<T> {
48
+ isVirtualized?: false;
49
+ groupBy?: (item: T) => string;
50
+ renderGroupHeading?: (label: string) => React.ReactNode;
51
+ }
52
+
53
+ interface SingleComboboxVirtualizedProps<T extends DataWithId>
54
+ extends SingleComboboxDataBaseProps<T> {
55
+ isVirtualized: true;
56
+ groupBy?: never;
57
+ renderGroupHeading?: never;
58
+ }
59
+
60
+ interface SingleComboboxChildrenProps extends SingleComboboxBaseProps {
61
+ children: React.ReactNode;
62
+ selectedItemId?: string | number | null;
63
+ defaultSelectedItemId?: string | number | null;
64
+ onSelectedItemIdChange?: (id: string | number | null) => void;
65
+ data?: never;
66
+ itemToString?: never;
67
+ renderItem?: never;
68
+ inputType?: never;
69
+ groupBy?: never;
70
+ renderGroupHeading?: never;
71
+ isVirtualized?: never;
72
+ }
73
+
74
+ export type SingleComboboxProps<T extends DataWithId> =
75
+ | SingleComboboxDataProps<T>
76
+ | SingleComboboxVirtualizedProps<T>
77
+ | SingleComboboxChildrenProps;
78
+
79
+ // ─── Component ────────────────────────────────────────────────────────────────
80
+
81
+ export default function SingleCombobox<T extends DataWithId>(
82
+ props: SingleComboboxProps<T>
83
+ ) {
84
+ const {
85
+ id,
86
+ placeholder = "Search...",
87
+ emptyText = "No results found.",
88
+ } = props;
89
+
90
+ const isChildrenMode = "children" in props && props.children != null;
91
+
92
+ type DataProps =
93
+ | SingleComboboxDataProps<T>
94
+ | SingleComboboxVirtualizedProps<T>;
95
+
96
+ const data = isChildrenMode ? ([] as T[]) : (props as DataProps).data;
97
+ const itemToString = isChildrenMode
98
+ ? (_item: T | null) => ""
99
+ : (props as DataProps).itemToString;
100
+ const renderItem = isChildrenMode
101
+ ? undefined
102
+ : (props as DataProps).renderItem;
103
+ const inputType = isChildrenMode
104
+ ? "icon"
105
+ : (props as DataProps).inputType ?? "icon";
106
+ const isVirtualized = isChildrenMode
107
+ ? false
108
+ : (props as DataProps).isVirtualized ?? false;
109
+ const groupBy = isChildrenMode
110
+ ? undefined
111
+ : isVirtualized
112
+ ? undefined
113
+ : (props as SingleComboboxDataProps<T>).groupBy;
114
+ const renderGroupHeading = isChildrenMode
115
+ ? undefined
116
+ : isVirtualized
117
+ ? undefined
118
+ : (props as SingleComboboxDataProps<T>).renderGroupHeading;
119
+ const children = isChildrenMode
120
+ ? (props as SingleComboboxChildrenProps).children
121
+ : undefined;
122
+
123
+ const selectedItemId = props.selectedItemId;
124
+ const defaultSelectedItemId = props.defaultSelectedItemId;
125
+ const onSelectedItemIdChange = props.onSelectedItemIdChange;
126
+
127
+ const { containerRef, portalContainer } = useSeedsPortalContainer();
128
+ const [open, setOpen] = React.useState(false);
129
+ const virtualizerRef = React.useRef<ReturnType<
130
+ typeof useVirtualizer<HTMLDivElement, Element>
131
+ > | null>(null);
132
+
133
+ const isControlled = selectedItemId !== undefined;
134
+
135
+ const idToItem = React.useCallback(
136
+ (id: T["id"] | null | undefined): T | null => {
137
+ if (id == null) return null;
138
+ return data.find((d) => String(d.id) === String(id)) ?? null;
139
+ },
140
+ [data]
141
+ );
142
+
143
+ const [internalValue, setInternalValue] = React.useState<T | null>(() =>
144
+ idToItem(defaultSelectedItemId as T["id"] | null)
145
+ );
146
+
147
+ const value = isControlled
148
+ ? idToItem(selectedItemId as T["id"] | null)
149
+ : internalValue;
150
+
151
+ function handleValueChange(newItem: T | null) {
152
+ if (!isControlled) {
153
+ setInternalValue(newItem);
154
+ }
155
+ if (onSelectedItemIdChange) {
156
+ onSelectedItemIdChange(newItem ? newItem.id : null);
157
+ }
158
+ }
159
+
160
+ const groups = React.useMemo(
161
+ () => (groupBy ? groupItems(data, groupBy) : null),
162
+ [data, groupBy]
163
+ );
164
+
165
+ return (
166
+ <Field>
167
+ <div ref={containerRef} style={{ position: "relative" }} />
168
+ <Combobox.Root
169
+ id={id}
170
+ value={value}
171
+ onValueChange={handleValueChange}
172
+ itemToStringLabel={itemToString}
173
+ isItemEqualToValue={(a, b) => a != null && b != null && a.id === b.id}
174
+ items={
175
+ isVirtualized ? data : groups ?? (isChildrenMode ? undefined : data)
176
+ }
177
+ virtualized={isVirtualized}
178
+ open={open}
179
+ onOpenChange={setOpen}
180
+ onItemHighlighted={
181
+ isVirtualized
182
+ ? (item, { reason, index }) => {
183
+ const virt = virtualizerRef.current;
184
+ if (!item || !virt) return;
185
+ const isStart = index === 0;
186
+ const isEnd = index === virt.options.count - 1;
187
+ if (
188
+ reason === "none" ||
189
+ (reason === "keyboard" && (isStart || isEnd))
190
+ ) {
191
+ queueMicrotask(() => {
192
+ virt.scrollToIndex(index, {
193
+ align: isEnd ? "start" : "end",
194
+ });
195
+ });
196
+ }
197
+ }
198
+ : undefined
199
+ }
200
+ >
201
+ <ComboboxInputGroup>
202
+ <ComboboxInput placeholder={placeholder} id={id} />
203
+ <ComboboxActionButtons>
204
+ <ComboboxClear aria-label="Clear selection">
205
+ <ClearIcon />
206
+ </ComboboxClear>
207
+ <ComboboxTrigger aria-label="Open popup">
208
+ <StyledChevron $isOpen={open}>
209
+ <ChevronIcon />
210
+ </StyledChevron>
211
+ </ComboboxTrigger>
212
+ </ComboboxActionButtons>
213
+ </ComboboxInputGroup>
214
+
215
+ <Combobox.Portal container={portalContainer}>
216
+ <ComboboxPositioner sideOffset={4}>
217
+ <ComboboxPopup
218
+ style={
219
+ isVirtualized
220
+ ? { display: "flex", flexDirection: "column" }
221
+ : undefined
222
+ }
223
+ >
224
+ <ComboboxEmpty>{emptyText}</ComboboxEmpty>
225
+ <ComboboxList
226
+ style={
227
+ isVirtualized
228
+ ? {
229
+ padding: 0,
230
+ flex: 1,
231
+ minHeight: 0,
232
+ display: "flex",
233
+ flexDirection: "column",
234
+ }
235
+ : undefined
236
+ }
237
+ >
238
+ {isVirtualized ? (
239
+ <VirtualizedComboboxList
240
+ open={open}
241
+ virtualizerRef={virtualizerRef}
242
+ value={value}
243
+ groups={groups}
244
+ data={data}
245
+ itemToString={itemToString}
246
+ inputType={inputType}
247
+ renderItem={renderItem}
248
+ renderGroupHeading={renderGroupHeading}
249
+ />
250
+ ) : children ? (
251
+ children
252
+ ) : groups ? (
253
+ (group: { value: string; items: T[] }, index: number) => (
254
+ <React.Fragment key={group.value}>
255
+ <ComboboxGroup items={group.items}>
256
+ <ComboboxGroupLabel>
257
+ {renderGroupHeading
258
+ ? renderGroupHeading(group.value)
259
+ : group.value}
260
+ </ComboboxGroupLabel>
261
+ <Combobox.Collection>
262
+ {(item: T) => (
263
+ <ComboboxItem
264
+ key={item.id}
265
+ value={item}
266
+ itemId={String(item.id)}
267
+ label={itemToString(item)}
268
+ inputType={inputType}
269
+ renderItem={
270
+ renderItem
271
+ ? () => renderItem(item)
272
+ : () => itemToString(item)
273
+ }
274
+ />
275
+ )}
276
+ </Combobox.Collection>
277
+ </ComboboxGroup>
278
+ {index < groups.length - 1 && <ComboboxSeparator />}
279
+ </React.Fragment>
280
+ )
281
+ ) : (
282
+ (item: T) => (
283
+ <ComboboxItem
284
+ key={item.id}
285
+ value={item}
286
+ itemId={String(item.id)}
287
+ label={itemToString(item)}
288
+ inputType={inputType}
289
+ renderItem={
290
+ renderItem
291
+ ? () => renderItem(item)
292
+ : () => itemToString(item)
293
+ }
294
+ />
295
+ )
296
+ )}
297
+ </ComboboxList>
298
+ </ComboboxPopup>
299
+ </ComboboxPositioner>
300
+ </Combobox.Portal>
301
+ </Combobox.Root>
302
+ </Field>
303
+ );
304
+ }
@@ -0,0 +1,239 @@
1
+ import React from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react";
3
+ import { FormField } from "@sproutsocial/seeds-react-form-field";
4
+ import SingleSearchableSelect from "./SingleSearchableSelect";
5
+ import { books, largeBookSet, itemToString, renderItem } from "./storyData";
6
+
7
+ const meta: Meta<typeof SingleSearchableSelect> = {
8
+ title: "Really Under Development/V3/Single Searchable Select",
9
+ component: SingleSearchableSelect,
10
+ };
11
+
12
+ export default meta;
13
+ type Story = StoryObj<typeof SingleSearchableSelect>;
14
+
15
+ export const Basic: Story = {
16
+ name: "Basic",
17
+ render: () => (
18
+ <div style={{ width: "300px" }}>
19
+ <FormField label="Pick a book">
20
+ {({ id }) => (
21
+ <SingleSearchableSelect
22
+ id={id}
23
+ data={books}
24
+ itemToString={itemToString}
25
+ placeholder="Select a book"
26
+ searchPlaceholder="Search books..."
27
+ />
28
+ )}
29
+ </FormField>
30
+ </div>
31
+ ),
32
+ };
33
+
34
+ export const CustomRenderItem: Story = {
35
+ name: "Custom render item",
36
+ render: () => (
37
+ <div style={{ width: "300px" }}>
38
+ <FormField label="Pick a book">
39
+ {({ id }) => (
40
+ <SingleSearchableSelect
41
+ id={id}
42
+ data={books}
43
+ itemToString={itemToString}
44
+ renderItem={renderItem}
45
+ placeholder="Select a book"
46
+ searchPlaceholder="Search books..."
47
+ />
48
+ )}
49
+ </FormField>
50
+ </div>
51
+ ),
52
+ };
53
+
54
+ export const Grouped: Story = {
55
+ name: "Grouped",
56
+ render: () => (
57
+ <div style={{ width: "300px" }}>
58
+ <FormField label="Pick a book">
59
+ {({ id }) => (
60
+ <SingleSearchableSelect
61
+ id={id}
62
+ data={books}
63
+ itemToString={itemToString}
64
+ renderItem={renderItem}
65
+ placeholder="Select a book"
66
+ searchPlaceholder="Search books..."
67
+ groupBy={(book) => book.author}
68
+ />
69
+ )}
70
+ </FormField>
71
+ </div>
72
+ ),
73
+ };
74
+
75
+ export const DefaultSelected: Story = {
76
+ name: "Default selected",
77
+ render: () => (
78
+ <div style={{ width: "300px" }}>
79
+ <FormField label="Pick a book">
80
+ {({ id }) => (
81
+ <SingleSearchableSelect
82
+ id={id}
83
+ data={books}
84
+ itemToString={itemToString}
85
+ renderItem={renderItem}
86
+ placeholder="Select a book"
87
+ searchPlaceholder="Search books..."
88
+ defaultSelectedItemId="book-3"
89
+ />
90
+ )}
91
+ </FormField>
92
+ </div>
93
+ ),
94
+ };
95
+
96
+ export const Controlled: Story = {
97
+ name: "Controlled",
98
+ render: () => {
99
+ const [selectedId, setSelectedId] = React.useState<string | null>("book-1");
100
+ const selected = books.find((b) => b.id === selectedId);
101
+ return (
102
+ <div
103
+ style={{
104
+ width: "300px",
105
+ display: "flex",
106
+ flexDirection: "column",
107
+ gap: 16,
108
+ }}
109
+ >
110
+ <FormField label="Pick a book">
111
+ {({ id }) => (
112
+ <SingleSearchableSelect
113
+ id={id}
114
+ data={books}
115
+ itemToString={itemToString}
116
+ renderItem={renderItem}
117
+ placeholder="Select a book"
118
+ searchPlaceholder="Search books..."
119
+ selectedItemId={selectedId}
120
+ onSelectedItemIdChange={(id: string | null) => setSelectedId(id)}
121
+ />
122
+ )}
123
+ </FormField>
124
+ <div style={{ fontSize: 13 }}>
125
+ Selected: <strong>{selected?.title ?? "none"}</strong>
126
+ </div>
127
+ <div style={{ display: "flex", gap: 8 }}>
128
+ <button onClick={() => setSelectedId(null)}>Clear</button>
129
+ <button onClick={() => setSelectedId("book-5")}>Select 1984</button>
130
+ </div>
131
+ </div>
132
+ );
133
+ },
134
+ };
135
+
136
+ export const CustomSelection: Story = {
137
+ name: "Custom renderSelection",
138
+ render: () => (
139
+ <div style={{ width: "300px" }}>
140
+ <FormField label="Pick a book">
141
+ {({ id }) => (
142
+ <SingleSearchableSelect
143
+ id={id}
144
+ data={books}
145
+ itemToString={itemToString}
146
+ renderItem={renderItem}
147
+ placeholder="Select a book"
148
+ searchPlaceholder="Search books..."
149
+ renderSelection={(item) => (
150
+ <span>
151
+ <span style={{ fontWeight: "bold" }}>{item.title}</span>
152
+ <span
153
+ style={{ fontSize: "11px", color: "#888", marginLeft: "6px" }}
154
+ >
155
+ by {item.author}
156
+ </span>
157
+ </span>
158
+ )}
159
+ />
160
+ )}
161
+ </FormField>
162
+ </div>
163
+ ),
164
+ };
165
+
166
+ export const Virtualized: Story = {
167
+ name: "Virtualized",
168
+ render: () => (
169
+ <div style={{ width: "300px" }}>
170
+ <FormField label="Pick a book">
171
+ {({ id }) => (
172
+ <SingleSearchableSelect
173
+ id={id}
174
+ data={largeBookSet}
175
+ itemToString={itemToString}
176
+ renderItem={renderItem}
177
+ placeholder="Select a book"
178
+ searchPlaceholder="Search books..."
179
+ isVirtualized
180
+ />
181
+ )}
182
+ </FormField>
183
+ </div>
184
+ ),
185
+ };
186
+
187
+ export const Loading: Story = {
188
+ name: "Loading",
189
+ render: () => (
190
+ <div style={{ width: "300px" }}>
191
+ <FormField label="Pick a book">
192
+ {({ id }) => (
193
+ <SingleSearchableSelect
194
+ id={id}
195
+ data={[]}
196
+ itemToString={itemToString}
197
+ placeholder="Select a book"
198
+ searchPlaceholder="Search books..."
199
+ isLoading
200
+ />
201
+ )}
202
+ </FormField>
203
+ </div>
204
+ ),
205
+ };
206
+
207
+ export const LoadingWithToggle: Story = {
208
+ name: "Loading (toggle)",
209
+ render: () => {
210
+ const [isLoading, setIsLoading] = React.useState(true);
211
+ const data = isLoading ? [] : books;
212
+ return (
213
+ <div
214
+ style={{
215
+ width: "300px",
216
+ display: "flex",
217
+ flexDirection: "column",
218
+ gap: 16,
219
+ }}
220
+ >
221
+ <FormField label="Pick a book">
222
+ {({ id }) => (
223
+ <SingleSearchableSelect
224
+ id={id}
225
+ data={data}
226
+ itemToString={itemToString}
227
+ placeholder="Select a book"
228
+ searchPlaceholder="Search books..."
229
+ isLoading={isLoading}
230
+ />
231
+ )}
232
+ </FormField>
233
+ <button onClick={() => setIsLoading((v) => !v)}>
234
+ {isLoading ? "Finish loading" : "Set loading"}
235
+ </button>
236
+ </div>
237
+ );
238
+ },
239
+ };