@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,498 @@
1
+ import * as React from "react";
2
+ import { Select } from "@base-ui/react/select";
3
+ import styled from "styled-components";
4
+ import { useSeedsPortalContainer } from "./SeedsPortal";
5
+ import SelectItem from "./Common/SelectItem";
6
+ import type { DataWithId } from "./Common/types";
7
+ import { groupItems } from "./Common/groupItems";
8
+ import {
9
+ Field,
10
+ SelectTrigger,
11
+ SelectValue,
12
+ SelectIcon,
13
+ SelectGroup,
14
+ SelectGroupLabel,
15
+ SelectSeparator,
16
+ SelectPositioner,
17
+ SelectPopup,
18
+ } from "./Common/SelectStyles";
19
+ import { StyledChevron, ChevronIcon } from "./Common/SelectIcons";
20
+
21
+ // ─── Styled components ────────────────────────────────────────────────────────
22
+
23
+ const SelectionText = styled.span`
24
+ flex: 1;
25
+ overflow: hidden;
26
+ text-overflow: ellipsis;
27
+ white-space: nowrap;
28
+ `;
29
+
30
+ // The styles in here need cleaned up, but in MultiSearchableSelect are correct
31
+ const Placeholder = styled.div`
32
+ color: ${({ theme }) => theme.colors.text.subtext};
33
+ font-size: ${({ theme }) => theme.typography[200].fontSize};
34
+ line-height: 16px;
35
+ padding-top: 2px;
36
+ padding-bottom: 2px;
37
+ margin: 2px 8px 0px 0px;
38
+ `;
39
+
40
+ // ─── Props ────────────────────────────────────────────────────────────────────
41
+
42
+ interface MultiSelectBaseProps {
43
+ id?: string;
44
+ placeholder?: string;
45
+ }
46
+
47
+ interface MultiSelectDataProps<T extends DataWithId>
48
+ extends MultiSelectBaseProps {
49
+ data: T[];
50
+ itemToString: (item: T) => string;
51
+ renderItem?: (item: T) => React.ReactNode;
52
+ customRenderSelections?: (item: T) => string;
53
+ renderSelection?: (items: T[]) => React.ReactNode;
54
+ inputType?: "icon" | "radio" | "checkbox" | "none";
55
+ maxSelections?: number;
56
+ selectedItemIds?: Array<T["id"]>;
57
+ defaultSelectedItemIds?: Array<T["id"]>;
58
+ onSelectedItemIdsChange?: (ids: Array<T["id"]>) => void;
59
+ groupBy?: (item: T) => string;
60
+ renderGroupHeading?: (label: string) => React.ReactNode;
61
+ removeSelectedItems?: boolean;
62
+ children?: never;
63
+ }
64
+
65
+ interface MultiSelectChildrenProps extends MultiSelectBaseProps {
66
+ children: React.ReactNode;
67
+ selectedItemIds?: Array<string | number>;
68
+ defaultSelectedItemIds?: Array<string | number>;
69
+ onSelectedItemIdsChange?: (ids: Array<string | number>) => void;
70
+ data?: never;
71
+ itemToString?: never;
72
+ renderItem?: never;
73
+ customRenderSelections?: never;
74
+ renderSelection?: never;
75
+ inputType?: never;
76
+ maxSelections?: never;
77
+ groupBy?: never;
78
+ renderGroupHeading?: never;
79
+ removeSelectedItems?: never;
80
+ }
81
+
82
+ /*
83
+ 'use client';
84
+ import * as React from 'react';
85
+ import { Combobox } from '@base-ui/react/combobox';
86
+
87
+ export default function ExampleAsyncMultipleCombobox() {
88
+ const id = React.useId();
89
+
90
+ const [searchResults, setSearchResults] = React.useState<DirectoryUser[]>([]);
91
+ const [selectedValues, setSelectedValues] = React.useState<DirectoryUser[]>([]);
92
+ const [searchValue, setSearchValue] = React.useState('');
93
+ const [error, setError] = React.useState<string | null>(null);
94
+ const [blockStartStatus, setBlockStartStatus] = React.useState(false);
95
+
96
+ const [isPending, startTransition] = React.useTransition();
97
+
98
+ const { contains } = Combobox.useFilter();
99
+
100
+ const abortControllerRef = React.useRef<AbortController | null>(null);
101
+ const selectedValuesRef = React.useRef<DirectoryUser[]>([]);
102
+
103
+ const trimmedSearchValue = searchValue.trim();
104
+
105
+ const items = React.useMemo(() => {
106
+ if (selectedValues.length === 0) {
107
+ return searchResults;
108
+ }
109
+
110
+ const merged = [...searchResults];
111
+
112
+ selectedValues.forEach((user) => {
113
+ if (!searchResults.some((result) => result.id === user.id)) {
114
+ merged.push(user);
115
+ }
116
+ });
117
+
118
+ return merged;
119
+ }, [searchResults, selectedValues]);
120
+
121
+ function getStatus() {
122
+ if (isPending) {
123
+ return (
124
+ <React.Fragment>
125
+ <span
126
+ aria-hidden
127
+ className="inline-block size-3 animate-[spin_0.75s_linear_infinite] rounded-full border border-current border-r-transparent rtl:border-r-current rtl:border-l-transparent"
128
+ />
129
+ Searching…
130
+ </React.Fragment>
131
+ );
132
+ }
133
+
134
+ if (error) {
135
+ return error;
136
+ }
137
+
138
+ if (trimmedSearchValue === '' && !blockStartStatus) {
139
+ return selectedValues.length > 0 ? null : 'Start typing to search people…';
140
+ }
141
+
142
+ if (searchResults.length === 0 && !blockStartStatus) {
143
+ return `No matches for "${trimmedSearchValue}".`;
144
+ }
145
+
146
+ return null;
147
+ }
148
+
149
+ function getEmptyMessage() {
150
+ if (trimmedSearchValue === '' || isPending || searchResults.length > 0 || error) {
151
+ return null;
152
+ }
153
+
154
+ return 'Try a different search term.';
155
+ }
156
+
157
+ const status = getStatus();
158
+ const emptyMessage = getEmptyMessage();
159
+
160
+ return (
161
+ <Combobox.Root
162
+ items={items}
163
+ itemToStringLabel={(user: DirectoryUser) => user.name}
164
+ multiple
165
+ filter={null}
166
+ onOpenChangeComplete={(open) => {
167
+ if (!open) {
168
+ setSearchResults(selectedValuesRef.current);
169
+ setBlockStartStatus(false);
170
+ }
171
+ }}
172
+ onValueChange={(nextSelectedValues) => {
173
+ selectedValuesRef.current = nextSelectedValues;
174
+ setSelectedValues(nextSelectedValues);
175
+ setSearchValue('');
176
+ setError(null);
177
+
178
+ if (nextSelectedValues.length === 0) {
179
+ setSearchResults([]);
180
+ setBlockStartStatus(false);
181
+ } else {
182
+ setBlockStartStatus(true);
183
+ }
184
+ }}
185
+ onInputValueChange={(nextSearchValue, { reason }) => {
186
+ setSearchValue(nextSearchValue);
187
+
188
+ const controller = new AbortController();
189
+ abortControllerRef.current?.abort();
190
+ abortControllerRef.current = controller;
191
+
192
+ if (nextSearchValue === '') {
193
+ setSearchResults(selectedValuesRef.current);
194
+ setError(null);
195
+ setBlockStartStatus(false);
196
+ return;
197
+ }
198
+
199
+ if (reason === 'item-press') {
200
+ return;
201
+ }
202
+ startTransition(async () => {
203
+ setError(null);
204
+
205
+ const result = await searchUsers(nextSearchValue, contains);
206
+
207
+ if (controller.signal.aborted) {
208
+ return;
209
+ }
210
+
211
+ startTransition(() => {
212
+ setSearchResults(result.users);
213
+ setError(result.error);
214
+ });
215
+ });
216
+ }}
217
+ >
218
+ <div className="flex flex-col gap-1 text-sm text-gray-900">
219
+ <label className="inline-flex text-inherit font-bold" htmlFor={id}>
220
+ Assign reviewers
221
+ </label>
222
+ <Combobox.InputGroup className="relative flex min-h-10 w-[16rem] cursor-text rounded-md border border-gray-200 bg-[canvas] px-1.5 py-1 focus-within:outline-2 focus-within:-outline-offset-1 focus-within:outline-blue-800 md:w-[20rem]">
223
+ <Combobox.Chips className="flex w-full flex-wrap items-center gap-1">
224
+ <Combobox.Value>
225
+ {(value: DirectoryUser[]) => (
226
+ <React.Fragment>
227
+ {value.map((user) => (
228
+ <Combobox.Chip
229
+ key={user.id}
230
+ className="flex cursor-default items-center gap-1 rounded-md bg-gray-100 py-1 pl-2 pr-1 text-sm text-gray-900 outline-none focus-within:bg-blue-800 focus-within:text-gray-50 [@media(hover:hover)]:[&[data-highlighted]]:bg-blue-800 [@media(hover:hover)]:[&[data-highlighted]]:text-gray-50"
231
+ aria-label={user.name}
232
+ >
233
+ {user.name}
234
+ <Combobox.ChipRemove
235
+ className="inline-flex items-center justify-center rounded-md border-none bg-transparent p-[0.2rem] text-inherit hover:bg-gray-200"
236
+ aria-label={`Remove ${user.name}`}
237
+ >
238
+ <XIcon />
239
+ </Combobox.ChipRemove>
240
+ </Combobox.Chip>
241
+ ))}
242
+ <Combobox.Input
243
+ id={id}
244
+ placeholder={value.length > 0 ? '' : 'e.g. Michael'}
245
+ className="h-8 min-w-24 flex-1 rounded-md border-0 bg-transparent pl-2 text-base font-normal text-gray-900 outline-none placeholder:font-normal"
246
+ />
247
+ </React.Fragment>
248
+ )}
249
+ </Combobox.Value>
250
+ </Combobox.Chips>
251
+ </Combobox.InputGroup>
252
+ </div>
253
+
254
+ <Combobox.Portal>
255
+ <Combobox.Positioner className="outline-none" sideOffset={4}>
256
+ <Combobox.Popup
257
+ className="box-border w-[var(--anchor-width)] max-h-[min(var(--available-height),23rem)] max-w-[var(--available-width)] origin-[var(--transform-origin)] overflow-y-auto scroll-pb-2 scroll-pt-2 overscroll-contain rounded-md bg-[canvas] py-2 text-gray-900 shadow-[0_10px_15px_-3px_var(--color-gray-200),0_4px_6px_-4px_var(--color-gray-200)] outline outline-1 outline-gray-200 transition-[opacity,transform,scale] duration-100 data-[ending-style]:transition-none data-[starting-style]:scale-95 data-[starting-style]:opacity-0 dark:-outline-offset-1 dark:shadow-none dark:outline-gray-300"
258
+ aria-busy={isPending || undefined}
259
+ >
260
+ <Combobox.Status>
261
+ {status ? (
262
+ <div className="flex items-center gap-2 py-1 pl-4 pr-5 text-sm text-gray-600">
263
+ {status}
264
+ </div>
265
+ ) : null}
266
+ </Combobox.Status>
267
+ <Combobox.Empty>
268
+ {emptyMessage ? (
269
+ <div className="box-border px-4 py-2 text-sm leading-4 text-gray-600">
270
+ {emptyMessage}
271
+ </div>
272
+ ) : null}
273
+ </Combobox.Empty>
274
+ <Combobox.List>
275
+ {(user: DirectoryUser) => (
276
+ <Combobox.Item
277
+ key={user.id}
278
+ value={user}
279
+ className="grid cursor-default select-none grid-cols-[0.75rem_1fr] items-start gap-2 py-2 pl-4 pr-5 text-base leading-[1.2rem] outline-none [@media(hover:hover)]:[&[data-highlighted]]:relative [@media(hover:hover)]:[&[data-highlighted]]:z-0 [@media(hover:hover)]:[&[data-highlighted]]:text-gray-900 [@media(hover:hover)]:[&[data-highlighted]]:before:absolute [@media(hover:hover)]:[&[data-highlighted]]:before:inset-y-0 [@media(hover:hover)]:[&[data-highlighted]]:before:inset-x-2 [@media(hover:hover)]:[&[data-highlighted]]:before:z-[-1] [@media(hover:hover)]:[&[data-highlighted]]:before:rounded [@media(hover:hover)]:[&[data-highlighted]]:before:bg-gray-100 [@media(hover:hover)]:[&[data-highlighted]]:before:content-['']"
280
+ >
281
+ <Combobox.ItemIndicator className="col-start-1 mt-1">
282
+ <CheckIcon className="size-3" />
283
+ </Combobox.ItemIndicator>
284
+ <span className="col-start-2 flex flex-col gap-1">
285
+ <span className="text-[0.95rem] font-bold">{user.name}</span>
286
+ <span className="flex flex-wrap gap-2 text-[0.8125rem] text-gray-600">
287
+ <span className="opacity-80">@{user.username}</span>
288
+ <span>{user.title}</span>
289
+ </span>
290
+ <span className="text-xs text-gray-500">{user.email}</span>
291
+ </span>
292
+ </Combobox.Item>
293
+ )}
294
+ </Combobox.List>
295
+ </Combobox.Popup>
296
+ </Combobox.Positioner>
297
+ </Combobox.Portal>
298
+ </Combobox.Root>
299
+ */
300
+
301
+ export type MultiSelectProps<T extends DataWithId> =
302
+ | MultiSelectDataProps<T>
303
+ | MultiSelectChildrenProps;
304
+
305
+ // ─── Component ────────────────────────────────────────────────────────────────
306
+
307
+ export default function MultiSelect<T extends DataWithId>(
308
+ props: MultiSelectProps<T>
309
+ ) {
310
+ const { id, placeholder = "Select..." } = props;
311
+
312
+ const isChildrenMode = "children" in props && props.children != null;
313
+
314
+ const data = isChildrenMode ? [] : (props as MultiSelectDataProps<T>).data;
315
+ const itemToString = isChildrenMode
316
+ ? () => ""
317
+ : (props as MultiSelectDataProps<T>).itemToString;
318
+ const renderItem = isChildrenMode
319
+ ? undefined
320
+ : (props as MultiSelectDataProps<T>).renderItem;
321
+ const customRenderSelections = isChildrenMode
322
+ ? undefined
323
+ : (props as MultiSelectDataProps<T>).customRenderSelections;
324
+ const maxSelections = isChildrenMode
325
+ ? undefined
326
+ : (props as MultiSelectDataProps<T>).maxSelections;
327
+ const renderSelection = isChildrenMode
328
+ ? undefined
329
+ : (props as MultiSelectDataProps<T>).renderSelection;
330
+ const removeSelectedItems = isChildrenMode
331
+ ? false
332
+ : (props as MultiSelectDataProps<T>).removeSelectedItems ?? false;
333
+ const inputType = isChildrenMode
334
+ ? "checkbox"
335
+ : removeSelectedItems
336
+ ? "none"
337
+ : (props as MultiSelectDataProps<T>).inputType ?? "checkbox";
338
+ const selectedItemIds = props.selectedItemIds;
339
+ const defaultSelectedItemIds = props.defaultSelectedItemIds;
340
+ const onSelectedItemIdsChange = props.onSelectedItemIdsChange;
341
+ const groupBy = isChildrenMode
342
+ ? undefined
343
+ : (props as MultiSelectDataProps<T>).groupBy;
344
+ const renderGroupHeading = isChildrenMode
345
+ ? undefined
346
+ : (props as MultiSelectDataProps<T>).renderGroupHeading;
347
+ const children = isChildrenMode
348
+ ? (props as MultiSelectChildrenProps).children
349
+ : undefined;
350
+
351
+ const { containerRef, portalContainer } = useSeedsPortalContainer();
352
+
353
+ const isControlled = selectedItemIds !== undefined;
354
+
355
+ const [internalValue, setInternalValue] = React.useState<string[]>(
356
+ () => defaultSelectedItemIds?.map(String) ?? []
357
+ );
358
+
359
+ const value = isControlled ? selectedItemIds!.map(String) : internalValue;
360
+
361
+ const items = React.useMemo(
362
+ () =>
363
+ data.map((item) => ({
364
+ value: String(item.id),
365
+ label: itemToString(item),
366
+ })),
367
+ [data, itemToString]
368
+ );
369
+
370
+ const selectedItems = React.useMemo(
371
+ () =>
372
+ value
373
+ .map((v) => data.find((d) => String(d.id) === v))
374
+ .filter((d): d is T => d != null),
375
+ [data, value]
376
+ );
377
+
378
+ function handleValueChange(newValues: string[]) {
379
+ if (!isControlled) {
380
+ setInternalValue(newValues);
381
+ }
382
+ if (onSelectedItemIdsChange) {
383
+ const ids = newValues.map((v) => {
384
+ const item = data.find((d) => String(d.id) === v);
385
+ return item ? item.id : v;
386
+ });
387
+ onSelectedItemIdsChange(ids as Array<T["id"]>);
388
+ }
389
+ }
390
+
391
+ return (
392
+ <Field>
393
+ <div ref={containerRef} style={{ position: "relative" }} />
394
+ <Select.Root
395
+ multiple
396
+ items={items}
397
+ value={value}
398
+ onValueChange={handleValueChange}
399
+ id={id}
400
+ >
401
+ <SelectTrigger>
402
+ <SelectValue>
403
+ {() => {
404
+ if (renderSelection) return renderSelection(selectedItems);
405
+ if (selectedItems.length === 0)
406
+ return <Placeholder>{placeholder}</Placeholder>;
407
+
408
+ const visibleItems =
409
+ maxSelections != null
410
+ ? selectedItems.slice(0, maxSelections)
411
+ : selectedItems;
412
+ const overflowCount =
413
+ maxSelections != null && selectedItems.length > maxSelections
414
+ ? selectedItems.length - maxSelections
415
+ : 0;
416
+
417
+ const text = visibleItems
418
+ .map((item) =>
419
+ customRenderSelections
420
+ ? customRenderSelections(item)
421
+ : itemToString(item)
422
+ )
423
+ .join(", ");
424
+
425
+ return (
426
+ <SelectionText>
427
+ {text}
428
+ {overflowCount > 0 ? `, +${overflowCount}` : ""}
429
+ </SelectionText>
430
+ );
431
+ }}
432
+ </SelectValue>
433
+ <SelectIcon>
434
+ <StyledChevron data-chevron>
435
+ <ChevronIcon />
436
+ </StyledChevron>
437
+ </SelectIcon>
438
+ </SelectTrigger>
439
+ <Select.Portal container={portalContainer}>
440
+ <SelectPositioner sideOffset={8} alignItemWithTrigger={false}>
441
+ <SelectPopup>
442
+ <Select.ScrollUpArrow />
443
+ <Select.List>
444
+ {children
445
+ ? children
446
+ : groupBy
447
+ ? groupItems(data, groupBy).map((group, index, arr) => (
448
+ <React.Fragment key={group.value}>
449
+ <SelectGroup>
450
+ <SelectGroupLabel>
451
+ {renderGroupHeading
452
+ ? renderGroupHeading(group.value)
453
+ : group.value}
454
+ </SelectGroupLabel>
455
+ {group.items.map((item) => (
456
+ <SelectItem
457
+ key={item.id}
458
+ value={String(item.id)}
459
+ label={itemToString(item)}
460
+ inputType={inputType}
461
+ hidden={
462
+ removeSelectedItems &&
463
+ value.includes(String(item.id))
464
+ }
465
+ renderItem={
466
+ renderItem ? () => renderItem(item) : undefined
467
+ }
468
+ />
469
+ ))}
470
+ </SelectGroup>
471
+ {index < arr.length - 1 && <SelectSeparator />}
472
+ </React.Fragment>
473
+ ))
474
+ : data.map((item) => (
475
+ <SelectItem
476
+ key={item.id}
477
+ value={String(item.id)}
478
+ label={itemToString(item)}
479
+ inputType={inputType}
480
+ hidden={
481
+ removeSelectedItems && value.includes(String(item.id))
482
+ }
483
+ renderItem={
484
+ renderItem
485
+ ? () => renderItem(item)
486
+ : () => itemToString(item)
487
+ }
488
+ />
489
+ ))}
490
+ </Select.List>
491
+ <Select.ScrollDownArrow />
492
+ </SelectPopup>
493
+ </SelectPositioner>
494
+ </Select.Portal>
495
+ </Select.Root>
496
+ </Field>
497
+ );
498
+ }
@@ -0,0 +1,35 @@
1
+ import * as React from "react";
2
+ import { DisablePortalToBodyContext } from "@sproutsocial/seeds-react-portal";
3
+
4
+ /**
5
+ * Returns the portal container to use for Base UI's Select.Portal /
6
+ * Combobox.Portal. When inside a Modal V2 or Drawer (which set
7
+ * DisablePortalToBodyContext=true), returns a ref'd div rendered inline so
8
+ * the popup stays inside the Radix Dialog's focus/pointer boundary.
9
+ * Otherwise returns undefined, letting Base UI portal to document.body.
10
+ *
11
+ * Usage:
12
+ * const { containerRef, portalContainer } = useSeedsPortalContainer();
13
+ * <div ref={containerRef} style={{ position: "relative" }} />
14
+ * <Select.Portal container={portalContainer}>...</Select.Portal>
15
+ */
16
+ export function useSeedsPortalContainer() {
17
+ const disablePortalToBody = React.useContext(DisablePortalToBodyContext);
18
+ const containerRef = React.useRef<HTMLDivElement>(null);
19
+ const [portalContainer, setPortalContainer] = React.useState<
20
+ HTMLElement | undefined
21
+ >(undefined);
22
+
23
+ React.useEffect(() => {
24
+ if (disablePortalToBody && containerRef.current) {
25
+ // Portal to the Radix Dialog content element so the popup escapes the
26
+ // ModalBody scroll container while staying inside the Dialog boundary
27
+ // (required for Radix focus/pointer-events containment).
28
+ const dialogContent =
29
+ containerRef.current.closest<HTMLElement>("[role='dialog']");
30
+ setPortalContainer(dialogContent ?? containerRef.current);
31
+ }
32
+ }, [disablePortalToBody]);
33
+
34
+ return { containerRef, portalContainer, disablePortalToBody };
35
+ }
@@ -0,0 +1,169 @@
1
+ import React from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react";
3
+ import { FormField } from "@sproutsocial/seeds-react-form-field";
4
+ import SingleCombobox from "./SingleCombobox";
5
+ import { books, largeBookSet, itemToString, renderItem } from "./storyData";
6
+
7
+ const meta: Meta<typeof SingleCombobox> = {
8
+ title: "Really Under Development/V3/Single Combobox",
9
+ component: SingleCombobox,
10
+ };
11
+
12
+ export default meta;
13
+ type Story = StoryObj<typeof SingleCombobox>;
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
+ <SingleCombobox
22
+ id={id}
23
+ data={books}
24
+ itemToString={itemToString}
25
+ placeholder="Search books..."
26
+ />
27
+ )}
28
+ </FormField>
29
+ </div>
30
+ ),
31
+ };
32
+
33
+ export const CustomRenderItem: Story = {
34
+ name: "Custom render item",
35
+ render: () => (
36
+ <div style={{ width: "300px" }}>
37
+ <FormField label="Pick a book">
38
+ {({ id }) => (
39
+ <SingleCombobox
40
+ id={id}
41
+ data={books}
42
+ itemToString={itemToString}
43
+ renderItem={renderItem}
44
+ placeholder="Search books..."
45
+ />
46
+ )}
47
+ </FormField>
48
+ </div>
49
+ ),
50
+ };
51
+
52
+ export const Radio: Story = {
53
+ name: "Radio",
54
+ render: () => (
55
+ <div style={{ width: "300px" }}>
56
+ <FormField label="Pick a book">
57
+ {({ id }) => (
58
+ <SingleCombobox
59
+ id={id}
60
+ data={books}
61
+ itemToString={itemToString}
62
+ renderItem={renderItem}
63
+ placeholder="Search books..."
64
+ inputType="radio"
65
+ />
66
+ )}
67
+ </FormField>
68
+ </div>
69
+ ),
70
+ };
71
+
72
+ export const Grouped: Story = {
73
+ name: "Grouped",
74
+ render: () => (
75
+ <div style={{ width: "300px" }}>
76
+ <FormField label="Pick a book">
77
+ {({ id }) => (
78
+ <SingleCombobox
79
+ id={id}
80
+ data={books}
81
+ itemToString={itemToString}
82
+ renderItem={renderItem}
83
+ placeholder="Search books..."
84
+ groupBy={(book) => book.author}
85
+ />
86
+ )}
87
+ </FormField>
88
+ </div>
89
+ ),
90
+ };
91
+
92
+ export const DefaultSelected: Story = {
93
+ name: "Default selected",
94
+ render: () => (
95
+ <div style={{ width: "300px" }}>
96
+ <FormField label="Pick a book">
97
+ {({ id }) => (
98
+ <SingleCombobox
99
+ id={id}
100
+ data={books}
101
+ itemToString={itemToString}
102
+ renderItem={renderItem}
103
+ placeholder="Search books..."
104
+ defaultSelectedItemId="book-3"
105
+ />
106
+ )}
107
+ </FormField>
108
+ </div>
109
+ ),
110
+ };
111
+
112
+ export const Controlled: Story = {
113
+ name: "Controlled",
114
+ render: () => {
115
+ const [selectedId, setSelectedId] = React.useState<string | null>("book-1");
116
+ const selected = books.find((b) => b.id === selectedId);
117
+ return (
118
+ <div
119
+ style={{
120
+ display: "flex",
121
+ flexDirection: "column",
122
+ gap: 16,
123
+ width: "300px",
124
+ }}
125
+ >
126
+ <FormField label="Pick a book">
127
+ {({ id }) => (
128
+ <SingleCombobox
129
+ id={id}
130
+ data={books}
131
+ itemToString={itemToString}
132
+ renderItem={renderItem}
133
+ placeholder="Search books..."
134
+ selectedItemId={selectedId}
135
+ onSelectedItemIdChange={(id: string | null) => setSelectedId(id)}
136
+ />
137
+ )}
138
+ </FormField>
139
+ <div style={{ fontSize: 13 }}>
140
+ Selected: <strong>{selected?.title ?? "none"}</strong>
141
+ </div>
142
+ <div style={{ display: "flex", gap: 8 }}>
143
+ <button onClick={() => setSelectedId(null)}>Clear</button>
144
+ <button onClick={() => setSelectedId("book-5")}>Select 1984</button>
145
+ </div>
146
+ </div>
147
+ );
148
+ },
149
+ };
150
+
151
+ export const Virtualized: Story = {
152
+ name: "Virtualized",
153
+ render: () => (
154
+ <div style={{ width: "300px" }}>
155
+ <FormField label="Pick a book">
156
+ {({ id }) => (
157
+ <SingleCombobox
158
+ id={id}
159
+ data={largeBookSet}
160
+ itemToString={itemToString}
161
+ renderItem={renderItem}
162
+ placeholder="Search books..."
163
+ isVirtualized
164
+ />
165
+ )}
166
+ </FormField>
167
+ </div>
168
+ ),
169
+ };