Tree
Free · MITHeadless, 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
npx astrocraft-ui add navigation/treePlain-CSS theme — no build step
npx astrocraft-ui add navigation/tree --theme cssTailwind theme — needs Tailwind v4
npx astrocraft-ui add navigation/tree --theme tailwindPlain-CSS theme, re-pointed onto Lumos tokens
npx astrocraft-ui add navigation/tree --theme css --bridge lumosTailwind theme, re-pointed onto Lumos tokens
npx astrocraft-ui add navigation/tree --theme tailwind --bridge lumosLive 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
- Drafts
- Roadmap.md
- Release notes.md
- Downloads
- astrocraft-ui.zip
- 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.
| Component | Slots | Variants | Runtime state | Native 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 — 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 — 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 — 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 — 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>
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 — 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.