v1.0

Tree

Preview

Basic

Loading…

Preview

Code

ts
import Tree from '@/components/data/Tree';

src/components/data/Tree.tsx

AI prompt

text
Build a tree view component in React + TypeScript + Tailwind CSS: a hierarchical list with expand/collapse, three selection modes (single, multiple, tri-state checkbox), a filter that keeps ancestors, lazy-loaded children, and the full WAI-ARIA tree keyboard.

## Look
- Root: `flex flex-col gap-2`. Optional filter on top: a search input in the house field style with a 14px lucide `Search` icon inset at the left (`left-2.5`, slate-400) and `pl-8`; placeholder "Filter…".
- Row: `flex items-center gap-1.5 rounded-md px-1.5 py-1 text-xs leading-none select-none`, slate-700 / dark slate-200, hover `bg-slate-100` / dark `bg-slate-800/60`. Selected (single/multiple): `bg-indigo-50 text-indigo-700`, dark `bg-indigo-500/10 text-indigo-300`. Disabled: 50% opacity, not-allowed cursor.
- Row contents, in order: a 16px chevron slot (always reserved so labels align) holding a 14px `ChevronRight` in slate-400 that rotates 90° when open, a spinning `Loader2` while children load, or nothing for a leaf; in checkbox mode a 14px rounded box (unchecked: slate-300 border on white, dark slate-600 on slate-800; checked or mixed: indigo-600 fill, dark indigo-500, with a white 10px `Check` or `Minus`, stroke 3); an optional 14px icon in slate-400 / dark slate-500; the label, truncating. While filtering, the first match in a label is wrapped in a `<mark>` with `bg-amber-100` (dark `amber-500/25`), inherited text colour, `rounded-sm`.
- Nesting: each child group is indented `ml-[13px] pl-1.5` with a left border, which IS the guide line — it lands under the parent's chevron (slate-200 / dark slate-700; transparent when `showGuides` is false). No per-depth spacer elements.
- Focus ring (`ring-2 ring-indigo-400` on focus-visible) goes on the ROW, not on the list item, which wraps its whole subtree.
- Empty: 11px slate-400 text, "Nothing to show." (or "No matches." while filtering).

## Behaviour
- Without `selectionMode` it is a navigation tree: clicking a row toggles it. With a mode, clicking the row selects and clicking the chevron toggles.
- single replaces the selection; multiple toggles the key in or out.
- checkbox: a branch's state is DERIVED bottom-up — checked if every child is, unchecked if none, otherwise mixed — never stored, so a parent can't end up ticked over an unticked child. Pressing a node sets its whole subtree, including rows the filter hides: to checked, unless every enabled leaf in it is already checked, then to unchecked. Disabled nodes and their subtrees are skipped. Then normalise: the selection is exactly the fully-checked keys (branches included), and keys the tree doesn't know yet (unloaded) pass through.
- Disabled nodes stay focusable and expandable but can't be selected or checked.
- Filter: case-insensitive substring match. Keeps matches plus their ancestors and opens those ancestors; a branch that matches itself keeps all its children but stays closed unless something inside matched too. The filter is a VIEW: it never changes the caller's expanded keys — toggles made while filtering are kept locally and dropped when the query changes. ArrowDown in the box moves focus to the first row. Only loaded nodes are searchable.
- Lazy children: with `onLoadChildren`, a node whose `children` is undefined (and isn't `leaf`) shows a chevron and fetches once on first expand. Loading is driven by the expanded set, so controlled `expandedKeys` that include a lazy node load too; guard against duplicate in-flight requests. A rejection collapses the node so the next expand retries. A checked lazy node's new children inherit the tick.
- Selection and expansion are each controlled or uncontrolled; `onToggle` receives the whole next expanded list.

## Keyboard (one tab stop, roving tabindex)
The tab stop is the last-focused row if still visible, else the first selected row, else the first row. Down/Up: next/previous visible row. Right: open a closed branch, or move to the first child of an open one. Left: close an open branch, or move to the parent. Home/End: first/last row. Enter/Space: activate (select, or toggle in a navigation tree). `*`: expand every expandable sibling. Type-ahead: a printable key jumps to the next row starting with it, wrapping; keys typed within 500ms build a prefix.

## Accessibility
`<ul role="tree">` with `aria-label` and `aria-multiselectable` for multiple/checkbox; rows are `<li role="treeitem">` with `aria-level`, `aria-setsize`, `aria-posinset`, `aria-expanded` (branches only), `aria-selected` (single/multiple), `aria-checked` true/false/"mixed" (checkbox), `aria-disabled`, `aria-busy` while loading; nested lists are `role="group"`. The chevron is an `aria-hidden` span, not a button — a tabbable control inside a treeitem would break the one-tab-stop rule.

## API
`TreeNode = { key: string (unique across the whole tree); label: string; icon?: ReactNode; children?: TreeNode[]; disabled?: boolean; leaf?: boolean }`. Props: `nodes`, `selectionMode?: 'single' | 'multiple' | 'checkbox'`, `selectedKeys?` / `defaultSelectedKeys?` / `onSelectionChange?(keys)`, `expandedKeys?` / `defaultExpandedKeys?` / `onToggle?(keys)`, `filter = false`, `filterPlaceholder = 'Filter…'`, `onLoadChildren?(node) => Promise<TreeNode[]>`, `showGuides = true`, `emptyText = 'Nothing to show.'`, `aria-label = 'Tree'`, `className?`.

## Demo
A `w-72` checkbox tree with filter: Documents (Work: Quarterly plan.pdf, Budget draft.xlsx, Meeting notes.md; Home: Lease.pdf, Insurance.pdf disabled), Media (Lake.jpg, Forest.jpg), Readme.txt — folder, file and image icons, Documents and Work open, Quarterly plan pre-checked. Beside it a single-select tree of "Archive 2023" and "Archive 2024" whose children (Q1–Q4 report.pdf) load after 700ms, plus an "Empty folder" marked `leaf`.

## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.

Source

tsx
'use client';

import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
import { Check, ChevronRight, Loader2, Minus, Search } from 'lucide-react';
import { cn } from '@/lib/cn';

export interface TreeNode {
  /** Unique across the WHOLE tree, not just among siblings — selection, expansion and focus are all keyed by it. */
  key: string;
  label: string;
  icon?: ReactNode;
  children?: TreeNode[];
  /** Still focusable and expandable (so its subtree stays reachable), but cannot be selected or checked. */
  disabled?: boolean;
  /**
   * With `onLoadChildren`, a node whose `children` is undefined is assumed to
   * have some to fetch. `leaf` says it has none, so it draws no chevron.
   */
  leaf?: boolean;
}

export type TreeSelectionMode = 'single' | 'multiple' | 'checkbox';
export type TreeCheckState = 'checked' | 'unchecked' | 'mixed';

export interface TreeProps {
  nodes: TreeNode[];
  /** Omit for a navigation-only tree, where clicking a branch toggles it. */
  selectionMode?: TreeSelectionMode;
  /**
   * Controlled selection. In `checkbox` mode this is every FULLY checked key,
   * branches included — a half-checked parent is derived, never stored.
   */
  selectedKeys?: string[];
  defaultSelectedKeys?: string[];
  onSelectionChange?: (keys: string[]) => void;
  /** Controlled expansion; pair with `onToggle`. */
  expandedKeys?: string[];
  defaultExpandedKeys?: string[];
  /** Receives the whole next expanded set, so a controlled caller just stores it. */
  onToggle?: (expandedKeys: string[]) => void;
  /** Shows a filter box above the tree. */
  filter?: boolean;
  filterPlaceholder?: string;
  /** Lazy children. Called once per node on first expand; a rejection collapses the node so the next expand retries. */
  onLoadChildren?: (node: TreeNode) => Promise<TreeNode[]>;
  /** Vertical lines joining each level to its parent. */
  showGuides?: boolean;
  emptyText?: string;
  'aria-label'?: string;
  className?: string;
}

type Entry = { node: TreeNode; parent: string | null };
type Row = { node: TreeNode; parent: string | null };

// Lazily loaded children live in component state, not in the caller's nodes,
// so they are spliced in here and everything downstream reads ONE tree.
function resolve(nodes: TreeNode[], loaded: Record<string, TreeNode[]>): TreeNode[] {
  return nodes.map((n) => {
    const kids = n.children ?? loaded[n.key];
    return kids ? { ...n, children: resolve(kids, loaded) } : n;
  });
}

function indexTree(nodes: TreeNode[], parent: string | null = null, map = new Map<string, Entry>()) {
  for (const n of nodes) {
    map.set(n.key, { node: n, parent });
    if (n.children) indexTree(n.children, n.key, map);
  }
  return map;
}

function collectKeys(nodes: TreeNode[], out: string[] = []): string[] {
  for (const n of nodes) {
    out.push(n.key);
    if (n.children) collectKeys(n.children, out);
  }
  return out;
}

// A branch's check state is DERIVED from its children, bottom-up. Storing it
// instead is how a parent ends up ticked over an unticked child: every toggle
// would have to walk up and repair every ancestor, and one missed path is a lie.
function checkStates(nodes: TreeNode[], checked: Set<string>, out = new Map<string, TreeCheckState>()) {
  const visit = (n: TreeNode): TreeCheckState => {
    let s: TreeCheckState;
    if (!n.children || n.children.length === 0) {
      s = checked.has(n.key) ? 'checked' : 'unchecked';
    } else {
      const kids = n.children.map(visit);
      s = kids.every((k) => k === 'checked') ? 'checked' : kids.every((k) => k === 'unchecked') ? 'unchecked' : 'mixed';
    }
    out.set(n.key, s);
    return s;
  };
  nodes.forEach(visit);
  return out;
}

// The keys one press on `n` can actually change.
function enabledLeaves(n: TreeNode): string[] {
  if (n.disabled) return [];
  if (!n.children || n.children.length === 0) return [n.key];
  return n.children.flatMap(enabledLeaves);
}

// Disabled descendants are skipped along with their subtree: "disabled" means
// this toggle cannot change them, not that they are silently ticked on the way past.
function setSubtree(n: TreeNode, on: boolean, set: Set<string>) {
  if (n.disabled) return;
  if (on) set.add(n.key);
  else set.delete(n.key);
  n.children?.forEach((c) => setSubtree(c, on, set));
}

// Keeps every match plus the ancestors that lead to it, and reports those
// ancestors so they can open. A branch that matches ITSELF keeps all its
// children — searching for a folder should show what is in it — but stays
// closed unless something inside matched too.
function filterNodes(nodes: TreeNode[], q: string, open: Set<string>): TreeNode[] {
  const out: TreeNode[] = [];
  for (const n of nodes) {
    const self = n.label.toLowerCase().includes(q);
    const kids = n.children ? filterNodes(n.children, q, open) : [];
    if (kids.length) {
      open.add(n.key);
      out.push({ ...n, children: self ? n.children : kids });
    } else if (self) {
      out.push(n);
    }
  }
  return out;
}

function Highlight({ text, q }: { text: string; q: string }) {
  const at = q ? text.toLowerCase().indexOf(q) : -1;
  if (at < 0) return <>{text}</>;
  return (
    <>
      {text.slice(0, at)}
      <mark className="rounded-sm bg-amber-100 text-inherit dark:bg-amber-500/25">{text.slice(at, at + q.length)}</mark>
      {text.slice(at + q.length)}
    </>
  );
}

/**
 * A hierarchical list with expand/collapse, three selection modes, a filter and
 * lazy children.
 *
 * Keyboard follows the WAI tree pattern with ONE tab stop (roving tabindex):
 * Tab enters the tree once and leaves it once, and the arrows move within. A
 * tree that put every row in the tab order would make a 200-node tree 200
 * presses to get past.
 *
 * The filter is a VIEW: it never touches the caller's `expandedKeys`. While it
 * is active the tree opens exactly the ancestors of matches, and toggles made
 * then are kept locally and discarded when the query changes — otherwise
 * clearing a search would leave the caller's tree blown wide open. Only
 * already-loaded nodes are searchable; a lazy branch nobody opened has no
 * children to match.
 *
 * Checking a parent in `checkbox` mode checks its WHOLE subtree, including
 * rows the filter is hiding: the box says "this folder", and a folder that is
 * only partly ticked because of what was on screen at the time would read as
 * ticked and behave as not.
 */
export default function Tree({
  nodes,
  selectionMode,
  selectedKeys,
  defaultSelectedKeys,
  onSelectionChange,
  expandedKeys,
  defaultExpandedKeys,
  onToggle,
  filter = false,
  filterPlaceholder = 'Filter…',
  onLoadChildren,
  showGuides = true,
  emptyText = 'Nothing to show.',
  'aria-label': ariaLabel = 'Tree',
  className,
}: TreeProps) {
  const [innerSelected, setInnerSelected] = useState<string[]>(defaultSelectedKeys ?? []);
  const [innerExpanded, setInnerExpanded] = useState<string[]>(defaultExpandedKeys ?? []);
  const [loaded, setLoaded] = useState<Record<string, TreeNode[]>>({});
  const [loading, setLoading] = useState<Set<string>>(() => new Set());
  const [query, setQuery] = useState('');
  const [filterOpen, setFilterOpen] = useState<{ q: string; keys: Set<string> } | null>(null);
  const [focusKey, setFocusKey] = useState<string | null>(null);

  const itemRefs = useRef(new Map<string, HTMLLIElement>());
  const loadingRef = useRef(new Set<string>());
  const typeahead = useRef({ buf: '', at: 0 });

  const selectedList = selectedKeys ?? innerSelected;
  const expandedList = expandedKeys ?? innerExpanded;

  const tree = useMemo(() => resolve(nodes, loaded), [nodes, loaded]);
  const index = useMemo(() => indexTree(tree), [tree]);
  const selected = useMemo(() => new Set(selectedList), [selectedList]);
  const checks = useMemo(
    () => (selectionMode === 'checkbox' ? checkStates(tree, selected) : null),
    [selectionMode, tree, selected],
  );

  const q = filter ? query.trim().toLowerCase() : '';
  const { visible, autoOpen } = useMemo(() => {
    if (!q) return { visible: tree, autoOpen: null };
    const open = new Set<string>();
    return { visible: filterNodes(tree, q, open), autoOpen: open };
  }, [tree, q]);

  const expanded = useMemo(() => {
    if (autoOpen) return filterOpen?.q === q ? filterOpen.keys : autoOpen;
    return new Set(expandedList);
  }, [autoOpen, filterOpen, q, expandedList]);

  const emitSelection = (next: string[]) => {
    if (selectedKeys === undefined) setInnerSelected(next);
    onSelectionChange?.(next);
  };

  const setExpanded = useCallback(
    (next: Set<string>) => {
      if (q) {
        setFilterOpen({ q, keys: next });
        return;
      }
      const list = [...next];
      if (expandedKeys === undefined) setInnerExpanded(list);
      onToggle?.(list);
    },
    [q, expandedKeys, onToggle],
  );

  const collapseAfterFailure = (key: string) => {
    const next = expandedList.filter((k) => k !== key);
    if (expandedKeys === undefined) setInnerExpanded(next);
    onToggle?.(next);
    setFilterOpen((f) => (f ? { q: f.q, keys: new Set([...f.keys].filter((k) => k !== key)) } : f));
  };

  // Read from the async load callback, which would otherwise close over the
  // props and state of the render that STARTED the fetch rather than the one
  // it resolves into — a selection made while children loaded would be undone.
  const latest = useRef({ selected: selectedList, selectionMode, emitSelection, collapseAfterFailure });
  useEffect(() => {
    latest.current = { selected: selectedList, selectionMode, emitSelection, collapseAfterFailure };
  });

  const canExpand = (n: TreeNode) =>
    (n.children?.length ?? 0) > 0 || (!!onLoadChildren && !n.leaf && n.children === undefined);

  const toggleOpen = (n: TreeNode, open = !expanded.has(n.key)) => {
    if (!canExpand(n) || open === expanded.has(n.key)) return;
    const next = new Set(expanded);
    if (open) next.add(n.key);
    else next.delete(n.key);
    setExpanded(next);
  };

  // Loading is driven by EXPANSION, not by the click: a controlled caller can
  // hand in `expandedKeys` that already include a lazy node, and it must load
  // just the same. The ref guards against the effect re-firing mid-flight.
  useEffect(() => {
    if (!onLoadChildren) return;
    for (const key of expanded) {
      const n = index.get(key)?.node;
      if (!n || n.leaf || n.children !== undefined || loadingRef.current.has(key)) continue;
      loadingRef.current.add(key);
      setLoading((s) => new Set(s).add(key));
      onLoadChildren(n)
        .then((kids) => {
          setLoaded((l) => ({ ...l, [key]: kids }));
          // A checked lazy node was a leaf until now; its new children inherit
          // the tick, or the derived state would flip the parent to unchecked.
          const cur = latest.current;
          if (cur.selectionMode === 'checkbox' && cur.selected.includes(key)) {
            cur.emitSelection([...new Set([...cur.selected, ...collectKeys(kids.filter((k) => !k.disabled))])]);
          }
        })
        .catch(() => latest.current.collapseAfterFailure(key))
        .finally(() => {
          loadingRef.current.delete(key);
          setLoading((s) => {
            const n2 = new Set(s);
            n2.delete(key);
            return n2;
          });
        });
    }
    // Callbacks are read at resolve time through `latest`; re-running on their
    // identity would only re-walk the guarded loop.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [expanded, index, onLoadChildren]);

  const activate = (n: TreeNode) => {
    if (!selectionMode) {
      toggleOpen(n);
      return;
    }
    if (n.disabled) return;
    if (selectionMode === 'single') {
      emitSelection([n.key]);
    } else if (selectionMode === 'multiple') {
      emitSelection(selected.has(n.key) ? selectedList.filter((k) => k !== n.key) : [...selectedList, n.key]);
    } else {
      const full = index.get(n.key)!.node;
      const set = new Set(selectedList);
      // Decide from what this press can change, not from the displayed state:
      // a folder holding one disabled, unticked file never reads "checked",
      // so keying off the display would make it impossible to untick.
      setSubtree(full, !enabledLeaves(full).every((k) => set.has(k)), set);
      // Normalise: re-derive, then store exactly the fully checked keys, so an
      // ancestor that just became complete is added and one that broke is dropped.
      // Keys the tree does not know (yet-unloaded nodes) are passed through.
      const states = checkStates(tree, set);
      const next = [
        ...selectedList.filter((k) => !index.has(k)),
        ...[...states].filter(([, s]) => s === 'checked').map(([k]) => k),
      ];
      emitSelection(next);
    }
  };

  const rows = useMemo(() => {
    const out: Row[] = [];
    const walk = (ns: TreeNode[], parent: string | null) => {
      for (const n of ns) {
        out.push({ node: n, parent });
        if (expanded.has(n.key) && n.children?.length) walk(n.children, n.key);
      }
    };
    walk(visible, null);
    return out;
  }, [visible, expanded]);

  // The single tab stop: the last-focused row if it is still on screen, else
  // the first selected one, else the first row.
  const tabStop =
    (focusKey && rows.some((r) => r.node.key === focusKey) && focusKey) ||
    rows.find((r) => selected.has(r.node.key))?.node.key ||
    rows[0]?.node.key;

  const focusRow = (key: string | undefined) => {
    if (!key) return;
    setFocusKey(key);
    itemRefs.current.get(key)?.focus();
  };

  const onKeyDown = (e: KeyboardEvent<HTMLUListElement>) => {
    const key = (e.target as HTMLElement).dataset?.key;
    const at = rows.findIndex((r) => r.node.key === key);
    if (at < 0) return;
    const row = rows[at];
    const n = row.node;
    const open = expanded.has(n.key);
    let handled = true;

    switch (e.key) {
      case 'ArrowDown':
        focusRow(rows[at + 1]?.node.key);
        break;
      case 'ArrowUp':
        focusRow(rows[at - 1]?.node.key);
        break;
      case 'ArrowRight':
        if (!canExpand(n)) break;
        if (!open) toggleOpen(n, true);
        else if (n.children?.length) focusRow(n.children[0].key);
        break;
      case 'ArrowLeft':
        if (open) toggleOpen(n, false);
        else if (row.parent) focusRow(row.parent);
        break;
      case 'Home':
        focusRow(rows[0]?.node.key);
        break;
      case 'End':
        focusRow(rows[rows.length - 1]?.node.key);
        break;
      case 'Enter':
      case ' ':
        activate(n);
        break;
      case '*': {
        const next = new Set(expanded);
        rows.filter((r) => r.parent === row.parent && canExpand(r.node)).forEach((r) => next.add(r.node.key));
        setExpanded(next);
        break;
      }
      default:
        handled = false;
        // Type-ahead: a printable character jumps to the next row starting
        // with it, wrapping. Keys typed within 500ms accumulate into a prefix.
        if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
          const now = Date.now();
          const t = typeahead.current;
          t.buf = now - t.at < 500 ? t.buf + e.key.toLowerCase() : e.key.toLowerCase();
          t.at = now;
          const order = [...rows.slice(at + (t.buf.length === 1 ? 1 : 0)), ...rows.slice(0, at + 1)];
          const hit = order.find((r) => r.node.label.toLowerCase().startsWith(t.buf));
          if (hit) {
            focusRow(hit.node.key);
            handled = true;
          }
        }
    }
    if (handled) {
      e.preventDefault();
      e.stopPropagation();
    }
  };

  const renderNodes = (ns: TreeNode[], depth: number): ReactNode =>
    ns.map((n, i) => {
      const expandable = canExpand(n);
      const open = expandable && expanded.has(n.key);
      const busy = loading.has(n.key);
      const check = checks?.get(n.key) ?? 'unchecked';
      const isSelected = (selectionMode === 'single' || selectionMode === 'multiple') && selected.has(n.key);

      return (
        <li
          key={n.key}
          ref={(el) => {
            if (el) itemRefs.current.set(n.key, el);
            else itemRefs.current.delete(n.key);
          }}
          role="treeitem"
          data-key={n.key}
          tabIndex={n.key === tabStop ? 0 : -1}
          aria-level={depth + 1}
          aria-setsize={ns.length}
          aria-posinset={i + 1}
          aria-expanded={expandable ? open : undefined}
          aria-selected={selectionMode === 'single' || selectionMode === 'multiple' ? isSelected : undefined}
          aria-checked={selectionMode === 'checkbox' ? (check === 'mixed' ? 'mixed' : check === 'checked') : undefined}
          aria-disabled={n.disabled || undefined}
          aria-busy={busy || undefined}
          // The ring goes on the ROW, not the <li>: the item wraps its whole
          // subtree, so ringing it would box every descendant along with it.
          className="outline-none [&:focus-visible>div]:ring-2 [&:focus-visible>div]:ring-indigo-400"
        >
          <div
            onClick={() => {
              focusRow(n.key);
              activate(n);
            }}
            className={cn(
              'flex items-center gap-1.5 rounded-md px-1.5 py-1 text-xs leading-none select-none transition-colors',
              isSelected
                ? 'bg-indigo-50 text-indigo-700 dark:bg-indigo-500/10 dark:text-indigo-300'
                : 'text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800/60',
              n.disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer',
            )}
          >
            {/* Not a <button>: a tabbable control inside a treeitem breaks the
                one-tab-stop contract. The keyboard path is ArrowRight/Left. */}
            <span
              aria-hidden
              onClick={(e) => {
                e.stopPropagation();
                focusRow(n.key);
                toggleOpen(n);
              }}
              className="flex h-4 w-4 shrink-0 items-center justify-center rounded text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-300"
            >
              {busy ? (
                <Loader2 className="h-3.5 w-3.5 animate-spin" />
              ) : expandable ? (
                <ChevronRight className={cn('h-3.5 w-3.5 transition-transform', open && 'rotate-90')} />
              ) : null}
            </span>
            {selectionMode === 'checkbox' && (
              <span
                aria-hidden
                className={cn(
                  'flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border transition-colors',
                  check === 'unchecked'
                    ? 'border-slate-300 bg-white dark:border-slate-600 dark:bg-slate-800'
                    : 'border-indigo-600 bg-indigo-600 text-white dark:border-indigo-500 dark:bg-indigo-500',
                )}
              >
                {check === 'checked' && <Check className="h-2.5 w-2.5" strokeWidth={3} />}
                {check === 'mixed' && <Minus className="h-2.5 w-2.5" strokeWidth={3} />}
              </span>
            )}
            {n.icon && (
              <span aria-hidden className="shrink-0 text-slate-400 dark:text-slate-500 [&>svg]:h-3.5 [&>svg]:w-3.5">
                {n.icon}
              </span>
            )}
            <span className="min-w-0 truncate py-0.5">
              <Highlight text={n.label} q={q} />
            </span>
          </div>
          {open && n.children && n.children.length > 0 && (
            // The indent IS the guide: a left border on the nested group,
            // offset to sit under the parent's chevron. No per-row spacer
            // elements to keep in step with depth.
            <ul
              role="group"
              className={cn(
                'ml-[13px] border-l pl-1.5',
                showGuides ? 'border-slate-200 dark:border-slate-700' : 'border-transparent dark:border-transparent',
              )}
            >
              {renderNodes(n.children, depth + 1)}
            </ul>
          )}
        </li>
      );
    });

  return (
    <div className={cn('flex flex-col gap-2', className)}>
      {filter && (
        <div className="relative">
          <Search aria-hidden className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
          <input
            type="search"
            value={query}
            onChange={(e) => setQuery(e.target.value)}
            onKeyDown={(e) => {
              // ArrowDown from the box drops into the tree, as in a combobox.
              if (e.key === 'ArrowDown' && rows.length) {
                e.preventDefault();
                focusRow(rows[0].node.key);
              }
            }}
            placeholder={filterPlaceholder}
            aria-label="Filter tree"
            className="field-input pl-8"
          />
        </div>
      )}
      {rows.length === 0 ? (
        <p className="px-2 py-1.5 text-[11px] text-slate-400 dark:text-slate-500">{q ? 'No matches.' : emptyText}</p>
      ) : (
        <ul
          role="tree"
          aria-label={ariaLabel}
          aria-multiselectable={selectionMode === 'multiple' || selectionMode === 'checkbox' || undefined}
          onKeyDown={onKeyDown}
          onFocus={(e) => {
            const k = (e.target as HTMLElement).dataset?.key;
            if (k) setFocusKey(k);
          }}
        >
          {renderNodes(visible, 0)}
        </ul>
      )}
    </div>
  );
}

Props

PropTypeDefaultDescription
nodes*TreeNode[]—
selectionModeTreeSelectionMode—Omit for a navigation-only tree, where clicking a branch toggles it.
selectedKeysstring[]—Controlled selection. In `checkbox` mode this is every FULLY checked key, branches included — a half-checked parent is derived, never stored.
defaultSelectedKeysstring[]—
onSelectionChange(keys: string[]) => void—
expandedKeysstring[]—Controlled expansion; pair with `onToggle`.
defaultExpandedKeysstring[]—
onToggle(expandedKeys: string[]) => void—Receives the whole next expanded set, so a controlled caller just stores it.
filterbooleanfalseShows a filter box above the tree.
filterPlaceholderstring'Filter…'
onLoadChildren(node: TreeNode) => Promise<TreeNode[]>—Lazy children. Called once per node on first expand; a rejection collapses the node so the next expand retries.
showGuidesbooleantrueVertical lines joining each level to its parent.
emptyTextstring'Nothing to show.'
classNamestring—