APG Patterns
日本語 GitHub
日本語 GitHub

Carousel

A rotating set of content items (slides) displayed one at a time with controls to navigate between them.

🤖 AI Implementation Guide

Demo

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

Carousel.svelte
<script lang="ts">
  import { onMount, onDestroy } from 'svelte';

  export interface CarouselSlide {
    /** Unique identifier for the slide */
    id: string;
    /** Slide content (HTML string) */
    content: string;
    /** Accessible label for the slide */
    label?: string;
  }

  interface CarouselProps {
    slides: CarouselSlide[];
    'aria-label': string;
    initialSlide?: number;
    autoRotate?: boolean;
    rotationInterval?: number;
    class?: string;
    onSlideChange?: (index: number) => void;
    'data-testid'?: string;
    /** Optional ID for SSR stability (auto-generated if not provided) */
    id?: string;
  }

  let {
    slides = [],
    'aria-label': ariaLabel,
    initialSlide = 0,
    autoRotate = false,
    rotationInterval = 5000,
    class: className = '',
    onSlideChange = () => {},
    'data-testid': testId,
    id: propId,
  }: CarouselProps = $props();

  // Validate initialSlide - fallback to 0 if out of bounds
  let validInitialSlide = $derived(
    initialSlide >= 0 && initialSlide < slides.length ? initialSlide : 0
  );

  // State - new model: separate user intent from temporary pause
  let currentSlide = $state(0);
  let focusedIndex = $state(0);
  let autoRotateMode = $state(false);
  let isPausedByInteraction = $state(false);

  // Transition animation state
  let exitingSlide = $state<number | null>(null);
  let transitionDirection = $state<'next' | 'prev' | null>(null);

  // Refs
  let tablistElement: HTMLElement;
  let tabRefs: HTMLButtonElement[] = [];
  // Generate ID - use prop if provided for SSR stability, otherwise generate on client
  const generateId = () => {
    if (typeof propId === 'string' && propId.length > 0) {
      return propId;
    }
    if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
      return `carousel-${crypto.randomUUID().slice(0, 8)}`;
    }
    return `carousel-${Math.random().toString(36).slice(2, 11)}`;
  };
  let carouselId = $state(propId || '');
  let timerRef: ReturnType<typeof setInterval> | null = null;
  let animationTimeoutRef: ReturnType<typeof setTimeout> | null = null;
  let rafRef: number | null = null;
  let pendingDragOffset = 0;
  let pointerStartX: number | null = null;
  let activePointerId: number | null = null;
  let isDragging = $state(false);
  let dragOffset = $state(0);

  // Generate ID on client side if not provided via prop
  $effect(() => {
    if (typeof window !== 'undefined' && !carouselId) {
      carouselId = generateId();
    }
  });

  // Derived
  let slidesContainerId = $derived(`${carouselId}-slides`);
  let isActuallyRotating = $derived(autoRotateMode && !isPausedByInteraction);
  let containerClass = $derived(`apg-carousel ${className}`.trim());
  let slidesContainerClass = $derived(
    `apg-carousel-slides${isDragging ? ' apg-carousel-slides--dragging' : ''}`
  );

  // Compute which adjacent slide to show during drag
  let swipeAdjacentSlide = $derived(
    isDragging && dragOffset !== 0
      ? dragOffset > 0
        ? (currentSlide - 1 + slides.length) % slides.length // swiping right, show prev
        : (currentSlide + 1) % slides.length // swiping left, show next
      : null
  );

  // Initialize on mount
  onMount(() => {
    currentSlide = validInitialSlide;
    focusedIndex = validInitialSlide;

    // Check prefers-reduced-motion
    if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
      const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
      if (prefersReducedMotion) {
        autoRotateMode = false;
      } else {
        autoRotateMode = autoRotate;
      }
    } else {
      autoRotateMode = autoRotate;
    }
  });

  onDestroy(() => {
    if (timerRef) {
      clearInterval(timerRef);
      timerRef = null;
    }
    if (animationTimeoutRef) {
      clearTimeout(animationTimeoutRef);
      animationTimeoutRef = null;
    }
    if (rafRef) {
      cancelAnimationFrame(rafRef);
      rafRef = null;
    }
  });

  // Auto-rotation timer effect with animation
  $effect(() => {
    if (timerRef) {
      clearInterval(timerRef);
      timerRef = null;
    }

    if (isActuallyRotating) {
      timerRef = setInterval(() => {
        const current = currentSlide;
        const nextIndex = (current + 1) % slides.length;

        // Trigger animation
        exitingSlide = current;
        transitionDirection = 'next';
        currentSlide = nextIndex;
        focusedIndex = nextIndex;
        onSlideChange(nextIndex);

        // Clean up animation state
        animationTimeoutRef = setTimeout(() => {
          exitingSlide = null;
          transitionDirection = null;
          animationTimeoutRef = null;
        }, 300);
      }, rotationInterval);
    }

    return () => {
      if (timerRef) {
        clearInterval(timerRef);
        timerRef = null;
      }
    };
  });

  // Slide navigation with animation
  function goToSlide(index: number) {
    if (slides.length < 2) return;
    const newIndex = ((index % slides.length) + slides.length) % slides.length;
    if (newIndex === currentSlide) return;

    // Determine direction based on index change
    const current = currentSlide;
    const isWrapForward = current === slides.length - 1 && newIndex === 0;
    const isWrapBackward = current === 0 && newIndex === slides.length - 1;
    const direction = isWrapForward || (!isWrapBackward && newIndex > current) ? 'next' : 'prev';

    // Start transition
    exitingSlide = current;
    transitionDirection = direction;
    currentSlide = newIndex;
    focusedIndex = newIndex;
    onSlideChange(newIndex);

    // Clean up after animation
    animationTimeoutRef = setTimeout(() => {
      exitingSlide = null;
      transitionDirection = null;
      animationTimeoutRef = null;
    }, 300);
  }

  // Instant slide change (no animation) for swipe completion
  function goToSlideInstant(index: number) {
    if (slides.length < 2) return;
    const newIndex = ((index % slides.length) + slides.length) % slides.length;
    currentSlide = newIndex;
    focusedIndex = newIndex;
    onSlideChange(newIndex);
  }

  function goToNextSlide() {
    goToSlide(currentSlide + 1);
  }

  function goToPrevSlide() {
    goToSlide(currentSlide - 1);
  }

  // Focus management
  function handleTabFocus(index: number) {
    focusedIndex = index;
    if (tabRefs[index]) {
      tabRefs[index].focus();
    }
  }

  // Keyboard handler for tablist
  function handleKeyDown(event: KeyboardEvent) {
    const { key } = event;
    const target = event.target;
    if (!tablistElement || !(target instanceof Node) || !tablistElement.contains(target)) {
      return;
    }

    let newIndex = focusedIndex;
    let shouldPreventDefault = false;

    switch (key) {
      case 'ArrowRight':
        newIndex = (focusedIndex + 1) % slides.length;
        shouldPreventDefault = true;
        break;

      case 'ArrowLeft':
        newIndex = (focusedIndex - 1 + slides.length) % slides.length;
        shouldPreventDefault = true;
        break;

      case 'Home':
        newIndex = 0;
        shouldPreventDefault = true;
        break;

      case 'End':
        newIndex = slides.length - 1;
        shouldPreventDefault = true;
        break;

      case 'Enter':
      case ' ':
        goToSlide(focusedIndex);
        shouldPreventDefault = true;
        break;
    }

    if (shouldPreventDefault) {
      event.preventDefault();

      if (newIndex !== focusedIndex) {
        handleTabFocus(newIndex);
        goToSlide(newIndex);
      }
    }
  }

  // Toggle auto-rotate mode (user intent)
  function toggleAutoRotateMode() {
    autoRotateMode = !autoRotateMode;
    // When enabling auto-rotate, reset interaction pause so rotation starts immediately
    if (autoRotateMode) {
      isPausedByInteraction = false;
    }
  }

  // Pause/resume by interaction (hover/focus)
  function pauseByInteraction() {
    isPausedByInteraction = true;
  }

  function resumeByInteraction() {
    isPausedByInteraction = false;
  }

  // Focus/blur handlers for entire carousel
  function handleCarouselFocusIn() {
    if (autoRotateMode) {
      pauseByInteraction();
    }
  }

  function handleCarouselFocusOut(event: FocusEvent) {
    if (!autoRotateMode) return;

    // Only resume if focus is leaving the carousel entirely
    const { currentTarget, relatedTarget } = event;
    const focusLeftCarousel =
      relatedTarget === null ||
      (currentTarget instanceof Element &&
        relatedTarget instanceof Node &&
        !currentTarget.contains(relatedTarget));
    if (focusLeftCarousel) {
      resumeByInteraction();
    }
  }

  // Mouse hover handlers for slides container only
  function handleSlidesMouseEnter() {
    if (autoRotateMode) {
      pauseByInteraction();
    }
  }

  function handleSlidesMouseLeave() {
    if (autoRotateMode) {
      resumeByInteraction();
    }
  }

  // Touch/swipe handlers
  function handlePointerDown(event: PointerEvent) {
    if (slides.length < 2) return; // Disable swipe for single slide
    if (activePointerId !== null) return; // Ignore if already tracking a pointer
    activePointerId = event.pointerId;
    pointerStartX = event.clientX;
    isDragging = true;
    dragOffset = 0;
    // Capture pointer to receive events even if pointer moves outside element
    (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
    if (autoRotateMode) {
      pauseByInteraction();
    }
  }

  function handlePointerMove(event: PointerEvent) {
    if (activePointerId !== event.pointerId) return; // Ignore other pointers
    if (pointerStartX === null) return;
    const diff = event.clientX - pointerStartX;
    pendingDragOffset = diff;

    // Throttle updates using requestAnimationFrame
    if (rafRef === null) {
      rafRef = requestAnimationFrame(() => {
        dragOffset = pendingDragOffset;
        rafRef = null;
      });
    }
  }

  function handlePointerUp(event: PointerEvent) {
    if (activePointerId !== event.pointerId) return; // Ignore other pointers
    if (!isDragging || pointerStartX === null) return;

    const diff = event.clientX - pointerStartX;
    const target = event.currentTarget as HTMLElement;
    const containerWidth = target?.offsetWidth || 300;
    const threshold = containerWidth * 0.2; // 20% of container width

    if (diff > threshold) {
      // Swiped right - go to previous slide (instant, no animation)
      goToSlideInstant(currentSlide - 1);
    } else if (diff < -threshold) {
      // Swiped left - go to next slide (instant, no animation)
      goToSlideInstant(currentSlide + 1);
    }
    // else: snap back (just reset dragOffset)

    // Cancel any pending RAF
    if (rafRef !== null) {
      cancelAnimationFrame(rafRef);
      rafRef = null;
    }

    activePointerId = null;
    pointerStartX = null;
    isDragging = false;
    dragOffset = 0;

    if (autoRotateMode) {
      resumeByInteraction();
    }
  }

  function handlePointerCancel(event: PointerEvent) {
    if (activePointerId !== event.pointerId) return; // Ignore other pointers
    // Cancel any pending RAF
    if (rafRef !== null) {
      cancelAnimationFrame(rafRef);
      rafRef = null;
    }
    activePointerId = null;
    pointerStartX = null;
    isDragging = false;
    dragOffset = 0;
  }
</script>

<section
  class={containerClass}
  aria-roledescription="carousel"
  aria-label={ariaLabel}
  data-testid={testId}
  onfocusin={handleCarouselFocusIn}
  onfocusout={handleCarouselFocusOut}
>
  <!-- Slides Container -->
  <div
    id={slidesContainerId}
    data-testid="slides-container"
    class={slidesContainerClass}
    role="group"
    aria-live={isActuallyRotating ? 'off' : 'polite'}
    aria-atomic="false"
    onpointerdown={handlePointerDown}
    onpointermove={handlePointerMove}
    onpointerup={handlePointerUp}
    onpointercancel={handlePointerCancel}
    onmouseenter={handleSlidesMouseEnter}
    onmouseleave={handleSlidesMouseLeave}
  >
    {#each slides as slide, index}
      {@const isActive = index === currentSlide}
      {@const isExiting = index === exitingSlide}
      {@const isSwipeAdjacent = index === swipeAdjacentSlide}
      {@const enteringClass =
        transitionDirection && !isDragging && isActive
          ? `apg-carousel-slide--entering-${transitionDirection}`
          : ''}
      {@const exitingClass =
        transitionDirection && !isDragging && isExiting
          ? `apg-carousel-slide--exiting-${transitionDirection}`
          : ''}
      {@const swipeClass =
        isDragging && isSwipeAdjacent
          ? dragOffset > 0
            ? 'apg-carousel-slide--swipe-prev'
            : 'apg-carousel-slide--swipe-next'
          : ''}
      {@const slideStyle = isDragging
        ? isActive
          ? `transform: translateX(${dragOffset}px)`
          : isSwipeAdjacent
            ? `transform: translateX(calc(${dragOffset > 0 ? '-100%' : '100%'} + ${dragOffset}px))`
            : undefined
        : undefined}
      <div
        id={`${carouselId}-panel-${slide.id}`}
        role="tabpanel"
        aria-roledescription="slide"
        aria-label={`${index + 1} of ${slides.length}`}
        aria-labelledby={`${carouselId}-tab-${slide.id}`}
        aria-hidden={!isActive}
        inert={!isActive ? true : undefined}
        class={`apg-carousel-slide ${isActive ? 'apg-carousel-slide--active' : ''} ${enteringClass} ${exitingClass} ${swipeClass}`.trim()}
        style={slideStyle}
      >
        {@html slide.content}
      </div>
    {/each}
  </div>

  <!-- Controls -->
  <div class="apg-carousel-controls">
    <!-- Play/Pause Button (first in tab order) -->
    {#if autoRotate}
      <button
        type="button"
        class="apg-carousel-play-pause"
        aria-label={autoRotateMode ? 'Stop automatic slide show' : 'Start automatic slide show'}
        onclick={toggleAutoRotateMode}
      >
        {#if autoRotateMode}
          <svg aria-hidden="true" width="18" height="18" viewBox="0 0 16 16" fill="currentColor">
            <rect x="3" y="2" width="4" height="12" rx="1.5" />
            <rect x="9" y="2" width="4" height="12" rx="1.5" />
          </svg>
        {:else}
          <svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
            <path d="M4 2.5v11a.5.5 0 0 0 .75.43l9-5.5a.5.5 0 0 0 0-.86l-9-5.5A.5.5 0 0 0 4 2.5z" />
          </svg>
        {/if}
      </button>
    {/if}

    <!-- Tablist (slide indicators) -->
    <div
      bind:this={tablistElement}
      role="tablist"
      aria-label="Slides"
      class="apg-carousel-tablist"
      onkeydown={handleKeyDown}
    >
      {#each slides as slide, index}
        <button
          bind:this={tabRefs[index]}
          type="button"
          role="tab"
          id={`${carouselId}-tab-${slide.id}`}
          aria-selected={index === currentSlide}
          aria-controls={`${carouselId}-panel-${slide.id}`}
          tabindex={index === focusedIndex ? 0 : -1}
          class={`apg-carousel-tab ${index === currentSlide ? 'apg-carousel-tab--selected' : ''}`}
          onclick={() => goToSlide(index)}
          aria-label={slide.label || `Slide ${index + 1}`}
        >
          <span class="apg-carousel-tab-indicator" aria-hidden="true"></span>
        </button>
      {/each}
    </div>

    <!-- Previous/Next Buttons -->
    <div role="group" aria-label="Slide controls" class="apg-carousel-nav">
      <button
        type="button"
        class="apg-carousel-prev"
        aria-label="Previous slide"
        aria-controls={slidesContainerId}
        onclick={goToPrevSlide}
      >
        <svg
          aria-hidden="true"
          width="20"
          height="20"
          viewBox="0 0 20 20"
          fill="none"
          stroke="currentColor"
          stroke-width="2.5"
          stroke-linecap="round"
          stroke-linejoin="round"
        >
          <line x1="15" y1="10" x2="5" y2="10" />
          <polyline points="10 5 5 10 10 15" />
        </svg>
      </button>
      <button
        type="button"
        class="apg-carousel-next"
        aria-label="Next slide"
        aria-controls={slidesContainerId}
        onclick={goToNextSlide}
      >
        <svg
          aria-hidden="true"
          width="20"
          height="20"
          viewBox="0 0 20 20"
          fill="none"
          stroke="currentColor"
          stroke-width="2.5"
          stroke-linecap="round"
          stroke-linejoin="round"
        >
          <line x1="5" y1="10" x2="15" y2="10" />
          <polyline points="10 5 15 10 10 15" />
        </svg>
      </button>
    </div>
  </div>
</section>

<style>
  /* Styles are in src/styles/patterns/carousel.css */
</style>

Usage

Example
<script lang="ts">
  import Carousel from './Carousel.svelte';

  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>

<Carousel
  {slides}
  aria-label="Featured content"
  autoRotate={true}
  rotationInterval={5000}
  onSlideChange={handleSlideChange}
/>

API

Carousel Props

Prop Type Default Description
slides CarouselSlide[] required Array of slide items
aria-label 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
onSlideChange (index: number) => void - Callback when slide changes

CarouselSlide Interface

Types
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

See testing-strategy.md (opens in new tab) for full documentation.

Carousel.test.svelte.ts
import { render, screen, waitFor } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import Carousel from './Carousel.svelte';
import type { CarouselSlide } from './Carousel.svelte';

// Mock matchMedia for tests
Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: vi.fn().mockImplementation((query: string) => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: vi.fn(),
    removeListener: vi.fn(),
    addEventListener: vi.fn(),
    removeEventListener: vi.fn(),
    dispatchEvent: vi.fn(),
  })),
});

// テスト用スライドデータ
const defaultSlides: CarouselSlide[] = [
  { id: 'slide1', content: 'Slide 1 Content', label: 'Slide 1' },
  { id: 'slide2', content: 'Slide 2 Content', label: 'Slide 2' },
  { id: 'slide3', content: 'Slide 3 Content', label: 'Slide 3' },
];

const fiveSlides: CarouselSlide[] = [
  { id: 'slide1', content: 'Slide 1', label: 'Slide 1' },
  { id: 'slide2', content: 'Slide 2', label: 'Slide 2' },
  { id: 'slide3', content: 'Slide 3', label: 'Slide 3' },
  { id: 'slide4', content: 'Slide 4', label: 'Slide 4' },
  { id: 'slide5', content: 'Slide 5', label: 'Slide 5' },
];

describe('Carousel (Svelte)', () => {
  beforeEach(() => {
    vi.useFakeTimers({ shouldAdvanceTime: true });
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  // 🔴 High Priority: APG ARIA Structure
  describe('APG: ARIA 構造', () => {
    it('コンテナに aria-roledescription="carousel" がある', () => {
      render(Carousel, { props: { slides: defaultSlides, 'aria-label': 'Featured content' } });
      const carousel = screen.getByRole('region');
      expect(carousel).toHaveAttribute('aria-roledescription', 'carousel');
    });

    it('コンテナに aria-label がある', () => {
      render(Carousel, { props: { slides: defaultSlides, 'aria-label': 'Featured content' } });
      const carousel = screen.getByRole('region');
      expect(carousel).toHaveAttribute('aria-label', 'Featured content');
    });

    it('各 tabpanel に aria-roledescription="slide" がある', () => {
      render(Carousel, { props: { slides: defaultSlides, 'aria-label': 'Featured content' } });
      const panels = screen.getAllByRole('tabpanel', { hidden: true });
      panels.forEach((panel) => {
        expect(panel).toHaveAttribute('aria-roledescription', 'slide');
      });
    });

    it('各スライドに aria-label="N of M" がある', () => {
      render(Carousel, { props: { slides: fiveSlides, 'aria-label': 'Featured content' } });
      const panels = screen.getAllByRole('tabpanel', { hidden: true });

      expect(panels[0]).toHaveAttribute('aria-label', '1 of 5');
      expect(panels[1]).toHaveAttribute('aria-label', '2 of 5');
      expect(panels[2]).toHaveAttribute('aria-label', '3 of 5');
      expect(panels[3]).toHaveAttribute('aria-label', '4 of 5');
      expect(panels[4]).toHaveAttribute('aria-label', '5 of 5');
    });
  });

  describe('APG: Tablist ARIA', () => {
    it('タブコンテナに role="tablist" がある', () => {
      render(Carousel, { props: { slides: defaultSlides, 'aria-label': 'Featured content' } });
      expect(screen.getByRole('tablist')).toBeInTheDocument();
    });

    it('各タブに role="tab" がある', () => {
      render(Carousel, { props: { slides: defaultSlides, 'aria-label': 'Featured content' } });
      const tabs = screen.getAllByRole('tab');
      expect(tabs).toHaveLength(3);
    });

    it('アクティブなタブに aria-selected="true" がある', () => {
      render(Carousel, { props: { slides: defaultSlides, 'aria-label': 'Featured content' } });
      const tabs = screen.getAllByRole('tab');

      expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
      expect(tabs[1]).toHaveAttribute('aria-selected', 'false');
      expect(tabs[2]).toHaveAttribute('aria-selected', 'false');
    });

    it('タブの aria-controls が tabpanel を指している', () => {
      render(Carousel, { props: { slides: defaultSlides, 'aria-label': 'Featured content' } });
      const tabs = screen.getAllByRole('tab');
      const panels = screen.getAllByRole('tabpanel', { hidden: true });

      tabs.forEach((tab, index) => {
        expect(tab).toHaveAttribute('aria-controls', panels[index].id);
      });
    });
  });

  // 🔴 High Priority: キーボード操作
  describe('APG: キーボード操作', () => {
    it('ArrowRight で次のタブにフォーカスが移動する', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(Carousel, { props: { slides: defaultSlides, 'aria-label': 'Featured content' } });

      const tabs = screen.getAllByRole('tab');
      await user.click(tabs[0]);
      await user.keyboard('{ArrowRight}');

      expect(tabs[1]).toHaveFocus();
      expect(tabs[1]).toHaveAttribute('aria-selected', 'true');
    });

    it('ArrowLeft で前のタブにフォーカスが移動する', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(Carousel, {
        props: { slides: defaultSlides, 'aria-label': 'Featured content', initialSlide: 1 },
      });

      const tabs = screen.getAllByRole('tab');
      await user.click(tabs[1]);
      await user.keyboard('{ArrowLeft}');

      expect(tabs[0]).toHaveFocus();
      expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
    });

    it('ArrowRight で最後から最初にループする', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(Carousel, {
        props: { slides: defaultSlides, 'aria-label': 'Featured content', initialSlide: 2 },
      });

      const tabs = screen.getAllByRole('tab');
      await user.click(tabs[2]);
      await user.keyboard('{ArrowRight}');

      expect(tabs[0]).toHaveFocus();
      expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
    });

    it('ArrowLeft で最初から最後にループする', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(Carousel, { props: { slides: defaultSlides, 'aria-label': 'Featured content' } });

      const tabs = screen.getAllByRole('tab');
      await user.click(tabs[0]);
      await user.keyboard('{ArrowLeft}');

      expect(tabs[2]).toHaveFocus();
      expect(tabs[2]).toHaveAttribute('aria-selected', 'true');
    });

    it('Home で最初のタブに移動する', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(Carousel, {
        props: { slides: defaultSlides, 'aria-label': 'Featured content', initialSlide: 2 },
      });

      const tabs = screen.getAllByRole('tab');
      await user.click(tabs[2]);
      await user.keyboard('{Home}');

      expect(tabs[0]).toHaveFocus();
      expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
    });

    it('End で最後のタブに移動する', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(Carousel, { props: { slides: defaultSlides, 'aria-label': 'Featured content' } });

      const tabs = screen.getAllByRole('tab');
      await user.click(tabs[0]);
      await user.keyboard('{End}');

      expect(tabs[2]).toHaveFocus();
      expect(tabs[2]).toHaveAttribute('aria-selected', 'true');
    });
  });

  // 🔴 High Priority: 自動回転
  describe('APG: 自動回転', () => {
    it('有効時にスライドが自動的に回転する', async () => {
      render(Carousel, {
        props: {
          slides: defaultSlides,
          'aria-label': 'Featured content',
          autoRotate: true,
          rotationInterval: 3000,
        },
      });

      const tabs = screen.getAllByRole('tab');
      expect(tabs[0]).toHaveAttribute('aria-selected', 'true');

      vi.advanceTimersByTime(3000);

      await waitFor(() => {
        expect(tabs[1]).toHaveAttribute('aria-selected', 'true');
      });
    });

    it('自動回転中は aria-live="off"', () => {
      render(Carousel, {
        props: { slides: defaultSlides, 'aria-label': 'Featured content', autoRotate: true },
      });

      const slidesContainer = screen.getByTestId('slides-container');
      expect(slidesContainer).toHaveAttribute('aria-live', 'off');
    });

    it('回転停止時は aria-live="polite"', () => {
      render(Carousel, {
        props: { slides: defaultSlides, 'aria-label': 'Featured content', autoRotate: false },
      });

      const slidesContainer = screen.getByTestId('slides-container');
      expect(slidesContainer).toHaveAttribute('aria-live', 'polite');
    });

    it('キーボードフォーカスで回転が停止する', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(Carousel, {
        props: {
          slides: defaultSlides,
          'aria-label': 'Featured content',
          autoRotate: true,
          rotationInterval: 3000,
        },
      });

      const tabs = screen.getAllByRole('tab');
      await user.click(tabs[0]);

      const slidesContainer = screen.getByTestId('slides-container');
      expect(slidesContainer).toHaveAttribute('aria-live', 'polite');

      vi.advanceTimersByTime(3000);
      expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
    });

    it('Play/Pause ボタンで回転を切り替えできる', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(Carousel, {
        props: {
          slides: defaultSlides,
          'aria-label': 'Featured content',
          autoRotate: true,
          rotationInterval: 3000,
        },
      });

      const playPauseButton = screen.getByRole('button', { name: /stop|pause/i });
      await user.click(playPauseButton);

      const slidesContainer = screen.getByTestId('slides-container');
      expect(slidesContainer).toHaveAttribute('aria-live', 'polite');
    });
  });

  // 🔴 High Priority: フォーカス管理
  describe('APG: フォーカス管理', () => {
    it('tablist で roving tabindex を使用している', () => {
      render(Carousel, { props: { slides: defaultSlides, 'aria-label': 'Featured content' } });
      const tabs = screen.getAllByRole('tab');

      expect(tabs[0]).toHaveAttribute('tabIndex', '0');
      expect(tabs[1]).toHaveAttribute('tabIndex', '-1');
      expect(tabs[2]).toHaveAttribute('tabIndex', '-1');
    });

    it('一度に1つのタブのみ tabindex="0" を持つ', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(Carousel, { props: { slides: defaultSlides, 'aria-label': 'Featured content' } });

      const tabs = screen.getAllByRole('tab');
      await user.click(tabs[0]);
      await user.keyboard('{ArrowRight}');

      const tabsWithZeroTabindex = tabs.filter((tab) => tab.getAttribute('tabIndex') === '0');
      expect(tabsWithZeroTabindex).toHaveLength(1);
    });
  });

  // 🟡 Medium Priority: ナビゲーションコントロール
  describe('ナビゲーションコントロール', () => {
    it('次へボタンで次のスライドを表示する', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(Carousel, { props: { slides: defaultSlides, 'aria-label': 'Featured content' } });

      const nextButton = screen.getByRole('button', { name: /next/i });
      await user.click(nextButton);

      const tabs = screen.getAllByRole('tab');
      expect(tabs[1]).toHaveAttribute('aria-selected', 'true');
    });

    it('前へボタンで前のスライドを表示する', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(Carousel, {
        props: { slides: defaultSlides, 'aria-label': 'Featured content', initialSlide: 1 },
      });

      const prevButton = screen.getByRole('button', { name: /previous|prev/i });
      await user.click(prevButton);

      const tabs = screen.getAllByRole('tab');
      expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
    });

    it('最後から最初にループする', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(Carousel, {
        props: { slides: defaultSlides, 'aria-label': 'Featured content', initialSlide: 2 },
      });

      const nextButton = screen.getByRole('button', { name: /next/i });
      await user.click(nextButton);

      const tabs = screen.getAllByRole('tab');
      expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
    });
  });

  // 🟡 Medium Priority: アクセシビリティ
  describe('アクセシビリティ', () => {
    it('axe による WCAG 2.1 AA 違反がない', async () => {
      const { container } = render(Carousel, {
        props: { slides: defaultSlides, 'aria-label': 'Featured content' },
      });
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });
  });

  // 🟢 Low Priority: Props
  describe('Props', () => {
    it('onSlideChange がスライド変更時に発火する', async () => {
      const handleSlideChange = vi.fn();
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(Carousel, {
        props: {
          slides: defaultSlides,
          'aria-label': 'Featured content',
          onSlideChange: handleSlideChange,
        },
      });

      const tabs = screen.getAllByRole('tab');
      await user.click(tabs[1]);

      expect(handleSlideChange).toHaveBeenCalledWith(1);
    });

    it('initialSlide prop を尊重する', () => {
      render(Carousel, {
        props: { slides: defaultSlides, 'aria-label': 'Featured content', initialSlide: 1 },
      });

      const tabs = screen.getAllByRole('tab');
      expect(tabs[1]).toHaveAttribute('aria-selected', 'true');
    });
  });

  // 異常系
  describe('異常系', () => {
    it('単一スライドを処理できる', () => {
      const singleSlide: CarouselSlide[] = [
        { id: 'slide1', content: 'Only Slide', label: 'Only Slide' },
      ];
      render(Carousel, {
        props: { slides: singleSlide, 'aria-label': 'Featured content' },
      });

      const tabs = screen.getAllByRole('tab');
      expect(tabs).toHaveLength(1);

      const panel = screen.getByRole('tabpanel');
      expect(panel).toHaveAttribute('aria-label', '1 of 1');
    });
  });
});

Resources