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.tsx
import { useCallback, useEffect, useId, useRef, useState } from 'react';
export interface CarouselSlide {
/** Unique identifier for the slide */
id: string;
/** Slide content (JSX or string) */
content: React.ReactNode;
/** Accessible label for the slide */
label?: string;
}
export interface CarouselProps {
/** Array of slides */
slides: CarouselSlide[];
/** Accessible label for the carousel (required) */
'aria-label': string;
/** Initial slide index (0-based) */
initialSlide?: number;
/** Enable auto-rotation */
autoRotate?: boolean;
/** Rotation interval in milliseconds (default: 5000) */
rotationInterval?: number;
/** Callback when slide changes */
onSlideChange?: (index: number) => void;
/** Additional CSS class */
className?: string;
/** Test ID for E2E testing */
'data-testid'?: string;
}
export function Carousel({
slides,
'aria-label': ariaLabel,
initialSlide = 0,
autoRotate = false,
rotationInterval = 5000,
onSlideChange,
className = '',
'data-testid': testId,
}: CarouselProps): React.ReactElement {
// Validate initialSlide - fallback to 0 if out of bounds
const validInitialSlide = initialSlide >= 0 && initialSlide < slides.length ? initialSlide : 0;
const [currentSlide, setCurrentSlide] = useState(validInitialSlide);
const [focusedIndex, setFocusedIndex] = useState(validInitialSlide);
// Transition animation state
const [exitingSlide, setExitingSlide] = useState<number | null>(null);
const [transitionDirection, setTransitionDirection] = useState<'next' | 'prev' | null>(null);
// New state model: separate user intent from temporary pause
const [autoRotateMode, setAutoRotateMode] = useState(() => {
// Check prefers-reduced-motion
if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (prefersReducedMotion) {
return false;
}
}
return autoRotate;
});
const [isPausedByInteraction, setIsPausedByInteraction] = useState(false);
const tabRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
const tablistRef = useRef<HTMLDivElement>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const animationTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const currentSlideRef = useRef(currentSlide);
const carouselId = useId();
const slidesContainerId = `${carouselId}-slides`;
// Computed: actual rotation state
const isActuallyRotating = autoRotateMode && !isPausedByInteraction;
// Handle slide change with animation
const goToSlide = useCallback(
(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
// Special case for wrap-around
const isWrapForward = currentSlide === slides.length - 1 && newIndex === 0;
const isWrapBackward = currentSlide === 0 && newIndex === slides.length - 1;
const direction =
isWrapForward || (!isWrapBackward && newIndex > currentSlide) ? 'next' : 'prev';
// Start transition
setExitingSlide(currentSlide);
setTransitionDirection(direction);
setCurrentSlide(newIndex);
setFocusedIndex(newIndex);
onSlideChange?.(newIndex);
// Clean up after animation
animationTimeoutRef.current = setTimeout(() => {
setExitingSlide(null);
setTransitionDirection(null);
animationTimeoutRef.current = null;
}, 300); // Match CSS animation duration
},
[slides.length, currentSlide, onSlideChange]
);
const goToNextSlide = useCallback(() => {
goToSlide(currentSlide + 1);
}, [currentSlide, goToSlide]);
const goToPrevSlide = useCallback(() => {
goToSlide(currentSlide - 1);
}, [currentSlide, goToSlide]);
// Focus management
const handleTabFocus = useCallback(
(index: number) => {
setFocusedIndex(index);
const slide = slides[index];
if (slide) {
tabRefs.current.get(slide.id)?.focus();
}
},
[slides]
);
// Keyboard handler for tablist
const handleKeyDown = useCallback(
(event: React.KeyboardEvent) => {
const { key } = event;
const target = event.target;
if (
!tablistRef.current ||
!(target instanceof Node) ||
!tablistRef.current.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 ' ':
// Tab is already selected on focus, but this handles manual confirmation
goToSlide(focusedIndex);
shouldPreventDefault = true;
break;
}
if (shouldPreventDefault) {
event.preventDefault();
if (newIndex !== focusedIndex) {
handleTabFocus(newIndex);
goToSlide(newIndex);
}
}
},
[focusedIndex, slides.length, handleTabFocus, goToSlide]
);
// Keep ref in sync with state
useEffect(() => {
currentSlideRef.current = currentSlide;
}, [currentSlide]);
// Auto-rotation timer with animation support
useEffect(() => {
if (isActuallyRotating) {
timerRef.current = setInterval(() => {
const current = currentSlideRef.current;
const nextIndex = (current + 1) % slides.length;
// Trigger animation
setExitingSlide(current);
setTransitionDirection('next');
setCurrentSlide(nextIndex);
setFocusedIndex(nextIndex);
onSlideChange?.(nextIndex);
// Clean up animation state
animationTimeoutRef.current = setTimeout(() => {
setExitingSlide(null);
setTransitionDirection(null);
animationTimeoutRef.current = null;
}, 300);
}, rotationInterval);
}
return () => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
};
}, [isActuallyRotating, slides.length, rotationInterval, onSlideChange]);
// Cleanup animation timeout and RAF on unmount
useEffect(() => {
return () => {
if (animationTimeoutRef.current) {
clearTimeout(animationTimeoutRef.current);
animationTimeoutRef.current = null;
}
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
};
}, []);
// Toggle auto-rotate mode (user intent)
const toggleAutoRotateMode = useCallback(() => {
setAutoRotateMode((prev) => {
const newMode = !prev;
// When enabling auto-rotate, reset interaction pause so rotation starts immediately
if (newMode) {
setIsPausedByInteraction(false);
}
return newMode;
});
}, []);
// Pause/resume by interaction (hover/focus)
const pauseByInteraction = useCallback(() => {
setIsPausedByInteraction(true);
}, []);
const resumeByInteraction = useCallback(() => {
setIsPausedByInteraction(false);
}, []);
// Focus/blur handlers for entire carousel
const handleCarouselFocusIn = useCallback(() => {
if (autoRotateMode) {
pauseByInteraction();
}
}, [autoRotateMode, pauseByInteraction]);
const handleCarouselFocusOut = useCallback(
(event: React.FocusEvent) => {
if (!autoRotateMode) return;
// Only resume if focus is leaving the carousel entirely
const carousel = event.currentTarget;
const relatedTarget = event.relatedTarget;
const focusLeftCarousel =
relatedTarget === null ||
(relatedTarget instanceof Node && !carousel.contains(relatedTarget));
if (focusLeftCarousel) {
resumeByInteraction();
}
},
[autoRotateMode, resumeByInteraction]
);
// Mouse hover handlers for slides container only
const handleSlidesMouseEnter = useCallback(() => {
if (autoRotateMode) {
pauseByInteraction();
}
}, [autoRotateMode, pauseByInteraction]);
const handleSlidesMouseLeave = useCallback(() => {
if (autoRotateMode) {
resumeByInteraction();
}
}, [autoRotateMode, resumeByInteraction]);
// Touch/swipe handlers
const pointerStartX = useRef<number | null>(null);
const activePointerId = useRef<number | null>(null);
const rafRef = useRef<number | null>(null);
const pendingDragOffset = useRef<number>(0);
const slidesContainerRef = useRef<HTMLDivElement>(null);
const [dragOffset, setDragOffset] = useState(0);
const [isDragging, setIsDragging] = useState(false);
// Compute which adjacent slide to show during drag
const swipeAdjacentSlide =
isDragging && dragOffset !== 0
? dragOffset > 0
? (currentSlide - 1 + slides.length) % slides.length // swiping right, show prev
: (currentSlide + 1) % slides.length // swiping left, show next
: null;
// Instant slide change (no animation) for swipe completion
const goToSlideInstant = useCallback(
(index: number) => {
if (slides.length < 2) return;
const newIndex = ((index % slides.length) + slides.length) % slides.length;
setCurrentSlide(newIndex);
setFocusedIndex(newIndex);
onSlideChange?.(newIndex);
},
[slides.length, onSlideChange]
);
const handlePointerDown = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (slides.length < 2) return; // Disable swipe for single slide
if (activePointerId.current !== null) return; // Ignore if already tracking a pointer
activePointerId.current = event.pointerId;
pointerStartX.current = event.clientX;
setIsDragging(true);
setDragOffset(0);
// Capture pointer to receive events even if pointer moves outside element
event.currentTarget.setPointerCapture(event.pointerId);
if (autoRotateMode) {
pauseByInteraction();
}
},
[slides.length, autoRotateMode, pauseByInteraction]
);
const handlePointerMove = useCallback((event: React.PointerEvent) => {
if (activePointerId.current !== event.pointerId) return; // Ignore other pointers
if (pointerStartX.current === null) return;
const diff = event.clientX - pointerStartX.current;
pendingDragOffset.current = diff;
// Throttle updates using requestAnimationFrame
if (rafRef.current === null) {
rafRef.current = requestAnimationFrame(() => {
setDragOffset(pendingDragOffset.current);
rafRef.current = null;
});
}
}, []);
const handlePointerUp = useCallback(
(event: React.PointerEvent) => {
if (activePointerId.current !== event.pointerId) return; // Ignore other pointers
if (pointerStartX.current === null) return;
const diff = event.clientX - pointerStartX.current;
const containerWidth = slidesContainerRef.current?.offsetWidth || 300;
const threshold = containerWidth * 0.2; // 20% of container width
// Use ref to get current slide value to avoid stale closure
const current = currentSlideRef.current;
if (diff > threshold) {
// Swiped right - go to previous slide (instant, no animation)
goToSlideInstant(current - 1);
} else if (diff < -threshold) {
// Swiped left - go to next slide (instant, no animation)
goToSlideInstant(current + 1);
}
// else: snap back (just reset dragOffset)
// Cancel any pending RAF
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
activePointerId.current = null;
pointerStartX.current = null;
setIsDragging(false);
setDragOffset(0);
if (autoRotateMode) {
resumeByInteraction();
}
},
[goToSlideInstant, autoRotateMode, resumeByInteraction]
);
const handlePointerCancel = useCallback((event: React.PointerEvent) => {
if (activePointerId.current !== event.pointerId) return; // Ignore other pointers
// Cancel any pending RAF
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
activePointerId.current = null;
pointerStartX.current = null;
setIsDragging(false);
setDragOffset(0);
}, []);
const containerClass = `apg-carousel ${className}`.trim();
return (
<section
className={containerClass}
aria-roledescription="carousel"
aria-label={ariaLabel}
data-testid={testId}
onFocus={handleCarouselFocusIn}
onBlur={handleCarouselFocusOut}
>
{/* Slides Container */}
<div
ref={slidesContainerRef}
id={slidesContainerId}
data-testid="slides-container"
className={['apg-carousel-slides', isDragging && 'apg-carousel-slides--dragging']
.filter(Boolean)
.join(' ')}
role="group"
aria-live={isActuallyRotating ? 'off' : 'polite'}
aria-atomic="false"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerCancel}
onMouseEnter={handleSlidesMouseEnter}
onMouseLeave={handleSlidesMouseLeave}
>
{slides.map((slide, index) => {
const isActive = index === currentSlide;
const isExiting = index === exitingSlide;
const isSwipeAdjacent = index === swipeAdjacentSlide;
const panelId = `${carouselId}-panel-${slide.id}`;
const tabId = `${carouselId}-tab-${slide.id}`;
// Determine animation class (only for non-swipe transitions)
let animationClass = '';
if (transitionDirection && !isDragging) {
if (isActive) {
animationClass = `apg-carousel-slide--entering-${transitionDirection}`;
} else if (isExiting) {
animationClass = `apg-carousel-slide--exiting-${transitionDirection}`;
}
}
// Determine swipe position class
let swipeClass = '';
if (isSwipeAdjacent && isDragging) {
swipeClass =
dragOffset > 0 ? 'apg-carousel-slide--swipe-prev' : 'apg-carousel-slide--swipe-next';
}
// Calculate transform for swipe
let slideStyle: React.CSSProperties | undefined;
if (isDragging) {
if (isActive) {
slideStyle = { transform: `translateX(${dragOffset}px)` };
} else if (isSwipeAdjacent) {
// Position adjacent slide next to current slide
const baseOffset = dragOffset > 0 ? '-100%' : '100%';
slideStyle = { transform: `translateX(calc(${baseOffset} + ${dragOffset}px))` };
}
}
return (
<div
key={slide.id}
id={panelId}
role="tabpanel"
aria-roledescription="slide"
aria-label={`${index + 1} of ${slides.length}`}
aria-labelledby={tabId}
aria-hidden={!isActive}
inert={!isActive ? true : undefined}
className={`apg-carousel-slide ${isActive ? 'apg-carousel-slide--active' : ''} ${animationClass} ${swipeClass}`.trim()}
style={slideStyle}
>
{slide.content}
</div>
);
})}
</div>
{/* Controls */}
<div className="apg-carousel-controls">
{/* Play/Pause Button (first in tab order) */}
{autoRotate && (
<button
type="button"
className="apg-carousel-play-pause"
aria-label={autoRotateMode ? 'Stop automatic slide show' : 'Start automatic slide show'}
onClick={toggleAutoRotateMode}
>
{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>
) : (
<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>
)}
</button>
)}
{/* Tablist (slide indicators) */}
<div
ref={tablistRef}
role="tablist"
aria-label="Slides"
className="apg-carousel-tablist"
onKeyDown={handleKeyDown}
>
{slides.map((slide, index) => {
const isSelected = index === currentSlide;
const isFocusTarget = index === focusedIndex;
const tabId = `${carouselId}-tab-${slide.id}`;
const panelId = `${carouselId}-panel-${slide.id}`;
return (
<button
key={slide.id}
ref={(el) => {
if (el) {
tabRefs.current.set(slide.id, el);
} else {
tabRefs.current.delete(slide.id);
}
}}
type="button"
role="tab"
id={tabId}
aria-selected={isSelected}
aria-controls={panelId}
tabIndex={isFocusTarget ? 0 : -1}
className={`apg-carousel-tab ${isSelected ? 'apg-carousel-tab--selected' : ''}`}
onClick={() => goToSlide(index)}
aria-label={slide.label || `Slide ${index + 1}`}
>
<span className="apg-carousel-tab-indicator" aria-hidden="true" />
</button>
);
})}
</div>
{/* Previous/Next Buttons */}
<div role="group" aria-label="Slide controls" className="apg-carousel-nav">
<button
type="button"
className="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"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="15" y1="10" x2="5" y2="10" />
<polyline points="10 5 5 10 10 15" />
</svg>
</button>
<button
type="button"
className="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"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="5" y1="10" x2="15" y2="10" />
<polyline points="10 5 15 10 10 15" />
</svg>
</button>
</div>
</div>
</section>
);
}
export default Carousel; 使い方
使用例
import { Carousel } from './Carousel';
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 App() {
return (
<Carousel
slides={slides}
aria-label="注目のコンテンツ"
autoRotate={true}
rotationInterval={5000}
onSlideChange={(index) => console.log('スライド変更:', index)}
/>
);
} 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: React.ReactNode;
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.tsx
import { render, screen, waitFor, within, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Carousel, type CarouselSlide } from './Carousel';
// 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(),
})),
});
// Test slide data
const defaultSlides: CarouselSlide[] = [
{ id: 'slide1', content: <div>Slide 1 Content</div>, label: 'Slide 1' },
{ id: 'slide2', content: <div>Slide 2 Content</div>, label: 'Slide 2' },
{ id: 'slide3', content: <div>Slide 3 Content</div>, label: 'Slide 3' },
];
const fiveSlides: CarouselSlide[] = [
{ id: 'slide1', content: <div>Slide 1</div>, label: 'Slide 1' },
{ id: 'slide2', content: <div>Slide 2</div>, label: 'Slide 2' },
{ id: 'slide3', content: <div>Slide 3</div>, label: 'Slide 3' },
{ id: 'slide4', content: <div>Slide 4</div>, label: 'Slide 4' },
{ id: 'slide5', content: <div>Slide 5</div>, label: 'Slide 5' },
];
describe('Carousel', () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
vi.useRealTimers();
});
// 🔴 High Priority: APG ARIA Structure
describe('APG: ARIA Structure', () => {
it('has aria-roledescription="carousel" on container', () => {
render(<Carousel slides={defaultSlides} aria-label="Featured content" />);
const carousel = screen.getByRole('region');
expect(carousel).toHaveAttribute('aria-roledescription', 'carousel');
});
it('has aria-label on container', () => {
render(<Carousel slides={defaultSlides} aria-label="Featured content" />);
const carousel = screen.getByRole('region');
expect(carousel).toHaveAttribute('aria-label', 'Featured content');
});
it('has aria-roledescription="slide" on each tabpanel', () => {
render(<Carousel slides={defaultSlides} aria-label="Featured content" />);
const panels = screen.getAllByRole('tabpanel', { hidden: true });
panels.forEach((panel) => {
expect(panel).toHaveAttribute('aria-roledescription', 'slide');
});
});
it('has aria-label="N of M" on each slide', () => {
render(<Carousel 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('has role="tablist" on tab container', () => {
render(<Carousel slides={defaultSlides} aria-label="Featured content" />);
expect(screen.getByRole('tablist')).toBeInTheDocument();
});
it('has role="tab" on each tab button', () => {
render(<Carousel slides={defaultSlides} aria-label="Featured content" />);
const tabs = screen.getAllByRole('tab');
expect(tabs).toHaveLength(3);
});
it('has role="tabpanel" on each slide', () => {
render(<Carousel slides={defaultSlides} aria-label="Featured content" />);
const panels = screen.getAllByRole('tabpanel', { hidden: true });
expect(panels).toHaveLength(3);
});
it('has aria-selected="true" on active tab', () => {
render(<Carousel 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('has aria-controls pointing to tabpanel', () => {
render(<Carousel 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);
});
});
it('panel aria-labelledby matches tab id', () => {
render(<Carousel slides={defaultSlides} aria-label="Featured content" />);
const tabs = screen.getAllByRole('tab');
const panels = screen.getAllByRole('tabpanel', { hidden: true });
panels.forEach((panel, index) => {
expect(panel).toHaveAttribute('aria-labelledby', tabs[index].id);
});
});
});
// 🔴 High Priority: Keyboard Interaction
describe('APG: Keyboard Interaction', () => {
it('moves focus to next tab on ArrowRight', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<Carousel 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('moves focus to previous tab on ArrowLeft', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<Carousel 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('wraps from last to first on ArrowRight', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<Carousel 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('wraps from first to last on ArrowLeft', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<Carousel 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('moves focus to first tab on Home', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<Carousel 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('moves focus to last tab on End', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<Carousel 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');
});
it('activates tab on Enter', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<Carousel slides={defaultSlides} aria-label="Featured content" />);
const tabs = screen.getAllByRole('tab');
await user.click(tabs[0]);
await user.keyboard('{ArrowRight}');
await user.keyboard('{Enter}');
expect(tabs[1]).toHaveAttribute('aria-selected', 'true');
});
it('activates tab on Space', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<Carousel slides={defaultSlides} aria-label="Featured content" />);
const tabs = screen.getAllByRole('tab');
await user.click(tabs[0]);
await user.keyboard('{ArrowRight}');
await user.keyboard(' ');
expect(tabs[1]).toHaveAttribute('aria-selected', 'true');
});
});
// 🔴 High Priority: Auto-Rotation
describe('APG: Auto-Rotation', () => {
it('rotates slides automatically when enabled', async () => {
render(
<Carousel
slides={defaultSlides}
aria-label="Featured content"
autoRotate
rotationInterval={3000}
/>
);
const tabs = screen.getAllByRole('tab');
expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
// Advance timer
act(() => {
vi.advanceTimersByTime(3000);
});
await waitFor(() => {
expect(tabs[1]).toHaveAttribute('aria-selected', 'true');
});
act(() => {
vi.advanceTimersByTime(3000);
});
await waitFor(() => {
expect(tabs[2]).toHaveAttribute('aria-selected', 'true');
});
});
it('has aria-live="off" during auto-rotation', () => {
render(<Carousel slides={defaultSlides} aria-label="Featured content" autoRotate />);
const slidesContainer = screen.getByTestId('slides-container');
expect(slidesContainer).toHaveAttribute('aria-live', 'off');
});
it('has aria-live="polite" when rotation stopped', () => {
render(<Carousel slides={defaultSlides} aria-label="Featured content" autoRotate={false} />);
const slidesContainer = screen.getByTestId('slides-container');
expect(slidesContainer).toHaveAttribute('aria-live', 'polite');
});
it('stops rotation on keyboard focus', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(
<Carousel
slides={defaultSlides}
aria-label="Featured content"
autoRotate
rotationInterval={3000}
/>
);
const tabs = screen.getAllByRole('tab');
await user.click(tabs[0]);
const slidesContainer = screen.getByTestId('slides-container');
expect(slidesContainer).toHaveAttribute('aria-live', 'polite');
// Advance time - should NOT rotate
act(() => {
vi.advanceTimersByTime(3000);
});
expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
});
it('stops rotation on mouse hover over slides container', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(
<Carousel
slides={defaultSlides}
aria-label="Featured content"
autoRotate
rotationInterval={3000}
/>
);
const slidesContainer = screen.getByTestId('slides-container');
await user.hover(slidesContainer);
expect(slidesContainer).toHaveAttribute('aria-live', 'polite');
// Advance time - should NOT rotate
act(() => {
vi.advanceTimersByTime(3000);
});
const tabs = screen.getAllByRole('tab');
expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
});
it('resumes rotation on focus/hover out (if not manually stopped)', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(
<Carousel
slides={defaultSlides}
aria-label="Featured content"
autoRotate
rotationInterval={3000}
/>
);
const slidesContainer = screen.getByTestId('slides-container');
const tabs = screen.getAllByRole('tab');
// Hover over slides container to pause
await user.hover(slidesContainer);
act(() => {
vi.advanceTimersByTime(3000);
});
expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
// Unhover to resume
await user.unhover(slidesContainer);
act(() => {
vi.advanceTimersByTime(3000);
});
await waitFor(() => {
expect(tabs[1]).toHaveAttribute('aria-selected', 'true');
});
});
it('toggles rotation with play/pause button', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(
<Carousel
slides={defaultSlides}
aria-label="Featured content"
autoRotate
rotationInterval={3000}
/>
);
const playPauseButton = screen.getByRole('button', { name: /stop|pause/i });
// Click to pause
await user.click(playPauseButton);
const slidesContainer = screen.getByTestId('slides-container');
expect(slidesContainer).toHaveAttribute('aria-live', 'polite');
const tabs = screen.getAllByRole('tab');
act(() => {
vi.advanceTimersByTime(3000);
});
expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
// Click to resume
const startButton = screen.getByRole('button', { name: /start|play/i });
await user.click(startButton);
act(() => {
vi.advanceTimersByTime(3000);
});
await waitFor(() => {
expect(tabs[1]).toHaveAttribute('aria-selected', 'true');
});
});
it('updates button label based on rotation state', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(
<Carousel
slides={defaultSlides}
aria-label="Featured content"
autoRotate
rotationInterval={3000}
/>
);
// Initially rotating - button should say "Stop" or "Pause"
expect(screen.getByRole('button', { name: /stop|pause/i })).toBeInTheDocument();
// Click to pause
await user.click(screen.getByRole('button', { name: /stop|pause/i }));
// Button should now say "Start" or "Play"
expect(screen.getByRole('button', { name: /start|play/i })).toBeInTheDocument();
});
it('respects prefers-reduced-motion', () => {
// Mock matchMedia for prefers-reduced-motion
const originalMatchMedia = window.matchMedia;
window.matchMedia = vi.fn().mockImplementation((query) => ({
matches: query === '(prefers-reduced-motion: reduce)',
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
render(
<Carousel
slides={defaultSlides}
aria-label="Featured content"
autoRotate
rotationInterval={3000}
/>
);
// Should not auto-rotate when reduced motion is preferred
const slidesContainer = screen.getByTestId('slides-container');
expect(slidesContainer).toHaveAttribute('aria-live', 'polite');
const tabs = screen.getAllByRole('tab');
act(() => {
vi.advanceTimersByTime(3000);
});
expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
window.matchMedia = originalMatchMedia;
});
it('loops back to first slide after last', async () => {
render(
<Carousel
slides={defaultSlides}
aria-label="Featured content"
autoRotate
rotationInterval={1000}
initialSlide={2}
/>
);
const tabs = screen.getAllByRole('tab');
expect(tabs[2]).toHaveAttribute('aria-selected', 'true');
act(() => {
vi.advanceTimersByTime(1000);
});
await waitFor(() => {
expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
});
});
});
// 🔴 High Priority: Focus Management
describe('APG: Focus Management', () => {
it('uses roving tabindex on tablist', () => {
render(<Carousel 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('only one tab has tabindex="0" at a time', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<Carousel 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);
});
it('rotation control is first in tab order', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<Carousel slides={defaultSlides} aria-label="Featured content" autoRotate />);
// Start from outside the carousel and tab in
const carousel = screen.getByRole('region');
carousel.focus();
await user.tab();
// First focusable should be the play/pause button
const playPauseButton = screen.getByRole('button', { name: /stop|pause|start|play/i });
expect(playPauseButton).toHaveFocus();
});
});
// 🟡 Medium Priority: Navigation Controls
describe('Navigation Controls', () => {
it('shows next slide on next button click', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<Carousel 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('shows previous slide on previous button click', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<Carousel 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('wraps to first slide from last on next', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<Carousel 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');
});
it('wraps to last slide from first on previous', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<Carousel slides={defaultSlides} aria-label="Featured content" />);
const prevButton = screen.getByRole('button', { name: /previous|prev/i });
await user.click(prevButton);
const tabs = screen.getAllByRole('tab');
expect(tabs[2]).toHaveAttribute('aria-selected', 'true');
});
it('prev/next buttons have aria-controls', () => {
render(<Carousel slides={defaultSlides} aria-label="Featured content" />);
const prevButton = screen.getByRole('button', { name: /previous|prev/i });
const nextButton = screen.getByRole('button', { name: /next/i });
const slidesContainer = screen.getByTestId('slides-container');
expect(prevButton).toHaveAttribute('aria-controls', slidesContainer.id);
expect(nextButton).toHaveAttribute('aria-controls', slidesContainer.id);
});
});
// 🟡 Medium Priority: Accessibility
describe('Accessibility', () => {
it('has no axe violations', async () => {
const { container } = render(
<Carousel slides={defaultSlides} aria-label="Featured content" />
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations with autoRotate', async () => {
const { container } = render(
<Carousel slides={defaultSlides} aria-label="Featured content" autoRotate />
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
// 🟢 Low Priority: Props & Behavior
describe('Props & Behavior', () => {
it('calls onSlideChange when slide changes', async () => {
const handleSlideChange = vi.fn();
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(
<Carousel
slides={defaultSlides}
aria-label="Featured content"
onSlideChange={handleSlideChange}
/>
);
const tabs = screen.getAllByRole('tab');
await user.click(tabs[1]);
expect(handleSlideChange).toHaveBeenCalledWith(1);
});
it('respects initialSlide prop', () => {
render(<Carousel slides={defaultSlides} aria-label="Featured content" initialSlide={1} />);
const tabs = screen.getAllByRole('tab');
expect(tabs[1]).toHaveAttribute('aria-selected', 'true');
});
it('respects autoRotate prop', () => {
render(<Carousel slides={defaultSlides} aria-label="Featured content" autoRotate={false} />);
const slidesContainer = screen.getByTestId('slides-container');
expect(slidesContainer).toHaveAttribute('aria-live', 'polite');
});
it('applies className to container', () => {
const { container } = render(
<Carousel
slides={defaultSlides}
aria-label="Featured content"
className="custom-carousel"
/>
);
const carousel = container.firstChild as HTMLElement;
expect(carousel).toHaveClass('custom-carousel');
});
});
// Edge Cases
describe('Edge Cases', () => {
it('handles single slide', () => {
const singleSlide: CarouselSlide[] = [
{ id: 'slide1', content: <div>Only Slide</div>, label: 'Only Slide' },
];
render(<Carousel 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');
});
it('handles initialSlide out of bounds', () => {
render(<Carousel slides={defaultSlides} aria-label="Featured content" initialSlide={99} />);
const tabs = screen.getAllByRole('tab');
// Should fallback to first slide
expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
});
});
}); リソース
- WAI-ARIA APG: Carousel パターン (opens in new tab)
- AI 実装ガイド (llm.md) (opens in new tab) - ARIA 仕様、キーボードサポート、テストチェックリスト