Skip to content

Commit aad9e03

Browse files
committed
✨ feat(base-ui): add Tree
Data-driven tree with expand / select / multiple / linked checkboxes, keyboard navigation and rounded SVG guide lines. Claude-Session: https://claude.ai/code/session_01Ee1HGYYoaPTGdAhTmLhz6y
1 parent 84da859 commit aad9e03

16 files changed

Lines changed: 1152 additions & 0 deletions

File tree

src/base-ui/Tree/Tree.tsx

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
'use client';
2+
3+
import { cx } from 'antd-style';
4+
import { type KeyboardEvent, memo, useCallback, useMemo, useRef, useState } from 'react';
5+
import useControlledState from 'use-merge-value';
6+
7+
import { TreeContext, type TreeContextValue } from './context';
8+
import { styles } from './style';
9+
import TreeNode from './TreeNode';
10+
import type { TreeDataNode, TreeProps } from './type';
11+
import { conductCheck, flattenVisible, getAllKeys, getAncestorKeys, getSubtreeKeys } from './utils';
12+
13+
const EMPTY: string[] = [];
14+
15+
const Tree = memo<TreeProps>(
16+
({
17+
blockNode = false,
18+
checkStrictly = false,
19+
checkable = false,
20+
checkedKeys: checkedKeysProp,
21+
className,
22+
classNames = {},
23+
defaultCheckedKeys = EMPTY,
24+
defaultExpandAll = false,
25+
defaultExpandedKeys = EMPTY,
26+
defaultSelectedKeys = EMPTY,
27+
disabled = false,
28+
expandedKeys: expandedKeysProp,
29+
indent = 20,
30+
multiple = false,
31+
onCheck,
32+
onExpand,
33+
onRightClick,
34+
onSelect,
35+
selectedKeys: selectedKeysProp,
36+
showIcon = false,
37+
showLine = false,
38+
size = 'middle',
39+
style,
40+
styles: customStyles = {},
41+
switcherIcon,
42+
titleRender,
43+
treeData,
44+
}) => {
45+
const [expandedKeys, setExpandedKeys] = useControlledState<string[]>(
46+
defaultExpandAll ? getAllKeys(treeData) : defaultExpandedKeys,
47+
{ value: expandedKeysProp },
48+
);
49+
const [selectedKeys, setSelectedKeys] = useControlledState<string[]>(defaultSelectedKeys, {
50+
value: selectedKeysProp,
51+
});
52+
const [checkedKeys, setCheckedKeys] = useControlledState<string[]>(defaultCheckedKeys, {
53+
value: checkedKeysProp,
54+
});
55+
56+
const expanded = useMemo(() => new Set(expandedKeys), [expandedKeys]);
57+
const selected = useMemo(() => new Set(selectedKeys), [selectedKeys]);
58+
const flat = useMemo(() => flattenVisible(treeData, expanded), [treeData, expanded]);
59+
const { checked, halfChecked } = useMemo(
60+
() =>
61+
checkStrictly
62+
? { checked: new Set(checkedKeys), halfChecked: new Set<string>() }
63+
: conductCheck(treeData, checkedKeys),
64+
[checkStrictly, treeData, checkedKeys],
65+
);
66+
67+
const [activeKeyState, setActiveKeyState] = useState<string | null>(null);
68+
const setActiveKey = setActiveKeyState;
69+
const activeKey =
70+
activeKeyState && flat.some((row) => row.node.key === activeKeyState)
71+
? activeKeyState
72+
: (selectedKeys.find((key) => flat.some((row) => row.node.key === key)) ??
73+
flat[0]?.node.key ??
74+
null);
75+
76+
const rowsRef = useRef(new Map<string, HTMLDivElement>());
77+
const anchorRef = useRef<string | null>(null);
78+
79+
const registerRow = useCallback((key: string, el: HTMLDivElement | null) => {
80+
if (el) rowsRef.current.set(key, el);
81+
else rowsRef.current.delete(key);
82+
}, []);
83+
84+
const focusRow = (key: string) => {
85+
setActiveKey(key);
86+
rowsRef.current.get(key)?.focus();
87+
};
88+
89+
const isDisabled = (node: TreeDataNode) => disabled || !!node.disabled;
90+
91+
const toggleExpand = (node: TreeDataNode) => {
92+
const willExpand = !expanded.has(node.key);
93+
const next = willExpand
94+
? [...expandedKeys, node.key]
95+
: expandedKeys.filter((key) => key !== node.key);
96+
setExpandedKeys(next);
97+
onExpand?.(next, { expanded: willExpand, node });
98+
};
99+
100+
const toggleSelect: TreeContextValue['toggleSelect'] = (node, event) => {
101+
if (isDisabled(node) || node.selectable === false) return;
102+
let next: string[];
103+
if (multiple && event?.shiftKey && anchorRef.current) {
104+
const a = flat.findIndex((row) => row.node.key === anchorRef.current);
105+
const b = flat.findIndex((row) => row.node.key === node.key);
106+
next = flat
107+
.slice(Math.min(a, b), Math.max(a, b) + 1)
108+
.filter((row) => !isDisabled(row.node) && row.node.selectable !== false)
109+
.map((row) => row.node.key);
110+
} else if (multiple && (event?.metaKey || event?.ctrlKey)) {
111+
next = selected.has(node.key)
112+
? selectedKeys.filter((key) => key !== node.key)
113+
: [...selectedKeys, node.key];
114+
anchorRef.current = node.key;
115+
} else {
116+
next = [node.key];
117+
anchorRef.current = node.key;
118+
}
119+
setSelectedKeys(next);
120+
setActiveKey(node.key);
121+
onSelect?.(next, { node, selected: next.includes(node.key) });
122+
};
123+
124+
const toggleCheck = (node: TreeDataNode) => {
125+
if (isDisabled(node) || node.checkable === false) return;
126+
const willCheck = !checked.has(node.key);
127+
const keys = checkStrictly ? [node.key] : getSubtreeKeys(node);
128+
const base = new Set(checkStrictly ? checkedKeys : checked);
129+
keys.forEach((key) => (willCheck ? base.add(key) : base.delete(key)));
130+
if (!willCheck && !checkStrictly) getAncestorKeys(treeData, node.key).forEach((key) => base.delete(key));
131+
const next = checkStrictly ? [...base] : [...conductCheck(treeData, base).checked];
132+
setCheckedKeys(next);
133+
setActiveKey(node.key);
134+
onCheck?.(next, { checked: willCheck, node });
135+
};
136+
137+
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
138+
const index = flat.findIndex((row) => row.node.key === activeKey);
139+
if (index < 0) return;
140+
const row = flat[index];
141+
const isOpen = row.hasChildren && expanded.has(row.node.key);
142+
const handlers: Record<string, () => void> = {
143+
' ': () => {
144+
if (checkable && row.node.checkable !== false) toggleCheck(row.node);
145+
else toggleSelect(row.node, event);
146+
},
147+
'*': () => {
148+
const siblings = flat.filter(
149+
(r) => r.parentKey === row.parentKey && r.hasChildren && !expanded.has(r.node.key),
150+
);
151+
if (siblings.length === 0) return;
152+
const next = [...expandedKeys, ...siblings.map((r) => r.node.key)];
153+
setExpandedKeys(next);
154+
onExpand?.(next, { expanded: true, node: row.node });
155+
},
156+
ArrowDown: () => flat[index + 1] && focusRow(flat[index + 1].node.key),
157+
ArrowLeft: () => {
158+
if (isOpen) toggleExpand(row.node);
159+
else if (row.parentKey) focusRow(row.parentKey);
160+
},
161+
ArrowRight: () => {
162+
if (!row.hasChildren) return;
163+
if (isOpen) focusRow(flat[index + 1].node.key);
164+
else toggleExpand(row.node);
165+
},
166+
ArrowUp: () => flat[index - 1] && focusRow(flat[index - 1].node.key),
167+
End: () => focusRow(flat.at(-1)!.node.key),
168+
Enter: () => toggleSelect(row.node, event),
169+
Home: () => focusRow(flat[0].node.key),
170+
};
171+
const handler = handlers[event.key];
172+
if (!handler) return;
173+
event.preventDefault();
174+
handler();
175+
};
176+
177+
const ctx: TreeContextValue = {
178+
activeKey,
179+
blockNode,
180+
checkable,
181+
checked,
182+
classNames,
183+
disabled,
184+
expanded,
185+
halfChecked,
186+
indent,
187+
onRightClick: (event, node) => onRightClick?.({ event, node }),
188+
registerRow,
189+
selected,
190+
setActiveKey,
191+
showIcon,
192+
showLine,
193+
size,
194+
styles: customStyles,
195+
switcherIcon,
196+
titleRender,
197+
toggleCheck,
198+
toggleExpand,
199+
toggleSelect,
200+
};
201+
202+
return (
203+
<TreeContext value={ctx}>
204+
<div
205+
className={cx(styles.root, classNames.root, className)}
206+
role="tree"
207+
style={{ ...customStyles.root, ...style }}
208+
onKeyDown={onKeyDown}
209+
>
210+
{treeData.map((node, i) => (
211+
<TreeNode
212+
depth={0}
213+
isLast={i === treeData.length - 1}
214+
key={node.key}
215+
node={node}
216+
trail={[]}
217+
/>
218+
))}
219+
</div>
220+
</TreeContext>
221+
);
222+
},
223+
);
224+
225+
Tree.displayName = 'Tree';
226+
227+
export default Tree;

src/base-ui/Tree/TreeNode.tsx

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
'use client';
2+
3+
import { Collapsible } from '@base-ui/react/collapsible';
4+
import { cx } from 'antd-style';
5+
import { ChevronRight, File, Folder } from 'lucide-react';
6+
import { type CSSProperties, memo, type MouseEvent } from 'react';
7+
8+
import { Checkbox } from '@/base-ui/Checkbox';
9+
import { controlHeight } from '@/base-ui/controlSize';
10+
import Icon from '@/Icon';
11+
12+
import { useTreeContext } from './context';
13+
import { styles } from './style';
14+
import type { TreeDataNode } from './type';
15+
import { hasChildren as isExpandable } from './utils';
16+
17+
interface TreeNodeProps {
18+
depth: number;
19+
isLast: boolean;
20+
node: TreeDataNode;
21+
trail: boolean[];
22+
}
23+
24+
const ARC = 6;
25+
const SWITCHER_CENTER = 11.5;
26+
27+
const guidePath = (depth: number, isLast: boolean, trail: boolean[], indent: number, height: number) => {
28+
const cx = (i: number) => i * indent + SWITCHER_CENTER;
29+
let d = '';
30+
trail.forEach((more, i) => {
31+
if (more) d += `M${cx(i)} 0V${height}`;
32+
});
33+
const x = cx(depth - 1);
34+
const y = height / 2;
35+
const elbow = `M${x} ${y - ARC}A${ARC} ${ARC} 0 0 0 ${x + ARC} ${y}H${x + indent - 2}`;
36+
d += isLast ? `M${x} 0V${y - ARC}${elbow.slice(elbow.indexOf('A'))}` : `M${x} 0V${height}${elbow}`;
37+
return d;
38+
};
39+
40+
const TreeNode = memo<TreeNodeProps>(({ node, depth, isLast, trail }) => {
41+
const ctx = useTreeContext();
42+
const expandable = isExpandable(node);
43+
const expanded = expandable && ctx.expanded.has(node.key);
44+
const disabled = ctx.disabled || !!node.disabled;
45+
const checkable = ctx.checkable && node.checkable !== false;
46+
const checkState = ctx.checked.has(node.key)
47+
? 'checked'
48+
: ctx.halfChecked.has(node.key)
49+
? 'mixed'
50+
: 'unchecked';
51+
const rowHeight = controlHeight[ctx.size];
52+
53+
const switcherIcon =
54+
typeof ctx.switcherIcon === 'function'
55+
? ctx.switcherIcon({ expanded, node })
56+
: (ctx.switcherIcon ?? <Icon icon={ChevronRight} size={14} />);
57+
58+
const rowStyle: CSSProperties = {
59+
height: rowHeight,
60+
paddingInlineStart: depth * ctx.indent,
61+
...ctx.styles.node,
62+
};
63+
64+
const select = (event: MouseEvent) => ctx.toggleSelect(node, event);
65+
66+
return (
67+
<>
68+
<div
69+
aria-checked={checkable ? (checkState === 'mixed' ? 'mixed' : checkState === 'checked') : undefined}
70+
aria-disabled={disabled || undefined}
71+
aria-expanded={expandable ? expanded : undefined}
72+
aria-level={depth + 1}
73+
aria-selected={ctx.selected.has(node.key)}
74+
ref={(el) => ctx.registerRow(node.key, el)}
75+
role="treeitem"
76+
style={rowStyle}
77+
tabIndex={ctx.activeKey === node.key ? 0 : -1}
78+
className={cx(
79+
styles.node,
80+
ctx.blockNode && styles.nodeBlock,
81+
disabled && styles.nodeDisabled,
82+
ctx.classNames.node,
83+
)}
84+
onClick={ctx.blockNode ? select : undefined}
85+
onContextMenu={(event) => ctx.onRightClick(event, node)}
86+
onFocus={() => ctx.setActiveKey(node.key)}
87+
>
88+
{ctx.showLine && depth > 0 && (
89+
<svg
90+
aria-hidden
91+
className={cx(styles.guide, ctx.classNames.guide)}
92+
height={rowHeight}
93+
style={ctx.styles.guide}
94+
width={depth * ctx.indent}
95+
>
96+
<path d={guidePath(depth, isLast, trail, ctx.indent, rowHeight)} />
97+
</svg>
98+
)}
99+
<button
100+
aria-hidden
101+
className={cx(styles.switcher, !expandable && styles.switcherLeaf, ctx.classNames.switcher)}
102+
style={ctx.styles.switcher}
103+
tabIndex={-1}
104+
type="button"
105+
onClick={(event) => {
106+
event.stopPropagation();
107+
ctx.toggleExpand(node);
108+
}}
109+
>
110+
{switcherIcon}
111+
</button>
112+
{checkable && (
113+
<Checkbox
114+
checked={checkState === 'checked'}
115+
className={styles.checkbox}
116+
disabled={disabled}
117+
indeterminate={checkState === 'mixed'}
118+
onChange={() => ctx.toggleCheck(node)}
119+
onClick={(event) => event.stopPropagation()}
120+
/>
121+
)}
122+
{ctx.showIcon && (
123+
<span className={styles.icon}>
124+
{node.icon ?? <Icon icon={expandable ? Folder : File} size={16} />}
125+
</span>
126+
)}
127+
<span
128+
style={ctx.styles.title}
129+
className={cx(
130+
styles.title,
131+
ctx.blockNode ? styles.titleBlock : styles.titleInline,
132+
ctx.classNames.title,
133+
)}
134+
onClick={ctx.blockNode ? undefined : select}
135+
>
136+
{ctx.titleRender ? ctx.titleRender(node) : node.title}
137+
</span>
138+
</div>
139+
{expandable && (
140+
<Collapsible.Root open={expanded}>
141+
<Collapsible.Panel className={styles.panel}>
142+
{node.children!.map((child, i) => (
143+
<TreeNode
144+
depth={depth + 1}
145+
isLast={i === node.children!.length - 1}
146+
key={child.key}
147+
node={child}
148+
trail={[...trail, !isLast]}
149+
/>
150+
))}
151+
</Collapsible.Panel>
152+
</Collapsible.Root>
153+
)}
154+
</>
155+
);
156+
});
157+
158+
TreeNode.displayName = 'TreeNode';
159+
160+
export default TreeNode;

0 commit comments

Comments
 (0)