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, shown without its comments; the files you install keep them, with the reasoning behind every rule.

src/components/ui/navigation/tree/Tree.astro
---
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"]';
  const TYPE_AHEAD_MS = 500;

  function ownText(item: HTMLElement): string {
    return [...item.childNodes]
      .filter((node) => !(node instanceof Element && node.getAttribute("role") === "group"))
      .map((node) => node.textContent ?? "")
      .join("")
      .trim();
  }

  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;

    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));

      const text = ownText(item);
      if (item.querySelector('[role="group"]') && text && !item.hasAttribute("aria-label")) {
        item.setAttribute("aria-label", text);
      }
    }

    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));
    };

    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":
          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":
          if (expanded === "false") setExpanded(item, true);
          else if (expanded === "true") {
            const child = item.querySelector<HTMLElement>(`[role="group"] > ${ITEM}`);
            if (child) focusItem(child);
          } else return;
          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: {
          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]);
        }
      }
      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;
      setExpanded(item, item.getAttribute("aria-expanded") === "false");
      select(item);
      focusItem(item);
    });
  }

  onReadyOnce('[data-slot="tree"]', wire);
</script>
src/components/ui/navigation/tree/TreeExpander.astro
---
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
---
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
---
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"li"> & {
  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
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) {
    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.