## Tree View — Navigation Disclosure the arrow keys can walk. Docs: https://www.interior.dev/docs/tree-view Reference: https://www.interior.dev/reference/tree-view License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT) ### Install Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project. `bunx shadcn@latest add https://www.interior.dev/r/tree-view.json` Or run `bun add motion` and copy the source below. ### Usage ```tsx "use client"; import { useState } from "react"; import { TreeView, type TreeNode } from "@/components/interior/tree-view"; const FILES: TreeNode[] = [ { id: "src", label: "src", children: [ { id: "index", label: "index.ts", meta: "2 kB" }, { id: "config", label: "config.ts", meta: "1 kB" }, ], }, { id: "readme", label: "README.md", meta: "4 kB" }, ]; export function FilePicker({ onOpen }: { onOpen: (id: string) => void }) { const [selected, setSelected] = useState("readme"); return ( { setSelected(id); onOpen(id); }} className="max-w-[280px]" /> ); } ``` ### Props - `nodes` (`TreeNode[]`) The tree, root list first. Each node is { id, label, meta?, children? }; id must be stable because it keys expansion, selection and the roving focus. - `label` (`string`) Accessible name for the tree. - `expanded` (`string[]`) Controlled set of open branch ids. - `defaultExpanded` (`string[]`) — default: `[]` Uncontrolled starting open set. - `onExpandedChange` (`(expanded: string[]) => void`) Fires with the next open set on every toggle. - `selected` (`string | null`) Controlled selected id. - `defaultSelected` (`string | null`) — default: `null` Uncontrolled starting selection. - `onSelectedChange` (`(selected: string) => void`) Fires when a row is chosen by click, Enter or Space. - `className` (`string`) — default: `""` Appended last to the card, so a caller's width and surface win. ### Behavior notes - The keyboard model is the full APG tree pattern, not a subset: arrows walk and fold, Home and End jump, Enter and Space choose, and typing a letter moves to the next visible name that starts with it. - One tab stop for the whole tree. If the focused row is folded away by an ancestor, the stop falls back to the selection or the first row without stealing the browser's focus from wherever it actually is. - A closed branch is not rendered and hidden — it is not rendered at all, so assistive technology is never handed rows the eye cannot reach. - Disclosure runs height and opacity on separate clocks with opacity finishing first, so rows are fully formed before the reflow that reveals them has finished. - aria-level, aria-setsize and aria-posinset are written from the data on every row, so a screen reader hears 'level 2, item 3 of 5' rather than a flat list. - Every row reserves the caret's slot whether or not it has one, so labels in one list share one left edge instead of ragging on folder boundaries. - Under prefers-reduced-motion branches open and close in place; the information arrives, the travel is skipped. ### Source (`components/interior/tree-view.tsx`) ```tsx "use client"; import { useCallback, useId, useRef, useState } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; const EASE = [0.23, 1, 0.32, 1] as const; const LEAVE = [0.4, 0, 1, 1] as const; const SMALL = { type: "spring", stiffness: 700, damping: 46, mass: 0.5 } as const; const OPEN_H = { duration: 0.28, ease: EASE } as const; const OPEN_O = { duration: 0.18, ease: EASE } as const; const SHUT_H = { duration: 0.2, ease: LEAVE } as const; const SHUT_O = { duration: 0.14, ease: LEAVE } as const; const STILL = { duration: 0 } as const; export type TreeNode = { id: string; label: string; meta?: string; children?: TreeNode[]; }; export type TreeRow = { node: TreeNode; level: number; parentId: string | null; posinset: number; setsize: number; branch: boolean; open: boolean; }; function flatten( nodes: TreeNode[], openSet: ReadonlySet, level = 1, parentId: string | null = null, out: TreeRow[] = [], ): TreeRow[] { nodes.forEach((node, i) => { const children = node.children ?? []; const branch = children.length > 0; const open = branch && openSet.has(node.id); out.push({ node, level, parentId, posinset: i + 1, setsize: nodes.length, branch, open, }); if (open) flatten(children, openSet, level + 1, node.id, out); }); return out; } export type UseTreeViewOptions = { nodes: TreeNode[]; expanded?: string[]; defaultExpanded?: string[]; onExpandedChange?: (expanded: string[]) => void; selected?: string | null; defaultSelected?: string | null; onSelectedChange?: (selected: string) => void; }; export function useTreeView({ nodes, expanded, defaultExpanded = [], onExpandedChange, selected, defaultSelected = null, onSelectedChange, }: UseTreeViewOptions) { const [internalOpen, setInternalOpen] = useState(defaultExpanded); const openControlled = expanded !== undefined; const openList = openControlled ? expanded : internalOpen; const openSet = new Set(openList); const [internalSel, setInternalSel] = useState(defaultSelected); const selControlled = selected !== undefined; const selectedId = selControlled ? selected : internalSel; const emitOpen = useRef(onExpandedChange); emitOpen.current = onExpandedChange; const emitSel = useRef(onSelectedChange); emitSel.current = onSelectedChange; const rows = flatten(nodes, openSet); const [focusId, setFocusId] = useState(null); const visible = focusId !== null && rows.some((r) => r.node.id === focusId) ? focusId : (rows.find((r) => r.node.id === selectedId)?.node.id ?? rows[0]?.node.id ?? null); const refs = useRef(new Map()); const register = useCallback((id: string, el: HTMLElement | null) => { if (el) refs.current.set(id, el); else refs.current.delete(id); }, []); const focusRow = useCallback((id: string) => { setFocusId(id); refs.current.get(id)?.focus(); }, []); const setOpen = useCallback( (next: string[]) => { if (!openControlled) setInternalOpen(next); emitOpen.current?.(next); }, [openControlled], ); const toggle = useCallback( (id: string) => { const has = openList.includes(id); setOpen(has ? openList.filter((v) => v !== id) : [...openList, id]); }, [openList, setOpen], ); const select = useCallback( (id: string) => { if (!selControlled) setInternalSel(id); emitSel.current?.(id); }, [selControlled], ); const handleKey = useCallback( (event: React.KeyboardEvent, row: TreeRow) => { const at = rows.findIndex((r) => r.node.id === row.node.id); const go = (index: number) => { const target = rows[index]; if (target) focusRow(target.node.id); }; switch (event.key) { case "ArrowDown": event.preventDefault(); go(at + 1); return; case "ArrowUp": event.preventDefault(); go(at - 1); return; case "ArrowRight": event.preventDefault(); if (row.branch && !row.open) toggle(row.node.id); else if (row.open) go(at + 1); return; case "ArrowLeft": event.preventDefault(); if (row.open) toggle(row.node.id); else if (row.parentId) focusRow(row.parentId); return; case "Home": event.preventDefault(); go(0); return; case "End": event.preventDefault(); go(rows.length - 1); return; case "Enter": case " ": event.preventDefault(); select(row.node.id); if (row.branch) toggle(row.node.id); return; default: } if (event.key.length === 1 && !event.metaKey && !event.ctrlKey) { const letter = event.key.toLowerCase(); if (letter === " ") return; for (let step = 1; step <= rows.length; step++) { const candidate = rows[(at + step) % rows.length]; if (candidate.node.label.toLowerCase().startsWith(letter)) { event.preventDefault(); focusRow(candidate.node.id); return; } } } }, [rows, focusRow, toggle, select], ); return { rows, openSet, selectedId, tabStop: visible, register, focusRow, setFocusId, toggle, select, handleKey, }; } function Caret({ open }: { open: boolean }) { const reduced = useReducedMotion(); return ( ); } export type TreeViewProps = { nodes: TreeNode[]; label: string; expanded?: string[]; defaultExpanded?: string[]; onExpandedChange?: (expanded: string[]) => void; selected?: string | null; defaultSelected?: string | null; onSelectedChange?: (selected: string) => void; className?: string; }; export function TreeView({ nodes, label, expanded, defaultExpanded, onExpandedChange, selected, defaultSelected, onSelectedChange, className = "", }: TreeViewProps) { const tree = useTreeView({ nodes, expanded, defaultExpanded, onExpandedChange, selected, defaultSelected, onSelectedChange, }); const reduced = useReducedMotion(); const hintId = useId(); const renderNodes = (list: TreeNode[], level: number) => list.map((node, i) => { const row = tree.rows.find((r) => r.node.id === node.id); if (!row) return null; const isSelected = tree.selectedId === node.id; return (
  • tree.register(node.id, el)} aria-level={level} aria-posinset={i + 1} aria-setsize={list.length} aria-expanded={row.branch ? row.open : undefined} aria-selected={isSelected} aria-describedby={hintId} tabIndex={tree.tabStop === node.id ? 0 : -1} onFocus={() => tree.setFocusId(node.id)} onKeyDown={(e) => tree.handleKey(e, row)} onClick={() => { tree.select(node.id); tree.focusRow(node.id); if (row.branch) tree.toggle(node.id); }} className={`flex h-7 cursor-default select-none items-center gap-1 rounded-[8px] px-1.5 outline-none transition-colors duration-150 focus-visible:bg-[#4568FF]/[0.06] focus-visible:shadow-[inset_0_0_0_1px_#4568FF] dark:focus-visible:bg-[#93B0FF]/[0.1] dark:focus-visible:shadow-[inset_0_0_0_1px_#93B0FF] ${ isSelected ? "bg-stone-100/80 text-stone-800 dark:bg-white/[0.07] dark:text-stone-100" : "text-stone-600 hover:bg-stone-100/60 dark:text-stone-300 dark:hover:bg-white/[0.04]" }`} > {row.branch ? : } {node.label} {node.meta ? ( {node.meta} ) : null}
    {row.branch ? ( {row.open ? (
    {renderNodes(node.children ?? [], level + 1)}
    ) : null}
    ) : null}
  • ); }); return (
      {renderNodes(nodes, 1)}
    Use the arrow keys to move. Right expands a folder, left collapses it or climbs to its parent. Home and End jump to the ends, and typing a letter jumps to the next name starting with it.
    ); } ```