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

使い方

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

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>

<template>
  <Carousel
    :slides="slides"
    aria-label="注目のコンテンツ"
    :auto-rotate="true"
    :rotation-interval="5000"
    @slide-change="handleSlideChange"
  />
</template>

API

Carousel Props

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

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

リソース