@sproutsocial/seeds-react-tree 0.3.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 +21 -0
- package/CHANGELOG.md +112 -0
- package/README.md +51 -0
- package/dist/esm/index.js +887 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/index.d.mts +102 -0
- package/dist/index.d.ts +102 -0
- package/dist/index.js +926 -0
- package/dist/index.js.map +1 -0
- package/jest.config.js +12 -0
- package/package.json +61 -0
- package/src/Common/filterTree.ts +51 -0
- package/src/Common/treeContext.ts +52 -0
- package/src/Common/treeNavigation.ts +171 -0
- package/src/Common/types.ts +23 -0
- package/src/Common/useTreeKeyboard.ts +124 -0
- package/src/Common/useTreeState.ts +107 -0
- package/src/Tree.stories.tsx +231 -0
- package/src/Tree.tsx +181 -0
- package/src/TreeCombobox.stories.tsx +100 -0
- package/src/TreeCombobox.tsx +291 -0
- package/src/TreeItem.tsx +218 -0
- package/src/TreeStyles.tsx +114 -0
- package/src/__tests__/Tree.test.tsx +199 -0
- package/src/__tests__/TreeCombobox.test.tsx +230 -0
- package/src/index.ts +10 -0
- package/src/storyData.tsx +166 -0
- package/tsconfig.json +12 -0
- package/tsup.config.ts +12 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import styled from "styled-components";
|
|
3
|
+
import { Icon } from "@sproutsocial/seeds-react-icon";
|
|
4
|
+
import { focusRing } from "@sproutsocial/seeds-react-mixins";
|
|
5
|
+
import { Tree, type TreeProps } from "./Tree";
|
|
6
|
+
import { TreeItem } from "./TreeItem";
|
|
7
|
+
import { filterTree } from "./Common/filterTree";
|
|
8
|
+
import { computeTreeNavigation, treeItemDomId } from "./Common/treeNavigation";
|
|
9
|
+
import type { TreeItemData } from "./Common/types";
|
|
10
|
+
|
|
11
|
+
const Root = styled.div`
|
|
12
|
+
display: flex;
|
|
13
|
+
flex-direction: column;
|
|
14
|
+
gap: ${({ theme }) => theme.space[300]};
|
|
15
|
+
`;
|
|
16
|
+
|
|
17
|
+
const InputGroup = styled.div`
|
|
18
|
+
display: flex;
|
|
19
|
+
align-items: center;
|
|
20
|
+
gap: ${({ theme }) => theme.space[200]};
|
|
21
|
+
padding: ${({ theme }) => theme.space[200]} ${({ theme }) => theme.space[300]};
|
|
22
|
+
border-radius: ${({ theme }) => theme.radii[500]};
|
|
23
|
+
border: 1px solid ${({ theme }) => theme.colors.form.border.base};
|
|
24
|
+
background: ${({ theme }) => theme.colors.form.background.base};
|
|
25
|
+
color: ${({ theme }) => theme.colors.icon.base};
|
|
26
|
+
|
|
27
|
+
&:focus-within {
|
|
28
|
+
${focusRing}
|
|
29
|
+
}
|
|
30
|
+
`;
|
|
31
|
+
|
|
32
|
+
const ComboboxInput = styled.input`
|
|
33
|
+
flex: 1;
|
|
34
|
+
border: none;
|
|
35
|
+
background: transparent;
|
|
36
|
+
outline: none;
|
|
37
|
+
font-family: ${({ theme }) => theme.fontFamily};
|
|
38
|
+
${({ theme }) => theme.typography[300]}
|
|
39
|
+
color: ${({ theme }) => theme.colors.text.body};
|
|
40
|
+
|
|
41
|
+
&::placeholder {
|
|
42
|
+
color: ${({ theme }) => theme.colors.text.subtext};
|
|
43
|
+
}
|
|
44
|
+
`;
|
|
45
|
+
|
|
46
|
+
const EmptyState = styled.div`
|
|
47
|
+
padding: ${({ theme }) => theme.space[400]};
|
|
48
|
+
text-align: center;
|
|
49
|
+
color: ${({ theme }) => theme.colors.text.subtext};
|
|
50
|
+
${({ theme }) => theme.typography[300]}
|
|
51
|
+
`;
|
|
52
|
+
|
|
53
|
+
const NAV_KEYS = new Set([
|
|
54
|
+
"ArrowDown",
|
|
55
|
+
"ArrowUp",
|
|
56
|
+
"ArrowLeft",
|
|
57
|
+
"ArrowRight",
|
|
58
|
+
"Home",
|
|
59
|
+
"End",
|
|
60
|
+
"Enter",
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
export type TreeComboboxProps = Omit<
|
|
64
|
+
TreeProps,
|
|
65
|
+
| "children"
|
|
66
|
+
| "focusedId"
|
|
67
|
+
| "onFocusedIdChange"
|
|
68
|
+
| "defaultFocusedId"
|
|
69
|
+
| "manageDomFocus"
|
|
70
|
+
| "aria-label"
|
|
71
|
+
| "aria-labelledby"
|
|
72
|
+
> & {
|
|
73
|
+
items: ReadonlyArray<TreeItemData>;
|
|
74
|
+
/** Placeholder for the combobox input. */
|
|
75
|
+
placeholder?: string;
|
|
76
|
+
/** Text shown when the query has no matches. */
|
|
77
|
+
emptyText?: string;
|
|
78
|
+
/** Accessible name for the combobox. One of `aria-label` / `aria-labelledby` is required. */
|
|
79
|
+
"aria-label"?: string;
|
|
80
|
+
"aria-labelledby"?: string;
|
|
81
|
+
/** Controlled query value. */
|
|
82
|
+
query?: string;
|
|
83
|
+
/** Uncontrolled initial query. */
|
|
84
|
+
defaultQuery?: string;
|
|
85
|
+
onQueryChange?: (query: string) => void;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Combobox + tree pattern. Implements the WAI-ARIA combobox pattern with a
|
|
90
|
+
* tree as the popup (`aria-haspopup="tree"`). DOM focus stays on the input;
|
|
91
|
+
* `aria-activedescendant` points at the active treeitem, which the input's
|
|
92
|
+
* arrow-key handler moves through the visible tree. Selection and expansion
|
|
93
|
+
* reuse the underlying Tree's logic — Enter on the input synthesizes a click
|
|
94
|
+
* on the active row, ArrowRight/Left compute the new expanded set and pass
|
|
95
|
+
* it through Tree's controlled `expanded` prop.
|
|
96
|
+
*/
|
|
97
|
+
export function TreeCombobox(props: TreeComboboxProps) {
|
|
98
|
+
const {
|
|
99
|
+
items,
|
|
100
|
+
placeholder = "Search...",
|
|
101
|
+
emptyText = "No results found.",
|
|
102
|
+
query: queryProp,
|
|
103
|
+
defaultQuery = "",
|
|
104
|
+
onQueryChange,
|
|
105
|
+
selectionMode = "none",
|
|
106
|
+
selectableNodes = "all",
|
|
107
|
+
defaultExpanded,
|
|
108
|
+
expanded: expandedProp,
|
|
109
|
+
onExpandedChange,
|
|
110
|
+
defaultSelected,
|
|
111
|
+
selected: selectedProp,
|
|
112
|
+
onSelectionChange,
|
|
113
|
+
renderSelectionIndicator,
|
|
114
|
+
id,
|
|
115
|
+
className,
|
|
116
|
+
} = props;
|
|
117
|
+
const ariaLabel = props["aria-label"];
|
|
118
|
+
const ariaLabelledBy = props["aria-labelledby"];
|
|
119
|
+
|
|
120
|
+
const [uncontrolledQuery, setUncontrolledQuery] =
|
|
121
|
+
React.useState(defaultQuery);
|
|
122
|
+
const query = queryProp ?? uncontrolledQuery;
|
|
123
|
+
const setQuery = (next: string) => {
|
|
124
|
+
if (queryProp === undefined) setUncontrolledQuery(next);
|
|
125
|
+
onQueryChange?.(next);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// Track the user's last "real" expansion state so it can be restored when
|
|
129
|
+
// the query clears, matching the prior SearchableTree behavior.
|
|
130
|
+
const [userExpanded, setUserExpanded] = React.useState<string[]>(() => [
|
|
131
|
+
...(defaultExpanded ?? expandedProp ?? []),
|
|
132
|
+
]);
|
|
133
|
+
|
|
134
|
+
const { items: visibleItems, forceExpanded } = React.useMemo(
|
|
135
|
+
() => filterTree([...items], query),
|
|
136
|
+
[items, query]
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const isFiltering = query.trim().length > 0;
|
|
140
|
+
const effectiveExpanded = isFiltering
|
|
141
|
+
? Array.from(new Set([...userExpanded, ...forceExpanded]))
|
|
142
|
+
: expandedProp ?? userExpanded;
|
|
143
|
+
|
|
144
|
+
const handleExpandedChange = (next: string[]) => {
|
|
145
|
+
if (!isFiltering) {
|
|
146
|
+
setUserExpanded(next);
|
|
147
|
+
}
|
|
148
|
+
onExpandedChange?.(next);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const [focusedId, setFocusedId] = React.useState<string | null>(null);
|
|
152
|
+
|
|
153
|
+
const treeRef = React.useRef<HTMLUListElement>(null);
|
|
154
|
+
const reactId = React.useId();
|
|
155
|
+
const treeDomId = id ? `${id}-tree` : `${reactId}-tree`;
|
|
156
|
+
|
|
157
|
+
const findTreeItemEl = (itemId: string): HTMLElement | null =>
|
|
158
|
+
treeRef.current?.querySelector<HTMLElement>(
|
|
159
|
+
`[role="treeitem"][data-treeitem-id="${CSS.escape(itemId)}"]`
|
|
160
|
+
) ?? null;
|
|
161
|
+
|
|
162
|
+
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
163
|
+
if (e.key === "Escape") {
|
|
164
|
+
if (query.length > 0) {
|
|
165
|
+
e.preventDefault();
|
|
166
|
+
setQuery("");
|
|
167
|
+
setFocusedId(null);
|
|
168
|
+
}
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (!NAV_KEYS.has(e.key)) return;
|
|
172
|
+
|
|
173
|
+
const currentEl = focusedId ? findTreeItemEl(focusedId) : null;
|
|
174
|
+
const meta =
|
|
175
|
+
currentEl && focusedId
|
|
176
|
+
? {
|
|
177
|
+
id: focusedId,
|
|
178
|
+
hasChildren: currentEl.getAttribute("aria-expanded") !== null,
|
|
179
|
+
isExpanded: currentEl.getAttribute("aria-expanded") === "true",
|
|
180
|
+
level: parseInt(currentEl.getAttribute("aria-level") ?? "1", 10),
|
|
181
|
+
}
|
|
182
|
+
: null;
|
|
183
|
+
|
|
184
|
+
// Enter: replay a row click on the active treeitem so we get TreeItem's
|
|
185
|
+
// exact selection/expansion semantics (single vs multi, leaves-only,
|
|
186
|
+
// branch-vs-leaf) for free.
|
|
187
|
+
if (e.key === "Enter") {
|
|
188
|
+
if (!currentEl) return;
|
|
189
|
+
e.preventDefault();
|
|
190
|
+
const row = currentEl.querySelector<HTMLElement>("[data-treeitem-row]");
|
|
191
|
+
row?.click();
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const result = computeTreeNavigation(
|
|
196
|
+
treeRef.current,
|
|
197
|
+
currentEl,
|
|
198
|
+
e.key,
|
|
199
|
+
meta
|
|
200
|
+
);
|
|
201
|
+
if (result.preventDefault) e.preventDefault();
|
|
202
|
+
|
|
203
|
+
if (result.expandToggle) {
|
|
204
|
+
const current = new Set(effectiveExpanded);
|
|
205
|
+
const willOpen =
|
|
206
|
+
result.expandToggle.next ?? !current.has(result.expandToggle.id);
|
|
207
|
+
if (willOpen) current.add(result.expandToggle.id);
|
|
208
|
+
else current.delete(result.expandToggle.id);
|
|
209
|
+
handleExpandedChange(Array.from(current));
|
|
210
|
+
}
|
|
211
|
+
if (result.nextFocusedId) {
|
|
212
|
+
setFocusedId(result.nextFocusedId);
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
// If a filter change drops the active item out of the visible set, the
|
|
217
|
+
// Tree's recovery layout-effect rebases focusedId via onFocusedIdChange.
|
|
218
|
+
// We accept that and don't reset focusedId on filter ourselves — clearing
|
|
219
|
+
// happens explicitly on Escape.
|
|
220
|
+
|
|
221
|
+
const showEmpty = visibleItems.length === 0;
|
|
222
|
+
|
|
223
|
+
return (
|
|
224
|
+
<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}
|
|
231
|
+
aria-label={ariaLabel}
|
|
232
|
+
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>
|
|
268
|
+
|
|
269
|
+
{showEmpty ? (
|
|
270
|
+
<EmptyState role="status" aria-live="polite">
|
|
271
|
+
{emptyText}
|
|
272
|
+
</EmptyState>
|
|
273
|
+
) : null}
|
|
274
|
+
</Root>
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function TreeNode({ item }: { item: TreeItemData }) {
|
|
279
|
+
return (
|
|
280
|
+
<TreeItem
|
|
281
|
+
id={item.id}
|
|
282
|
+
label={item.label}
|
|
283
|
+
icon={item.icon}
|
|
284
|
+
disabled={item.disabled}
|
|
285
|
+
>
|
|
286
|
+
{item.children?.map((child) => (
|
|
287
|
+
<TreeNode key={child.id} item={child} />
|
|
288
|
+
))}
|
|
289
|
+
</TreeItem>
|
|
290
|
+
);
|
|
291
|
+
}
|
package/src/TreeItem.tsx
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Icon } from "@sproutsocial/seeds-react-icon";
|
|
3
|
+
import { TreeItemContext, useTreeContext } from "./Common/treeContext";
|
|
4
|
+
import { useTreeKeyboard } from "./Common/useTreeKeyboard";
|
|
5
|
+
import { treeItemDomId } from "./Common/treeNavigation";
|
|
6
|
+
import {
|
|
7
|
+
TreeItemChevron,
|
|
8
|
+
TreeItemEl,
|
|
9
|
+
TreeItemGroup,
|
|
10
|
+
TreeItemIcon,
|
|
11
|
+
TreeItemLabel,
|
|
12
|
+
TreeItemRow,
|
|
13
|
+
} from "./TreeStyles";
|
|
14
|
+
|
|
15
|
+
export type TreeItemProps = {
|
|
16
|
+
id: string;
|
|
17
|
+
label: React.ReactNode;
|
|
18
|
+
icon?: React.ReactNode;
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
children?: React.ReactNode;
|
|
21
|
+
/**
|
|
22
|
+
* Optional override for the row click target. When provided, this is used
|
|
23
|
+
* as the accessible name for the row (otherwise the rendered `label` is).
|
|
24
|
+
*/
|
|
25
|
+
"aria-label"?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function hasTreeItemChildren(children: React.ReactNode): boolean {
|
|
29
|
+
let found = false;
|
|
30
|
+
React.Children.forEach(children, (child) => {
|
|
31
|
+
if (found) return;
|
|
32
|
+
if (!React.isValidElement(child)) return;
|
|
33
|
+
const childType = child.type;
|
|
34
|
+
if (childType === React.Fragment) {
|
|
35
|
+
found = hasTreeItemChildren(
|
|
36
|
+
(child as React.ReactElement<{ children?: React.ReactNode }>).props
|
|
37
|
+
.children
|
|
38
|
+
);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
// Any other element child counts as a tree item. Consumers should only
|
|
42
|
+
// place TreeItems (or components that render one) inside a TreeItem.
|
|
43
|
+
found = true;
|
|
44
|
+
});
|
|
45
|
+
return found;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function TreeItem(props: TreeItemProps) {
|
|
49
|
+
const { id, label, icon, disabled = false, children } = props;
|
|
50
|
+
const ctx = useTreeContext();
|
|
51
|
+
const { level } = React.useContext(TreeItemContext);
|
|
52
|
+
|
|
53
|
+
const hasChildren = React.useMemo(
|
|
54
|
+
() => hasTreeItemChildren(children),
|
|
55
|
+
[children]
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const isExpanded = ctx.expanded.has(id);
|
|
59
|
+
const isSelected = ctx.selected.has(id);
|
|
60
|
+
const selectionMode = ctx.selectionMode;
|
|
61
|
+
|
|
62
|
+
// Roving tabindex: this item is tabbable when it's the focused id. When the
|
|
63
|
+
// host (e.g. a combobox) drives focus externally, no treeitem is tabbable.
|
|
64
|
+
const itemRef = React.useRef<HTMLLIElement>(null);
|
|
65
|
+
const isTabbable = ctx.manageDomFocus && ctx.focusedId === id;
|
|
66
|
+
|
|
67
|
+
// On first mount of the very first item, register ourselves as the initial
|
|
68
|
+
// roving target if nothing else has claimed it. Skipped when the host owns
|
|
69
|
+
// focus — the host decides when (if ever) to set an initial active id.
|
|
70
|
+
React.useEffect(() => {
|
|
71
|
+
if (!ctx.manageDomFocus) return;
|
|
72
|
+
if (ctx.focusedId !== null) return;
|
|
73
|
+
const root = ctx.rootRef.current;
|
|
74
|
+
if (!root) return;
|
|
75
|
+
const first = root.querySelector<HTMLElement>('[role="treeitem"]');
|
|
76
|
+
if (first === itemRef.current) {
|
|
77
|
+
ctx.setFocusedId(id);
|
|
78
|
+
}
|
|
79
|
+
// We only want this on mount; deps intentionally minimal.
|
|
80
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
81
|
+
}, []);
|
|
82
|
+
|
|
83
|
+
const handleKeyDown = useTreeKeyboard(ctx);
|
|
84
|
+
|
|
85
|
+
const handleRowClick = (e: React.MouseEvent) => {
|
|
86
|
+
if (disabled) return;
|
|
87
|
+
// Ignore clicks that originated on the chevron — it owns its own handler.
|
|
88
|
+
const target = e.target as HTMLElement;
|
|
89
|
+
if (target.closest("[data-tree-chevron]")) return;
|
|
90
|
+
|
|
91
|
+
ctx.setFocusedId(id);
|
|
92
|
+
if (ctx.manageDomFocus) itemRef.current?.focus();
|
|
93
|
+
|
|
94
|
+
if (hasChildren && selectionMode === "none") {
|
|
95
|
+
ctx.toggleExpanded(id);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (hasChildren && ctx.selectableNodes === "leaves") {
|
|
99
|
+
ctx.toggleExpanded(id);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (selectionMode !== "none") {
|
|
103
|
+
ctx.toggleSelected(id, hasChildren);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const handleChevronClick = (e: React.MouseEvent) => {
|
|
108
|
+
e.stopPropagation();
|
|
109
|
+
if (disabled) return;
|
|
110
|
+
ctx.setFocusedId(id);
|
|
111
|
+
if (ctx.manageDomFocus) itemRef.current?.focus();
|
|
112
|
+
ctx.toggleExpanded(id);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const ariaSelected =
|
|
116
|
+
selectionMode === "single" && ctx.selectableNodes !== "leaves"
|
|
117
|
+
? isSelected
|
|
118
|
+
: undefined;
|
|
119
|
+
const ariaChecked =
|
|
120
|
+
selectionMode === "multiple"
|
|
121
|
+
? isSelected
|
|
122
|
+
: selectionMode === "single" && ctx.selectableNodes === "leaves"
|
|
123
|
+
? isSelected
|
|
124
|
+
: undefined;
|
|
125
|
+
|
|
126
|
+
const groupId = `${id}__group`;
|
|
127
|
+
const labelId = `${id}__label`;
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<TreeItemEl
|
|
131
|
+
ref={itemRef}
|
|
132
|
+
id={treeItemDomId(id)}
|
|
133
|
+
role="treeitem"
|
|
134
|
+
data-treeitem-id={id}
|
|
135
|
+
data-treeitem-active={
|
|
136
|
+
!ctx.manageDomFocus && ctx.focusedId === id ? true : undefined
|
|
137
|
+
}
|
|
138
|
+
aria-level={level}
|
|
139
|
+
aria-expanded={hasChildren ? isExpanded : undefined}
|
|
140
|
+
aria-selected={ariaSelected}
|
|
141
|
+
aria-checked={ariaChecked}
|
|
142
|
+
aria-disabled={disabled || undefined}
|
|
143
|
+
aria-labelledby={labelId}
|
|
144
|
+
aria-owns={hasChildren && isExpanded ? groupId : undefined}
|
|
145
|
+
tabIndex={isTabbable ? 0 : -1}
|
|
146
|
+
onKeyDown={(e) => {
|
|
147
|
+
if (!ctx.manageDomFocus) return;
|
|
148
|
+
handleKeyDown(e, {
|
|
149
|
+
id,
|
|
150
|
+
hasChildren,
|
|
151
|
+
isExpanded,
|
|
152
|
+
isDisabled: disabled,
|
|
153
|
+
level,
|
|
154
|
+
});
|
|
155
|
+
}}
|
|
156
|
+
onClick={(e) => {
|
|
157
|
+
// Branch treeitems contain descendant LIs whose row clicks bubble up.
|
|
158
|
+
// Only handle the click if it originated within this LI's own row —
|
|
159
|
+
// not within a descendant treeitem.
|
|
160
|
+
const target = e.target as HTMLElement;
|
|
161
|
+
const nearestTreeitem = target.closest('[role="treeitem"]');
|
|
162
|
+
if (nearestTreeitem !== e.currentTarget) return;
|
|
163
|
+
handleRowClick(e);
|
|
164
|
+
}}
|
|
165
|
+
onMouseDown={(e) => {
|
|
166
|
+
// In external-focus (combobox) mode, mousedown on a tabindex=-1
|
|
167
|
+
// treeitem would still steal DOM focus from the host input. Prevent
|
|
168
|
+
// it so focus stays where the host put it; onClick still fires.
|
|
169
|
+
if (!ctx.manageDomFocus) e.preventDefault();
|
|
170
|
+
}}
|
|
171
|
+
onFocus={() => {
|
|
172
|
+
if (!ctx.manageDomFocus) return;
|
|
173
|
+
if (!ctx.hasFocused) ctx.setHasFocused(true);
|
|
174
|
+
if (ctx.focusedId !== id) ctx.setFocusedId(id);
|
|
175
|
+
}}
|
|
176
|
+
>
|
|
177
|
+
<TreeItemRow
|
|
178
|
+
data-treeitem-row
|
|
179
|
+
$level={level}
|
|
180
|
+
$selected={isSelected}
|
|
181
|
+
$disabled={disabled}
|
|
182
|
+
>
|
|
183
|
+
{ctx.renderSelectionIndicator &&
|
|
184
|
+
!(ctx.selectableNodes === "leaves" && hasChildren)
|
|
185
|
+
? ctx.renderSelectionIndicator({
|
|
186
|
+
selected: isSelected,
|
|
187
|
+
disabled,
|
|
188
|
+
selectionMode,
|
|
189
|
+
})
|
|
190
|
+
: null}
|
|
191
|
+
|
|
192
|
+
{icon != null ? <TreeItemIcon aria-hidden>{icon}</TreeItemIcon> : null}
|
|
193
|
+
<TreeItemLabel id={labelId}>{label}</TreeItemLabel>
|
|
194
|
+
|
|
195
|
+
{hasChildren ? (
|
|
196
|
+
<TreeItemChevron
|
|
197
|
+
data-tree-chevron
|
|
198
|
+
aria-hidden
|
|
199
|
+
$expanded={isExpanded}
|
|
200
|
+
onClick={handleChevronClick}
|
|
201
|
+
>
|
|
202
|
+
<Icon name="chevron-down-outline" size="small" />
|
|
203
|
+
</TreeItemChevron>
|
|
204
|
+
) : null}
|
|
205
|
+
</TreeItemRow>
|
|
206
|
+
|
|
207
|
+
{hasChildren ? (
|
|
208
|
+
<TreeItemGroup role="group" id={groupId} hidden={!isExpanded}>
|
|
209
|
+
<TreeItemContext.Provider value={{ level: level + 1 }}>
|
|
210
|
+
{children}
|
|
211
|
+
</TreeItemContext.Provider>
|
|
212
|
+
</TreeItemGroup>
|
|
213
|
+
) : null}
|
|
214
|
+
</TreeItemEl>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
TreeItem.displayName = "TreeItem";
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import styled, { css } from "styled-components";
|
|
2
|
+
|
|
3
|
+
export const TreeRoot = styled.ul`
|
|
4
|
+
list-style: none;
|
|
5
|
+
margin: 0;
|
|
6
|
+
padding: 0;
|
|
7
|
+
font-family: ${({ theme }) => theme.fontFamily};
|
|
8
|
+
${({ theme }) => theme.typography[300]}
|
|
9
|
+
color: ${({ theme }) => theme.colors.text.body};
|
|
10
|
+
`;
|
|
11
|
+
|
|
12
|
+
export const TreeItemRow = styled.div<{
|
|
13
|
+
$level: number;
|
|
14
|
+
$selected: boolean;
|
|
15
|
+
$disabled: boolean;
|
|
16
|
+
}>`
|
|
17
|
+
display: flex;
|
|
18
|
+
align-items: center;
|
|
19
|
+
gap: ${({ theme }) => theme.space[300]};
|
|
20
|
+
padding: ${({ theme }) => theme.space[300]};
|
|
21
|
+
padding-left: ${({ theme, $level }) =>
|
|
22
|
+
`calc(${theme.space[300]} + ${$level - 1} * ${theme.space[500]})`};
|
|
23
|
+
border-radius: ${({ theme }) => theme.radii[400]};
|
|
24
|
+
cursor: pointer;
|
|
25
|
+
user-select: none;
|
|
26
|
+
background: transparent;
|
|
27
|
+
transition: background-color ${({ theme }) => theme.duration.fast}
|
|
28
|
+
${({ theme }) => theme.easing.ease_in};
|
|
29
|
+
|
|
30
|
+
&:hover {
|
|
31
|
+
background: ${({ theme }) => theme.colors.listItem.background.hover};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
${({ $selected, theme }) =>
|
|
35
|
+
$selected &&
|
|
36
|
+
css`
|
|
37
|
+
background: ${theme.colors.listItem.background.hover};
|
|
38
|
+
font-weight: ${theme.fontWeights.semibold};
|
|
39
|
+
`}
|
|
40
|
+
|
|
41
|
+
${({ $disabled }) =>
|
|
42
|
+
$disabled &&
|
|
43
|
+
css`
|
|
44
|
+
opacity: 0.4;
|
|
45
|
+
cursor: not-allowed;
|
|
46
|
+
pointer-events: none;
|
|
47
|
+
`}
|
|
48
|
+
`;
|
|
49
|
+
|
|
50
|
+
export const TreeItemEl = styled.li`
|
|
51
|
+
list-style: none;
|
|
52
|
+
outline: none;
|
|
53
|
+
|
|
54
|
+
/*
|
|
55
|
+
* Tree rows stack tightly, so the standard outset Seeds focusRing bleeds
|
|
56
|
+
* into adjacent rows. Use an inset outline so the ring is drawn just inside
|
|
57
|
+
* the row's edge and never overlaps siblings or children.
|
|
58
|
+
*
|
|
59
|
+
* The same ring is drawn when [data-treeitem-active] is set so combobox
|
|
60
|
+
* hosts that drive the tree via aria-activedescendant (no real DOM focus)
|
|
61
|
+
* still get a visible "active" indicator on the row.
|
|
62
|
+
*/
|
|
63
|
+
&:focus-visible > ${TreeItemRow}, &[data-treeitem-active] > ${TreeItemRow} {
|
|
64
|
+
outline: 2px solid
|
|
65
|
+
${({ theme }) => theme.colors.button.primary.background.base};
|
|
66
|
+
outline-offset: -2px;
|
|
67
|
+
}
|
|
68
|
+
`;
|
|
69
|
+
|
|
70
|
+
export const TreeItemGroup = styled.ul`
|
|
71
|
+
list-style: none;
|
|
72
|
+
margin: 0;
|
|
73
|
+
padding: 0;
|
|
74
|
+
`;
|
|
75
|
+
|
|
76
|
+
export const TreeItemLabel = styled.span`
|
|
77
|
+
flex: 1;
|
|
78
|
+
min-width: 0;
|
|
79
|
+
overflow: hidden;
|
|
80
|
+
text-overflow: ellipsis;
|
|
81
|
+
white-space: nowrap;
|
|
82
|
+
`;
|
|
83
|
+
|
|
84
|
+
export const TreeItemIcon = styled.span`
|
|
85
|
+
display: inline-flex;
|
|
86
|
+
align-items: center;
|
|
87
|
+
flex-shrink: 0;
|
|
88
|
+
`;
|
|
89
|
+
|
|
90
|
+
export const TreeItemChevron = styled.button.attrs({
|
|
91
|
+
type: "button",
|
|
92
|
+
tabIndex: -1,
|
|
93
|
+
})<{ $expanded: boolean }>`
|
|
94
|
+
display: inline-flex;
|
|
95
|
+
align-items: center;
|
|
96
|
+
justify-content: center;
|
|
97
|
+
flex-shrink: 0;
|
|
98
|
+
width: ${({ theme }) => theme.space[500]};
|
|
99
|
+
height: ${({ theme }) => theme.space[500]};
|
|
100
|
+
padding: 0;
|
|
101
|
+
border: none;
|
|
102
|
+
background: transparent;
|
|
103
|
+
color: ${({ theme }) => theme.colors.icon.base};
|
|
104
|
+
cursor: pointer;
|
|
105
|
+
border-radius: ${({ theme }) => theme.radii[300]};
|
|
106
|
+
transition: transform ${({ theme }) => theme.duration.fast}
|
|
107
|
+
${({ theme }) => theme.easing.ease_in};
|
|
108
|
+
transform: ${({ $expanded }) =>
|
|
109
|
+
$expanded ? "rotate(0deg)" : "rotate(-90deg)"};
|
|
110
|
+
|
|
111
|
+
&:hover {
|
|
112
|
+
background: ${({ theme }) => theme.colors.listItem.background.hover};
|
|
113
|
+
}
|
|
114
|
+
`;
|