@sproutsocial/seeds-react-tree 0.3.1 → 0.4.1
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 +10 -10
- package/CHANGELOG.md +46 -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 +7 -6
- 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,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
|
+
}
|
package/src/TreeCombobox.tsx
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
24
|
-
background: ${({ theme }) => theme.colors.
|
|
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[
|
|
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[
|
|
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[
|
|
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
|
-
<
|
|
226
|
-
<
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
307
|
+
{showEmpty ? (
|
|
308
|
+
<EmptyState role="status" aria-live="polite">
|
|
309
|
+
{emptyText}
|
|
310
|
+
</EmptyState>
|
|
311
|
+
) : null}
|
|
312
|
+
</ListSection>
|
|
274
313
|
</Root>
|
|
275
314
|
);
|
|
276
315
|
}
|
package/src/TreeStyles.tsx
CHANGED
|
@@ -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[
|
|
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[
|
|
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;
|