ツールバー
stimeo--toolbar
ボタンを並べた道具箱。Tab は 1 回だけ止まり、中は矢印キーで移動します。
ボタンを並べた道具箱です。WAI-ARIA の Toolbar パターンの実装で、まとまり全体で Tab は 1 回しか止まりません。中の移動は矢印キーです。横並びなら左右、縦並びなら上下で、Home と End で両端へ飛べます。端で回り込むか止まるかは選べます。クリックや外からフォーカスが入ったときも止まる位置が追いつくので、戻ってきたときは最後にいたボタンへ戻ります。使えないボタンは移動先から外れます。無効だと伝えたいボタンは、見つけられるようフォーカスは当たりますが押せません。状態があとから変わっても追随します。
実行中
キーボード操作
| キー | 動作 |
|---|---|
| → / ← | 次/前のコントロールへ(横向き)。RTL では左右が入れ替わる。 |
| ↓ / ↑ | 次/前のコントロールへ(縦向き)。 |
| Home / End | 先頭/末尾のコントロールへ。 |
<%# Markup for the toolbar demo.
Two toolbars share the same roving-tabindex behavior; only orientation-value differs,
so the arrow axis follows it: Left/Right for horizontal, Up/Down for vertical (Home/End
jump to the ends in both). A vertical toolbar also needs aria-orientation="vertical" —
like role and the accessible name, that ARIA is the author's, not the library's.
Each caption is the toolbar's accessible name via aria-labelledby. Keydown is delegated
on the toolbar container, so no control needs a data-action. Pressing/toggling each
button is each element's own responsibility. %>
<div class="toolbar-demo">
<div class="toolbar-demo__groups">
<%# Horizontal: arrows Left/Right. %>
<div class="toolbar-demo__group">
<span class="toolbar-demo__caption" id="toolbar-demo-horizontal">
<%= t("components.toolbar.demo.horizontal") %>
</span>
<div
class="toolbar"
data-controller="stimeo--toolbar"
role="toolbar"
aria-labelledby="toolbar-demo-horizontal"
data-stimeo--toolbar-orientation-value="horizontal">
<button type="button" class="toolbar__button" aria-pressed="false" tabindex="0"
data-stimeo--toolbar-target="control">
<%= t("components.toolbar.demo.bold") %>
</button>
<button type="button" class="toolbar__button" aria-pressed="false" tabindex="-1"
data-stimeo--toolbar-target="control">
<%= t("components.toolbar.demo.italic") %>
</button>
<button type="button" class="toolbar__button" aria-pressed="false" tabindex="-1"
data-stimeo--toolbar-target="control">
<%= t("components.toolbar.demo.underline") %>
</button>
<span class="toolbar__separator" role="separator" aria-orientation="vertical"></span>
<button type="button" class="toolbar__button" tabindex="-1"
data-stimeo--toolbar-target="control">
<%= t("components.toolbar.demo.link") %>
</button>
</div>
</div>
<%# Vertical: same controls, arrows Up/Down. The separator is horizontal here. %>
<div class="toolbar-demo__group">
<span class="toolbar-demo__caption" id="toolbar-demo-vertical">
<%= t("components.toolbar.demo.vertical") %>
</span>
<div
class="toolbar toolbar--vertical"
data-controller="stimeo--toolbar"
role="toolbar"
aria-labelledby="toolbar-demo-vertical"
aria-orientation="vertical"
data-stimeo--toolbar-orientation-value="vertical">
<button type="button" class="toolbar__button" aria-pressed="false" tabindex="0"
data-stimeo--toolbar-target="control">
<%= t("components.toolbar.demo.bold") %>
</button>
<button type="button" class="toolbar__button" aria-pressed="false" tabindex="-1"
data-stimeo--toolbar-target="control">
<%= t("components.toolbar.demo.italic") %>
</button>
<button type="button" class="toolbar__button" aria-pressed="false" tabindex="-1"
data-stimeo--toolbar-target="control">
<%= t("components.toolbar.demo.underline") %>
</button>
<span class="toolbar__separator" role="separator" aria-orientation="horizontal"></span>
<button type="button" class="toolbar__button" tabindex="-1"
data-stimeo--toolbar-target="control">
<%= t("components.toolbar.demo.link") %>
</button>
</div>
</div>
</div>
<%# "Link" deliberately has no pressed state: it is an action, not a toggle. Saying so
keeps its lack of visible feedback from reading as a broken control. %>
<p class="toolbar-demo__hint"><%= t("components.toolbar.demo.hint") %></p>
</div>
/*
* Presentation-only styles for the toolbar demo.
* The library toggles the controls' tabindex (active=0 / others=-1). The pressed-state
* look is built by reacting to each button's aria-pressed: a fill, a darker label and a
* heavier weight, so it does not rest on color alone. The focus ring is the only outline
* a control draws.
*/
.toolbar-demo {
display: flex;
flex-direction: column;
gap: 1rem;
align-items: flex-start;
}
.toolbar-demo__groups {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
align-items: flex-start;
}
.toolbar-demo__hint {
max-width: 52ch;
margin: 0;
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.toolbar-demo__group {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.toolbar-demo__caption {
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.toolbar {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.3rem;
border: 1px solid var(--border-strong);
border-radius: 0.5rem;
background: var(--surface, var(--surface-card));
}
.toolbar__button {
min-width: 2.25rem;
padding: 0.35rem 0.6rem;
/* Transparent, and it stays that way: the only ring on a control here is the
focus one. It is kept so every button reserves the same box whatever its
state. */
border: 1px solid transparent;
border-radius: 0.375rem;
background: none;
color: var(--fg, var(--color-text));
font: inherit;
cursor: pointer;
}
.toolbar__button:hover {
background: var(--surface-subtle);
}
.toolbar__button:focus-visible {
outline: 2px solid var(--accent, var(--color-primary));
outline-offset: 1px;
}
/* A fixed dark accent, not the theme-aware `--accent-700`: this text sits on
`--vital-100`, a raw ramp value that stays light in **both** themes, so a token that
lightens for dark would fail there (measured 2.5:1). */
.toolbar__button[aria-pressed="true"] {
background: var(--vital-100);
color: var(--vital-700);
font-weight: 600;
}
.toolbar__separator {
width: 1px;
align-self: stretch;
margin: 0.15rem 0.25rem;
background: var(--surface-subtle);
}
/* Vertical toolbar: stack the controls and lay the separator across the column. */
.toolbar--vertical {
flex-direction: column;
align-items: stretch;
}
.toolbar--vertical .toolbar__separator {
width: auto;
height: 1px;
margin: 0.25rem 0.15rem;
}
// Demo of each toolbar control's function (consumer-side JS).
//
// The core controller (stimeo--toolbar) only handles roving tabindex and arrow-key
// focus movement; what each button does (here, toggling aria-pressed) is the consumer's
// job. This shows the toolbar's "single tab stop + arrow movement" coexisting
// independently with each button's pressed state.
//
// Scoped to this demo's root rather than the whole document: a page-wide query would
// also claim toolbars this demo does not own. Note that "Link" is intentionally left
// out — it has no aria-pressed because it is an action, not a toggle.
const demoRoot = document.querySelector('.toolbar-demo');
demoRoot?.querySelectorAll('[data-controller~="stimeo--toolbar"]').forEach((toolbar) => {
toolbar.querySelectorAll('[aria-pressed]').forEach((button) => {
button.addEventListener('click', () => {
const pressed = button.getAttribute('aria-pressed') === 'true';
button.setAttribute('aria-pressed', pressed ? 'false' : 'true');
});
});
});
これらのスタイルは共通のデザイントークン(ライト/ダーク両対応)を使います。共通スタイルも一緒にコピーし、ルート要素の data-theme を切り替えればダークになります。
このコンポーネントを動かすために HTML へ記述する data-* 属性です。ルート要素に下の data-controller を付け、その内側に各 target / value / action を配置します。
ルート要素に付与
data-controller="stimeo--toolbar"
ターゲット
| 名前 | 説明 | 属性 |
|---|---|---|
control
必須
|
ツールバーのコントロール。矢印キーのロービングで一つの Tab ストップを形成する。 | data-stimeo--toolbar-target="control" |
値(Values)
| 名前 | 説明 | 属性 |
|---|---|---|
orientation
|
矢印キーの軸。horizontal または vertical。既定値は horizontal で、それ以外の値も horizontal として扱う。両軸が必要なときは Roving Tabindex(both を受け付ける)を使う。 |
data-stimeo--toolbar-orientation-value |
wrap
|
矢印移動が端を越えて循環するか。既定値は true。 | data-stimeo--toolbar-wrap-value |
アクション
| 名前 | 説明 | アクション |
|---|---|---|
onKeydown
|
矢印キー(向きに応じて)と Home/End でフォーカスと単一の Tab ストップを移動する。キー処理はコンテナ側で受け持つため、各コントロールへの data-action は任意(既存マークアップとの互換のために受け付ける)。 | stimeo--toolbar#onKeydown |
状態フック
ライブラリが操作するのはこれらの ARIA / data 属性、カスタムプロパティだけです。見た目は利用側 CSS がこれらに反応して作ります([aria-selected] / [aria-expanded] / var(--stimeo--…) などのセレクタでフックします)。
| フック | 対象 | 意味 |
|---|---|---|
tabindex |
コントロール | アクティブは 0、他は -1(単一タブストップ)。 |