# Tree

A decomposed, shadcn-styled tree for `@djangocfg/ui-tools`. Pure React engine, zero external tree libraries. Generic over `T`, slot-driven, async-friendly.

## Why this exists

We tried popular headless tree engines first. They all leak React-integration bugs (state references, mounting order, click handlers desyncing from re-renders). So this tree is intentionally small and predictable: a `useReducer` holds the state, a `flattenTree` walk produces visible rows, components consume those rows. No black boxes.

## Philosophy

1. **No engine.** State lives in plain React. Every interaction goes through React's commit cycle.
2. **Generic over `T`.** Tree nodes carry your domain payload (`File`, `Project`, `JsonNode`, …). The component never assumes filesystem semantics.
3. **Sync or async.** Pass inline `children: TreeNode<T>[]` for sync data, or omit them and provide `loadChildren` for lazy loading. The async cache de-duplicates concurrent fetches.
4. **Slots over props.** New visual needs add a slot, not a flag: `renderRow` / `renderIcon` / `renderLabel` / `renderActions` / `renderContextMenu`.
5. **VSCode-style highlights.** Hover, focus, and selection have distinct levels. Selection inside a focused tree gets the primary tint and a left active-indicator bar.
6. **CSS-variable theming.** Density, sizes, gaps, indent — all exposed as `--tree-*` variables on the root. Override in any consumer without re-implementing components.

## Layered architecture

```
types.ts                    public types — generic over T, no `any`
data/
  appearance.ts             density / accent / radius / sizes → CSS vars + classes
  childCache.ts             id → { status, children, error }
  flatten.ts                roots + expanded + cache → FlatRow<T>[]
  persist.ts                versioned localStorage helper
  createDemoTree.ts         deterministic synthetic tree for stories/tests
context/
  TreeContext.tsx           reducer + Provider + async loader
  hooks.ts                  useTreeSelection / Expansion / Focus / Search / Actions / Rows
hooks/
  useTreeKeyboard.ts        ↑↓ ←→ Home End Enter Esc on the container
  useTreeTypeAhead.ts       Finder-style 600 ms prefix buffer
components/
  TreeRow.tsx               default row: chevron + icon + label + actions + ctx-menu
  TreeChevron / TreeIcon / TreeLabel / TreeIndentGuides
  TreeSearchInput.tsx       controlled search input
  TreeContent.tsx           iterates flatRows, default-renders TreeRow
  TreeEmpty / TreeSkeleton / TreeError
TreeRoot.tsx                high-level entry — Provider + shell + content
lazy.tsx                    LazyTree via createLazyComponent
```

Dependency direction: `components → context → data → types`. `hooks/` consume `context/`. Nothing cycles back.

## Quick start

```tsx
import { TreeRoot, type TreeNode } from '@djangocfg/ui-tools/tree';

interface FsNode { name: string }

const data: TreeNode<FsNode>[] = [
  {
    id: 'src',
    data: { name: 'src' },
    children: [
      { id: 'index.ts', data: { name: 'index.ts' } },
    ],
  },
];

<TreeRoot<FsNode>
  data={data}
  getItemName={(n) => n.data.name}
  onSelectionChange={(ids) => console.log(ids)}
  onActivate={(node) => openFile(node.id)}
  enableSearch
  showIndentGuides
  persistKey="settings.fileTree"
/>
```

## Async children

```tsx
const data: TreeNode<FsNode>[] = [
  { id: 'root', data: { name: 'remote' }, isFolder: true },
];

<TreeRoot<FsNode>
  data={data}
  getItemName={(n) => n.data.name}
  loadChildren={async (node) => {
    const list = await fetchChildren(node.id);
    return list.map((it) => ({ id: it.id, data: it, isFolder: it.isDir }));
  }}
/>
```

The provider caches resolved children, de-duplicates concurrent fetches per id, and re-renders when the cache mutates. Use `useTreeActions().refresh(id)` to invalidate one node, or `refreshAll()` after a backend signal.

## Composition mode

```tsx
import {
  TreeProvider, TreeContent, TreeSearchInput,
  useTreeSelection, useTreeActions,
} from '@djangocfg/ui-tools/tree';

<TreeProvider data={data} getItemName={getName}>
  <Toolbar />
  <TreeSearchInput />
  <TreeContent>{(row) => <CustomRow {...row} />}</TreeContent>
</TreeProvider>

function Toolbar() {
  const { expandAll, collapseAll, refreshAll } = useTreeActions();
  // …
}
```

## Appearance

Cosmetic configuration is a single optional prop. Defaults to a comfortable VSCode-Explorer density.

```tsx
<TreeRoot
  data={…}
  getItemName={…}
  appearance={{
    density: 'cozy',          // 'compact' | 'cozy' | 'comfortable'
    accent: 'default',        // 'subtle' | 'default' | 'strong'
    radius: 'sm',             // 'none' | 'sm' | 'md'
    iconStrokeWidth: 1.5,
    indentGuideOpacity: 0.4,
    showActiveIndicator: true,

    // fine-grained overrides (win over density):
    rowHeight: 30,
    iconSize: 18,
    fontSize: 14,
    gap: 10,
    indent: 20,
  }}
/>
```

The resolved appearance is exposed on the root container as CSS variables, so any nested override (`className`, custom slot) can read them:

```
--tree-row-height
--tree-icon-size
--tree-icon-stroke
--tree-font-size
--tree-gap
--tree-indent
--tree-guide-opacity
```

### VSCode-style highlights

Row state visuals follow VSCode's Explorer:

| State | Look |
| --- | --- |
| Hover | subtle neutral wash (`bg-foreground/[.06]`) |
| Focused (keyboard nav, not selected) | slightly stronger neutral |
| Selected, tree NOT focused | muted neutral block |
| Selected + tree focused-within | primary tint + colored text + left active bar |
| Search match | thin primary ring |
| Disabled | dimmed + cursor-not-allowed |

Toggle the bar with `appearance.showActiveIndicator`. Intensity scales with `appearance.accent`.

## Extension points

| Need | Mechanism |
| --- | --- |
| Custom row markup | `renderRow={(row) => …}` |
| Replace icon (per file type) | `renderIcon={(row) => …}` (see `WithIcons` story) |
| Modified / error / disabled labels | `renderLabel={(row) => …}` (see `WithStatus` story) |
| Right-side buttons (per row) | `renderActions={(row) => …}` (see `WithActions` story) |
| Right-click menu | `renderContextMenu={(row, trigger) => <ContextMenu>…</ContextMenu>}` |
| Localised copy | `labels={{ empty: '…', searchPlaceholder: '…' }}` |
| Persist state | `persistKey="settings.fileTree"`, optional `persistSelection` |
| Imperative actions | `useTreeActions()` |
| Read raw flat rows | `useTreeRows()` |

## Defaults

| Option | Default |
| --- | --- |
| `selectionMode` | `'single'` |
| `activationMode` | `'single-click'` |
| `enableSearch` | `false` |
| `enableTypeAhead` | `true` |
| `showIndentGuides` | `false` |
| `persistSelection` | `false` |
| `appearance.density` | `'cozy'` |
| `appearance.accent` | `'default'` |
| `appearance.radius` | `'sm'` |
| `appearance.showActiveIndicator` | `true` |
| `appearance.iconStrokeWidth` | `1.5` |
| `appearance.indent` | `16` |

## Stories

| Story | Demonstrates |
| --- | --- |
| Default | sensible cozy defaults |
| WithActivationModes | single-click / double-click / preview semantics |
| WithHiddenFilter | `filterNode` toggle hides dot-files |
| Densities | three density presets side-by-side |
| WithIcons | file-type icons through `renderIcon` |
| WithStatus | modified / error / disabled rows through `renderLabel` |
| WithActions | rename / delete on hover through `renderActions` |
| WithSearch | built-in search bar + match highlight |
| WithIndentGuides | opt-in vertical guides |
| WithContextMenu | right-click via `renderContextMenu` + `ui-core/ContextMenu` |
| AsyncLazyChildren | `loadChildren` + cache + dedup |
| ExpandCollapseAll | composition mode with `useTreeActions` |
| Persisted | localStorage round-trip |
| LargeTree | ~500 nodes scalability check |
| Playground | every knob exposed as a control |

## Filtering nodes

`filterNode` is a single predicate that decides which nodes appear at all.
Nodes returning `false` (and their descendants) are excluded from rendering,
keyboard navigation, and search.

```tsx
const [showHidden, setShowHidden] = useState(false);

<TreeRoot
  data={data}
  getItemName={(n) => n.data.name}
  filterNode={(n) => showHidden || !n.data.name.startsWith('.')}
/>
```

This is intentionally minimal — Tree is generic over `T` and has no opinion
on what "hidden" means in your domain. If your backend already provides
flags like `entry.isHidden` / `entry.isSystem`, use them directly:

```tsx
filterNode={(n) => showHidden || (!n.data.isHidden && !n.data.isSystem)}
```

> **Frontend note.** From the browser you cannot read OS-level hidden
> attributes (Windows `FILE_ATTRIBUTE_HIDDEN`, macOS `kIsInvisible`).
> Either filter by name (Unix dot-prefix is the de-facto convention), or
> let your backend determine those flags and forward them in `node.data`.

## Activation modes

How a leaf becomes "activated" (opened) on pointer interaction is controlled
by `activationMode`. Folders ignore this setting — they always toggle on
single click and never call `onActivate`.

| Mode | Single click | Double click |
| --- | --- | --- |
| `'single-click'` *(default)* | activate `{ preview: false }` | activate `{ preview: false }` |
| `'double-click'` | select + focus only | activate `{ preview: false }` |
| `'single-click-preview'` *(VSCode / Cursor)* | activate `{ preview: true }` | activate `{ preview: false }` |

Keyboard `Enter` / `Space` always activates with `{ preview: false }` —
keyboard input is treated as an explicit user action.

```tsx
<TreeRoot<FsNode>
  data={data}
  getItemName={(n) => n.data.name}
  activationMode="single-click-preview"
  onActivate={(node, { preview }) =>
    preview ? openPreviewTab(node) : openPinnedTab(node)
  }
/>
```

The active mode is also exposed on each row as
`data-activation-mode="<mode>"` for CSS-level targeting.

## VSCode-style file icons

Tree is generic over `T` — it has no opinion on whether nodes are files. For a
ready-made VSCode-style icon set, install the optional companion subpath:

```bash
pnpm add material-file-icons
```

```tsx
import { TreeRoot } from '@djangocfg/ui-tools/tree';
import { createFileIconSlot } from '@djangocfg/ui-tools/file-icon';

<TreeRoot
  data={data}
  getItemName={(n) => n.data.name}
  renderIcon={createFileIconSlot({ getName: (n) => n.data.name })}
/>
```

`material-file-icons` is declared in `optionalDependencies`. If it's not
installed, `<FileIcon>` falls back to a Lucide `File` icon — no warnings, no
runtime errors.

Folders use a small built-in mapping (`src` → `FolderCode`,
`node_modules` → `Package`, `.git` → `FolderGit2`, `dist`/`build`/`.next`
→ `FolderOutput`, `tests`/`__tests__` → `FlaskConical`, …). Anything not
in the table renders the generic `Folder` / `FolderOpen` pair. Override
or extend the table per-call:

```tsx
import { FolderHeart } from 'lucide-react';

renderIcon={createFileIconSlot({
  getName: (n) => n.data.name,
  folderOverrides: { favorites: FolderHeart },
})}
```

## Out of scope (today)

- Inline rename UX
- Drag-and-drop
- Virtualization (wrap with `@tanstack/react-virtual` when needed)
- Multi-tree (cross-tree DnD)
- Live filesystem watchers / quick-open palette — app-level concerns; build them on top of `useTreeActions()` and `useTreeContext()`.
