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.svelte
<script lang="ts">
  import { onMount, onDestroy, untrack } 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(untrack(() => 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 (slide.id)}
      {@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}
      >
        <!-- eslint-disable-next-line svelte/no-at-html-tags -- Content is provided by the consuming application -->
        {@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) -->
    <!-- svelte-ignore a11y_interactive_supports_focus -->
    <div
      bind:this={tablistElement}
      role="tablist"
      aria-label="Slides"
      class="apg-carousel-tablist"
      onkeydown={handleKeyDown}
    >
      {#each slides as slide, index (slide.id)}
        <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

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

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