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

Native basis

Nested <ul> elements with role="tree" / role="treeitem" ARIA pattern for hierarchical data.

Web Platform APIs

<ul>Tree View WAI-ARIA pattern<details>

Classes

.tree.tree-group.tree-item.tree-branch.tree-branch-trigger.tree-leaf

Accessibility

• Root <ul> has role="tree" and aria-label

• Branch items have role="treeitem" and aria-expanded

• Leaf items have role="treeitem" without aria-expanded

• Nested groups use role="group"

• data-selectable tree: aria-selected on every operable item, click / Enter / Space selects, tree-select event

• aria-disabled="true" items stay focusable but can't be opened, selected or followed

• Keyboard: Arrow keys navigate, Enter/Space toggles branches (and selects in a selectable tree)

§File Explorer

Nested tree with folders and files.

§Guidelines

data-variant='guides' on the tree root draws a vertical guideline under every open folder, so the depth of nesting reads at a glance. The guideline of the level that holds keyboard focus darkens.

§Selection

Add data-selectable to the tree root: a click, Enter or Space selects the row (aria-selected='true' - a primary-tinted surface and medium weight). A folder toggles AND selects. Every choice fires a bubbling tree-select event with the treeitem in detail.item.

§Checkboxes

data-checkable on the tree: a .tree-check checkbox in each row (after the icons, before the label). Checking a folder checks everything in it; a folder with some children checked shows the indeterminate dash. Space ticks the focused row, a click on a file's name too. The inputs are real form controls - name/value submit - and every change fires tree-check with the checked values.

§Checkboxes with emojis, independent

data-checkable='independent' keeps every box on its own (no cascade) - a permissions list where a group can be granted without its members. Emojis stand in for icons.

§Drag and drop

data-sortable: drag a row onto the upper or lower edge of another to put it before or after, onto the middle of a folder to move it inside (the folder opens). Keyboard: Alt+ArrowUp / Alt+ArrowDown moves the focused row among its siblings. Every move fires tree-reorder with the item, its new parent and index.

§Checkboxes and drag and drop

Both at once: pick items and sort them. Moving an item re-derives the folders' checkbox states.

§Disabled items

aria-disabled='true' on a treeitem dims the row and makes it inert: a disabled folder can't be opened (click, Enter, Space, ArrowRight), a disabled row can't be selected or followed. It stays in the arrow-key path, so screen-reader users still learn it exists.

§Beyond files

Any hierarchy fits: product categories, an org chart, a docs navigation menu. Icons are optional - the chevron is the only required glyph. Navigation leaves are links (a.tree-leaf with href); aria-selected marks the current page.

§Collapsed and expanded

The same tree authored two ways: every <details> closed (only the top level shows) or every <details> open with aria-expanded='true' (the whole hierarchy at once). The open attribute is the whole API; each branch's api.setState('expanded') / ('default') changes it later.

§Deep nesting

Eight levels in a 20rem frame, with guides so each level stays traceable. Every level indents 1.25rem; labels that run out of room truncate with an ellipsis instead of overflowing - keep the full text in title for hover and assistive tech.

§Empty branch

A folder with no children keeps its role='group' list empty; the tree shows a muted placeholder row instead of opening onto nothing. The text defaults to 'Empty' - set data-empty on the .tree-group for your own wording.

§Density

Set data-density on the component root to scale its internal whitespace. A whitespace policy, not a zoom: only gaps and padding scale (ratio 0.75 / 1 / 1.25), typography and fixed dimensions stay identical. comfortable matches the unsized default.

§States

Named states via the shared State API, driven per instance through the bound api:

  • default - the branch's authored open/closed state (restored from the init snapshot)
  • expanded - branch open, children visible (per-branch API; aria-expanded stays in sync via the toggle event)

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

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

StateTypeValuesDefaultDescription
expandedbooleantrue, falsefalseFirst branch open (native <details open>; default restores the authored state).

§API

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

States

type TreeViewState = 'default' | 'expanded' - setState(name, config) takes the config of the state it names.

StateDescription
default
The branch closed.

No config.

expanded
The branch open.

No config.

Every element

MemberDescription
el.api.setState<S extends TreeViewState>(name: S, config?: TreeViewStateConfigs[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?TreeViewStateConfigs[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: TreeViewState; config: TreeViewStateConfigs[TreeViewState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.

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

el.api.render(state?: { name: TreeViewState; config: TreeViewStateConfigs[TreeViewState]; 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: TreeViewState; config: TreeViewStateConfigs[TreeViewState]; 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: TreeViewState; config: TreeViewStateConfigs[TreeViewState] }>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.treeViewApi.setState<S extends TreeViewState>(el: HTMLElement, name: S, config?: TreeViewStateConfigs[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?TreeViewStateConfigs[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.treeViewApi.getState(el: HTMLElement): { name: TreeViewState; config: TreeViewStateConfigs[TreeViewState]; 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: TreeViewState; config: TreeViewStateConfigs[TreeViewState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

df$.shadcn.treeViewApi.render(state: { name: TreeViewState; config: TreeViewStateConfigs[TreeViewState]; 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: TreeViewState; config: TreeViewStateConfigs[TreeViewState]; model?: ElementModel }a state as getState() returns it (with its model)

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

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

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

df$.shadcn.treeViewApi.commit<S extends TreeViewState>(el: HTMLElement, name: S, config?: TreeViewStateConfigs[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?TreeViewStateConfigs[S]its config
df$.shadcn.treeViewStates: TreeViewState[]The declared states, 'default' first: default, expanded.

Events

EventDescription
tree-check
Fires when a checkbox is toggled - the item, whether it is checked, and every checked value.

detail: TreeCheckDetail

FieldTypeDescription
itemHTMLElementthe treeitem whose checkbox was toggled
checkedbooleanwhether it is checked now
valuesstring[]every fully checked item's value (the checkbox value, else the item's label)
tree-reorder
Fires after an item is moved - the item, its new parent and its index there.

detail: TreeReorderDetail

FieldTypeDescription
itemHTMLElementthe treeitem that moved
parentHTMLElementits new parent treeitem - the tree itself at the top level
indexnumberits index among the parent's children
tree-select
Fires when an item is selected - the item.

detail: TreeSelectDetail

FieldTypeDescription
itemHTMLElementthe selected treeitem

Types

TypeDescription
TreeCheckDetail
What tree-check carries.
FieldTypeDescription
itemHTMLElementthe treeitem whose checkbox was toggled
checkedbooleanwhether it is checked now
valuesstring[]every fully checked item's value (the checkbox value, else the item's label)
TreeReorderDetail
What tree-reorder carries.
FieldTypeDescription
itemHTMLElementthe treeitem that moved
parentHTMLElementits new parent treeitem - the tree itself at the top level
indexnumberits index among the parent's children
TreeSelectDetail
What tree-select carries.
FieldTypeDescription
itemHTMLElementthe selected treeitem

§CSS view file

Styles for the tree-view component. Uses design tokens for colors, spacing, and radius.

@layer components {
  .tree {
    list-style: none;
    margin: 0;
    padding: 0;
    font-size: 0.875rem;
  }
  .tree-group {
    list-style: none;
    margin: 0;
    padding: 0 0 0 1.25rem;
  }
  .tree-item {
    padding: 0;
  }
  .tree-branch {
    border: none;
    /* Two gotchas, both required for the branch to glide:
       1. `::details-content` must attach to the compound (`&::details-content`,
          NOT `& ::details-content` - the descendant form never matches, the
          pseudo's originating element is the subject itself).
       2. `block-size: auto` is a keyword - without interpolate-size the
          0→auto pair is non-interpolable and the transition silently snaps. */
    &::details-content {
      block-size: 0;
      overflow-y: clip;
      interpolate-size: allow-keywords;
      transition: block-size 200ms ease, content-visibility 200ms allow-discrete;
    }
    &[open]::details-content {
      block-size: auto;
    }
  }
  @starting-style {
    .tree-branch[open]::details-content {
      block-size: 0;
    }
  }
  .tree-branch-trigger {
    display: flex;
    align-items: center;
    gap: 0.375rem;
    padding: 0.25rem 0.5rem;
    border-radius: var(--radius-md);
    cursor: pointer;
    color: var(--foreground);
    list-style: none;
    &:hover {
      background-color: var(--accent);
    }
    &:focus-visible {
      outline: 2px solid var(--ring);
      outline-offset: -2px;
    }
    &::-webkit-details-marker {
      display: none;
    }
    & svg {
      width: 1rem;
      height: 1rem;
      flex-shrink: 0;
    }
    & svg:first-child {
      width: 0.875rem;
      height: 0.875rem;
      color: var(--muted-foreground);
      transition: transform 200ms ease;
    }
  }
  details[open] > .tree-branch-trigger > svg:first-child {
    transform: rotate(90deg);
  }
  .tree-leaf {
    display: flex;
    align-items: center;
    gap: 0.375rem;
    padding: 0.25rem 0.5rem;
    padding-left: calc(0.5rem + 0.875rem + 0.375rem);
    border-radius: var(--radius-md);
    color: var(--foreground);
    cursor: default;
    &:hover {
      background-color: var(--accent);
    }
    & svg {
      width: 1rem;
      height: 1rem;
      flex-shrink: 0;
      color: var(--muted-foreground);
    }
  }
  /* -- Checkboxes (data-checkable) ---------------------------------
     A .tree-check (the checkbox component) sits after the icons, before
     the label; tree-view.js cascades and rolls it up (indeterminate). */
  .tree-check {
    flex-shrink: 0;
    margin: 0;
  }
  .tree[data-checkable] .tree-leaf:has(.tree-check) { cursor: pointer; }
  /* -- Drag & drop (data-sortable) ----------------------------------
     A dragged item fades; the row under the pointer shows where it lands:
     a line above / below (before / after) or a tinted frame (into a
     folder). */
  .tree[data-sortable] :is(.tree-branch-trigger, .tree-leaf)[draggable="true"] { cursor: grab; }
  .tree[data-sortable] :is(.tree-branch-trigger, .tree-leaf):active { cursor: grabbing; }
  .tree [data-dragging] > :is(.tree-leaf, details > .tree-branch-trigger) { opacity: 0.4; }
  :is(.tree-branch-trigger, .tree-leaf) {
    &[data-drop="before"] { box-shadow: inset 0 2px 0 var(--primary); }
    &[data-drop="after"] { box-shadow: inset 0 -2px 0 var(--primary); }
    &[data-drop="inside"] {
      background-color: color-mix(in oklch, var(--primary) 12%, transparent);
      box-shadow: inset 0 0 0 1px var(--primary);
    }
  }
  /* -- Labels -----------------------------------------------------
     The label is the trigger's / leaf's last <span>. Deep nesting eats the
     inline space (1.25rem indent per level), so a long label truncates with
     an ellipsis instead of overflowing the tree - put the full text in
     title="…" when it matters. Leaves may be links (<a class="tree-leaf">,
     navigation trees): no underline, pointer cursor. */
  :is(.tree-branch-trigger, .tree-leaf) > span:last-child {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  a.tree-leaf {
    text-decoration: none;
    cursor: pointer;
  }
  /* -- Selection (opt-in: data-selectable on .tree) ---------------
     aria-selected on the treeitem is the state (tree-view.js writes it on
     click / Enter / Space; authored markup may preset it). The selected row
     gets a primary-tinted surface and medium weight - distinct from the
     neutral accent hover surface, in every theme. The row is the item's
     leaf OR its branch's <summary> (a grandchild: <li> > <details> > <summary>). */
  :is(.tree-item[aria-selected="true"] > .tree-leaf,
    .tree-item[aria-selected="true"] > .tree-branch > .tree-branch-trigger) {
    background-color: color-mix(in oklch, var(--primary) 12%, transparent);
    color: var(--foreground);
    font-weight: 500;
  }
  .tree[data-selectable] :is(.tree-leaf, .tree-branch-trigger) {
    cursor: pointer;
  }
  /* -- Disabled: aria-disabled="true" on the treeitem --------------
     Still focusable (APG: disabled items stay in the keyboard path) but not
     operable - tree-view.js refuses to open or select it. The dimming covers
     the row only; an open disabled branch's children keep their own state. */
  :is(.tree-item[aria-disabled="true"] > .tree-leaf,
    .tree-item[aria-disabled="true"] > .tree-branch > .tree-branch-trigger) {
    opacity: 0.5;
    cursor: not-allowed;
    &:hover {
      background-color: transparent;
    }
  }
  /* -- Empty branch -----------------------------------------------
     A folder with no children shows a muted placeholder row instead of
     opening onto nothing. Text: data-empty on the .tree-group ("Empty" by
     default). :has() rather than :empty - authored whitespace would defeat
     :empty. Generated content: screen readers announce the group's size. ::after, because
     the guides variant already draws its line with ::before. */
  .tree-group:not(:has(> .tree-item))::after {
    content: 'Empty';
    display: block;
    padding: 0.25rem 0.5rem;
    padding-left: calc(0.5rem + 0.875rem + 0.375rem);
    color: var(--muted-foreground);
    font-style: italic;
  }
  .tree-group[data-empty]:not(:has(> .tree-item))::after {
    content: attr(data-empty);
  }
  /* -- Variant: guides --------------------------------------------
     data-variant="guides" on the .tree root draws one vertical guideline
     per nested .tree-group, so the depth of nesting reads at a glance. The
     line sits under the parent branch's chevron centre (trigger padding
     0.5rem + half the 0.875rem chevron) and spans the whole group; it is a
     border, not a background, so forced-colors keeps it visible. The line
     of the innermost group that holds focus darkens - the keyboard user
     sees which level they are on. */
  .tree[data-variant="guides"] .tree-group {
    position: relative;
    &::before {
      content: '';
      position: absolute;
      inset-block: 0;
      inset-inline-start: calc(0.5rem + 0.4375rem - 0.5px);
      border-inline-start: 1px solid var(--border);
      pointer-events: none;
    }
    &:focus-within:not(:has(.tree-group:focus-within))::before {
      border-inline-start-color: var(--muted-foreground);
    }
  }
  /* -- Density ----------------------------------------------------
     data-density on the .tree root scales the row padding-block. Indent
     (padding-left) is structural - untouched. comfortable == the default. */
  .tree:where([data-density="compact"]) {
    & .tree-branch-trigger, & .tree-leaf { padding-block: 0.125rem; }
  }
  .tree:where([data-density="comfortable"]) {
    & .tree-branch-trigger, & .tree-leaf { padding-block: 0.25rem; }
  }
  .tree:where([data-density="spacious"]) {
    & .tree-branch-trigger, & .tree-leaf { padding-block: 0.375rem; }
  }
}
/* Accessibility: suppress motion for users who request it (REQUIRED for all
   components - AGENTS.md "Accessibility CSS"). Near-zero duration instead of
   `none` keeps transitionend/animationend (and discrete display flips)
   firing so JS state machines that await them keep working. */
@media (prefers-reduced-motion: reduce) {
  @layer components {
    .tree,
    .tree *,
    .tree::before,
    .tree::after,
    .tree *::before,
    .tree *::after,
    .tree::backdrop,
    .tree-branch,
    .tree-branch *,
    .tree-branch::before,
    .tree-branch::after,
    .tree-branch *::before,
    .tree-branch *::after,
    .tree-branch::backdrop,
    .tree-branch::details-content,
    .tree-branch-trigger,
    .tree-branch-trigger *,
    .tree-branch-trigger::before,
    .tree-branch-trigger::after,
    .tree-branch-trigger *::before,
    .tree-branch-trigger *::after,
    .tree-branch-trigger::backdrop,
    .tree-group,
    .tree-group *,
    .tree-group::before,
    .tree-group::after,
    .tree-group *::before,
    .tree-group *::after,
    .tree-group::backdrop,
    .tree-item,
    .tree-item *,
    .tree-item::before,
    .tree-item::after,
    .tree-item *::before,
    .tree-item *::after,
    .tree-item::backdrop,
    .tree-leaf,
    .tree-leaf *,
    .tree-leaf::before,
    .tree-leaf::after,
    .tree-leaf *::before,
    .tree-leaf *::after,
    .tree-leaf::backdrop {
      transition-duration: 0.01ms !important;
      animation-duration: 0.01ms !important;
      animation-iteration-count: 1 !important;
    }
  }
}
/* Forced colors (Windows High Contrast) drop the tinted surface - the
   selected row must still read as selected: system
   Highlight pair, the same signal native listboxes use. */
@media (forced-colors: active) {
  @layer components {
    :is(.tree-item[aria-selected="true"] > .tree-leaf,
      .tree-item[aria-selected="true"] > .tree-branch > .tree-branch-trigger) {
      forced-color-adjust: none;
      background-color: Highlight;
      color: HighlightText;
    }
  }
}

§JavaScript view file

Interaction logic for the tree-view component. Uses data attributes for wiring.

// -- Tree View ------------------------------------------------
// Keyboard navigation and ARIA state for tree views, plus the named-state
// API bound per branch (<details class="tree-branch">), so agents/tests can
// expand branches by name (AGENTS.md "State API"). Opt-in on the .tree:
// data-selectable (single selection), data-checkable (checkboxes that
// cascade to children and roll up to parents - tri-state) and data-sortable
// (drag & drop reordering with the native Drag and Drop API, Alt+Arrow keys).
// 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 } from '../../../shared/state-api.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.
/** What tree-select carries. */
interface TreeSelectDetail {
  /** the selected treeitem */
  item: HTMLElement;
}
/** What tree-check carries. */
interface TreeCheckDetail {
  /** the treeitem whose checkbox was toggled */
  item: HTMLElement;
  /** whether it is checked now */
  checked: boolean;
  /** every fully checked item's value (the checkbox value, else the item's label) */
  values: string[];
}
/** What tree-reorder carries. */
interface TreeReorderDetail {
  /** the treeitem that moved */
  item: HTMLElement;
  /** its new parent treeitem - the tree itself at the top level */
  parent: HTMLElement;
  /** its index among the parent's children */
  index: number;
}
const treeViewStates = ['default', 'expanded'];
/** setState() configs per state - bound on every branch (a <details>); the states take none. */
export interface TreeViewStateConfigs {
  /** The branch closed. */
  default: {};
  /** The branch open. */
  expanded: {};
}
/**
 * The markup of a state, for render(): the attributes a state writes, applied
 * to a detached copy of the authored markup ('default' IS the authored
 * markup). The live element gets the same markup from triggerStateChange -
 * the e2e render round trip proves they agree.
 */
function applyMarkup(el, stateName) {
  // 'expanded' is open; 'default' is the authored open flag - in place on the
  // authored copy
  if (stateName === 'expanded') dfDollar(el).attr('open', '');
}
/**
 * UI side of setState (per branch): 'expanded' opens the branch, 'default'
 * restores the authored open/closed snapshot taken at init. The <details>
 * toggle event keeps aria-expanded on the treeitem in sync automatically.
 */
function triggerStateChange(details, stateName, _config) {
  switch (stateName) {
    case 'default':
      details.open = details._defaultOpen ?? false;
      break;
    case 'expanded':
      details.open = true;
      break;
  }
}
/** Registry-level API; pass the branch element explicitly. Unknown names throw. */
export const treeViewApi = componentState<HTMLDetailsElement>({
  component: 'tree-view',
  states: treeViewStates,
  apply: (details, state) => triggerStateChange(details, state.name, state.config),
  read: (details, state) => {
    // reflect reality: summary clicks and ArrowLeft/Right change it too
    return {
      name: details.open ? 'expanded' : 'default',
      config: state.config,
    };
  },
  markup: (el, state) => applyMarkup(el, state.name),
});
df$.treeViewApi = treeViewApi;
df$.treeViewStates = treeViewStates;
/** The treeitem a row (branch trigger or leaf) belongs to. */
const itemOf = (row) => row.closest('[role="treeitem"]');
/** aria-disabled="true" on the treeitem: focusable, never operable. */
const isDisabled = (item) => item?.getAttribute('aria-disabled') === 'true';
/**
 * Single selection (APG tree, opt-in via data-selectable on .tree): the
 * chosen treeitem gets aria-selected="true", every other selectable item
 * "false"; disabled items are skipped. Announces the choice as a bubbling
 * `tree-select` CustomEvent ({ detail: { item } }) - selection is an
 * interaction on the tree, not a per-branch State API state.
 */
function selectItem(tree, item) {
  if (!item || isDisabled(item) || item.getAttribute('aria-selected') === 'true') return;
  dfDollar(tree).find('[role="treeitem"][aria-selected="true"]').toArray().forEach((other) => other.setAttribute('aria-selected', 'false'));
  item.setAttribute('aria-selected', 'true');
  // Fires when an item is selected - the item.
  tree.dispatchEvent(new CustomEvent<TreeSelectDetail>('tree-select', { bubbles: true, detail: { item } }));
}
/* -- Checkboxes (data-checkable) ------------------------------------------
   A checkbox in a row (.tree-check, after the icons, before the label).
   Checking a folder checks everything under it; a folder's own box shows
   checked / unchecked / indeterminate from its children. data-checkable=
   "independent" turns the cascade off. The inputs stay real form controls
   (name / value submit natively). */
const checkOf = (item) => (item ? dfDollar(item).find<HTMLInputElement>(':scope > .tree-leaf > .tree-check, :scope > details > .tree-branch-trigger > .tree-check').get(0) : undefined);
const childItems = (item) => [...(dfDollar(item).find(':scope > details > .tree-group').get(0)?.children ?? [])].filter((li) => li.matches('[role="treeitem"]'));
const cascades = (tree) => tree.dataset.checkable !== 'independent';
/** Down: a folder's state to every descendant box. */
function checkDown(item, checked) {
  for (const child of childItems(item)) {
    const box = checkOf(child);
    if (box && !box.disabled) {
      box.checked = checked;
      box.indeterminate = false;
    }
    checkDown(child, checked);
  }
}
/** Up: every ancestor folder derives its box from its children. */
function rollUp(tree, item) {
  let parent = item.parentElement?.closest('[role="treeitem"]');
  while (parent && tree.contains(parent)) {
    const box = checkOf(parent);
    if (box) {
      const kids = childItems(parent).map(checkOf).filter(Boolean);
      const on = kids.filter((k) => k.checked && !k.indeterminate).length;
      const mixed = kids.some((k) => k.indeterminate);
      box.checked = kids.length > 0 && on === kids.length;
      box.indeterminate = mixed || (on > 0 && on < kids.length);
    }
    parent = parent.parentElement?.closest('[role="treeitem"]');
  }
}
function syncAria(tree) {
  dfDollar(tree).find('[role="treeitem"]').toArray().forEach((item) => {
    const box = checkOf(item);
    if (box) item.setAttribute('aria-checked', box.indeterminate ? 'mixed' : String(box.checked));
  });
}
function checkedValues(tree) {
  return [...dfDollar(tree).find<HTMLInputElement>('.tree-check').toArray()]
    .filter((b) => b.checked && !b.indeterminate)
    .map((b) => b.value !== 'on' ? b.value : dfDollar(b).closest('[role="treeitem"]').find(':scope > * > span:last-child, :scope > details > summary > span:last-child').get(0)?.textContent ?? '');
}
function onCheck(tree, item) {
  const box = checkOf(item);
  if (!box) return;
  box.indeterminate = false;
  if (cascades(tree)) {
    checkDown(item, box.checked);
    rollUp(tree, item);
  }
  syncAria(tree);
  // Fires when a checkbox is toggled - the item, whether it is checked, and every checked value.
  tree.dispatchEvent(new CustomEvent<TreeCheckDetail>('tree-check', { bubbles: true, detail: { item, checked: box.checked, values: checkedValues(tree) } }));
}
function initChecks(tree) {
  let n = 0;
  dfDollar(tree).find('.tree-check').toArray().forEach((box) => {
    // the row's label names the checkbox
    if (!box.hasAttribute('aria-label') && !box.hasAttribute('aria-labelledby')) {
      const label = dfDollar(box.parentElement).find(':scope > span:last-child').get(0);
      if (label) {
        label.id ||= `${tree.id || 'tree'}-lbl-${n++}-${Math.random().toString(36).slice(2, 7)}`;
        box.setAttribute('aria-labelledby', label.id);
      }
    }
  });
  if (cascades(tree)) {
    // authored checked folders cascade down, then every folder rolls up
    dfDollar(tree).find('[role="treeitem"]').toArray().forEach((item) => { const b = checkOf(item); if (b?.checked) checkDown(item, true); });
    const leaves = [...dfDollar(tree).find('[role="treeitem"]').toArray()].filter((i) => !childItems(i).length);
    leaves.forEach((leaf) => rollUp(tree, leaf));
  }
  syncAria(tree);
  tree.addEventListener('change', (e) => {
    if (!e.target.matches?.('.tree-check')) return;
    onCheck(tree, itemOf(e.target));
  });
}
/* -- Drag & drop reordering (data-sortable) -------------------------------
   Rows are draggable (native Drag and Drop API). Dropping on the upper /
   lower quarter of a row puts the item before / after it; dropping on the
   middle of a folder moves it into that folder (opened). A folder can't
   move into itself. Alt+ArrowUp / Alt+ArrowDown moves the focused item
   among its siblings. Every move fires a bubbling tree-reorder event. */
function clearDrop(tree) {
  dfDollar(tree).find('[data-drop]').toArray().forEach((r) => r.removeAttribute('data-drop'));
}
function announceMove(tree, item) {
  const parentItem = item.parentElement.closest('[role="treeitem"]');
  const index = [...item.parentElement.children].indexOf(item);
  // Fires after an item is moved - the item, its new parent and its index there.
  tree.dispatchEvent(new CustomEvent<TreeReorderDetail>('tree-reorder', { bubbles: true, detail: { item, parent: parentItem ?? tree, index } }));
}
function initSortable(tree) {
  let dragged = null;
  const rows = () => dfDollar(tree).find('.tree-branch-trigger, .tree-leaf').toArray();
  rows().forEach((row) => { if (!isDisabled(itemOf(row))) row.draggable = true; });
  tree.addEventListener('dragstart', (e) => {
    const row = e.target.closest?.('.tree-branch-trigger, .tree-leaf');
    if (!row) return;
    dragged = itemOf(row);
    dragged.dataset.dragging = '';
    e.dataTransfer.effectAllowed = 'move';
    e.dataTransfer.setData('text/plain', row.textContent.trim());
  });
  tree.addEventListener('dragover', (e) => {
    const row = e.target.closest?.('.tree-branch-trigger, .tree-leaf');
    if (!dragged || !row) return;
    const target = itemOf(row);
    if (target === dragged || dragged.contains(target)) return clearDrop(tree);
    e.preventDefault();
    e.dataTransfer.dropEffect = 'move';
    const r = row.getBoundingClientRect();
    const y = (e.clientY - r.top) / r.height;
    const isBranch = row.matches('.tree-branch-trigger');
    const where = isBranch ? (y < 0.25 ? 'before' : y > 0.75 ? 'after' : 'inside') : y < 0.5 ? 'before' : 'after';
    if (row.dataset.drop !== where) {
      clearDrop(tree);
      row.dataset.drop = where;
    }
  });
  tree.addEventListener('dragleave', (e) => {
    if (!tree.contains(e.relatedTarget)) clearDrop(tree);
  });
  tree.addEventListener('drop', (e) => {
    const row = dfDollar(tree).find('[data-drop]').get(0);
    if (!dragged || !row) return;
    e.preventDefault();
    const target = itemOf(row);
    const where = row.dataset.drop;
    if (where === 'inside') {
      const details = dfDollar(target).find<HTMLDetailsElement>(':scope > details').get(0);
      details.open = true;
      dfDollar(details).find(':scope > .tree-group').get(0).append(dragged);
    } else {
      const ref = where === 'before' ? target : target.nextSibling;
      if (ref) dfDollar(ref).before(dragged);
      else dfDollar(target.parentElement).append(dragged);
    }
    clearDrop(tree);
    announceMove(tree, dragged);
    if (tree.hasAttribute('data-checkable') && cascades(tree)) {
      dfDollar(tree).find('[role="treeitem"]').toArray().forEach((i) => { if (!childItems(i).length) rollUp(tree, i); });
      syncAria(tree);
    }
  });
  tree.addEventListener('dragend', () => {
    if (dragged) delete dragged.dataset.dragging;
    dragged = null;
    clearDrop(tree);
  });
}
/** Alt+ArrowUp / Alt+ArrowDown: move among siblings, keep focus. */
function moveByKey(tree, row, dir) {
  const item = itemOf(row);
  const sib = dir < 0 ? item.previousElementSibling : item.nextElementSibling;
  if (!sib) return;
  const ref = dir < 0 ? sib : sib.nextSibling;
  if (ref) dfDollar(ref).before(item);
  else dfDollar(item.parentElement).append(item);
  row.focus();
  announceMove(tree, item);
}
function init() {
  dfDollar('.tree[role="tree"]:not([data-init])').toArray().forEach((tree) => {
    tree.dataset.init = '';
    const selectable = tree.hasAttribute('data-selectable');
    if (selectable) {
      // every operable item states its selection explicitly (authored
      // aria-selected="true" wins); disabled items carry none
      dfDollar(tree).find('[role="treeitem"]').toArray().forEach((item) => {
        if (!isDisabled(item) && !item.hasAttribute('aria-selected')) item.setAttribute('aria-selected', 'false');
      });
    }
    if (tree.hasAttribute('data-checkable')) initChecks(tree);
    if (tree.hasAttribute('data-sortable')) initSortable(tree);
    const checkable = tree.hasAttribute('data-checkable');
    /* Clicks: disabled rows neither toggle nor navigate; selectable trees
       select the clicked row (a branch toggles AND selects, like a file
       explorer). */
    tree.addEventListener('click', (e) => {
      const row = (e.target as HTMLElement).closest<HTMLElement>('.tree-branch-trigger, .tree-leaf');
      if (!row || !tree.contains(row)) return;
      const item = itemOf(row);
      if (isDisabled(item)) {
        e.preventDefault(); // <summary> would toggle, <a> would navigate
        return;
      }
      if (selectable) selectItem(tree, item);
      // a checkable leaf: clicking the row (not the box itself) ticks it
      const box = checkOf(item);
      if (checkable && !selectable && box && row.matches('.tree-leaf') && e.target !== box && !box.disabled) {
        box.checked = !box.checked;
        onCheck(tree, item);
      }
    });
    /* Keep aria-expanded in sync with <details> open state */
    dfDollar(tree).find<HTMLDetailsElement>('.tree-branch').toArray().forEach((details) => {
      const treeitem = details.closest('[role="treeitem"]');
      if (!treeitem) return;
      // snapshot the authored state + bind the api per branch:
      // `$('#my-branch').api.setState('expanded')`
      details._defaultOpen = details.open;
      // el.store + el.api (AGENTS.md "State through stores")
      bindComponent(details, treeViewApi);
      details.addEventListener('toggle', () => {
        treeitem.setAttribute('aria-expanded', String(details.open));
        // user interaction also moves the named state (keeps getState honest)
        details.dataset.stateName = details.open ? 'expanded' : 'default';
      });
    });
    /* Keyboard navigation */
    tree.addEventListener('keydown', (e) => {
      const target = (e.target as HTMLElement).closest<HTMLElement>('.tree-branch-trigger, .tree-leaf');
      if (!target) return;
      const allItems = Array.from(dfDollar(tree).find('.tree-branch-trigger, .tree-leaf').toArray());
      const visibleItems = allItems.filter((item) => item.checkVisibility());
      const index = visibleItems.indexOf(target);
      // sortable: Alt+ArrowUp / Alt+ArrowDown reorder among siblings
      if (e.altKey && tree.hasAttribute('data-sortable') && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
        e.preventDefault();
        moveByKey(tree, target, e.key === 'ArrowUp' ? -1 : 1);
        return;
      }
      // checkable: Space ticks the row's box (a <summary> would toggle instead)
      if (e.key === ' ' && checkable) {
        const box = checkOf(itemOf(target));
        if (box && !box.disabled && !isDisabled(itemOf(target))) {
          e.preventDefault();
          box.checked = !box.checked;
          onCheck(tree, itemOf(target));
          return;
        }
      }
      switch (e.key) {
        case 'ArrowDown':
          e.preventDefault();
          if (index < visibleItems.length - 1) visibleItems[index + 1].focus();
          break;
        case 'ArrowUp':
          e.preventDefault();
          if (index > 0) visibleItems[index - 1].focus();
          break;
        case 'ArrowRight':
          e.preventDefault();
          { const detailsR = target.closest<HTMLDetailsElement>('details.tree-branch');
          if (detailsR && !detailsR.open && !isDisabled(itemOf(target))) detailsR.open = true; }
          break;
        case 'Enter':
        case ' ': {
          const item = itemOf(target);
          // disabled: no native <summary> toggle, no link activation
          if (isDisabled(item)) {
            e.preventDefault();
            break;
          }
          if (!selectable) break;
          // a leaf span has no native activation - keep Space from scrolling
          if (target.matches('span.tree-leaf')) e.preventDefault();
          selectItem(tree, item);
          break;
        }
        case 'ArrowLeft':
          e.preventDefault();
          { const detailsL = target.closest<HTMLDetailsElement>('details.tree-branch');
          if (detailsL && detailsL.open) detailsL.open = false; }
          break;
        case 'Home':
          e.preventDefault();
          if (visibleItems.length) visibleItems[0].focus();
          break;
        case 'End':
          e.preventDefault();
          if (visibleItems.length) visibleItems[visibleItems.length - 1].focus();
          break;
      }
    });
});
}
init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });

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