ツリービュー
stimeo--tree-view
折りたためる木構造。キーボードで移動、開閉、頭文字での検索ができます。
フォルダのような木構造を、キーボードで扱えるようにします。親子の関係はマークアップの入れ子から読み取ります。木全体で Tab は 1 回止まるだけです。上下キーで見えている項目を移動し、Home と End で先頭と末尾へ飛び、文字を打つとその頭文字の項目へ移ります。右キーは閉じている親を開き、開いていれば最初の子へ入ります。左キーは開いている親を閉じ、閉じていれば親へ戻ります。Enter か Space、クリックで 1 つ選べます。開閉と選択はそれぞれイベントになり、読み上げにも伝わります。
実行中
-
app
-
controllers
- application_controller.rb
- components_controller.rb
- application.rb
-
controllers
- README.md
- Gemfile
キーボード操作
| キー | 動作 |
|---|---|
| ↓ / ↑ | 次 / 前の可視項目へ。 |
| → | 折りたたみ中の親を展開、展開済みなら最初の子へ。RTL では ← の動作。 |
| ← | 展開中の親を折りたたみ、それ以外は親項目へ。RTL では → の動作。 |
| Home / End | 先頭 / 末尾の可視項目へ。 |
| Enter / Space | 項目を選択。 |
| 印字可能文字 | ラベルが先頭一致する次の項目へ型先読み。同一文字の連打では候補を巡回する。 |
<%# 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') %>">
<%# The hidden field carries the selected item's data-value into the form; both
the field and the values are optional. An item without a data-value submits
an empty string, so give one to every item that should be selectable. %>
<input type="hidden" name="path" data-stimeo--tree-view-target="field" />
<li
class="tree-view__item"
role="treeitem"
aria-expanded="true"
aria-selected="false"
tabindex="0"
data-value="app"
data-stimeo--tree-view-target="item"
data-action="keydown->stimeo--tree-view#onKeydown click->stimeo--tree-view#select">
<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-value="app/controllers"
data-stimeo--tree-view-target="item"
data-action="keydown->stimeo--tree-view#onKeydown click->stimeo--tree-view#select">
<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-value="app/controllers/application_controller.rb"
data-stimeo--tree-view-target="item"
data-action="keydown->stimeo--tree-view#onKeydown click->stimeo--tree-view#select">
<span class="tree-view__label">application_controller.rb</span>
</li>
<li class="tree-view__item" role="treeitem" aria-selected="false"
tabindex="-1" data-value="app/controllers/components_controller.rb"
data-stimeo--tree-view-target="item"
data-action="keydown->stimeo--tree-view#onKeydown click->stimeo--tree-view#select">
<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-value="app/application.rb"
data-stimeo--tree-view-target="item"
data-action="keydown->stimeo--tree-view#onKeydown click->stimeo--tree-view#select">
<span class="tree-view__label">application.rb</span>
</li>
</ul>
</li>
<li class="tree-view__item" role="treeitem" aria-selected="false"
tabindex="-1" data-value="README.md"
data-stimeo--tree-view-target="item"
data-action="keydown->stimeo--tree-view#onKeydown click->stimeo--tree-view#select">
<span class="tree-view__label">README.md</span>
</li>
<li class="tree-view__item" role="treeitem" aria-selected="false"
tabindex="-1" data-value="Gemfile"
data-stimeo--tree-view-target="item"
data-action="keydown->stimeo--tree-view#onKeydown click->stimeo--tree-view#select">
<span class="tree-view__label">Gemfile</span>
</li>
</ul>
<%# Readout: what the component just reported. Purely visual — the component
already announces what a screen reader needs, so this line is aria-hidden to
keep it from being read twice. The value is a code fragment (API identifiers
and values), so only the label is translated. %>
<p class="demo-readout" aria-hidden="true">
<span><%= t("catalog.readout.field") %></span>
<span class="demo-readout__value" data-readout
data-empty-text="<%= t("catalog.readout.empty") %>">—</span>
</p>
/*
* 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;
}
// The hidden field mirrors the value, and for a move the user made it emits the
// native `change` a form already listens to — so `stimeo--auto-submit`, form
// validation, or any `change` handler on the `<form>` reacts without knowing what
// this component is. A refresh that follows a morph or a repair is silent, and
// the component reports a selection the page moved — the selected item removed,
// a morph or a script rewriting an item's `aria-selected` or the selected item's
// `data-value`, a selected item arriving ahead of it — as
// `stimeo--tree-view:reconcile`. This demo reads both events, so the readout
// follows the user's moves and the page's.
// The page chrome uses some of these controllers too (the nav, the tab strip),
// so everything below is scoped to the demo itself.
const demoStage = document.querySelector(".demo-stage") ?? document;
const fieldReadout = demoStage.querySelector("[data-readout]");
const fieldSources = demoStage.querySelectorAll("[data-stimeo--tree-view-target='field']");
// An input without a `name` is not a submitted control, so it is left out.
const submitted = () =>
Array.from(demoStage.querySelectorAll("[data-stimeo--tree-view-target='field']"))
.filter((input) => input.name)
.map((input) => `${input.name}=${input.value}`)
.join(" & ") || fieldReadout?.dataset.emptyText || "";
const showSubmitted = () => {
if (fieldReadout) fieldReadout.textContent = submitted();
};
for (const source of fieldSources) source.addEventListener("change", showSubmitted);
demoStage.addEventListener("stimeo--tree-view:reconcile", showSubmitted);
showSubmitted();
これらのスタイルは共通のデザイントークン(ライト/ダーク両対応)を使います。共通スタイルも一緒にコピーし、ルート要素の data-theme を切り替えればダークになります。
このコンポーネントを動かすために HTML へ記述する data-* 属性です。ルート要素に下の data-controller を付け、その内側に各 target / value / action を配置します。
ルート要素に付与
data-controller="stimeo--tree-view"
ターゲット
| 名前 | 説明 | 属性 |
|---|---|---|
item
必須
|
treeitem。ツリー全体が一つの Tab ストップで、矢印ナビゲーション・開閉・選択を行う。ホストは li や div などの非操作要素にする(リンクやボタンを項目そのものにするのは契約の対象外で、その場合ツリーは一切反応しない)。実行時に削除された項目は、タブストップを(フォーカスがその中にあったならフォーカスも)隣の項目へ引き継ぐ。 | data-stimeo--tree-view-target="item" |
group
|
treeitem の子を保持する role=group。hidden 状態が親の aria-expanded と同期する。子コンテナの識別は role=group で行うため、この target は付けなくても動作するが、stimeo check の診断アンカーとして推奨する。 |
data-stimeo--tree-view-target="group" |
field
|
選択 item の data-value を写す任意の hidden input。値を持たない item は空文字。 | data-stimeo--tree-view-target="field" |
アクション
| 名前 | 説明 | アクション |
|---|---|---|
onKeydown
|
ツリーのキー操作を振り分ける。矢印で移動・開閉、Home/End、Enter/Space で選択、印字可能文字でタイプアヘッド(項目内の入力欄など操作要素からのキー、他のハンドラが preventDefault ですでに消費したキー、IME 変換中のキーはいずれも無視する)。 | stimeo--tree-view#onKeydown |
select
|
イベントまたは要素で指定した自部品の項目を選択する。要素指定でのフォーカス移動は、このツリー内にフォーカスがある場合だけ行う。項目内の入力欄やボタン、子 group の余白からのクリックは無視する。 | stimeo--tree-view#select |
toggle
|
イベント対象に最も近い treeitem の開閉を反転し、その項目へフォーカスとタブストップを移す(矢印キーがそのまま続けられる。葉では無反応)。任意のアクションで、シェブロンなどに結ぶとポインタ操作でも子ノードへ到達できる。 | stimeo--tree-view#toggle |
イベント
| 名前 | 説明 | イベント |
|---|---|---|
select
|
選択を報告し、detail に { item, reason } を運ぶ。reason で操作と要素指定の API 呼び出しを区別する。detail.reason は user / focus / pointer / api を報告する。 |
stimeo--tree-view:select |
toggle
|
展開状態の変化を報告し、detail に { item, expanded, reason } を運ぶ。detail.reason は user / focus / pointer / api を報告する。 |
stimeo--tree-view:toggle |
reconcile
|
選択項目の削除や項目を書き換える morph など、ページ側の変化で選択が動いたときに発火する。detail は { item } で、reason は含めない。選択が無くなれば item は null。接続時は発火しない。 |
stimeo--tree-view:reconcile |
状態フック
ライブラリが操作するのはこれらの ARIA / data 属性、カスタムプロパティだけです。見た目は利用側 CSS がこれらに反応して作ります([aria-selected] / [aria-expanded] / var(--stimeo--…) などのセレクタでフックします)。
| フック | 対象 | 意味 |
|---|---|---|
aria-expanded |
親項目 | 子グループの開閉状態。 |
aria-selected |
項目 | 選択中の項目に "true"、ほかの項目には "false"。サーバー側で初期選択を書いておける(複数書いた場合は先頭が勝つ)。 |
aria-disabled |
項目 | "true" の項目はフォーカスできるが選択されない。 |
tabindex |
項目 | ロービング: アクティブ=0 / 他=-1(ツリーは 1 タブストップ)。 |
hidden |
グループ | 親が折りたたみのとき付与。 |
hidden |
項目 | その項目を移動対象から外す。フォーカスも唯一のタブストップも乗らない。 |