Tree View

Hierarchical tree component for nested data with controlled state, single/multi-select, and keyboard navigation.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/tree-view.json

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

Code

"use client"; import { type ComponentPropsWithoutRef, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, useCallback, useMemo, useState, } from "react"; import { cn } from "../../lib/utils"; /** * Selection mode for {@link TreeView}. * * @public */ export type TreeViewSelectionMode = "multiple" | "single"; /** * A node in the tree. * * @public */ export type TreeNode = { /** When `true`, the node renders dimmed and ignores clicks. */ disabled?: boolean; /** Optional leading icon. */ icon?: ReactNode; /** Stable identifier. */ id: string; /** Visible label. */ label: ReactNode; /** Child nodes; the node renders as a branch when present. */ nodes?: TreeNode[]; }; /** * Localizable strings. * * @public */ export type TreeViewLabels = { /** Aria-label for the tree. Defaults to `"Tree"`. */ region?: string; }; const DEFAULT_LABELS = { region: "Tree", } as const satisfies Required<TreeViewLabels>; /** * Props for {@link TreeView}. * * @public */ export type TreeViewProps = { /** Default expanded ids (uncontrolled). */ defaultExpanded?: string[]; /** Default selected ids (uncontrolled). */ defaultSelected?: string[]; /** Controlled expanded ids. */ expanded?: string[]; /** Localizable strings. */ labels?: TreeViewLabels; /** Tree data. */ nodes: TreeNode[]; /** Fires when expanded ids change. */ onExpandedChange?: (next: string[]) => void; /** Fires when selection changes. */ onSelect?: (next: string[]) => void; /** Controlled selected ids. */ selected?: string[]; /** Selection mode. Defaults to `"single"`. */ selectionMode?: TreeViewSelectionMode; } & Omit<ComponentPropsWithoutRef<"ul">, "onSelect"> & { ref?: React.Ref<HTMLUListElement>; }; type FlatNode = { depth: number; hasChildren: boolean; node: TreeNode; parentId?: string; }; type FlattenArguments = { depth?: number; expanded: ReadonlySet<string>; nodes: TreeNode[]; parentId?: string; }; function flattenVisible(arguments_: FlattenArguments): FlatNode[] { const { depth = 0, expanded, nodes, parentId } = arguments_; return nodes.flatMap((node) => { const hasChildren = (node.nodes?.length ?? 0) > 0; const head: FlatNode = { depth, hasChildren, node, parentId }; if (!hasChildren || !expanded.has(node.id)) return [head]; return [ head, ...flattenVisible({ depth: depth + 1, expanded, nodes: node.nodes ?? [], parentId: node.id, }), ]; }); } function useControlled<T>( controlled: T | undefined, defaultValue: T, ): readonly [T, (next: T) => void, boolean] { const [internal, setInternal] = useState<T>(defaultValue); const isControlled = controlled !== undefined; const value = isControlled ? controlled : internal; const setValue = useCallback( (next: T) => { if (!isControlled) setInternal(next); }, [isControlled], ); return [value, setValue, isControlled]; } function toggleSet(set: ReadonlySet<string>, id: string): string[] { const next = new Set(set); if (next.has(id)) next.delete(id); else next.add(id); return [...next]; } type TreeRowProps = { active: boolean; expanded: boolean; flat: FlatNode; onActivate: (id: string) => void; onExpand: (id: string) => void; onSelect: (id: string) => void; selected: boolean; }; function TreeRow({ active, expanded, flat, onActivate, onExpand, onSelect, selected, }: TreeRowProps): ReactNode { const { depth, hasChildren, node } = flat; const activate = (): void => { if (node.disabled) return; onActivate(node.id); if (hasChildren) onExpand(node.id); else onSelect(node.id); }; const handleKeyDown = (event: ReactKeyboardEvent<HTMLLIElement>): void => { if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); activate(); }; return ( <li aria-disabled={node.disabled || undefined} aria-expanded={hasChildren ? expanded : undefined} aria-selected={selected || undefined} className={cn( "flex cursor-pointer items-center gap-1.5 rounded-md px-2 py-1 text-sm focus:outline-none", active ? "bg-accent text-accent-foreground" : "hover:bg-accent/60", selected ? "ring-1 ring-primary" : "", node.disabled ? "cursor-not-allowed opacity-50" : "", )} data-active={active ? "true" : undefined} data-depth={depth} data-node-id={node.id} data-selected={selected ? "true" : undefined} onClick={activate} onKeyDown={handleKeyDown} role="treeitem" style={{ paddingLeft: `${(depth * 16 + 8).toString()}px` }} > <span aria-hidden="true" className={cn( "inline-flex size-4 shrink-0 items-center justify-center text-xs text-muted-foreground transition-transform", hasChildren ? "" : "opacity-0", expanded ? "rotate-90" : "", )} > </span> {node.icon ? ( <span aria-hidden="true" className="inline-flex size-4 shrink-0"> {node.icon} </span> ) : null} <span className="truncate">{node.label}</span> </li> ); } function nextActiveId( flat: FlatNode[], delta: number, current?: string, ): string | undefined { if (flat.length === 0) return undefined; if (!current) return flat[0]?.node.id; const index = flat.findIndex((entry) => entry.node.id === current); if (index < 0) return flat[0]?.node.id; const nextIndex = Math.min(Math.max(index + delta, 0), flat.length - 1); return flat[nextIndex]?.node.id; } function findFlat(flat: FlatNode[], id?: string): FlatNode | undefined { if (!id) return undefined; return flat.find((entry) => entry.node.id === id); } function useTreeState(arguments_: { defaultExpanded?: string[]; defaultSelected?: string[]; expanded?: string[]; onExpandedChange?: (next: string[]) => void; onSelect?: (next: string[]) => void; selected?: string[]; selectionMode: TreeViewSelectionMode; }): { applyExpand: (id: string) => void; applySelect: (id: string) => void; expandedSet: ReadonlySet<string>; selectedSet: ReadonlySet<string>; } { const { defaultExpanded = [], defaultSelected = [], expanded, onExpandedChange, onSelect, selected, selectionMode, } = arguments_; const [expandedState, setExpandedState] = useControlled<string[]>( expanded, defaultExpanded, ); const [selectedState, setSelectedState] = useControlled<string[]>( selected, defaultSelected, ); const expandedSet = useMemo(() => new Set(expandedState), [expandedState]); const selectedSet = useMemo(() => new Set(selectedState), [selectedState]); const applyExpand = useCallback( (id: string) => { const next = toggleSet(expandedSet, id); setExpandedState(next); onExpandedChange?.(next); }, [expandedSet, onExpandedChange, setExpandedState], ); const applySelect = useCallback( (id: string) => { const next = selectionMode === "single" ? [id] : toggleSet(selectedSet, id); setSelectedState(next); onSelect?.(next); }, [onSelect, selectedSet, selectionMode, setSelectedState], ); return { applyExpand, applySelect, expandedSet, selectedSet }; } function useKeyboardHandler(arguments_: { activeId?: string; applyExpand: (id: string) => void; applySelect: (id: string) => void; expandedSet: ReadonlySet<string>; flat: FlatNode[]; setActiveId: (id?: string) => void; }): (event: ReactKeyboardEvent<HTMLUListElement>) => void { const { activeId, applyExpand, applySelect, expandedSet, flat, setActiveId } = arguments_; return useCallback( (event) => { const current = findFlat(flat, activeId); if (event.key === "ArrowDown") { event.preventDefault(); setActiveId(nextActiveId(flat, 1, activeId)); return; } if (event.key === "ArrowUp") { event.preventDefault(); setActiveId(nextActiveId(flat, -1, activeId)); return; } if (event.key === "ArrowRight" && current?.hasChildren) { event.preventDefault(); if (!expandedSet.has(current.node.id)) applyExpand(current.node.id); return; } if (event.key === "ArrowLeft" && current) { event.preventDefault(); if (current.hasChildren && expandedSet.has(current.node.id)) { applyExpand(current.node.id); } else if (current.parentId) { setActiveId(current.parentId); } return; } if (event.key === "Enter" || event.key === " ") { if (!current || current.node.disabled) return; event.preventDefault(); if (current.hasChildren) applyExpand(current.node.id); else applySelect(current.node.id); } }, [activeId, applyExpand, applySelect, expandedSet, flat, setActiveId], ); } type TreeRowsProps = { activeId?: string; applyExpand: (id: string) => void; applySelect: (id: string) => void; expandedSet: ReadonlySet<string>; flat: FlatNode[]; selectedSet: ReadonlySet<string>; setActiveId: (id?: string) => void; }; function TreeRows({ activeId, applyExpand, applySelect, expandedSet, flat, selectedSet, setActiveId, }: TreeRowsProps): ReactNode { return ( <> {flat.map((entry) => ( <TreeRow active={entry.node.id === activeId} expanded={expandedSet.has(entry.node.id)} flat={entry} key={entry.node.id} onActivate={setActiveId} onExpand={applyExpand} onSelect={applySelect} selected={selectedSet.has(entry.node.id)} /> ))} </> ); } /** * Hierarchical tree component for nested data (file systems, categories, * org charts). Pass {@link TreeNode} data via `nodes`. Supports controlled * and uncontrolled expand/select state, single or multi-select, and full * keyboard navigation (arrows expand/collapse and traverse, enter/space * activates). * * @example * ```tsx * <TreeView * nodes={[ * { id: "src", label: "src/", nodes: [ * { id: "components", label: "components/" }, * { id: "utils", label: "utils/" }, * ]}, * ]} * defaultExpanded={["src"]} * onSelect={(ids) => console.info(ids)} * /> * ``` * * @public */ export const TreeView = (props: TreeViewProps) => { const { className, defaultExpanded, defaultSelected, expanded, labels, nodes, onExpandedChange, onSelect, ref, selected, selectionMode = "single", ...rest } = props; const resolvedLabels = useMemo( () => ({ ...DEFAULT_LABELS, ...labels }), [labels], ); const { applyExpand, applySelect, expandedSet, selectedSet } = useTreeState({ defaultExpanded, defaultSelected, expanded, onExpandedChange, onSelect, selected, selectionMode, }); const flat = useMemo( () => flattenVisible({ expanded: expandedSet, nodes }), [expandedSet, nodes], ); const [activeId, setActiveId] = useState<string | undefined>( () => flat[0]?.node.id, ); const handleKeyDown = useKeyboardHandler({ activeId, applyExpand, applySelect, expandedSet, flat, setActiveId, }); return ( <ul aria-label={resolvedLabels.region} aria-multiselectable={selectionMode === "multiple" || undefined} className={cn( "flex flex-col gap-0.5 rounded-2xl border bg-background p-2 text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring", className, )} onKeyDown={handleKeyDown} ref={ref} role="tree" tabIndex={0} {...rest} > <TreeRows activeId={activeId} applyExpand={applyExpand} applySelect={applySelect} expandedSet={expandedSet} flat={flat} selectedSet={selectedSet} setActiveId={setActiveId} /> </ul> ); }; TreeView.displayName = "TreeView";

Dependencies

  • @vllnt/ui@^0.3.0