Skip to main content
astrocraft-ui/ components · 101

Tree

Free · MIT

Headless, accessible, and styled entirely through the attribute surface below — bring your own CSS system, or start from the reference theme.

Install

Without a flag you get no styling at all — plain HTML carrying the attribute surface below. --theme copies the same theme in two dialects;--bridge lumos re-points either one onto Lumos for Astro's tokens (it needs a theme to re-point, and Lumos itself, already in your project).

Unstyled — behavior only, you write the CSS

Install command
npx astrocraft-ui add navigation/tree

Plain-CSS theme — no build step

Install command
npx astrocraft-ui add navigation/tree --theme css

Tailwind theme — needs Tailwind v4

Install command
npx astrocraft-ui add navigation/tree --theme tailwind

Plain-CSS theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add navigation/tree --theme css --bridge lumos

Tailwind theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add navigation/tree --theme tailwind --bridge lumos

Live demo

Tree

One tab stop for the whole widget. Tab into it, then arrow keys: down and up walk what you can see, right opens a branch and then steps into it, left closes it and then climbs out, and typing a letter jumps — press the same letter again to cycle. aria-level, aria-posinset and aria-setsize are derived from the nesting, so nothing in the markup below states them.

  • Documents
    • Invoice.pdf
  • Readme.md

Attribute surface

With no class-merging machinery, these attributes are the whole extension point. State is a data-attribute or a native attribute; visibility is the hiddenattribute. Generated from source by pnpm slots.

ComponentSlotsVariantsRuntime stateNative state
`Tree.astro``tree`———
`TreeExpander.astro``chevron` `tree-expander`———
`TreeGroup.astro``tree-group`———
`TreeItem.astro``tree-item`——`[aria-expanded]` `[aria-selected]`

Source

What the command copies — 6 files, alias-free so the copy lands standing alone. This is the whole component; there is nothing else.

src/components/ui/navigation/tree/Tree.astro
---
// src/components/ui/navigation/tree/Tree.astro — headless primitive (see ../../README.md).
// A full ARIA tree: file explorers, nested navigation, category pickers.
//
// This is the widget with the largest gap between "looks easy" and "is right", which is exactly why
// it is worth owning once. A tree is not a list of nested <ul>s with click handlers; it is a single
// tab stop that moves with the arrow keys, and it has to tell assistive tech how deep every item is,
// how many siblings it has, and which of them it is. Written by hand that is four attributes per
// item that nobody keeps accurate, so the script derives all of them from the DOM instead:
//
//   • ROVING TABINDEX — exactly one item is tabbable. Tab enters the tree and Tab leaves it; inside,
//     it is the arrow keys, as in every native tree.
//   • aria-level / aria-posinset / aria-setsize — computed from the nesting the author already wrote.
//   • ArrowRight expands, then descends. ArrowLeft collapses, then climbs to the parent. Home / End
//     go to the first and last item the user can actually see.
//   • Type-ahead — see type-ahead.ts for the two rules that make it feel native.
//
// Collapsed subtrees are hidden by structure.css, keyed on `aria-expanded="false"` — the same
// "state already in the DOM drives the CSS" trick as the menu checkmark, so no JavaScript has to
// write visibility here.
//
//   <Tree aria-label="Files">
//     <TreeItem expanded>
//       <TreeExpander />Documents
//       <TreeGroup><TreeItem>Invoice.pdf</TreeItem></TreeGroup>
//     </TreeItem>
//     <TreeItem>Readme.md</TreeItem>
//   </Tree>
//
// Label it. A tree with no `aria-label` is announced as an unnamed tree, and a page with two is
// unnavigable.
import "../../../../styles/structure.css";

import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"ul">;

const { class: className, ...rest } = Astro.props;
---

<ul role="tree" class={className} data-slot="tree" {...rest}>
  <slot />
</ul>

<script>
  import { onReadyOnce } from "../../_once";
  import { matchTypeAhead } from "./type-ahead";

  const ITEM = '[role="treeitem"]';
  /** How long a keystroke stays in the type-ahead buffer — the interval every native list uses. */
  const TYPE_AHEAD_MS = 500;

  /** The item's OWN text: a parent's label must not swallow the labels of its children. */
  function ownText(item: HTMLElement): string {
    return [...item.childNodes]
      .filter((node) => !(node instanceof Element && node.getAttribute("role") === "group"))
      .map((node) => node.textContent ?? "")
      .join("")
      .trim();
  }

  /** The treeitem containing `item`, or null at the top level. */
  function parentOf(item: HTMLElement, tree: HTMLElement): HTMLElement | null {
    const parent = item.parentElement?.closest<HTMLElement>(ITEM) ?? null;
    return parent && tree.contains(parent) ? parent : null;
  }

  function wire(tree: HTMLElement) {
    const all = [...tree.querySelectorAll<HTMLElement>(ITEM)];
    if (all.length === 0) return;

    // ── The ARIA bookkeeping nobody writes by hand ───────────────────────────────────────────────
    for (const item of all) {
      const siblings = [...(item.parentElement?.children ?? [])].filter((el) => el.matches(ITEM));
      let level = 1;
      for (let up = parentOf(item, tree); up; up = parentOf(up, tree)) level += 1;
      item.setAttribute("aria-level", String(level));
      item.setAttribute("aria-posinset", String(siblings.indexOf(item) + 1));
      item.setAttribute("aria-setsize", String(siblings.length));

      // A treeitem takes its name from its contents, and its contents include the whole subtree —
      // so an unnamed parent announces itself followed by every descendant. Pinning the name to its
      // own text is the fix, and it is skipped when the author has already given one, or when the
      // item has no text of its own to pin it to.
      const text = ownText(item);
      if (item.querySelector('[role="group"]') && text && !item.hasAttribute("aria-label")) {
        item.setAttribute("aria-label", text);
      }
    }

    /** Items the user can reach right now — everything inside a collapsed parent is not one. */
    const reachable = () =>
      all.filter((item) => {
        for (let up = parentOf(item, tree); up; up = parentOf(up, tree)) {
          if (up.getAttribute("aria-expanded") === "false") return false;
        }
        return true;
      });

    const focusItem = (item: HTMLElement) => {
      for (const other of all) other.tabIndex = other === item ? 0 : -1;
      item.focus();
    };

    const select = (item: HTMLElement) => {
      for (const other of all) other.setAttribute("aria-selected", String(other === item));
    };

    const setExpanded = (item: HTMLElement, open: boolean) => {
      if (item.hasAttribute("aria-expanded")) item.setAttribute("aria-expanded", String(open));
    };

    // Exactly one tab stop: the selected item if the author marked one, otherwise the first.
    const initial = all.find((item) => item.getAttribute("aria-selected") === "true") ?? all[0];
    for (const item of all) item.tabIndex = item === initial ? 0 : -1;

    let buffer = "";
    let clear: ReturnType<typeof setTimeout> | undefined;

    tree.addEventListener("keydown", (event) => {
      if (!(event.target instanceof HTMLElement)) return;
      const item = event.target.closest<HTMLElement>(ITEM);
      if (!item || !tree.contains(item)) return;

      const items = reachable();
      const i = items.indexOf(item);
      const expanded = item.getAttribute("aria-expanded");

      switch (event.key) {
        case "ArrowDown":
          // Clamped rather than wrapped: a tree is a hierarchy, and falling off the bottom back to
          // the root reads as a bug. Native trees do not wrap either.
          focusItem(items[Math.min(i + 1, items.length - 1)]);
          break;
        case "ArrowUp":
          focusItem(items[Math.max(i - 1, 0)]);
          break;
        case "Home":
          focusItem(items[0]);
          break;
        case "End":
          focusItem(items[items.length - 1]);
          break;
        case "ArrowRight":
          // One key, two jobs, in this order: open a closed node, step into an open one.
          if (expanded === "false") setExpanded(item, true);
          else if (expanded === "true") {
            const child = item.querySelector<HTMLElement>(`[role="group"] > ${ITEM}`);
            if (child) focusItem(child);
          } else return; // a leaf: let the event through
          break;
        case "ArrowLeft":
          if (expanded === "true") setExpanded(item, false);
          else {
            const parent = parentOf(item, tree);
            if (!parent) return;
            focusItem(parent);
          }
          break;
        case "Enter":
        case " ":
          select(item);
          setExpanded(item, expanded === "false");
          break;
        default: {
          // Printable keys only, and never while a modifier is down — Ctrl+F is find, not "f".
          if (event.key.length !== 1 || event.ctrlKey || event.metaKey || event.altKey) return;
          buffer += event.key;
          clearTimeout(clear);
          clear = setTimeout(() => (buffer = ""), TYPE_AHEAD_MS);
          const match = matchTypeAhead(items.map(ownText), i, buffer);
          if (match === -1) return;
          focusItem(items[match]);
        }
      }
      // Only reached when the key was handled: every `return` above leaves the default alone, so
      // ArrowDown on a leaf still scrolls and Ctrl+F still opens find.
      event.preventDefault();
    });

    tree.addEventListener("click", (event) => {
      if (!(event.target instanceof Element)) return;
      const item = event.target.closest<HTMLElement>(ITEM);
      if (!item || !tree.contains(item)) return;
      // Clicking anywhere on a parent toggles it, the way a file explorer does; the expander is
      // just the obvious place to aim. Selecting and focusing happen either way, so the keyboard
      // resumes from where the pointer left off.
      setExpanded(item, item.getAttribute("aria-expanded") === "false");
      select(item);
      focusItem(item);
    });
  }

  onReadyOnce('[data-slot="tree"]', wire);
</script>
src/components/ui/navigation/tree/TreeExpander.astro
---
// src/components/ui/navigation/tree/TreeExpander.astro — Tree compound part (see ../../README.md).
// The disclosure glyph on a branch. Decorative and NOT a button, deliberately: the treeitem is
// already the focusable, operable thing, and a nested <button> would put a second tab stop inside a
// widget whose entire contract is that it has one. Clicks land on the item either way.
//
// A theme rotates it from the item's own state —
// `[data-slot="tree-item"][aria-expanded="true"] [data-slot="chevron"]`.
import type { HTMLAttributes } from "astro/types";

import Chevron from "../../_Chevron.astro";

type Props = HTMLAttributes<"span">;

const { class: className, ...rest } = Astro.props;
---

<span aria-hidden="true" class={className} data-slot="tree-expander" {...rest}>
  <slot><slot name="chevron"><Chevron /></slot></slot>
</span>
src/components/ui/navigation/tree/TreeGroup.astro
---
// src/components/ui/navigation/tree/TreeGroup.astro — Tree compound part (see ../../README.md).
// The children of a branch. `role="group"` rather than a second `role="tree"`: a tree has exactly one
// root, and nesting trees would give the user a new tab stop at every level.
//
// It goes INSIDE its parent TreeItem, after the label — that nesting is what the script reads to
// compute `aria-level`, and it is what structure.css hides when the parent reads
// `aria-expanded="false"`. A group parked as a sibling of its parent looks identical and is neither
// collapsible nor correctly levelled.
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"ul">;

const { class: className, ...rest } = Astro.props;
---

<ul role="group" class={className} data-slot="tree-group" {...rest}>
  <slot />
</ul>
src/components/ui/navigation/tree/TreeItem.astro
---
// src/components/ui/navigation/tree/TreeItem.astro — Tree compound part (see ../../README.md).
// One node. Whether it is a branch or a leaf is decided by `expanded`: pass it (either value) and the
// item renders `aria-expanded` and is a branch; leave it off entirely and it is a leaf, with no
// `aria-expanded` at all. That distinction is not cosmetic — `aria-expanded="false"` on a leaf tells
// a screen reader there is something to open, and the arrow keys then appear to do nothing.
//
// `aria-selected` is always rendered, so the state is declared rather than appearing on first click,
// and a theme never needs `:not([aria-selected])`. Level, position and set size are NOT here: the
// script derives them from the nesting, because a hand-written `aria-level` is wrong the first time
// anyone moves a branch.
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"li"> & {
  /** Present = a branch, and its open state. Absent = a leaf. */
  expanded?: boolean;
  selected?: boolean;
};

const { expanded, selected = false, class: className, ...rest } = Astro.props;
---

<li
  role="treeitem"
  aria-expanded={expanded === undefined ? undefined : expanded ? "true" : "false"}
  aria-selected={selected ? "true" : "false"}
  class={className}
  data-slot="tree-item"
  {...rest}
>
  <slot />
</li>
src/components/ui/navigation/tree/index.ts
import Tree from "./Tree.astro";
import TreeExpander from "./TreeExpander.astro";
import TreeGroup from "./TreeGroup.astro";
import TreeItem from "./TreeItem.astro";

export { Tree, TreeExpander, TreeGroup, TreeItem };
export default Tree;
src/components/ui/navigation/tree/type-ahead.ts
// src/components/ui/navigation/tree/type-ahead.ts — the letter-navigation rule Tree needs, in a plain module so
// it is checkable without a DOM (see type-ahead.test.ts).
//
// Type-ahead looks trivial until the two cases that are not. Pressing the same letter repeatedly has
// to CYCLE through the items beginning with it — that is what every native list, file manager and
// <select> does, and a naive "find the first match" implementation traps the user on item one
// forever. Growing a longer prefix has to do the opposite and be allowed to keep matching the item
// already focused, or typing "fo" after "f" jumps off "Foo" the moment the second letter lands.
//
// Both fall out of one question: is every character in the buffer the same? If so the buffer means
// "the next one", and the search starts after the current item; otherwise it means "this prefix",
// and the search starts at it.

/**
 * Index of the next label matching `query`, searching forward from `from` and wrapping; `-1` when
 * nothing matches.
 *
 * @param labels Item labels in the order the user moves through them.
 * @param from Index the user is on now; `-1` when nothing is focused yet.
 * @param query The accumulated keystroke buffer.
 * @returns The index to move to, or `-1` to stay put.
 *
 * @example matchTypeAhead(["Apple", "Avocado", "Beet"], 0, "a") // 1 — the same letter moves on
 * @example matchTypeAhead(["Apple", "Avocado", "Beet"], 0, "ap") // 0 — a prefix may match in place
 */
export function matchTypeAhead(labels: readonly string[], from: number, query: string): number {
  if (labels.length === 0 || query === "") return -1;

  const repeated = [...query].every((char) => char === query[0]);
  const needle = (repeated ? query[0] : query).toLowerCase();
  const start = repeated ? from + 1 : from;

  for (let step = 0; step < labels.length; step += 1) {
    // `+ labels.length` keeps the operand positive: `from` is -1 when nothing is focused, and a
    // negative remainder in JavaScript is negative.
    const i = (start + step + labels.length) % labels.length;
    if ((labels[i] ?? "").trim().toLowerCase().startsWith(needle)) return i;
  }
  return -1;
}

What you get

The component source, copied into your project by npx astrocraft-ui add navigation/tree — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.