Tree
Recursive folders and files with keyboard navigation, remote child states, and validated moves.
Import Tree and its types from @commenda/pathfinder/tree.
API reference
| Prop | Type | Behavior |
|---|---|---|
labelRequired | string | Required tree name |
nodesRequired | readonly TreeNode[] | Globally unique IDs, acyclic immutable data |
selectionConfig | ObjectView fields · 2 shapes | Controlled value/onValueChange or defaultValue; single selection |
expansionConfig | ObjectView fields · 2 shapes | Controlled/default expanded branch IDs |
onLoadChildren | (id: string) => void | App request when an unloaded branch opens or error retries |
menuConfig | (
node: TreeNode
) => DropdownMenu | App actions appended after built-in tree actions |
onCreateFolder | (
parentId: string
) => void | Enables New folder control and menu action |
moveConfig | TreeMoveConfigView fields | Enables pointer dragging, Move menu and keyboard move dialog |
emptyContent | ReactNode | Defaults to No items |
className | string | Root wrapper classes |
selectionConfigObject fields
Choose one of these 2 shapes. Do not combine fields excluded by the selected shape.
Option 1
| Field | Type | Requirement |
|---|---|---|
value | string | null | Required |
onValueChange | (value: string | null) => void | Required |
Do not supply: defaultValue.
Option 2
| Field | Type | Requirement |
|---|---|---|
defaultValue | string | null | Optional |
onValueChange | ((value: string | null) => void) | Optional |
Do not supply: value.
expansionConfigObject fields
Choose one of these 2 shapes. Do not combine fields excluded by the selected shape.
Option 1
| Field | Type | Requirement |
|---|---|---|
value | readonly string[] | Required |
onValueChange | (value: readonly string[]) => void | Required |
Do not supply: defaultValue.
Option 2
| Field | Type | Requirement |
|---|---|---|
defaultValue | readonly string[] | Optional |
onValueChange | ((value: readonly string[]) => void) | Optional |
Do not supply: value.
moveConfigObject fields
| Field | Type | Requirement |
|---|---|---|
canMove | (request: TreeMove) => boolean | Required |
onMove | (request: TreeMove) => void | Promise<void> | Required |
Every node has id, label, optional icon, count, and disabled. A leaf has kind: 'leaf'. A branch has kind: 'branch' and explicit children:
// Undefined does not mean an empty folder.
type TreeChildren =
| { status: 'unloaded' | 'loading' }
| { status: 'loaded'; nodes: readonly TreeNode[] }
| { status: 'error'; message?: string };Entity roots use flags. Branches default to the exact open/closed Folder artwork; pass a matching FileType icon for files. The component preserves labels for assistive technology and exposes the full truncated label on hover. Duplicate/empty IDs render a clear error instead of creating unstable navigation.
Loading and retry
The example deliberately fails its first request. Retry succeeds. The app owns fetching, cancellation and replacing nodes; mark loading while pending, distinguish loaded-empty from unloaded, and supply readable failures. Known ancestor paths are revealed for a newly selected node; callers must provide expansion IDs for ancestors that have not been loaded yet.
Moving items
TreeMove is { sourceId, targetId, position: 'before' | 'after' | 'inside' }. Self and descendant targets are always rejected; inside requires a branch. canMove adds application permission and business rules and must be pure. onMove returns void or a promise; the application updates nodes only after accepting the change. No optimistic data mutation occurs inside Tree. Pending requests cannot duplicate; rejection keeps the dialog open and announces an error for retry.
Pointer drop zones are the top quarter (before), middle half (inside), and bottom quarter (after). Invalid targets do not accept drops. Touch or keyboard users choose Move from the menu and select a destination/placement. Ctrl/⌘ + Shift + M opens the same move dialog for the focused item; Shift + F10 or the context-menu key opens its actions. The example includes an API-failure toggle.
Keyboard
Focus is separate from selection. Up/Down move among visible nodes; Right expands or enters children, Left collapses or returns to parent (mirrored in RTL); Home/End jump to edges; typing finds matching names. Enter/Space selects. Chevron activation expands without selecting. Row actions remain discoverable on focus/touch, and the menu includes New folder/Retry/Move alternatives. Removing the focused row recovers focus to a surviving tree item.
Scope deep folders
Use Focus this folder to show a subtree and a return path. Tree accepts arbitrary depth but caps visual indentation after four levels to keep labels usable in narrow panels; aria-level and hierarchy remain accurate. Prefer scoped browsing for deeper structures and load large branches incrementally. The stress fixture covers 1,000 rendered leaves; it is not a virtualization or unbounded-scale guarantee. Read the hierarchy rules.
Design rules
Figma guidance and reviewed implementation decisions for this component. Rule IDs remain stable for source comments and AI references.
Tree
- TREE-001: Rows 28px, radius 6, horizontal inset 8 and gap 6, indent 16 per visual level; 12px chevron in a reserved 16px slot even on leaves. Exact Folder artwork, flags at entity roots, file-type icons for files, Label/13, optional count; New folder/More on hover, focus and touch. Hover pearl200, selected pearl300 without a weight change, drop target blue100/blue600.
- TREE-002: Semantic nested treeitem/group hierarchy and one roving row tab stop. Focus is independent from selection; arrows, Home/End, typeahead and Enter/Space work. RTL mirrors expand/collapse arrows. Full accessible labels preserve truncated text. Loaded selected ancestors reveal; app supplies unknown remote ancestors. Removal recovers focus.
- TREE-003: Explicit unloaded/loading/loaded/error states. Empty folder is not an unknown folder. Loading uses a skeleton/status; errors have Retry and a keyboard menu alternative. API requests, cancellation and immutable data replacement are app-owned.
- TREE-004: Every move uses the same validation: no self, no descendant, inside only a branch, then application canMove. Drag before/inside/after, menu Move and Ctrl/⌘+Shift+M share one commit path. The app updates data after success; duplicate pending moves are blocked; failure is announced and retryable. Permissions must also be enforced by the application/API.
Scale and scope
Do not silently remove deep nodes. Keep semantic depth accurate; visual indentation stops growing after four levels. Compose a focused subtree with a return/breadcrumb path for deeper work. Load large folders incrementally; this component does not promise virtualization. Keep worst-case fixtures in Storybook, not enterprise. Test long non-Latin names, huge counts, empty/single/large branches, disabled nodes, data replacement, RTL, keyboard and pointer moves, and async rejection.
Reviewed accessibility and geometry adjustments
Tree prose describes a 4px inset, while the reviewed item 818:274 uses 8px horizontal padding and a 6px gap. The implementation follows the component and the full tree 820:211, including its root spacer and 2px row gaps.
Explicit differences and boundaries
| Reference | Coverage |
|---|---|
| Cell master 430:13537 | Text/description/icon/badge, people/groups/email, entities, files, tags, boolean, progress, copy/link, numbers/units/currency/trends, loading and totals compose TableCell, TableNumberCell and TableRowActions. Tree parent/child/grandchild behavior belongs to the recursive DataGrid examples. Hover and selection share the row tint in both densities. |