APG Patterns
ๆ—ฅๆœฌ่ชž
ๆ—ฅๆœฌ่ชž

Carousel

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

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.

Open demo only โ†’

Accessibility Features

WAI-ARIA Roles

RoleTarget ElementDescription
regionContainer (section)Landmark region for the carousel
groupSlides containerGroups all slides together
tablistTab containerContainer for slide indicator tabs
tabEach tab buttonIndividual slide indicator
tabpanelEach slideIndividual slide content area

WAI-ARIA Properties

aria-roledescription

Announces โ€œcarouselโ€ to screen readers

Values
carousel
Required
Yes

aria-roledescription

Announces โ€œslideโ€ instead of โ€œtabpanelโ€

Values
slide
Required
Yes

aria-label

Describes the carousel purpose

Values
Text
Required
Yes

aria-label

Slide position (e.g., โ€œ1 of 5โ€)

Values
N of M
Required
Yes

aria-controls

References controlled element

Values
ID reference
Required
Yes

aria-labelledby

References associated tab

Values
ID reference
Required
Yes

aria-atomic

Only announce changed content

Values
false
Required
No

WAI-ARIA States

aria-selected

Target Element
Tab element
Values
true | false
Required
Yes
Change Trigger

Tab click, Arrow keys, Prev/Next buttons, Auto-rotation

aria-live

Target Element
Slides container
Values
off | polite
Required
Yes
Change Trigger

Play/Pause click, Focus in/out, Mouse hover

Keyboard Support

KeyAction
TabNavigate between controls (Play/Pause, tablist, Prev/Next)
ArrowRightMove to next slide indicator tab (loops to first)
ArrowLeftMove to previous slide indicator tab (loops to last)
HomeMove focus to first slide indicator tab
EndMove focus to last slide indicator tab
Enter / SpaceActivate focused tab or button
  • Set aria-live to โ€œoffโ€ during auto-rotation to prevent interrupting users. Changes to โ€œpoliteโ€ when rotation stops, allowing slide changes to be announced.

Focus Management

EventBehavior
Selected tabtabIndex="0"
Other tabstabIndex="-1"
Keyboard focus enters carouselRotation pauses temporarily, aria-live changes to โ€œpoliteโ€
Keyboard focus leaves carouselRotation resumes (if auto-rotate mode is on)
Mouse hovers over slidesRotation pauses temporarily
Mouse leaves slidesRotation resumes (if auto-rotate mode is on)
Pause button clickedTurns off auto-rotate mode, button shows play icon
Play button clickedTurns on auto-rotate mode and starts rotation immediately
prefers-reduced-motion: reduceAuto-rotation disabled by default

References

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

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

Custom Events

Event Detail Description
slide-change number Emitted when slide changes (index)

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

Running Tests

# Run unit tests for Carousel
npm run test -- carousel

# Run E2E tests for Carousel (all frameworks)
npm run test:e2e:pattern --pattern=carousel

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