APG Patterns
English GitHub
English GitHub

Feed

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

🤖 AI 実装ガイド

デモ

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

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

Keyboard Navigation
Page Down / Page Up
Move between articles
Ctrl + End
Move focus after feed
Ctrl + Home
Move focus before feed

Understanding the Feed Pattern

Deep dive into the APG Feed pattern implementation.

The Feed pattern is designed for content that loads dynamically as users scroll. Unlike other patterns, Feed is a structure that allows screen readers to use their default reading mode.

Keyboard Navigation Best Practices

Tips for implementing effective keyboard navigation.

Effective keyboard navigation is crucial for accessibility. This article explores Page Up/Down navigation in feeds and how it differs from other patterns that use arrow keys.

ARIA Attributes Deep Dive

Understanding ARIA roles, states, and properties.

ARIA (Accessible Rich Internet Applications) provides attributes that define ways to make web content and applications more accessible.

デモのみ表示 →

アクセシビリティ

WAI-ARIA ロール

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

WAI-ARIA APG Feed パターン (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)

注意: 複数の記事を追加する際に早期通知を防ぐため 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 (
    <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((article, index) => {
        const titleId = `${baseId}-article-${article.id}-title`;
        const descId = article.description ? `${baseId}-article-${article.id}-desc` : undefined;

        return (
          <article
            key={article.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}>
              <a
                href="#"
                className="apg-feed-article-title-link"
                onClick={(e) => e.preventDefault()}
              >
                {article.title}
              </a>
            </h3>
            {article.description && <p id={descId}>{article.description}</p>}
            <div className="apg-feed-article-content">{article.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 構造

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

高優先度: キーボード操作

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

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

テスト 説明
ローヴィング tabindex 1つの記事のみが tabindex="0"
tabindex 更新 フォーカス移動時に tabindex が更新される
初期状態 最初の記事がデフォルトで tabindex="0"

高優先度: 動的読み込み

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

中優先度: アクセシビリティ

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

テストの実行

ユニットテスト

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

# フレームワーク別のテスト
npm run test:react -- Feed.test.tsx
npm run test:vue -- Feed.test.vue.ts
npm run test:svelte -- Feed.test.svelte.ts
npm run test:astro

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

リソース