タブ
stimeo--tabs
タブの切り替え。矢印キーで移動すると、そのタブがすぐ選ばれます。
WAI-ARIA の Tabs パターンの実装です。矢印キーでタブを移ると、移った先がその場で選ばれます。タブの列で Tab は 1 回止まるだけなので、キーボードでページを辿るときに邪魔になりません。どのタブが選ばれているかは読み上げにも伝わり、対応するパネルだけが表示されます。タブとパネルは並び順で対応づきます。
実行中
概要コンテンツを表示しています。
プロフィールコンテンツを表示しています。
お支払い履歴コンテンツを表示しています。
キーボード操作
| キー | 動作 |
|---|---|
| → / ← | 次 / 前のタブを選択する(端で循環)。RTL では左右が入れ替わる。 |
| Home / End | 最初 / 最後のタブを選択する。 |
<%# Markup for the tabs (APG Tabs / automatic activation) demo.
stimeo--tabs provides aria-selected, roving tabindex, arrow-key switching, and panel
visibility control. The initially selected tab has aria-selected="true" and
non-selected panels carry hidden (to prevent SSR flicker). %>
<div class="tabs" data-controller="stimeo--tabs">
<div class="tabs__list" role="tablist" aria-label="<%= t("components.tabs.demo.label") %>"
data-stimeo--tabs-target="list">
<button class="tabs__tab" role="tab" id="tab-overview" aria-controls="panel-overview"
aria-selected="true"
data-stimeo--tabs-target="tab"
data-action="click->stimeo--tabs#select
keydown->stimeo--tabs#onKeydown"><%= t(
"components.tabs.demo.overview"
) %></button>
<button class="tabs__tab" role="tab" id="tab-profile" aria-controls="panel-profile"
aria-selected="false" tabindex="-1"
data-stimeo--tabs-target="tab"
data-action="click->stimeo--tabs#select
keydown->stimeo--tabs#onKeydown"><%= t(
"components.tabs.demo.profile"
) %></button>
<button class="tabs__tab" role="tab" id="tab-billing" aria-controls="panel-billing"
aria-selected="false" tabindex="-1"
data-stimeo--tabs-target="tab"
data-action="click->stimeo--tabs#select
keydown->stimeo--tabs#onKeydown"><%= t(
"components.tabs.demo.billing"
) %></button>
</div>
<div class="tabs__panel" role="tabpanel" id="panel-overview" aria-labelledby="tab-overview"
data-stimeo--tabs-target="panel"><%= t("components.tabs.demo.overview_content") %></div>
<div class="tabs__panel" role="tabpanel" id="panel-profile" aria-labelledby="tab-profile"
data-stimeo--tabs-target="panel" hidden><%= t(
"components.tabs.demo.profile_content"
) %></div>
<div class="tabs__panel" role="tabpanel" id="panel-billing" aria-labelledby="tab-billing"
data-stimeo--tabs-target="panel" hidden><%= t(
"components.tabs.demo.billing_content"
) %></div>
</div>
<%# 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.events") %></span>
<span class="demo-readout__value" data-readout
data-change-template="<%= t("catalog.readout.tabs_change") %>">—</span>
</p>
/*
* Presentation-only styles for the tabs demo.
* The selected state is expressed via aria-selected (set by the library).
*/
.tabs__list {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid var(--border);
}
.tabs__tab {
padding: 0.5rem 1rem;
font-size: 0.95rem;
cursor: pointer;
color: var(--muted);
background: transparent;
border: 0;
border-bottom: 2px solid transparent;
}
.tabs__tab[aria-selected="true"] {
color: var(--fg);
border-bottom-color: var(--accent);
}
.tabs__panel {
padding: 1rem 0.25rem;
}
// Selecting a tab reports which one moved: `index` is the new tab, `previous` the
// one it came from, and `total` how many there are. Reselecting the tab that is
// already selected says nothing. A real app uses this to sync a URL fragment or
// load a panel lazily; this demo writes the numbers into the readout.
//
// The event counts from 0, the way an array does. The readout counts from 1,
// the way the tabs read on screen — so "1 -> 2 of 3" is the first tab moving to
// the second. Same convention as the carousel and sortable demos.
//
// 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 stateReadout = demoStage.querySelector("[data-readout]");
const stateRoot = demoStage.querySelector("[data-controller~='stimeo--tabs']");
stateRoot?.addEventListener("stimeo--tabs:change", (event) => {
const { index, total, previous } = event.detail;
if (!stateReadout) return;
// The words come from the ERB, already localized; only the numbers are ours.
const ordinal = (position) => String(position + 1);
const moved = (stateReadout.dataset.changeTemplate ?? "{previous} -> {index} of {total}")
.replace("{previous}", ordinal(previous))
.replace("{index}", ordinal(index))
.replace("{total}", String(total));
stateReadout.textContent = `stimeo--tabs:change · ${moved}`;
});
これらのスタイルは共通のデザイントークン(ライト/ダーク両対応)を使います。共通スタイルも一緒にコピーし、ルート要素の data-theme を切り替えればダークになります。
このコンポーネントを動かすために HTML へ記述する data-* 属性です。ルート要素に下の data-controller を付け、その内側に各 target / value / action を配置します。
ルート要素に付与
data-controller="stimeo--tabs"
ターゲット
| 名前 | 説明 | 属性 |
|---|---|---|
tab
必須
|
タブボタン(role=tab)。同じインデックスのパネルと対応し、aria-selected とロービング tabindex を持つ。 |
data-stimeo--tabs-target="tab" |
panel
必須
|
タブパネル(role=tabpanel)。hidden 属性で対応タブの選択状態を反映する。 |
data-stimeo--tabs-target="panel" |
list
必須
|
data-stimeo--tabs-target="list" |
アクション
| 名前 | 説明 | アクション |
|---|---|---|
onKeydown
|
矢印/Home/End によるナビゲーション。自動アクティベーションでフォーカス先のタブを選択する。 | stimeo--tabs#onKeydown |
select
|
クリックされたタブを選択し、aria-selected・tabindex・パネルの表示を更新する。 |
stimeo--tabs#select |
イベント
| 名前 | 説明 | イベント |
|---|---|---|
change
|
選択が動いた直後に発火。detail に index / total / previous を載せる。detail.reason は user / focus / pointer / api を報告する。 |
stimeo--tabs:change |
状態フック
ライブラリが操作するのはこれらの ARIA / data 属性、カスタムプロパティだけです。見た目は利用側 CSS がこれらに反応して作ります([aria-selected] / [aria-expanded] / var(--stimeo--…) などのセレクタでフックします)。
| フック | 対象 | 意味 |
|---|---|---|
aria-selected |
各タブ | 選択中のタブに "true"、ほかのタブには "false"。サーバー側で初期タブを書いておける(複数書いた場合は先頭が勝つ)。 |
tabindex |
各タブ | 選択中のタブは 0、それ以外は -1(ローヴィング tabindex)。 |
hidden |
各パネル | 選択中のタブのパネルだけ表示される。 |