APG Patterns
English GitHub
English GitHub

Carousel

回転するコンテンツアイテム(スライド)のセットで、一度に1つずつ表示し、ナビゲーションコントロールで切り替えます。

🤖 AI 実装ガイド

デモ

手動ナビゲーション

タブインジケータ、前へ/次へボタン、またはキーボードの矢印キーで操作できます。

自動回転

スライドが自動的に回転します。ホバー、フォーカス、または一時停止ボタンのクリックで停止します。prefers-reduced-motion 設定を尊重します。

アクセシビリティ

WAI-ARIA ロール

ロール 対象要素 説明
region コンテナ(section) カルーセルのランドマーク領域
group スライドコンテナ すべてのスライドをグループ化
tablist タブコンテナ スライドインジケータタブのコンテナ
tab 各タブボタン 個々のスライドインジケータ
tabpanel 各スライド 個々のスライドコンテンツエリア

WAI-ARIA APG Carousel パターン (opens in new tab)

WAI-ARIA プロパティ

属性 対象 必須 説明
aria-roledescription コンテナ "carousel" はい スクリーンリーダーに「carousel」と通知
aria-roledescription 各スライド(tabpanel) "slide" はい 「tabpanel」の代わりに「slide」と通知
aria-label コンテナ テキスト はい カルーセルの目的を説明
aria-label 各スライド(tabpanel) "N of M" はい スライド位置(例: "1 of 5")
aria-controls タブ、前へ/次へボタン ID参照 はい 制御対象要素を参照
aria-labelledby 各スライド(tabpanel) ID参照 はい 関連するタブを参照
aria-atomic スライドコンテナ "false" いいえ 変更されたコンテンツのみ通知

WAI-ARIA ステート

aria-selected

現在選択されているスライドインジケータタブを示します。

対象 tab 要素
true | false
必須 はい
変更トリガー タブクリック、矢印キー、前へ/次へボタン、自動回転
リファレンス aria-selected (opens in new tab)

aria-live

回転状態に応じてスクリーンリーダーへの通知を動的に制御します。

対象 スライドコンテナ
"off"(自動回転中) | "polite"(手動/停止時)
必須 はい(自動回転が有効な場合)
変更トリガー 再生/一時停止クリック、フォーカスイン/アウト、マウスホバー
リファレンス aria-live (opens in new tab)

注意: 自動回転中はユーザーの作業を中断しないよう "off" に設定されます。回転が停止すると "polite" に変更され、スライド変更が通知されるようになります。

キーボードサポート

キー アクション
Tab コントロール間を移動(再生/一時停止、タブリスト、前へ/次へ)
Arrow Right 次のスライドインジケータタブに移動(最初にループ)
Arrow Left 前のスライドインジケータタブに移動(最後にループ)
Home 最初のスライドインジケータタブにフォーカス移動
End 最後のスライドインジケータタブにフォーカス移動
Enter / Space フォーカスされたタブまたはボタンをアクティブ化

自動回転の動作

トリガー 動作
キーボードフォーカスがカルーセルに入る 回転が一時的に停止、aria-live が "polite" に変更
キーボードフォーカスがカルーセルから離れる 回転が再開(自動回転モードがオンの場合)
マウスがスライド上をホバー 回転が一時的に停止
マウスがスライドから離れる 回転が再開(自動回転モードがオンの場合)
一時停止ボタンをクリック 自動回転モードをオフ、ボタンは再生アイコンを表示
再生ボタンをクリック 自動回転モードをオンにし、即座に回転を開始
prefers-reduced-motion: reduce 自動回転がデフォルトで無効

ソースコード

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    let newIndex = focusedIndex;
    let shouldPreventDefault = false;

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

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

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

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

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

    if (shouldPreventDefault) {
      event.preventDefault();

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

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

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

  function resumeByInteraction() {
    isPausedByInteraction = false;
  }

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

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

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

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

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

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

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

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

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

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

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

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

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

    if (autoRotateMode) {
      resumeByInteraction();
    }
  }

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

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

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

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

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

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

使い方

使用例
<script lang="ts">
  import Carousel from './Carousel.svelte';

  const slides = [
    { id: 'slide1', content: '<p>スライド1の内容</p>', label: '最初のスライド' },
    { id: 'slide2', content: '<p>スライド2の内容</p>', label: '2番目のスライド' },
    { id: 'slide3', content: '<p>スライド3の内容</p>', label: '3番目のスライド' }
  ];

  function handleSlideChange(index: number) {
    console.log('スライド変更:', index);
  }
</script>

<Carousel
  {slides}
  aria-label="注目のコンテンツ"
  autoRotate={true}
  rotationInterval={5000}
  onSlideChange={handleSlideChange}
/>

API

Carousel Props

プロパティ デフォルト 説明
slides CarouselSlide[] 必須 スライドアイテムの配列
aria-label string 必須 カルーセルのアクセシブルな名前
initialSlide number 0 初期スライドのインデックス(0から開始)
autoRotate boolean false 自動回転を有効にする
rotationInterval number 5000 回転間隔(ミリ秒)
onSlideChange (index: number) => void - スライド変更時のコールバック

CarouselSlide インターフェース

型定義
interface CarouselSlide {
  id: string;
  content: string;
  label?: string;
}

テスト

テストは、キーボード操作、ARIA属性、自動回転の動作、アクセシビリティ要件全体にわたってAPG準拠を検証します。Carouselコンポーネントは2層テスト戦略を採用しています。

テスト戦略

ユニットテスト(Container API)

Astro Container APIを使用してコンポーネントのHTML出力を検証します。これらのテストはブラウザを必要とせずに正しいテンプレートレンダリングを確認します。

  • HTML構造と要素の階層
  • 初期ARIA属性(aria-roledescription, aria-label, aria-selected)
  • tablist/tab/tabpanel構造
  • 初期tabindex値(ローヴィングタブインデックス)
  • CSSクラスの適用

E2Eテスト(Playwright)

実際のブラウザ環境でWeb Componentの動作を検証します。これらのテストはJavaScriptの実行を必要とするインタラクションをカバーします。

  • キーボードナビゲーション(矢印キー、Home、End)
  • タブ選択とスライド変更
  • 自動回転の開始/停止
  • 再生/一時停止ボタンの操作
  • ナビゲーション中のフォーカス管理

テストカテゴリ

高優先度: ARIA構造(ユニット)

テスト 説明
aria-roledescription="carousel" コンテナにcarouselロール記述がある
aria-roledescription="slide" 各tabpanelにslideロール記述がある
aria-label(コンテナ) コンテナにアクセシブルな名前がある
aria-label="N of M" 各スライドに位置ラベルがある(例: "1 of 5")

高優先度: Tablist ARIA(ユニット)

テスト 説明
role="tablist" タブコンテナにtablistロールがある
role="tab" 各スライドインジケータにtabロールがある
role="tabpanel" 各スライドにtabpanelロールがある
aria-selected アクティブなタブにaria-selected="true"がある
aria-controls タブがaria-controls経由でスライドを参照

高優先度: キーボード操作(E2E)

テスト 説明
ArrowRight 次のスライドタブにフォーカスを移動しアクティブ化
ArrowLeft 前のスライドタブにフォーカスを移動しアクティブ化
ループナビゲーション 矢印キーで最後から最初へ、またはその逆にループ
Home/End 最初/最後のスライドタブにフォーカスを移動

高優先度: フォーカス管理(ユニット + E2E)

テスト 説明
tabIndex=0(ユニット) 選択されたタブは初期状態でtabIndex=0を持つ
tabIndex=-1(ユニット) 選択されていないタブは初期状態でtabIndex=-1を持つ
ローヴィングタブインデックス(E2E) ナビゲーション中に1つのタブのみがtabIndex=0を持つ

高優先度: 自動回転(ユニット + E2E)

テスト 説明
aria-live="off"(ユニット) autoRotateがtrueの場合の初期aria-live
aria-live="polite"(ユニット) autoRotateがfalseの場合の初期aria-live
再生/一時停止ボタン(ユニット) autoRotateがtrueの場合にボタンがレンダリングされる
再生/一時停止切り替え(E2E) ボタンで回転状態を切り替え

中優先度: ナビゲーションコントロール(ユニット + E2E)

テスト 説明
前へ/次へボタン(ユニット) ナビゲーションボタンがレンダリングされる
aria-controls(ユニット) ボタンがスライドコンテナを指すaria-controlsを持つ
次へボタン(E2E) クリックで次のスライドを表示
前へボタン(E2E) クリックで前のスライドを表示
ループナビゲーション(E2E) 最後から最初へ、またはその逆にループ

低優先度: HTML属性(ユニット)

テスト 説明
class属性 カスタムクラスがコンテナに適用される
id属性 ID属性が正しく設定される

テストツール

詳細なドキュメントについては、 testing-strategy.md (opens in new tab) を参照してください。

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');
    });
  });
});

リソース