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.astro
---
/**
* APG Carousel Pattern - Astro Implementation
*
* A carousel displays a set of slides, one at a time, with controls to navigate
* between them and optionally auto-rotate.
* Uses Web Components for client-side keyboard navigation and state management.
*
* @see https://www.w3.org/WAI/ARIA/apg/patterns/carousel/
*/
export interface CarouselSlide {
id: string;
/** Slide content (HTML string) */
content: string;
label?: string;
}
export interface Props {
/** 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;
/** Additional CSS class */
class?: string;
/** Instance ID (optional, auto-generated if not provided) */
id?: string;
/** Test ID for E2E testing */
'data-testid'?: string;
}
const {
slides,
'aria-label': ariaLabel,
initialSlide = 0,
autoRotate = false,
rotationInterval = 5000,
class: className = '',
id,
'data-testid': testId,
} = Astro.props;
// Validate initialSlide - fallback to 0 if out of bounds
const validInitialSlide = initialSlide >= 0 && initialSlide < slides.length ? initialSlide : 0;
// Generate unique ID for this instance
const instanceId = id || `carousel-${Math.random().toString(36).substring(2, 11)}`;
const slidesContainerId = `${instanceId}-slides`;
const containerClass = `apg-carousel ${className}`.trim();
---
<apg-carousel
data-auto-rotate={autoRotate ? 'true' : 'false'}
data-rotation-interval={rotationInterval.toString()}
data-initial-slide={validInitialSlide.toString()}
>
<section
class={containerClass}
aria-roledescription="carousel"
aria-label={ariaLabel}
id={id}
data-testid={testId}
>
<!-- Slides Container -->
<div
id={slidesContainerId}
data-testid="slides-container"
class="apg-carousel-slides"
role="group"
aria-live={autoRotate ? 'off' : 'polite'}
aria-atomic="false"
>
{
slides.map((slide, index) => {
const isActive = index === validInitialSlide;
const panelId = `${instanceId}-panel-${slide.id}`;
const tabId = `${instanceId}-tab-${slide.id}`;
return (
<div
id={panelId}
role="tabpanel"
aria-roledescription="slide"
aria-label={`${index + 1} of ${slides.length}`}
aria-labelledby={tabId}
aria-hidden={!isActive}
inert={!isActive ? true : undefined}
class={`apg-carousel-slide ${isActive ? 'apg-carousel-slide--active' : ''}`}
data-slide-id={slide.id}
data-slide-index={index.toString()}
>
<Fragment set:html={slide.content} />
</div>
);
})
}
</div>
<!-- Controls -->
<div class="apg-carousel-controls">
<!-- Play/Pause Button (first in tab order) -->
{
autoRotate && (
<button
type="button"
class="apg-carousel-play-pause"
aria-label="Stop automatic slide show"
data-playing="true"
>
<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>
</button>
)
}
<!-- Tablist (slide indicators) -->
<div role="tablist" aria-label="Slides" class="apg-carousel-tablist">
{
slides.map((slide, index) => {
const isSelected = index === validInitialSlide;
const tabId = `${instanceId}-tab-${slide.id}`;
const panelId = `${instanceId}-panel-${slide.id}`;
return (
<button
type="button"
role="tab"
id={tabId}
aria-selected={isSelected ? 'true' : 'false'}
aria-controls={panelId}
tabindex={isSelected ? 0 : -1}
class={`apg-carousel-tab ${isSelected ? 'apg-carousel-tab--selected' : ''}`}
aria-label={slide.label || `Slide ${index + 1}`}
data-tab-index={index.toString()}
>
<span class="apg-carousel-tab-indicator" aria-hidden="true" />
</button>
);
})
}
</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}
>
<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"></line>
<polyline points="10 5 5 10 10 15"></polyline>
</svg>
</button>
<button
type="button"
class="apg-carousel-next"
aria-label="Next slide"
aria-controls={slidesContainerId}
>
<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"></line>
<polyline points="10 5 15 10 10 15"></polyline>
</svg>
</button>
</div>
</div>
</section>
<script>
class ApgCarousel extends HTMLElement {
private currentSlide = 0;
private focusedIndex = 0;
// New state model: separate user intent from temporary pause
private autoRotateMode = false;
private isPausedByInteraction = false;
private timerRef: ReturnType<typeof setInterval> | null = null;
private animationTimeoutRef: ReturnType<typeof setTimeout> | null = null;
private rafRef: number | null = null;
private pendingDragOffset = 0;
private pointerStartX: number | null = null;
private activePointerId: number | null = null;
private isDragging = false;
private dragOffset = 0;
private slides: HTMLElement[] = [];
private tabs: HTMLButtonElement[] = [];
private tablist: HTMLElement | null = null;
private slidesContainer: HTMLElement | null = null;
private playPauseButton: HTMLButtonElement | null = null;
private prevButton: HTMLButtonElement | null = null;
private nextButton: HTMLButtonElement | null = null;
// Bound event handlers (stored for cleanup)
private boundHandleKeyDown: (e: KeyboardEvent) => void;
private boundToggleAutoRotateMode: () => void;
private boundGoToPrevSlide: () => void;
private boundGoToNextSlide: () => void;
private boundHandleCarouselFocusIn: () => void;
private boundHandleCarouselFocusOut: (e: FocusEvent) => void;
private boundHandleSlidesMouseEnter: () => void;
private boundHandleSlidesMouseLeave: () => void;
private boundHandlePointerDown: (e: PointerEvent) => void;
private boundHandlePointerMove: (e: PointerEvent) => void;
private boundHandlePointerUp: (e: PointerEvent) => void;
private boundHandlePointerCancel: (e: PointerEvent) => void;
private tabClickHandlers: Array<() => void> = [];
constructor() {
super();
// Bind handlers once in constructor
this.boundHandleKeyDown = this.handleKeyDown.bind(this);
this.boundToggleAutoRotateMode = this.toggleAutoRotateMode.bind(this);
this.boundGoToPrevSlide = this.goToPrevSlide.bind(this);
this.boundGoToNextSlide = this.goToNextSlide.bind(this);
this.boundHandleCarouselFocusIn = this.handleCarouselFocusIn.bind(this);
this.boundHandleCarouselFocusOut = this.handleCarouselFocusOut.bind(this);
this.boundHandleSlidesMouseEnter = this.handleSlidesMouseEnter.bind(this);
this.boundHandleSlidesMouseLeave = this.handleSlidesMouseLeave.bind(this);
this.boundHandlePointerDown = this.handlePointerDown.bind(this);
this.boundHandlePointerMove = this.handlePointerMove.bind(this);
this.boundHandlePointerUp = this.handlePointerUp.bind(this);
this.boundHandlePointerCancel = this.handlePointerCancel.bind(this);
}
connectedCallback() {
// Use requestAnimationFrame to ensure DOM is ready
requestAnimationFrame(() => {
this.initializeElements();
this.initializeState();
this.setupEventListeners();
this.startAutoRotation();
});
}
disconnectedCallback() {
this.stopAutoRotation();
if (this.animationTimeoutRef) {
clearTimeout(this.animationTimeoutRef);
this.animationTimeoutRef = null;
}
if (this.rafRef) {
cancelAnimationFrame(this.rafRef);
this.rafRef = null;
}
this.removeEventListeners();
}
private initializeElements() {
this.slides = Array.from(this.querySelectorAll('[role="tabpanel"]'));
this.tabs = Array.from(this.querySelectorAll('[role="tab"]'));
this.tablist = this.querySelector('[role="tablist"]');
this.slidesContainer = this.querySelector('[data-testid="slides-container"]');
this.playPauseButton = this.querySelector('.apg-carousel-play-pause');
this.prevButton = this.querySelector('.apg-carousel-prev');
this.nextButton = this.querySelector('.apg-carousel-next');
}
private initializeState() {
const initialSlide = parseInt(this.dataset.initialSlide || '0', 10);
this.currentSlide = initialSlide;
this.focusedIndex = initialSlide;
const autoRotate = this.dataset.autoRotate === 'true';
// Check prefers-reduced-motion
if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
const prefersReducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)'
).matches;
if (prefersReducedMotion) {
this.autoRotateMode = false;
} else {
this.autoRotateMode = autoRotate;
}
} else {
this.autoRotateMode = autoRotate;
}
this.updateAriaLive();
// Sync play/pause button state with autoRotateMode (important for prefers-reduced-motion)
this.updatePlayPauseButton();
}
private setupEventListeners() {
// Tablist keyboard navigation
this.tablist?.addEventListener('keydown', this.boundHandleKeyDown);
// Tab clicks
this.tabClickHandlers = [];
this.tabs.forEach((tab, index) => {
const handler = () => this.goToSlide(index);
this.tabClickHandlers.push(handler);
tab.addEventListener('click', handler);
});
// Play/Pause button
this.playPauseButton?.addEventListener('click', this.boundToggleAutoRotateMode);
// Previous/Next buttons
this.prevButton?.addEventListener('click', this.boundGoToPrevSlide);
this.nextButton?.addEventListener('click', this.boundGoToNextSlide);
// Focus/blur for auto-rotation pause (on entire carousel)
this.addEventListener('focusin', this.boundHandleCarouselFocusIn);
this.addEventListener('focusout', this.boundHandleCarouselFocusOut);
// Mouse hover for auto-rotation pause (only on slides container)
this.slidesContainer?.addEventListener('mouseenter', this.boundHandleSlidesMouseEnter);
this.slidesContainer?.addEventListener('mouseleave', this.boundHandleSlidesMouseLeave);
// Touch/swipe
this.slidesContainer?.addEventListener('pointerdown', this.boundHandlePointerDown);
this.slidesContainer?.addEventListener('pointermove', this.boundHandlePointerMove);
this.slidesContainer?.addEventListener('pointerup', this.boundHandlePointerUp);
this.slidesContainer?.addEventListener('pointercancel', this.boundHandlePointerCancel);
}
private removeEventListeners() {
// Tablist keyboard navigation
this.tablist?.removeEventListener('keydown', this.boundHandleKeyDown);
// Tab clicks
this.tabs.forEach((tab, index) => {
const handler = this.tabClickHandlers[index];
if (handler) {
tab.removeEventListener('click', handler);
}
});
this.tabClickHandlers = [];
// Play/Pause button
this.playPauseButton?.removeEventListener('click', this.boundToggleAutoRotateMode);
// Previous/Next buttons
this.prevButton?.removeEventListener('click', this.boundGoToPrevSlide);
this.nextButton?.removeEventListener('click', this.boundGoToNextSlide);
// Focus/blur for auto-rotation pause (on entire carousel)
this.removeEventListener('focusin', this.boundHandleCarouselFocusIn);
this.removeEventListener('focusout', this.boundHandleCarouselFocusOut);
// Mouse hover for auto-rotation pause (only on slides container)
this.slidesContainer?.removeEventListener('mouseenter', this.boundHandleSlidesMouseEnter);
this.slidesContainer?.removeEventListener('mouseleave', this.boundHandleSlidesMouseLeave);
// Touch/swipe
this.slidesContainer?.removeEventListener('pointerdown', this.boundHandlePointerDown);
this.slidesContainer?.removeEventListener('pointermove', this.boundHandlePointerMove);
this.slidesContainer?.removeEventListener('pointerup', this.boundHandlePointerUp);
this.slidesContainer?.removeEventListener('pointercancel', this.boundHandlePointerCancel);
}
private handleKeyDown(event: KeyboardEvent) {
const { key } = event;
let newIndex = this.focusedIndex;
let shouldPreventDefault = false;
switch (key) {
case 'ArrowRight':
newIndex = (this.focusedIndex + 1) % this.slides.length;
shouldPreventDefault = true;
break;
case 'ArrowLeft':
newIndex = (this.focusedIndex - 1 + this.slides.length) % this.slides.length;
shouldPreventDefault = true;
break;
case 'Home':
newIndex = 0;
shouldPreventDefault = true;
break;
case 'End':
newIndex = this.slides.length - 1;
shouldPreventDefault = true;
break;
case 'Enter':
case ' ':
this.goToSlide(this.focusedIndex);
shouldPreventDefault = true;
break;
}
if (shouldPreventDefault) {
event.preventDefault();
if (newIndex !== this.focusedIndex) {
this.focusedIndex = newIndex;
this.tabs[newIndex]?.focus();
this.goToSlide(newIndex);
}
}
}
private goToSlide(index: number) {
if (this.slides.length < 2) return;
const newIndex = ((index % this.slides.length) + this.slides.length) % this.slides.length;
if (newIndex === this.currentSlide) return;
const previousSlide = this.currentSlide;
// Determine direction based on index change
const isWrapForward = previousSlide === this.slides.length - 1 && newIndex === 0;
const isWrapBackward = previousSlide === 0 && newIndex === this.slides.length - 1;
const direction =
isWrapForward || (!isWrapBackward && newIndex > previousSlide) ? 'next' : 'prev';
this.currentSlide = newIndex;
this.focusedIndex = newIndex;
// Update slides with animation classes
this.slides.forEach((slide, i) => {
const isActive = i === newIndex;
const isExiting = i === previousSlide;
// Only active slide is exposed to AT, exiting is visual-only during animation
slide.setAttribute('aria-hidden', (!isActive).toString());
if (isActive) {
slide.removeAttribute('inert');
} else {
slide.setAttribute('inert', '');
}
slide.classList.toggle('apg-carousel-slide--active', isActive);
// Remove old animation classes
slide.classList.remove(
'apg-carousel-slide--entering-next',
'apg-carousel-slide--entering-prev',
'apg-carousel-slide--exiting-next',
'apg-carousel-slide--exiting-prev',
'apg-carousel-slide--swipe-prev',
'apg-carousel-slide--swipe-next'
);
// Add new animation classes
if (isActive) {
slide.classList.add(`apg-carousel-slide--entering-${direction}`);
} else if (isExiting) {
slide.classList.add(`apg-carousel-slide--exiting-${direction}`);
}
});
// Update tabs
this.tabs.forEach((tab, i) => {
const isSelected = i === newIndex;
const isFocusTarget = i === this.focusedIndex;
tab.setAttribute('aria-selected', isSelected.toString());
tab.tabIndex = isFocusTarget ? 0 : -1;
tab.classList.toggle('apg-carousel-tab--selected', isSelected);
});
// Dispatch event
this.dispatchEvent(new CustomEvent('slidechange', { detail: { index: newIndex } }));
// Clean up after animation
this.animationTimeoutRef = setTimeout(() => {
this.animationTimeoutRef = null;
// Hide exiting slide and remove animation classes
this.slides.forEach((slide, i) => {
const shouldHide = i !== this.currentSlide;
slide.setAttribute('aria-hidden', shouldHide.toString());
if (shouldHide) {
slide.setAttribute('inert', '');
} else {
slide.removeAttribute('inert');
}
slide.classList.remove(
'apg-carousel-slide--entering-next',
'apg-carousel-slide--entering-prev',
'apg-carousel-slide--exiting-next',
'apg-carousel-slide--exiting-prev'
);
});
}, 300);
}
// Instant slide change (no animation) for swipe completion
private goToSlideInstant(index: number) {
if (this.slides.length < 2) return;
const newIndex = ((index % this.slides.length) + this.slides.length) % this.slides.length;
this.currentSlide = newIndex;
this.focusedIndex = newIndex;
// Update slides without animation
this.slides.forEach((slide, i) => {
const isActive = i === newIndex;
slide.setAttribute('aria-hidden', (!isActive).toString());
if (isActive) {
slide.removeAttribute('inert');
} else {
slide.setAttribute('inert', '');
}
slide.classList.toggle('apg-carousel-slide--active', isActive);
// Remove all animation and swipe classes
slide.classList.remove(
'apg-carousel-slide--entering-next',
'apg-carousel-slide--entering-prev',
'apg-carousel-slide--exiting-next',
'apg-carousel-slide--exiting-prev',
'apg-carousel-slide--swipe-prev',
'apg-carousel-slide--swipe-next'
);
slide.style.transform = '';
});
// Update tabs
this.tabs.forEach((tab, i) => {
const isSelected = i === newIndex;
const isFocusTarget = i === this.focusedIndex;
tab.setAttribute('aria-selected', isSelected.toString());
tab.tabIndex = isFocusTarget ? 0 : -1;
tab.classList.toggle('apg-carousel-tab--selected', isSelected);
});
// Dispatch event
this.dispatchEvent(new CustomEvent('slidechange', { detail: { index: newIndex } }));
}
private goToNextSlide() {
this.goToSlide(this.currentSlide + 1);
}
private goToPrevSlide() {
this.goToSlide(this.currentSlide - 1);
}
// Computed: actual rotation state
private get isActuallyRotating() {
return this.autoRotateMode && !this.isPausedByInteraction;
}
private get rotationInterval() {
return parseInt(this.dataset.rotationInterval || '5000', 10);
}
private startAutoRotation() {
this.stopAutoRotation();
if (this.isActuallyRotating) {
this.timerRef = setInterval(() => {
this.goToSlide(this.currentSlide + 1);
}, this.rotationInterval);
}
}
private stopAutoRotation() {
if (this.timerRef) {
clearInterval(this.timerRef);
this.timerRef = null;
}
}
private updateAriaLive() {
if (this.slidesContainer) {
this.slidesContainer.setAttribute(
'aria-live',
this.isActuallyRotating ? 'off' : 'polite'
);
}
}
private updatePlayPauseButton() {
if (this.playPauseButton) {
// Button reflects autoRotateMode (user intent), not isActuallyRotating
this.playPauseButton.setAttribute(
'aria-label',
this.autoRotateMode ? 'Stop automatic slide show' : 'Start automatic slide show'
);
this.playPauseButton.innerHTML = this.autoRotateMode
? '<svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><rect x="3" y="2" width="4" height="12" rx="1" /><rect x="9" y="2" width="4" height="12" rx="1" /></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>';
this.playPauseButton.dataset.playing = this.autoRotateMode.toString();
}
}
// Toggle auto-rotate mode (user intent)
private toggleAutoRotateMode() {
this.autoRotateMode = !this.autoRotateMode;
// When enabling auto-rotate, reset interaction pause so rotation starts immediately
if (this.autoRotateMode) {
this.isPausedByInteraction = false;
}
this.startAutoRotation();
this.updateAriaLive();
this.updatePlayPauseButton();
}
// Pause/resume by interaction (hover/focus)
private pauseByInteraction() {
this.isPausedByInteraction = true;
this.stopAutoRotation();
this.updateAriaLive();
}
private resumeByInteraction() {
this.isPausedByInteraction = false;
this.startAutoRotation();
this.updateAriaLive();
}
// Focus/blur handlers for entire carousel
private handleCarouselFocusIn() {
if (this.autoRotateMode) {
this.pauseByInteraction();
}
}
private handleCarouselFocusOut(event: FocusEvent) {
if (!this.autoRotateMode) {
return;
}
// Only resume if focus is leaving the carousel entirely
const { relatedTarget } = event;
// Treat null relatedTarget (focus moved to body) as "left carousel"
const focusLeftCarousel =
relatedTarget === null ||
(relatedTarget instanceof Node && !this.contains(relatedTarget));
if (focusLeftCarousel) {
this.resumeByInteraction();
}
}
// Mouse hover handlers for slides container only
private handleSlidesMouseEnter() {
if (this.autoRotateMode) {
this.pauseByInteraction();
}
}
private handleSlidesMouseLeave() {
if (this.autoRotateMode) {
this.resumeByInteraction();
}
}
private handlePointerDown(event: PointerEvent) {
if (this.slides.length < 2) return; // Disable swipe for single slide
if (this.activePointerId !== null) return; // Ignore if already tracking a pointer
this.activePointerId = event.pointerId;
this.pointerStartX = event.clientX;
this.isDragging = true;
this.dragOffset = 0;
// Capture pointer to receive events even if pointer moves outside element
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
// Add dragging class
this.slidesContainer?.classList.add('apg-carousel-slides--dragging');
if (this.autoRotateMode) {
this.pauseByInteraction();
}
}
private handlePointerMove(event: PointerEvent) {
if (this.activePointerId !== event.pointerId) return; // Ignore other pointers
if (this.pointerStartX === null || !this.isDragging) return;
const diff = event.clientX - this.pointerStartX;
this.pendingDragOffset = diff;
// Throttle updates using requestAnimationFrame
if (this.rafRef === null) {
this.rafRef = requestAnimationFrame(() => {
this.dragOffset = this.pendingDragOffset;
this.rafRef = null;
this.updateSwipeVisual();
});
}
}
private updateSwipeVisual() {
const diff = this.dragOffset;
// Compute which adjacent slide to show during drag
const swipeAdjacentSlide =
diff !== 0
? diff > 0
? (this.currentSlide - 1 + this.slides.length) % this.slides.length // swiping right, show prev
: (this.currentSlide + 1) % this.slides.length // swiping left, show next
: null;
// Update slides for swipe visual
this.slides.forEach((slide, i) => {
const isActive = i === this.currentSlide;
const isSwipeAdjacent = i === swipeAdjacentSlide;
// Only active slide is exposed to AT, adjacent is visual-only
slide.setAttribute('aria-hidden', (!isActive).toString());
if (isActive) {
slide.removeAttribute('inert');
} else {
slide.setAttribute('inert', '');
}
// Remove animation classes during drag
slide.classList.remove(
'apg-carousel-slide--entering-next',
'apg-carousel-slide--entering-prev',
'apg-carousel-slide--exiting-next',
'apg-carousel-slide--exiting-prev'
);
// Add/remove swipe classes
slide.classList.remove(
'apg-carousel-slide--swipe-prev',
'apg-carousel-slide--swipe-next'
);
if (isSwipeAdjacent) {
slide.classList.add(
diff > 0 ? 'apg-carousel-slide--swipe-prev' : 'apg-carousel-slide--swipe-next'
);
}
// Apply transform to active and adjacent slides
if (isActive) {
slide.style.transform = `translateX(${diff}px)`;
} else if (isSwipeAdjacent) {
// Position adjacent slide next to current slide
const baseOffset = diff > 0 ? '-100%' : '100%';
slide.style.transform = `translateX(calc(${baseOffset} + ${diff}px))`;
} else {
slide.style.transform = '';
}
});
}
private handlePointerUp(event: PointerEvent) {
if (this.activePointerId !== event.pointerId) return; // Ignore other pointers
if (!this.isDragging || this.pointerStartX === null) return;
const diff = event.clientX - this.pointerStartX;
const containerWidth = this.slidesContainer?.offsetWidth || 300;
const threshold = containerWidth * 0.2; // 20% of container width
if (diff > threshold) {
// Swiped right - go to previous slide (instant, no animation)
this.goToSlideInstant(this.currentSlide - 1);
} else if (diff < -threshold) {
// Swiped left - go to next slide (instant, no animation)
this.goToSlideInstant(this.currentSlide + 1);
} else {
// Snap back - reset slide styles
this.slides.forEach((slide, i) => {
const isActive = i === this.currentSlide;
slide.setAttribute('aria-hidden', (!isActive).toString());
if (isActive) {
slide.removeAttribute('inert');
} else {
slide.setAttribute('inert', '');
}
slide.classList.remove(
'apg-carousel-slide--swipe-prev',
'apg-carousel-slide--swipe-next'
);
slide.style.transform = '';
});
}
// Cancel any pending RAF
if (this.rafRef !== null) {
cancelAnimationFrame(this.rafRef);
this.rafRef = null;
}
this.activePointerId = null;
this.pointerStartX = null;
this.isDragging = false;
this.dragOffset = 0;
// Reset visual feedback
this.slidesContainer?.classList.remove('apg-carousel-slides--dragging');
if (this.autoRotateMode) {
this.resumeByInteraction();
}
}
private handlePointerCancel(event: PointerEvent) {
if (this.activePointerId !== event.pointerId) return; // Ignore other pointers
// Cancel any pending RAF
if (this.rafRef !== null) {
cancelAnimationFrame(this.rafRef);
this.rafRef = null;
}
this.activePointerId = null;
this.pointerStartX = null;
this.isDragging = false;
this.dragOffset = 0;
// Reset visual feedback
this.slidesContainer?.classList.remove('apg-carousel-slides--dragging');
// Reset slide styles
this.slides.forEach((slide, i) => {
const isActive = i === this.currentSlide;
slide.setAttribute('aria-hidden', (!isActive).toString());
if (isActive) {
slide.removeAttribute('inert');
} else {
slide.setAttribute('inert', '');
}
slide.classList.remove(
'apg-carousel-slide--swipe-prev',
'apg-carousel-slide--swipe-next'
);
slide.style.transform = '';
});
}
}
// Register the custom element
if (!customElements.get('apg-carousel')) {
customElements.define('apg-carousel', ApgCarousel);
}
</script>
</apg-carousel> 使い方
使用例
---
import Carousel from '@patterns/carousel/Carousel.astro';
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番目のスライド' }
];
---
<Carousel
slides={slides}
aria-label="注目のコンテンツ"
autoRotate={true}
rotationInterval={5000}
/> API
Carousel Props
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
slides | CarouselSlide[] | 必須 | スライドアイテムの配列 |
aria-label | string | 必須 | カルーセルのアクセシブルな名前 |
initialSlide | number | 0 | 初期スライドのインデックス(0から開始) |
autoRotate | boolean | false | 自動回転を有効にする |
rotationInterval | number | 5000 | 回転間隔(ミリ秒) |
id | string | 自動生成 | カルーセルのインスタンスID |
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.astro.ts
/**
* Carousel Astro Component Tests using Container API
*
* These tests verify the Carousel.astro component output using Astro's Container API.
* This ensures the component renders correct ARIA structure and attributes.
*
* Note: Interactive behavior (keyboard, auto-rotation) is tested in E2E tests.
*
* @see https://docs.astro.build/en/reference/container-reference/
*/
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { describe, it, expect, beforeEach } from 'vitest';
import { JSDOM } from 'jsdom';
import Carousel from './Carousel.astro';
describe('Carousel (Astro Container API)', () => {
let container: AstroContainer;
beforeEach(async () => {
container = await AstroContainer.create();
});
// Helper to render and parse HTML
async function renderCarousel(props: {
slides: Array<{ id: string; content: string; label?: string }>;
'aria-label': string;
initialSlide?: number;
autoRotate?: boolean;
rotationInterval?: number;
class?: string;
id?: string;
}): Promise<Document> {
const html = await container.renderToString(Carousel, { props });
const dom = new JSDOM(html);
return dom.window.document;
}
const basicSlides = [
{ 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 = [
{ 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' },
];
// 🔴 High Priority: APG ARIA Structure
describe('APG: ARIA Structure', () => {
it('has aria-roledescription="carousel" on container', async () => {
const doc = await renderCarousel({
slides: basicSlides,
'aria-label': 'Featured content',
});
const carousel = doc.querySelector('section');
expect(carousel?.getAttribute('aria-roledescription')).toBe('carousel');
});
it('has aria-label on container', async () => {
const doc = await renderCarousel({
slides: basicSlides,
'aria-label': 'Featured content',
});
const carousel = doc.querySelector('section');
expect(carousel?.getAttribute('aria-label')).toBe('Featured content');
});
it('has aria-roledescription="slide" on each tabpanel', async () => {
const doc = await renderCarousel({
slides: basicSlides,
'aria-label': 'Featured content',
});
const panels = doc.querySelectorAll('[role="tabpanel"]');
expect(panels).toHaveLength(3);
panels.forEach((panel) => {
expect(panel.getAttribute('aria-roledescription')).toBe('slide');
});
});
it('has aria-label="N of M" on each slide', async () => {
const doc = await renderCarousel({
slides: fiveSlides,
'aria-label': 'Featured content',
});
const panels = doc.querySelectorAll('[role="tabpanel"]');
expect(panels[0]?.getAttribute('aria-label')).toBe('1 of 5');
expect(panels[1]?.getAttribute('aria-label')).toBe('2 of 5');
expect(panels[2]?.getAttribute('aria-label')).toBe('3 of 5');
expect(panels[3]?.getAttribute('aria-label')).toBe('4 of 5');
expect(panels[4]?.getAttribute('aria-label')).toBe('5 of 5');
});
});
describe('APG: Tablist ARIA', () => {
it('has role="tablist" on tab container', async () => {
const doc = await renderCarousel({
slides: basicSlides,
'aria-label': 'Featured content',
});
const tablist = doc.querySelector('[role="tablist"]');
expect(tablist).not.toBeNull();
});
it('has role="tab" on each tab button', async () => {
const doc = await renderCarousel({
slides: basicSlides,
'aria-label': 'Featured content',
});
const tabs = doc.querySelectorAll('[role="tab"]');
expect(tabs).toHaveLength(3);
});
it('has role="tabpanel" on each slide', async () => {
const doc = await renderCarousel({
slides: basicSlides,
'aria-label': 'Featured content',
});
const panels = doc.querySelectorAll('[role="tabpanel"]');
expect(panels).toHaveLength(3);
});
it('has aria-selected="true" on first tab by default', async () => {
const doc = await renderCarousel({
slides: basicSlides,
'aria-label': 'Featured content',
});
const tabs = doc.querySelectorAll('[role="tab"]');
expect(tabs[0]?.getAttribute('aria-selected')).toBe('true');
expect(tabs[1]?.getAttribute('aria-selected')).toBe('false');
expect(tabs[2]?.getAttribute('aria-selected')).toBe('false');
});
it('has aria-selected="true" on initialSlide tab', async () => {
const doc = await renderCarousel({
slides: basicSlides,
'aria-label': 'Featured content',
initialSlide: 1,
});
const tabs = doc.querySelectorAll('[role="tab"]');
expect(tabs[0]?.getAttribute('aria-selected')).toBe('false');
expect(tabs[1]?.getAttribute('aria-selected')).toBe('true');
expect(tabs[2]?.getAttribute('aria-selected')).toBe('false');
});
it('has aria-controls pointing to tabpanel', async () => {
const doc = await renderCarousel({
slides: basicSlides,
'aria-label': 'Featured content',
});
const tabs = doc.querySelectorAll('[role="tab"]');
const panels = doc.querySelectorAll('[role="tabpanel"]');
tabs.forEach((tab, index) => {
const controls = tab.getAttribute('aria-controls');
const panelId = panels[index]?.getAttribute('id');
expect(controls).toBe(panelId);
});
});
it('panel aria-labelledby matches tab id', async () => {
const doc = await renderCarousel({
slides: basicSlides,
'aria-label': 'Featured content',
});
const tabs = doc.querySelectorAll('[role="tab"]');
const panels = doc.querySelectorAll('[role="tabpanel"]');
panels.forEach((panel, index) => {
const labelledby = panel.getAttribute('aria-labelledby');
const tabId = tabs[index]?.getAttribute('id');
expect(labelledby).toBe(tabId);
});
});
});
// 🔴 High Priority: Auto-Rotation State
describe('APG: Auto-Rotation', () => {
it('has aria-live="off" when autoRotate is true', async () => {
const doc = await renderCarousel({
slides: basicSlides,
'aria-label': 'Featured content',
autoRotate: true,
});
const slidesContainer = doc.querySelector('[data-testid="slides-container"]');
expect(slidesContainer?.getAttribute('aria-live')).toBe('off');
});
it('has aria-live="polite" when autoRotate is false', async () => {
const doc = await renderCarousel({
slides: basicSlides,
'aria-label': 'Featured content',
autoRotate: false,
});
const slidesContainer = doc.querySelector('[data-testid="slides-container"]');
expect(slidesContainer?.getAttribute('aria-live')).toBe('polite');
});
it('has play/pause button when autoRotate is true', async () => {
const doc = await renderCarousel({
slides: basicSlides,
'aria-label': 'Featured content',
autoRotate: true,
});
const playPauseButton = doc.querySelector(
'button[aria-label*="Stop"], button[aria-label*="Pause"]'
);
expect(playPauseButton).not.toBeNull();
});
});
// 🔴 High Priority: Focus Management
describe('APG: Focus Management', () => {
it('uses roving tabindex on tablist', async () => {
const doc = await renderCarousel({
slides: basicSlides,
'aria-label': 'Featured content',
});
const tabs = doc.querySelectorAll('[role="tab"]');
expect(tabs[0]?.getAttribute('tabindex')).toBe('0');
expect(tabs[1]?.getAttribute('tabindex')).toBe('-1');
expect(tabs[2]?.getAttribute('tabindex')).toBe('-1');
});
it('active tab has tabindex="0"', async () => {
const doc = await renderCarousel({
slides: basicSlides,
'aria-label': 'Featured content',
initialSlide: 1,
});
const tabs = doc.querySelectorAll('[role="tab"]');
expect(tabs[0]?.getAttribute('tabindex')).toBe('-1');
expect(tabs[1]?.getAttribute('tabindex')).toBe('0');
expect(tabs[2]?.getAttribute('tabindex')).toBe('-1');
});
});
// 🟡 Medium Priority: Navigation Controls
describe('Navigation Controls', () => {
it('has previous button', async () => {
const doc = await renderCarousel({
slides: basicSlides,
'aria-label': 'Featured content',
});
const prevButton = doc.querySelector(
'button[aria-label*="Previous"], button[aria-label*="Prev"]'
);
expect(prevButton).not.toBeNull();
});
it('has next button', async () => {
const doc = await renderCarousel({
slides: basicSlides,
'aria-label': 'Featured content',
});
const nextButton = doc.querySelector('button[aria-label*="Next"]');
expect(nextButton).not.toBeNull();
});
it('prev/next buttons have aria-controls', async () => {
const doc = await renderCarousel({
slides: basicSlides,
'aria-label': 'Featured content',
});
const prevButton = doc.querySelector(
'button[aria-label*="Previous"], button[aria-label*="Prev"]'
);
const nextButton = doc.querySelector('button[aria-label*="Next"]');
const slidesContainer = doc.querySelector('[data-testid="slides-container"]');
expect(prevButton?.getAttribute('aria-controls')).toBe(slidesContainer?.getAttribute('id'));
expect(nextButton?.getAttribute('aria-controls')).toBe(slidesContainer?.getAttribute('id'));
});
});
// 🟢 Low Priority: HTML Attributes
describe('HTML Attributes', () => {
it('applies class to container', async () => {
const doc = await renderCarousel({
slides: basicSlides,
'aria-label': 'Featured content',
class: 'custom-carousel',
});
const carousel = doc.querySelector('section');
expect(carousel?.classList.contains('custom-carousel')).toBe(true);
});
it('applies id to container', async () => {
const doc = await renderCarousel({
slides: basicSlides,
'aria-label': 'Featured content',
id: 'my-carousel',
});
const carousel = doc.querySelector('section');
expect(carousel?.getAttribute('id')).toBe('my-carousel');
});
});
// Edge Cases
describe('Edge Cases', () => {
it('handles single slide', async () => {
const singleSlide = [{ id: 'slide1', content: '<div>Only Slide</div>', label: 'Only' }];
const doc = await renderCarousel({
slides: singleSlide,
'aria-label': 'Featured content',
});
const tabs = doc.querySelectorAll('[role="tab"]');
expect(tabs).toHaveLength(1);
const panel = doc.querySelector('[role="tabpanel"]');
expect(panel?.getAttribute('aria-label')).toBe('1 of 1');
});
it('clamps initialSlide to valid range', async () => {
const doc = await renderCarousel({
slides: basicSlides,
'aria-label': 'Featured content',
initialSlide: 99,
});
const tabs = doc.querySelectorAll('[role="tab"]');
// Should fallback to first slide
expect(tabs[0]?.getAttribute('aria-selected')).toBe('true');
});
});
}); リソース
- WAI-ARIA APG: Carousel パターン (opens in new tab)
- AI 実装ガイド (llm.md) (opens in new tab) - ARIA 仕様、キーボードサポート、テストチェックリスト