a tree of files, layers or tags: every key, selection, rename, drag to move
npx shadcn add @ag/treeAlready installed? Preview an update
npx shadcn add @ag/tree --dry-runYour installed source stays unchanged until you update it. How updates work
- A press that ends a text selection in a row (rows whose words are text to read) no longer selects or opens the row.
also called tree view
shelf controls
uses scrims, focus-source, key-router, material, menu, search, remembered, selection
used by tree-marks, components, data-view, user-flows, trace
the demo above, verbatim
"use client"
import * as React from "react"
import { Sample } from "@/app/samples"
import { cn } from "@/lib/utils"
import { isStrings, setting } from "@/registry/base-nova/lib/remembered-core"
import {
type Drop,
moveNodes,
ownChildren,
rollup,
} from "@/registry/base-nova/lib/tree-core"
import { Button } from "@/registry/base-nova/ui/button"
import { Input } from "@/registry/base-nova/ui/input"
import {
Tree,
TreeAction,
type TreeDensity,
type TreeHandle,
} from "@/registry/base-nova/ui/tree"
import { FileIcon, ICONS } from "@/registry/base-nova/ui/tree-icons"
// The files' open folders, kept across visits until a month untouched (ui/remembered).
const OPEN = setting("tree-demo:files", {
initial: [] as readonly string[],
keep: { idle: 30 * 24 * 3600 * 1000 },
parse: isStrings,
})
const PANEL =
"w-72 rounded-xl bg-foreground/[0.03] p-1 ring-1 ring-foreground/8"
// #region locations
type Place = {
id: string
name: string
kind: "phone" | "camera" | "disk" | "folder"
count?: number
online?: boolean
children?: Place[]
}
const PLACES: Place[] = [
{
id: "iphone",
name: "iphone",
kind: "phone",
children: [
{ id: "iphone/2025", name: "2025", kind: "folder", count: 1 },
{ id: "iphone/2026", name: "2026", kind: "folder", count: 9 },
],
},
{ id: "camera", name: "camera", kind: "camera", count: 16 },
{
id: "archive",
name: "archive disk",
kind: "disk",
count: 4,
online: false,
},
]
// A place's count is its own files plus everything under it.
const COUNTS = rollup(PLACES, ownChildren, (p) => p.count ?? 0)
function Locations() {
const [selected, setSelected] = React.useState(["iphone/2026"])
const PlaceIcon = (p: Place, open: boolean) => {
if (p.kind === "folder")
return <FileIcon name={p.name} folder open={open} />
if (p.kind === "phone") return <ICONS.phone />
if (p.kind === "camera") return <ICONS.camera />
return p.online === false ? <ICONS.diskOffline /> : <ICONS.disk />
}
return (
<div className={PANEL}>
<Tree
aria-label="locations"
items={PLACES}
label={(p) => p.name}
icon={(p, s) => PlaceIcon(p, s.expanded)}
meta={(p) =>
p.online === false
? `offline · ${COUNTS.get(p.id)}`
: COUNTS.get(p.id)
}
dimmed={(p) => p.online === false}
disabled={(p) => p.online === false}
defaultExpanded={["iphone"]}
selected={selected}
onSelectedChange={setSelected}
/>
</div>
)
}
// #endregion
// #region layers
type Layer = {
id: string
name: string
kind: "frame" | "group" | "shape" | "text" | "image"
hidden?: boolean
locked?: boolean
children?: Layer[]
}
const LAYERS: Layer[] = [
{
id: "hero",
name: "hero",
kind: "frame",
children: [
{ id: "title", name: "title", kind: "text" },
{ id: "kicker", name: "kicker", kind: "text", hidden: true },
{
id: "card",
name: "card",
kind: "group",
children: [
{ id: "still", name: "still", kind: "image" },
{ id: "scrim", name: "scrim", kind: "shape", locked: true },
{ id: "caption", name: "caption", kind: "text" },
],
},
{ id: "ground", name: "ground", kind: "shape", locked: true },
],
},
{ id: "notes", name: "notes", kind: "frame", children: [] },
]
const LAYER_ICON = {
frame: ICONS.frame,
group: ICONS.group,
shape: ICONS.shape,
text: ICONS.text,
image: ICONS.image,
}
/** The forest with one layer changed. */
const patch = (
layers: Layer[],
id: string,
change: (l: Layer) => Layer,
): Layer[] =>
layers.map((l) =>
l.id === id
? change(l)
: l.children
? { ...l, children: patch(l.children, id, change) }
: l,
)
function Layers() {
const [layers, setLayers] = React.useState(LAYERS)
const [selected, setSelected] = React.useState<string[]>([])
const flip = (id: string, key: "hidden" | "locked") =>
setLayers((ls) => patch(ls, id, (l) => ({ ...l, [key]: !l[key] })))
return (
<div className={PANEL}>
<Tree
aria-label="layers"
items={layers}
label={(l) => l.name}
icon={(l) => {
const Icon = LAYER_ICON[l.kind]
return <Icon />
}}
dimmed={(l) => !!l.hidden}
actions={(l) => (
<>
<TreeAction
label={l.locked ? "unlock" : "lock"}
pinned={l.locked}
onClick={() => flip(l.id, "locked")}
>
{l.locked ? <ICONS.lock /> : <ICONS.unlock />}
</TreeAction>
<TreeAction
label={l.hidden ? "show" : "hide"}
pinned={l.hidden}
onClick={() => flip(l.id, "hidden")}
>
{l.hidden ? <ICONS.eyeOff /> : <ICONS.eye />}
</TreeAction>
</>
)}
// Figma's keys: ⌘⇧H hides, ⌘⇧L locks.
onRowKeyDown={(l, e) => {
if (!(e.metaKey || e.ctrlKey) || !e.shiftKey) return
const key = e.key.toLowerCase()
if (key === "h" || key === "l") {
e.preventDefault()
flip(l.id, key === "h" ? "hidden" : "locked")
}
}}
selection="multi"
selected={selected}
onSelectedChange={setSelected}
defaultExpanded={["hero", "card"]}
onRename={(l, name) =>
setLayers((ls) => patch(ls, l.id, (x) => ({ ...x, name })))
}
// Only frames and groups hold layers.
canDrop={(_, target, drop: Drop) =>
drop !== "inside" || target.children !== undefined
}
onMove={(ids, target, drop) =>
setLayers((ls) => moveNodes(ls, ids, target, drop))
}
guides
/>
</div>
)
}
// #endregion
// #region files
type Entry = {
id: string
name: string
folder?: boolean
children?: Entry[]
}
const folder = (id: string, name: string, children?: Entry[]): Entry => ({
id,
name,
folder: true,
children,
})
const file = (id: string, name: string): Entry => ({ id, name })
// A library big enough to need the window: 60 days of 80 shots, 4800 rows.
const KINDS = ["mov", "heic", "jpg", "dng", "m4a"]
const DAYS = Array.from({ length: 60 }, (_, d) => {
const day = `2026-${String(Math.floor(d / 28) + 7).padStart(2, "0")}-${String((d % 28) + 1).padStart(2, "0")}`
return folder(
`photos/${day}`,
day,
Array.from({ length: 80 }, (_, i) => {
const name = `img_${String(d * 80 + i).padStart(4, "0")}.${KINDS[(d + i) % KINDS.length]}`
return file(`photos/${day}/${name}`, name)
}),
)
})
const FILES: Entry[] = [
folder("site", "site", [
folder("site/app", "app", [
file("site/app/page.tsx", "page.tsx"),
file("site/app/globals.css", "globals.css"),
]),
file("site/package.json", "package.json"),
file("site/readme.md", "readme.md"),
file("site/brief.pdf", "brief.pdf"),
file("site/fonts.zip", "fonts.zip"),
file("site/budget.csv", "budget.csv"),
]),
folder("photos", "photos", DAYS),
// Children fetched on first open: no `children` yet, `folder` says it has some.
folder("share", "network share"),
]
const loadShare = (e: Entry): Promise<Entry[]> =>
new Promise((done) =>
setTimeout(
() =>
done([
file(`${e.id}/cut-v3.mov`, "cut-v3.mov"),
file(`${e.id}/score.wav`, "score.wav"),
file(`${e.id}/notes.txt`, "notes.txt"),
]),
900,
),
)
function Files() {
const tree = React.useRef<TreeHandle>(null)
const [filter, setFilter] = React.useState("")
const [density, setDensity] = React.useState<TreeDensity>("compact")
return (
<div className={cn(PANEL, "w-80")}>
<div className="flex items-center gap-1 px-1 pb-1">
<Input
size="sm"
aria-label="filter"
placeholder="filter"
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="min-w-0 flex-1"
/>
<Button
size="sm"
variant="ghost"
onClick={() => tree.current?.expandAll()}
>
open all
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => tree.current?.collapseAll()}
>
close
</Button>
<Button
size="sm"
variant="ghost"
onClick={() =>
setDensity((d) => (d === "compact" ? "comfortable" : "compact"))
}
>
{density === "compact" ? "roomy" : "dense"}
</Button>
</div>
<Tree
ref={tree}
aria-label="files"
className="h-80"
items={FILES}
label={(e) => e.name}
icon={(e, s) => (
<FileIcon name={e.name} folder={e.folder} open={s.expanded} />
)}
meta={(e) => e.children?.length}
isBranch={(e) => !!e.folder}
loadChildren={loadShare}
filter={filter}
remembered={OPEN}
selection="multi"
density={density}
guides
/>
</div>
)
}
// #endregion
// #region glass
function GlassLocations() {
const [selected, setSelected] = React.useState(["camera"])
return (
<div className="rounded-2xl bg-[url(/lightbox/peaks.jpg)] bg-cover bg-center p-8">
<div className="h-56 w-72">
<Tree
material="glass"
scheme="dark"
aria-label="locations"
items={PLACES}
label={(p) => p.name}
meta={(p) => COUNTS.get(p.id)}
dimmed={(p) => p.online === false}
disabled={(p) => p.online === false}
defaultExpanded={["iphone"]}
selected={selected}
onSelectedChange={setSelected}
className="h-full"
/>
</div>
</div>
)
}
// #endregion
export default function TreeDemo() {
return (
<div className="space-y-8">
<Sample
name="locations"
glance
title="locations"
with="locations"
description="every row on one grid: a leaf keeps its chevron gutter empty, so iphone, camera and archive disk start their icons at one x. counts roll up; the offline disk dims and cannot be chosen."
>
<Locations />
</Sample>
<Sample
name="layers"
title="layers, like figma"
with="layers"
description="hover for the eye and the lock (a hidden or locked layer keeps its mark), double-click or F2 to rename, drag to reorder or into a frame or group, cmd and shift to select many, ⌘⇧H hides."
>
<Layers />
</Sample>
<Sample
name="files"
title="files: filter, lazy, 4800 rows"
with="files"
description="only the rows in view are mounted. a filter keeps the ancestors of a match, opened. the network share loads on first open. what is open is remembered across reloads."
>
<Files />
</Sample>
<Sample
name="glass"
title="in glass"
with="locations glass"
description="the chosen row is a pane of glass; over a picture (scheme dark) the tree takes the dark theme's ink"
>
<GlassLocations />
</Sample>
</div>
)
}the source, every file an install writes
"use client"
// A TREE: files and places, a layers panel, a tag hierarchy, one component. The
// arithmetic is lib/tree-core (what is visible, filtered, counted, moved), the state
// over time ui/tree-state (`useTree`: expansion, selection, focus, the window, the
// keys); this file is the drawing and the hands.
//
// ONE GRID, every row: depth indent, a fixed chevron gutter (empty on a leaf, never
// absent), the icon column, the label, then the trailing meta. A level of depth is
// exactly the gutter plus its gap, so a child's chevron sits under its parent's icon
// and every row at one depth starts at one x. The numbers live in GRID below and reach
// the CSS as custom properties; nothing is measured.
//
// THE LIST IS FLAT AND WINDOWED: the visible tree is flattened into rows of one fixed
// height (the density's), absolutely placed, and only the rows in the scroller's view
// (plus an overscan) are mounted, so ten thousand rows cost what forty do. The focused
// row is always mounted, wherever it is, so focus is never lost to a scroll. The tree
// is role=tree over treeitems carrying aria-level/setsize/posinset (the flat form the
// pattern allows), with a roving tabindex: Tab enters at one row, arrows move.
//
// FOCUS IS SHOWN TO THE KEYBOARD ONLY: the ring is tokens' `:focus-visible` (with
// focus-source mounted, never on what a press focused), drawn just inside the row so
// the scroller cannot clip it. A click never rings a row.
//
// A LAYER adds a column at the row's end and what pressing it means: `layer`, a
// factory the tree hands its rows to each render (tree-marks' include and exclude
// marks, and their lens, are one). The tree knows a layer's column, its row press
// (a filter rail whose rows ARE its marks), its keys, its menu verbs, the word the
// row reads as (`TreeRowState.mark`) and its data on the row; nothing of what a mark
// means. RIGHT-CLICK, the ⋯ that appears on hover or focus, the context-menu key or
// ⇧F10 open the row's menu, anchored to the row: the layer's verbs, then `rowMenu`'s.
import { ChevronRight, Ellipsis, LoaderCircle } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
import { actsOn } from "@/registry/base-nova/lib/selection"
import {
ancestors,
canMove,
type Drop,
type Entry,
type Nodelike,
type Row,
} from "@/registry/base-nova/lib/tree-core"
import { ownedHere } from "@/registry/base-nova/ui/key-router"
import {
type Material,
type Scheme,
useMaterial,
useSurfaceScheme,
} from "@/registry/base-nova/ui/material"
import { glassVariants, schemeOf } from "@/registry/base-nova/ui/material-glass"
import { Menu, type MenuEntry } from "@/registry/base-nova/ui/menu"
import { Highlight } from "@/registry/base-nova/ui/search"
import {
type TreeHandle,
type TreeOptions,
type TreeSelection,
useTree,
} from "@/registry/base-nova/ui/tree-state"
import "./edge-fade"
export type { TreeHandle, TreeSelection }
export type TreeDensity = "compact" | "comfortable"
/** What a slot (icon, meta, actions) knows about the row it draws in. */
export interface TreeRowState {
depth: number
branch: boolean
expanded: boolean
selected: boolean
loading: boolean
/** the row matched the filter (its ancestors are shown but do not match) */
match: boolean
/** what the row reads as in the tree's layer (tree-marks: "include", "exclude",
* "mixed"), null without one or unmarked */
mark: string | null
}
/** What a layer is told of the tree each render: its rows and how to reach a node. */
export interface TreeApi<N extends Nodelike<N>> {
items: readonly N[]
rows: readonly Row<N>[]
rowAt: ReadonlyMap<string, number>
index: ReadonlyMap<string, Entry<N>>
childrenOf: (node: N) => readonly N[] | undefined
isDisabled: (node: N) => boolean
setActive: (id: string) => void
}
/** A column at the row's end and what pressing it means (see the header): what
* `layer` makes of the tree's rows each render. */
export interface TreeLayer<N extends Nodelike<N>> {
/** a press on the row is the layer's (a filter rail whose rows are its marks), not
* selection's: a click, Enter */
pressRows: boolean
/** the word the row reads as in the layer, handed to the slots as `mark` */
stateOf: (id: string) => string | null
/** data attributes on the row, for a consumer's styling (`data-mark`) */
rowData: (id: string) => Record<string, string | undefined>
/** classes on the label (an excluded row struck through) */
labelClass?: string
/** the column at the row's end, or null for a row without one */
column: (node: N, text: string) => React.ReactNode
/** a press on the row (with `pressRows`) or on its column, modifiers and all */
press: (e: React.MouseEvent | React.KeyboardEvent, node: N) => void
/** a key the layer answers on the focused row (Space, + and −): true takes it */
key: (e: React.KeyboardEvent, node: N) => boolean
/** its verbs, first in the row's menu; absent, it adds none (and alone opens no
* menu) */
menu?: (node: N, state: TreeRowState) => MenuEntry[]
}
export interface TreeProps<N extends Nodelike<N>>
extends Omit<TreeOptions<N>, "rowHeight"> {
/** the label column drawn as something richer than its text (a chip, a name and a
* badge); `label` stays the text searched, typed to and named */
content?: (node: N, state: TreeRowState) => React.ReactNode
/** the icon column; leave it out and the tree has none */
icon?: (node: N, state: TreeRowState) => React.ReactNode
/** trailing meta: a count, a badge, "offline · 4". Strings and numbers are set in
* the meta voice. */
meta?: (node: N, state: TreeRowState) => React.ReactNode
/** hover actions (TreeAction buttons); one marked `pinned` stays visible */
actions?: (node: N, state: TreeRowState) => React.ReactNode
/** a column at the row's end and what pressing it means, made of the rows each
* render (the gallery's include and exclude marks are one, `useTreeMarks`) */
layer?: (tree: TreeApi<N>) => TreeLayer<N>
/** trailing columns kept empty on a tree that has none, so its meta lines up
* with trees beside it that have them (one rail, one column system): `mark` the
* layer's column, `more` the ⋯ */
reserve?: readonly ("mark" | "more")[]
/** the row's menu after the layer's verbs: right-click, the ⋯, ⇧F10. `ids` is what
* its verbs act on: the selection, in row order, when the row is in it, else the
* row alone (lib/selection's `actsOn`, as every set's menu) */
rowMenu?: (
node: N,
state: TreeRowState,
ids: readonly string[],
) => MenuEntry[]
/** drag to reorder and reparent; apply it with lib/tree-core's moveNodes */
onMove?: (ids: string[], target: string, drop: Drop) => void
/** narrow where a drag may land (only groups take children, say) */
canDrop?: (ids: readonly string[], target: N, drop: Drop) => boolean
/** whether a row takes a dropped row inside it, the middle half of the row being
* "inside": by default a branch does and a leaf does not (a file is no folder);
* a tag tree says every tag can become a parent */
nestable?: (node: N) => boolean
/** before the row menu opens; preventDefault to keep it (and draw your own) */
onContextMenu?: (node: N, event: React.MouseEvent) => void
density?: TreeDensity
/** a hairline down each open level */
guides?: boolean
/** plain, rows lit in the foreground's tiers; glass, the chosen row a pane of the
* material's glass */
material?: Material
/** the colour scheme it stands in (material): default the nearest provider's;
* dark over a picture, its words and its chosen row's glass with it */
scheme?: Scheme
"aria-label"?: string
className?: string
ref?: React.Ref<TreeHandle>
}
/** The grid, in px. A level of depth is the gutter and its gap, so a child's chevron
* sits exactly under its parent's icon. */
const GRID = {
/** the row's own left padding, inside its hover fill */
pad: 6,
/** the chevron column, present on every row */
gutter: 16,
/** between the chevron and the icon */
gap: 2,
rows: { compact: 24, comfortable: 32 } satisfies Record<TreeDensity, number>,
}
const STEP = GRID.gutter + GRID.gap
/** The tree's own parts, whose keys it answers itself (focus walks its rows). */
const TREE_ROLES = ["tree", "treeitem", "group"]
/** How long a hovered, closed branch waits before a drag opens it. */
const SPRING_MS = 600
const ROW =
"group/row absolute inset-x-1 top-0 flex h-(--ag-tree-row) translate-y-(--ag-tree-y) cursor-default select-none items-center pr-2 pl-(--ag-tree-indent) text-foreground/80 focus-visible:-outline-offset-2 aria-selected:text-foreground data-dimmed:text-foreground/40 aria-disabled:hover:bg-transparent"
/** Plain: rows lit in the foreground's tiers. Glass: the chosen row is glass (its
* pane comes from the material), the others lit under the hand only. */
const ROW_FACE: Record<Material, string> = {
plain: "rounded-md hover:bg-foreground/5 aria-selected:bg-foreground/10",
glass:
"rounded-(--glass-radius) [--glass-radius:10px] hover:not-aria-selected:bg-foreground/8",
}
const META =
"shrink-0 font-mono text-[11px] text-foreground/45 tabular-nums group-data-dimmed/row:text-foreground/35"
/** The icon column. A glyph placed straight in it (a folder, a file, a disk) is
* sized here and dimmed as a whole (its opacity), never by a translucent colour: its
* strokes would each be painted at that alpha and double where they cross. A mark that
* brings its own size and colour in a wrapper of its own (a tag's, gallery-tag: its
* icon in its hue, a face) is its own, and the rule reaches only the glyph that is
* the column's child: a coloured mark dimmed to half was a mark that could not be read. */
const ICON_COL =
"ml-0.5 grid size-4 shrink-0 place-items-center text-foreground [&>svg]:size-3.5 [&>svg]:opacity-55 group-aria-selected/row:[&>svg]:opacity-80 group-data-dimmed/row:[&>svg]:opacity-35 group-data-dimmed/row:[&>:not(svg)]:opacity-45"
/** A hover action on a row (visibility, lock). Never a tab stop: the row is the stop,
* and the row's own keys (onRowKeyDown) are the keyboard's way to the same verb. */
export function TreeAction({
label,
pinned,
onClick,
children,
className,
}: {
label: string
/** stays visible off hover: a hidden layer's closed eye */
pinned?: boolean
onClick: () => void
children: React.ReactNode
className?: string
}) {
return (
<button
type="button"
tabIndex={-1}
aria-label={label}
data-slot="tree-action"
data-pinned={pinned || undefined}
onClick={(e) => {
e.stopPropagation()
onClick()
}}
onDoubleClick={(e) => e.stopPropagation()}
className={cn(
"grid size-5 place-items-center rounded-sm text-foreground hover:bg-foreground/10 [&_svg]:size-3.5 [&_svg]:opacity-45 hover:[&_svg]:opacity-100",
className,
)}
>
{children}
</button>
)
}
export function Tree<N extends Nodelike<N>>({
content,
icon,
meta,
actions,
layer,
reserve = [],
rowMenu,
onMove,
canDrop,
nestable,
onContextMenu,
density = "compact",
guides = false,
material,
scheme,
"aria-label": ariaLabel,
className,
ref,
...options
}: TreeProps<N>) {
const mat = useMaterial(material)
const height = GRID.rows[density]
const { items, label, onRename, onActivate } = options
const t = useTree({ ...options, rowHeight: height })
const { rows, rowAt, index, scroller, selectedSet, activeId, setActive } = t
// Over a picture it is read by the part of it under the tree; its row menu wears
// what it reads.
const [sch, here] = useSurfaceScheme(scheme, scroller)
React.useImperativeHandle(ref, () => t.handle)
// The layer, made of the rows as they stand this render.
const lay = layer?.({
items,
rows,
rowAt,
index,
childrenOf: t.childrenOf,
isDisabled: t.isDisabled,
setActive,
})
const [drag, setDrag] = React.useState<string[] | null>(null)
const [drop, setDrop] = React.useState<{ id: string; at: Drop } | null>(null)
const spring = React.useRef<{ id: string; timer: number } | null>(null)
// ── the row menu: one popup for the tree, anchored to whichever row asked.
const [menu, setMenu] = React.useState<{
id: string
anchor: Element
} | null>(null)
const rowEl = (id: string) =>
scroller.current?.querySelector<HTMLElement>(
`[data-id="${CSS.escape(id)}"]`,
) ?? null
const entriesFor = (n: N, state: TreeRowState): MenuEntry[] => {
const own = lay?.menu && !t.isDisabled(n) ? lay.menu(n, state) : []
const more = rowMenu?.(n, state, actsOn(t.order(), t.current(), n.id)) ?? []
return own.length && more.length
? [...own, "separator", ...more]
: [...own, ...more]
}
const hasMenu = !!lay?.menu || !!rowMenu
const openMenu = (id: string, anchor: Element) => {
setActive(id)
setMenu({ id, anchor })
}
// ── keys: the row's own, the arrows, then the menu's and the layer's, then the
// tree's own (ui/tree-state).
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
// A rename in progress, a field, a row's own control (an action's button) and a
// press from a portal (the row menu) keep their keys (key-router's `ownedHere`).
if (t.editing !== null || !e.currentTarget.contains(e.target as Node))
return
if (ownedHere(e.nativeEvent, e.currentTarget, TREE_ROLES)) return
t.keys(e, (e, row) => {
if (e.key === "ContextMenu" || (e.shiftKey && e.key === "F10")) {
e.preventDefault()
const el = rowEl(row.id)
if (hasMenu && el) openMenu(row.id, el)
return true
}
if (!lay) return false
if (e.key === "Enter" && lay.pressRows) {
e.preventDefault()
lay.press(e, row.node)
return true
}
return lay.key(e, row.node)
})
}
// ── drag.
const dropAt = (
e: React.DragEvent<HTMLElement>,
i: number,
): { id: string; at: Drop } | null => {
const row = rows[i]
if (!drag || !row) return null
const r = e.currentTarget.getBoundingClientRect()
const y = (e.clientY - r.top) / r.height
const container =
nestable?.(row.node) ??
(t.branchOf(row.node) || row.node.children !== undefined)
let at: Drop = container
? y < 0.25
? "before"
: y > 0.75
? "after"
: "inside"
: y < 0.5
? "before"
: "after"
let id = row.id
// Below an open branch's middle is its first child's top, where the eye puts it.
if (at === "after" && row.expanded && rows[i + 1]?.parent === row.id) {
at = "before"
id = (rows[i + 1] as typeof row).id
}
const target = (index.get(id) as { node: N }).node
if (!canMove(index, drag, id, at)) return null
if (canDrop && !canDrop(drag, target, at)) return null
return { id, at }
}
// What a drag carries: the rows picked up and everything under them, drawn faint
// where they stand, so it is plain the branch goes whole.
const carried = React.useMemo(() => {
if (!drag) return null
const out = new Set<string>()
for (const r of rows)
if (
drag.includes(r.id) ||
ancestors(index, r.id).some((a) => drag.includes(a))
)
out.add(r.id)
return out
}, [drag, rows, index])
/** How many nodes sit under these, at every depth. */
const countUnder = (ids: readonly string[]) => {
let n = 0
const walk = (node: N) => {
for (const k of t.childrenOf(node) ?? []) {
n++
walk(k)
}
}
for (const id of ids) {
const e = index.get(id)
if (e) walk(e.node)
}
return n
}
/** Where a drop lands, in words: "into birds", "in animals, after gull", "at the
* top, before a face". */
const landing = (d: { id: string; at: Drop }) => {
const target = index.get(d.id)
if (!target) return ""
if (d.at === "inside") return `into ${label(target.node)}`
const parent = target.parent ? index.get(target.parent) : undefined
return `${parent ? `in ${label(parent.node)}` : "at the top"}, ${d.at} ${label(target.node)}`
}
const clearSpring = () => {
if (spring.current) clearTimeout(spring.current.timer)
spring.current = null
}
const endDrag = () => {
clearSpring()
setDrag(null)
setDrop(null)
}
// ── draw.
const stateOf = (row: Row<N>): TreeRowState => ({
depth: row.depth,
branch: row.branch,
expanded: row.expanded,
selected: selectedSet.has(row.id),
loading: t.loading.has(row.id),
match: t.matches?.has(row.id) ?? false,
mark: lay?.stateOf(row.id) ?? null,
})
const menuRow = menu ? rows[rowAt.get(menu.id) ?? -1] : undefined
const menuEntries = menuRow ? entriesFor(menuRow.node, stateOf(menuRow)) : []
return (
<div
ref={here}
role="tree"
aria-label={ariaLabel}
aria-multiselectable={t.selection === "multi" || undefined}
data-slot="tree"
data-density={density}
data-material={mat}
onKeyDown={onKeyDown}
onScroll={t.onScroll}
className={cn(
// Scrolls both ways, its cut edges fading where there is more (scrims'
// `ag-edge-fade-xy`).
"ag-edge-fade-xy relative min-h-0 text-[13px] data-[density=comfortable]:text-sm",
// In its scheme: dark over a picture, its words take the dark theme's ink.
schemeOf(sch),
sch === "dark" && "text-foreground",
className,
)}
style={
{
"--ag-tree-row": `${height}px`,
"--ag-tree-height": `${rows.length * height}px`,
} as React.CSSProperties
}
>
<div className="relative h-(--ag-tree-height)">
{t.drawn.map((i) => {
const row = rows[i] as Row<N>
const n = row.node
const isLoading = t.loading.has(row.id)
const off = t.isDisabled(n)
const state = stateOf(row)
const indent = GRID.pad + row.depth * STEP
const metaNode = meta?.(n, state)
const text = label(n)
const dropHere = drop?.id === row.id ? drop.at : null
const column = lay?.column(n, text) ?? null
return (
// biome-ignore lint/a11y/useKeyWithClickEvents: the tree's onKeyDown is every row's keyboard
<div
key={row.id}
role="treeitem"
data-slot="tree-row"
data-id={row.id}
data-dimmed={t.isDimmed(n) || undefined}
{...lay?.rowData(row.id)}
data-menu-open={menu?.id === row.id || undefined}
data-drop={dropHere ?? undefined}
aria-level={row.depth + 1}
aria-setsize={row.size}
aria-posinset={row.index}
aria-expanded={row.branch ? row.expanded : undefined}
aria-selected={
t.selection === "none" ? undefined : state.selected
}
aria-disabled={off || undefined}
aria-busy={isLoading || undefined}
tabIndex={row.id === activeId ? 0 : -1}
draggable={!!onMove && !off && t.editing !== row.id}
className={cn(
// Glass: the row chosen is a pane of the material's glass, the
// others lit in the text's colour under the hand.
mat === "glass" &&
state.selected &&
glassVariants({ shape: "surface" }),
ROW,
ROW_FACE[mat],
// Inside: the row it goes into, lit as a target, unmistakable from
// the before and after lines.
dropHere === "inside" &&
"bg-tone-info/15 text-foreground ring-1 ring-tone-info/70 ring-inset",
carried?.has(row.id) && "opacity-40",
)}
style={
{
"--ag-tree-y": `${i * height}px`,
"--ag-tree-indent": `${indent}px`,
} as React.CSSProperties
}
onFocus={() => {
if (activeId !== row.id) setActive(row.id)
}}
onClick={(e) => {
setActive(row.id)
if (off) return
// A drag that selected text (rows whose words are text to read,
// inspect's) ends in a click: it is the reader's, not a press.
if (!window.getSelection()?.isCollapsed) return
if (lay?.pressRows) {
lay.press(e, n)
return
}
t.select(
row.id,
e.shiftKey
? "range"
: e.metaKey || e.ctrlKey
? "toggle"
: "replace",
e.metaKey || e.ctrlKey,
)
if (!e.shiftKey && !e.metaKey && !e.ctrlKey) onActivate?.(n)
}}
onDoubleClick={(e) => {
const onLabel = (e.target as HTMLElement).closest(
"[data-slot=tree-label]",
)
if (onRename && onLabel) t.startRename(n)
else if (row.branch) t.toggle(row.id)
}}
onContextMenu={(e) => {
onContextMenu?.(n, e)
if (e.defaultPrevented || !hasMenu) return
e.preventDefault()
openMenu(row.id, e.currentTarget)
}}
onDragStart={(e) => {
const ids = selectedSet.has(row.id)
? rows.map((r) => r.id).filter((id) => selectedSet.has(id))
: [row.id]
setDrag(ids)
e.dataTransfer.effectAllowed = "move"
e.dataTransfer.setData("text/plain", ids.join("\n"))
// The pointer carries a chip naming what moves, the branch counted:
// the browser's own ghost is a faint copy of one row.
const under = countUnder(ids)
const ghost = document.createElement("div")
ghost.textContent = `${ids.length > 1 ? `${ids.length} items` : text}${under ? ` + ${under} under ${ids.length > 1 ? "them" : "it"}` : ""}`
Object.assign(ghost.style, {
position: "fixed",
top: "-1000px",
left: "0",
padding: "4px 10px",
borderRadius: "8px",
font: "500 12px var(--font-sans, system-ui)",
background: "rgb(28 28 32 / 0.96)",
color: "white",
border: "1px solid rgb(255 255 255 / 0.14)",
boxShadow: "0 8px 24px rgb(0 0 0 / 0.5)",
whiteSpace: "nowrap",
})
document.body.append(ghost)
e.dataTransfer.setDragImage(ghost, 14, 14)
requestAnimationFrame(() => ghost.remove())
}}
onDragOver={(e) => {
const d = dropAt(e, i)
if (!d) {
if (drop?.id === row.id) setDrop(null)
return
}
e.preventDefault()
e.dataTransfer.dropEffect = "move"
if (drop?.id !== d.id || drop.at !== d.at) setDrop(d)
if (d.at === "inside" && row.branch && !row.expanded) {
if (spring.current?.id !== row.id) {
clearSpring()
spring.current = {
id: row.id,
timer: window.setTimeout(() => t.open(row.id), SPRING_MS),
}
}
} else if (spring.current?.id === row.id) clearSpring()
}}
onDrop={(e) => {
e.preventDefault()
if (drag && drop) {
onMove?.(drag, drop.id, drop.at)
// What was put inside is shown there: the new parent opens.
if (drop.at === "inside") t.open(drop.id)
}
endDrag()
}}
onDragEnd={endDrag}
>
{guides &&
Array.from({ length: row.depth }, (_, d) => (
<span
// biome-ignore lint/suspicious/noArrayIndexKey: a guide is its level
key={d}
aria-hidden
data-slot="tree-guide"
className="absolute inset-y-0 left-(--ag-tree-guide) w-px bg-foreground/10"
style={
{
"--ag-tree-guide": `${GRID.pad + d * STEP + GRID.gutter / 2}px`,
} as React.CSSProperties
}
/>
))}
<span
aria-hidden
data-slot="tree-chevron"
className="grid size-4 shrink-0 place-items-center text-foreground/40 hover:text-foreground"
onClick={(e) => {
if (!row.branch) return
e.stopPropagation()
setActive(row.id)
t.toggle(row.id)
}}
onDoubleClick={(e) => e.stopPropagation()}
>
{isLoading ? (
<LoaderCircle className="size-3 animate-spin motion-reduce:animate-none" />
) : row.branch ? (
<ChevronRight
className={cn(
"size-3 transition-transform duration-150 ease-out motion-reduce:transition-none",
row.expanded && "rotate-90",
)}
/>
) : null}
</span>
{icon && (
<span aria-hidden data-slot="tree-icon" className={ICON_COL}>
{icon(n, state)}
</span>
)}
{t.editing === row.id ? (
<input
data-slot="tree-input"
aria-label={`rename ${text}`}
defaultValue={text}
// biome-ignore lint/a11y/noAutofocus: rename was asked for, the field is where the keys go
autoFocus
onFocus={(e) => e.currentTarget.select()}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
e.stopPropagation()
if (e.key === "Enter") t.endRename(n, e.currentTarget.value)
else if (e.key === "Escape") t.endRename(n, null)
}}
onBlur={(e) => t.endRename(n, e.currentTarget.value)}
className="ml-1 h-5 min-w-0 flex-1 rounded-sm bg-background px-1 text-foreground outline-none ring-1 ring-foreground/30"
/>
) : (
<span
data-slot="tree-label"
className={cn(
"ml-1.5 min-w-0 flex-1 truncate",
lay?.labelClass,
)}
>
{content ? (
content(n, state)
) : state.match ? (
<Highlight text={text} query={t.query} />
) : (
text
)}
</span>
)}
{actions && (
<span
data-slot="tree-actions"
className="ml-1 flex shrink-0 items-center gap-0.5 group-hover/row:[&>*]:opacity-100 group-focus-visible/row:[&>*]:opacity-100 [&>*]:opacity-0 [&>[data-pinned]]:opacity-100"
>
{actions(n, state)}
</span>
)}
{metaNode !== undefined && metaNode !== null && (
<span data-slot="tree-meta" className={cn("ml-2", META)}>
{metaNode}
</span>
)}
{column}
{column === null && reserve.includes("mark") && (
<span aria-hidden className="ml-1.5 size-5 shrink-0" />
)}
{!hasMenu && reserve.includes("more") && (
<span aria-hidden className="-mr-1 ml-0.5 size-5 shrink-0" />
)}
{hasMenu && (
<button
type="button"
tabIndex={-1}
aria-label={`${text}: more`}
aria-haspopup="menu"
data-slot="tree-more"
onClick={(e) => {
e.stopPropagation()
openMenu(
row.id,
e.currentTarget.closest("[role=treeitem]") as Element,
)
}}
onDoubleClick={(e) => e.stopPropagation()}
className="-mr-1 ml-0.5 grid size-5 shrink-0 place-items-center rounded-sm text-foreground/45 opacity-0 hover:bg-foreground/10 hover:text-foreground group-hover/row:opacity-100 group-focus-visible/row:opacity-100 group-data-menu-open/row:opacity-100"
>
<Ellipsis className="size-3.5" />
</button>
)}
{dropHere && (
// Where it lands, said: the line or the lit row shows the place,
// the words name it.
<span
aria-hidden
data-slot="tree-landing"
className={cn(
"pointer-events-none absolute right-2 z-10 rounded-md bg-tone-info px-1.5 py-0.5 font-medium text-[10px] text-white leading-none shadow-md",
dropHere === "before"
? "-top-2.5"
: dropHere === "after"
? "-bottom-2.5"
: "top-1/2 -translate-y-1/2",
)}
>
{landing({ id: row.id, at: dropHere })}
</span>
)}
{(dropHere === "before" || dropHere === "after") && (
<span
aria-hidden
data-slot="tree-drop"
className={cn(
"pointer-events-none absolute right-1 left-(--ag-tree-drop) h-0.5 rounded-full bg-foreground",
dropHere === "before" ? "-top-px" : "-bottom-px",
)}
style={
{
"--ag-tree-drop": `${indent + GRID.gutter}px`,
} as React.CSSProperties
}
/>
)}
</div>
)
})}
</div>
{hasMenu && (
<Menu
entries={menuEntries}
anchor={menu?.anchor ?? null}
onClose={() => setMenu(null)}
returnTo={() => (menu ? rowEl(menu.id) : null)}
material={mat}
scheme={sch}
/>
)}
</div>
)
}"use client"
// THE TREE, HEADLESS: everything a tree knows and does except how it is drawn. The
// arithmetic is lib/tree-core (what is visible, filtered, where a key goes); this is
// its state over time: expansion (controlled or not, remembered as a setting says),
// lazy children and who is loading, the filter and the branches a person shut while
// it was on, selection (lib/selection, the way every set selects), the roving focus
// (the one row that is the tab stop), the window of rows mounted, rename in place, and
// the keys of the WAI-ARIA tree pattern. ui/tree draws a row grid on it; a list of
// another shape can draw on it too.
import * as React from "react"
import { fuzzy } from "@/registry/base-nova/lib/fuzzy"
import type { Setting } from "@/registry/base-nova/lib/remembered-core"
import {
all,
NONE,
only,
range,
type Selection as SelectionState,
toggle as toggleIn,
} from "@/registry/base-nova/lib/selection"
import {
ancestors,
branchIds,
type Entry,
filterTree,
flatten,
indexTree,
type Nodelike,
navigate,
type Row,
typeahead,
} from "@/registry/base-nova/lib/tree-core"
import { recall, remember } from "@/registry/base-nova/ui/remembered"
export type TreeSelection = "single" | "multi" | "none"
export interface TreeHandle {
expandAll: () => void
collapseAll: () => void
/** move keyboard focus to a row, scrolling it into view */
focus: (id: string) => void
}
export interface TreeOptions<N extends Nodelike<N>> {
items: readonly N[]
/** the row's text: searched, typed ahead to, renamed */
label: (node: N) => string
/** drawn faint (an offline disk), and so is everything under it */
dimmed?: (node: N) => boolean
/** cannot be selected, activated or renamed, nor can anything under it; still
* reachable by keys */
disabled?: (node: N) => boolean
expanded?: readonly string[]
defaultExpanded?: readonly string[]
onExpandedChange?: (expanded: string[]) => void
/** the expansion kept as this setting keeps it (ui/remembered; uncontrolled only) */
remembered?: Setting<readonly string[]>
/** single (selection follows focus), multi (lib/selection: ⌘ toggles, ⇧ a range in
* row order, ⌘⇧ adds one, ⌘A all, Esc none), or none */
selection?: TreeSelection
selected?: readonly string[]
defaultSelected?: readonly string[]
onSelectedChange?: (selected: string[]) => void
/** a click, or Enter */
onActivate?: (node: N) => void
/** children fetched on first expand; pair with isBranch, which says who has them */
loadChildren?: (node: N) => Promise<readonly N[]>
/** has (or can load) children. Default: a non-empty children list */
isBranch?: (node: N) => boolean
/** a search: matches and their ancestors stay, opened */
filter?: string
/** default: the query found in the label, fuzzily (lib/fuzzy) */
match?: (node: N, query: string) => boolean
/** rename in place: F2 (and what draws the tree, a double-click on the label) */
onRename?: (node: N, name: string) => void
/** a row's own keys, before the tree's; preventDefault to keep the tree out */
onRowKeyDown?: (node: N, event: React.KeyboardEvent) => void
/** px per row: every row is one height, so the window is arithmetic */
rowHeight: number
}
/** Rows drawn before the scroller has been measured (the server's render). */
const FIRST_VIEW = 600
const OVERSCAN = 8
/** An id list, controlled when `value` is given, else its own state; `set` reports
* either way. */
function useIds(
value: readonly string[] | undefined,
initial: readonly string[],
onChange?: (next: string[]) => void,
): [readonly string[], (next: string[]) => void] {
const [own, setOwn] = React.useState(initial)
const set = (next: string[]) => {
if (value === undefined) setOwn(next)
onChange?.(next)
}
return [value ?? own, set]
}
/** A tree's state and verbs (see the header). `keys` answers a press on the tree:
* `local` is asked after the row's own keys and the arrows, before the tree's own
* (Enter, Space, F2, ⌘A, Esc, type-ahead), and ends it by returning true. */
export function useTree<N extends Nodelike<N>>({
items,
label,
dimmed,
disabled,
expanded: expandedProp,
defaultExpanded = [],
onExpandedChange,
remembered,
selection = "single",
selected: selectedProp,
defaultSelected = [],
onSelectedChange,
onActivate,
loadChildren,
isBranch,
filter = "",
match,
onRename,
onRowKeyDown,
rowHeight: height,
}: TreeOptions<N>) {
const scroller = React.useRef<HTMLDivElement>(null)
// ── expansion, controlled or not, remembered as its setting says.
const [expandedList, setExpandedIds] = useIds(
expandedProp,
defaultExpanded,
onExpandedChange,
)
// Remembered by writing on every change a person makes and reading once after
// mount (the server has no storage, and the first render must match it). Never an
// effect that writes: it would store the default before the read had landed.
const stored = remembered && !expandedProp ? remembered : null
const setExpanded = (next: string[]) => {
setExpandedIds(next)
if (stored) remember(stored, next)
}
// biome-ignore lint/correctness/useExhaustiveDependencies: mount only
React.useEffect(() => {
if (stored) setExpandedIds([...recall(stored)])
}, [])
// ── lazy children: what a loader brought, and who is loading.
const [loaded, setLoaded] = React.useState<ReadonlyMap<string, readonly N[]>>(
() => new Map(),
)
const [loading, setLoading] = React.useState<ReadonlySet<string>>(
() => new Set(),
)
const childrenOf = React.useCallback(
(n: N) => n.children ?? loaded.get(n.id),
[loaded],
)
const branchOf = React.useCallback(
(n: N) => (isBranch ? isBranch(n) : (childrenOf(n)?.length ?? 0) > 0),
[isBranch, childrenOf],
)
const index = React.useMemo(
() => indexTree(items, childrenOf),
[items, childrenOf],
)
// ── filter: matches and their ancestors stay, the ancestors open. A branch closed
// by hand while filtering stays closed until the query changes.
const query = filter.trim()
const found = React.useMemo(() => {
if (!query) return null
const test = match
? (n: N) => match(n, query)
: (n: N) => fuzzy(query, label(n)) !== null
return filterTree(items, childrenOf, test)
}, [items, childrenOf, query, match, label])
const [shut, setShut] = React.useState<{
query: string
ids: ReadonlySet<string>
}>({ query, ids: new Set() })
if (shut.query !== query) setShut({ query, ids: new Set() })
const expandedSet = React.useMemo(() => {
const s = new Set(expandedList)
if (found) for (const id of found.open) if (!shut.ids.has(id)) s.add(id)
return s
}, [expandedList, found, shut.ids])
const rows = React.useMemo(
() =>
flatten(items, {
expanded: expandedSet,
children: childrenOf,
branch: branchOf,
keep: found?.keep,
}),
[items, expandedSet, childrenOf, branchOf, found],
)
const rowAt = React.useMemo(
() => new Map(rows.map((r, i) => [r.id, i])),
[rows],
)
// ── selection.
const [selectedList, setSelected] = useIds(
selectedProp,
defaultSelected,
onSelectedChange,
)
const selectedSet = React.useMemo(
() => new Set(selection === "none" ? [] : selectedList),
[selectedList, selection],
)
const anchor = React.useRef<string | null>(null)
// ── the roving focus: the one row that is the tab stop.
const [active, setActive] = React.useState<string | null>(null)
const activeId =
active !== null && rowAt.has(active)
? active
: (selectedList.find((id) => rowAt.has(id)) ?? rows[0]?.id ?? null)
const pendingFocus = React.useRef<string | null>(null)
// ── the window.
const [view, setView] = React.useState({ top: 0, height: FIRST_VIEW })
React.useLayoutEffect(() => {
const el = scroller.current as HTMLDivElement
const measure = () =>
setView({ top: el.scrollTop, height: el.clientHeight })
measure()
const ro = new ResizeObserver(measure)
ro.observe(el)
return () => ro.disconnect()
}, [])
const onScroll = (e: React.UIEvent<HTMLElement>) =>
setView({
top: e.currentTarget.scrollTop,
height: e.currentTarget.clientHeight,
})
React.useLayoutEffect(() => {
const id = pendingFocus.current
if (id === null) return
pendingFocus.current = null
const el = scroller.current?.querySelector<HTMLElement>(
`[data-id="${CSS.escape(id)}"]`,
)
el?.focus({ preventScroll: true })
})
const [editing, setEditing] = React.useState<string | null>(null)
// ── verbs.
/** Fetch a lazy branch's children the first time it opens. */
const load = (id: string) => {
const n = (index.get(id) as { node: N }).node
if (loadChildren && !childrenOf(n) && !loading.has(id)) {
setLoading((s) => new Set(s).add(id))
loadChildren(n)
.then((kids) => setLoaded((m) => new Map(m).set(id, kids)))
.finally(() =>
setLoading((s) => {
const next = new Set(s)
next.delete(id)
return next
}),
)
}
}
/** Open branches: one gesture, one state change, whatever the count. */
const open = (...ids: string[]) => {
const next = ids.filter((id) => {
const entry = index.get(id)
return entry !== undefined && branchOf(entry.node)
})
setExpanded([...new Set([...expandedList, ...next])])
if (next.some((id) => shut.ids.has(id)))
setShut({
query,
ids: new Set([...shut.ids].filter((x) => !next.includes(x))),
})
for (const id of next) load(id)
}
const close = (id: string) => {
setExpanded(expandedList.filter((x) => x !== id))
if (found?.open.has(id)) setShut({ query, ids: new Set(shut.ids).add(id) })
}
const toggle = (id: string) => (expandedSet.has(id) ? close(id) : open(id))
// A state a node has, its whole subtree has: what is under an offline disk is
// offline too, so `disabled` and `dimmed` are asked of a node and its ancestors.
const inherited = (own?: (n: N) => boolean) => (n: N) =>
!!own &&
(own(n) ||
ancestors(index, n.id).some((a) => {
const up = index.get(a)
return up !== undefined && own(up.node)
}))
const isDisabled = inherited(disabled)
const isDimmed = inherited(dimmed)
const focusRow = (i: number) => {
const row = rows[i]
const el = scroller.current
if (!row || !el) return
const top = i * height
if (top < el.scrollTop) el.scrollTop = top
else if (top + height > el.scrollTop + el.clientHeight)
el.scrollTop = top + height - el.clientHeight
setActive(row.id)
pendingFocus.current = row.id
}
// The set's order for the kernel (lib/selection): the rows shown, top to bottom,
// that can be selected. A range runs in it, ⌘A takes it.
const order = () => rows.filter((r) => !isDisabled(r.node)).map((r) => r.id)
const current = (): SelectionState => ({
ids: selectedSet,
anchor: anchor.current ?? undefined,
})
const choose = (next: SelectionState) => {
anchor.current = next.anchor ?? null
setSelected([...next.ids])
}
/** A press or a key on a row, as every set selects (lib/selection): alone, ⌘ in or
* out, ⇧ a range from the last pressed (⌘⇧ adding it). Single selection: alone. */
const select = (
id: string,
how: "replace" | "toggle" | "range",
keep = false,
) => {
if (selection === "none") return
const entry = index.get(id)
if (!entry || isDisabled(entry.node)) return
choose(
selection === "single" || how === "replace"
? only(id)
: how === "toggle"
? toggleIn(current(), id)
: range(order(), current(), id, keep),
)
}
const activate = (n: N) => {
if (isDisabled(n)) return
if (onActivate) onActivate(n)
else if (branchOf(n)) toggle(n.id)
}
const startRename = (n: N) => {
if (onRename && !isDisabled(n)) setEditing(n.id)
}
const endRename = (n: N, name: string | null) => {
setEditing(null)
pendingFocus.current = n.id
const next = name?.trim()
if (next && next !== label(n)) onRename?.(n, next)
}
const handle: TreeHandle = {
expandAll: () => setExpanded(branchIds(items, childrenOf)),
collapseAll: () => setExpanded([]),
focus: (id: string) => {
const i = rowAt.get(id)
if (i !== undefined) focusRow(i)
},
}
// ── keys.
const typed = React.useRef({ text: "", at: 0 })
/** The tree's keys, for the row in focus; see the hook's comment for `local`. */
const keys = (
e: React.KeyboardEvent<HTMLElement>,
local?: (e: React.KeyboardEvent<HTMLElement>, row: Row<N>) => boolean,
) => {
const i = activeId === null ? -1 : (rowAt.get(activeId) ?? -1)
const row = rows[i]
if (row && onRowKeyDown) {
onRowKeyDown(row.node, e)
if (e.defaultPrevented) return
}
const mod = e.metaKey || e.ctrlKey || e.altKey
const page = Math.max(1, Math.floor(view.height / height) - 1)
const nav = mod ? null : navigate(rows, i, e.key, page)
if (nav) {
e.preventDefault()
if ("focus" in nav) {
const to = rows[nav.focus] as Row<N>
focusRow(nav.focus)
if (selection === "single") select(to.id, "replace")
else if (selection === "multi" && e.shiftKey) select(to.id, "range")
} else if ("expand" in nav) open(nav.expand)
else if ("collapse" in nav) close(nav.collapse)
else
open(
...rows
.filter((r) => r.parent === nav.expandSiblings && r.branch)
.map((r) => r.id),
)
return
}
if (!row) return
if (local?.(e, row)) return
if (e.key === "Enter") {
e.preventDefault()
select(row.id, "replace")
activate(row.node)
} else if (e.key === " ") {
e.preventDefault()
select(row.id, selection === "multi" ? "toggle" : "replace")
} else if (e.key === "F2") {
e.preventDefault()
startRename(row.node)
} else if (
selection === "multi" &&
(e.metaKey || e.ctrlKey) &&
e.key.toLowerCase() === "a"
) {
e.preventDefault()
choose(all(order(), current()))
} else if (
selection === "multi" &&
e.key === "Escape" &&
selectedSet.size > 0
) {
e.preventDefault()
choose({ ...NONE, anchor: anchor.current ?? undefined })
} else if (e.key.length === 1 && !mod && /\S/.test(e.key)) {
e.preventDefault()
const now = e.timeStamp
const t = typed.current
t.text = now - t.at > 600 ? e.key : t.text + e.key
t.at = now
// One letter pressed again cycles through the rows starting with it.
const same = [...t.text].every((c) => c === t.text[0])
const q = same ? e.key : t.text
const hit = typeahead(rows, same ? i : i - 1, q, label)
if (hit >= 0) {
focusRow(hit)
if (selection === "single") select((rows[hit] as Row<N>).id, "replace")
}
}
}
// ── the rows mounted: the scroller's view and an overscan, and the focused row
// wherever it is, so focus is never lost to a scroll.
const first = Math.max(0, Math.floor(view.top / height) - OVERSCAN)
const last = Math.min(
rows.length - 1,
Math.ceil((view.top + view.height) / height) + OVERSCAN,
)
const drawn: number[] = []
for (let i = first; i <= last; i++) drawn.push(i)
const activeAt = activeId === null ? undefined : rowAt.get(activeId)
if (activeAt !== undefined && (activeAt < first || activeAt > last))
drawn.push(activeAt)
return {
scroller,
rows,
rowAt,
index: index as ReadonlyMap<string, Entry<N>>,
childrenOf,
branchOf,
query,
/** the filter's matches (their ancestors are shown but do not match) */
matches: found?.matches ?? null,
selection,
selectedSet,
activeId,
setActive,
loading,
editing,
drawn,
onScroll,
open,
close,
toggle,
select,
activate,
focusRow,
order,
current,
isDisabled,
isDimmed,
startRename,
endRename,
keys,
handle,
}
}
export type TreeState<N extends Nodelike<N>> = ReturnType<typeof useTree<N>>// THE TREE'S ARITHMETIC, framework-free: a forest of nodes in, the rows a list draws
// out, plus every question a tree UI asks of its data (what is visible, what a filter
// keeps, what a count sums to, where a drag lands, what a key does). ui/tree draws on
// it; anything else can too (a server rendering a sidebar, a test). Marks on a tree
// (include, exclude, the lens) are lib/tree-marks-core's, a layer over this.
//
// A NODE is any object with an `id` and optional `children`; nothing else is read
// here. Children are always asked through a `Children` function rather than read off
// the node, so a lazy tree (children fetched on expand, kept beside the data) and an
// eager one go through the same arithmetic. Every function is pure: nothing mutates
// its input, and a move returns a new forest.
export type Nodelike<N> = { id: string; children?: readonly N[] }
/** The children a node has right now: its own, or what a loader brought. */
export type Children<N> = (node: N) => readonly N[] | undefined
export const ownChildren = <N extends Nodelike<N>>(node: N) => node.children
/** One drawn row: a node at its place in the flattened, visible tree. */
export interface Row<N> {
node: N
id: string
/** 0 for a root */
depth: number
parent: string | null
/** 1-based place among its visible siblings (aria-posinset) */
index: number
/** how many visible siblings, itself included (aria-setsize) */
size: number
branch: boolean
expanded: boolean
}
/** The rows a list draws, in order: every root, and the children of every expanded
* branch. `keep`, when given, is the only ids drawn (a filter), and it is also what
* siblings are counted over, so posinset and setsize describe what is on screen. */
export function flatten<N extends Nodelike<N>>(
roots: readonly N[],
opts: {
expanded: ReadonlySet<string>
children: Children<N>
branch: (node: N) => boolean
keep?: ReadonlySet<string>
},
): Row<N>[] {
const { expanded, children, branch, keep } = opts
const out: Row<N>[] = []
const walk = (nodes: readonly N[], depth: number, parent: string | null) => {
const shown = keep ? nodes.filter((n) => keep.has(n.id)) : nodes
shown.forEach((node, i) => {
const isBranch = branch(node)
const open = isBranch && expanded.has(node.id)
out.push({
node,
id: node.id,
depth,
parent,
index: i + 1,
size: shown.length,
branch: isBranch,
expanded: open,
})
const kids = open ? children(node) : undefined
if (kids) walk(kids, depth + 1, node.id)
})
}
walk(roots, 0, null)
return out
}
/** Where every known node sits: its parent and depth, by id. */
export interface Entry<N> {
node: N
parent: string | null
depth: number
}
export function indexTree<N extends Nodelike<N>>(
roots: readonly N[],
children: Children<N>,
): Map<string, Entry<N>> {
const out = new Map<string, Entry<N>>()
const walk = (nodes: readonly N[], depth: number, parent: string | null) => {
for (const node of nodes) {
if (out.has(node.id)) throw new Error(`tree: duplicate id ${node.id}`)
out.set(node.id, { node, parent, depth })
const kids = children(node)
if (kids) walk(kids, depth + 1, node.id)
}
}
walk(roots, 0, null)
return out
}
/** The ids above a node, nearest first. */
export function ancestors<N>(
index: ReadonlyMap<string, Entry<N>>,
id: string,
): string[] {
const out: string[] = []
for (
let p = index.get(id)?.parent ?? null;
p;
p = index.get(p)?.parent ?? null
)
out.push(p)
return out
}
/** Every known id under a node, not the node itself. */
export function descendants<N extends Nodelike<N>>(
node: N,
children: Children<N>,
): string[] {
const out: string[] = []
const walk = (n: N) => {
for (const k of children(n) ?? []) {
out.push(k.id)
walk(k)
}
}
walk(node)
return out
}
/** Every id in tree order (preorder), the order a lens or a range is read in. */
export function preorder<N extends Nodelike<N>>(
roots: readonly N[],
children: Children<N>,
): string[] {
const out: string[] = []
const walk = (nodes: readonly N[]) => {
for (const n of nodes) {
out.push(n.id)
walk(children(n) ?? [])
}
}
walk(roots)
return out
}
/** Every known node with children, `depth` levels down from the roots (all of them
* when absent): what "expand all" opens, or what a tree opens on its first look (1:
* the roots open on their children, everything deeper folded until asked for). */
export function branchIds<N extends Nodelike<N>>(
roots: readonly N[],
children: Children<N>,
depth = Number.POSITIVE_INFINITY,
): string[] {
const out: string[] = []
const walk = (nodes: readonly N[], level: number) => {
if (level > depth) return
for (const n of nodes) {
const kids = children(n)
if (kids?.length) {
out.push(n.id)
walk(kids, level + 1)
}
}
}
walk(roots, 1)
return out
}
// ── filter
/** What a search keeps: the matches, every ancestor of one (so a match is reached
* the way it lives), and every descendant of one (a matched folder still opens onto
* its contents). `open` is the ancestors, which a list expands while it filters. */
export function filterTree<N extends Nodelike<N>>(
roots: readonly N[],
children: Children<N>,
match: (node: N) => boolean,
): { keep: Set<string>; matches: Set<string>; open: Set<string> } {
const keep = new Set<string>()
const matches = new Set<string>()
const open = new Set<string>()
const all = (n: N) => {
keep.add(n.id)
for (const k of children(n) ?? []) all(k)
}
// True when the node or something under it matched.
const walk = (n: N): boolean => {
if (match(n)) {
matches.add(n.id)
all(n)
for (const k of children(n) ?? []) if (walk(k)) open.add(n.id)
return true
}
let hit = false
for (const k of children(n) ?? []) if (walk(k)) hit = true
if (hit) {
keep.add(n.id)
open.add(n.id)
}
return hit
}
for (const r of roots) walk(r)
return { keep, matches, open }
}
// ── counts
/** A count that rolls up: each node's own number plus everything under it. `total`,
* when it knows a node's whole count, stands for its subtree instead of the sum: a
* store that counts distinct files under a tag knows that a file tagged twice below
* it is still one file, which no sum can know. */
export function rollup<N extends Nodelike<N>>(
roots: readonly N[],
children: Children<N>,
own: (node: N) => number,
total?: (node: N) => number | undefined,
): Map<string, number> {
const out = new Map<string, number>()
const walk = (n: N): number => {
let sum = own(n)
for (const k of children(n) ?? []) sum += walk(k)
const known = total?.(n)
out.set(n.id, known ?? sum)
return known ?? sum
}
for (const r of roots) walk(r)
return out
}
// ── paths
export interface PathNode {
/** the full path: `vehicles/sedan` */
id: string
/** the last segment: `sedan` */
name: string
children?: PathNode[]
}
/** A tree from slash paths, in first-seen order. A path implies its parents, so
* `["vehicles/sedan", "color/red"]` makes four nodes. */
export function fromPaths(paths: readonly string[], sep = "/"): PathNode[] {
const roots: PathNode[] = []
const byId = new Map<string, PathNode>()
for (const path of paths) {
const parts = path.split(sep).filter(Boolean)
let siblings = roots
let id = ""
for (const name of parts) {
id = id ? `${id}${sep}${name}` : name
let node = byId.get(id)
if (!node) {
node = { id, name }
byId.set(id, node)
siblings.push(node)
}
node.children ??= []
siblings = node.children
}
}
// A leaf is a node nothing was placed under: drop its empty list so it reads as one.
for (const n of byId.values()) if (n.children?.length === 0) delete n.children
return roots
}
// ── moving (drag to reorder and reparent)
/** Where a drop lands relative to its target row. */
export type Drop = "before" | "inside" | "after"
/** A move is legal unless it would put a node inside itself or onto itself. */
export function canMove<N>(
index: ReadonlyMap<string, Entry<N>>,
ids: readonly string[],
target: string,
drop: Drop,
): boolean {
if (ids.includes(target)) return false
const above = ancestors(index, target)
if (ids.some((id) => above.includes(id))) return false
return drop !== "inside" || index.has(target)
}
/** The forest with `ids` moved before, inside (last child) or after `target`. A node
* whose ancestor also moves travels with it rather than on its own; the moved nodes
* keep their tree order. `rebuild` makes a node with new children (default: a
* spread), for data that is not a plain object. */
export function moveNodes<N extends Nodelike<N>>(
roots: readonly N[],
ids: readonly string[],
target: string,
drop: Drop,
rebuild: (node: N, children: N[]) => N = (node, children) => ({
...node,
children,
}),
): N[] {
const index = indexTree(roots, ownChildren)
if (!canMove(index, ids, target, drop))
throw new Error(`tree: cannot move ${ids.join(",")} ${drop} ${target}`)
const moving = new Set(ids)
const top = preorder(roots, ownChildren).filter(
(id) => moving.has(id) && !ancestors(index, id).some((a) => moving.has(a)),
)
const carried = top.map((id) => (index.get(id) as Entry<N>).node)
const lifted = new Set(top)
// Untouched subtrees come back as the same objects, so a renderer keyed on identity
// redraws only the branches the move changed.
const same = (a: readonly N[], b: readonly N[]) =>
a.length === b.length && a.every((n, i) => n === b[i])
const place = (nodes: readonly N[]): N[] => {
const out: N[] = []
for (const n of nodes) {
if (lifted.has(n.id)) continue
const kids = n.children ? place(n.children) : undefined
const next =
n.id === target && drop === "inside"
? rebuild(n, [...(kids ?? []), ...carried])
: kids && n.children && !same(kids, n.children)
? rebuild(n, kids)
: n
if (n.id === target && drop === "before") out.push(...carried)
out.push(next)
if (n.id === target && drop === "after") out.push(...carried)
}
return out
}
return place(roots)
}
// ── keyboard (the WAI-ARIA tree pattern, as data)
export type Nav =
| { focus: number }
| { expand: string }
| { collapse: string }
| { expandSiblings: string | null }
| null
/** What a key does at row `i`: move focus, open, close, or open every sibling branch.
* Right opens a closed branch and steps into an open one; Left closes an open branch
* and steps out to the parent otherwise. `page` is how many rows a page key moves. */
export function navigate<N>(
rows: readonly Row<N>[],
i: number,
key: string,
page = 10,
): Nav {
const row = rows[i]
if (!row) return rows.length ? { focus: 0 } : null
const last = rows.length - 1
switch (key) {
case "ArrowDown":
return { focus: Math.min(last, i + 1) }
case "ArrowUp":
return { focus: Math.max(0, i - 1) }
case "Home":
return { focus: 0 }
case "End":
return { focus: last }
case "PageDown":
return { focus: Math.min(last, i + page) }
case "PageUp":
return { focus: Math.max(0, i - page) }
case "ArrowRight":
if (!row.branch) return null
if (!row.expanded) return { expand: row.id }
return rows[i + 1]?.parent === row.id ? { focus: i + 1 } : null
case "ArrowLeft": {
if (row.branch && row.expanded) return { collapse: row.id }
const p = rows.findIndex((r) => r.id === row.parent)
return p >= 0 ? { focus: p } : null
}
case "*":
return { expandSiblings: row.parent }
default:
return null
}
}
/** Type-ahead: the next row after `from` whose label starts with `query`, wrapping. */
export function typeahead<N>(
rows: readonly Row<N>[],
from: number,
query: string,
label: (node: N) => string,
): number {
const q = query.toLowerCase()
for (let step = 1; step <= rows.length; step++) {
const i = (from + step) % rows.length
const r = rows[i] as Row<N>
if (label(r.node).toLowerCase().startsWith(q)) return i
}
return -1
}
/** The ids from row `a` to row `b`, either direction: a shift-click's range. */
export function between<N>(
rows: readonly Row<N>[],
a: number,
b: number,
): string[] {
const [lo, hi] = a < b ? [a, b] : [b, a]
return rows.slice(lo, hi + 1).map((r) => r.id)
}// The icons a tree of files and places wears, usable anywhere else too. A file's icon
// is read from its kind, and its kind from its mime type when there is one, else
// from its extension; a folder is open or closed. Places and layer verbs are a fixed
// set under plain names (`disk`, `phone`, `eye`), so a caller never hunts lucide for
// "the offline drive". Every icon is lucide's, drawn in currentColor.
import {
Archive,
Camera,
Cloud,
CloudOff,
Eye,
EyeOff,
File,
FileArchive,
FileAudio,
FileBraces,
FileCode,
FileImage,
FileSpreadsheet,
FileText,
FileType,
FileVideo,
Folder,
FolderOpen,
Frame,
Group,
HardDrive,
Image as ImageIcon,
Layers,
Lock,
LockOpen,
type LucideIcon,
ServerOff,
Smartphone,
Square,
Star,
Tag,
Tags,
Trash2,
Type,
Usb,
} from "lucide-react"
import { cn } from "@/lib/utils"
export type FileKind =
| "video"
| "image"
| "audio"
| "code"
| "data"
| "text"
| "sheet"
| "archive"
| "pdf"
| "font"
| "file"
/** Extension → kind. Lowercase, without the dot. */
const BY_EXTENSION: Record<string, FileKind> = {}
const add = (kind: FileKind, exts: string) => {
for (const e of exts.split(" ")) BY_EXTENSION[e] = kind
}
add("video", "mp4 m4v mov mkv webm avi wmv flv mts m2ts mpg mpeg 3gp ogv")
add(
"image",
"jpg jpeg png gif webp avif heic heif tif tiff bmp svg ico raw dng cr2 cr3 nef arw raf orf rw2 psd exr",
)
add("audio", "mp3 wav flac aac m4a ogg oga opus aif aiff alac wma mid midi")
add(
"code",
"ts tsx js jsx mjs cjs go rs py rb swift kt java c h cc cpp hpp cs php lua zig sh bash zsh fish ps1 sql html htm css scss sass less vue svelte astro dart scala ex exs hs ml clj",
)
add("data", "json jsonl ndjson yaml yml toml xml plist ini env lock")
add("text", "txt md mdx markdown rtf log tex org rst srt vtt ass")
add("sheet", "csv tsv xls xlsx numbers ods")
add("archive", "zip rar 7z tar gz tgz bz2 xz zst dmg iso pkg deb rpm jar")
add("pdf", "pdf")
add("font", "ttf otf woff woff2")
/** Mime prefixes and exact types that name a kind outright. */
function kindOfMime(mime: string): FileKind | undefined {
const m = mime.toLowerCase()
if (m.startsWith("video/")) return "video"
if (m.startsWith("image/")) return "image"
if (m.startsWith("audio/")) return "audio"
if (m.startsWith("font/")) return "font"
if (m === "application/pdf") return "pdf"
if (m.includes("json") || m.includes("yaml") || m.includes("xml"))
return "data"
if (m.includes("zip") || m.includes("tar") || m.includes("compressed"))
return "archive"
if (m.includes("spreadsheet") || m === "text/csv") return "sheet"
if (m.includes("javascript") || m.includes("typescript")) return "code"
if (m.startsWith("text/")) return "text"
return undefined
}
/** What a file is, from its mime type when known, else its extension. */
export function kindOf(name: string, mime?: string): FileKind {
const fromMime = mime ? kindOfMime(mime) : undefined
if (fromMime) return fromMime
const dot = name.lastIndexOf(".")
if (dot <= 0) return "file"
return BY_EXTENSION[name.slice(dot + 1).toLowerCase()] ?? "file"
}
const KIND_ICON: Record<FileKind, LucideIcon> = {
video: FileVideo,
image: FileImage,
audio: FileAudio,
code: FileCode,
data: FileBraces,
text: FileText,
sheet: FileSpreadsheet,
archive: FileArchive,
pdf: FileType,
font: Type,
file: File,
}
/** The icon for a file or folder by name: a folder open or closed, a file by kind. */
export function iconFor(
name: string,
opts: { folder?: boolean; open?: boolean; mime?: string } = {},
): LucideIcon {
if (opts.folder) return opts.open ? FolderOpen : Folder
return KIND_ICON[kindOf(name, opts.mime)]
}
/** A file or folder's icon as an element: `<FileIcon name="clip.mov" />`. */
export function FileIcon({
name,
folder,
open,
mime,
className,
}: {
name: string
folder?: boolean
open?: boolean
mime?: string
className?: string
}) {
const Icon = iconFor(name, { folder, open, mime })
return (
<Icon
aria-hidden
data-slot="file-icon"
className={cn("size-3.5 shrink-0", className)}
/>
)
}
/** The places and verbs a sidebar or a layers panel names, under plain names. */
export const ICONS = {
disk: HardDrive,
diskOffline: ServerOff,
usb: Usb,
phone: Smartphone,
camera: Camera,
cloud: Cloud,
cloudOffline: CloudOff,
trash: Trash2,
archive: Archive,
star: Star,
tag: Tag,
tags: Tags,
layers: Layers,
group: Group,
frame: Frame,
shape: Square,
image: ImageIcon,
text: Type,
eye: Eye,
eyeOff: EyeOff,
lock: Lock,
unlock: LockOpen,
folder: Folder,
folderOpen: FolderOpen,
file: File,
} satisfies Record<string, LucideIcon>