@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.
@@ -0,0 +1,92 @@
1
+ import * as React from "react";
2
+ import {
3
+ TreeSearchableSelectBase,
4
+ type RenderTrigger,
5
+ } from "./Common/TreeSearchableSelectBase";
6
+ import type {
7
+ TreeItemData,
8
+ TreeSelectableNodes,
9
+ TreeSelectionIndicator,
10
+ } from "./Common/types";
11
+
12
+ export type MultiTreeSearchableSelectProps = {
13
+ items: ReadonlyArray<TreeItemData>;
14
+ selectableNodes?: TreeSelectableNodes;
15
+
16
+ selectedItemIds?: ReadonlyArray<string>;
17
+ defaultSelectedItemIds?: ReadonlyArray<string>;
18
+ onSelectedItemIdsChange?: (ids: string[]) => void;
19
+
20
+ /** Accessible name for the trigger. One of label / labelledby is required. */
21
+ "aria-label"?: string;
22
+ "aria-labelledby"?: string;
23
+ "aria-describedby"?: string;
24
+
25
+ placeholder?: string;
26
+ searchPlaceholder?: string;
27
+ emptyText?: string;
28
+
29
+ /** Render override for the trigger label area. */
30
+ renderTrigger?: RenderTrigger;
31
+
32
+ open?: boolean;
33
+ defaultOpen?: boolean;
34
+ onOpenChange?: (open: boolean) => void;
35
+
36
+ disabled?: boolean;
37
+ isInvalid?: boolean;
38
+ required?: boolean;
39
+ readOnly?: boolean;
40
+ /**
41
+ * Name for the form inputs. Renders one `<input type="hidden">` per selected
42
+ * id under this name so `formData.getAll(name)` returns every selection.
43
+ */
44
+ name?: string;
45
+ fullWidth?: boolean;
46
+ contentWidth?: number | string;
47
+
48
+ renderSelectionIndicator?: TreeSelectionIndicator;
49
+ defaultExpanded?: ReadonlyArray<string>;
50
+ expanded?: ReadonlyArray<string>;
51
+ onExpandedChange?: (expanded: string[]) => void;
52
+
53
+ id?: string;
54
+ className?: string;
55
+ };
56
+
57
+ /**
58
+ * Multi-select tree inside a popout, following the same shape as
59
+ * `MultiSearchableSelect`. Picks accumulate; the popout stays open until the
60
+ * user dismisses it (Escape on empty query, outside click, or trigger click).
61
+ */
62
+ export function MultiTreeSearchableSelect(
63
+ props: MultiTreeSearchableSelectProps
64
+ ) {
65
+ const {
66
+ selectedItemIds: selectedItemIdsProp,
67
+ defaultSelectedItemIds,
68
+ onSelectedItemIdsChange,
69
+ ...rest
70
+ } = props;
71
+
72
+ const isControlled = selectedItemIdsProp !== undefined;
73
+ const [internalIds, setInternalIds] = React.useState<string[]>(() => [
74
+ ...(defaultSelectedItemIds ?? []),
75
+ ]);
76
+ const selectedIds = isControlled ? selectedItemIdsProp ?? [] : internalIds;
77
+
78
+ const handleSelectedIdsChange = (ids: string[]) => {
79
+ if (!isControlled) setInternalIds(ids);
80
+ onSelectedItemIdsChange?.(ids);
81
+ };
82
+
83
+ return (
84
+ <TreeSearchableSelectBase
85
+ {...rest}
86
+ selectionMode="multiple"
87
+ selectedIds={selectedIds}
88
+ onSelectedIdsChange={handleSelectedIdsChange}
89
+ closeOnSelect={false}
90
+ />
91
+ );
92
+ }
@@ -0,0 +1,217 @@
1
+ import React from "react";
2
+ import styled from "styled-components";
3
+ import type { Meta, StoryObj } from "@storybook/react";
4
+ import { SingleTreeSearchableSelect } from "./SingleTreeSearchableSelect";
5
+ import {
6
+ collections,
7
+ fileTree,
8
+ pokedex,
9
+ radioIndicator,
10
+ SelectionReadout,
11
+ } from "./storyData";
12
+
13
+ const meta: Meta<typeof SingleTreeSearchableSelect> = {
14
+ title: "Really Under Development/Tree/SingleTreeSearchableSelect",
15
+ component: SingleTreeSearchableSelect,
16
+ };
17
+
18
+ export default meta;
19
+ type Story = StoryObj<typeof SingleTreeSearchableSelect>;
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
+ <SingleTreeSearchableSelect
49
+ aria-label="Pick a collection"
50
+ items={collections}
51
+ placeholder="Pick a collection"
52
+ renderSelectionIndicator={radioIndicator}
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
+ <SingleTreeSearchableSelect
66
+ aria-label="Pick a collection"
67
+ items={collections}
68
+ placeholder="Pick a collection"
69
+ renderSelectionIndicator={radioIndicator}
70
+ defaultExpanded={["customer-care"]}
71
+ defaultSelectedItemId="questions"
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 | null>("questions");
82
+ return (
83
+ <Stage>
84
+ <Field>
85
+ <SingleTreeSearchableSelect
86
+ aria-label="Pick a collection"
87
+ items={collections}
88
+ placeholder="Pick a collection"
89
+ renderSelectionIndicator={radioIndicator}
90
+ defaultExpanded={["customer-care"]}
91
+ selectedItemId={selected}
92
+ onSelectedItemIdChange={setSelected}
93
+ />
94
+ <SelectionReadout selected={selected ? [selected] : []} />
95
+ </Field>
96
+ </Stage>
97
+ );
98
+ },
99
+ };
100
+
101
+ export const LeavesOnly: Story = {
102
+ name: "Leaves-only selection",
103
+ render: () => (
104
+ <Stage>
105
+ <Field>
106
+ <SingleTreeSearchableSelect
107
+ aria-label="Pick a file"
108
+ items={fileTree}
109
+ placeholder="Pick a file"
110
+ selectableNodes="leaves"
111
+ renderSelectionIndicator={radioIndicator}
112
+ />
113
+ </Field>
114
+ </Stage>
115
+ ),
116
+ };
117
+
118
+ export const FullWidth: Story = {
119
+ name: "Full width",
120
+ render: () => (
121
+ <Stage>
122
+ <div style={{ width: 600 }}>
123
+ <SingleTreeSearchableSelect
124
+ aria-label="Pick a collection"
125
+ items={collections}
126
+ placeholder="Pick a collection"
127
+ renderSelectionIndicator={radioIndicator}
128
+ defaultExpanded={["customer-care"]}
129
+ fullWidth
130
+ />
131
+ </div>
132
+ </Stage>
133
+ ),
134
+ };
135
+
136
+ export const DisabledAndInvalid: Story = {
137
+ name: "Disabled & invalid states",
138
+ render: () => (
139
+ <Stage>
140
+ <TwoColumn>
141
+ <div>
142
+ <FieldLabel>Disabled</FieldLabel>
143
+ <SingleTreeSearchableSelect
144
+ aria-label="Disabled select"
145
+ items={collections}
146
+ placeholder="Pick a collection"
147
+ renderSelectionIndicator={radioIndicator}
148
+ disabled
149
+ />
150
+ </div>
151
+ <div>
152
+ <FieldLabel>Invalid</FieldLabel>
153
+ <SingleTreeSearchableSelect
154
+ aria-label="Invalid select"
155
+ items={collections}
156
+ placeholder="Pick a collection"
157
+ renderSelectionIndicator={radioIndicator}
158
+ isInvalid
159
+ />
160
+ </div>
161
+ </TwoColumn>
162
+ </Stage>
163
+ ),
164
+ };
165
+
166
+ export const DeepTree: Story = {
167
+ name: "Deep tree (7 levels)",
168
+ render: () => (
169
+ <Stage>
170
+ <Field>
171
+ <SingleTreeSearchableSelect
172
+ aria-label="Pick a Pokémon form"
173
+ items={pokedex}
174
+ placeholder="Pick a Pokémon form"
175
+ searchPlaceholder="Search the Pokédex..."
176
+ selectableNodes="leaves"
177
+ renderSelectionIndicator={radioIndicator}
178
+ defaultExpanded={[
179
+ "gen-1",
180
+ "kanto",
181
+ "kanto-fire",
182
+ "charmander-line",
183
+ "charm-stage-3",
184
+ "charizard",
185
+ ]}
186
+ />
187
+ </Field>
188
+ </Stage>
189
+ ),
190
+ };
191
+
192
+ export const CustomRenderTrigger: Story = {
193
+ name: "Custom renderTrigger",
194
+ render: () => (
195
+ <Stage>
196
+ <Field>
197
+ <SingleTreeSearchableSelect
198
+ aria-label="Pick a collection"
199
+ items={collections}
200
+ placeholder="Pick a collection"
201
+ renderSelectionIndicator={radioIndicator}
202
+ defaultExpanded={["customer-care"]}
203
+ defaultSelectedItemId="bug-reports"
204
+ renderTrigger={(selected) =>
205
+ selected.length === 0 ? (
206
+ <em style={{ color: "#888" }}>Nothing selected yet</em>
207
+ ) : (
208
+ <span>
209
+ <strong>Selected:</strong> {selected[0]?.label}
210
+ </span>
211
+ )
212
+ }
213
+ />
214
+ </Field>
215
+ </Stage>
216
+ ),
217
+ };
@@ -0,0 +1,95 @@
1
+ import * as React from "react";
2
+ import {
3
+ TreeSearchableSelectBase,
4
+ type RenderTrigger,
5
+ } from "./Common/TreeSearchableSelectBase";
6
+ import type {
7
+ TreeItemData,
8
+ TreeSelectableNodes,
9
+ TreeSelectionIndicator,
10
+ } from "./Common/types";
11
+
12
+ export type SingleTreeSearchableSelectProps = {
13
+ items: ReadonlyArray<TreeItemData>;
14
+ selectableNodes?: TreeSelectableNodes;
15
+
16
+ selectedItemId?: string | null;
17
+ defaultSelectedItemId?: string | null;
18
+ onSelectedItemIdChange?: (id: string | null) => void;
19
+
20
+ /** Accessible name for the trigger. One of label / labelledby is required. */
21
+ "aria-label"?: string;
22
+ "aria-labelledby"?: string;
23
+ "aria-describedby"?: string;
24
+
25
+ placeholder?: string;
26
+ searchPlaceholder?: string;
27
+ emptyText?: string;
28
+
29
+ /** Render override for the trigger label area. */
30
+ renderTrigger?: RenderTrigger;
31
+
32
+ open?: boolean;
33
+ defaultOpen?: boolean;
34
+ onOpenChange?: (open: boolean) => void;
35
+
36
+ disabled?: boolean;
37
+ isInvalid?: boolean;
38
+ required?: boolean;
39
+ readOnly?: boolean;
40
+ /** Name for the hidden form input, enabling participation in native form submission. */
41
+ name?: string;
42
+ fullWidth?: boolean;
43
+ contentWidth?: number | string;
44
+
45
+ renderSelectionIndicator?: TreeSelectionIndicator;
46
+ defaultExpanded?: ReadonlyArray<string>;
47
+ expanded?: ReadonlyArray<string>;
48
+ onExpandedChange?: (expanded: string[]) => void;
49
+
50
+ id?: string;
51
+ className?: string;
52
+ };
53
+
54
+ /**
55
+ * Single-select tree inside a popout, following the same shape as
56
+ * `SingleSearchableSelect`. Picks close the popout and return focus to the
57
+ * trigger.
58
+ */
59
+ export function SingleTreeSearchableSelect(
60
+ props: SingleTreeSearchableSelectProps
61
+ ) {
62
+ const {
63
+ selectedItemId: selectedItemIdProp,
64
+ defaultSelectedItemId = null,
65
+ onSelectedItemIdChange,
66
+ ...rest
67
+ } = props;
68
+
69
+ const isControlled = selectedItemIdProp !== undefined;
70
+ const [internalId, setInternalId] = React.useState<string | null>(
71
+ defaultSelectedItemId
72
+ );
73
+ const selectedItemId = isControlled ? selectedItemIdProp ?? null : internalId;
74
+
75
+ const selectedIds = React.useMemo(
76
+ () => (selectedItemId ? [selectedItemId] : []),
77
+ [selectedItemId]
78
+ );
79
+
80
+ const handleSelectedIdsChange = (ids: string[]) => {
81
+ const next = ids[0] ?? null;
82
+ if (!isControlled) setInternalId(next);
83
+ onSelectedItemIdChange?.(next);
84
+ };
85
+
86
+ return (
87
+ <TreeSearchableSelectBase
88
+ {...rest}
89
+ selectionMode="single"
90
+ selectedIds={selectedIds}
91
+ onSelectedIdsChange={handleSelectedIdsChange}
92
+ closeOnSelect
93
+ />
94
+ );
95
+ }
@@ -11,7 +11,14 @@ import type { TreeItemData } from "./Common/types";
11
11
  const Root = styled.div`
12
12
  display: flex;
13
13
  flex-direction: column;
14
- gap: ${({ theme }) => theme.space[300]};
14
+ flex: 1;
15
+ min-height: 0;
16
+ `;
17
+
18
+ const SearchSection = styled.div`
19
+ padding: ${({ theme }) => theme.space[300]};
20
+ border-bottom: 1px solid ${({ theme }) => theme.colors.container.border.base};
21
+ flex-shrink: 0;
15
22
  `;
16
23
 
17
24
  const InputGroup = styled.div`
@@ -20,8 +27,8 @@ const InputGroup = styled.div`
20
27
  gap: ${({ theme }) => theme.space[200]};
21
28
  padding: ${({ theme }) => theme.space[200]} ${({ theme }) => theme.space[300]};
22
29
  border-radius: ${({ theme }) => theme.radii[500]};
23
- border: 1px solid ${({ theme }) => theme.colors.form.border.base};
24
- background: ${({ theme }) => theme.colors.form.background.base};
30
+ border: 1px solid ${({ theme }) => theme.colors.container.border.base};
31
+ background: ${({ theme }) => theme.colors.container.background.base};
25
32
  color: ${({ theme }) => theme.colors.icon.base};
26
33
 
27
34
  &:focus-within {
@@ -35,7 +42,8 @@ const ComboboxInput = styled.input`
35
42
  background: transparent;
36
43
  outline: none;
37
44
  font-family: ${({ theme }) => theme.fontFamily};
38
- ${({ theme }) => theme.typography[300]}
45
+ font-size: ${({ theme }) => theme.typography[200].fontSize};
46
+ line-height: ${({ theme }) => theme.typography[200].lineHeight};
39
47
  color: ${({ theme }) => theme.colors.text.body};
40
48
 
41
49
  &::placeholder {
@@ -43,11 +51,19 @@ const ComboboxInput = styled.input`
43
51
  }
44
52
  `;
45
53
 
54
+ const ListSection = styled.div`
55
+ padding: ${({ theme }) => theme.space[200]};
56
+ overflow: auto;
57
+ flex: 1;
58
+ min-height: 0;
59
+ `;
60
+
46
61
  const EmptyState = styled.div`
47
- padding: ${({ theme }) => theme.space[400]};
62
+ padding: ${({ theme }) => `${theme.space[300]} ${theme.space[350]}`};
48
63
  text-align: center;
49
64
  color: ${({ theme }) => theme.colors.text.subtext};
50
- ${({ theme }) => theme.typography[300]}
65
+ font-size: ${({ theme }) => theme.typography[200].fontSize};
66
+ line-height: ${({ theme }) => theme.typography[200].lineHeight};
51
67
  `;
52
68
 
53
69
  const NAV_KEYS = new Set([
@@ -218,59 +234,82 @@ export function TreeCombobox(props: TreeComboboxProps) {
218
234
  // We accept that and don't reset focusedId on filter ourselves — clearing
219
235
  // happens explicitly on Escape.
220
236
 
237
+ // Keep the active treeitem visible as the user navigates with the
238
+ // keyboard. In DOM-focus mode the browser auto-scrolls on `.focus()`, but
239
+ // combobox mode skips that call (focus stays on the input), so we have to
240
+ // scroll explicitly to meet the WAI-ARIA combobox/treeview expectation
241
+ // that the active option stays in view. `block: "nearest"` only scrolls
242
+ // when the row is actually off-screen.
243
+ React.useLayoutEffect(() => {
244
+ if (!focusedId) return;
245
+ const itemEl = findTreeItemEl(focusedId);
246
+ const row =
247
+ itemEl?.querySelector<HTMLElement>("[data-treeitem-row]") ?? itemEl;
248
+ // jsdom doesn't implement scrollIntoView; bail there.
249
+ if (row && typeof row.scrollIntoView === "function") {
250
+ row.scrollIntoView({ block: "nearest", inline: "nearest" });
251
+ }
252
+ // findTreeItemEl is read from a ref so it doesn't need to be a dep.
253
+ // eslint-disable-next-line react-hooks/exhaustive-deps
254
+ }, [focusedId]);
255
+
221
256
  const showEmpty = visibleItems.length === 0;
222
257
 
223
258
  return (
224
259
  <Root className={className}>
225
- <InputGroup>
226
- <Icon name="magnifying-glass-outline" size="small" aria-hidden />
227
- <ComboboxInput
228
- type="text"
229
- role="combobox"
230
- id={id}
260
+ <SearchSection>
261
+ <InputGroup>
262
+ <Icon name="magnifying-glass-outline" size="small" aria-hidden />
263
+ <ComboboxInput
264
+ type="text"
265
+ role="combobox"
266
+ id={id}
267
+ aria-label={ariaLabel}
268
+ aria-labelledby={ariaLabelledBy}
269
+ aria-expanded={!showEmpty}
270
+ aria-controls={treeDomId}
271
+ aria-haspopup="tree"
272
+ aria-autocomplete="list"
273
+ aria-activedescendant={
274
+ focusedId ? treeItemDomId(focusedId) : undefined
275
+ }
276
+ placeholder={placeholder}
277
+ value={query}
278
+ onChange={(e) => setQuery(e.target.value)}
279
+ onKeyDown={handleInputKeyDown}
280
+ />
281
+ </InputGroup>
282
+ </SearchSection>
283
+
284
+ <ListSection>
285
+ <Tree
286
+ ref={treeRef}
231
287
  aria-label={ariaLabel}
232
288
  aria-labelledby={ariaLabelledBy}
233
- aria-expanded={!showEmpty}
234
- aria-controls={treeDomId}
235
- aria-haspopup="tree"
236
- aria-autocomplete="list"
237
- aria-activedescendant={
238
- focusedId ? treeItemDomId(focusedId) : undefined
239
- }
240
- placeholder={placeholder}
241
- value={query}
242
- onChange={(e) => setQuery(e.target.value)}
243
- onKeyDown={handleInputKeyDown}
244
- />
245
- </InputGroup>
246
-
247
- <Tree
248
- ref={treeRef}
249
- aria-label={ariaLabel}
250
- aria-labelledby={ariaLabelledBy}
251
- id={treeDomId}
252
- manageDomFocus={false}
253
- selectionMode={selectionMode}
254
- selectableNodes={selectableNodes}
255
- expanded={effectiveExpanded}
256
- onExpandedChange={handleExpandedChange}
257
- selected={selectedProp}
258
- defaultSelected={defaultSelected}
259
- onSelectionChange={onSelectionChange}
260
- renderSelectionIndicator={renderSelectionIndicator}
261
- focusedId={focusedId}
262
- onFocusedIdChange={setFocusedId}
263
- >
264
- {visibleItems.map((item) => (
265
- <TreeNode key={item.id} item={item} />
266
- ))}
267
- </Tree>
289
+ id={treeDomId}
290
+ manageDomFocus={false}
291
+ selectionMode={selectionMode}
292
+ selectableNodes={selectableNodes}
293
+ expanded={effectiveExpanded}
294
+ onExpandedChange={handleExpandedChange}
295
+ selected={selectedProp}
296
+ defaultSelected={defaultSelected}
297
+ onSelectionChange={onSelectionChange}
298
+ renderSelectionIndicator={renderSelectionIndicator}
299
+ focusedId={focusedId}
300
+ onFocusedIdChange={setFocusedId}
301
+ >
302
+ {visibleItems.map((item) => (
303
+ <TreeNode key={item.id} item={item} />
304
+ ))}
305
+ </Tree>
268
306
 
269
- {showEmpty ? (
270
- <EmptyState role="status" aria-live="polite">
271
- {emptyText}
272
- </EmptyState>
273
- ) : null}
307
+ {showEmpty ? (
308
+ <EmptyState role="status" aria-live="polite">
309
+ {emptyText}
310
+ </EmptyState>
311
+ ) : null}
312
+ </ListSection>
274
313
  </Root>
275
314
  );
276
315
  }
@@ -5,7 +5,8 @@ export const TreeRoot = styled.ul`
5
5
  margin: 0;
6
6
  padding: 0;
7
7
  font-family: ${({ theme }) => theme.fontFamily};
8
- ${({ theme }) => theme.typography[300]}
8
+ font-size: ${({ theme }) => theme.typography[200].fontSize};
9
+ line-height: ${({ theme }) => theme.typography[200].lineHeight};
9
10
  color: ${({ theme }) => theme.colors.text.body};
10
11
  `;
11
12
 
@@ -19,7 +20,7 @@ export const TreeItemRow = styled.div<{
19
20
  gap: ${({ theme }) => theme.space[300]};
20
21
  padding: ${({ theme }) => theme.space[300]};
21
22
  padding-left: ${({ theme, $level }) =>
22
- `calc(${theme.space[300]} + ${$level - 1} * ${theme.space[500]})`};
23
+ `calc(${theme.space[300]} + ${$level - 1} * ${theme.space[400]})`};
23
24
  border-radius: ${({ theme }) => theme.radii[400]};
24
25
  cursor: pointer;
25
26
  user-select: none;