Tree View
stimeo--tree-view
A collapsible tree you can move through, open and close, and jump around by first letter.
Makes a folder-like tree workable from the keyboard, reading the parent and child structure from your nesting. Tab stops on the tree once. The up and down keys move between the items you can see, Home and End jump to the first and last, and typing a character jumps to the item starting with it. The right key opens a closed parent, or steps into its first child when it is already open. The left key closes an open parent, or goes up to the parent when it is not. Enter, Space or a click selects one. Opening, closing and selecting are each reported and announced.
-
app
-
controllers
- application_controller.rb
- components_controller.rb
- application.rb
-
controllers
- README.md
- Gemfile
Keyboard
| Key | Action |
|---|---|
| ↓ / ↑ | Move to the next / previous visible item. |
| → | Expand a collapsed parent, else step into the first child. Under RTL this is ArrowLeft. |
| ← | Collapse an expanded parent, else step to the parent item. Under RTL this is ArrowRight. |
| Home / End | Move to the first / last visible item. |
| Enter / Space | Select the item. |
| Printable characters | Typeahead to the next item whose label starts with the text; repeating one character cycles the matches. |
<%# Markup for the tree-view (tree view / single selection) demo.
Nested role="tree" / "treeitem" / "group" express the hierarchy. Arrows move between
visible items; ArrowRight / ArrowLeft expand/collapse and move between parent/child;
Home/End, typeahead, and Enter/Space select a single item. The library handles roving,
aria-expanded / aria-selected sync, and toggling each group's hidden. Across the whole
tree only one treeitem is tabindex=0.
The chevron is an optional pointer affordance wired to the toggle action. It stays
tabindex="-1" (the tree remains a single Tab stop) and aria-hidden (the item itself
already announces its expanded state); clicking it expands/collapses without selecting,
because the tree never consumes events raised inside a nested control. The toggle action
hands focus to the row afterwards, so focus never rests on this decorative button and
the arrow keys keep working right after a click. %>
<ul class="tree-view" data-controller="stimeo--tree-view" role="tree"
aria-label="<%= t('components.tree_view.demo.label') %>">
<li
class="tree-view__item"
role="treeitem"
aria-expanded="true"
aria-selected="false"
tabindex="0"
data-stimeo--tree-view-target="item"
data-action="keydown->stimeo--tree-view#onKeydown click->stimeo--tree-view#onClick">
<button type="button" class="tree-view__chevron" tabindex="-1" aria-hidden="true"
data-action="click->stimeo--tree-view#toggle"></button>
<span class="tree-view__label">app</span>
<ul class="tree-view__group" role="group" data-stimeo--tree-view-target="group">
<li
class="tree-view__item"
role="treeitem"
aria-expanded="false"
aria-selected="false"
tabindex="-1"
data-stimeo--tree-view-target="item"
data-action="keydown->stimeo--tree-view#onKeydown click->stimeo--tree-view#onClick">
<button type="button" class="tree-view__chevron" tabindex="-1" aria-hidden="true"
data-action="click->stimeo--tree-view#toggle"></button>
<span class="tree-view__label">controllers</span>
<ul class="tree-view__group" role="group" data-stimeo--tree-view-target="group" hidden>
<li class="tree-view__item" role="treeitem" aria-selected="false" tabindex="-1"
data-stimeo--tree-view-target="item"
data-action="keydown->stimeo--tree-view#onKeydown click->stimeo--tree-view#onClick">
<span class="tree-view__label">application_controller.rb</span>
</li>
<li class="tree-view__item" role="treeitem" aria-selected="false" tabindex="-1"
data-stimeo--tree-view-target="item"
data-action="keydown->stimeo--tree-view#onKeydown click->stimeo--tree-view#onClick">
<span class="tree-view__label">components_controller.rb</span>
</li>
</ul>
</li>
<li class="tree-view__item" role="treeitem" aria-selected="false" tabindex="-1"
data-stimeo--tree-view-target="item"
data-action="keydown->stimeo--tree-view#onKeydown click->stimeo--tree-view#onClick">
<span class="tree-view__label">application.rb</span>
</li>
</ul>
</li>
<li class="tree-view__item" role="treeitem" aria-selected="false" tabindex="-1"
data-stimeo--tree-view-target="item"
data-action="keydown->stimeo--tree-view#onKeydown click->stimeo--tree-view#onClick">
<span class="tree-view__label">README.md</span>
</li>
<li class="tree-view__item" role="treeitem" aria-selected="false" tabindex="-1"
data-stimeo--tree-view-target="item"
data-action="keydown->stimeo--tree-view#onKeydown click->stimeo--tree-view#onClick">
<span class="tree-view__label">Gemfile</span>
</li>
</ul>
/*
* Presentation-only styles for the tree-view demo.
* The library drives expand/collapse via items' aria-expanded and groups' hidden,
* selection via aria-selected, and roving via tabindex. Here we add the hierarchy
* indentation and the selection / expansion look (the triangle marker, highlight).
*/
.tree-view {
margin: 0;
padding: 0;
list-style: none;
min-width: 18rem;
font-size: 0.9rem;
color: var(--fg, var(--color-text));
}
.tree-view__group {
margin: 0;
padding-left: 1.1rem;
list-style: none;
}
.tree-view__group[hidden] {
display: none;
}
/* The chevron is absolutely positioned over the label's leading padding, so the
* label keeps its full-row hit area and highlight. */
.tree-view__item {
position: relative;
}
.tree-view__label {
display: block;
padding: 0.25rem 0.4rem 0.25rem 1.5rem;
border-radius: 0.25rem;
cursor: pointer;
}
/* Expand/collapse trigger; only items with children carry one. The glyph follows
* the item's aria-expanded, which the library keeps in sync. */
.tree-view__chevron {
position: absolute;
top: 0.25rem;
left: 0.3rem;
width: 1rem;
padding: 0;
border: 0;
background: none;
font: inherit;
line-height: 1.4;
color: var(--color-text-muted);
cursor: pointer;
}
.tree-view__chevron::before {
content: "▸";
}
.tree-view__item[aria-expanded="true"] > .tree-view__chevron::before {
content: "▾";
}
/* A theme-aware soft accent surface, not the raw `--vital-100`: this rule sets a
background but lets the text inherit `--color-text`, which flips with the theme.
A fixed light mint therefore paired light text on light in dark mode — measured
1.02:1, i.e. the row the keyboard is on was effectively invisible. */
.tree-view__item[aria-selected="true"] > .tree-view__label {
background: var(--color-primary-soft);
font-weight: 600;
}
.tree-view__item:focus {
outline: none;
}
.tree-view__item:focus-visible > .tree-view__label {
outline: 2px solid var(--accent, var(--color-primary));
outline-offset: -2px;
}
This demo needs no consumer-side JS (the controller handles the behavior).
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--tree-view"
Targets
| Name | Description | Attribute |
|---|---|---|
item
required
|
A treeitem; the whole tree is one Tab stop with arrow navigation, expand/collapse, and selection. Host it on a non-interactive element (li, div): a link or button host is out of contract, and the tree then stands down entirely. Items removed at runtime hand the tab stop — and focus, if it was inside them — to the neighbouring item. | data-stimeo--tree-view-target="item" |
group
|
A role=group holding a treeitem's children; its hidden state syncs with the parent's aria-expanded. Child containers are resolved by role=group, so the target is optional for behavior and recommended as the anchor for stimeo check. |
data-stimeo--tree-view-target="group" |
Actions
| Name | Description | Action |
|---|---|---|
onClick
|
Focuses and selects the clicked item (only the nearest treeitem to the target acts; clicks from a nested control or a child group's own box are ignored). | stimeo--tree-view#onClick |
onKeydown
|
Routes tree keys: arrows navigate/expand/collapse, Home/End, Enter/Space select, printable chars typeahead (keys raised inside a nested control, keys another handler already consumed via preventDefault, and keys during IME composition are all left alone). | stimeo--tree-view#onKeydown |
toggle
|
Expands or collapses the treeitem nearest to the event target, then gives that row focus and the tab stop so arrows keep working. A leaf is a no-op. Optional: wire it on a chevron so pointer users can reach child nodes. | stimeo--tree-view#toggle |
Events
| Name | Description | Event |
|---|---|---|
select
|
Dispatched when an item is selected; detail carries { item }. |
stimeo--tree-view:select |
toggle
|
Dispatched when a parent is expanded or collapsed; detail carries { item, expanded }. |
stimeo--tree-view:toggle |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
aria-expanded |
Parent item | Open/closed state of the child group. |
aria-selected |
Item | "true" on the selected item, "false" on the others. You can render the initial selection server-side; if several are marked, the first wins. |
aria-disabled |
Item | "true" keeps the item focusable but never selectable. |
tabindex |
Item | Roving: active=0, others=-1 (the tree is one Tab stop). |
hidden |
Group | Present when the parent is collapsed. |
hidden |
Item | Takes the item out of the move set: neither focus nor the only Tab stop lands on it. |