Theme
Design your own
On this page (8)
Component Skill — components/data-tree/component-skill.md

Native basis

A focusable scroll container with role="tree": a sizer gives the scrollbar the length of every visible node, a pool of recycled treeitems rides inside it, aria-activedescendant names the active one. df$.shadcn.dataTree.setSource(el, records) hands it the data.

Web Platform APIs

ARIA treeoverflow-anchor: nonearia-activedescendantcontain: strictResizeObserver

Classes

.data-tree.data-tree-sizer.data-tree-items.data-tree-item.data-tree-toggle.data-tree-label.data-tree-meta

Data attributes

data-label-field, data-id-field, data-parent-field, data-persist / data-persist-prefix / data-persist-key, data-empty-text; data-tree-filter="tree-id" on any input; --data-tree-row-height, --data-tree-indent.

§111,110 nodes

Ten regions × ten countries × a hundred cities × ten districts. Open branches with the chevrons or the keyboard (↑ ↓ → ← Home End, Enter selects, * opens every sibling). The input is bound by data-tree-filter: type 'district 7.3.4' - every match shows with the path to it opened, the first match becomes active. The line below subscribes to the tree's store.

§Custom labels and a kept view

render(label, record, meta) fills each label - an icon, the name, a size. The open branches, the filters, the sort and the selection are kept: in session storage by default, here in local storage under its own key (data-persist='local', data-persist-key) - on your page they survive a reload and the next visit (this sandboxed example has no storage).

§Selection drives a detail pane

Selecting (click, Enter, Space) dispatches data-tree-select with the record - here a detail pane follows it. Sorting orders siblings: the select writes config.sorters.

§Loading and empty

A tree without records waits in 'loading' (placeholder rows, aria-busy); a filter nothing matches lands in 'empty' with data-empty-text.

§States

Named states via the shared State API, driven per instance through the bound api; the config of every state is the query (filters, sorters, expanded, collapsed, selected), merged on each setState and observable as el.store:

  • default - the visible nodes of the query; nothing visible lands in empty
  • loading - aria-busy="true" and placeholder rows; a tree without records starts here
  • empty - nothing to show: the data-empty-text message

The first example carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/data-tree-{state}.png.

Machine contract - verified against data-tree.schema.json by bun run verify:

StateTypeValuesDefaultDescription
loadingbooleantrue, falsefalsearia-busy with placeholder rows (setState('loading')).
emptybooleantrue, falsefalseNothing to show - shows data-empty-text (setState('empty')).

§API

Generated from data-tree.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.

States

type DataTreeState = 'default' | 'loading' | 'empty' - setState(name, config) takes the config of the state it names.

StateDescription
default
The visible items of the query; nothing visible lands in empty.
Config fieldTypeDescription
filters?DataviewFilter[]filters over every record (a match shows with its ancestors)
sorters?DataviewSorter[]the order of siblings
expanded?DataviewJsonValue[]the ids of the open branches
collapsed?DataviewJsonValue[]while filtering: the branches the user closed again
selected?DataviewJsonValue | nullthe selected record's id, null for none
loading
Busy: placeholder rows, aria-busy - a tree without records starts here.
Config fieldTypeDescription
filters?DataviewFilter[]filters over every record (a match shows with its ancestors)
sorters?DataviewSorter[]the order of siblings
expanded?DataviewJsonValue[]the ids of the open branches
collapsed?DataviewJsonValue[]while filtering: the branches the user closed again
selected?DataviewJsonValue | nullthe selected record's id, null for none
empty
Nothing to show: the data-empty-text shows.
Config fieldTypeDescription
filters?DataviewFilter[]filters over every record (a match shows with its ancestors)
sorters?DataviewSorter[]the order of siblings
expanded?DataviewJsonValue[]the ids of the open branches
collapsed?DataviewJsonValue[]while filtering: the branches the user closed again
selected?DataviewJsonValue | nullthe selected record's id, null for none

Every element

MemberDescription
el.api.setState<S extends DataTreeState>(name: S, config?: DataTreeStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
nameSa declared state (an unknown name throws)
config?DataTreeStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (or await settled())

el.api.getState(): { name: DataTreeState; config: DataTreeStateConfigs[DataTreeState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.

Returns { name: DataTreeState; config: DataTreeStateConfigs[DataTreeState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

el.api.render(state?: { name: DataTreeState; config: DataTreeStateConfigs[DataTreeState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state?{ name: DataTreeState; config: DataTreeStateConfigs[DataTreeState]; model?: ElementModel }a state as getState() returns it (default: the current one)

Returns string - the element's outer HTML in that state

el.api.settled(): Promise<void>
Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting).

Returns Promise<void> - resolves when nothing is pending

el.store: Store<{ name: DataTreeState; config: DataTreeStateConfigs[DataTreeState] }>A defuss-store store of the element's state - subscribe to follow every change (also the user's), set it to drive the component.

Registry

MemberDescription
df$.shadcn.dataTreeApi.setState<S extends DataTreeState>(el: HTMLElement, name: S, config?: DataTreeStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSa declared state (an unknown name throws)
config?DataTreeStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (await it, or el.api.settled())

df$.shadcn.dataTreeApi.getState(el: HTMLElement): { name: DataTreeState; config: DataTreeStateConfigs[DataTreeState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.
ArgumentTypeDescription
elHTMLElementthe component's element

Returns { name: DataTreeState; config: DataTreeStateConfigs[DataTreeState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

df$.shadcn.dataTreeApi.render(state: { name: DataTreeState; config: DataTreeStateConfigs[DataTreeState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state{ name: DataTreeState; config: DataTreeStateConfigs[DataTreeState]; model?: ElementModel }a state as getState() returns it (with its model)

Returns string - the element's outer HTML in that state

df$.shadcn.dataTreeApi.store(el: HTMLElement): Store<{ name: DataTreeState; config: DataTreeStateConfigs[DataTreeState] }>
The element's store (bindComponent made it).
ArgumentTypeDescription
elHTMLElementthe component's element

Returns Store<{ name: DataTreeState; config: DataTreeStateConfigs[DataTreeState] }> - a defuss-store store of { name, config } - subscribe to follow every change, set it to drive the component

df$.shadcn.dataTreeApi.commit<S extends DataTreeState>(el: HTMLElement, name: S, config?: DataTreeStateConfigs[S]): void
Record a state the element reached on its own (no DOM work) - for a component's own handlers.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSthe state it is in
config?DataTreeStateConfigs[S]its config
df$.shadcn.dataTreeStates: DataTreeState[]The declared states, 'default' first: default, loading, empty.

df$.shadcn.dataTree

MemberDescription
setSource(target: string | HTMLElement, rows: DataviewRow[], options: DataTreeOptions = {}): void
Hand the tree its records. Options: idField ('id'), parentIdField ('parentId', or data-parent-field), render(el, record, meta) for the label (default: the data-label-field value), query (the starting view), persist ({ area: 'session' | 'local' | 'none', prefix, key }).
ArgumentTypeDescription
targetstring | HTMLElementthe .data-tree element or its selector
rowsDataviewRow[]the records, a flat list linked by parent id
optionsDataTreeOptions = {}fields, label rendering, the starting view and its persistence
query(target: string | HTMLElement, patch: DataTreeQuery): void
Merge into the query: { filters?, sorters?, expanded?, selected? }.
ArgumentTypeDescription
targetstring | HTMLElementthe .data-tree element or its selector
patchDataTreeQuerythe query keys to change
expandAll(target: string | HTMLElement): void
Open every branch.
ArgumentTypeDescription
targetstring | HTMLElementthe .data-tree element or its selector
collapseAll(target: string | HTMLElement): void
Close every branch (while filtering: the ways to the matches too).
ArgumentTypeDescription
targetstring | HTMLElementthe .data-tree element or its selector
selected(target: string | HTMLElement): DataviewRow | null
The selected record.
ArgumentTypeDescription
targetstring | HTMLElementthe .data-tree element or its selector

Returns DataviewRow | null - the record whose id is selected, null when none is

Events

EventDescription
data-tree-select
Fires when an item is selected (click, Enter, Space) - its record and its tree meta (depth, hasChildren ...).

detail: DataTreeSelectDetail

FieldTypeDescription
recordDataviewRowthe selected record
metaDataTreeMetawhere it sits in the tree

Types

TypeDescription
DataTreeMeta
Where a record sits in the tree - what render() and data-tree-select receive.
FieldTypeDescription
depthnumber0 for a root
hasChildrenbooleanwhether it has child records
isExpandedbooleanwhether its branch is open
isMatchbooleanwhether it matches the filters (false for an ancestor shown for a match)
isSelectedbooleanwhether it is the selected record
parentIdDataviewJsonValue | nullits parent's id, null for a root
DataTreeOptions
What setSource() takes besides the records.
FieldTypeDescription
idField?stringthe id field (default 'id', or data-id-field)
parentIdField?stringthe parent-id field (default 'parentId', or data-parent-field)
render?(el: HTMLElement, record: DataviewRow, meta: DataTreeMeta) => voidfill an item's label yourself (default: the data-label-field value as text)
query?DataTreeQuerythe starting view (a kept view wins over it)
persist?ViewPersistencewhere the view is kept between visits (default: session storage under a generated key)
DataTreeQuery
The tree's query - its state config (merged on every query()).
FieldTypeDescription
filters?DataviewFilter[]filters over every record (a match shows with its ancestors)
sorters?DataviewSorter[]the order of siblings
expanded?DataviewJsonValue[]the ids of the open branches
collapsed?DataviewJsonValue[]while filtering: the branches the user closed again
selected?DataviewJsonValue | nullthe selected record's id, null for none
DataTreeSelectDetail
What data-tree-select carries.
FieldTypeDescription
recordDataviewRowthe selected record
metaDataTreeMetawhere it sits in the tree

§CSS view file

/* -- Data Tree --------------------------------------------------- */
/* A windowed tree: the sizer gives the scrollbar its length, a      */
/* recycled pool of items rides inside it. Depth is one custom       */
/* property per item - indentation is CSS.                           */
@layer components {
  .data-tree {
    --data-tree-row-height: 32px;
    --data-tree-indent: 1.25rem;
    position: relative;
    overflow-y: auto;
    overscroll-behavior: contain;
    /* the windowing owns the scroll position: Firefox's scroll anchoring would follow
       the recycled rows, move scrollTop, re-render and move it again - a scroll
       that runs on by itself (big-data-firefox.e2e) */
    overflow-anchor: none;
    scrollbar-gutter: stable;
    border: 1px solid var(--border);
    border-radius: var(--radius-lg);
    background: var(--card);
    color: var(--card-foreground);
    font-size: 0.875rem;
    contain: strict;
    &:focus-visible {
      outline: 2px solid var(--ring);
      outline-offset: 2px;
    }
  }
  .data-tree-sizer {
    position: relative;
    width: 100%;
  }
  .data-tree-items {
    position: absolute;
    inset-inline: 0;
    top: 0;
    padding-inline: 0.25rem;
    will-change: translate;
  }
  .data-tree-item {
    display: flex;
    align-items: center;
    gap: 0.25rem;
    height: var(--data-tree-row-height);
    padding-inline-start: calc(var(--depth, 0) * var(--data-tree-indent) + 0.25rem);
    padding-inline-end: 0.5rem;
    border-radius: var(--radius-sm);
    cursor: default;
    user-select: none;
    &:hover {
      background: var(--accent);
      color: var(--accent-foreground);
    }
    &[aria-selected="true"] {
      background: color-mix(in oklch, var(--primary) 14%, var(--card));
      color: var(--foreground);
      font-weight: 500;
    }
    /* a filter's own hits, among the ancestors kept to reach them */
    &[data-match] .data-tree-label {
      color: var(--foreground);
      font-weight: 600;
    }
  }
  .data-tree:focus-visible .data-tree-item[data-active] {
    box-shadow: inset 0 0 0 2px var(--ring);
  }
  .data-tree-toggle {
    flex: none;
    display: grid;
    place-items: center;
    width: 1.25rem;
    height: 1.25rem;
    border-radius: var(--radius-sm);
    color: var(--muted-foreground);
    cursor: pointer;
    &::before {
      content: "";
      width: 0.375rem;
      height: 0.375rem;
      border-inline-end: 1.5px solid currentColor;
      border-bottom: 1.5px solid currentColor;
      rotate: -45deg;
      transition: rotate 150ms ease;
    }
    &:hover {
      background: color-mix(in oklch, var(--foreground) 8%, transparent);
    }
  }
  /* a leaf has no toggle - only its place, so labels line up */
  .data-tree-item:not([aria-expanded]) .data-tree-toggle {
    cursor: default;
    &::before {
      content: none;
    }
  }
  .data-tree-item[aria-expanded="true"] .data-tree-toggle::before {
    rotate: 45deg;
  }
  .data-tree-label {
    display: flex;
    align-items: center;
    gap: 0.375rem;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  /* secondary text inside a label (counts, sizes) */
  .data-tree-meta {
    margin-inline-start: auto;
    padding-inline-start: 0.75rem;
    color: var(--muted-foreground);
    font-size: 0.75rem;
    font-variant-numeric: tabular-nums;
  }
  /* -- loading and empty -------------------------------------- */
  .data-tree[data-state="loading"],
  .data-tree[data-state="empty"] {
    & .data-tree-items {
      display: none;
    }
    & .data-tree-sizer {
      height: 100% !important; /* nothing to scroll in these states */
    }
  }
  .data-tree[data-state="loading"]::before {
    content: "";
    position: absolute;
    inset: 0.25rem;
    background: repeating-linear-gradient(
      to bottom,
      var(--muted) 0,
      var(--muted) calc(var(--data-tree-row-height) - 6px),
      transparent calc(var(--data-tree-row-height) - 6px),
      transparent var(--data-tree-row-height)
    );
    opacity: 0.6;
    animation: data-tree-pulse 1.6s ease-in-out infinite;
  }
  .data-tree[data-state="empty"]::after {
    content: attr(data-empty-text);
    position: absolute;
    inset: 0;
    display: grid;
    place-items: center;
    color: var(--muted-foreground);
  }
  .data-tree:not([data-empty-text])[data-state="empty"]::after {
    content: "Nothing matches.";
  }
  @keyframes data-tree-pulse {
    0%, 100% { opacity: 0.6; }
    50% { opacity: 0.3; }
  }
}
@media (prefers-reduced-motion: reduce) {
  @layer components {
    .data-tree,
    .data-tree *,
    .data-tree::before,
    .data-tree::after,
    .data-tree *::before,
    .data-tree *::after,
    .data-tree[data-state="loading"]::before {
      transition: none;
      animation: none;
      scroll-behavior: auto;
    }
  }
}
@media (prefers-contrast: more) {
  @layer components {
    .data-tree {
      border-color: var(--muted-foreground);
    }
    .data-tree:focus-visible .data-tree-item[data-active] {
      box-shadow: inset 0 0 0 2px var(--foreground);
    }
  }
}
@media (forced-colors: active) {
  @layer components {
    .data-tree {
      border-color: ButtonText;
    }
    .data-tree-item[aria-selected="true"] {
      background: Highlight;
      color: HighlightText;
    }
    .data-tree:focus-visible .data-tree-item[data-active] {
      outline: 2px solid Highlight;
      outline-offset: -2px;
    }
    .data-tree-toggle::before {
      border-color: ButtonText;
    }
  }
}

§JS view file

// -- Data Tree ------------------------------------------------
// A tree over any number of records that each name their parent: only the
// rows on screen exist (shared windowing, src/shared/virtual.ts), and the
// hierarchy, filtering and sorting run locally over every record through
// defuss-dataview (src/shared/dataview.ts). A filter keeps the ancestors of
// every match and opens the way to it; collapsing keeps the query.
//
// The query IS the state's config (AGENTS.md "State through stores"):
// el.store.value.config = { filters, sorters, expanded, collapsed, selected }.
// The view survives a reload: session storage by default, local storage or
// nothing per instance (data-persist, data-persist-prefix / -key, or
// setSource's persist option).
// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —
// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.
import {
  defussGlobals,
  defussQuery,
  componentState,
  bindComponent,
  persisted,
  viewPersistence,
  dataSource,
  parseFilter,
  filterText,
  virtualWindow,
  sizerHeight,
  scrollIntoViewTop,
} from '../../../shared/state-api.js';
import type { DataviewFilter, DataviewJsonValue, DataviewRow, DataviewSorter } from '../../../shared/dataview.js';
import type { ViewPersistence } from '../../../shared/store.js';
const df$ = defussGlobals();
const dfDollar = defussQuery();
// VERIFIED: (verify's component types ratchet - tsc -p tsconfig.components.json) every type
// this file's API docs state - arguments, return values, event details - holds
// against its code: a wrong one is a new type error and fails the build.
/** The tree's query - its state config (merged on every query()). */
interface DataTreeQuery {
  /** filters over every record (a match shows with its ancestors) */
  filters?: DataviewFilter[];
  /** the order of siblings */
  sorters?: DataviewSorter[];
  /** the ids of the open branches */
  expanded?: DataviewJsonValue[];
  /** while filtering: the branches the user closed again */
  collapsed?: DataviewJsonValue[];
  /** the selected record's id, null for none */
  selected?: DataviewJsonValue | null;
}
/** Where a record sits in the tree - what render() and data-tree-select receive. */
interface DataTreeMeta {
  /** 0 for a root */
  depth: number;
  /** whether it has child records */
  hasChildren: boolean;
  /** whether its branch is open */
  isExpanded: boolean;
  /** whether it matches the filters (false for an ancestor shown for a match) */
  isMatch: boolean;
  /** whether it is the selected record */
  isSelected: boolean;
  /** its parent's id, null for a root */
  parentId: DataviewJsonValue | null;
}
/** What setSource() takes besides the records. */
interface DataTreeOptions {
  /** the id field (default 'id', or data-id-field) */
  idField?: string;
  /** the parent-id field (default 'parentId', or data-parent-field) */
  parentIdField?: string;
  /** fill an item's label yourself (default: the data-label-field value as text) */
  render?: (el: HTMLElement, record: DataviewRow, meta: DataTreeMeta) => void;
  /** the starting view (a kept view wins over it) */
  query?: DataTreeQuery;
  /** where the view is kept between visits (default: session storage under a generated key) */
  persist?: ViewPersistence;
}
/** What data-tree-select carries. */
interface DataTreeSelectDetail {
  /** the selected record */
  record: DataviewRow;
  /** where it sits in the tree */
  meta: DataTreeMeta;
}
const dataTreeStates = ['default', 'loading', 'empty'];
/** setState() configs per state - the config IS the tree's query, merged into the stored one ({ expanded } keeps the filters). */
export interface DataTreeStateConfigs {
  /** The visible items of the query; nothing visible lands in empty. */
  default: DataTreeQuery;
  /** Busy: placeholder rows, aria-busy - a tree without records starts here. */
  loading: DataTreeQuery;
  /** Nothing to show: the data-empty-text shows. */
  empty: DataTreeQuery;
}
let uid = 0;
// the config being shown: set first thing in apply (the store records it
// only AFTER the DOM work - reading the store there would be one step behind)
const configOf = (tree) => tree._config ?? tree.store?.value.config ?? {};
const labelField = (tree) => tree.dataset.labelField || 'name';
/**
 * The markup of a state, for render() AND the live element: data-state and
 * aria-busy. Everything else a tree shows is its records (runtime).
 */
function applyMarkup(el, state) {
  dfDollar(el)
    .attr('data-state', state.name)
    .attr('aria-busy', state.name === 'loading' ? 'true' : null);
}
/** position among visible siblings (aria-posinset / aria-setsize), per result */
function siblingInfo(tree) {
  const result = tree._result;
  if (tree._siblings?.result === result) return tree._siblings;
  const size = new Map();
  const pos = Array.from({ length: result.entries.length }, () => 0);
  result.entries.forEach((entry, i) => {
    const parent = entry.meta.parentId ?? null;
    const n = (size.get(parent) ?? 0) + 1;
    size.set(parent, n);
    pos[i] = n;
  });
  tree._siblings = { result, size, pos };
  return tree._siblings;
}
/** writes the window of items that belongs at the current scroll position */
function renderItems(tree) {
  const pool = tree._pool;
  if (!pool || !tree._result) return;
  const entries = tree._result.entries;
  const total = entries.length;
  const h = tree._rowHeight;
  const win = virtualWindow(tree.scrollTop, tree.clientHeight, h, total);
  tree._sizer.style.height = `${sizerHeight(total, h)}px`;
  while (pool.children.length < win.count) {
    const item = document.createElement('div');
    item.className = 'data-tree-item';
    item.setAttribute('role', 'treeitem');
    item.id = `${tree._uid}-item-${pool.children.length}`;
    const toggle = document.createElement('span');
    toggle.className = 'data-tree-toggle';
    toggle.setAttribute('aria-hidden', 'true');
    const label = document.createElement('span');
    label.className = 'data-tree-label';
    item.append(toggle, label);
    pool.append(item);
  }
  while (pool.children.length > win.count) pool.lastElementChild.remove();
  pool.style.translate = `0 ${win.shift}px`;
  const { size, pos } = siblingInfo(tree);
  const selected = configOf(tree).selected ?? null;
  const idField = tree._source.idField;
  let active = null;
  for (let i = 0; i < pool.children.length; i++) {
    const item = pool.children[i];
    const index = win.first + i;
    const entry = entries[index];
    const key = `${tree._gen}:${index}`;
    if (item._key !== key) {
      item._key = key;
      item._index = index;
      item.dataset.index = String(index);
      const meta = entry.meta;
      item.style.setProperty('--depth', String(meta.depth));
      item.setAttribute('aria-level', String(meta.depth + 1));
      item.setAttribute('aria-setsize', String(size.get(meta.parentId ?? null)));
      item.setAttribute('aria-posinset', String(pos[index]));
      if (meta.hasChildren) item.setAttribute('aria-expanded', String(meta.isExpanded));
      else item.removeAttribute('aria-expanded');
      item.toggleAttribute('data-match', !!(configOf(tree).filters || []).length && meta.isMatch);
      const label = item.lastElementChild;
      if (tree._render) {
        label.textContent = '';
        tree._render(label, entry.row, meta);
      } else {
        label.textContent = String(entry.row[labelField(tree)] ?? '');
      }
    }
    item.setAttribute('aria-selected', String(selected !== null && entry.row[idField] === selected));
    item.toggleAttribute('data-active', index === tree._active);
    if (index === tree._active) active = item;
  }
  if (active) tree.setAttribute('aria-activedescendant', active.id);
  else tree.removeAttribute('aria-activedescendant');
}
/** evaluate the config's query, re-render; returns the visible count */
function refresh(tree, config, previous) {
  if (!tree._source) return 0;
  tree._result = tree._source.query({ filters: config.filters, sorters: config.sorters, expanded: config.expanded, collapsed: config.collapsed });
  tree._gen = (tree._gen || 0) + 1;
  const prev = previous?.config || {};
  if (JSON.stringify(prev.filters ?? []) !== JSON.stringify(config.filters ?? [])) {
    tree.scrollTop = 0;
    // the first match is where the eye goes
    const first = tree._result.entries.findIndex((e) => e.meta.isMatch);
    tree._active = (config.filters || []).length ? Math.max(0, first) : 0;
  }
  tree._active = Math.min(tree._active ?? 0, Math.max(0, tree._result.entries.length - 1));
  renderItems(tree);
  return tree._result.entries.length;
}
/**
 * UI side of setState: 'default' shows the records of the config's query
 * (none match → 'empty'), 'loading' shows placeholder rows, 'empty' shows
 * data-empty-text. Every state keeps the query.
 */
function triggerStateChange(tree, state, previous) {
  tree._config = state.config;
  let name = state.name;
  if (name !== 'loading') {
    const rows = refresh(tree, state.config, previous);
    if (name === 'default' && !rows) name = 'empty';
  }
  applyMarkup(tree, { name, config: state.config });
  tree.dataset.stateName = name;
  if (tree._saved) {
    tree._saved.set({ filters: state.config.filters ?? [], sorters: state.config.sorters ?? [], expanded: state.config.expanded ?? [], selected: state.config.selected ?? null });
  }
  // bound filter inputs show the query (a restored one too) - never the one being typed in
  if (tree.id) {
    for (const input of dfDollar<HTMLInputElement>(`[data-tree-filter="${CSS.escape(tree.id)}"]`).toArray()) {
      if (input === document.activeElement) continue;
      const field = input.dataset.field || labelField(tree);
      input.value = filterText((state.config.filters || []).find((x) => x.field === field));
    }
  }
}
/** the persisted view (viewPersistence: data-persist, -prefix, -key, or the config) */
const KEPT = ['filters', 'sorters', 'expanded'];
function attachPersistence(tree, config) {
  tree._saved?.destroy();
  const where = viewPersistence(tree, 'data-tree', String(dfDollar('.data-tree').toArray().indexOf(tree)), config || {});
  tree._saved = where
    ? persisted<Record<string, unknown>>(where.key, {}, { area: where.area, validate: (v): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v) })
    : null;
  const kept: Record<string, unknown> = {};
  for (const k of KEPT) if (Array.isArray(tree._saved?.value[k])) kept[k] = tree._saved.value[k];
  if (tree._saved && tree._saved.value.selected !== undefined) kept.selected = tree._saved.value.selected;
  return kept;
}
/** Registry-level API; pass the tree explicitly. Unknown names throw. */
export const dataTreeApi = componentState({
  component: 'data-tree',
  states: dataTreeStates,
  // a config merges: { expanded } keeps the filters
  mergeConfig: true,
  apply: (tree, state, previous) => triggerStateChange(tree, state, previous),
  markup: (el, state) => applyMarkup(el, state),
});
df$.dataTreeApi = dataTreeApi;
df$.dataTreeStates = dataTreeStates;
// -- interaction ------------------------------------------------------------------
const query = (tree, patch) => dataTreeApi.setState(tree, tree.store.value.name === 'loading' ? 'loading' : 'default', patch);
const entryAt = (tree, index) => tree._result?.entries[index];
function setExpanded(tree, index, open) {
  const entry = entryAt(tree, index);
  if (!entry?.meta.hasChildren || entry.meta.isExpanded === open) return false;
  const id = entry.row[tree._source.idField];
  const config = configOf(tree);
  if ((config.filters || []).length) {
    const collapsed = new Set(config.collapsed || []);
    if (open) collapsed.delete(id);
    else collapsed.add(id);
    query(tree, { collapsed: [...collapsed] });
  } else {
    const expanded = new Set(config.expanded || []);
    if (open) expanded.add(id);
    else expanded.delete(id);
    query(tree, { expanded: [...expanded] });
  }
  return true;
}
function select(tree, index) {
  const entry = entryAt(tree, index);
  if (!entry) return;
  query(tree, { selected: entry.row[tree._source.idField] });
  // Fires when an item is selected (click, Enter, Space) - its record and its tree meta (depth, hasChildren ...).
  tree.dispatchEvent(new CustomEvent<DataTreeSelectDetail>('data-tree-select', { bubbles: true, detail: { record: entry.row, meta: entry.meta } }));
}
/** move the active item and keep it on screen */
function activate(tree, index) {
  const total = tree._result?.entries.length ?? 0;
  if (!total) return;
  tree._active = Math.max(0, Math.min(total - 1, index));
  tree.scrollTop = scrollIntoViewTop(tree._active, tree.scrollTop, tree.clientHeight, tree._rowHeight, total);
  renderItems(tree);
}
function onKeydown(tree, e) {
  const index = tree._active ?? 0;
  const entry = entryAt(tree, index);
  if (!entry) return;
  const page = Math.max(1, Math.floor(tree.clientHeight / tree._rowHeight) - 1);
  switch (e.key) {
    case 'ArrowDown': activate(tree, index + 1); break;
    case 'ArrowUp': activate(tree, index - 1); break;
    case 'ArrowRight':
      // closed: open it; open: step to its first child
      if (entry.meta.hasChildren && !setExpanded(tree, index, true)) activate(tree, index + 1);
      break;
    case 'ArrowLeft':
      if (entry.meta.hasChildren && entry.meta.isExpanded) setExpanded(tree, index, false);
      else if (entry.meta.parentId != null) {
        const parent = tree._result.entries.findIndex((x) => x.row[tree._source.idField] === entry.meta.parentId);
        if (parent >= 0) activate(tree, parent);
      }
      break;
    case 'Home': activate(tree, 0); break;
    case 'End': activate(tree, tree._result.entries.length - 1); break;
    case 'PageDown': activate(tree, index + page); break;
    case 'PageUp': activate(tree, index - page); break;
    case 'Enter':
    case ' ':
      select(tree, index);
      break;
    case '*': {
      // APG: open every sibling of the active item
      const parent = entry.meta.parentId ?? null;
      const ids = tree._result.entries.filter((x) => (x.meta.parentId ?? null) === parent && x.meta.hasChildren).map((x) => x.row[tree._source.idField]);
      query(tree, { expanded: [...new Set([...(configOf(tree).expanded || []), ...ids])] });
      break;
    }
    default:
      return;
  }
  e.preventDefault();
}
function onClick(tree, e) {
  const item = e.target.closest?.('.data-tree-item');
  if (!item || !tree._pool.contains(item)) return;
  tree._active = item._index;
  if (e.target.closest('.data-tree-toggle')) {
    const entry = entryAt(tree, item._index);
    setExpanded(tree, item._index, !entry?.meta.isExpanded);
    return;
  }
  select(tree, item._index);
}
// one page-wide listener: <input data-tree-filter="tree-id" [data-field]> filters that tree
if (!document.__dataTreeFilterInit) {
  document.__dataTreeFilterInit = true;
  document.addEventListener('input', (e) => {
    const input = (e.target as HTMLElement).closest?.<HTMLInputElement>('[data-tree-filter]');
    if (!input) return;
    const tree = dfDollar('#' + CSS.escape(input.dataset.treeFilter)).get(0);
    if (!tree?.store) return;
    clearTimeout(tree._filterTimer);
    tree._filterTimer = setTimeout(() => {
      const filter = parseFilter(input.dataset.field || labelField(tree), input.value, 'text');
      query(tree, { filters: filter ? [filter] : [], collapsed: [] });
    }, 150);
  });
}
// -- df$.shadcn.dataTree: the imperative surface ----------------------------------
const resolve = (target) => (typeof target === 'string' ? dfDollar(target).get(0) : target);
df$.dataTree = {
  /**
   * Hand the tree its records. Options: idField ('id'), parentIdField
   * ('parentId', or data-parent-field), render(el, record, meta) for the
   * label (default: the data-label-field value), query (the starting view),
   * persist ({ area: 'session' | 'local' | 'none', prefix, key }).
   * @param target - the .data-tree element or its selector
   * @param rows - the records, a flat list linked by parent id
   * @param options - fields, label rendering, the starting view and its persistence
   */
  setSource(target: string | HTMLElement, rows: DataviewRow[], options: DataTreeOptions = {}): void {
    const tree = resolve(target);
    const idField = options.idField || tree.dataset.idField || 'id';
    const parentIdField = options.parentIdField || tree.dataset.parentField || 'parentId';
    tree._source = dataSource(rows, { idField, tree: { idField, parentIdField } });
    tree._render = options.render || null;
    tree._sourceOptions = options;
    // options.query starts the view; a kept view (options.persist moves it) wins
    if (tree.store) dataTreeApi.setState(tree, 'default', { ...options.query, ...(options.persist ? attachPersistence(tree, options.persist) : {}) });
  },
  /**
   * Merge into the query: { filters?, sorters?, expanded?, selected? }.
   * @param target - the .data-tree element or its selector
   * @param patch - the query keys to change
   */
  query: (target: string | HTMLElement, patch: DataTreeQuery): void => { query(resolve(target), patch); },
  /**
   * Open every branch.
   * @param target - the .data-tree element or its selector
   */
  expandAll(target: string | HTMLElement): void {
    const tree = resolve(target);
    query(tree, { expanded: tree._source.branchIds(), collapsed: [] });
  },
  /**
   * Close every branch (while filtering: the ways to the matches too).
   * @param target - the .data-tree element or its selector
   */
  collapseAll(target: string | HTMLElement): void {
    const tree = resolve(target);
    const filtering = (configOf(tree).filters || []).length > 0;
    query(tree, filtering ? { collapsed: tree._source.branchIds() } : { expanded: [] });
  },
  /**
   * The selected record.
   * @param target - the .data-tree element or its selector
   * @returns the record whose id is selected, null when none is
   */
  selected(target: string | HTMLElement): DataviewRow | null {
    const tree = resolve(target);
    const id = configOf(tree).selected ?? null;
    return id === null ? null : (tree._source?.rows.find((r) => r[tree._source.idField] === id) ?? null);
  },
};
// -- init --------------------------------------------------------------------------
function init() {
  dfDollar('.data-tree:not([data-init])').toArray().forEach((tree) => {
    tree.dataset.init = '';
    tree._uid = tree.id || `data-tree-${++uid}`;
    const sizer = document.createElement('div');
    sizer.className = 'data-tree-sizer';
    const pool = document.createElement('div');
    pool.className = 'data-tree-items';
    pool.setAttribute('role', 'presentation');
    sizer.append(pool);
    dfDollar(tree).append(sizer);
    tree._sizer = sizer;
    tree._pool = pool;
    tree._active = 0;
    tree._rowHeight = parseFloat(getComputedStyle(tree).getPropertyValue('--data-tree-row-height')) || 32;
    tree.setAttribute('role', 'tree');
    if (!tree.hasAttribute('tabindex')) tree.tabIndex = 0;
    const early = tree._sourceOptions || {};
    const config = { filters: [], sorters: [], expanded: [], collapsed: [], selected: null, ...early.query, ...attachPersistence(tree, early.persist) };
    let queued = false;
    tree.addEventListener('scroll', () => {
      if (queued) return;
      queued = true;
      requestAnimationFrame(() => {
        queued = false;
        renderItems(tree);
      });
    }, { passive: true });
    new ResizeObserver(() => renderItems(tree)).observe(tree);
    tree.addEventListener('click', (e) => onClick(tree, e));
    tree.addEventListener('keydown', (e) => onKeydown(tree, e));
    // el.store + el.api (AGENTS.md "State through stores"): no records yet = loading
    bindComponent(tree, dataTreeApi, { name: tree._source ? 'default' : 'loading', config });
    dataTreeApi.setState(tree, tree._source ? 'default' : 'loading', config);
  });
}
init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });

Comments, ideas or improvements? Edit this page's source on GitHub