ツリービュー
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') %>">
<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;
}
このデモに固有の消費側 JS はありません(挙動はコントローラが担います)。
これらのスタイルは共通のデザイントークン(ライト/ダーク両対応)を使います。共通スタイルも一緒にコピーし、ルート要素の 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" |
アクション
| 名前 | 説明 | アクション |
|---|---|---|
onClick
|
クリックされた項目をフォーカスして選択する(ターゲットに最も近い treeitem のみが動作。項目内の入力欄やボタン、子 group の余白からのクリックは無視する)。 | stimeo--tree-view#onClick |
onKeydown
|
ツリーのキー操作を振り分ける。矢印で移動・開閉、Home/End、Enter/Space で選択、印字可能文字でタイプアヘッド(項目内の入力欄など操作要素からのキー、他のハンドラが preventDefault ですでに消費したキー、IME 変換中のキーはいずれも無視する)。 | stimeo--tree-view#onKeydown |
toggle
|
イベント対象に最も近い treeitem の開閉を反転し、その項目へフォーカスとタブストップを移す(矢印キーがそのまま続けられる。葉では無反応)。任意のアクションで、シェブロンなどに結ぶとポインタ操作でも子ノードへ到達できる。 | stimeo--tree-view#toggle |
イベント
| 名前 | 説明 | イベント |
|---|---|---|
select
|
項目が選択されると発火する。detail に { item } を含む。 |
stimeo--tree-view:select |
toggle
|
親項目が開閉されると発火する。detail に { item, expanded } を含む。 |
stimeo--tree-view:toggle |
状態フック
ライブラリが操作するのはこれらの ARIA / data 属性、カスタムプロパティだけです。見た目は利用側 CSS がこれらに反応して作ります([aria-selected] / [aria-expanded] / var(--stimeo--…) などのセレクタでフックします)。
| フック | 対象 | 意味 |
|---|---|---|
aria-expanded |
親項目 | 子グループの開閉状態。 |
aria-selected |
項目 | 選択中の項目に "true"、ほかの項目には "false"。サーバー側で初期選択を書いておける(複数書いた場合は先頭が勝つ)。 |
aria-disabled |
項目 | "true" の項目はフォーカスできるが選択されない。 |
tabindex |
項目 | ロービング: アクティブ=0 / 他=-1(ツリーは 1 タブストップ)。 |
hidden |
グループ | 親が折りたたみのとき付与。 |
hidden |
項目 | その項目を移動対象から外す。フォーカスも唯一のタブストップも乗らない。 |