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