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.astro
---
/**
 * APG Carousel Pattern - Astro Implementation
 *
 * A carousel displays a set of slides, one at a time, with controls to navigate
 * between them and optionally auto-rotate.
 * Uses Web Components for client-side keyboard navigation and state management.
 *
 * @see https://www.w3.org/WAI/ARIA/apg/patterns/carousel/
 */

export interface CarouselSlide {
  id: string;
  /** Slide content (HTML string) */
  content: string;
  label?: string;
}

export interface Props {
  /** Array of slides */
  slides: CarouselSlide[];
  /** Accessible label for the carousel (required) */
  'aria-label': 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;
  /** Instance ID (optional, auto-generated if not provided) */
  id?: string;
  /** Test ID for E2E testing */
  'data-testid'?: string;
}

const {
  slides,
  'aria-label': ariaLabel,
  initialSlide = 0,
  autoRotate = false,
  rotationInterval = 5000,
  class: className = '',
  id,
  'data-testid': testId,
} = Astro.props;

// Validate initialSlide - fallback to 0 if out of bounds
const validInitialSlide = initialSlide >= 0 && initialSlide < slides.length ? initialSlide : 0;

// Generate unique ID for this instance
const instanceId = id || `carousel-${Math.random().toString(36).substring(2, 11)}`;
const slidesContainerId = `${instanceId}-slides`;

const containerClass = `apg-carousel ${className}`.trim();
---

<apg-carousel
  data-auto-rotate={autoRotate ? 'true' : 'false'}
  data-rotation-interval={rotationInterval.toString()}
  data-initial-slide={validInitialSlide.toString()}
>
  <section
    class={containerClass}
    aria-roledescription="carousel"
    aria-label={ariaLabel}
    id={id}
    data-testid={testId}
  >
    <!-- Slides Container -->
    <div
      id={slidesContainerId}
      data-testid="slides-container"
      class="apg-carousel-slides"
      role="group"
      aria-live={autoRotate ? 'off' : 'polite'}
      aria-atomic="false"
    >
      {
        slides.map((slide, index) => {
          const isActive = index === validInitialSlide;
          const panelId = `${instanceId}-panel-${slide.id}`;
          const tabId = `${instanceId}-tab-${slide.id}`;

          return (
            <div
              id={panelId}
              role="tabpanel"
              aria-roledescription="slide"
              aria-label={`${index + 1} of ${slides.length}`}
              aria-labelledby={tabId}
              aria-hidden={!isActive}
              inert={!isActive ? true : undefined}
              class={`apg-carousel-slide ${isActive ? 'apg-carousel-slide--active' : ''}`}
              data-slide-id={slide.id}
              data-slide-index={index.toString()}
            >
              <Fragment set:html={slide.content} />
            </div>
          );
        })
      }
    </div>

    <!-- Controls -->
    <div class="apg-carousel-controls">
      <!-- Play/Pause Button (first in tab order) -->
      {
        autoRotate && (
          <button
            type="button"
            class="apg-carousel-play-pause"
            aria-label="Stop automatic slide show"
            data-playing="true"
          >
            <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>
          </button>
        )
      }

      <!-- Tablist (slide indicators) -->
      <div role="tablist" aria-label="Slides" class="apg-carousel-tablist">
        {
          slides.map((slide, index) => {
            const isSelected = index === validInitialSlide;
            const tabId = `${instanceId}-tab-${slide.id}`;
            const panelId = `${instanceId}-panel-${slide.id}`;

            return (
              <button
                type="button"
                role="tab"
                id={tabId}
                aria-selected={isSelected ? 'true' : 'false'}
                aria-controls={panelId}
                tabindex={isSelected ? 0 : -1}
                class={`apg-carousel-tab ${isSelected ? 'apg-carousel-tab--selected' : ''}`}
                aria-label={slide.label || `Slide ${index + 1}`}
                data-tab-index={index.toString()}
              >
                <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}
        >
          <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"></line>
            <polyline points="10 5 5 10 10 15"></polyline>
          </svg>
        </button>
        <button
          type="button"
          class="apg-carousel-next"
          aria-label="Next slide"
          aria-controls={slidesContainerId}
        >
          <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"></line>
            <polyline points="10 5 15 10 10 15"></polyline>
          </svg>
        </button>
      </div>
    </div>
  </section>

  <script>
    class ApgCarousel extends HTMLElement {
      private currentSlide = 0;
      private focusedIndex = 0;
      // New state model: separate user intent from temporary pause
      private autoRotateMode = false;
      private isPausedByInteraction = false;
      private timerRef: ReturnType<typeof setInterval> | null = null;
      private animationTimeoutRef: ReturnType<typeof setTimeout> | null = null;
      private rafRef: number | null = null;
      private pendingDragOffset = 0;
      private pointerStartX: number | null = null;
      private activePointerId: number | null = null;
      private isDragging = false;
      private dragOffset = 0;
      private slides: HTMLElement[] = [];
      private tabs: HTMLButtonElement[] = [];
      private tablist: HTMLElement | null = null;
      private slidesContainer: HTMLElement | null = null;
      private playPauseButton: HTMLButtonElement | null = null;
      private prevButton: HTMLButtonElement | null = null;
      private nextButton: HTMLButtonElement | null = null;

      // Bound event handlers (stored for cleanup)
      private boundHandleKeyDown: (e: KeyboardEvent) => void;
      private boundToggleAutoRotateMode: () => void;
      private boundGoToPrevSlide: () => void;
      private boundGoToNextSlide: () => void;
      private boundHandleCarouselFocusIn: () => void;
      private boundHandleCarouselFocusOut: (e: FocusEvent) => void;
      private boundHandleSlidesMouseEnter: () => void;
      private boundHandleSlidesMouseLeave: () => void;
      private boundHandlePointerDown: (e: PointerEvent) => void;
      private boundHandlePointerMove: (e: PointerEvent) => void;
      private boundHandlePointerUp: (e: PointerEvent) => void;
      private boundHandlePointerCancel: (e: PointerEvent) => void;
      private tabClickHandlers: Array<() => void> = [];

      constructor() {
        super();
        // Bind handlers once in constructor
        this.boundHandleKeyDown = this.handleKeyDown.bind(this);
        this.boundToggleAutoRotateMode = this.toggleAutoRotateMode.bind(this);
        this.boundGoToPrevSlide = this.goToPrevSlide.bind(this);
        this.boundGoToNextSlide = this.goToNextSlide.bind(this);
        this.boundHandleCarouselFocusIn = this.handleCarouselFocusIn.bind(this);
        this.boundHandleCarouselFocusOut = this.handleCarouselFocusOut.bind(this);
        this.boundHandleSlidesMouseEnter = this.handleSlidesMouseEnter.bind(this);
        this.boundHandleSlidesMouseLeave = this.handleSlidesMouseLeave.bind(this);
        this.boundHandlePointerDown = this.handlePointerDown.bind(this);
        this.boundHandlePointerMove = this.handlePointerMove.bind(this);
        this.boundHandlePointerUp = this.handlePointerUp.bind(this);
        this.boundHandlePointerCancel = this.handlePointerCancel.bind(this);
      }

      connectedCallback() {
        // Use requestAnimationFrame to ensure DOM is ready
        requestAnimationFrame(() => {
          this.initializeElements();
          this.initializeState();
          this.setupEventListeners();
          this.startAutoRotation();
        });
      }

      disconnectedCallback() {
        this.stopAutoRotation();
        if (this.animationTimeoutRef) {
          clearTimeout(this.animationTimeoutRef);
          this.animationTimeoutRef = null;
        }
        if (this.rafRef) {
          cancelAnimationFrame(this.rafRef);
          this.rafRef = null;
        }
        this.removeEventListeners();
      }

      private initializeElements() {
        this.slides = Array.from(this.querySelectorAll('[role="tabpanel"]'));
        this.tabs = Array.from(this.querySelectorAll('[role="tab"]'));
        this.tablist = this.querySelector('[role="tablist"]');
        this.slidesContainer = this.querySelector('[data-testid="slides-container"]');
        this.playPauseButton = this.querySelector('.apg-carousel-play-pause');
        this.prevButton = this.querySelector('.apg-carousel-prev');
        this.nextButton = this.querySelector('.apg-carousel-next');
      }

      private initializeState() {
        const initialSlide = parseInt(this.dataset.initialSlide || '0', 10);
        this.currentSlide = initialSlide;
        this.focusedIndex = initialSlide;

        const autoRotate = this.dataset.autoRotate === 'true';

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

        this.updateAriaLive();
        // Sync play/pause button state with autoRotateMode (important for prefers-reduced-motion)
        this.updatePlayPauseButton();
      }

      private setupEventListeners() {
        // Tablist keyboard navigation
        this.tablist?.addEventListener('keydown', this.boundHandleKeyDown);

        // Tab clicks
        this.tabClickHandlers = [];
        this.tabs.forEach((tab, index) => {
          const handler = () => this.goToSlide(index);
          this.tabClickHandlers.push(handler);
          tab.addEventListener('click', handler);
        });

        // Play/Pause button
        this.playPauseButton?.addEventListener('click', this.boundToggleAutoRotateMode);

        // Previous/Next buttons
        this.prevButton?.addEventListener('click', this.boundGoToPrevSlide);
        this.nextButton?.addEventListener('click', this.boundGoToNextSlide);

        // Focus/blur for auto-rotation pause (on entire carousel)
        this.addEventListener('focusin', this.boundHandleCarouselFocusIn);
        this.addEventListener('focusout', this.boundHandleCarouselFocusOut);

        // Mouse hover for auto-rotation pause (only on slides container)
        this.slidesContainer?.addEventListener('mouseenter', this.boundHandleSlidesMouseEnter);
        this.slidesContainer?.addEventListener('mouseleave', this.boundHandleSlidesMouseLeave);

        // Touch/swipe
        this.slidesContainer?.addEventListener('pointerdown', this.boundHandlePointerDown);
        this.slidesContainer?.addEventListener('pointermove', this.boundHandlePointerMove);
        this.slidesContainer?.addEventListener('pointerup', this.boundHandlePointerUp);
        this.slidesContainer?.addEventListener('pointercancel', this.boundHandlePointerCancel);
      }

      private removeEventListeners() {
        // Tablist keyboard navigation
        this.tablist?.removeEventListener('keydown', this.boundHandleKeyDown);

        // Tab clicks
        this.tabs.forEach((tab, index) => {
          const handler = this.tabClickHandlers[index];
          if (handler) {
            tab.removeEventListener('click', handler);
          }
        });
        this.tabClickHandlers = [];

        // Play/Pause button
        this.playPauseButton?.removeEventListener('click', this.boundToggleAutoRotateMode);

        // Previous/Next buttons
        this.prevButton?.removeEventListener('click', this.boundGoToPrevSlide);
        this.nextButton?.removeEventListener('click', this.boundGoToNextSlide);

        // Focus/blur for auto-rotation pause (on entire carousel)
        this.removeEventListener('focusin', this.boundHandleCarouselFocusIn);
        this.removeEventListener('focusout', this.boundHandleCarouselFocusOut);

        // Mouse hover for auto-rotation pause (only on slides container)
        this.slidesContainer?.removeEventListener('mouseenter', this.boundHandleSlidesMouseEnter);
        this.slidesContainer?.removeEventListener('mouseleave', this.boundHandleSlidesMouseLeave);

        // Touch/swipe
        this.slidesContainer?.removeEventListener('pointerdown', this.boundHandlePointerDown);
        this.slidesContainer?.removeEventListener('pointermove', this.boundHandlePointerMove);
        this.slidesContainer?.removeEventListener('pointerup', this.boundHandlePointerUp);
        this.slidesContainer?.removeEventListener('pointercancel', this.boundHandlePointerCancel);
      }

      private handleKeyDown(event: KeyboardEvent) {
        const { key } = event;

        let newIndex = this.focusedIndex;
        let shouldPreventDefault = false;

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

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

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

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

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

        if (shouldPreventDefault) {
          event.preventDefault();

          if (newIndex !== this.focusedIndex) {
            this.focusedIndex = newIndex;
            this.tabs[newIndex]?.focus();
            this.goToSlide(newIndex);
          }
        }
      }

      private goToSlide(index: number) {
        if (this.slides.length < 2) return;
        const newIndex = ((index % this.slides.length) + this.slides.length) % this.slides.length;
        if (newIndex === this.currentSlide) return;

        const previousSlide = this.currentSlide;

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

        this.currentSlide = newIndex;
        this.focusedIndex = newIndex;

        // Update slides with animation classes
        this.slides.forEach((slide, i) => {
          const isActive = i === newIndex;
          const isExiting = i === previousSlide;

          // Only active slide is exposed to AT, exiting is visual-only during animation
          slide.setAttribute('aria-hidden', (!isActive).toString());
          if (isActive) {
            slide.removeAttribute('inert');
          } else {
            slide.setAttribute('inert', '');
          }
          slide.classList.toggle('apg-carousel-slide--active', isActive);

          // Remove old animation classes
          slide.classList.remove(
            'apg-carousel-slide--entering-next',
            'apg-carousel-slide--entering-prev',
            'apg-carousel-slide--exiting-next',
            'apg-carousel-slide--exiting-prev',
            'apg-carousel-slide--swipe-prev',
            'apg-carousel-slide--swipe-next'
          );

          // Add new animation classes
          if (isActive) {
            slide.classList.add(`apg-carousel-slide--entering-${direction}`);
          } else if (isExiting) {
            slide.classList.add(`apg-carousel-slide--exiting-${direction}`);
          }
        });

        // Update tabs
        this.tabs.forEach((tab, i) => {
          const isSelected = i === newIndex;
          const isFocusTarget = i === this.focusedIndex;
          tab.setAttribute('aria-selected', isSelected.toString());
          tab.tabIndex = isFocusTarget ? 0 : -1;
          tab.classList.toggle('apg-carousel-tab--selected', isSelected);
        });

        // Dispatch event
        this.dispatchEvent(new CustomEvent('slidechange', { detail: { index: newIndex } }));

        // Clean up after animation
        this.animationTimeoutRef = setTimeout(() => {
          this.animationTimeoutRef = null;

          // Hide exiting slide and remove animation classes
          this.slides.forEach((slide, i) => {
            const shouldHide = i !== this.currentSlide;
            slide.setAttribute('aria-hidden', shouldHide.toString());
            if (shouldHide) {
              slide.setAttribute('inert', '');
            } else {
              slide.removeAttribute('inert');
            }
            slide.classList.remove(
              'apg-carousel-slide--entering-next',
              'apg-carousel-slide--entering-prev',
              'apg-carousel-slide--exiting-next',
              'apg-carousel-slide--exiting-prev'
            );
          });
        }, 300);
      }

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

        this.currentSlide = newIndex;
        this.focusedIndex = newIndex;

        // Update slides without animation
        this.slides.forEach((slide, i) => {
          const isActive = i === newIndex;
          slide.setAttribute('aria-hidden', (!isActive).toString());
          if (isActive) {
            slide.removeAttribute('inert');
          } else {
            slide.setAttribute('inert', '');
          }
          slide.classList.toggle('apg-carousel-slide--active', isActive);

          // Remove all animation and swipe classes
          slide.classList.remove(
            'apg-carousel-slide--entering-next',
            'apg-carousel-slide--entering-prev',
            'apg-carousel-slide--exiting-next',
            'apg-carousel-slide--exiting-prev',
            'apg-carousel-slide--swipe-prev',
            'apg-carousel-slide--swipe-next'
          );
          slide.style.transform = '';
        });

        // Update tabs
        this.tabs.forEach((tab, i) => {
          const isSelected = i === newIndex;
          const isFocusTarget = i === this.focusedIndex;
          tab.setAttribute('aria-selected', isSelected.toString());
          tab.tabIndex = isFocusTarget ? 0 : -1;
          tab.classList.toggle('apg-carousel-tab--selected', isSelected);
        });

        // Dispatch event
        this.dispatchEvent(new CustomEvent('slidechange', { detail: { index: newIndex } }));
      }

      private goToNextSlide() {
        this.goToSlide(this.currentSlide + 1);
      }

      private goToPrevSlide() {
        this.goToSlide(this.currentSlide - 1);
      }

      // Computed: actual rotation state
      private get isActuallyRotating() {
        return this.autoRotateMode && !this.isPausedByInteraction;
      }

      private get rotationInterval() {
        return parseInt(this.dataset.rotationInterval || '5000', 10);
      }

      private startAutoRotation() {
        this.stopAutoRotation();

        if (this.isActuallyRotating) {
          this.timerRef = setInterval(() => {
            this.goToSlide(this.currentSlide + 1);
          }, this.rotationInterval);
        }
      }

      private stopAutoRotation() {
        if (this.timerRef) {
          clearInterval(this.timerRef);
          this.timerRef = null;
        }
      }

      private updateAriaLive() {
        if (this.slidesContainer) {
          this.slidesContainer.setAttribute(
            'aria-live',
            this.isActuallyRotating ? 'off' : 'polite'
          );
        }
      }

      private updatePlayPauseButton() {
        if (this.playPauseButton) {
          // Button reflects autoRotateMode (user intent), not isActuallyRotating
          this.playPauseButton.setAttribute(
            'aria-label',
            this.autoRotateMode ? 'Stop automatic slide show' : 'Start automatic slide show'
          );
          this.playPauseButton.innerHTML = this.autoRotateMode
            ? '<svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><rect x="3" y="2" width="4" height="12" rx="1" /><rect x="9" y="2" width="4" height="12" rx="1" /></svg>'
            : '<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>';
          this.playPauseButton.dataset.playing = this.autoRotateMode.toString();
        }
      }

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

      // Pause/resume by interaction (hover/focus)
      private pauseByInteraction() {
        this.isPausedByInteraction = true;
        this.stopAutoRotation();
        this.updateAriaLive();
      }

      private resumeByInteraction() {
        this.isPausedByInteraction = false;
        this.startAutoRotation();
        this.updateAriaLive();
      }

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

      private handleCarouselFocusOut(event: FocusEvent) {
        if (!this.autoRotateMode) {
          return;
        }

        // Only resume if focus is leaving the carousel entirely
        const { relatedTarget } = event;
        // Treat null relatedTarget (focus moved to body) as "left carousel"
        const focusLeftCarousel =
          relatedTarget === null ||
          (relatedTarget instanceof Node && !this.contains(relatedTarget));
        if (focusLeftCarousel) {
          this.resumeByInteraction();
        }
      }

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

      private handleSlidesMouseLeave() {
        if (this.autoRotateMode) {
          this.resumeByInteraction();
        }
      }

      private handlePointerDown(event: PointerEvent) {
        if (this.slides.length < 2) return; // Disable swipe for single slide
        if (this.activePointerId !== null) return; // Ignore if already tracking a pointer
        this.activePointerId = event.pointerId;
        this.pointerStartX = event.clientX;
        this.isDragging = true;
        this.dragOffset = 0;
        // Capture pointer to receive events even if pointer moves outside element
        (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
        // Add dragging class
        this.slidesContainer?.classList.add('apg-carousel-slides--dragging');
        if (this.autoRotateMode) {
          this.pauseByInteraction();
        }
      }

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

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

      private updateSwipeVisual() {
        const diff = this.dragOffset;

        // Compute which adjacent slide to show during drag
        const swipeAdjacentSlide =
          diff !== 0
            ? diff > 0
              ? (this.currentSlide - 1 + this.slides.length) % this.slides.length // swiping right, show prev
              : (this.currentSlide + 1) % this.slides.length // swiping left, show next
            : null;

        // Update slides for swipe visual
        this.slides.forEach((slide, i) => {
          const isActive = i === this.currentSlide;
          const isSwipeAdjacent = i === swipeAdjacentSlide;

          // Only active slide is exposed to AT, adjacent is visual-only
          slide.setAttribute('aria-hidden', (!isActive).toString());
          if (isActive) {
            slide.removeAttribute('inert');
          } else {
            slide.setAttribute('inert', '');
          }

          // Remove animation classes during drag
          slide.classList.remove(
            'apg-carousel-slide--entering-next',
            'apg-carousel-slide--entering-prev',
            'apg-carousel-slide--exiting-next',
            'apg-carousel-slide--exiting-prev'
          );

          // Add/remove swipe classes
          slide.classList.remove(
            'apg-carousel-slide--swipe-prev',
            'apg-carousel-slide--swipe-next'
          );
          if (isSwipeAdjacent) {
            slide.classList.add(
              diff > 0 ? 'apg-carousel-slide--swipe-prev' : 'apg-carousel-slide--swipe-next'
            );
          }

          // Apply transform to active and adjacent slides
          if (isActive) {
            slide.style.transform = `translateX(${diff}px)`;
          } else if (isSwipeAdjacent) {
            // Position adjacent slide next to current slide
            const baseOffset = diff > 0 ? '-100%' : '100%';
            slide.style.transform = `translateX(calc(${baseOffset} + ${diff}px))`;
          } else {
            slide.style.transform = '';
          }
        });
      }

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

        const diff = event.clientX - this.pointerStartX;
        const containerWidth = this.slidesContainer?.offsetWidth || 300;
        const threshold = containerWidth * 0.2; // 20% of container width

        if (diff > threshold) {
          // Swiped right - go to previous slide (instant, no animation)
          this.goToSlideInstant(this.currentSlide - 1);
        } else if (diff < -threshold) {
          // Swiped left - go to next slide (instant, no animation)
          this.goToSlideInstant(this.currentSlide + 1);
        } else {
          // Snap back - reset slide styles
          this.slides.forEach((slide, i) => {
            const isActive = i === this.currentSlide;
            slide.setAttribute('aria-hidden', (!isActive).toString());
            if (isActive) {
              slide.removeAttribute('inert');
            } else {
              slide.setAttribute('inert', '');
            }
            slide.classList.remove(
              'apg-carousel-slide--swipe-prev',
              'apg-carousel-slide--swipe-next'
            );
            slide.style.transform = '';
          });
        }

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

        this.activePointerId = null;
        this.pointerStartX = null;
        this.isDragging = false;
        this.dragOffset = 0;
        // Reset visual feedback
        this.slidesContainer?.classList.remove('apg-carousel-slides--dragging');

        if (this.autoRotateMode) {
          this.resumeByInteraction();
        }
      }

      private handlePointerCancel(event: PointerEvent) {
        if (this.activePointerId !== event.pointerId) return; // Ignore other pointers
        // Cancel any pending RAF
        if (this.rafRef !== null) {
          cancelAnimationFrame(this.rafRef);
          this.rafRef = null;
        }
        this.activePointerId = null;
        this.pointerStartX = null;
        this.isDragging = false;
        this.dragOffset = 0;
        // Reset visual feedback
        this.slidesContainer?.classList.remove('apg-carousel-slides--dragging');

        // Reset slide styles
        this.slides.forEach((slide, i) => {
          const isActive = i === this.currentSlide;
          slide.setAttribute('aria-hidden', (!isActive).toString());
          if (isActive) {
            slide.removeAttribute('inert');
          } else {
            slide.setAttribute('inert', '');
          }
          slide.classList.remove(
            'apg-carousel-slide--swipe-prev',
            'apg-carousel-slide--swipe-next'
          );
          slide.style.transform = '';
        });
      }
    }

    // Register the custom element
    if (!customElements.get('apg-carousel')) {
      customElements.define('apg-carousel', ApgCarousel);
    }
  </script>
</apg-carousel>

使い方

使用例
---
import Carousel from '@patterns/carousel/Carousel.astro';

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番目のスライド' }
];
---

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

API

Carousel Props

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

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.astro.ts
/**
 * Carousel Astro Component Tests using Container API
 *
 * These tests verify the Carousel.astro component output using Astro's Container API.
 * This ensures the component renders correct ARIA structure and attributes.
 *
 * Note: Interactive behavior (keyboard, auto-rotation) is tested in E2E tests.
 *
 * @see https://docs.astro.build/en/reference/container-reference/
 */
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { describe, it, expect, beforeEach } from 'vitest';
import { JSDOM } from 'jsdom';
import Carousel from './Carousel.astro';

describe('Carousel (Astro Container API)', () => {
  let container: AstroContainer;

  beforeEach(async () => {
    container = await AstroContainer.create();
  });

  // Helper to render and parse HTML
  async function renderCarousel(props: {
    slides: Array<{ id: string; content: string; label?: string }>;
    'aria-label': string;
    initialSlide?: number;
    autoRotate?: boolean;
    rotationInterval?: number;
    class?: string;
    id?: string;
  }): Promise<Document> {
    const html = await container.renderToString(Carousel, { props });
    const dom = new JSDOM(html);
    return dom.window.document;
  }

  const basicSlides = [
    { id: 'slide1', content: '<div>Slide 1 Content</div>', label: 'Slide 1' },
    { id: 'slide2', content: '<div>Slide 2 Content</div>', label: 'Slide 2' },
    { id: 'slide3', content: '<div>Slide 3 Content</div>', label: 'Slide 3' },
  ];

  const fiveSlides = [
    { id: 'slide1', content: '<div>Slide 1</div>', label: 'Slide 1' },
    { id: 'slide2', content: '<div>Slide 2</div>', label: 'Slide 2' },
    { id: 'slide3', content: '<div>Slide 3</div>', label: 'Slide 3' },
    { id: 'slide4', content: '<div>Slide 4</div>', label: 'Slide 4' },
    { id: 'slide5', content: '<div>Slide 5</div>', label: 'Slide 5' },
  ];

  // 🔴 High Priority: APG ARIA Structure
  describe('APG: ARIA Structure', () => {
    it('has aria-roledescription="carousel" on container', async () => {
      const doc = await renderCarousel({
        slides: basicSlides,
        'aria-label': 'Featured content',
      });
      const carousel = doc.querySelector('section');
      expect(carousel?.getAttribute('aria-roledescription')).toBe('carousel');
    });

    it('has aria-label on container', async () => {
      const doc = await renderCarousel({
        slides: basicSlides,
        'aria-label': 'Featured content',
      });
      const carousel = doc.querySelector('section');
      expect(carousel?.getAttribute('aria-label')).toBe('Featured content');
    });

    it('has aria-roledescription="slide" on each tabpanel', async () => {
      const doc = await renderCarousel({
        slides: basicSlides,
        'aria-label': 'Featured content',
      });
      const panels = doc.querySelectorAll('[role="tabpanel"]');
      expect(panels).toHaveLength(3);
      panels.forEach((panel) => {
        expect(panel.getAttribute('aria-roledescription')).toBe('slide');
      });
    });

    it('has aria-label="N of M" on each slide', async () => {
      const doc = await renderCarousel({
        slides: fiveSlides,
        'aria-label': 'Featured content',
      });
      const panels = doc.querySelectorAll('[role="tabpanel"]');

      expect(panels[0]?.getAttribute('aria-label')).toBe('1 of 5');
      expect(panels[1]?.getAttribute('aria-label')).toBe('2 of 5');
      expect(panels[2]?.getAttribute('aria-label')).toBe('3 of 5');
      expect(panels[3]?.getAttribute('aria-label')).toBe('4 of 5');
      expect(panels[4]?.getAttribute('aria-label')).toBe('5 of 5');
    });
  });

  describe('APG: Tablist ARIA', () => {
    it('has role="tablist" on tab container', async () => {
      const doc = await renderCarousel({
        slides: basicSlides,
        'aria-label': 'Featured content',
      });
      const tablist = doc.querySelector('[role="tablist"]');
      expect(tablist).not.toBeNull();
    });

    it('has role="tab" on each tab button', async () => {
      const doc = await renderCarousel({
        slides: basicSlides,
        'aria-label': 'Featured content',
      });
      const tabs = doc.querySelectorAll('[role="tab"]');
      expect(tabs).toHaveLength(3);
    });

    it('has role="tabpanel" on each slide', async () => {
      const doc = await renderCarousel({
        slides: basicSlides,
        'aria-label': 'Featured content',
      });
      const panels = doc.querySelectorAll('[role="tabpanel"]');
      expect(panels).toHaveLength(3);
    });

    it('has aria-selected="true" on first tab by default', async () => {
      const doc = await renderCarousel({
        slides: basicSlides,
        'aria-label': 'Featured content',
      });
      const tabs = doc.querySelectorAll('[role="tab"]');

      expect(tabs[0]?.getAttribute('aria-selected')).toBe('true');
      expect(tabs[1]?.getAttribute('aria-selected')).toBe('false');
      expect(tabs[2]?.getAttribute('aria-selected')).toBe('false');
    });

    it('has aria-selected="true" on initialSlide tab', async () => {
      const doc = await renderCarousel({
        slides: basicSlides,
        'aria-label': 'Featured content',
        initialSlide: 1,
      });
      const tabs = doc.querySelectorAll('[role="tab"]');

      expect(tabs[0]?.getAttribute('aria-selected')).toBe('false');
      expect(tabs[1]?.getAttribute('aria-selected')).toBe('true');
      expect(tabs[2]?.getAttribute('aria-selected')).toBe('false');
    });

    it('has aria-controls pointing to tabpanel', async () => {
      const doc = await renderCarousel({
        slides: basicSlides,
        'aria-label': 'Featured content',
      });
      const tabs = doc.querySelectorAll('[role="tab"]');
      const panels = doc.querySelectorAll('[role="tabpanel"]');

      tabs.forEach((tab, index) => {
        const controls = tab.getAttribute('aria-controls');
        const panelId = panels[index]?.getAttribute('id');
        expect(controls).toBe(panelId);
      });
    });

    it('panel aria-labelledby matches tab id', async () => {
      const doc = await renderCarousel({
        slides: basicSlides,
        'aria-label': 'Featured content',
      });
      const tabs = doc.querySelectorAll('[role="tab"]');
      const panels = doc.querySelectorAll('[role="tabpanel"]');

      panels.forEach((panel, index) => {
        const labelledby = panel.getAttribute('aria-labelledby');
        const tabId = tabs[index]?.getAttribute('id');
        expect(labelledby).toBe(tabId);
      });
    });
  });

  // 🔴 High Priority: Auto-Rotation State
  describe('APG: Auto-Rotation', () => {
    it('has aria-live="off" when autoRotate is true', async () => {
      const doc = await renderCarousel({
        slides: basicSlides,
        'aria-label': 'Featured content',
        autoRotate: true,
      });
      const slidesContainer = doc.querySelector('[data-testid="slides-container"]');
      expect(slidesContainer?.getAttribute('aria-live')).toBe('off');
    });

    it('has aria-live="polite" when autoRotate is false', async () => {
      const doc = await renderCarousel({
        slides: basicSlides,
        'aria-label': 'Featured content',
        autoRotate: false,
      });
      const slidesContainer = doc.querySelector('[data-testid="slides-container"]');
      expect(slidesContainer?.getAttribute('aria-live')).toBe('polite');
    });

    it('has play/pause button when autoRotate is true', async () => {
      const doc = await renderCarousel({
        slides: basicSlides,
        'aria-label': 'Featured content',
        autoRotate: true,
      });
      const playPauseButton = doc.querySelector(
        'button[aria-label*="Stop"], button[aria-label*="Pause"]'
      );
      expect(playPauseButton).not.toBeNull();
    });
  });

  // 🔴 High Priority: Focus Management
  describe('APG: Focus Management', () => {
    it('uses roving tabindex on tablist', async () => {
      const doc = await renderCarousel({
        slides: basicSlides,
        'aria-label': 'Featured content',
      });
      const tabs = doc.querySelectorAll('[role="tab"]');

      expect(tabs[0]?.getAttribute('tabindex')).toBe('0');
      expect(tabs[1]?.getAttribute('tabindex')).toBe('-1');
      expect(tabs[2]?.getAttribute('tabindex')).toBe('-1');
    });

    it('active tab has tabindex="0"', async () => {
      const doc = await renderCarousel({
        slides: basicSlides,
        'aria-label': 'Featured content',
        initialSlide: 1,
      });
      const tabs = doc.querySelectorAll('[role="tab"]');

      expect(tabs[0]?.getAttribute('tabindex')).toBe('-1');
      expect(tabs[1]?.getAttribute('tabindex')).toBe('0');
      expect(tabs[2]?.getAttribute('tabindex')).toBe('-1');
    });
  });

  // 🟡 Medium Priority: Navigation Controls
  describe('Navigation Controls', () => {
    it('has previous button', async () => {
      const doc = await renderCarousel({
        slides: basicSlides,
        'aria-label': 'Featured content',
      });
      const prevButton = doc.querySelector(
        'button[aria-label*="Previous"], button[aria-label*="Prev"]'
      );
      expect(prevButton).not.toBeNull();
    });

    it('has next button', async () => {
      const doc = await renderCarousel({
        slides: basicSlides,
        'aria-label': 'Featured content',
      });
      const nextButton = doc.querySelector('button[aria-label*="Next"]');
      expect(nextButton).not.toBeNull();
    });

    it('prev/next buttons have aria-controls', async () => {
      const doc = await renderCarousel({
        slides: basicSlides,
        'aria-label': 'Featured content',
      });
      const prevButton = doc.querySelector(
        'button[aria-label*="Previous"], button[aria-label*="Prev"]'
      );
      const nextButton = doc.querySelector('button[aria-label*="Next"]');
      const slidesContainer = doc.querySelector('[data-testid="slides-container"]');

      expect(prevButton?.getAttribute('aria-controls')).toBe(slidesContainer?.getAttribute('id'));
      expect(nextButton?.getAttribute('aria-controls')).toBe(slidesContainer?.getAttribute('id'));
    });
  });

  // 🟢 Low Priority: HTML Attributes
  describe('HTML Attributes', () => {
    it('applies class to container', async () => {
      const doc = await renderCarousel({
        slides: basicSlides,
        'aria-label': 'Featured content',
        class: 'custom-carousel',
      });
      const carousel = doc.querySelector('section');
      expect(carousel?.classList.contains('custom-carousel')).toBe(true);
    });

    it('applies id to container', async () => {
      const doc = await renderCarousel({
        slides: basicSlides,
        'aria-label': 'Featured content',
        id: 'my-carousel',
      });
      const carousel = doc.querySelector('section');
      expect(carousel?.getAttribute('id')).toBe('my-carousel');
    });
  });

  // Edge Cases
  describe('Edge Cases', () => {
    it('handles single slide', async () => {
      const singleSlide = [{ id: 'slide1', content: '<div>Only Slide</div>', label: 'Only' }];
      const doc = await renderCarousel({
        slides: singleSlide,
        'aria-label': 'Featured content',
      });

      const tabs = doc.querySelectorAll('[role="tab"]');
      expect(tabs).toHaveLength(1);

      const panel = doc.querySelector('[role="tabpanel"]');
      expect(panel?.getAttribute('aria-label')).toBe('1 of 1');
    });

    it('clamps initialSlide to valid range', async () => {
      const doc = await renderCarousel({
        slides: basicSlides,
        'aria-label': 'Featured content',
        initialSlide: 99,
      });
      const tabs = doc.querySelectorAll('[role="tab"]');

      // Should fallback to first slide
      expect(tabs[0]?.getAttribute('aria-selected')).toBe('true');
    });
  });
});

リソース