@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.
- package/.turbo/turbo-build.log +21 -15
- package/CHANGELOG.md +22 -0
- package/dist/esm/index.js +45 -24
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/v2/index.js +1 -1
- package/dist/esm/v2/index.js.map +1 -1
- package/dist/esm/v3/index.js +800 -0
- package/dist/esm/v3/index.js.map +1 -0
- package/dist/index.d.mts +22 -22
- package/dist/index.d.ts +22 -22
- package/dist/index.js +35 -14
- package/dist/index.js.map +1 -1
- package/dist/v2/index.js +1 -1
- package/dist/v2/index.js.map +1 -1
- package/dist/v3/index.d.mts +295 -0
- package/dist/v3/index.d.ts +295 -0
- package/dist/v3/index.js +868 -0
- package/dist/v3/index.js.map +1 -0
- package/package.json +7 -1
- package/src/MenuGroup/MenuGroup.tsx +16 -3
- package/src/MenuGroup/__tests__/MenuGroup.test.tsx +19 -0
- package/src/MenuItem/MenuItem.tsx +21 -10
- package/src/MenuItem/__tests__/MenuItem.test.tsx +19 -0
- package/src/v2/Common-V2/ComboboxContent.tsx +1 -1
- package/src/v3/Common/ComboboxItem.tsx +82 -0
- package/src/v3/Common/ComboboxStyles.tsx +475 -0
- package/src/v3/Common/SelectGroup.tsx +28 -0
- package/src/v3/Common/SelectIcons.tsx +26 -0
- package/src/v3/Common/SelectItem.tsx +108 -0
- package/src/v3/Common/SelectStyles.tsx +126 -0
- package/src/v3/Common/VirtualizedComboboxList.tsx +200 -0
- package/src/v3/Common/groupItems.ts +12 -0
- package/src/v3/Common/types.ts +3 -0
- package/src/v3/ModalStories.stories.tsx +181 -0
- package/src/v3/MultiCombobox.stories.tsx +322 -0
- package/src/v3/MultiCombobox.tsx +562 -0
- package/src/v3/MultiSearchableSelect.stories.tsx +389 -0
- package/src/v3/MultiSearchableSelect.tsx +545 -0
- package/src/v3/MultiSelect.stories.tsx +300 -0
- package/src/v3/MultiSelect.tsx +498 -0
- package/src/v3/SeedsPortal.tsx +35 -0
- package/src/v3/SingleCombobox.stories.tsx +169 -0
- package/src/v3/SingleCombobox.tsx +304 -0
- package/src/v3/SingleSearchableSelect.stories.tsx +239 -0
- package/src/v3/SingleSearchableSelect.tsx +319 -0
- package/src/v3/SingleSelect.stories.tsx +227 -0
- package/src/v3/SingleSelect.tsx +245 -0
- package/src/v3/index.ts +7 -0
- package/src/v3/storyData.tsx +117 -0
- 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
|
+
};
|