Carousel
A rotating set of content items (slides) displayed one at a time with controls to navigate between them.
🤖 AI Implementation GuideDemo
Manual Navigation
Navigate using the tab indicators, previous/next buttons, or keyboard arrows.
Auto-Rotation
Automatically rotates through slides. Pauses on hover, focus, or when the user clicks the pause button. Respects prefers-reduced-motion.
Accessibility Features
WAI-ARIA Roles
| Role | Target Element | Description |
|---|---|---|
region | Container (section) | Landmark region for the carousel |
group | Slides container | Groups all slides together |
tablist | Tab container | Container for slide indicator tabs |
tab | Each tab button | Individual slide indicator |
tabpanel | Each slide | Individual slide content area |
WAI-ARIA APG Carousel Pattern (opens in new tab)
WAI-ARIA Properties
| Attribute | Target | Values | Required | Description |
|---|---|---|---|---|
aria-roledescription | Container | "carousel" | Yes | Announces "carousel" to screen readers |
aria-roledescription | Each slide (tabpanel) | "slide" | Yes | Announces "slide" instead of "tabpanel" |
aria-label | Container | Text | Yes | Describes the carousel purpose |
aria-label | Each slide (tabpanel) | "N of M" | Yes | Slide position (e.g., "1 of 5") |
aria-controls | Tab, Prev/Next buttons | ID reference | Yes | References controlled element |
aria-labelledby | Each slide (tabpanel) | ID reference | Yes | References associated tab |
aria-atomic | Slides container | "false" | No | Only announce changed content |
WAI-ARIA States
aria-selected
Indicates the currently selected slide indicator tab.
| Target | Tab element |
| Values | true | false |
| Required | Yes |
| Change Trigger | Tab click, Arrow keys, Prev/Next buttons, Auto-rotation |
| Reference | aria-selected (opens in new tab) |
aria-live
Dynamically controls screen reader announcements based on rotation state.
| Target | Slides container |
| Values | "off" (auto-rotating) | "polite" (manual/paused)
|
| Required | Yes (when auto-rotation enabled) |
| Change Trigger | Play/Pause click, Focus in/out, Mouse hover |
| Reference | aria-live (opens in new tab) |
Note: Set to "off" during auto-rotation to prevent interrupting users.
Changes to "polite" when rotation stops, allowing slide changes to be announced.
Keyboard Support
| Key | Action |
|---|---|
| Tab | Navigate between controls (Play/Pause, tablist, Prev/Next) |
| Arrow Right | Move to next slide indicator tab (loops to first) |
| Arrow Left | Move to previous slide indicator tab (loops to last) |
| Home | Move focus to first slide indicator tab |
| End | Move focus to last slide indicator tab |
| Enter / Space | Activate focused tab or button |
Auto-Rotation Behavior
| Trigger | Behavior |
|---|---|
| Keyboard focus enters carousel | Rotation pauses temporarily, aria-live changes to "polite" |
| Keyboard focus leaves carousel | Rotation resumes (if auto-rotate mode is on) |
| Mouse hovers over slides | Rotation pauses temporarily |
| Mouse leaves slides | Rotation resumes (if auto-rotate mode is on) |
| Pause button clicked | Turns off auto-rotate mode, button shows play icon |
| Play button clicked | Turns on auto-rotate mode and starts rotation immediately |
| prefers-reduced-motion: reduce | Auto-rotation disabled by default |
Source Code
<template>
<section
:class="containerClass"
aria-roledescription="carousel"
:aria-label="props.ariaLabel"
:data-testid="props.dataTestid"
@focusin="handleCarouselFocusIn"
@focusout="handleCarouselFocusOut"
>
<!-- Slides Container -->
<div
:id="slidesContainerId"
data-testid="slides-container"
:class="slidesContainerClass"
role="group"
:aria-live="isActuallyRotating ? 'off' : 'polite'"
aria-atomic="false"
:style="undefined"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerUp"
@pointercancel="handlePointerCancel"
@mouseenter="handleSlidesMouseEnter"
@mouseleave="handleSlidesMouseLeave"
>
<div
v-for="(slide, index) in slides"
:key="slide.id"
: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="index !== currentSlide"
:inert="index !== currentSlide ? true : undefined"
:class="[
'apg-carousel-slide',
index === currentSlide ? 'apg-carousel-slide--active' : '',
transitionDirection && !isDragging && index === currentSlide
? `apg-carousel-slide--entering-${transitionDirection}`
: '',
transitionDirection && !isDragging && index === exitingSlide
? `apg-carousel-slide--exiting-${transitionDirection}`
: '',
isDragging && index === swipeAdjacentSlide && dragOffset > 0
? 'apg-carousel-slide--swipe-prev'
: '',
isDragging && index === swipeAdjacentSlide && dragOffset < 0
? 'apg-carousel-slide--swipe-next'
: '',
]"
:style="getSlideStyle(index)"
>
<div v-html="slide.content" />
</div>
</div>
<!-- Controls -->
<div class="apg-carousel-controls">
<!-- Play/Pause Button (first in tab order) -->
<button
v-if="autoRotate"
type="button"
class="apg-carousel-play-pause"
:aria-label="autoRotateMode ? 'Stop automatic slide show' : 'Start automatic slide show'"
@click="toggleAutoRotateMode"
>
<svg
v-if="autoRotateMode"
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
v-else
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"
class="apg-carousel-tablist"
@keydown="handleKeyDown"
>
<button
v-for="(slide, index) in slides"
:key="slide.id"
:ref="(el) => setTabRef(slide.id, el)"
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' : '']"
@click="goToSlide(index)"
:aria-label="slide.label || `Slide ${index + 1}`"
>
<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"
@click="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"
@click="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>
</template>
<script setup lang="ts">
import { ref, computed, watch, onUnmounted } from 'vue';
export interface CarouselSlide {
/** Unique identifier for the slide */
id: string;
/** Slide content (HTML string) */
content: string;
/** Accessible label for the slide */
label?: string;
}
export interface CarouselProps {
/** Array of slides */
slides: CarouselSlide[];
/** Accessible label for the carousel (required) */
ariaLabel: 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;
/** Test ID for E2E testing */
dataTestid?: string;
/** Optional ID for SSR stability (auto-generated if not provided) */
id?: string;
}
const props = withDefaults(defineProps<CarouselProps>(), {
initialSlide: 0,
autoRotate: false,
rotationInterval: 5000,
class: '',
});
const emit = defineEmits<{
slideChange: [index: number];
}>();
// Validate initialSlide - fallback to 0 if out of bounds
const validInitialSlide = computed(() => {
return props.initialSlide >= 0 && props.initialSlide < props.slides.length
? props.initialSlide
: 0;
});
// Generate ID - use prop if provided for SSR stability, otherwise generate
let generatedId: string | undefined;
if (typeof props.id === 'string' && props.id.length > 0) {
generatedId = props.id;
} else if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
// Use crypto.randomUUID for better uniqueness when available
generatedId = `carousel-${crypto.randomUUID().slice(0, 8)}`;
} else {
generatedId = `carousel-${Math.random().toString(36).slice(2, 11)}`;
}
const carouselId = ref(generatedId);
// Helper to get initial auto-rotate mode
const getInitialAutoRotateMode = (): boolean => {
if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (prefersReducedMotion) {
return false;
}
}
return props.autoRotate;
};
// State - new model: separate user intent from temporary pause
const currentSlide = ref(validInitialSlide.value);
const focusedIndex = ref(validInitialSlide.value);
const autoRotateMode = ref(getInitialAutoRotateMode());
const isPausedByInteraction = ref(false);
// Transition animation state
const exitingSlide = ref<number | null>(null);
const transitionDirection = ref<'next' | 'prev' | null>(null);
// Refs
const tablistRef = ref<HTMLElement>();
const tabRefs = ref<Record<string, HTMLButtonElement>>({});
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;
const isDragging = ref(false);
const dragOffset = ref(0);
// Computed
const slidesContainerId = computed(() => `${carouselId.value}-slides`);
const isActuallyRotating = computed(() => autoRotateMode.value && !isPausedByInteraction.value);
const containerClass = computed(() => `apg-carousel ${props.class}`.trim());
const slidesContainerClass = computed(
() => `apg-carousel-slides${isDragging.value ? ' apg-carousel-slides--dragging' : ''}`
);
// Compute which adjacent slide to show during drag
const swipeAdjacentSlide = computed(() =>
isDragging.value && dragOffset.value !== 0
? dragOffset.value > 0
? (currentSlide.value - 1 + props.slides.length) % props.slides.length // swiping right, show prev
: (currentSlide.value + 1) % props.slides.length // swiping left, show next
: null
);
// Calculate transform style for each slide during swipe
const getSlideStyle = (index: number): Record<string, string> | undefined => {
if (!isDragging.value) return undefined;
if (index === currentSlide.value) {
return { transform: `translateX(${dragOffset.value}px)` };
} else if (index === swipeAdjacentSlide.value) {
// Position adjacent slide next to current slide
const baseOffset = dragOffset.value > 0 ? '-100%' : '100%';
return { transform: `translateX(calc(${baseOffset} + ${dragOffset.value}px))` };
}
return undefined;
};
onUnmounted(() => {
if (timerRef) {
clearInterval(timerRef);
timerRef = null;
}
if (animationTimeoutRef) {
clearTimeout(animationTimeoutRef);
animationTimeoutRef = null;
}
if (rafRef) {
cancelAnimationFrame(rafRef);
rafRef = null;
}
});
const setTabRef = (id: string, el: unknown) => {
if (el instanceof HTMLButtonElement) {
tabRefs.value[id] = el;
}
};
// Slide navigation with animation
const goToSlide = (index: number) => {
if (props.slides.length < 2) return;
const newIndex = ((index % props.slides.length) + props.slides.length) % props.slides.length;
if (newIndex === currentSlide.value) return;
// Determine direction based on index change
const current = currentSlide.value;
const isWrapForward = current === props.slides.length - 1 && newIndex === 0;
const isWrapBackward = current === 0 && newIndex === props.slides.length - 1;
const direction = isWrapForward || (!isWrapBackward && newIndex > current) ? 'next' : 'prev';
// Start transition
exitingSlide.value = current;
transitionDirection.value = direction;
currentSlide.value = newIndex;
focusedIndex.value = newIndex;
emit('slideChange', newIndex);
// Clean up after animation
animationTimeoutRef = setTimeout(() => {
exitingSlide.value = null;
transitionDirection.value = null;
animationTimeoutRef = null;
}, 300);
};
// Instant slide change (no animation) for swipe completion
const goToSlideInstant = (index: number) => {
if (props.slides.length < 2) return;
const newIndex = ((index % props.slides.length) + props.slides.length) % props.slides.length;
currentSlide.value = newIndex;
focusedIndex.value = newIndex;
emit('slideChange', newIndex);
};
const goToNextSlide = () => {
goToSlide(currentSlide.value + 1);
};
const goToPrevSlide = () => {
goToSlide(currentSlide.value - 1);
};
// Focus management
const handleTabFocus = (index: number) => {
focusedIndex.value = index;
const slide = props.slides[index];
if (slide) {
tabRefs.value[slide.id]?.focus();
}
};
// Keyboard handler for tablist
const handleKeyDown = (event: KeyboardEvent) => {
const { key } = event;
const target = event.target;
if (!tablistRef.value || !(target instanceof Node) || !tablistRef.value.contains(target)) {
return;
}
let newIndex = focusedIndex.value;
let shouldPreventDefault = false;
switch (key) {
case 'ArrowRight':
newIndex = (focusedIndex.value + 1) % props.slides.length;
shouldPreventDefault = true;
break;
case 'ArrowLeft':
newIndex = (focusedIndex.value - 1 + props.slides.length) % props.slides.length;
shouldPreventDefault = true;
break;
case 'Home':
newIndex = 0;
shouldPreventDefault = true;
break;
case 'End':
newIndex = props.slides.length - 1;
shouldPreventDefault = true;
break;
case 'Enter':
case ' ':
// Tab is already selected on focus, but this handles manual confirmation
goToSlide(focusedIndex.value);
shouldPreventDefault = true;
break;
}
if (shouldPreventDefault) {
event.preventDefault();
if (newIndex !== focusedIndex.value) {
handleTabFocus(newIndex);
goToSlide(newIndex);
}
}
};
// Auto-rotation timer with animation
watch(
[isActuallyRotating, () => props.slides.length, () => props.rotationInterval],
([rotating, slidesLength, interval]) => {
if (timerRef) {
clearInterval(timerRef);
timerRef = null;
}
if (rotating) {
timerRef = setInterval(() => {
const current = currentSlide.value;
const nextIndex = (current + 1) % slidesLength;
// Trigger animation
exitingSlide.value = current;
transitionDirection.value = 'next';
currentSlide.value = nextIndex;
focusedIndex.value = nextIndex;
emit('slideChange', nextIndex);
// Clean up animation state
animationTimeoutRef = setTimeout(() => {
exitingSlide.value = null;
transitionDirection.value = null;
animationTimeoutRef = null;
}, 300);
}, interval);
}
},
{ immediate: true }
);
// Toggle auto-rotate mode (user intent)
const toggleAutoRotateMode = () => {
autoRotateMode.value = !autoRotateMode.value;
// When enabling auto-rotate, reset interaction pause so rotation starts immediately
if (autoRotateMode.value) {
isPausedByInteraction.value = false;
}
};
// Pause/resume by interaction (hover/focus)
const pauseByInteraction = () => {
isPausedByInteraction.value = true;
};
const resumeByInteraction = () => {
isPausedByInteraction.value = false;
};
// Focus/blur handlers for entire carousel
const handleCarouselFocusIn = () => {
if (autoRotateMode.value) {
pauseByInteraction();
}
};
const handleCarouselFocusOut = (event: FocusEvent) => {
if (!autoRotateMode.value) 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
const handleSlidesMouseEnter = () => {
if (autoRotateMode.value) {
pauseByInteraction();
}
};
const handleSlidesMouseLeave = () => {
if (autoRotateMode.value) {
resumeByInteraction();
}
};
// Touch/swipe handlers
const handlePointerDown = (event: PointerEvent) => {
if (props.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.value = true;
dragOffset.value = 0;
// Capture pointer to receive events even if pointer moves outside element
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
if (autoRotateMode.value) {
pauseByInteraction();
}
};
const 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.value = pendingDragOffset;
rafRef = null;
});
}
};
const handlePointerUp = (event: PointerEvent) => {
if (activePointerId !== event.pointerId) return; // Ignore other pointers
if (!isDragging.value || 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.value - 1);
} else if (diff < -threshold) {
// Swiped left - go to next slide (instant, no animation)
goToSlideInstant(currentSlide.value + 1);
}
// else: snap back (just reset dragOffset)
// Cancel any pending RAF
if (rafRef !== null) {
cancelAnimationFrame(rafRef);
rafRef = null;
}
activePointerId = null;
pointerStartX = null;
isDragging.value = false;
dragOffset.value = 0;
if (autoRotateMode.value) {
resumeByInteraction();
}
};
const 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.value = false;
dragOffset.value = 0;
};
</script>
<style scoped>
/* Styles are in src/styles/patterns/carousel.css */
</style> Usage
<script setup lang="ts">
import Carousel from './Carousel.vue';
const slides = [
{ id: 'slide1', content: '<p>Slide 1 content</p>', label: 'First slide' },
{ id: 'slide2', content: '<p>Slide 2 content</p>', label: 'Second slide' },
{ id: 'slide3', content: '<p>Slide 3 content</p>', label: 'Third slide' }
];
function handleSlideChange(index: number) {
console.log('Slide changed:', index);
}
</script>
<template>
<Carousel
:slides="slides"
aria-label="Featured content"
:auto-rotate="true"
:rotation-interval="5000"
@slide-change="handleSlideChange"
/>
</template> API
Carousel Props
| Prop | Type | Default | Description |
|---|---|---|---|
slides | CarouselSlide[] | required | Array of slide items |
ariaLabel | string | required | Accessible name for the carousel |
initialSlide | number | 0 | Initial slide index (0-based) |
autoRotate | boolean | false | Enable auto-rotation |
rotationInterval | number | 5000 | Rotation interval in milliseconds |
Events
| Event | Payload | Description |
|---|---|---|
slide-change | number | Emitted when slide changes (index) |
CarouselSlide Interface
interface CarouselSlide {
id: string;
content: string;
label?: string;
} Testing
Tests verify APG compliance across keyboard interaction, ARIA attributes, auto-rotation behavior, and accessibility requirements. The Carousel component uses a two-layer testing strategy.
Testing Strategy
Unit Tests (Container API)
Verify the component's HTML output using Astro Container API. These tests ensure correct template rendering without requiring a browser.
- HTML structure and element hierarchy
- Initial ARIA attributes (aria-roledescription, aria-label, aria-selected)
- Tablist/tab/tabpanel structure
- Initial tabindex values (roving tabindex)
- CSS class application
E2E Tests (Playwright)
Verify Web Component behavior in a real browser environment. These tests cover interactions that require JavaScript execution.
- Keyboard navigation (Arrow keys, Home, End)
- Tab selection and slide changes
- Auto-rotation start/stop
- Play/pause button interaction
- Focus management during navigation
Test Categories
High Priority: ARIA Structure (Unit)
| Test | Description |
|---|---|
aria-roledescription="carousel" | Container has carousel role description |
aria-roledescription="slide" | Each tabpanel has slide role description |
aria-label (container) | Container has accessible name |
aria-label="N of M" | Each slide has position label (e.g., "1 of 5") |
High Priority: Tablist ARIA (Unit)
| Test | Description |
|---|---|
role="tablist" | Tab container has tablist role |
role="tab" | Each slide indicator has tab role |
role="tabpanel" | Each slide has tabpanel role |
aria-selected | Active tab has aria-selected="true" |
aria-controls | Tab references its slide via aria-controls |
High Priority: Keyboard Interaction (E2E)
| Test | Description |
|---|---|
ArrowRight | Moves focus and activates next slide tab |
ArrowLeft | Moves focus and activates previous slide tab |
Loop navigation | Arrow keys loop from last to first and vice versa |
Home/End | Moves focus to first/last slide tab |
High Priority: Focus Management (Unit + E2E)
| Test | Description |
|---|---|
tabIndex=0 (Unit) | Selected tab has tabIndex=0 initially |
tabIndex=-1 (Unit) | Non-selected tabs have tabIndex=-1 initially |
Roving tabindex (E2E) | Only one tab has tabIndex=0 during navigation |
High Priority: Auto-Rotation (Unit + E2E)
| Test | Description |
|---|---|
aria-live="off" (Unit) | Initial aria-live when autoRotate is true |
aria-live="polite" (Unit) | Initial aria-live when autoRotate is false |
Play/Pause button (Unit) | Button is rendered when autoRotate is true |
Play/Pause toggle (E2E) | Button toggles rotation state |
Medium Priority: Navigation Controls (Unit + E2E)
| Test | Description |
|---|---|
Prev/Next buttons (Unit) | Navigation buttons are rendered |
aria-controls (Unit) | Buttons have aria-controls pointing to slides container |
Next button (E2E) | Shows next slide on click |
Previous button (E2E) | Shows previous slide on click |
Loop navigation (E2E) | Loops from last to first and vice versa |
Low Priority: HTML Attributes (Unit)
| Test | Description |
|---|---|
class attribute | Custom classes are applied to container |
id attribute | ID attribute is correctly set |
Testing Tools
- Vitest (opens in new tab) - Test runner for unit tests with fake timers for auto-rotation testing
- Astro Container API (opens in new tab) - Server-side component rendering for unit tests
- Playwright (opens in new tab) - Browser automation for E2E tests
- Testing Library (opens in new tab) - Framework-specific testing utilities (React, Vue, Svelte)
- jest-axe (opens in new tab) - Automated accessibility testing
See testing-strategy.md (opens in new tab) for full documentation.
import { render, screen, waitFor } from '@testing-library/vue';
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.vue';
import type { CarouselSlide } from './Carousel.vue';
// 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 (Vue)', () => {
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, ariaLabel: 'Featured content' } });
const carousel = screen.getByRole('region');
expect(carousel).toHaveAttribute('aria-roledescription', 'carousel');
});
it('コンテナに aria-label がある', () => {
render(Carousel, { props: { slides: defaultSlides, ariaLabel: 'Featured content' } });
const carousel = screen.getByRole('region');
expect(carousel).toHaveAttribute('aria-label', 'Featured content');
});
it('各 tabpanel に aria-roledescription="slide" がある', () => {
render(Carousel, { props: { slides: defaultSlides, ariaLabel: '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, ariaLabel: '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, ariaLabel: 'Featured content' } });
expect(screen.getByRole('tablist')).toBeInTheDocument();
});
it('各タブに role="tab" がある', () => {
render(Carousel, { props: { slides: defaultSlides, ariaLabel: 'Featured content' } });
const tabs = screen.getAllByRole('tab');
expect(tabs).toHaveLength(3);
});
it('アクティブなタブに aria-selected="true" がある', () => {
render(Carousel, { props: { slides: defaultSlides, ariaLabel: '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, ariaLabel: '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, ariaLabel: '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, ariaLabel: '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, ariaLabel: '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, ariaLabel: '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, ariaLabel: '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, ariaLabel: '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,
ariaLabel: '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, ariaLabel: '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, ariaLabel: '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,
ariaLabel: '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('マウスホバーで回転が停止する', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(Carousel, {
props: {
slides: defaultSlides,
ariaLabel: 'Featured content',
autoRotate: true,
rotationInterval: 3000,
},
});
const carousel = screen.getByRole('region');
await user.hover(carousel);
vi.advanceTimersByTime(3000);
const tabs = screen.getAllByRole('tab');
expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
});
it('Play/Pause ボタンで回転を切り替えできる', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(Carousel, {
props: {
slides: defaultSlides,
ariaLabel: '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, ariaLabel: '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, ariaLabel: '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, ariaLabel: '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, ariaLabel: '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, ariaLabel: '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, ariaLabel: '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,
ariaLabel: '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, ariaLabel: '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, ariaLabel: 'Featured content' },
});
const tabs = screen.getAllByRole('tab');
expect(tabs).toHaveLength(1);
const panel = screen.getByRole('tabpanel');
expect(panel).toHaveAttribute('aria-label', '1 of 1');
});
});
}); Resources
- WAI-ARIA APG: Carousel Pattern (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist