File Tree
A file browser rendered as a real ARIA tree. Opening a folder animates its
wrapper from the measured scrollHeight to 0 and back, while the chevron
rotates 90° on a small spring. The subtree stays mounted while collapsed so the
height has something to measure; inert and aria-hidden keep those rows out
of the tab order and the accessibility tree in the meantime.
Rows come from flattening the visible nodes every time the expansion changes, and that flat list is what keyboard navigation walks: ArrowDown and ArrowUp move between visible rows, ArrowRight opens a folder or steps into it, ArrowLeft closes it or climbs to the parent, Home and End jump to the edges, and typing a letter cycles through names that start with it.
Usage
Pass recursive items. Each node needs a unique id, a name and a type;
folders take children. Seed the open folders with default-expanded and turn
on show-lines for guide rails between levels. Rows are selected on click, and
a folder click also toggles it.
<template>
<div class="flex w-full justify-center py-6">
<MFileTree
:items="items"
:default-expanded="['src', 'src/components']"
show-lines
class="w-full max-w-sm rounded-lg border border-default bg-default p-3"
/>
</div>
</template>
<script setup lang="ts">
interface TreeItem {
id: string
name: string
type: 'file' | 'folder'
children?: TreeItem[]
badge?: string
}
const items: TreeItem[] = [
{
id: 'src',
name: 'src',
type: 'folder',
children: [
{
id: 'src/components',
name: 'components',
type: 'folder',
children: [
{ id: 'src/components/button', name: 'Button.vue', type: 'file' },
{ id: 'src/components/card', name: 'Card.vue', type: 'file' },
{
id: 'src/components/file-tree',
name: 'FileTree.vue',
type: 'file',
badge: 'modified',
},
],
},
{ id: 'src/app', name: 'App.vue', type: 'file' },
{ id: 'src/main', name: 'main.ts', type: 'file' },
],
},
{
id: 'docs',
name: 'docs',
type: 'folder',
children: [
{ id: 'docs/readme', name: 'README.md', type: 'file' },
],
},
{ id: 'package', name: 'package.json', type: 'file' },
]
</script>
Customize
expanded and selected are both optional. Bind them and the parent owns the
state; leave them off and the tree keeps its own copy. update:expanded and
update:selected fire in either case, so the panel below can mirror clicks and
keyboard toggles without reaching into the tree. indent is the number of pixels
added per level, and show-lines draws a one-pixel guide down each open group.
Customize
Props
| Prop | Type | Default | Description |
|---|---|---|---|
itemsrequired | FileTreeItem[] | - | Recursive tree data. A node is { id, name, type, children?, icon?, badge? },
where type is 'file' or 'folder'. id must be unique across the whole
tree: it keys expansion, selection and row focus. |
expanded | string[] | - | Controlled list of open folder ids. Leave it out to let the component keep its
own list, seeded from defaultExpanded. |
defaultExpanded | string[] | [] | Folder ids open on first render when expanded is not bound. Later changes to
this prop are ignored, like any other default. |
selected | string | - | Controlled id of the selected row. Leave it out to let clicks and keys set the selection internally. |
indent | number | 16 | Horizontal indent per level, in pixels. It also spaces the guide line inside each open group. |
showLines | boolean | false | Draw a vertical guide down the left edge of every open subtree. |
class | string | - | Extra classes for the root element. |
ui | Record<string, any> | - | Per-slot classes merged over the theme defaults. See UI for the slot list. |
Events
| Event | Payload | Description |
|---|---|---|
update:expanded | (expanded: string[]) => void | The next list of open folder ids, emitted on click, Enter, Space, ArrowRight and ArrowLeft. Emitted only when the list actually changes. |
update:selected | (id: string) => void | The id of the row that was clicked, or activated with Enter or Space. Clicking
a folder emits this as well as update:expanded, because selecting and
toggling are separate gestures. |
UI
This component's theme exposes the slots below. Override any of them per instance with the ui prop, or app-wide under ui.fileTree in app.config.ts.
| Prop | Type | Default | Description |
|---|---|---|---|
ui | object | - | Per-slot classes merged over the theme defaults. |
| Slot | Props | Description |
|---|---|---|
root | - | The role="tree" wrapper. |
row | - | One role="treeitem" row. Receives selected as a variant. |
chevronBox | - | The fixed-size box that reserves room for the chevron. |
chevron | - | The rotating chevron itself. |
icon | - | The folder or file glyph. |
label | - | The node name. Receives selected as a variant. |
badge | - | Optional trailing text, such as a size or a status. |
group | - | The role="group" container inside a subtree. Receives lines as a variant. |
Accessibility
- The root is
role="tree", every row isrole="treeitem"witharia-level, and every open subtree is arole="group". Folders exposearia-expanded; files omit it. Selection is mirrored witharia-selected. - Focus is roving: exactly one row has
tabindex="0"— the focused row, or the selected row, or the first visible row — and the rest sit at-1. Tab enters and leaves the tree once instead of stepping through every file. - Arrow keys move between visible rows, so a collapsed folder's children are never focused. ArrowRight opens a closed folder or moves to its first child; ArrowLeft closes an open folder or moves to its parent. Home and End jump to the first and last visible rows.
- Enter and Space select the focused row. Typing a letter jumps to the next visible name starting with it, wrapping around the list.
- A collapsed subtree stays in the DOM for its height animation but is marked
inertandaria-hidden, so its rows cannot be tabbed to or announced. - Rows carry a
focus-visiblering in the theme, so keyboard focus is visible without adding a second hover style. - Under
prefers-reduced-motionthe chevron and the subtree transition collapse to{ duration: 0 }. The open state still commits immediately; only the rotation and the unfold are removed.
Performance
- The visible rows are flattened in one
computedkeyed onitemsand the expanded set. A second map from id to index makes every key press an O(1) lookup instead of a scan. - Heights are measured only when a folder's open state changes. Once an open
subtree finishes animating it rests at
height: auto, so later content changes do not trigger another measurement pass. - Only the chevron and the subtree wrapper are motion components. Row markup
stays static, and the animation writes
heightandtransformwithout re-rendering the tree. - There is no
ResizeObserver, no scroll listener and norequestAnimationFrameloop. Indentation and guide offsets are plain inline styles computed from the node's level. - Collapsed subtrees remain mounted by necessity, which trades a little DOM for
a height that can be measured and animated. Their rows are
inert, so the hidden cost does not extend to the tab order or the accessibility tree.