Carousel
回転するコンテンツアイテム(スライド)のセットで、一度に1つずつ表示し、ナビゲーションコントロールで切り替えます。
🤖 AI 実装ガイドデモ
手動ナビゲーション
タブインジケータ、前へ/次へボタン、またはキーボードの矢印キーで操作できます。
自動回転
スライドが自動的に回転します。ホバー、フォーカス、または一時停止ボタンのクリックで停止します。prefers-reduced-motion 設定を尊重します。
アクセシビリティ
WAI-ARIA ロール
| ロール | 対象要素 | 説明 |
|---|---|---|
region | コンテナ(section) | カルーセルのランドマーク領域 |
group | スライドコンテナ | すべてのスライドをグループ化 |
tablist | タブコンテナ | スライドインジケータタブのコンテナ |
tab | 各タブボタン | 個々のスライドインジケータ |
tabpanel | 各スライド | 個々のスライドコンテンツエリア |
WAI-ARIA APG Carousel パターン (opens in new tab)
WAI-ARIA プロパティ
| 属性 | 対象 | 値 | 必須 | 説明 |
|---|---|---|---|---|
aria-roledescription | コンテナ | "carousel" | はい | スクリーンリーダーに「carousel」と通知 |
aria-roledescription | 各スライド(tabpanel) | "slide" | はい | 「tabpanel」の代わりに「slide」と通知 |
aria-label | コンテナ | テキスト | はい | カルーセルの目的を説明 |
aria-label | 各スライド(tabpanel) | "N of M" | はい | スライド位置(例: "1 of 5") |
aria-controls | タブ、前へ/次へボタン | ID参照 | はい | 制御対象要素を参照 |
aria-labelledby | 各スライド(tabpanel) | ID参照 | はい | 関連するタブを参照 |
aria-atomic | スライドコンテナ | "false" | いいえ | 変更されたコンテンツのみ通知 |
WAI-ARIA ステート
aria-selected
現在選択されているスライドインジケータタブを示します。
| 対象 | tab 要素 |
| 値 | true | false |
| 必須 | はい |
| 変更トリガー | タブクリック、矢印キー、前へ/次へボタン、自動回転 |
| リファレンス | aria-selected (opens in new tab) |
aria-live
回転状態に応じてスクリーンリーダーへの通知を動的に制御します。
| 対象 | スライドコンテナ |
| 値 | "off"(自動回転中) | "polite"(手動/停止時)
|
| 必須 | はい(自動回転が有効な場合) |
| 変更トリガー | 再生/一時停止クリック、フォーカスイン/アウト、マウスホバー |
| リファレンス | aria-live (opens in new tab) |
注意: 自動回転中はユーザーの作業を中断しないよう "off"
に設定されます。回転が停止すると "polite"
に変更され、スライド変更が通知されるようになります。
キーボードサポート
| キー | アクション |
|---|---|
| Tab | コントロール間を移動(再生/一時停止、タブリスト、前へ/次へ) |
| Arrow Right | 次のスライドインジケータタブに移動(最初にループ) |
| Arrow Left | 前のスライドインジケータタブに移動(最後にループ) |
| Home | 最初のスライドインジケータタブにフォーカス移動 |
| End | 最後のスライドインジケータタブにフォーカス移動 |
| Enter / Space | フォーカスされたタブまたはボタンをアクティブ化 |
自動回転の動作
| トリガー | 動作 |
|---|---|
| キーボードフォーカスがカルーセルに入る | 回転が一時的に停止、aria-live が "polite" に変更 |
| キーボードフォーカスがカルーセルから離れる | 回転が再開(自動回転モードがオンの場合) |
| マウスがスライド上をホバー | 回転が一時的に停止 |
| マウスがスライドから離れる | 回転が再開(自動回転モードがオンの場合) |
| 一時停止ボタンをクリック | 自動回転モードをオフ、ボタンは再生アイコンを表示 |
| 再生ボタンをクリック | 自動回転モードをオンにし、即座に回転を開始 |
| prefers-reduced-motion: reduce | 自動回転がデフォルトで無効 |
ソースコード
Carousel.svelte
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
export interface CarouselSlide {
/** Unique identifier for the slide */
id: string;
/** Slide content (HTML string) */
content: string;
/** Accessible label for the slide */
label?: string;
}
interface CarouselProps {
slides: CarouselSlide[];
'aria-label': string;
initialSlide?: number;
autoRotate?: boolean;
rotationInterval?: number;
class?: string;
onSlideChange?: (index: number) => void;
'data-testid'?: string;
/** Optional ID for SSR stability (auto-generated if not provided) */
id?: string;
}
let {
slides = [],
'aria-label': ariaLabel,
initialSlide = 0,
autoRotate = false,
rotationInterval = 5000,
class: className = '',
onSlideChange = () => {},
'data-testid': testId,
id: propId,
}: CarouselProps = $props();
// Validate initialSlide - fallback to 0 if out of bounds
let validInitialSlide = $derived(
initialSlide >= 0 && initialSlide < slides.length ? initialSlide : 0
);
// State - new model: separate user intent from temporary pause
let currentSlide = $state(0);
let focusedIndex = $state(0);
let autoRotateMode = $state(false);
let isPausedByInteraction = $state(false);
// Transition animation state
let exitingSlide = $state<number | null>(null);
let transitionDirection = $state<'next' | 'prev' | null>(null);
// Refs
let tablistElement: HTMLElement;
let tabRefs: HTMLButtonElement[] = [];
// Generate ID - use prop if provided for SSR stability, otherwise generate on client
const generateId = () => {
if (typeof propId === 'string' && propId.length > 0) {
return propId;
}
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return `carousel-${crypto.randomUUID().slice(0, 8)}`;
}
return `carousel-${Math.random().toString(36).slice(2, 11)}`;
};
let carouselId = $state(propId || '');
let timerRef: ReturnType<typeof setInterval> | null = null;
let animationTimeoutRef: ReturnType<typeof setTimeout> | null = null;
let rafRef: number | null = null;
let pendingDragOffset = 0;
let pointerStartX: number | null = null;
let activePointerId: number | null = null;
let isDragging = $state(false);
let dragOffset = $state(0);
// Generate ID on client side if not provided via prop
$effect(() => {
if (typeof window !== 'undefined' && !carouselId) {
carouselId = generateId();
}
});
// Derived
let slidesContainerId = $derived(`${carouselId}-slides`);
let isActuallyRotating = $derived(autoRotateMode && !isPausedByInteraction);
let containerClass = $derived(`apg-carousel ${className}`.trim());
let slidesContainerClass = $derived(
`apg-carousel-slides${isDragging ? ' apg-carousel-slides--dragging' : ''}`
);
// Compute which adjacent slide to show during drag
let swipeAdjacentSlide = $derived(
isDragging && dragOffset !== 0
? dragOffset > 0
? (currentSlide - 1 + slides.length) % slides.length // swiping right, show prev
: (currentSlide + 1) % slides.length // swiping left, show next
: null
);
// Initialize on mount
onMount(() => {
currentSlide = validInitialSlide;
focusedIndex = validInitialSlide;
// Check prefers-reduced-motion
if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (prefersReducedMotion) {
autoRotateMode = false;
} else {
autoRotateMode = autoRotate;
}
} else {
autoRotateMode = autoRotate;
}
});
onDestroy(() => {
if (timerRef) {
clearInterval(timerRef);
timerRef = null;
}
if (animationTimeoutRef) {
clearTimeout(animationTimeoutRef);
animationTimeoutRef = null;
}
if (rafRef) {
cancelAnimationFrame(rafRef);
rafRef = null;
}
});
// Auto-rotation timer effect with animation
$effect(() => {
if (timerRef) {
clearInterval(timerRef);
timerRef = null;
}
if (isActuallyRotating) {
timerRef = setInterval(() => {
const current = currentSlide;
const nextIndex = (current + 1) % slides.length;
// Trigger animation
exitingSlide = current;
transitionDirection = 'next';
currentSlide = nextIndex;
focusedIndex = nextIndex;
onSlideChange(nextIndex);
// Clean up animation state
animationTimeoutRef = setTimeout(() => {
exitingSlide = null;
transitionDirection = null;
animationTimeoutRef = null;
}, 300);
}, rotationInterval);
}
return () => {
if (timerRef) {
clearInterval(timerRef);
timerRef = null;
}
};
});
// Slide navigation with animation
function goToSlide(index: number) {
if (slides.length < 2) return;
const newIndex = ((index % slides.length) + slides.length) % slides.length;
if (newIndex === currentSlide) return;
// Determine direction based on index change
const current = currentSlide;
const isWrapForward = current === slides.length - 1 && newIndex === 0;
const isWrapBackward = current === 0 && newIndex === slides.length - 1;
const direction = isWrapForward || (!isWrapBackward && newIndex > current) ? 'next' : 'prev';
// Start transition
exitingSlide = current;
transitionDirection = direction;
currentSlide = newIndex;
focusedIndex = newIndex;
onSlideChange(newIndex);
// Clean up after animation
animationTimeoutRef = setTimeout(() => {
exitingSlide = null;
transitionDirection = null;
animationTimeoutRef = null;
}, 300);
}
// Instant slide change (no animation) for swipe completion
function goToSlideInstant(index: number) {
if (slides.length < 2) return;
const newIndex = ((index % slides.length) + slides.length) % slides.length;
currentSlide = newIndex;
focusedIndex = newIndex;
onSlideChange(newIndex);
}
function goToNextSlide() {
goToSlide(currentSlide + 1);
}
function goToPrevSlide() {
goToSlide(currentSlide - 1);
}
// Focus management
function handleTabFocus(index: number) {
focusedIndex = index;
if (tabRefs[index]) {
tabRefs[index].focus();
}
}
// Keyboard handler for tablist
function handleKeyDown(event: KeyboardEvent) {
const { key } = event;
const target = event.target;
if (!tablistElement || !(target instanceof Node) || !tablistElement.contains(target)) {
return;
}
let newIndex = focusedIndex;
let shouldPreventDefault = false;
switch (key) {
case 'ArrowRight':
newIndex = (focusedIndex + 1) % slides.length;
shouldPreventDefault = true;
break;
case 'ArrowLeft':
newIndex = (focusedIndex - 1 + slides.length) % slides.length;
shouldPreventDefault = true;
break;
case 'Home':
newIndex = 0;
shouldPreventDefault = true;
break;
case 'End':
newIndex = slides.length - 1;
shouldPreventDefault = true;
break;
case 'Enter':
case ' ':
goToSlide(focusedIndex);
shouldPreventDefault = true;
break;
}
if (shouldPreventDefault) {
event.preventDefault();
if (newIndex !== focusedIndex) {
handleTabFocus(newIndex);
goToSlide(newIndex);
}
}
}
// Toggle auto-rotate mode (user intent)
function toggleAutoRotateMode() {
autoRotateMode = !autoRotateMode;
// When enabling auto-rotate, reset interaction pause so rotation starts immediately
if (autoRotateMode) {
isPausedByInteraction = false;
}
}
// Pause/resume by interaction (hover/focus)
function pauseByInteraction() {
isPausedByInteraction = true;
}
function resumeByInteraction() {
isPausedByInteraction = false;
}
// Focus/blur handlers for entire carousel
function handleCarouselFocusIn() {
if (autoRotateMode) {
pauseByInteraction();
}
}
function handleCarouselFocusOut(event: FocusEvent) {
if (!autoRotateMode) return;
// Only resume if focus is leaving the carousel entirely
const { currentTarget, relatedTarget } = event;
const focusLeftCarousel =
relatedTarget === null ||
(currentTarget instanceof Element &&
relatedTarget instanceof Node &&
!currentTarget.contains(relatedTarget));
if (focusLeftCarousel) {
resumeByInteraction();
}
}
// Mouse hover handlers for slides container only
function handleSlidesMouseEnter() {
if (autoRotateMode) {
pauseByInteraction();
}
}
function handleSlidesMouseLeave() {
if (autoRotateMode) {
resumeByInteraction();
}
}
// Touch/swipe handlers
function handlePointerDown(event: PointerEvent) {
if (slides.length < 2) return; // Disable swipe for single slide
if (activePointerId !== null) return; // Ignore if already tracking a pointer
activePointerId = event.pointerId;
pointerStartX = event.clientX;
isDragging = true;
dragOffset = 0;
// Capture pointer to receive events even if pointer moves outside element
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
if (autoRotateMode) {
pauseByInteraction();
}
}
function handlePointerMove(event: PointerEvent) {
if (activePointerId !== event.pointerId) return; // Ignore other pointers
if (pointerStartX === null) return;
const diff = event.clientX - pointerStartX;
pendingDragOffset = diff;
// Throttle updates using requestAnimationFrame
if (rafRef === null) {
rafRef = requestAnimationFrame(() => {
dragOffset = pendingDragOffset;
rafRef = null;
});
}
}
function handlePointerUp(event: PointerEvent) {
if (activePointerId !== event.pointerId) return; // Ignore other pointers
if (!isDragging || pointerStartX === null) return;
const diff = event.clientX - pointerStartX;
const target = event.currentTarget as HTMLElement;
const containerWidth = target?.offsetWidth || 300;
const threshold = containerWidth * 0.2; // 20% of container width
if (diff > threshold) {
// Swiped right - go to previous slide (instant, no animation)
goToSlideInstant(currentSlide - 1);
} else if (diff < -threshold) {
// Swiped left - go to next slide (instant, no animation)
goToSlideInstant(currentSlide + 1);
}
// else: snap back (just reset dragOffset)
// Cancel any pending RAF
if (rafRef !== null) {
cancelAnimationFrame(rafRef);
rafRef = null;
}
activePointerId = null;
pointerStartX = null;
isDragging = false;
dragOffset = 0;
if (autoRotateMode) {
resumeByInteraction();
}
}
function handlePointerCancel(event: PointerEvent) {
if (activePointerId !== event.pointerId) return; // Ignore other pointers
// Cancel any pending RAF
if (rafRef !== null) {
cancelAnimationFrame(rafRef);
rafRef = null;
}
activePointerId = null;
pointerStartX = null;
isDragging = false;
dragOffset = 0;
}
</script>
<section
class={containerClass}
aria-roledescription="carousel"
aria-label={ariaLabel}
data-testid={testId}
onfocusin={handleCarouselFocusIn}
onfocusout={handleCarouselFocusOut}
>
<!-- Slides Container -->
<div
id={slidesContainerId}
data-testid="slides-container"
class={slidesContainerClass}
role="group"
aria-live={isActuallyRotating ? 'off' : 'polite'}
aria-atomic="false"
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
onpointercancel={handlePointerCancel}
onmouseenter={handleSlidesMouseEnter}
onmouseleave={handleSlidesMouseLeave}
>
{#each slides as slide, index}
{@const isActive = index === currentSlide}
{@const isExiting = index === exitingSlide}
{@const isSwipeAdjacent = index === swipeAdjacentSlide}
{@const enteringClass =
transitionDirection && !isDragging && isActive
? `apg-carousel-slide--entering-${transitionDirection}`
: ''}
{@const exitingClass =
transitionDirection && !isDragging && isExiting
? `apg-carousel-slide--exiting-${transitionDirection}`
: ''}
{@const swipeClass =
isDragging && isSwipeAdjacent
? dragOffset > 0
? 'apg-carousel-slide--swipe-prev'
: 'apg-carousel-slide--swipe-next'
: ''}
{@const slideStyle = isDragging
? isActive
? `transform: translateX(${dragOffset}px)`
: isSwipeAdjacent
? `transform: translateX(calc(${dragOffset > 0 ? '-100%' : '100%'} + ${dragOffset}px))`
: undefined
: undefined}
<div
id={`${carouselId}-panel-${slide.id}`}
role="tabpanel"
aria-roledescription="slide"
aria-label={`${index + 1} of ${slides.length}`}
aria-labelledby={`${carouselId}-tab-${slide.id}`}
aria-hidden={!isActive}
inert={!isActive ? true : undefined}
class={`apg-carousel-slide ${isActive ? 'apg-carousel-slide--active' : ''} ${enteringClass} ${exitingClass} ${swipeClass}`.trim()}
style={slideStyle}
>
{@html slide.content}
</div>
{/each}
</div>
<!-- Controls -->
<div class="apg-carousel-controls">
<!-- Play/Pause Button (first in tab order) -->
{#if autoRotate}
<button
type="button"
class="apg-carousel-play-pause"
aria-label={autoRotateMode ? 'Stop automatic slide show' : 'Start automatic slide show'}
onclick={toggleAutoRotateMode}
>
{#if autoRotateMode}
<svg aria-hidden="true" width="18" height="18" viewBox="0 0 16 16" fill="currentColor">
<rect x="3" y="2" width="4" height="12" rx="1.5" />
<rect x="9" y="2" width="4" height="12" rx="1.5" />
</svg>
{:else}
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M4 2.5v11a.5.5 0 0 0 .75.43l9-5.5a.5.5 0 0 0 0-.86l-9-5.5A.5.5 0 0 0 4 2.5z" />
</svg>
{/if}
</button>
{/if}
<!-- Tablist (slide indicators) -->
<div
bind:this={tablistElement}
role="tablist"
aria-label="Slides"
class="apg-carousel-tablist"
onkeydown={handleKeyDown}
>
{#each slides as slide, index}
<button
bind:this={tabRefs[index]}
type="button"
role="tab"
id={`${carouselId}-tab-${slide.id}`}
aria-selected={index === currentSlide}
aria-controls={`${carouselId}-panel-${slide.id}`}
tabindex={index === focusedIndex ? 0 : -1}
class={`apg-carousel-tab ${index === currentSlide ? 'apg-carousel-tab--selected' : ''}`}
onclick={() => goToSlide(index)}
aria-label={slide.label || `Slide ${index + 1}`}
>
<span class="apg-carousel-tab-indicator" aria-hidden="true"></span>
</button>
{/each}
</div>
<!-- Previous/Next Buttons -->
<div role="group" aria-label="Slide controls" class="apg-carousel-nav">
<button
type="button"
class="apg-carousel-prev"
aria-label="Previous slide"
aria-controls={slidesContainerId}
onclick={goToPrevSlide}
>
<svg
aria-hidden="true"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="15" y1="10" x2="5" y2="10" />
<polyline points="10 5 5 10 10 15" />
</svg>
</button>
<button
type="button"
class="apg-carousel-next"
aria-label="Next slide"
aria-controls={slidesContainerId}
onclick={goToNextSlide}
>
<svg
aria-hidden="true"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="5" y1="10" x2="15" y2="10" />
<polyline points="10 5 15 10 10 15" />
</svg>
</button>
</div>
</div>
</section>
<style>
/* Styles are in src/styles/patterns/carousel.css */
</style> 使い方
使用例
<script lang="ts">
import Carousel from './Carousel.svelte';
const slides = [
{ id: 'slide1', content: '<p>スライド1の内容</p>', label: '最初のスライド' },
{ id: 'slide2', content: '<p>スライド2の内容</p>', label: '2番目のスライド' },
{ id: 'slide3', content: '<p>スライド3の内容</p>', label: '3番目のスライド' }
];
function handleSlideChange(index: number) {
console.log('スライド変更:', index);
}
</script>
<Carousel
{slides}
aria-label="注目のコンテンツ"
autoRotate={true}
rotationInterval={5000}
onSlideChange={handleSlideChange}
/> API
Carousel Props
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
slides | CarouselSlide[] | 必須 | スライドアイテムの配列 |
aria-label | string | 必須 | カルーセルのアクセシブルな名前 |
initialSlide | number | 0 | 初期スライドのインデックス(0から開始) |
autoRotate | boolean | false | 自動回転を有効にする |
rotationInterval | number | 5000 | 回転間隔(ミリ秒) |
onSlideChange | (index: number) => void | - | スライド変更時のコールバック |
CarouselSlide インターフェース
型定義
interface CarouselSlide {
id: string;
content: string;
label?: string;
} テスト
テストは、キーボード操作、ARIA属性、自動回転の動作、アクセシビリティ要件全体にわたってAPG準拠を検証します。Carouselコンポーネントは2層テスト戦略を採用しています。
テスト戦略
ユニットテスト(Container API)
Astro Container APIを使用してコンポーネントのHTML出力を検証します。これらのテストはブラウザを必要とせずに正しいテンプレートレンダリングを確認します。
- HTML構造と要素の階層
- 初期ARIA属性(aria-roledescription, aria-label, aria-selected)
- tablist/tab/tabpanel構造
- 初期tabindex値(ローヴィングタブインデックス)
- CSSクラスの適用
E2Eテスト(Playwright)
実際のブラウザ環境でWeb Componentの動作を検証します。これらのテストはJavaScriptの実行を必要とするインタラクションをカバーします。
- キーボードナビゲーション(矢印キー、Home、End)
- タブ選択とスライド変更
- 自動回転の開始/停止
- 再生/一時停止ボタンの操作
- ナビゲーション中のフォーカス管理
テストカテゴリ
高優先度: ARIA構造(ユニット)
| テスト | 説明 |
|---|---|
aria-roledescription="carousel" | コンテナにcarouselロール記述がある |
aria-roledescription="slide" | 各tabpanelにslideロール記述がある |
aria-label(コンテナ) | コンテナにアクセシブルな名前がある |
aria-label="N of M" | 各スライドに位置ラベルがある(例: "1 of 5") |
高優先度: Tablist ARIA(ユニット)
| テスト | 説明 |
|---|---|
role="tablist" | タブコンテナにtablistロールがある |
role="tab" | 各スライドインジケータにtabロールがある |
role="tabpanel" | 各スライドにtabpanelロールがある |
aria-selected | アクティブなタブにaria-selected="true"がある |
aria-controls | タブがaria-controls経由でスライドを参照 |
高優先度: キーボード操作(E2E)
| テスト | 説明 |
|---|---|
ArrowRight | 次のスライドタブにフォーカスを移動しアクティブ化 |
ArrowLeft | 前のスライドタブにフォーカスを移動しアクティブ化 |
ループナビゲーション | 矢印キーで最後から最初へ、またはその逆にループ |
Home/End | 最初/最後のスライドタブにフォーカスを移動 |
高優先度: フォーカス管理(ユニット + E2E)
| テスト | 説明 |
|---|---|
tabIndex=0(ユニット) | 選択されたタブは初期状態でtabIndex=0を持つ |
tabIndex=-1(ユニット) | 選択されていないタブは初期状態でtabIndex=-1を持つ |
ローヴィングタブインデックス(E2E) | ナビゲーション中に1つのタブのみがtabIndex=0を持つ |
高優先度: 自動回転(ユニット + E2E)
| テスト | 説明 |
|---|---|
aria-live="off"(ユニット) | autoRotateがtrueの場合の初期aria-live |
aria-live="polite"(ユニット) | autoRotateがfalseの場合の初期aria-live |
再生/一時停止ボタン(ユニット) | autoRotateがtrueの場合にボタンがレンダリングされる |
再生/一時停止切り替え(E2E) | ボタンで回転状態を切り替え |
中優先度: ナビゲーションコントロール(ユニット + E2E)
| テスト | 説明 |
|---|---|
前へ/次へボタン(ユニット) | ナビゲーションボタンがレンダリングされる |
aria-controls(ユニット) | ボタンがスライドコンテナを指すaria-controlsを持つ |
次へボタン(E2E) | クリックで次のスライドを表示 |
前へボタン(E2E) | クリックで前のスライドを表示 |
ループナビゲーション(E2E) | 最後から最初へ、またはその逆にループ |
低優先度: HTML属性(ユニット)
| テスト | 説明 |
|---|---|
class属性 | カスタムクラスがコンテナに適用される |
id属性 | ID属性が正しく設定される |
テストツール
- Vitest (opens in new tab) - 自動回転テスト用のフェイクタイマー付きテストランナー
- Astro Container API (opens in new tab) - ユニットテスト用のサーバーサイドコンポーネントレンダリング
- Playwright (opens in new tab) - E2Eテスト用のブラウザ自動化
- Testing Library (opens in new tab) - フレームワーク固有のテストユーティリティ(React、Vue、Svelte)
- jest-axe (opens in new tab) - 自動アクセシビリティテスト
詳細なドキュメントについては、 testing-strategy.md (opens in new tab) を参照してください。
Carousel.test.svelte.ts
import { render, screen, waitFor } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import Carousel from './Carousel.svelte';
import type { CarouselSlide } from './Carousel.svelte';
// Mock matchMedia for tests
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
// テスト用スライドデータ
const defaultSlides: CarouselSlide[] = [
{ id: 'slide1', content: 'Slide 1 Content', label: 'Slide 1' },
{ id: 'slide2', content: 'Slide 2 Content', label: 'Slide 2' },
{ id: 'slide3', content: 'Slide 3 Content', label: 'Slide 3' },
];
const fiveSlides: CarouselSlide[] = [
{ id: 'slide1', content: 'Slide 1', label: 'Slide 1' },
{ id: 'slide2', content: 'Slide 2', label: 'Slide 2' },
{ id: 'slide3', content: 'Slide 3', label: 'Slide 3' },
{ id: 'slide4', content: 'Slide 4', label: 'Slide 4' },
{ id: 'slide5', content: 'Slide 5', label: 'Slide 5' },
];
describe('Carousel (Svelte)', () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
vi.useRealTimers();
});
// 🔴 High Priority: APG ARIA Structure
describe('APG: ARIA 構造', () => {
it('コンテナに aria-roledescription="carousel" がある', () => {
render(Carousel, { props: { slides: defaultSlides, 'aria-label': 'Featured content' } });
const carousel = screen.getByRole('region');
expect(carousel).toHaveAttribute('aria-roledescription', 'carousel');
});
it('コンテナに aria-label がある', () => {
render(Carousel, { props: { slides: defaultSlides, 'aria-label': 'Featured content' } });
const carousel = screen.getByRole('region');
expect(carousel).toHaveAttribute('aria-label', 'Featured content');
});
it('各 tabpanel に aria-roledescription="slide" がある', () => {
render(Carousel, { props: { slides: defaultSlides, 'aria-label': 'Featured content' } });
const panels = screen.getAllByRole('tabpanel', { hidden: true });
panels.forEach((panel) => {
expect(panel).toHaveAttribute('aria-roledescription', 'slide');
});
});
it('各スライドに aria-label="N of M" がある', () => {
render(Carousel, { props: { slides: fiveSlides, 'aria-label': 'Featured content' } });
const panels = screen.getAllByRole('tabpanel', { hidden: true });
expect(panels[0]).toHaveAttribute('aria-label', '1 of 5');
expect(panels[1]).toHaveAttribute('aria-label', '2 of 5');
expect(panels[2]).toHaveAttribute('aria-label', '3 of 5');
expect(panels[3]).toHaveAttribute('aria-label', '4 of 5');
expect(panels[4]).toHaveAttribute('aria-label', '5 of 5');
});
});
describe('APG: Tablist ARIA', () => {
it('タブコンテナに role="tablist" がある', () => {
render(Carousel, { props: { slides: defaultSlides, 'aria-label': 'Featured content' } });
expect(screen.getByRole('tablist')).toBeInTheDocument();
});
it('各タブに role="tab" がある', () => {
render(Carousel, { props: { slides: defaultSlides, 'aria-label': 'Featured content' } });
const tabs = screen.getAllByRole('tab');
expect(tabs).toHaveLength(3);
});
it('アクティブなタブに aria-selected="true" がある', () => {
render(Carousel, { props: { slides: defaultSlides, 'aria-label': 'Featured content' } });
const tabs = screen.getAllByRole('tab');
expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
expect(tabs[1]).toHaveAttribute('aria-selected', 'false');
expect(tabs[2]).toHaveAttribute('aria-selected', 'false');
});
it('タブの aria-controls が tabpanel を指している', () => {
render(Carousel, { props: { slides: defaultSlides, 'aria-label': 'Featured content' } });
const tabs = screen.getAllByRole('tab');
const panels = screen.getAllByRole('tabpanel', { hidden: true });
tabs.forEach((tab, index) => {
expect(tab).toHaveAttribute('aria-controls', panels[index].id);
});
});
});
// 🔴 High Priority: キーボード操作
describe('APG: キーボード操作', () => {
it('ArrowRight で次のタブにフォーカスが移動する', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(Carousel, { props: { slides: defaultSlides, 'aria-label': 'Featured content' } });
const tabs = screen.getAllByRole('tab');
await user.click(tabs[0]);
await user.keyboard('{ArrowRight}');
expect(tabs[1]).toHaveFocus();
expect(tabs[1]).toHaveAttribute('aria-selected', 'true');
});
it('ArrowLeft で前のタブにフォーカスが移動する', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(Carousel, {
props: { slides: defaultSlides, 'aria-label': 'Featured content', initialSlide: 1 },
});
const tabs = screen.getAllByRole('tab');
await user.click(tabs[1]);
await user.keyboard('{ArrowLeft}');
expect(tabs[0]).toHaveFocus();
expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
});
it('ArrowRight で最後から最初にループする', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(Carousel, {
props: { slides: defaultSlides, 'aria-label': 'Featured content', initialSlide: 2 },
});
const tabs = screen.getAllByRole('tab');
await user.click(tabs[2]);
await user.keyboard('{ArrowRight}');
expect(tabs[0]).toHaveFocus();
expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
});
it('ArrowLeft で最初から最後にループする', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(Carousel, { props: { slides: defaultSlides, 'aria-label': 'Featured content' } });
const tabs = screen.getAllByRole('tab');
await user.click(tabs[0]);
await user.keyboard('{ArrowLeft}');
expect(tabs[2]).toHaveFocus();
expect(tabs[2]).toHaveAttribute('aria-selected', 'true');
});
it('Home で最初のタブに移動する', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(Carousel, {
props: { slides: defaultSlides, 'aria-label': 'Featured content', initialSlide: 2 },
});
const tabs = screen.getAllByRole('tab');
await user.click(tabs[2]);
await user.keyboard('{Home}');
expect(tabs[0]).toHaveFocus();
expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
});
it('End で最後のタブに移動する', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(Carousel, { props: { slides: defaultSlides, 'aria-label': 'Featured content' } });
const tabs = screen.getAllByRole('tab');
await user.click(tabs[0]);
await user.keyboard('{End}');
expect(tabs[2]).toHaveFocus();
expect(tabs[2]).toHaveAttribute('aria-selected', 'true');
});
});
// 🔴 High Priority: 自動回転
describe('APG: 自動回転', () => {
it('有効時にスライドが自動的に回転する', async () => {
render(Carousel, {
props: {
slides: defaultSlides,
'aria-label': 'Featured content',
autoRotate: true,
rotationInterval: 3000,
},
});
const tabs = screen.getAllByRole('tab');
expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
vi.advanceTimersByTime(3000);
await waitFor(() => {
expect(tabs[1]).toHaveAttribute('aria-selected', 'true');
});
});
it('自動回転中は aria-live="off"', () => {
render(Carousel, {
props: { slides: defaultSlides, 'aria-label': 'Featured content', autoRotate: true },
});
const slidesContainer = screen.getByTestId('slides-container');
expect(slidesContainer).toHaveAttribute('aria-live', 'off');
});
it('回転停止時は aria-live="polite"', () => {
render(Carousel, {
props: { slides: defaultSlides, 'aria-label': 'Featured content', autoRotate: false },
});
const slidesContainer = screen.getByTestId('slides-container');
expect(slidesContainer).toHaveAttribute('aria-live', 'polite');
});
it('キーボードフォーカスで回転が停止する', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(Carousel, {
props: {
slides: defaultSlides,
'aria-label': 'Featured content',
autoRotate: true,
rotationInterval: 3000,
},
});
const tabs = screen.getAllByRole('tab');
await user.click(tabs[0]);
const slidesContainer = screen.getByTestId('slides-container');
expect(slidesContainer).toHaveAttribute('aria-live', 'polite');
vi.advanceTimersByTime(3000);
expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
});
it('Play/Pause ボタンで回転を切り替えできる', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(Carousel, {
props: {
slides: defaultSlides,
'aria-label': 'Featured content',
autoRotate: true,
rotationInterval: 3000,
},
});
const playPauseButton = screen.getByRole('button', { name: /stop|pause/i });
await user.click(playPauseButton);
const slidesContainer = screen.getByTestId('slides-container');
expect(slidesContainer).toHaveAttribute('aria-live', 'polite');
});
});
// 🔴 High Priority: フォーカス管理
describe('APG: フォーカス管理', () => {
it('tablist で roving tabindex を使用している', () => {
render(Carousel, { props: { slides: defaultSlides, 'aria-label': 'Featured content' } });
const tabs = screen.getAllByRole('tab');
expect(tabs[0]).toHaveAttribute('tabIndex', '0');
expect(tabs[1]).toHaveAttribute('tabIndex', '-1');
expect(tabs[2]).toHaveAttribute('tabIndex', '-1');
});
it('一度に1つのタブのみ tabindex="0" を持つ', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(Carousel, { props: { slides: defaultSlides, 'aria-label': 'Featured content' } });
const tabs = screen.getAllByRole('tab');
await user.click(tabs[0]);
await user.keyboard('{ArrowRight}');
const tabsWithZeroTabindex = tabs.filter((tab) => tab.getAttribute('tabIndex') === '0');
expect(tabsWithZeroTabindex).toHaveLength(1);
});
});
// 🟡 Medium Priority: ナビゲーションコントロール
describe('ナビゲーションコントロール', () => {
it('次へボタンで次のスライドを表示する', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(Carousel, { props: { slides: defaultSlides, 'aria-label': 'Featured content' } });
const nextButton = screen.getByRole('button', { name: /next/i });
await user.click(nextButton);
const tabs = screen.getAllByRole('tab');
expect(tabs[1]).toHaveAttribute('aria-selected', 'true');
});
it('前へボタンで前のスライドを表示する', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(Carousel, {
props: { slides: defaultSlides, 'aria-label': 'Featured content', initialSlide: 1 },
});
const prevButton = screen.getByRole('button', { name: /previous|prev/i });
await user.click(prevButton);
const tabs = screen.getAllByRole('tab');
expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
});
it('最後から最初にループする', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(Carousel, {
props: { slides: defaultSlides, 'aria-label': 'Featured content', initialSlide: 2 },
});
const nextButton = screen.getByRole('button', { name: /next/i });
await user.click(nextButton);
const tabs = screen.getAllByRole('tab');
expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
});
});
// 🟡 Medium Priority: アクセシビリティ
describe('アクセシビリティ', () => {
it('axe による WCAG 2.1 AA 違反がない', async () => {
const { container } = render(Carousel, {
props: { slides: defaultSlides, 'aria-label': 'Featured content' },
});
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
// 🟢 Low Priority: Props
describe('Props', () => {
it('onSlideChange がスライド変更時に発火する', async () => {
const handleSlideChange = vi.fn();
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(Carousel, {
props: {
slides: defaultSlides,
'aria-label': 'Featured content',
onSlideChange: handleSlideChange,
},
});
const tabs = screen.getAllByRole('tab');
await user.click(tabs[1]);
expect(handleSlideChange).toHaveBeenCalledWith(1);
});
it('initialSlide prop を尊重する', () => {
render(Carousel, {
props: { slides: defaultSlides, 'aria-label': 'Featured content', initialSlide: 1 },
});
const tabs = screen.getAllByRole('tab');
expect(tabs[1]).toHaveAttribute('aria-selected', 'true');
});
});
// 異常系
describe('異常系', () => {
it('単一スライドを処理できる', () => {
const singleSlide: CarouselSlide[] = [
{ id: 'slide1', content: 'Only Slide', label: 'Only Slide' },
];
render(Carousel, {
props: { slides: singleSlide, 'aria-label': 'Featured content' },
});
const tabs = screen.getAllByRole('tab');
expect(tabs).toHaveLength(1);
const panel = screen.getByRole('tabpanel');
expect(panel).toHaveAttribute('aria-label', '1 of 1');
});
});
}); リソース
- WAI-ARIA APG: Carousel パターン (opens in new tab)
- AI 実装ガイド (llm.md) (opens in new tab) - ARIA 仕様、キーボードサポート、テストチェックリスト