@sproutsocial/seeds-react-tree 0.3.1 → 0.4.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 +11 -10
- package/CHANGELOG.md +36 -0
- package/dist/esm/index.js +389 -32
- package/dist/esm/index.js.map +1 -1
- package/dist/index.d.mts +86 -1
- package/dist/index.d.ts +86 -1
- package/dist/index.js +391 -32
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/Common/TreeSearchableSelectBase.tsx +383 -0
- package/src/Common/flattenTree.ts +22 -0
- package/src/MultiTreeSearchableSelect.stories.tsx +256 -0
- package/src/MultiTreeSearchableSelect.tsx +92 -0
- package/src/SingleTreeSearchableSelect.stories.tsx +217 -0
- package/src/SingleTreeSearchableSelect.tsx +95 -0
- package/src/TreeCombobox.tsx +91 -52
- package/src/TreeStyles.tsx +3 -2
- package/src/__tests__/MultiTreeSearchableSelect.test.tsx +194 -0
- package/src/__tests__/SingleTreeSearchableSelect.test.tsx +210 -0
- package/src/index.ts +8 -0
- package/src/storyData.tsx +339 -6
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import styled, { css } from "styled-components";
|
|
3
|
+
import { Icon } from "@sproutsocial/seeds-react-icon";
|
|
4
|
+
import { focusRing } from "@sproutsocial/seeds-react-mixins";
|
|
5
|
+
import { Popout } from "@sproutsocial/seeds-react-popout/v2";
|
|
6
|
+
import { TreeCombobox } from "../TreeCombobox";
|
|
7
|
+
import { flattenTreeItems } from "./flattenTree";
|
|
8
|
+
import type {
|
|
9
|
+
TreeItemData,
|
|
10
|
+
TreeSelectableNodes,
|
|
11
|
+
TreeSelectionIndicator,
|
|
12
|
+
TreeSelectionMode,
|
|
13
|
+
} from "./types";
|
|
14
|
+
|
|
15
|
+
const invalidStyles = css`
|
|
16
|
+
border-color: ${({ theme }) => theme.colors.form.border.error};
|
|
17
|
+
`;
|
|
18
|
+
|
|
19
|
+
const Trigger = styled.button<{ $isInvalid?: boolean; $fullWidth: boolean }>`
|
|
20
|
+
display: flex;
|
|
21
|
+
align-items: center;
|
|
22
|
+
justify-content: space-between;
|
|
23
|
+
gap: ${({ theme }) => theme.space[300]};
|
|
24
|
+
padding: ${({ theme }) => theme.space[200]} ${({ theme }) => theme.space[350]};
|
|
25
|
+
min-width: 160px;
|
|
26
|
+
width: ${({ $fullWidth }) => ($fullWidth ? "100%" : "auto")};
|
|
27
|
+
border-radius: ${({ theme }) => theme.radii[500]};
|
|
28
|
+
border: 1px solid ${({ theme }) => theme.colors.form.border.base};
|
|
29
|
+
background: ${({ theme }) => theme.colors.form.background.base};
|
|
30
|
+
font-family: ${({ theme }) => theme.fontFamily};
|
|
31
|
+
font-size: ${({ theme }) => theme.typography[200].fontSize};
|
|
32
|
+
line-height: ${({ theme }) => theme.typography[200].lineHeight};
|
|
33
|
+
color: ${({ theme }) => theme.colors.text.body};
|
|
34
|
+
text-align: left;
|
|
35
|
+
cursor: default;
|
|
36
|
+
outline: none;
|
|
37
|
+
user-select: none;
|
|
38
|
+
transition: border-color ${({ theme }) => theme.duration.fast}
|
|
39
|
+
${({ theme }) => theme.easing.ease_in},
|
|
40
|
+
box-shadow ${({ theme }) => theme.duration.fast}
|
|
41
|
+
${({ theme }) => theme.easing.ease_in};
|
|
42
|
+
|
|
43
|
+
&:focus-visible {
|
|
44
|
+
${focusRing}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
&[aria-disabled="true"],
|
|
48
|
+
&:disabled {
|
|
49
|
+
opacity: 0.4;
|
|
50
|
+
cursor: not-allowed;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
${({ $isInvalid }) => $isInvalid && invalidStyles}
|
|
54
|
+
`;
|
|
55
|
+
|
|
56
|
+
const TriggerLabel = styled.span`
|
|
57
|
+
flex: 1;
|
|
58
|
+
min-width: 0;
|
|
59
|
+
overflow: hidden;
|
|
60
|
+
text-overflow: ellipsis;
|
|
61
|
+
white-space: nowrap;
|
|
62
|
+
`;
|
|
63
|
+
|
|
64
|
+
const PlaceholderText = styled.span`
|
|
65
|
+
color: ${({ theme }) => theme.colors.text.subtext};
|
|
66
|
+
`;
|
|
67
|
+
|
|
68
|
+
const Chevron = styled.span<{ $open: boolean }>`
|
|
69
|
+
display: inline-flex;
|
|
70
|
+
align-items: center;
|
|
71
|
+
color: ${({ theme }) => theme.colors.icon.base};
|
|
72
|
+
transition: transform ${({ theme }) => theme.duration.fast}
|
|
73
|
+
${({ theme }) => theme.easing.ease_in};
|
|
74
|
+
${({ $open }) =>
|
|
75
|
+
$open &&
|
|
76
|
+
css`
|
|
77
|
+
transform: rotate(-180deg);
|
|
78
|
+
`}
|
|
79
|
+
`;
|
|
80
|
+
|
|
81
|
+
/*
|
|
82
|
+
* Visually-hidden input that mirrors the combobox's selected value into a
|
|
83
|
+
* form-submittable element. Matches Base UI's pattern (used by v3
|
|
84
|
+
* SearchableSelect): the input stays in the DOM so HTML forms pick up the
|
|
85
|
+
* value, but it's removed from view and from the accessibility tree.
|
|
86
|
+
*/
|
|
87
|
+
const HiddenInput = styled.input`
|
|
88
|
+
clip-path: inset(50%);
|
|
89
|
+
overflow: hidden;
|
|
90
|
+
white-space: nowrap;
|
|
91
|
+
border: 0;
|
|
92
|
+
padding: 0;
|
|
93
|
+
width: 1px;
|
|
94
|
+
height: 1px;
|
|
95
|
+
margin: -1px;
|
|
96
|
+
position: fixed;
|
|
97
|
+
top: 0;
|
|
98
|
+
left: 0;
|
|
99
|
+
`;
|
|
100
|
+
|
|
101
|
+
const PopoutContent = styled.div<{ $width?: number | string }>`
|
|
102
|
+
display: flex;
|
|
103
|
+
flex-direction: column;
|
|
104
|
+
/* Width sizes to content within bounds: at least the trigger width, never */
|
|
105
|
+
/* wider than the available viewport space. Shallow popups stay flush with */
|
|
106
|
+
/* the trigger; deeply nested trees expand horizontally as needed. */
|
|
107
|
+
min-width: var(--radix-popover-trigger-width);
|
|
108
|
+
max-width: var(--radix-popper-available-width);
|
|
109
|
+
/* Height caps at 400px (or available viewport) so a popup with many */
|
|
110
|
+
/* expanded branches scrolls vertically instead of growing off-screen. */
|
|
111
|
+
max-height: min(var(--radix-popper-available-height), 400px);
|
|
112
|
+
width: ${({ $width }) =>
|
|
113
|
+
$width === undefined
|
|
114
|
+
? "max-content"
|
|
115
|
+
: typeof $width === "number"
|
|
116
|
+
? `${$width}px`
|
|
117
|
+
: $width};
|
|
118
|
+
`;
|
|
119
|
+
|
|
120
|
+
const MAX_INLINE_LABELS = 3;
|
|
121
|
+
|
|
122
|
+
export type RenderTrigger = (selected: TreeItemData[]) => React.ReactNode;
|
|
123
|
+
|
|
124
|
+
type TreeSearchableSelectBaseProps = {
|
|
125
|
+
items: ReadonlyArray<TreeItemData>;
|
|
126
|
+
|
|
127
|
+
selectionMode: Exclude<TreeSelectionMode, "none">;
|
|
128
|
+
selectableNodes?: TreeSelectableNodes;
|
|
129
|
+
/** Selected ids in the underlying Tree's array form. */
|
|
130
|
+
selectedIds: ReadonlyArray<string>;
|
|
131
|
+
/** Fires with the next selected ids whenever Tree's onSelectionChange fires. */
|
|
132
|
+
onSelectedIdsChange: (ids: string[]) => void;
|
|
133
|
+
|
|
134
|
+
/** Accessible name for the trigger. Required (label or labelledby). */
|
|
135
|
+
"aria-label"?: string;
|
|
136
|
+
"aria-labelledby"?: string;
|
|
137
|
+
"aria-describedby"?: string;
|
|
138
|
+
|
|
139
|
+
/** Trigger placeholder when nothing is selected. */
|
|
140
|
+
placeholder?: string;
|
|
141
|
+
/** Search input placeholder inside the popout. */
|
|
142
|
+
searchPlaceholder?: string;
|
|
143
|
+
/** Empty-state text shown inside the popout when the query has no matches. */
|
|
144
|
+
emptyText?: string;
|
|
145
|
+
|
|
146
|
+
/** Render override for the trigger label area. Receives resolved selected items. */
|
|
147
|
+
renderTrigger?: RenderTrigger;
|
|
148
|
+
|
|
149
|
+
/** Open state — controllable. */
|
|
150
|
+
open?: boolean;
|
|
151
|
+
defaultOpen?: boolean;
|
|
152
|
+
onOpenChange?: (open: boolean) => void;
|
|
153
|
+
|
|
154
|
+
/** Close the popout immediately after a selection change. Single-select uses this. */
|
|
155
|
+
closeOnSelect: boolean;
|
|
156
|
+
|
|
157
|
+
disabled?: boolean;
|
|
158
|
+
isInvalid?: boolean;
|
|
159
|
+
required?: boolean;
|
|
160
|
+
readOnly?: boolean;
|
|
161
|
+
/**
|
|
162
|
+
* Name for the hidden form input. When set, the component participates in
|
|
163
|
+
* native HTML form submission. For multi-select, one `<input type="hidden">`
|
|
164
|
+
* is rendered per selected id under this name (so `formData.getAll(name)`
|
|
165
|
+
* returns every selection).
|
|
166
|
+
*/
|
|
167
|
+
name?: string;
|
|
168
|
+
fullWidth?: boolean;
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Popout content width. Defaults to the trigger's width (via Radix's
|
|
172
|
+
* `--radix-popover-trigger-width`). Pass a number (px) or any valid CSS
|
|
173
|
+
* width string to override.
|
|
174
|
+
*/
|
|
175
|
+
contentWidth?: number | string;
|
|
176
|
+
|
|
177
|
+
/** Tree props passed through. */
|
|
178
|
+
renderSelectionIndicator?: TreeSelectionIndicator;
|
|
179
|
+
defaultExpanded?: ReadonlyArray<string>;
|
|
180
|
+
expanded?: ReadonlyArray<string>;
|
|
181
|
+
onExpandedChange?: (expanded: string[]) => void;
|
|
182
|
+
|
|
183
|
+
id?: string;
|
|
184
|
+
className?: string;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
function defaultRenderTrigger(
|
|
188
|
+
selected: TreeItemData[],
|
|
189
|
+
placeholder: string
|
|
190
|
+
): React.ReactNode {
|
|
191
|
+
if (selected.length === 0) {
|
|
192
|
+
return <PlaceholderText>{placeholder}</PlaceholderText>;
|
|
193
|
+
}
|
|
194
|
+
if (selected.length <= MAX_INLINE_LABELS) {
|
|
195
|
+
return selected.map((s) => s.label).join(", ");
|
|
196
|
+
}
|
|
197
|
+
return `${selected.length} selected`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function TreeSearchableSelectBase(props: TreeSearchableSelectBaseProps) {
|
|
201
|
+
const {
|
|
202
|
+
items,
|
|
203
|
+
selectionMode,
|
|
204
|
+
selectableNodes,
|
|
205
|
+
selectedIds,
|
|
206
|
+
onSelectedIdsChange,
|
|
207
|
+
placeholder = "Select...",
|
|
208
|
+
searchPlaceholder = "Search...",
|
|
209
|
+
emptyText = "No results found.",
|
|
210
|
+
renderTrigger,
|
|
211
|
+
open: openProp,
|
|
212
|
+
defaultOpen = false,
|
|
213
|
+
onOpenChange,
|
|
214
|
+
closeOnSelect,
|
|
215
|
+
disabled,
|
|
216
|
+
isInvalid,
|
|
217
|
+
required,
|
|
218
|
+
readOnly,
|
|
219
|
+
name,
|
|
220
|
+
fullWidth = true,
|
|
221
|
+
contentWidth,
|
|
222
|
+
renderSelectionIndicator,
|
|
223
|
+
defaultExpanded,
|
|
224
|
+
expanded,
|
|
225
|
+
onExpandedChange,
|
|
226
|
+
id,
|
|
227
|
+
className,
|
|
228
|
+
} = props;
|
|
229
|
+
const ariaLabel = props["aria-label"];
|
|
230
|
+
const ariaLabelledBy = props["aria-labelledby"];
|
|
231
|
+
const ariaDescribedBy = props["aria-describedby"];
|
|
232
|
+
|
|
233
|
+
const isOpenControlled = openProp !== undefined;
|
|
234
|
+
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);
|
|
235
|
+
const open = isOpenControlled ? !!openProp : uncontrolledOpen;
|
|
236
|
+
const setOpen = (next: boolean) => {
|
|
237
|
+
if (!isOpenControlled) setUncontrolledOpen(next);
|
|
238
|
+
onOpenChange?.(next);
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
// Persist the search query across open/close so users don't lose their
|
|
242
|
+
// place after picking a multi-select item.
|
|
243
|
+
const [query, setQuery] = React.useState("");
|
|
244
|
+
|
|
245
|
+
// Stable id for the popup so the trigger's `aria-controls` resolves to a
|
|
246
|
+
// real DOM element when open. Colons from React.useId() get stripped — axe
|
|
247
|
+
// rejects them inside aria-* attribute values.
|
|
248
|
+
const reactId = React.useId();
|
|
249
|
+
const popupId = `tree-select-popup-${reactId.replace(/:/g, "")}`;
|
|
250
|
+
|
|
251
|
+
// Resolve selected ids to data once per change.
|
|
252
|
+
const flat = React.useMemo(() => flattenTreeItems(items), [items]);
|
|
253
|
+
const selectedItems = React.useMemo(() => {
|
|
254
|
+
const order = new Map(flat.map((it, i) => [it.id, i] as const));
|
|
255
|
+
return selectedIds
|
|
256
|
+
.map((id) => flat.find((it) => it.id === id))
|
|
257
|
+
.filter((it): it is TreeItemData => it != null)
|
|
258
|
+
.sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0));
|
|
259
|
+
}, [flat, selectedIds]);
|
|
260
|
+
|
|
261
|
+
const handleSelectionChange = (next: string[]) => {
|
|
262
|
+
onSelectedIdsChange(next);
|
|
263
|
+
if (closeOnSelect) {
|
|
264
|
+
setOpen(false);
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const triggerContent = renderTrigger
|
|
269
|
+
? renderTrigger(selectedItems)
|
|
270
|
+
: defaultRenderTrigger(selectedItems, placeholder);
|
|
271
|
+
|
|
272
|
+
// Hidden input mirrors Base UI's pattern (see v3 SearchableSelect):
|
|
273
|
+
// - Single-select: the primary input carries the value + name, so the
|
|
274
|
+
// form picks it up like any text input.
|
|
275
|
+
// - Multi-select: the primary input has no name (doesn't submit) and a
|
|
276
|
+
// `<input type="hidden">` is rendered per selected id so
|
|
277
|
+
// `formData.getAll(name)` returns every selection.
|
|
278
|
+
// When no `name` is provided, only an id-based primary input is rendered
|
|
279
|
+
// so the `FormField → input` association keeps working.
|
|
280
|
+
const isMulti = selectionMode === "multiple";
|
|
281
|
+
const primaryInputName = isMulti ? undefined : name;
|
|
282
|
+
const primaryInputId =
|
|
283
|
+
id && primaryInputName == null ? `${id}-hidden-input` : undefined;
|
|
284
|
+
|
|
285
|
+
return (
|
|
286
|
+
<>
|
|
287
|
+
<HiddenInput
|
|
288
|
+
type="text"
|
|
289
|
+
id={primaryInputId}
|
|
290
|
+
name={primaryInputName}
|
|
291
|
+
value={isMulti ? "" : selectedIds[0] ?? ""}
|
|
292
|
+
onChange={() => {
|
|
293
|
+
// Controlled value with no user-driven mutation — value updates come
|
|
294
|
+
// from the combobox's onSelectedIdsChange path, not from the input.
|
|
295
|
+
}}
|
|
296
|
+
disabled={disabled}
|
|
297
|
+
required={required && !isMulti}
|
|
298
|
+
readOnly={readOnly}
|
|
299
|
+
tabIndex={-1}
|
|
300
|
+
aria-hidden="true"
|
|
301
|
+
/>
|
|
302
|
+
{isMulti && name
|
|
303
|
+
? selectedIds.map((selectedId) => (
|
|
304
|
+
<input
|
|
305
|
+
key={selectedId}
|
|
306
|
+
type="hidden"
|
|
307
|
+
name={name}
|
|
308
|
+
value={selectedId}
|
|
309
|
+
/>
|
|
310
|
+
))
|
|
311
|
+
: null}
|
|
312
|
+
<Popout
|
|
313
|
+
open={open}
|
|
314
|
+
onOpenChange={setOpen}
|
|
315
|
+
side="bottom"
|
|
316
|
+
align="start"
|
|
317
|
+
// Popout v2's default has padding: space[400] around its content. Our
|
|
318
|
+
// inner sections (search header + tree list) own their padding, so we
|
|
319
|
+
// strip the outer padding here to match the v3 SearchableSelect popup.
|
|
320
|
+
style={{ padding: 0 }}
|
|
321
|
+
onEscapeKeyDown={(event) => {
|
|
322
|
+
// When the user has a query typed, the first Escape should clear the
|
|
323
|
+
// query (handled inside TreeCombobox) and leave the popout open.
|
|
324
|
+
// The second Escape (with empty query) closes it.
|
|
325
|
+
if (query.length > 0) {
|
|
326
|
+
event.preventDefault();
|
|
327
|
+
}
|
|
328
|
+
}}
|
|
329
|
+
content={
|
|
330
|
+
<PopoutContent id={popupId} $width={contentWidth}>
|
|
331
|
+
<TreeCombobox
|
|
332
|
+
/*
|
|
333
|
+
* The wrapper's `aria-label` / `aria-labelledby` already names the
|
|
334
|
+
* outer trigger combobox. Giving the inner search input the same
|
|
335
|
+
* name produces two `role="combobox"` elements with identical
|
|
336
|
+
* accessible names — confusing for AT and ambiguous for tests.
|
|
337
|
+
* The inner input is the "search-within-the-popup" control, so
|
|
338
|
+
* give it a distinct, generic label.
|
|
339
|
+
*/
|
|
340
|
+
aria-label="Search"
|
|
341
|
+
items={items}
|
|
342
|
+
placeholder={searchPlaceholder}
|
|
343
|
+
emptyText={emptyText}
|
|
344
|
+
query={query}
|
|
345
|
+
onQueryChange={setQuery}
|
|
346
|
+
selectionMode={selectionMode}
|
|
347
|
+
selectableNodes={selectableNodes}
|
|
348
|
+
selected={selectedIds}
|
|
349
|
+
onSelectionChange={handleSelectionChange}
|
|
350
|
+
renderSelectionIndicator={renderSelectionIndicator}
|
|
351
|
+
defaultExpanded={defaultExpanded}
|
|
352
|
+
expanded={expanded}
|
|
353
|
+
onExpandedChange={onExpandedChange}
|
|
354
|
+
/>
|
|
355
|
+
</PopoutContent>
|
|
356
|
+
}
|
|
357
|
+
>
|
|
358
|
+
<Trigger
|
|
359
|
+
type="button"
|
|
360
|
+
id={id}
|
|
361
|
+
className={className}
|
|
362
|
+
$isInvalid={isInvalid}
|
|
363
|
+
$fullWidth={fullWidth}
|
|
364
|
+
disabled={disabled}
|
|
365
|
+
role="combobox"
|
|
366
|
+
aria-haspopup="dialog"
|
|
367
|
+
aria-expanded={open}
|
|
368
|
+
aria-controls={open ? popupId : undefined}
|
|
369
|
+
aria-label={ariaLabel}
|
|
370
|
+
aria-labelledby={ariaLabelledBy}
|
|
371
|
+
aria-describedby={ariaDescribedBy}
|
|
372
|
+
aria-invalid={isInvalid || undefined}
|
|
373
|
+
aria-required={required || undefined}
|
|
374
|
+
>
|
|
375
|
+
<TriggerLabel>{triggerContent}</TriggerLabel>
|
|
376
|
+
<Chevron $open={open} aria-hidden>
|
|
377
|
+
<Icon name="chevron-down-outline" size="small" />
|
|
378
|
+
</Chevron>
|
|
379
|
+
</Trigger>
|
|
380
|
+
</Popout>
|
|
381
|
+
</>
|
|
382
|
+
);
|
|
383
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { TreeItemData } from "./types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Walk a tree depth-first and return every item in a flat array, preserving
|
|
5
|
+
* the original visit order.
|
|
6
|
+
*
|
|
7
|
+
* Used by `TreeSearchableSelect` wrappers to resolve selected ids back to
|
|
8
|
+
* their `TreeItemData` for rendering trigger labels.
|
|
9
|
+
*/
|
|
10
|
+
export function flattenTreeItems(
|
|
11
|
+
items: ReadonlyArray<TreeItemData>
|
|
12
|
+
): TreeItemData[] {
|
|
13
|
+
const out: TreeItemData[] = [];
|
|
14
|
+
const walk = (nodes: ReadonlyArray<TreeItemData>) => {
|
|
15
|
+
for (const node of nodes) {
|
|
16
|
+
out.push(node);
|
|
17
|
+
if (node.children) walk(node.children);
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
walk(items);
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import styled from "styled-components";
|
|
3
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
4
|
+
import { MultiTreeSearchableSelect } from "./MultiTreeSearchableSelect";
|
|
5
|
+
import {
|
|
6
|
+
collections,
|
|
7
|
+
fileTree,
|
|
8
|
+
pokedex,
|
|
9
|
+
checkboxIndicator,
|
|
10
|
+
SelectionReadout,
|
|
11
|
+
} from "./storyData";
|
|
12
|
+
|
|
13
|
+
const meta: Meta<typeof MultiTreeSearchableSelect> = {
|
|
14
|
+
title: "Really Under Development/Tree/MultiTreeSearchableSelect",
|
|
15
|
+
component: MultiTreeSearchableSelect,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export default meta;
|
|
19
|
+
type Story = StoryObj<typeof MultiTreeSearchableSelect>;
|
|
20
|
+
|
|
21
|
+
const Stage = styled.div`
|
|
22
|
+
padding: ${({ theme }) => theme.space[500]};
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
const Field = styled.div`
|
|
26
|
+
width: 300px;
|
|
27
|
+
`;
|
|
28
|
+
|
|
29
|
+
const TwoColumn = styled.div`
|
|
30
|
+
display: grid;
|
|
31
|
+
grid-template-columns: 1fr 1fr;
|
|
32
|
+
gap: ${({ theme }) => theme.space[500]};
|
|
33
|
+
align-items: start;
|
|
34
|
+
max-width: 640px;
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
const FieldLabel = styled.div`
|
|
38
|
+
${({ theme }) => theme.typography[200]}
|
|
39
|
+
font-weight: ${({ theme }) => theme.fontWeights.semibold};
|
|
40
|
+
margin-bottom: ${({ theme }) => theme.space[200]};
|
|
41
|
+
`;
|
|
42
|
+
|
|
43
|
+
export const Basic: Story = {
|
|
44
|
+
name: "Basic",
|
|
45
|
+
render: () => (
|
|
46
|
+
<Stage>
|
|
47
|
+
<Field>
|
|
48
|
+
<MultiTreeSearchableSelect
|
|
49
|
+
aria-label="Pick collections"
|
|
50
|
+
items={collections}
|
|
51
|
+
placeholder="Pick collections"
|
|
52
|
+
renderSelectionIndicator={checkboxIndicator}
|
|
53
|
+
defaultExpanded={["customer-care"]}
|
|
54
|
+
/>
|
|
55
|
+
</Field>
|
|
56
|
+
</Stage>
|
|
57
|
+
),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const Preselected: Story = {
|
|
61
|
+
name: "Pre-selected (uncontrolled default)",
|
|
62
|
+
render: () => (
|
|
63
|
+
<Stage>
|
|
64
|
+
<Field>
|
|
65
|
+
<MultiTreeSearchableSelect
|
|
66
|
+
aria-label="Pick collections"
|
|
67
|
+
items={collections}
|
|
68
|
+
placeholder="Pick collections"
|
|
69
|
+
renderSelectionIndicator={checkboxIndicator}
|
|
70
|
+
defaultExpanded={["customer-care"]}
|
|
71
|
+
defaultSelectedItemIds={["questions", "bug-reports"]}
|
|
72
|
+
/>
|
|
73
|
+
</Field>
|
|
74
|
+
</Stage>
|
|
75
|
+
),
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export const Controlled: Story = {
|
|
79
|
+
name: "Controlled selection",
|
|
80
|
+
render: () => {
|
|
81
|
+
const [selected, setSelected] = React.useState<string[]>([
|
|
82
|
+
"questions",
|
|
83
|
+
"bug-reports",
|
|
84
|
+
]);
|
|
85
|
+
return (
|
|
86
|
+
<Stage>
|
|
87
|
+
<Field>
|
|
88
|
+
<MultiTreeSearchableSelect
|
|
89
|
+
aria-label="Pick collections"
|
|
90
|
+
items={collections}
|
|
91
|
+
placeholder="Pick collections"
|
|
92
|
+
renderSelectionIndicator={checkboxIndicator}
|
|
93
|
+
defaultExpanded={["customer-care"]}
|
|
94
|
+
selectedItemIds={selected}
|
|
95
|
+
onSelectedItemIdsChange={setSelected}
|
|
96
|
+
/>
|
|
97
|
+
<SelectionReadout selected={selected} />
|
|
98
|
+
</Field>
|
|
99
|
+
</Stage>
|
|
100
|
+
);
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export const ManySelectionsCollapsesToCount: Story = {
|
|
105
|
+
name: "Many selections (N selected)",
|
|
106
|
+
render: () => {
|
|
107
|
+
const [selected, setSelected] = React.useState<string[]>([
|
|
108
|
+
"questions",
|
|
109
|
+
"bug-reports",
|
|
110
|
+
"feature-requests",
|
|
111
|
+
"complaints",
|
|
112
|
+
]);
|
|
113
|
+
return (
|
|
114
|
+
<Stage>
|
|
115
|
+
<Field>
|
|
116
|
+
<MultiTreeSearchableSelect
|
|
117
|
+
aria-label="Pick collections"
|
|
118
|
+
items={collections}
|
|
119
|
+
placeholder="Pick collections"
|
|
120
|
+
renderSelectionIndicator={checkboxIndicator}
|
|
121
|
+
defaultExpanded={["customer-care"]}
|
|
122
|
+
selectedItemIds={selected}
|
|
123
|
+
onSelectedItemIdsChange={setSelected}
|
|
124
|
+
/>
|
|
125
|
+
<SelectionReadout selected={selected} />
|
|
126
|
+
</Field>
|
|
127
|
+
</Stage>
|
|
128
|
+
);
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export const LeavesOnly: Story = {
|
|
133
|
+
name: "Leaves-only selection",
|
|
134
|
+
render: () => (
|
|
135
|
+
<Stage>
|
|
136
|
+
<Field>
|
|
137
|
+
<MultiTreeSearchableSelect
|
|
138
|
+
aria-label="Pick files"
|
|
139
|
+
items={fileTree}
|
|
140
|
+
placeholder="Pick files"
|
|
141
|
+
selectableNodes="leaves"
|
|
142
|
+
renderSelectionIndicator={checkboxIndicator}
|
|
143
|
+
/>
|
|
144
|
+
</Field>
|
|
145
|
+
</Stage>
|
|
146
|
+
),
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
export const FullWidth: Story = {
|
|
150
|
+
name: "Full width",
|
|
151
|
+
render: () => (
|
|
152
|
+
<Stage>
|
|
153
|
+
<div style={{ width: 600 }}>
|
|
154
|
+
<MultiTreeSearchableSelect
|
|
155
|
+
aria-label="Pick collections"
|
|
156
|
+
items={collections}
|
|
157
|
+
placeholder="Pick collections"
|
|
158
|
+
renderSelectionIndicator={checkboxIndicator}
|
|
159
|
+
defaultExpanded={["customer-care"]}
|
|
160
|
+
fullWidth
|
|
161
|
+
/>
|
|
162
|
+
</div>
|
|
163
|
+
</Stage>
|
|
164
|
+
),
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
export const DisabledAndInvalid: Story = {
|
|
168
|
+
name: "Disabled & invalid states",
|
|
169
|
+
render: () => (
|
|
170
|
+
<Stage>
|
|
171
|
+
<TwoColumn>
|
|
172
|
+
<div>
|
|
173
|
+
<FieldLabel>Disabled</FieldLabel>
|
|
174
|
+
<MultiTreeSearchableSelect
|
|
175
|
+
aria-label="Disabled select"
|
|
176
|
+
items={collections}
|
|
177
|
+
placeholder="Pick collections"
|
|
178
|
+
renderSelectionIndicator={checkboxIndicator}
|
|
179
|
+
disabled
|
|
180
|
+
/>
|
|
181
|
+
</div>
|
|
182
|
+
<div>
|
|
183
|
+
<FieldLabel>Invalid</FieldLabel>
|
|
184
|
+
<MultiTreeSearchableSelect
|
|
185
|
+
aria-label="Invalid select"
|
|
186
|
+
items={collections}
|
|
187
|
+
placeholder="Pick collections"
|
|
188
|
+
renderSelectionIndicator={checkboxIndicator}
|
|
189
|
+
isInvalid
|
|
190
|
+
/>
|
|
191
|
+
</div>
|
|
192
|
+
</TwoColumn>
|
|
193
|
+
</Stage>
|
|
194
|
+
),
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
export const DeepTree: Story = {
|
|
198
|
+
name: "Deep tree (7 levels)",
|
|
199
|
+
render: () => {
|
|
200
|
+
const [selected, setSelected] = React.useState<string[]>([]);
|
|
201
|
+
return (
|
|
202
|
+
<Stage>
|
|
203
|
+
<Field>
|
|
204
|
+
<MultiTreeSearchableSelect
|
|
205
|
+
aria-label="Pick Pokémon"
|
|
206
|
+
items={pokedex}
|
|
207
|
+
placeholder="Pick Pokémon"
|
|
208
|
+
searchPlaceholder="Search the Pokédex..."
|
|
209
|
+
renderSelectionIndicator={checkboxIndicator}
|
|
210
|
+
defaultExpanded={[
|
|
211
|
+
"gen-1",
|
|
212
|
+
"kanto",
|
|
213
|
+
"kanto-grass",
|
|
214
|
+
"bulbasaur-line",
|
|
215
|
+
]}
|
|
216
|
+
selectedItemIds={selected}
|
|
217
|
+
onSelectedItemIdsChange={setSelected}
|
|
218
|
+
/>
|
|
219
|
+
<SelectionReadout selected={selected} />
|
|
220
|
+
</Field>
|
|
221
|
+
</Stage>
|
|
222
|
+
);
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
export const CustomRenderTrigger: Story = {
|
|
227
|
+
name: "Custom renderTrigger",
|
|
228
|
+
render: () => {
|
|
229
|
+
const [selected, setSelected] = React.useState<string[]>([
|
|
230
|
+
"questions",
|
|
231
|
+
"bug-reports",
|
|
232
|
+
]);
|
|
233
|
+
return (
|
|
234
|
+
<Stage>
|
|
235
|
+
<Field>
|
|
236
|
+
<MultiTreeSearchableSelect
|
|
237
|
+
aria-label="Pick collections"
|
|
238
|
+
items={collections}
|
|
239
|
+
placeholder="Pick collections"
|
|
240
|
+
renderSelectionIndicator={checkboxIndicator}
|
|
241
|
+
defaultExpanded={["customer-care"]}
|
|
242
|
+
selectedItemIds={selected}
|
|
243
|
+
onSelectedItemIdsChange={setSelected}
|
|
244
|
+
renderTrigger={(items) => (
|
|
245
|
+
<span>
|
|
246
|
+
<strong>{items.length}</strong>{" "}
|
|
247
|
+
{items.length === 1 ? "collection" : "collections"} picked
|
|
248
|
+
</span>
|
|
249
|
+
)}
|
|
250
|
+
/>
|
|
251
|
+
<SelectionReadout selected={selected} />
|
|
252
|
+
</Field>
|
|
253
|
+
</Stage>
|
|
254
|
+
);
|
|
255
|
+
},
|
|
256
|
+
};
|