APG Patterns
English
English

Feed

スクロールに応じて新しいコンテンツが追加される記事リストで、Page Up/Down キーで記事間をナビゲートできます。

デモ

Page Down(次へ)と Page Up(前へ)で記事間を移動できます。Ctrl+End でフィードの後、Ctrl+Home でフィードの前にフォーカスを移動します。 下にスクロールすると自動的に記事が追加されます。

aria-busy: 新しい記事を読み込む際、スクリーンリーダーが不完全なコンテンツを読み上げないよう aria-busy="true" が設定されます。読み込み完了後、false に戻ります。

キーボード操作
Page Down / Page Up
記事間を移動
Ctrl + End
フィードの後にフォーカス移動
Ctrl + Home
フィードの前にフォーカス移動

🪗 Accordion

A vertically stacked set of interactive headings that each reveal a section of content.

A vertically stacked set of interactive headings that each reveal a section of content.

Accordion パターンを見る →

⚠️ Alert

An element that displays a brief, important message in a way that attracts the user's attention without interrupting the user's task.

An element that displays a brief, important message in a way that attracts the user's attention without interrupting the user's task.

Alert パターンを見る →

🚨 Alert Dialog

A modal dialog that interrupts the user's workflow to communicate an important message and acquire a response.

A modal dialog that interrupts the user's workflow to communicate an important message and acquire a response.

Alert Dialog パターンを見る →

デモのみ表示 →

アクセシビリティ

WAI-ARIA ロール

ロール 対象要素 説明
feed コンテナ要素 スクロールでコンテンツが追加/削除される記事の動的リスト
article 各記事要素 フィード内の独立したコンテンツアイテム

WAI-ARIA APG Feed Pattern (opens in new tab)

注意: Feed はウィジェットではなくストラクチャー

ウィジェットパターン(例: Listbox、Menu)とは異なり、Feed パターンは ストラクチャー です。これは支援技術がフィードコンテンツをナビゲートする際にデフォルトの読み上げモードを使用できることを意味します。Feed ロールにより、ユーザーは Page Up/Down で記事間を移動しながら、各記事内では自然な読み上げが可能になります。

WAI-ARIA プロパティ

属性 対象 必須 説明
aria-label Feedコンテナ テキスト 条件付き* フィードのアクセシブルな名前(条件付き*)
aria-labelledby Feedコンテナ ID参照 条件付き* フィードの可視見出しを参照(条件付き*)
aria-labelledby 各記事 ID参照 はい 記事タイトル要素を参照
aria-describedby 各記事 ID参照 いいえ 記事の説明またはコンテンツを参照(推奨)
aria-posinset 各記事 数値(1始まり) はい フィード内の記事の位置(1から開始)
aria-setsize 各記事 数値または-1 はい フィード内の総記事数、不明な場合は-1

Feed コンテナには aria-label または aria-labelledby のいずれかが必要です。可視見出しがある場合は aria-labelledby を使用してください。

WAI-ARIA ステート

aria-busy

対象 Feedコンテナ
true | false
必須 条件付き(読み込みが発生する場合)
変更トリガー 読み込み開始(true)、読み込み完了(false)。フィードが新しいコンテンツを読み込み中であることを示します。スクリーンリーダーは読み込みが完了するまで変更の通知を待機します。
リファレンス aria-busy (opens in new tab)

注意: 複数の記事を追加する際に早期通知を防ぐため aria-busy を true に設定します。すべての DOM 更新が完了した後に false に設定します。

キーボードサポート

キー アクション
Page Down フォーカスをフィード内の次の記事に移動
Page Up フォーカスをフィード内の前の記事に移動
Ctrl + End フォーカスをフィードの後の最初のフォーカス可能要素に移動
Ctrl + Home フォーカスをフィードの前の最初のフォーカス可能要素に移動

なぜ矢印キーではなく Page Up/Down なのか? フィードには記事のような長文コンテンツが含まれます。Page Up/Down を使用することで、ユーザーは記事間を移動でき、矢印キーは記事内の読み上げに使用できます。

重要: キーボード操作のドキュメント

Feed パターンは Page Up/Down でナビゲートしますが、これは矢印キーを使用する一般的なウィジェットパターンとは異なります。 APG 仕様 (opens in new tab) には次のように記載されています: 「慣習がないため、簡単に発見できるキーボードインターフェースのドキュメントを提供することが特に重要です。」 ユーザーがこれらのナビゲーションオプションを発見できるよう、フィードの近くにキーボード操作のヒントを常に表示してください。

フォーカス管理

イベント 振る舞い
ローヴィング tabindex 1つの記事のみが tabindex="0"、他は tabindex="-1"
初期フォーカス デフォルトで最初の記事が tabindex="0"
フォーカス追跡 記事間のフォーカス移動に伴い tabindex が更新される
ラップなし 最初から最後、または最後から最初の記事へのラップはしない
記事内のコンテンツ 記事内のインタラクティブ要素はキーボードでアクセス可能

ソースコード

Feed.tsx
import { useCallback, useEffect, useId, useRef, useState } from 'react';

/**
 * Feed Article data structure
 */
export interface FeedArticle {
  /** Unique identifier for the article */
  id: string;
  /** Article title (required for aria-labelledby) */
  title: string;
  /** Optional description (used for aria-describedby) */
  description?: string;
  /** Article content (React elements are safe from XSS) */
  content: React.ReactNode;
}

/**
 * Feed component props
 */
export interface FeedProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'role'> {
  /** Array of article data */
  articles: FeedArticle[];
  /** Accessible name for the feed (mutually exclusive with aria-labelledby) */
  'aria-label'?: string;
  /** ID reference to visible label (mutually exclusive with aria-label) */
  'aria-labelledby'?: string;
  /**
   * Total number of articles
   * - undefined: use articles.length (auto-calculate)
   * - -1: unknown total (infinite scroll)
   * - positive number: explicit total count
   */
  setSize?: number;
  /** Loading state (suppresses onLoadMore during loading) */
  loading?: boolean;
  /** Callback when more content should be loaded (called automatically on scroll) */
  onLoadMore?: () => void;
  /**
   * Callback when focus changes between articles
   * @param articleId - ID of the focused article
   * @param index - Index of the focused article (0-based)
   */
  onFocusChange?: (articleId: string, index: number) => void;
  /** Disable automatic infinite scroll (manual load only) */
  disableAutoLoad?: boolean;
  /** Intersection Observer root margin for triggering load (default: "200px") */
  loadMoreRootMargin?: string;
}

/**
 * Feed Pattern Component
 *
 * A feed is a section of a page that automatically loads new sections of content
 * as the user scrolls. It is a structure (not a widget), allowing assistive
 * technologies to use their default reading mode.
 *
 * @see https://www.w3.org/WAI/ARIA/apg/patterns/feed/
 *
 * Features:
 * - Page Up/Down navigation between articles (not Arrow keys)
 * - Ctrl+Home/End to escape the feed
 * - Roving tabindex on articles
 * - aria-busy during dynamic loading
 * - aria-posinset/aria-setsize on each article
 */
export function Feed({
  articles,
  'aria-label': ariaLabel,
  'aria-labelledby': ariaLabelledby,
  setSize,
  loading = false,
  onLoadMore,
  onFocusChange,
  disableAutoLoad = false,
  loadMoreRootMargin = '200px',
  className,
  ...rest
}: FeedProps) {
  const baseId = useId();
  const containerRef = useRef<HTMLDivElement>(null);
  const articleRefs = useRef<(HTMLElement | null)[]>([]);
  const sentinelRef = useRef<HTMLDivElement>(null);
  const [focusedIndex, setFocusedIndex] = useState(0);

  // Warn if no accessible name is provided
  useEffect(() => {
    if (!ariaLabel && !ariaLabelledby) {
      console.warn(
        'Feed: An accessible name is required. ' +
          'Provide either aria-label or aria-labelledby prop.'
      );
    }
  }, [ariaLabel, ariaLabelledby]);

  // Calculate aria-setsize
  const computedSetSize = setSize !== undefined ? setSize : articles.length;

  // Intersection Observer for infinite scroll
  useEffect(() => {
    if (disableAutoLoad || !onLoadMore || !sentinelRef.current) return;

    const sentinel = sentinelRef.current;
    const observer = new IntersectionObserver(
      (entries) => {
        const entry = entries[0];
        if (entry.isIntersecting && !loading) {
          onLoadMore();
        }
      },
      {
        rootMargin: loadMoreRootMargin,
        threshold: 0,
      }
    );

    observer.observe(sentinel);

    return () => {
      observer.disconnect();
    };
  }, [disableAutoLoad, loading, onLoadMore, loadMoreRootMargin]);

  // Focus an article by index
  const focusArticle = useCallback(
    (index: number) => {
      const article = articleRefs.current[index];
      if (article) {
        article.focus();
        setFocusedIndex(index);
        if (onFocusChange && articles[index]) {
          onFocusChange(articles[index].id, index);
        }
      }
    },
    [articles, onFocusChange]
  );

  // Find focusable element outside the feed
  const focusOutsideFeed = useCallback((direction: 'before' | 'after') => {
    const feedElement = containerRef.current;
    if (!feedElement) return;

    const focusableSelector =
      'a[href], button:not([disabled]), input:not([disabled]), ' +
      'select:not([disabled]), textarea:not([disabled]), ' +
      '[tabindex]:not([tabindex="-1"])';

    // Get all focusable elements in document order
    const allFocusable = Array.from(document.querySelectorAll<HTMLElement>(focusableSelector));

    // Find the index range of feed elements
    let feedStartIndex = -1;
    let feedEndIndex = -1;

    for (let i = 0; i < allFocusable.length; i++) {
      if (feedElement.contains(allFocusable[i]) || allFocusable[i] === feedElement) {
        if (feedStartIndex === -1) feedStartIndex = i;
        feedEndIndex = i;
      }
    }

    if (direction === 'before') {
      // Find the last focusable element before the feed
      if (feedStartIndex > 0) {
        allFocusable[feedStartIndex - 1].focus();
      }
    } else {
      // Find the first focusable element after the feed
      if (feedEndIndex >= 0 && feedEndIndex < allFocusable.length - 1) {
        allFocusable[feedEndIndex + 1].focus();
      }
    }
  }, []);

  // Handle keyboard navigation
  const handleKeyDown = useCallback(
    (event: React.KeyboardEvent) => {
      // Find which article (or element inside article) has focus
      const { target } = event;
      if (!(target instanceof HTMLElement)) return;

      let currentIndex = focusedIndex;

      // Check if focus is on an article or inside an article
      for (let i = 0; i < articleRefs.current.length; i++) {
        const article = articleRefs.current[i];
        if (article && (article === target || article.contains(target))) {
          currentIndex = i;
          break;
        }
      }

      switch (event.key) {
        case 'PageDown':
          event.preventDefault();
          if (currentIndex < articles.length - 1) {
            focusArticle(currentIndex + 1);
          }
          break;

        case 'PageUp':
          event.preventDefault();
          if (currentIndex > 0) {
            focusArticle(currentIndex - 1);
          }
          break;

        case 'End':
          if (event.ctrlKey || event.metaKey) {
            event.preventDefault();
            focusOutsideFeed('after');
          }
          break;

        case 'Home':
          if (event.ctrlKey || event.metaKey) {
            event.preventDefault();
            focusOutsideFeed('before');
          }
          break;
      }
    },
    [articles.length, focusArticle, focusOutsideFeed, focusedIndex]
  );

  // Handle focus on article
  const handleArticleFocus = useCallback(
    (index: number) => {
      setFocusedIndex(index);
      if (onFocusChange && articles[index]) {
        onFocusChange(articles[index].id, index);
      }
    },
    [articles, onFocusChange]
  );

  return (
    // disabled to allow div with role="feed" to have keyboard events for capture children elements events
    // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
    <div
      ref={containerRef}
      role="feed"
      aria-label={ariaLabel}
      aria-labelledby={ariaLabelledby}
      aria-busy={loading}
      className={['apg-feed', className].filter(Boolean).join(' ')}
      onKeyDown={handleKeyDown}
      {...rest}
    >
      {articles.map(({ id, title, description, content }, index) => {
        const titleId = `${baseId}-article-${id}-title`;
        const descId = description ? `${baseId}-article-${id}-desc` : undefined;

        return (
          <article
            key={id}
            ref={(el) => {
              articleRefs.current[index] = el;
            }}
            className="apg-feed-article"
            tabIndex={index === focusedIndex ? 0 : -1}
            aria-labelledby={titleId}
            aria-describedby={descId}
            aria-posinset={index + 1}
            aria-setsize={computedSetSize}
            onFocus={() => handleArticleFocus(index)}
          >
            <h3 id={titleId}>{title}</h3>
            {description && <p id={descId}>{description}</p>}
            <div className="apg-feed-article-content">{content}</div>
          </article>
        );
      })}
      {/* Sentinel element for infinite scroll detection */}
      {onLoadMore && !disableAutoLoad && (
        <div ref={sentinelRef} aria-hidden="true" style={{ height: '1px', visibility: 'hidden' }} />
      )}
    </div>
  );
}

export default Feed;

使い方

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

const articles = [
  {
    id: 'article-1',
    title: 'React 入門',
    description: 'React 開発の基礎を学ぶ',
    content: <p>記事の全内容...</p>
  },
  {
    id: 'article-2',
    title: '高度なパターン',
    description: 'React の高度なパターンを探る',
    content: <p>記事の全内容...</p>
  }
];

function App() {
  return (
    <Feed
      articles={articles}
      aria-label="ブログ記事"
      setSize={-1}
      loading={false}
      onLoadMore={() => console.log('記事を追加読み込み')}
      onFocusChange={(id, index) => console.log('フォーカス:', id, index)}
    />
  );
}

API

Feed Props

プロパティ デフォルト 説明
articles FeedArticle[] 必須 記事アイテムの配列
aria-label string 条件付き アクセシブルな名前(aria-labelledby がない場合必須)
aria-labelledby string 条件付き 可視見出しへの ID 参照
setSize number articles.length 総数、または不明な場合は -1
loading boolean false 読み込み状態(aria-busy を設定)
onLoadMore () => void - 追加読み込みのコールバック
onFocusChange (articleId: string, index: number) => void - フォーカス変更時のコールバック

FeedArticle インターフェース

型定義
interface FeedArticle {
  id: string;
  title: string;
  description?: string;
  content: React.ReactNode;
}

テスト

テストは ARIA 構造、キーボードナビゲーション、フォーカス管理、動的読み込み状態の観点で APG 準拠を検証します。Feed コンポーネントは2層のテスト戦略を使用しています。

テスト戦略

ユニットテスト(Container API / Testing Library)

コンポーネントの HTML 出力と基本的なインタラクションを検証します。テンプレートレンダリングと ARIA 属性の正確性を確認します。

  • feed/article ロールを持つ HTML 構造
  • ARIA 属性(aria-labelledby、aria-posinset、aria-setsize)
  • 初期 tabindex 値(ローヴィング tabindex)
  • 動的読み込み状態(aria-busy)
  • CSS クラスの適用

E2Eテスト(Playwright)

実際のブラウザ環境でコンポーネントの動作を検証します。JavaScript の実行が必要なインタラクションをカバーします。

  • Page Up/Down による記事間のナビゲーション
  • Ctrl+Home/End によるフィードからの脱出
  • フォーカス管理と tabindex の更新
  • 記事内要素からのナビゲーション

テストカテゴリ

高優先度 : ARIA 構造(Unit + E2E)

テスト 説明
role="feed" コンテナが feed ロールを持つ
role="article" 各アイテムが article ロールを持つ
aria-label/labelledby (feed) Feed コンテナがアクセシブルな名前を持つ
aria-labelledby (article) 各記事がタイトルを参照
aria-posinset 1から始まる連番
aria-setsize 総数または不明な場合は-1

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

テスト 説明
Page Down 次の記事にフォーカスを移動
Page Up 前の記事にフォーカスを移動
No wrap 最初/最後の記事でループしない
Ctrl+End フィードの後にフォーカスを移動
Ctrl+Home フィードの前にフォーカスを移動
Inside article 記事内要素からも Page Down が動作

高優先度 : フォーカス管理(E2E)

テスト 説明
Roving tabindex 1つの記事のみが tabindex="0"
tabindex update フォーカス移動時に tabindex が更新される
Initial state 最初の記事がデフォルトで tabindex="0"

高優先度 : 動的読み込み(Unit + E2E)

テスト 説明
aria-busy="false" 読み込み中でない場合のデフォルト状態
aria-busy="true" 読み込み中に設定
Focus maintenance 読み込み中もフォーカスが維持される

中優先度 : アクセシビリティ(Unit + E2E)

テスト 説明
axe violations WCAG 2.1 AA 違反なし
Loading state 読み込み中も axe 違反なし
aria-describedby 説明がある場合に存在

テストの実行

ユニットテスト

            
              # すべての Feed ユニットテストを実行
npm run test:unit -- Feed
            
          

E2E テスト

            
              # すべての Feed E2E テストを実行
npm run test:e2e -- feed.spec.ts

# UI モードで実行
npm run test:e2e:ui -- feed.spec.ts
            
          
Feed.test.tsx
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import { Feed, type FeedArticle } from './Feed';

// Test article data
const defaultArticles: FeedArticle[] = [
  {
    id: 'article-1',
    title: 'First Article',
    description: 'Description 1',
    content: <p>Content 1</p>,
  },
  {
    id: 'article-2',
    title: 'Second Article',
    description: 'Description 2',
    content: <p>Content 2</p>,
  },
  {
    id: 'article-3',
    title: 'Third Article',
    description: 'Description 3',
    content: <p>Content 3</p>,
  },
];

const fiveArticles: FeedArticle[] = [
  { id: 'article-1', title: 'Article 1', content: <p>Content 1</p> },
  { id: 'article-2', title: 'Article 2', content: <p>Content 2</p> },
  { id: 'article-3', title: 'Article 3', content: <p>Content 3</p> },
  { id: 'article-4', title: 'Article 4', content: <p>Content 4</p> },
  { id: 'article-5', title: 'Article 5', content: <p>Content 5</p> },
];

// Helper to render Feed with focusable elements before/after for Ctrl+Home/End tests
const renderWithSurroundingElements = (props: React.ComponentProps<typeof Feed>) => {
  return render(
    <div>
      <button data-testid="before-feed">Before Feed</button>
      <Feed {...props} />
      <button data-testid="after-feed">After Feed</button>
    </div>
  );
};

describe('Feed', () => {
  // 🔴 High Priority: APG ARIA Structure
  describe('APG: ARIA Structure', () => {
    it('has role="feed" on container', () => {
      render(<Feed articles={defaultArticles} aria-label="News Feed" />);
      expect(screen.getByRole('feed')).toBeInTheDocument();
    });

    it('has role="article" on each article', () => {
      render(<Feed articles={defaultArticles} aria-label="News Feed" />);
      const articles = screen.getAllByRole('article');
      expect(articles).toHaveLength(3);
    });

    it('has aria-label on feed when provided', () => {
      render(<Feed articles={defaultArticles} aria-label="News Feed" />);
      const feed = screen.getByRole('feed');
      expect(feed).toHaveAttribute('aria-label', 'News Feed');
    });

    it('has aria-labelledby on feed when provided', () => {
      render(
        <div>
          <h2 id="feed-title">Latest News</h2>
          <Feed articles={defaultArticles} aria-labelledby="feed-title" />
        </div>
      );
      const feed = screen.getByRole('feed');
      expect(feed).toHaveAttribute('aria-labelledby', 'feed-title');
    });

    it('warns when feed has no accessible name', () => {
      const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
      render(<Feed articles={defaultArticles} />);
      expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('accessible name'));
      consoleSpy.mockRestore();
    });

    it('has aria-labelledby on each article referencing title', () => {
      render(<Feed articles={defaultArticles} aria-label="News Feed" />);
      const articles = screen.getAllByRole('article');

      articles.forEach((article) => {
        const labelledby = article.getAttribute('aria-labelledby');
        expect(labelledby).toBeTruthy();

        // Verify the referenced element exists and contains the title
        const titleElement = document.getElementById(labelledby!);
        expect(titleElement).toBeInTheDocument();
      });
    });

    it('has aria-describedby on articles when description provided', () => {
      render(<Feed articles={defaultArticles} aria-label="News Feed" />);
      const articles = screen.getAllByRole('article');

      articles.forEach((article) => {
        const describedby = article.getAttribute('aria-describedby');
        expect(describedby).toBeTruthy();

        const descElement = document.getElementById(describedby!);
        expect(descElement).toBeInTheDocument();
      });
    });

    it('has aria-posinset starting from 1 and sequential', () => {
      render(<Feed articles={fiveArticles} aria-label="News Feed" />);
      const articles = screen.getAllByRole('article');

      articles.forEach((article, index) => {
        expect(article).toHaveAttribute('aria-posinset', String(index + 1));
      });
    });

    it('has aria-setsize as total count when known', () => {
      render(<Feed articles={fiveArticles} aria-label="News Feed" />);
      const articles = screen.getAllByRole('article');

      articles.forEach((article) => {
        expect(article).toHaveAttribute('aria-setsize', '5');
      });
    });

    it('has aria-setsize as -1 when setSize is -1 (unknown)', () => {
      render(<Feed articles={fiveArticles} aria-label="News Feed" setSize={-1} />);
      const articles = screen.getAllByRole('article');

      articles.forEach((article) => {
        expect(article).toHaveAttribute('aria-setsize', '-1');
      });
    });

    it('has aria-setsize as explicit value when provided', () => {
      render(<Feed articles={defaultArticles} aria-label="News Feed" setSize={100} />);
      const articles = screen.getAllByRole('article');

      articles.forEach((article) => {
        expect(article).toHaveAttribute('aria-setsize', '100');
      });
    });
  });

  // 🔴 High Priority: Keyboard Interaction
  describe('APG: Keyboard Interaction', () => {
    it('moves focus to next article on Page Down', async () => {
      const user = userEvent.setup();
      render(<Feed articles={defaultArticles} aria-label="News Feed" />);

      const articles = screen.getAllByRole('article');
      articles[0].focus();

      await user.keyboard('{PageDown}');

      expect(articles[1]).toHaveFocus();
    });

    it('moves focus to previous article on Page Up', async () => {
      const user = userEvent.setup();
      render(<Feed articles={defaultArticles} aria-label="News Feed" />);

      const articles = screen.getAllByRole('article');
      articles[1].focus();

      await user.keyboard('{PageUp}');

      expect(articles[0]).toHaveFocus();
    });

    it('does not loop at first article on Page Up', async () => {
      const user = userEvent.setup();
      render(<Feed articles={defaultArticles} aria-label="News Feed" />);

      const articles = screen.getAllByRole('article');
      articles[0].focus();

      await user.keyboard('{PageUp}');

      expect(articles[0]).toHaveFocus(); // Still on first
    });

    it('does not loop at last article on Page Down', async () => {
      const user = userEvent.setup();
      render(<Feed articles={defaultArticles} aria-label="News Feed" />);

      const articles = screen.getAllByRole('article');
      articles[2].focus();

      await user.keyboard('{PageDown}');

      expect(articles[2]).toHaveFocus(); // Still on last
    });

    it('moves focus to next article even when focus is inside article element', async () => {
      const user = userEvent.setup();
      render(
        <Feed
          articles={[
            { id: '1', title: 'Article 1', content: <button>Inside Button 1</button> },
            { id: '2', title: 'Article 2', content: <button>Inside Button 2</button> },
          ]}
          aria-label="News Feed"
        />
      );

      // Focus on button inside first article
      const insideButton = screen.getByRole('button', { name: 'Inside Button 1' });
      insideButton.focus();

      await user.keyboard('{PageDown}');

      const articles = screen.getAllByRole('article');
      expect(articles[1]).toHaveFocus();
    });

    it('moves focus outside feed (after) on Ctrl+End', async () => {
      const user = userEvent.setup();
      renderWithSurroundingElements({
        articles: defaultArticles,
        'aria-label': 'News Feed',
      });

      const articles = screen.getAllByRole('article');
      articles[0].focus();

      await user.keyboard('{Control>}{End}{/Control}');

      const afterButton = screen.getByTestId('after-feed');
      expect(afterButton).toHaveFocus();
    });

    it('moves focus outside feed (before) on Ctrl+Home', async () => {
      const user = userEvent.setup();
      renderWithSurroundingElements({
        articles: defaultArticles,
        'aria-label': 'News Feed',
      });

      const articles = screen.getAllByRole('article');
      articles[1].focus();

      await user.keyboard('{Control>}{Home}{/Control}');

      const beforeButton = screen.getByTestId('before-feed');
      expect(beforeButton).toHaveFocus();
    });
  });

  // 🔴 High Priority: Focus Management
  describe('APG: Focus Management', () => {
    it('article elements are focusable with tabindex', () => {
      render(<Feed articles={defaultArticles} aria-label="News Feed" />);
      const articles = screen.getAllByRole('article');

      articles.forEach((article) => {
        expect(article).toHaveAttribute('tabindex');
      });
    });

    it('uses roving tabindex (only one article has tabindex="0")', () => {
      render(<Feed articles={defaultArticles} aria-label="News Feed" />);
      const articles = screen.getAllByRole('article');

      const withTabindex0 = articles.filter((article) => article.getAttribute('tabindex') === '0');
      expect(withTabindex0).toHaveLength(1);
    });

    it('updates tabindex when focus moves', async () => {
      const user = userEvent.setup();
      render(<Feed articles={defaultArticles} aria-label="News Feed" />);

      const articles = screen.getAllByRole('article');
      articles[0].focus();

      expect(articles[0]).toHaveAttribute('tabindex', '0');
      expect(articles[1]).toHaveAttribute('tabindex', '-1');

      await user.keyboard('{PageDown}');

      expect(articles[0]).toHaveAttribute('tabindex', '-1');
      expect(articles[1]).toHaveAttribute('tabindex', '0');
    });

    it('first article has tabindex="0" by default', () => {
      render(<Feed articles={defaultArticles} aria-label="News Feed" />);
      const articles = screen.getAllByRole('article');

      expect(articles[0]).toHaveAttribute('tabindex', '0');
      expect(articles[1]).toHaveAttribute('tabindex', '-1');
      expect(articles[2]).toHaveAttribute('tabindex', '-1');
    });
  });

  // 🔴 High Priority: Dynamic Loading
  describe('APG: Dynamic Loading', () => {
    it('has aria-busy="false" by default', () => {
      render(<Feed articles={defaultArticles} aria-label="News Feed" />);
      const feed = screen.getByRole('feed');
      expect(feed).toHaveAttribute('aria-busy', 'false');
    });

    it('sets aria-busy="true" during loading', () => {
      render(<Feed articles={defaultArticles} aria-label="News Feed" loading />);
      const feed = screen.getByRole('feed');
      expect(feed).toHaveAttribute('aria-busy', 'true');
    });

    it('sets aria-busy="false" after loading complete', () => {
      const { rerender } = render(
        <Feed articles={defaultArticles} aria-label="News Feed" loading />
      );

      expect(screen.getByRole('feed')).toHaveAttribute('aria-busy', 'true');

      rerender(<Feed articles={defaultArticles} aria-label="News Feed" loading={false} />);

      expect(screen.getByRole('feed')).toHaveAttribute('aria-busy', 'false');
    });

    it('updates aria-posinset/aria-setsize when articles are added', () => {
      const { rerender } = render(<Feed articles={defaultArticles} aria-label="News Feed" />);

      let articles = screen.getAllByRole('article');
      expect(articles).toHaveLength(3);
      expect(articles[2]).toHaveAttribute('aria-setsize', '3');

      // Add more articles
      rerender(<Feed articles={fiveArticles} aria-label="News Feed" />);

      articles = screen.getAllByRole('article');
      expect(articles).toHaveLength(5);
      articles.forEach((article, index) => {
        expect(article).toHaveAttribute('aria-posinset', String(index + 1));
        expect(article).toHaveAttribute('aria-setsize', '5');
      });
    });

    it('maintains focus during loading', async () => {
      const user = userEvent.setup();
      const { rerender } = render(<Feed articles={defaultArticles} aria-label="News Feed" />);

      const articles = screen.getAllByRole('article');
      articles[1].focus();

      // Start loading
      rerender(<Feed articles={defaultArticles} aria-label="News Feed" loading />);

      expect(articles[1]).toHaveFocus();
    });

    it('calls onLoadMore when provided', async () => {
      const handleLoadMore = vi.fn();
      render(
        <Feed articles={defaultArticles} aria-label="News Feed" onLoadMore={handleLoadMore} />
      );

      const articles = screen.getAllByRole('article');
      articles[2].focus(); // Focus on last article

      // Implementation should call onLoadMore when user reaches end
      // This is tested in E2E for scroll behavior
    });

    it('does not call onLoadMore during loading', async () => {
      const handleLoadMore = vi.fn();
      render(
        <Feed
          articles={defaultArticles}
          aria-label="News Feed"
          loading
          onLoadMore={handleLoadMore}
        />
      );

      // Even if we try to trigger load more, it should be suppressed
      expect(handleLoadMore).not.toHaveBeenCalled();
    });
  });

  // 🟡 Medium Priority: Accessibility
  describe('Accessibility', () => {
    it('has no axe violations', async () => {
      const { container } = render(<Feed articles={defaultArticles} aria-label="News Feed" />);
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations during loading state', async () => {
      const { container } = render(
        <Feed articles={defaultArticles} aria-label="News Feed" loading />
      );
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations with setSize=-1', async () => {
      const { container } = render(
        <Feed articles={defaultArticles} aria-label="News Feed" setSize={-1} />
      );
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });
  });

  // 🟡 Medium Priority: Props & Callbacks
  describe('Props & Callbacks', () => {
    it('renders articles from data', () => {
      render(<Feed articles={defaultArticles} aria-label="News Feed" />);

      expect(screen.getByText('First Article')).toBeInTheDocument();
      expect(screen.getByText('Second Article')).toBeInTheDocument();
      expect(screen.getByText('Third Article')).toBeInTheDocument();
    });

    it('renders article content', () => {
      render(<Feed articles={defaultArticles} aria-label="News Feed" />);

      expect(screen.getByText('Content 1')).toBeInTheDocument();
      expect(screen.getByText('Content 2')).toBeInTheDocument();
      expect(screen.getByText('Content 3')).toBeInTheDocument();
    });

    it('calls onFocusChange with articleId and index on focus', async () => {
      const handleFocusChange = vi.fn();
      const user = userEvent.setup();
      render(
        <Feed articles={defaultArticles} aria-label="News Feed" onFocusChange={handleFocusChange} />
      );

      const articles = screen.getAllByRole('article');
      articles[0].focus();

      await user.keyboard('{PageDown}');

      expect(handleFocusChange).toHaveBeenCalledWith('article-2', 1);
    });
  });

  // 🟢 Low Priority: HTML Attribute Inheritance
  describe('HTML Attribute Inheritance', () => {
    it('merges className', () => {
      const { container } = render(
        <Feed articles={defaultArticles} aria-label="News Feed" className="custom-feed" />
      );
      const feed = container.querySelector('[role="feed"]');
      expect(feed).toHaveClass('custom-feed');
    });

    it('passes through data-* attributes', () => {
      render(<Feed articles={defaultArticles} aria-label="News Feed" data-testid="my-feed" />);
      expect(screen.getByTestId('my-feed')).toBeInTheDocument();
    });
  });

  // Edge Cases
  describe('Edge Cases', () => {
    it('handles empty articles array', () => {
      render(<Feed articles={[]} aria-label="Empty Feed" />);
      const feed = screen.getByRole('feed');
      expect(feed).toBeInTheDocument();
      expect(screen.queryAllByRole('article')).toHaveLength(0);
    });

    it('handles single article', () => {
      render(
        <Feed
          articles={[{ id: '1', title: 'Only Article', content: <p>Content</p> }]}
          aria-label="Single Article Feed"
        />
      );

      const articles = screen.getAllByRole('article');
      expect(articles).toHaveLength(1);
      expect(articles[0]).toHaveAttribute('aria-posinset', '1');
      expect(articles[0]).toHaveAttribute('aria-setsize', '1');
    });

    it('handles article without description', () => {
      render(
        <Feed
          articles={[{ id: '1', title: 'No Description', content: <p>Content</p> }]}
          aria-label="Feed"
        />
      );

      const article = screen.getByRole('article');
      // Should not have aria-describedby when no description
      expect(article).not.toHaveAttribute('aria-describedby');
    });
  });
});

リソース