カルーセル
stimeo--carousel
一時停止できる自動再生と、ロービングするスライドピッカーのタブリストを備えたスライドショー。
stimeo--carousel コントローラは、WAI-ARIA の Carousel(タブ付き)パターンを実装する。スライド移動(next / prev / goto)を行い、現在スライドを data-state と hidden 属性で同期し(非アクティブスライドはフォーカス順から外れる)、対応するピッカーの aria-selected と単一のロービング tabindex を連動させる。再生/停止トグルは自動再生状態を aria-pressed に映す。自動再生は WCAG 2.2.2 に従い、ポインタのホバー中は一時停止し、キーボードフォーカスが入ると確実に停止する(フォーカスが外れても勝手に再開しない)ので、キーボード利用者が不意の動きに驚かされない。タイマーは disconnect(Turbo 遷移含む)でクリアする。ピッカー上の矢印キーはフォーカス移動のみ(手動アクティベーション)、Home/End で先頭/末尾のスライドへ移動する。ライブラリは振る舞いのみを提供し、遷移やレイアウトは利用側が所有する。
アクセシビリティ配慮(WCAG 2.2.2)として、自動再生はマウスを乗せている間は一時停止し、キーボードフォーカスがカルーセル内に入ると停止します(不意の動きで操作を妨げないため。フォーカスが外れても自動再開はしません)。再生ボタンでいつでも再開できます。
キーボード操作
| キー | 動作 |
|---|---|
| Enter / Space | ボタンを実行(前へ/次へ/再生停止/スライド選択)。 |
| → / ← | 次/前のスライドピッカーへフォーカス移動(ロービング)。 |
| Home / End | 先頭/末尾のスライドをアクティブにする。 |
<%# Markup for the carousel demo.
The library handles slide advance, autoplay, syncing the current slide's
data-state / hidden, and the pickers' (tabs) aria-selected and roving.
Autoplay deliberately demonstrates WCAG 2.2.2: it pauses while the pointer hovers
and hard-stops when keyboard focus enters the carousel (it does not auto-resume on
focus out — press play). This is intentional and is spelled out in the on-screen
note below the carousel. Transitions and layout are the consumer's CSS. %>
<section
class="carousel"
data-controller="stimeo--carousel"
aria-roledescription="carousel"
aria-label="<%= t("components.carousel.demo.label") %>"
data-stimeo--carousel-autoplay-value="false"
data-stimeo--carousel-interval-value="2500"
data-stimeo--carousel-loop-value="true"
data-action="
mouseenter->stimeo--carousel#pause
mouseleave->stimeo--carousel#resume
focusin->stimeo--carousel#pause
focusout->stimeo--carousel#resume">
<div class="carousel__bar">
<button
type="button"
class="carousel__play"
aria-pressed="false"
aria-label="<%= t("components.carousel.demo.autoplay") %>"
data-stimeo--carousel-target="playToggle"
data-action="click->stimeo--carousel#togglePlay">
<%# The library toggles aria-pressed; demo.css swaps the glyph off it so the
control visibly reflects play (❚❚) vs. paused (▶). The icons are decorative;
the accessible name comes from aria-label above. %>
<span class="carousel__play-icon carousel__play-icon--play" aria-hidden="true">▶</span>
<span class="carousel__play-icon carousel__play-icon--pause" aria-hidden="true">❚❚</span>
</button>
<p
class="carousel__status"
data-carousel-status
data-template="<%= t("components.carousel.demo.status_template") %>"
data-playing-suffix="<%= t("components.carousel.demo.status_playing") %>"
aria-live="polite"></p>
</div>
<div class="carousel__viewport" data-stimeo--carousel-target="viewport">
<% t("components.carousel.demo.slides").each_with_index do |slide, i| %>
<div
id="carousel-slide-<%= i + 1 %>"
class="carousel__slide"
role="tabpanel"
aria-roledescription="slide"
aria-label="<%= "#{i + 1} of #{t('components.carousel.demo.slides').size}" %>"
aria-labelledby="carousel-dot-<%= i + 1 %>"
data-stimeo--carousel-target="slide"
<%= "hidden" if i.positive? %>>
<h3 class="carousel__title"><%= slide[:title] %></h3>
<p><%= slide[:body] %></p>
</div>
<% end %>
</div>
<div class="carousel__nav">
<button type="button" class="carousel__arrow"
aria-label="<%= t("components.carousel.demo.prev") %>"
data-stimeo--carousel-target="prev"
data-action="click->stimeo--carousel#prev">‹</button>
<button type="button" class="carousel__arrow"
aria-label="<%= t("components.carousel.demo.next") %>"
data-stimeo--carousel-target="next"
data-action="click->stimeo--carousel#next">›</button>
</div>
<div class="carousel__dots" role="tablist"
aria-label="<%= t("components.carousel.demo.tablist") %>">
<% t("components.carousel.demo.slides").each_with_index do |slide, i| %>
<button
id="carousel-dot-<%= i + 1 %>"
class="carousel__dot"
role="tab"
aria-selected="<%= i.zero? %>"
aria-controls="carousel-slide-<%= i + 1 %>"
aria-label="<%= slide[:title] %>"
tabindex="<%= i.zero? ? 0 : -1 %>"
data-stimeo--carousel-target="picker"
data-action="click->stimeo--carousel#goto
keydown->stimeo--carousel#onPickerKeydown"></button>
<% end %>
</div>
</section>
<%# On-screen explanation of the deliberate autoplay accessibility behavior, so the
pause-on-hover / hard-stop-on-focus is understood as intentional (WCAG 2.2.2), not a bug.
Kept outside the <section> so reading it doesn't itself pause the carousel. %>
<p class="carousel__hint"><%= t("components.carousel.demo.hint") %></p>
/*
* Presentation-only styles for the carousel demo.
* The library toggles the current slide's data-state / hidden, the pickers'
* aria-selected / tabindex, and the play toggle's aria-pressed. The visual
* switching and styling are built here.
*/
.carousel {
max-width: 28rem;
padding: 0.75rem;
border: 1px solid var(--border-strong);
border-radius: 0.75rem;
background: var(--surface, var(--surface-card));
}
.carousel__bar {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 0.6rem;
}
.carousel__play {
padding: 0.3rem 0.55rem;
border: 1px solid var(--border-strong);
border-radius: 0.4rem;
background: none;
font: inherit;
cursor: pointer;
}
.carousel__play[aria-pressed="true"] {
border-color: var(--accent, var(--color-primary));
background: var(--vital-100);
color: var(--accent, var(--color-primary));
}
/* Swap the glyph off aria-pressed: ▶ when paused, ❚❚ while autoplay runs. */
.carousel__play-icon--pause {
display: none;
}
.carousel__play[aria-pressed="true"] .carousel__play-icon--play {
display: none;
}
.carousel__play[aria-pressed="true"] .carousel__play-icon--pause {
display: inline;
}
.carousel__status {
margin: 0;
font-size: 0.8rem;
color: var(--color-text-muted);
}
/* Explains the deliberate WCAG 2.2.2 autoplay behavior (pause on hover / stop on focus). */
.carousel__hint {
max-width: 28rem;
margin: 0.6rem 0 0;
font-size: 0.8rem;
line-height: 1.5;
color: var(--color-text-muted);
}
.carousel__viewport {
min-height: 6rem;
padding: 1rem;
border-radius: 0.5rem;
background: var(--surface-subtle);
}
/* Inactive slides are hidden via the hidden attribute, so only the visible one needs styling. */
.carousel__slide[data-state="active"] {
animation: carousel-fade 0.25s ease;
}
@keyframes carousel-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.carousel__slide[data-state="active"] {
animation: none;
}
}
.carousel__title {
margin: 0 0 0.4rem;
font-size: 1rem;
}
.carousel__nav {
display: flex;
justify-content: space-between;
margin-top: 0.6rem;
}
.carousel__arrow {
width: 2rem;
height: 2rem;
border: 1px solid var(--border-strong);
border-radius: 50%;
background: none;
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
}
.carousel__dots {
display: flex;
justify-content: center;
gap: 0.4rem;
margin-top: 0.7rem;
}
.carousel__dot {
width: 0.7rem;
height: 0.7rem;
padding: 0;
border: 1px solid var(--border-interactive);
border-radius: 50%;
background: none;
cursor: pointer;
}
.carousel__dot[aria-selected="true"] {
border-color: var(--accent, var(--color-primary));
background: var(--accent, var(--color-primary));
}
.carousel__dot:focus-visible,
.carousel__arrow:focus-visible,
.carousel__play:focus-visible {
outline: 2px solid var(--accent, var(--color-primary));
outline-offset: 2px;
}
// Demo that subscribes to carousel events (consumer-side JS).
//
// The core controller (stimeo--carousel) handles slide advance, autoplay, and state
// sync, firing stimeo--carousel:change on slide change and stimeo--carousel:play /
// :pause when autoplay starts/stops. Here we only subscribe to those and show the
// current position and play state in a live region. Layout and transitions are CSS.
//
// For the bilingual catalog the copy isn't hardcoded: it uses the localized template
// the ERB passes (the "{position}" token in data-template and data-playing-suffix),
// and JS only fills in the position (number) and play state.
document.querySelectorAll('[data-controller~="stimeo--carousel"]').forEach((carousel) => {
const status = carousel.querySelector('[data-carousel-status]');
if (!status) return;
const template = status.dataset.template || '{position}';
const playingSuffix = status.dataset.playingSuffix || '';
const total = carousel.querySelectorAll('[data-stimeo--carousel-target="slide"]').length;
let position = `1 / ${total}`;
let playing = false;
const render = () => {
status.textContent = template.replace('{position}', position) + (playing ? playingSuffix : '');
};
carousel.addEventListener('stimeo--carousel:change', (event) => {
position = `${event.detail.index + 1} / ${event.detail.total}`;
render();
});
carousel.addEventListener('stimeo--carousel:play', () => {
playing = true;
render();
});
carousel.addEventListener('stimeo--carousel:pause', () => {
playing = false;
render();
});
render();
});
これらのスタイルは共通のデザイントークン(ライト/ダーク両対応)を使います。 共通スタイルも一緒にコピーし、ルート要素の data-theme を切り替えればダークになります。
このコンポーネントを動かすために HTML へ記述する data-* 属性です。ルート要素に下の data-controller を付け、その内側に各 target / value / action を配置します。
ルート要素に付与
data-controller="stimeo--carousel"
ターゲット
| 名前 | 説明 | 属性 |
|---|---|---|
slide
必須
|
個々のスライドパネル。アクティブなものだけが表示・フォーカス対象になる。 | data-stimeo--carousel-target="slide" |
viewport
|
スライドを内包するビューポート要素。 | data-stimeo--carousel-target="viewport" |
prev
|
前のスライドへ移動するボタン。 | data-stimeo--carousel-target="prev" |
next
|
次のスライドへ移動するボタン。 | data-stimeo--carousel-target="next" |
picker
必須
|
対応スライドを選択するタブ。aria-selectedとロービングtabindexを持つ。 |
data-stimeo--carousel-target="picker" |
playToggle
|
再生/一時停止ボタン。aria-pressedが自動再生状態と連動する。 |
data-stimeo--carousel-target="playToggle" |
値(Values)
| 名前 | 説明 | 属性 |
|---|---|---|
autoplay
|
接続時に自動再生を開始するか(既定false)。 | data-stimeo--carousel-autoplay-value |
interval
|
自動再生の間隔(ミリ秒、既定5000)。 | data-stimeo--carousel-interval-value |
loop
|
前後移動が端で循環するか(既定true)。 | data-stimeo--carousel-loop-value |
アクション
| 名前 | 説明 | アクション |
|---|---|---|
goto
|
操作されたpickerに対応するスライドへ移動する。 | stimeo--carousel#goto |
next
|
次のスライドへ進む。 | stimeo--carousel#next |
onPickerKeydown
|
pickerのキー操作。矢印はフォーカス移動のみ、Home/Endで先頭・末尾スライドを選択。 | stimeo--carousel#onPickerKeydown |
pause
|
自動再生を停止する。ホバーは一時的、フォーカスは恒久的な停止。 | stimeo--carousel#pause |
prev
|
前のスライドへ戻る。 | stimeo--carousel#prev |
resume
|
ホバー一時停止を解除し、自動再生が有効なら再開する(focusoutでは何もしない)。 | stimeo--carousel#resume |
togglePlay
|
ユーザー操作で自動再生を切り替える。 | stimeo--carousel#togglePlay |
イベント
| 名前 | 説明 | イベント |
|---|---|---|
change
|
アクティブスライドが変わると発火する。detailにindexとtotalを含む。 | stimeo--carousel:change |
pause
|
自動再生タイマーが停止すると発火する。 | stimeo--carousel:pause |
play
|
自動再生タイマーが開始すると発火する。 | stimeo--carousel:play |
状態フック
ライブラリが操作するのはこれらの ARIA / data 属性、カスタムプロパティだけです。見た目は利用側 CSS がこれらに反応して作ります([aria-selected] / [aria-expanded] / var(--stimeo--…) などのセレクタでフックします)。
| フック | 対象 | 意味 |
|---|---|---|
data-state |
スライド | "active" / "inactive"。 |
hidden |
非アクティブスライド | 非表示にしフォーカス順から除外する。 |
aria-selected |
ピッカー(タブ) | 現在スライドのピッカーのみ true。 |
tabindex |
ピッカー(タブ) | 選択中のピッカーが 0、他は -1(ロービング)。 |
aria-pressed |
再生トグル | 自動再生中は true。 |