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.vue
<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

Example
<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

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.vue.ts
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