---
/**
* FileTree — a project tree of folders that open with an animated height,
* zero JS.
*
* Folders are native <details>: the click, the keyboard and the open state
* are the browser's; the height animates through ::details-content laid
* out as a grid whose only row goes 0fr → 1fr (the Accordion technique),
* the chevron turns and the folder glyph opens. `open` lists the paths to
* start open; `highlight` the files to light up ("this is the one you
* change"). Semantics are nested lists — role="tree" is not claimed because
* arrow-key navigation is not provided. Renders itself for each level.
*/
import type { HTMLAttributes } from 'astro/types';
export interface Node {
name: string;
children?: Node[];
/** Light this row up. */
highlight?: boolean;
/** Start open (folders); also settable through the `open` prop by path. */
open?: boolean;
}
interface Props extends HTMLAttributes<'div'> {
tree: Node[];
/** Paths to start open, e.g. ['src', 'src/pages']. */
open?: string[];
/** Paths to light up, e.g. ['src/pages/index.astro']. */
highlight?: string[];
/** Accessible name of the tree. */
label?: string;
/** Internal: the path of this level. */
path?: string;
/** Internal: nested level. */
nested?: boolean;
}
const { tree, open = [], highlight = [], label = 'Files', path = '', nested = false, class: className, ...rest } = Astro.props;
const join = (a: string, b: string) => (a ? `${a}/${b}` : b);
const Level = Astro.self;
---
{
nested ? (
<ul class="ma-ftree__list">
{tree.map((n) => {
const p = join(path, n.name);
return n.children ? (
<li class="ma-ftree__folder">
<details class="ma-ftree__details" open={n.open || open.includes(p)}>
<summary class="ma-ftree__row" data-highlight={n.highlight || highlight.includes(p) ? '' : undefined}>
<svg class="ma-ftree__chevron" aria-hidden="true" viewBox="0 0 16 16" width="12" height="12"><path d="M6 4l4 4-4 4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></svg>
<svg class="ma-ftree__glyph" aria-hidden="true" viewBox="0 0 20 20" width="16" height="16">
<path class="ma-ftree__folder-back" d="M2 5.5A1.5 1.5 0 0 1 3.5 4h4l2 2h7A1.5 1.5 0 0 1 18 7.5v7A1.5 1.5 0 0 1 16.5 16h-13A1.5 1.5 0 0 1 2 14.5z" fill="currentColor" opacity="0.35" />
<path class="ma-ftree__folder-flap" d="M2 8.5h16v6A1.5 1.5 0 0 1 16.5 16h-13A1.5 1.5 0 0 1 2 14.5z" fill="currentColor" />
</svg>
<span class="ma-ftree__name">{n.name}</span>
</summary>
<div class="ma-ftree__body">
<Level tree={n.children} open={open} highlight={highlight} path={p} nested />
</div>
</details>
</li>
) : (
<li class="ma-ftree__file">
<span class="ma-ftree__row" data-highlight={n.highlight || highlight.includes(p) ? '' : undefined}>
<svg class="ma-ftree__glyph" aria-hidden="true" viewBox="0 0 20 20" width="16" height="16"><path d="M5 2h7l4 4v11a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" /><path d="M12 2v4h4" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" /></svg>
<span class="ma-ftree__name">{n.name}</span>
</span>
</li>
);
})}
</ul>
) : (
<div class:list={['ma-ftree', className]} role="group" aria-label={label} {...rest}>
<Level tree={tree} open={open} highlight={highlight} path={path} nested />
</div>
)
}
<style is:global>
@layer components {
:where(.ma-ftree) {
font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
font-size: 0.8125rem;
line-height: 1.4;
}
:where(.ma-ftree__list) {
margin: 0;
padding: 0;
list-style: none;
}
/* nested levels indent behind a guide line */
:where(.ma-ftree__list .ma-ftree__list) {
margin-inline-start: 0.8rem;
padding-inline-start: 0.7rem;
border-inline-start: 1px solid var(--ma-edge);
}
:where(.ma-ftree__row) {
position: relative;
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.25rem 0.5rem;
border-radius: 0.375rem;
color: inherit;
transition: background-color var(--ma-duration-fast) var(--ma-ease-out);
}
:where(.ma-ftree__file .ma-ftree__row) {
padding-inline-start: 1.55rem;
}
:where(summary.ma-ftree__row) {
cursor: pointer;
list-style: none;
font-weight: 500;
}
:where(summary.ma-ftree__row)::-webkit-details-marker {
display: none;
}
:where(summary.ma-ftree__row:hover) {
background: color-mix(in srgb, var(--ma-ink) 6%, transparent);
}
:where(summary.ma-ftree__row:focus-visible) {
outline: 2px solid currentColor;
outline-offset: -2px;
}
:where(.ma-ftree__glyph) {
flex: none;
opacity: 0.75;
}
:where(.ma-ftree__chevron) {
flex: none;
opacity: 0.55;
transition: rotate var(--ma-duration) var(--ma-ease);
}
/* right to left the closed chevron points left, into the line it starts (a half turn of a symmetric glyph), and the
open rule below turns it the other way to point down. [dir] and not :dir(): Chrome has :dir() only from 120, and a
build that targets an older Chrome rewrites it into a guess from lang, which a page that sets only dir never
matches. An ltr island one level inside an rtl page is set back */
:where([dir='rtl'] .ma-ftree__chevron, .ma-ftree[dir='rtl'] .ma-ftree__chevron) {
rotate: 180deg;
}
:where([dir='rtl'] [dir='ltr'] .ma-ftree__chevron, [dir='rtl'] .ma-ftree[dir='ltr'] .ma-ftree__chevron) {
rotate: none;
}
:where(.ma-ftree__details[open] > .ma-ftree__row .ma-ftree__chevron) {
rotate: 90deg;
}
/* the folder opens: the flap tips down from its bottom edge */
:where(.ma-ftree__folder-flap) {
transform-box: fill-box;
transform-origin: 50% 100%;
transition: transform var(--ma-duration) var(--ma-ease);
}
:where(.ma-ftree__details[open] > .ma-ftree__row .ma-ftree__folder-flap) {
transform: skewX(-16deg) scaleY(0.82);
}
/* the animated part: the content box is a grid whose only row interpolates 0fr → 1fr */
:where(.ma-ftree__details)::details-content {
display: grid;
grid-template-rows: 0fr;
transition:
grid-template-rows var(--ma-duration) var(--ma-ease),
content-visibility var(--ma-duration) allow-discrete;
}
:where(.ma-ftree__details[open])::details-content {
grid-template-rows: 1fr;
}
:where(.ma-ftree__body) {
min-height: 0;
overflow: clip;
}
/* the highlighted row: a tint and a dot in the accent, default the ink */
:where(.ma-ftree__row[data-highlight]) {
background: color-mix(in srgb, var(--ma-ftree-accent, var(--ma-ink)) 10%, transparent);
color: var(--ma-ftree-accent, inherit);
font-weight: 600;
}
:where(.ma-ftree__row[data-highlight])::after {
content: '';
position: absolute;
top: 50%;
inset-inline-end: 0.6rem;
width: 6px;
height: 6px;
border-radius: 999px;
background: var(--ma-ftree-accent, currentColor);
translate: 0 -50%;
}
/* ::details-content in a rule of its own: in an engine that does not know it, a selector list that names it is
dropped whole, and the chevron, the flap and the rows kept their transitions under reduced motion. The minifier merges
neighbouring rules with the same declarations back into one list, so this one sits behind a feature test */
@media (prefers-reduced-motion: reduce) {
@supports selector(::details-content) {
:where(.ma-ftree__details)::details-content {
transition: none;
}
}
:where(.ma-ftree__chevron),
:where(.ma-ftree__folder-flap),
:where(.ma-ftree__row) {
transition: none;
}
}
}
</style>