Theme
On this page (16)
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).

§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 } from '../../shared/state-api.js';
const df$ = defussGlobals();
const treeViewStates = ['default', 'expanded'];
/**
 * 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 = {
  setState(details, stateName, config = {}) {
    if (!treeViewStates.includes(stateName)) {
      throw new Error(`tree-view: unknown state "${stateName}" (supported: ${treeViewStates.join(', ')})`);
    }
    triggerStateChange(details, stateName, config);
    // state lives on the ELEMENT, not the module (many branches per tree)
    details.dataset.stateName = stateName;
    details._stateConfig = config;
  },
  getState(details) {
    // reflect reality: summary clicks and ArrowLeft/Right change it too
    return {
      name: details.open ? 'expanded' : 'default',
      config: details._stateConfig ?? {},
    };
  },
};
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;
  tree.querySelectorAll('[role="treeitem"][aria-selected="true"]').forEach((other) => other.setAttribute('aria-selected', 'false'));
  item.setAttribute('aria-selected', 'true');
  tree.dispatchEvent(new CustomEvent('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?.querySelector(':scope > .tree-leaf > .tree-check, :scope > details > .tree-branch-trigger > .tree-check');
const childItems = (item) => [...(item.querySelector(':scope > details > .tree-group')?.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) {
  tree.querySelectorAll('[role="treeitem"]').forEach((item) => {
    const box = checkOf(item);
    if (box) item.setAttribute('aria-checked', box.indeterminate ? 'mixed' : String(box.checked));
  });
}
function checkedValues(tree) {
  return [...tree.querySelectorAll('.tree-check')]
    .filter((b) => b.checked && !b.indeterminate)
    .map((b) => b.value !== 'on' ? b.value : b.closest('[role="treeitem"]').querySelector(':scope > * > span:last-child, :scope > details > summary > span:last-child')?.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);
  tree.dispatchEvent(new CustomEvent('tree-check', { bubbles: true, detail: { item, checked: box.checked, values: checkedValues(tree) } }));
}
function initChecks(tree) {
  let n = 0;
  tree.querySelectorAll('.tree-check').forEach((box) => {
    // the row's label names the checkbox
    if (!box.hasAttribute('aria-label') && !box.hasAttribute('aria-labelledby')) {
      const label = box.parentElement.querySelector(':scope > span:last-child');
      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
    tree.querySelectorAll('[role="treeitem"]').forEach((item) => { const b = checkOf(item); if (b?.checked) checkDown(item, true); });
    const leaves = [...tree.querySelectorAll('[role="treeitem"]')].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) {
  tree.querySelectorAll('[data-drop]').forEach((r) => r.removeAttribute('data-drop'));
}
function announceMove(tree, item) {
  const parentItem = item.parentElement.closest('[role="treeitem"]');
  const index = [...item.parentElement.children].indexOf(item);
  tree.dispatchEvent(new CustomEvent('tree-reorder', { bubbles: true, detail: { item, parent: parentItem ?? tree, index } }));
}
function initSortable(tree) {
  let dragged = null;
  const rows = () => tree.querySelectorAll('.tree-branch-trigger, .tree-leaf');
  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 = tree.querySelector('[data-drop]');
    if (!dragged || !row) return;
    e.preventDefault();
    const target = itemOf(row);
    const where = row.dataset.drop;
    if (where === 'inside') {
      const details = target.querySelector(':scope > details');
      details.open = true;
      details.querySelector(':scope > .tree-group').append(dragged);
    } else {
      target.parentElement.insertBefore(dragged, where === 'before' ? target : target.nextSibling);
    }
    clearDrop(tree);
    announceMove(tree, dragged);
    if (tree.hasAttribute('data-checkable') && cascades(tree)) {
      tree.querySelectorAll('[role="treeitem"]').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;
  item.parentElement.insertBefore(item, dir < 0 ? sib : sib.nextSibling);
  row.focus();
  announceMove(tree, item);
}
function init() {
  document.querySelectorAll('.tree[role="tree"]:not([data-init])').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
      tree.querySelectorAll('[role="treeitem"]').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.closest('.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 */
    tree.querySelectorAll('.tree-branch').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;
      details.api = {
        setState: (stateName, config) => treeViewApi.setState(details, stateName, config),
        getState: () => treeViewApi.getState(details),
      };
      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.closest('.tree-branch-trigger, .tree-leaf');
      if (!target) return;
      const allItems = Array.from(tree.querySelectorAll('.tree-branch-trigger, .tree-leaf'));
      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('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('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