パンくずリスト
stimeo--breadcrumb
幅が足りないとき、途中の階層を「…」に畳むパンくずです。
WAI-ARIA の Breadcrumb パターンに沿ったパンくずです。並びと現在地の印はマークアップが持ち、ここが受け持つのは幅に合わせた畳み方です。1 行に収まらないときは、あらかじめ指定しておいた途中の階層を隠し、代わりに「…」のボタンを出します。押すと全部の階層が開きます。幅が戻れば元どおり全部を出し、「…」は消えます。幅の変化も、項目や文言の差し替えも自分で見張るので、あとから中身が変わっても追随します。畳むときにフォーカス中の項目が隠れる場合は、先に「…」へフォーカスを移すので、キーボードの居場所を見失いません。区切り文字と見た目は利用側のものです。
実行中
キーボード操作
| キー | 動作 |
|---|---|
| Enter / Space | 省略ボタンで全項目を展開/再折りたたみする。 |
<%# Markup for the breadcrumb demo.
The APG structure (nav + ol + aria-current="page") lives in the markup; the
library only handles the responsive behavior of collapsing middle items into a
"…" disclosure button when width runs short. Separators are drawn by demo.css's
::after. aria-controls lists the id of *every* item the button collapses, so the
control relationship is complete rather than pointing at the first one only.
About the href values — this part is demo scaffolding, not a pattern to copy. A real
breadcrumb links to real paths and wants Turbo to handle them; replace the hrefs and
drop the data-turbo attribute when you copy this.
A catalog demo has nowhere real to go, and the usual placeholder `href="#"` is worse
than useless here: Turbo reads it as a visit to the current page, re-renders the body
from the server's HTML, and the trail you just expanded snaps shut (then flashes open
from the cached snapshot on the way back). Pointing at a same-page fragment only
helps on the first press — once the URL carries that hash, the next press is a
same-URL visit again and resets just the same, which is measurable in the browser.
So the demo opts out of Turbo for this subtree (data-turbo is inherited, so one
attribute covers every link): the browser then treats them as what they are, a scroll
to an element in this document, and the controller's state is never torn down. %>
<div id="breadcrumb-demo" class="breadcrumb-demo" data-turbo="false">
<%# The trail collapses only when it does not fit, so the demo needs a width it can
lose. Widen it past the full trail and nothing collapses and nothing scrolls;
narrow it and the middle items bank behind the "…". %>
<label class="demo-width-control">
<span><%= t("components.breadcrumb.demo.width") %></span>
<input type="range" min="220" max="640" value="352" data-breadcrumb-demo-width>
</label>
<nav
class="breadcrumb"
data-controller="stimeo--breadcrumb"
aria-label="<%= t("components.breadcrumb.demo.label") %>">
<ol class="breadcrumb__list" data-stimeo--breadcrumb-target="list">
<li class="breadcrumb__item">
<a href="#breadcrumb-demo"><%= t("components.breadcrumb.demo.home") %></a>
</li>
<li class="breadcrumb__item" data-stimeo--breadcrumb-target="ellipsis" hidden>
<button
type="button"
class="breadcrumb__ellipsis"
aria-expanded="false"
aria-controls="breadcrumb-collapsed-1 breadcrumb-collapsed-2 breadcrumb-collapsed-3"
aria-label="<%= t("components.breadcrumb.demo.expand") %>"
data-stimeo--breadcrumb-target="trigger"
data-action="click->stimeo--breadcrumb#toggle">…</button>
</li>
<li
class="breadcrumb__item"
id="breadcrumb-collapsed-1"
data-stimeo--breadcrumb-target="collapsible">
<a href="#breadcrumb-demo"><%= t("components.breadcrumb.demo.section") %></a>
</li>
<li
class="breadcrumb__item"
id="breadcrumb-collapsed-2"
data-stimeo--breadcrumb-target="collapsible">
<a href="#breadcrumb-demo"><%= t("components.breadcrumb.demo.subsection") %></a>
</li>
<li
class="breadcrumb__item"
id="breadcrumb-collapsed-3"
data-stimeo--breadcrumb-target="collapsible">
<a href="#breadcrumb-demo"><%= t("components.breadcrumb.demo.category") %></a>
</li>
<li class="breadcrumb__item">
<a href="#breadcrumb-demo" aria-current="page">
<%= t("components.breadcrumb.demo.current") %>
</a>
</li>
</ol>
</nav>
</div>
/*
* Presentation-only styles for the breadcrumb demo.
* Collapse/expand is the library toggling the hidden attribute of collapsible items
* and the ellipsis, plus the trigger's aria-expanded. Here we narrow the container
* to force overflow and draw the "/" separator with ::after.
*/
.breadcrumb-demo {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
/* The width the slider drives. The declared value is the starting point and the
fallback when the sidecar script is absent: narrow enough that the trail collapses,
so the component's own behavior is what greets the reader.
The frame is what makes the width legible — without it the container has no visible
edge and the trail appears to shorten for no reason. The controller reads both widths
off the list itself, so this padding does not skew its overflow check. */
.breadcrumb {
max-width: 22rem;
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 0.5rem;
overflow: hidden;
}
/* When the user expands the collapsed trail, let the row scroll instead of clipping it,
so every item — including the current page — can be reached inside the container. It
scrolls rather than spilling out: `overflow: visible` let the trail run past the edge
of a narrow card (measured at ~529px out of a 200px card), which is not something a
demo should be teaching. Only the inline axis is relaxed: the list stays nowrap so its
measured width is unchanged, which avoids retriggering the controller's
ResizeObserver-driven overflow check (wrapping it would make the trail re-collapse).
The "…" button doubles as the re-collapse toggle, so it stays in place.
Keyboard reach comes from the links inside the row, which are all in the tab order —
a scroll container reachable only by pointer would fail WCAG 2.1.1. `scroll-padding`
keeps the item a link scrolls into view clear of the sticky-feeling left edge. */
.breadcrumb:has([data-stimeo--breadcrumb-target="trigger"][aria-expanded="true"]) {
overflow-x: auto;
scroll-padding-inline: 0.25rem;
}
.breadcrumb__list {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 0.25rem;
margin: 0;
padding: 0;
list-style: none;
white-space: nowrap;
}
.breadcrumb__item {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.875rem;
}
/* Collapsing is expressed purely through the hidden attribute, and the author-origin
`display: inline-flex` above always beats the UA's `[hidden] { display: none }`
(origin, not specificity — reordering or lowering specificity does not help). Restore
it here so this file works on its own wherever it is pasted. */
.breadcrumb [hidden] {
display: none !important;
}
.breadcrumb__item:not(:last-child)::after {
content: "/";
color: var(--color-text-subtle);
}
/* The theme-aware accent *text* token, not the raw brand accent: the latter measures
2.86:1 on the light canvas (#0ea88f on #f8fafc), below the 4.5:1 floor for body-size
text. Same substitution the rest of the catalog already carries. */
.breadcrumb__item a {
color: var(--accent-700);
text-decoration: none;
}
.breadcrumb__item a[aria-current="page"] {
color: var(--fg, var(--color-text));
font-weight: 600;
pointer-events: none;
}
.breadcrumb__ellipsis {
padding: 0 0.25rem;
border: 0;
background: none;
color: var(--color-text-muted);
font: inherit;
line-height: 1;
cursor: pointer;
}
.breadcrumb__ellipsis:hover {
color: var(--fg, var(--color-text));
}
.breadcrumb__ellipsis:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
// Breadcrumb demo (consumer-side JS).
//
// The controller needs no knob of its own — it watches the trail with a ResizeObserver
// and collapses the middle items when they stop fitting. This slider only changes the
// width they have to fit into, so the threshold can be crossed in both directions:
// wide enough and the whole trail shows with no scrolling, narrow and it banks behind
// the "…".
document.querySelectorAll(".breadcrumb-demo").forEach((root) => {
const trail = root.querySelector('[data-controller~="stimeo--breadcrumb"]');
const range = root.querySelector("[data-breadcrumb-demo-width]");
if (!trail || !range) return;
const apply = () => {
trail.style.maxWidth = `${range.value}px`;
};
apply();
range.addEventListener("input", apply);
});
これらのスタイルは共通のデザイントークン(ライト/ダーク両対応)を使います。共通スタイルも一緒にコピーし、ルート要素の data-theme を切り替えればダークになります。
このコンポーネントを動かすために HTML へ記述する data-* 属性です。ルート要素に下の data-controller を付け、その内側に各 target / value / action を配置します。
ルート要素に付与
data-controller="stimeo--breadcrumb"
ターゲット
| 名前 | 説明 | 属性 |
|---|---|---|
list
必須
|
<ol> のパンくず列コンテナ。その幅を測ってオーバーフローを検知する。 |
data-stimeo--breadcrumb-target="list" |
collapsible
|
著者が印付けした中間項目。列が折りたたまれると非表示になる。 | data-stimeo--breadcrumb-target="collapsible" |
ellipsis
|
オーバーフロー時に折りたたんだ項目の代わりに表示される省略記号 (…) 項目。 | data-stimeo--breadcrumb-target="ellipsis" |
trigger
|
列を展開・再折りたたみするディスクロージャーボタン。aria-expanded が同期される。 |
data-stimeo--breadcrumb-target="trigger" |
アクション
| 名前 | 説明 | アクション |
|---|---|---|
toggle
|
列を展開または再折りたたみし、toggle イベントを発火する。収まっている間は何もしない。 | stimeo--breadcrumb#toggle |
update
|
列を測り直して描画し直す。Web フォントの差し替えなど、リサイズ/変更の監視で拾えない変化のあとに呼ぶ。 | stimeo--breadcrumb#update |
イベント
| 名前 | 説明 | イベント |
|---|---|---|
toggle
|
ユーザーが列を展開・再折りたたみすると発火する。detail に { expanded } を含む。 |
stimeo--breadcrumb:toggle |
状態フック
ライブラリが操作するのはこれらの ARIA / data 属性、カスタムプロパティだけです。見た目は利用側 CSS がこれらに反応して作ります([aria-selected] / [aria-expanded] / var(--stimeo--…) などのセレクタでフックします)。
| フック | 対象 | 意味 |
|---|---|---|
hidden |
折りたたみ項目 | 折りたたみ時に付与、展開時や収まるときは除去。 |
hidden |
省略項目 | 溢れている間は除去、収まるときや折りたたみ対象が無いときは付与。 |
aria-expanded |
トリガー | 折りたたみ項目を展開している間は "true"。収まっている間は常に "false"。 |