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.tsx
import { useCallback, useEffect, useId, useRef, useState } from 'react';

export interface CarouselSlide {
  /** Unique identifier for the slide */
  id: string;
  /** Slide content (JSX or string) */
  content: React.ReactNode;
  /** Accessible label for the slide */
  label?: string;
}

export interface CarouselProps {
  /** 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;
  /** Callback when slide changes */
  onSlideChange?: (index: number) => void;
  /** Additional CSS class */
  className?: string;
  /** Test ID for E2E testing */
  'data-testid'?: string;
}

export function Carousel({
  slides,
  'aria-label': ariaLabel,
  initialSlide = 0,
  autoRotate = false,
  rotationInterval = 5000,
  onSlideChange,
  className = '',
  'data-testid': testId,
}: CarouselProps): React.ReactElement {
  // Validate initialSlide - fallback to 0 if out of bounds
  const validInitialSlide = initialSlide >= 0 && initialSlide < slides.length ? initialSlide : 0;

  const [currentSlide, setCurrentSlide] = useState(validInitialSlide);
  const [focusedIndex, setFocusedIndex] = useState(validInitialSlide);

  // Transition animation state
  const [exitingSlide, setExitingSlide] = useState<number | null>(null);
  const [transitionDirection, setTransitionDirection] = useState<'next' | 'prev' | null>(null);

  // New state model: separate user intent from temporary pause
  const [autoRotateMode, setAutoRotateMode] = useState(() => {
    // Check prefers-reduced-motion
    if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
      const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
      if (prefersReducedMotion) {
        return false;
      }
    }
    return autoRotate;
  });
  const [isPausedByInteraction, setIsPausedByInteraction] = useState(false);

  const tabRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
  const tablistRef = useRef<HTMLDivElement>(null);
  const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
  const animationTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const currentSlideRef = useRef(currentSlide);

  const carouselId = useId();
  const slidesContainerId = `${carouselId}-slides`;

  // Computed: actual rotation state
  const isActuallyRotating = autoRotateMode && !isPausedByInteraction;

  // Handle slide change with animation
  const goToSlide = useCallback(
    (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
      // Special case for wrap-around
      const isWrapForward = currentSlide === slides.length - 1 && newIndex === 0;
      const isWrapBackward = currentSlide === 0 && newIndex === slides.length - 1;
      const direction =
        isWrapForward || (!isWrapBackward && newIndex > currentSlide) ? 'next' : 'prev';

      // Start transition
      setExitingSlide(currentSlide);
      setTransitionDirection(direction);
      setCurrentSlide(newIndex);
      setFocusedIndex(newIndex);
      onSlideChange?.(newIndex);

      // Clean up after animation
      animationTimeoutRef.current = setTimeout(() => {
        setExitingSlide(null);
        setTransitionDirection(null);
        animationTimeoutRef.current = null;
      }, 300); // Match CSS animation duration
    },
    [slides.length, currentSlide, onSlideChange]
  );

  const goToNextSlide = useCallback(() => {
    goToSlide(currentSlide + 1);
  }, [currentSlide, goToSlide]);

  const goToPrevSlide = useCallback(() => {
    goToSlide(currentSlide - 1);
  }, [currentSlide, goToSlide]);

  // Focus management
  const handleTabFocus = useCallback(
    (index: number) => {
      setFocusedIndex(index);
      const slide = slides[index];
      if (slide) {
        tabRefs.current.get(slide.id)?.focus();
      }
    },
    [slides]
  );

  // Keyboard handler for tablist
  const handleKeyDown = useCallback(
    (event: React.KeyboardEvent) => {
      const { key } = event;
      const target = event.target;
      if (
        !tablistRef.current ||
        !(target instanceof Node) ||
        !tablistRef.current.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 ' ':
          // Tab is already selected on focus, but this handles manual confirmation
          goToSlide(focusedIndex);
          shouldPreventDefault = true;
          break;
      }

      if (shouldPreventDefault) {
        event.preventDefault();

        if (newIndex !== focusedIndex) {
          handleTabFocus(newIndex);
          goToSlide(newIndex);
        }
      }
    },
    [focusedIndex, slides.length, handleTabFocus, goToSlide]
  );

  // Keep ref in sync with state
  useEffect(() => {
    currentSlideRef.current = currentSlide;
  }, [currentSlide]);

  // Auto-rotation timer with animation support
  useEffect(() => {
    if (isActuallyRotating) {
      timerRef.current = setInterval(() => {
        const current = currentSlideRef.current;
        const nextIndex = (current + 1) % slides.length;

        // Trigger animation
        setExitingSlide(current);
        setTransitionDirection('next');
        setCurrentSlide(nextIndex);
        setFocusedIndex(nextIndex);
        onSlideChange?.(nextIndex);

        // Clean up animation state
        animationTimeoutRef.current = setTimeout(() => {
          setExitingSlide(null);
          setTransitionDirection(null);
          animationTimeoutRef.current = null;
        }, 300);
      }, rotationInterval);
    }

    return () => {
      if (timerRef.current) {
        clearInterval(timerRef.current);
        timerRef.current = null;
      }
    };
  }, [isActuallyRotating, slides.length, rotationInterval, onSlideChange]);

  // Cleanup animation timeout and RAF on unmount
  useEffect(() => {
    return () => {
      if (animationTimeoutRef.current) {
        clearTimeout(animationTimeoutRef.current);
        animationTimeoutRef.current = null;
      }
      if (rafRef.current) {
        cancelAnimationFrame(rafRef.current);
        rafRef.current = null;
      }
    };
  }, []);

  // Toggle auto-rotate mode (user intent)
  const toggleAutoRotateMode = useCallback(() => {
    setAutoRotateMode((prev) => {
      const newMode = !prev;
      // When enabling auto-rotate, reset interaction pause so rotation starts immediately
      if (newMode) {
        setIsPausedByInteraction(false);
      }
      return newMode;
    });
  }, []);

  // Pause/resume by interaction (hover/focus)
  const pauseByInteraction = useCallback(() => {
    setIsPausedByInteraction(true);
  }, []);

  const resumeByInteraction = useCallback(() => {
    setIsPausedByInteraction(false);
  }, []);

  // Focus/blur handlers for entire carousel
  const handleCarouselFocusIn = useCallback(() => {
    if (autoRotateMode) {
      pauseByInteraction();
    }
  }, [autoRotateMode, pauseByInteraction]);

  const handleCarouselFocusOut = useCallback(
    (event: React.FocusEvent) => {
      if (!autoRotateMode) return;

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

  // Mouse hover handlers for slides container only
  const handleSlidesMouseEnter = useCallback(() => {
    if (autoRotateMode) {
      pauseByInteraction();
    }
  }, [autoRotateMode, pauseByInteraction]);

  const handleSlidesMouseLeave = useCallback(() => {
    if (autoRotateMode) {
      resumeByInteraction();
    }
  }, [autoRotateMode, resumeByInteraction]);

  // Touch/swipe handlers
  const pointerStartX = useRef<number | null>(null);
  const activePointerId = useRef<number | null>(null);
  const rafRef = useRef<number | null>(null);
  const pendingDragOffset = useRef<number>(0);
  const slidesContainerRef = useRef<HTMLDivElement>(null);
  const [dragOffset, setDragOffset] = useState(0);
  const [isDragging, setIsDragging] = useState(false);

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

  // Instant slide change (no animation) for swipe completion
  const goToSlideInstant = useCallback(
    (index: number) => {
      if (slides.length < 2) return;
      const newIndex = ((index % slides.length) + slides.length) % slides.length;
      setCurrentSlide(newIndex);
      setFocusedIndex(newIndex);
      onSlideChange?.(newIndex);
    },
    [slides.length, onSlideChange]
  );

  const handlePointerDown = useCallback(
    (event: React.PointerEvent<HTMLDivElement>) => {
      if (slides.length < 2) return; // Disable swipe for single slide
      if (activePointerId.current !== null) return; // Ignore if already tracking a pointer
      activePointerId.current = event.pointerId;
      pointerStartX.current = event.clientX;
      setIsDragging(true);
      setDragOffset(0);
      // Capture pointer to receive events even if pointer moves outside element
      event.currentTarget.setPointerCapture(event.pointerId);
      if (autoRotateMode) {
        pauseByInteraction();
      }
    },
    [slides.length, autoRotateMode, pauseByInteraction]
  );

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

    // Throttle updates using requestAnimationFrame
    if (rafRef.current === null) {
      rafRef.current = requestAnimationFrame(() => {
        setDragOffset(pendingDragOffset.current);
        rafRef.current = null;
      });
    }
  }, []);

  const handlePointerUp = useCallback(
    (event: React.PointerEvent) => {
      if (activePointerId.current !== event.pointerId) return; // Ignore other pointers
      if (pointerStartX.current === null) return;

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

      // Use ref to get current slide value to avoid stale closure
      const current = currentSlideRef.current;
      if (diff > threshold) {
        // Swiped right - go to previous slide (instant, no animation)
        goToSlideInstant(current - 1);
      } else if (diff < -threshold) {
        // Swiped left - go to next slide (instant, no animation)
        goToSlideInstant(current + 1);
      }
      // else: snap back (just reset dragOffset)

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

      activePointerId.current = null;
      pointerStartX.current = null;
      setIsDragging(false);
      setDragOffset(0);

      if (autoRotateMode) {
        resumeByInteraction();
      }
    },
    [goToSlideInstant, autoRotateMode, resumeByInteraction]
  );

  const handlePointerCancel = useCallback((event: React.PointerEvent) => {
    if (activePointerId.current !== event.pointerId) return; // Ignore other pointers
    // Cancel any pending RAF
    if (rafRef.current !== null) {
      cancelAnimationFrame(rafRef.current);
      rafRef.current = null;
    }
    activePointerId.current = null;
    pointerStartX.current = null;
    setIsDragging(false);
    setDragOffset(0);
  }, []);

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

  return (
    <section
      className={containerClass}
      aria-roledescription="carousel"
      aria-label={ariaLabel}
      data-testid={testId}
      onFocus={handleCarouselFocusIn}
      onBlur={handleCarouselFocusOut}
    >
      {/* Slides Container */}
      <div
        ref={slidesContainerRef}
        id={slidesContainerId}
        data-testid="slides-container"
        className={['apg-carousel-slides', isDragging && 'apg-carousel-slides--dragging']
          .filter(Boolean)
          .join(' ')}
        role="group"
        aria-live={isActuallyRotating ? 'off' : 'polite'}
        aria-atomic="false"
        onPointerDown={handlePointerDown}
        onPointerMove={handlePointerMove}
        onPointerUp={handlePointerUp}
        onPointerCancel={handlePointerCancel}
        onMouseEnter={handleSlidesMouseEnter}
        onMouseLeave={handleSlidesMouseLeave}
      >
        {slides.map((slide, index) => {
          const isActive = index === currentSlide;
          const isExiting = index === exitingSlide;
          const isSwipeAdjacent = index === swipeAdjacentSlide;
          const panelId = `${carouselId}-panel-${slide.id}`;
          const tabId = `${carouselId}-tab-${slide.id}`;

          // Determine animation class (only for non-swipe transitions)
          let animationClass = '';
          if (transitionDirection && !isDragging) {
            if (isActive) {
              animationClass = `apg-carousel-slide--entering-${transitionDirection}`;
            } else if (isExiting) {
              animationClass = `apg-carousel-slide--exiting-${transitionDirection}`;
            }
          }

          // Determine swipe position class
          let swipeClass = '';
          if (isSwipeAdjacent && isDragging) {
            swipeClass =
              dragOffset > 0 ? 'apg-carousel-slide--swipe-prev' : 'apg-carousel-slide--swipe-next';
          }

          // Calculate transform for swipe
          let slideStyle: React.CSSProperties | undefined;
          if (isDragging) {
            if (isActive) {
              slideStyle = { transform: `translateX(${dragOffset}px)` };
            } else if (isSwipeAdjacent) {
              // Position adjacent slide next to current slide
              const baseOffset = dragOffset > 0 ? '-100%' : '100%';
              slideStyle = { transform: `translateX(calc(${baseOffset} + ${dragOffset}px))` };
            }
          }

          return (
            <div
              key={slide.id}
              id={panelId}
              role="tabpanel"
              aria-roledescription="slide"
              aria-label={`${index + 1} of ${slides.length}`}
              aria-labelledby={tabId}
              aria-hidden={!isActive}
              inert={!isActive ? true : undefined}
              className={`apg-carousel-slide ${isActive ? 'apg-carousel-slide--active' : ''} ${animationClass} ${swipeClass}`.trim()}
              style={slideStyle}
            >
              {slide.content}
            </div>
          );
        })}
      </div>

      {/* Controls */}
      <div className="apg-carousel-controls">
        {/* Play/Pause Button (first in tab order) */}
        {autoRotate && (
          <button
            type="button"
            className="apg-carousel-play-pause"
            aria-label={autoRotateMode ? 'Stop automatic slide show' : 'Start automatic slide show'}
            onClick={toggleAutoRotateMode}
          >
            {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>
            ) : (
              <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>
            )}
          </button>
        )}

        {/* Tablist (slide indicators) */}
        <div
          ref={tablistRef}
          role="tablist"
          aria-label="Slides"
          className="apg-carousel-tablist"
          onKeyDown={handleKeyDown}
        >
          {slides.map((slide, index) => {
            const isSelected = index === currentSlide;
            const isFocusTarget = index === focusedIndex;
            const tabId = `${carouselId}-tab-${slide.id}`;
            const panelId = `${carouselId}-panel-${slide.id}`;

            return (
              <button
                key={slide.id}
                ref={(el) => {
                  if (el) {
                    tabRefs.current.set(slide.id, el);
                  } else {
                    tabRefs.current.delete(slide.id);
                  }
                }}
                type="button"
                role="tab"
                id={tabId}
                aria-selected={isSelected}
                aria-controls={panelId}
                tabIndex={isFocusTarget ? 0 : -1}
                className={`apg-carousel-tab ${isSelected ? 'apg-carousel-tab--selected' : ''}`}
                onClick={() => goToSlide(index)}
                aria-label={slide.label || `Slide ${index + 1}`}
              >
                <span className="apg-carousel-tab-indicator" aria-hidden="true" />
              </button>
            );
          })}
        </div>

        {/* Previous/Next Buttons */}
        <div role="group" aria-label="Slide controls" className="apg-carousel-nav">
          <button
            type="button"
            className="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"
              strokeWidth="2.5"
              strokeLinecap="round"
              strokeLinejoin="round"
            >
              <line x1="15" y1="10" x2="5" y2="10" />
              <polyline points="10 5 5 10 10 15" />
            </svg>
          </button>
          <button
            type="button"
            className="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"
              strokeWidth="2.5"
              strokeLinecap="round"
              strokeLinejoin="round"
            >
              <line x1="5" y1="10" x2="15" y2="10" />
              <polyline points="10 5 15 10 10 15" />
            </svg>
          </button>
        </div>
      </div>
    </section>
  );
}

export default Carousel;

使い方

使用例
import { Carousel } from './Carousel';

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 App() {
  return (
    <Carousel
      slides={slides}
      aria-label="注目のコンテンツ"
      autoRotate={true}
      rotationInterval={5000}
      onSlideChange={(index) => console.log('スライド変更:', index)}
    />
  );
}

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: React.ReactNode;
  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.tsx
import { render, screen, waitFor, within, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Carousel, type CarouselSlide } from './Carousel';

// 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(),
  })),
});

// Test slide data
const defaultSlides: CarouselSlide[] = [
  { 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: CarouselSlide[] = [
  { 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' },
];

describe('Carousel', () => {
  beforeEach(() => {
    vi.useFakeTimers({ shouldAdvanceTime: true });
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  // 🔴 High Priority: APG ARIA Structure
  describe('APG: ARIA Structure', () => {
    it('has aria-roledescription="carousel" on container', () => {
      render(<Carousel slides={defaultSlides} aria-label="Featured content" />);
      const carousel = screen.getByRole('region');
      expect(carousel).toHaveAttribute('aria-roledescription', 'carousel');
    });

    it('has aria-label on container', () => {
      render(<Carousel slides={defaultSlides} aria-label="Featured content" />);
      const carousel = screen.getByRole('region');
      expect(carousel).toHaveAttribute('aria-label', 'Featured content');
    });

    it('has aria-roledescription="slide" on each tabpanel', () => {
      render(<Carousel slides={defaultSlides} aria-label="Featured content" />);
      const panels = screen.getAllByRole('tabpanel', { hidden: true });
      panels.forEach((panel) => {
        expect(panel).toHaveAttribute('aria-roledescription', 'slide');
      });
    });

    it('has aria-label="N of M" on each slide', () => {
      render(<Carousel 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('has role="tablist" on tab container', () => {
      render(<Carousel slides={defaultSlides} aria-label="Featured content" />);
      expect(screen.getByRole('tablist')).toBeInTheDocument();
    });

    it('has role="tab" on each tab button', () => {
      render(<Carousel slides={defaultSlides} aria-label="Featured content" />);
      const tabs = screen.getAllByRole('tab');
      expect(tabs).toHaveLength(3);
    });

    it('has role="tabpanel" on each slide', () => {
      render(<Carousel slides={defaultSlides} aria-label="Featured content" />);
      const panels = screen.getAllByRole('tabpanel', { hidden: true });
      expect(panels).toHaveLength(3);
    });

    it('has aria-selected="true" on active tab', () => {
      render(<Carousel 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('has aria-controls pointing to tabpanel', () => {
      render(<Carousel 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);
      });
    });

    it('panel aria-labelledby matches tab id', () => {
      render(<Carousel slides={defaultSlides} aria-label="Featured content" />);
      const tabs = screen.getAllByRole('tab');
      const panels = screen.getAllByRole('tabpanel', { hidden: true });

      panels.forEach((panel, index) => {
        expect(panel).toHaveAttribute('aria-labelledby', tabs[index].id);
      });
    });
  });

  // 🔴 High Priority: Keyboard Interaction
  describe('APG: Keyboard Interaction', () => {
    it('moves focus to next tab on ArrowRight', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(<Carousel 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('moves focus to previous tab on ArrowLeft', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(<Carousel 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('wraps from last to first on ArrowRight', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(<Carousel 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('wraps from first to last on ArrowLeft', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(<Carousel 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('moves focus to first tab on Home', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(<Carousel 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('moves focus to last tab on End', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(<Carousel 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');
    });

    it('activates tab on Enter', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(<Carousel slides={defaultSlides} aria-label="Featured content" />);

      const tabs = screen.getAllByRole('tab');
      await user.click(tabs[0]);
      await user.keyboard('{ArrowRight}');
      await user.keyboard('{Enter}');

      expect(tabs[1]).toHaveAttribute('aria-selected', 'true');
    });

    it('activates tab on Space', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(<Carousel slides={defaultSlides} aria-label="Featured content" />);

      const tabs = screen.getAllByRole('tab');
      await user.click(tabs[0]);
      await user.keyboard('{ArrowRight}');
      await user.keyboard(' ');

      expect(tabs[1]).toHaveAttribute('aria-selected', 'true');
    });
  });

  // 🔴 High Priority: Auto-Rotation
  describe('APG: Auto-Rotation', () => {
    it('rotates slides automatically when enabled', async () => {
      render(
        <Carousel
          slides={defaultSlides}
          aria-label="Featured content"
          autoRotate
          rotationInterval={3000}
        />
      );

      const tabs = screen.getAllByRole('tab');
      expect(tabs[0]).toHaveAttribute('aria-selected', 'true');

      // Advance timer
      act(() => {
        vi.advanceTimersByTime(3000);
      });

      await waitFor(() => {
        expect(tabs[1]).toHaveAttribute('aria-selected', 'true');
      });

      act(() => {
        vi.advanceTimersByTime(3000);
      });

      await waitFor(() => {
        expect(tabs[2]).toHaveAttribute('aria-selected', 'true');
      });
    });

    it('has aria-live="off" during auto-rotation', () => {
      render(<Carousel slides={defaultSlides} aria-label="Featured content" autoRotate />);

      const slidesContainer = screen.getByTestId('slides-container');
      expect(slidesContainer).toHaveAttribute('aria-live', 'off');
    });

    it('has aria-live="polite" when rotation stopped', () => {
      render(<Carousel slides={defaultSlides} aria-label="Featured content" autoRotate={false} />);

      const slidesContainer = screen.getByTestId('slides-container');
      expect(slidesContainer).toHaveAttribute('aria-live', 'polite');
    });

    it('stops rotation on keyboard focus', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(
        <Carousel
          slides={defaultSlides}
          aria-label="Featured content"
          autoRotate
          rotationInterval={3000}
        />
      );

      const tabs = screen.getAllByRole('tab');
      await user.click(tabs[0]);

      const slidesContainer = screen.getByTestId('slides-container');
      expect(slidesContainer).toHaveAttribute('aria-live', 'polite');

      // Advance time - should NOT rotate
      act(() => {
        vi.advanceTimersByTime(3000);
      });

      expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
    });

    it('stops rotation on mouse hover over slides container', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(
        <Carousel
          slides={defaultSlides}
          aria-label="Featured content"
          autoRotate
          rotationInterval={3000}
        />
      );

      const slidesContainer = screen.getByTestId('slides-container');
      await user.hover(slidesContainer);

      expect(slidesContainer).toHaveAttribute('aria-live', 'polite');

      // Advance time - should NOT rotate
      act(() => {
        vi.advanceTimersByTime(3000);
      });

      const tabs = screen.getAllByRole('tab');
      expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
    });

    it('resumes rotation on focus/hover out (if not manually stopped)', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(
        <Carousel
          slides={defaultSlides}
          aria-label="Featured content"
          autoRotate
          rotationInterval={3000}
        />
      );

      const slidesContainer = screen.getByTestId('slides-container');
      const tabs = screen.getAllByRole('tab');

      // Hover over slides container to pause
      await user.hover(slidesContainer);

      act(() => {
        vi.advanceTimersByTime(3000);
      });
      expect(tabs[0]).toHaveAttribute('aria-selected', 'true');

      // Unhover to resume
      await user.unhover(slidesContainer);

      act(() => {
        vi.advanceTimersByTime(3000);
      });

      await waitFor(() => {
        expect(tabs[1]).toHaveAttribute('aria-selected', 'true');
      });
    });

    it('toggles rotation with play/pause button', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(
        <Carousel
          slides={defaultSlides}
          aria-label="Featured content"
          autoRotate
          rotationInterval={3000}
        />
      );

      const playPauseButton = screen.getByRole('button', { name: /stop|pause/i });

      // Click to pause
      await user.click(playPauseButton);

      const slidesContainer = screen.getByTestId('slides-container');
      expect(slidesContainer).toHaveAttribute('aria-live', 'polite');

      const tabs = screen.getAllByRole('tab');

      act(() => {
        vi.advanceTimersByTime(3000);
      });
      expect(tabs[0]).toHaveAttribute('aria-selected', 'true');

      // Click to resume
      const startButton = screen.getByRole('button', { name: /start|play/i });
      await user.click(startButton);

      act(() => {
        vi.advanceTimersByTime(3000);
      });

      await waitFor(() => {
        expect(tabs[1]).toHaveAttribute('aria-selected', 'true');
      });
    });

    it('updates button label based on rotation state', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(
        <Carousel
          slides={defaultSlides}
          aria-label="Featured content"
          autoRotate
          rotationInterval={3000}
        />
      );

      // Initially rotating - button should say "Stop" or "Pause"
      expect(screen.getByRole('button', { name: /stop|pause/i })).toBeInTheDocument();

      // Click to pause
      await user.click(screen.getByRole('button', { name: /stop|pause/i }));

      // Button should now say "Start" or "Play"
      expect(screen.getByRole('button', { name: /start|play/i })).toBeInTheDocument();
    });

    it('respects prefers-reduced-motion', () => {
      // Mock matchMedia for prefers-reduced-motion
      const originalMatchMedia = window.matchMedia;
      window.matchMedia = vi.fn().mockImplementation((query) => ({
        matches: query === '(prefers-reduced-motion: reduce)',
        media: query,
        onchange: null,
        addListener: vi.fn(),
        removeListener: vi.fn(),
        addEventListener: vi.fn(),
        removeEventListener: vi.fn(),
        dispatchEvent: vi.fn(),
      }));

      render(
        <Carousel
          slides={defaultSlides}
          aria-label="Featured content"
          autoRotate
          rotationInterval={3000}
        />
      );

      // Should not auto-rotate when reduced motion is preferred
      const slidesContainer = screen.getByTestId('slides-container');
      expect(slidesContainer).toHaveAttribute('aria-live', 'polite');

      const tabs = screen.getAllByRole('tab');

      act(() => {
        vi.advanceTimersByTime(3000);
      });

      expect(tabs[0]).toHaveAttribute('aria-selected', 'true');

      window.matchMedia = originalMatchMedia;
    });

    it('loops back to first slide after last', async () => {
      render(
        <Carousel
          slides={defaultSlides}
          aria-label="Featured content"
          autoRotate
          rotationInterval={1000}
          initialSlide={2}
        />
      );

      const tabs = screen.getAllByRole('tab');
      expect(tabs[2]).toHaveAttribute('aria-selected', 'true');

      act(() => {
        vi.advanceTimersByTime(1000);
      });

      await waitFor(() => {
        expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
      });
    });
  });

  // 🔴 High Priority: Focus Management
  describe('APG: Focus Management', () => {
    it('uses roving tabindex on tablist', () => {
      render(<Carousel 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('only one tab has tabindex="0" at a time', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(<Carousel 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);
    });

    it('rotation control is first in tab order', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(<Carousel slides={defaultSlides} aria-label="Featured content" autoRotate />);

      // Start from outside the carousel and tab in
      const carousel = screen.getByRole('region');
      carousel.focus();

      await user.tab();

      // First focusable should be the play/pause button
      const playPauseButton = screen.getByRole('button', { name: /stop|pause|start|play/i });
      expect(playPauseButton).toHaveFocus();
    });
  });

  // 🟡 Medium Priority: Navigation Controls
  describe('Navigation Controls', () => {
    it('shows next slide on next button click', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(<Carousel 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('shows previous slide on previous button click', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(<Carousel 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('wraps to first slide from last on next', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(<Carousel 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');
    });

    it('wraps to last slide from first on previous', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(<Carousel slides={defaultSlides} aria-label="Featured content" />);

      const prevButton = screen.getByRole('button', { name: /previous|prev/i });
      await user.click(prevButton);

      const tabs = screen.getAllByRole('tab');
      expect(tabs[2]).toHaveAttribute('aria-selected', 'true');
    });

    it('prev/next buttons have aria-controls', () => {
      render(<Carousel slides={defaultSlides} aria-label="Featured content" />);

      const prevButton = screen.getByRole('button', { name: /previous|prev/i });
      const nextButton = screen.getByRole('button', { name: /next/i });
      const slidesContainer = screen.getByTestId('slides-container');

      expect(prevButton).toHaveAttribute('aria-controls', slidesContainer.id);
      expect(nextButton).toHaveAttribute('aria-controls', slidesContainer.id);
    });
  });

  // 🟡 Medium Priority: Accessibility
  describe('Accessibility', () => {
    it('has no axe violations', async () => {
      const { container } = render(
        <Carousel slides={defaultSlides} aria-label="Featured content" />
      );
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations with autoRotate', async () => {
      const { container } = render(
        <Carousel slides={defaultSlides} aria-label="Featured content" autoRotate />
      );
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });
  });

  // 🟢 Low Priority: Props & Behavior
  describe('Props & Behavior', () => {
    it('calls onSlideChange when slide changes', async () => {
      const handleSlideChange = vi.fn();
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(
        <Carousel
          slides={defaultSlides}
          aria-label="Featured content"
          onSlideChange={handleSlideChange}
        />
      );

      const tabs = screen.getAllByRole('tab');
      await user.click(tabs[1]);

      expect(handleSlideChange).toHaveBeenCalledWith(1);
    });

    it('respects initialSlide prop', () => {
      render(<Carousel slides={defaultSlides} aria-label="Featured content" initialSlide={1} />);

      const tabs = screen.getAllByRole('tab');
      expect(tabs[1]).toHaveAttribute('aria-selected', 'true');
    });

    it('respects autoRotate prop', () => {
      render(<Carousel slides={defaultSlides} aria-label="Featured content" autoRotate={false} />);

      const slidesContainer = screen.getByTestId('slides-container');
      expect(slidesContainer).toHaveAttribute('aria-live', 'polite');
    });

    it('applies className to container', () => {
      const { container } = render(
        <Carousel
          slides={defaultSlides}
          aria-label="Featured content"
          className="custom-carousel"
        />
      );
      const carousel = container.firstChild as HTMLElement;
      expect(carousel).toHaveClass('custom-carousel');
    });
  });

  // Edge Cases
  describe('Edge Cases', () => {
    it('handles single slide', () => {
      const singleSlide: CarouselSlide[] = [
        { id: 'slide1', content: <div>Only Slide</div>, label: 'Only Slide' },
      ];
      render(<Carousel 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');
    });

    it('handles initialSlide out of bounds', () => {
      render(<Carousel slides={defaultSlides} aria-label="Featured content" initialSlide={99} />);

      const tabs = screen.getAllByRole('tab');
      // Should fallback to first slide
      expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
    });
  });
});

リソース