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

Getting Started with Accessible Components

Learn the fundamentals of building accessible web components.

Building accessible web components is essential for creating inclusive web experiences. This guide covers the basics of ARIA attributes, keyboard navigation, and focus management.

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.

デモのみ表示 →

アクセシビリティ

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.svelte
<script lang="ts">
  import { onMount } from 'svelte';

  /**
   * 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 (plain text) */
    content: string;
  }

  interface FeedProps {
    /** 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;
    /** Additional CSS class */
    class?: string;
    /**
     * Callback when focus changes between articles
     */
    onfocuschange?: (detail: { articleId: string; index: number }) => void;
    /** Callback when more content should be loaded (called automatically on scroll) */
    onloadmore?: () => void;
    /** Disable automatic infinite scroll (manual load only) */
    disableAutoLoad?: boolean;
    /** Intersection Observer root margin for triggering load (default: "200px") */
    loadMoreRootMargin?: string;
  }

  let {
    articles = [],
    'aria-label': ariaLabel,
    'aria-labelledby': ariaLabelledby,
    setSize,
    loading = false,
    class: className = '',
    onfocuschange = () => {},
    onloadmore,
    disableAutoLoad = false,
    loadMoreRootMargin = '200px',
    ...restProps
  }: FeedProps = $props();

  // State
  let focusedIndex = $state(0);
  let baseId = $state('');

  // Refs
  let containerRef: HTMLDivElement;
  let articleRefs: (HTMLElement | null)[] = [];
  let sentinelRef: HTMLDivElement;

  // Computed
  let computedSetSize = $derived(setSize !== undefined ? setSize : articles.length);

  // Generate ID on mount
  onMount(() => {
    baseId = `feed-${Math.random().toString(36).slice(2, 9)}`;
  });

  // Intersection Observer for infinite scroll
  $effect(() => {
    if (disableAutoLoad || !onloadmore || !sentinelRef) return;

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

    observer.observe(sentinelRef);

    return () => {
      observer.disconnect();
    };
  });

  // Focus an article by index
  function focusArticle(index: number) {
    const article = articleRefs[index];
    if (article) {
      article.focus();
      focusedIndex = index;
      if (articles[index]) {
        onfocuschange({ articleId: articles[index].id, index });
      }
    }
  }

  // Find focusable element outside the feed
  function focusOutsideFeed(direction: 'before' | 'after') {
    const feedElement = containerRef;
    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
  function handleKeyDown(event: 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.length; i++) {
      const article = articleRefs[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;
    }
  }

  // Handle focus on article
  function handleArticleFocus(index: number) {
    focusedIndex = index;
    if (articles[index]) {
      onfocuschange({ articleId: articles[index].id, index });
    }
  }
</script>

<div
  bind:this={containerRef}
  role="feed"
  aria-label={ariaLabel}
  aria-labelledby={ariaLabelledby}
  aria-busy={loading}
  class={['apg-feed', className].filter(Boolean).join(' ') || undefined}
  onkeydown={handleKeyDown}
  {...restProps}
>
  {#each articles as article, index (article.id)}
    <article
      bind:this={articleRefs[index]}
      class="apg-feed-article"
      tabindex={index === focusedIndex ? 0 : -1}
      aria-labelledby={`${baseId}-article-${article.id}-title`}
      aria-describedby={article.description ? `${baseId}-article-${article.id}-desc` : undefined}
      aria-posinset={index + 1}
      aria-setsize={computedSetSize}
      onfocus={() => handleArticleFocus(index)}
    >
      <h3 id={`${baseId}-article-${article.id}-title`}>
        <a href="#" class="apg-feed-article-title-link" onclick={(e) => e.preventDefault()}
          >{article.title}</a
        >
      </h3>
      {#if article.description}
        <p id={`${baseId}-article-${article.id}-desc`}>{article.description}</p>
      {/if}
      <div class="apg-feed-article-content">{article.content}</div>
    </article>
  {/each}
  <!-- Sentinel element for infinite scroll detection -->
  {#if onloadmore && !disableAutoLoad}
    <div bind:this={sentinelRef} aria-hidden="true" style="height: 1px; visibility: hidden"></div>
  {/if}
</div>

<style>
  /* Styles are in src/styles/patterns/feed.css */
</style>

使い方

使用例
<script lang="ts">
  import Feed from './Feed.svelte';

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

  function handleLoadMore() {
    console.log('記事を追加読み込み');
  }

  function handleFocusChange(id: string, index: number) {
    console.log('フォーカス:', id, index);
  }
</script>

<Feed
  {articles}
  aria-label="ブログ記事"
  setSize={-1}
  loading={false}
  onloadmore={handleLoadMore}
  onfocuschange={handleFocusChange}
/>

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: string;
}

テスト

テストは 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 前の記事にフォーカスを移動
ラップなし 最初/最後の記事でループしない
Ctrl+End フィードの後にフォーカスを移動
Ctrl+Home フィードの前にフォーカスを移動
記事内から 記事内要素からも Page Down が動作

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

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

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

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

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

テスト 説明
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.svelte.ts
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import Feed from './Feed.svelte';
import type { FeedArticle } from './Feed.svelte';

// テスト用記事データ
const defaultArticles: FeedArticle[] = [
  { id: 'article-1', title: 'First Article', description: 'Description 1', content: 'Content 1' },
  { id: 'article-2', title: 'Second Article', description: 'Description 2', content: 'Content 2' },
  { id: 'article-3', title: 'Third Article', description: 'Description 3', content: 'Content 3' },
];

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

describe('Feed (Svelte)', () => {
  // 🔴 High Priority: APG ARIA Structure
  describe('APG: ARIA 構造', () => {
    it('コンテナに role="feed" がある', () => {
      render(Feed, { props: { articles: defaultArticles, 'aria-label': 'News Feed' } });
      expect(screen.getByRole('feed')).toBeInTheDocument();
    });

    it('各記事に role="article" がある', () => {
      render(Feed, { props: { articles: defaultArticles, 'aria-label': 'News Feed' } });
      const articles = screen.getAllByRole('article');
      expect(articles).toHaveLength(3);
    });

    it('フィードに aria-label がある', () => {
      render(Feed, { props: { articles: defaultArticles, 'aria-label': 'News Feed' } });
      const feed = screen.getByRole('feed');
      expect(feed).toHaveAttribute('aria-label', 'News Feed');
    });

    it('各記事に aria-labelledby がありタイトルを参照している', () => {
      render(Feed, { props: { articles: defaultArticles, 'aria-label': 'News Feed' } });
      const articles = screen.getAllByRole('article');

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

        const titleElement = document.getElementById(labelledby!);
        expect(titleElement).toBeInTheDocument();
      });
    });

    it('description 提供時に各記事に aria-describedby がある', () => {
      render(Feed, { props: { 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('aria-posinset が 1 から始まり連続している', () => {
      render(Feed, { props: { articles: fiveArticles, 'aria-label': 'News Feed' } });
      const articles = screen.getAllByRole('article');

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

    it('総数が既知の場合 aria-setsize に総数が設定される', () => {
      render(Feed, { props: { articles: fiveArticles, 'aria-label': 'News Feed' } });
      const articles = screen.getAllByRole('article');

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

    it('setSize が -1 の場合 aria-setsize に -1 が設定される', () => {
      render(Feed, { props: { articles: fiveArticles, 'aria-label': 'News Feed', setSize: -1 } });
      const articles = screen.getAllByRole('article');

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

  // 🔴 High Priority: Keyboard Interaction
  describe('APG: キーボード操作', () => {
    it('Page Down で次の記事にフォーカスが移動する', async () => {
      const user = userEvent.setup();
      render(Feed, { props: { articles: defaultArticles, 'aria-label': 'News Feed' } });

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

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

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

    it('Page Up で前の記事にフォーカスが移動する', async () => {
      const user = userEvent.setup();
      render(Feed, { props: { articles: defaultArticles, 'aria-label': 'News Feed' } });

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

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

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

    it('最初の記事で Page Up してもループしない', async () => {
      const user = userEvent.setup();
      render(Feed, { props: { articles: defaultArticles, 'aria-label': 'News Feed' } });

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

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

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

    it('最後の記事で Page Down してもループしない', async () => {
      const user = userEvent.setup();
      render(Feed, { props: { articles: defaultArticles, 'aria-label': 'News Feed' } });

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

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

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

  // 🔴 High Priority: Focus Management
  describe('APG: フォーカス管理', () => {
    it('記事要素が tabindex でフォーカス可能', () => {
      render(Feed, { props: { articles: defaultArticles, 'aria-label': 'News Feed' } });
      const articles = screen.getAllByRole('article');

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

    it('roving tabindex を使用(1つの記事のみ tabindex="0")', () => {
      render(Feed, { props: { 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('フォーカス移動時に tabindex が更新される', async () => {
      const user = userEvent.setup();
      render(Feed, { props: { 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('デフォルトで最初の記事が tabindex="0"', () => {
      render(Feed, { props: { 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: 動的読み込み', () => {
    it('デフォルトで aria-busy="false"', () => {
      render(Feed, { props: { articles: defaultArticles, 'aria-label': 'News Feed' } });
      const feed = screen.getByRole('feed');
      expect(feed).toHaveAttribute('aria-busy', 'false');
    });

    it('loading 時に aria-busy="true"', () => {
      render(Feed, {
        props: { articles: defaultArticles, 'aria-label': 'News Feed', loading: true },
      });
      const feed = screen.getByRole('feed');
      expect(feed).toHaveAttribute('aria-busy', 'true');
    });

    it('loading 完了後に aria-busy="false"', async () => {
      const { rerender } = render(Feed, {
        props: { articles: defaultArticles, 'aria-label': 'News Feed', loading: true },
      });

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

      await rerender({ articles: defaultArticles, 'aria-label': 'News Feed', loading: false });

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

  // 🟡 Medium Priority: Accessibility
  describe('アクセシビリティ', () => {
    it('axe による WCAG 2.1 AA 違反がない', async () => {
      const { container } = render(Feed, {
        props: { articles: defaultArticles, 'aria-label': 'News Feed' },
      });
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('loading 状態で axe 違反がない', async () => {
      const { container } = render(Feed, {
        props: { articles: defaultArticles, 'aria-label': 'News Feed', loading: true },
      });
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });
  });

  // 🟡 Medium Priority: Props & Events
  describe('Props & Events', () => {
    it('記事データから記事を描画する', () => {
      render(Feed, { props: { 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('フォーカス変更時に focuschange イベントが発火する', async () => {
      const handleFocusChange = vi.fn();
      const user = userEvent.setup();

      render(Feed, {
        props: {
          articles: defaultArticles,
          'aria-label': 'News Feed',
          onfocuschange: handleFocusChange,
        },
      });

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

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

      expect(handleFocusChange).toHaveBeenCalledWith({ articleId: 'article-2', index: 1 });
    });
  });

  // 🟢 Low Priority: HTML Attribute Inheritance
  describe('HTML 属性継承', () => {
    it('className をマージする', () => {
      const { container } = render(Feed, {
        props: { articles: defaultArticles, 'aria-label': 'News Feed', class: 'custom-feed' },
      });
      const feed = container.querySelector('[role="feed"]');
      expect(feed).toHaveClass('custom-feed');
    });
  });

  // Edge Cases
  describe('異常系', () => {
    it('空の記事配列を処理できる', () => {
      render(Feed, { props: { articles: [], 'aria-label': 'Empty Feed' } });
      const feed = screen.getByRole('feed');
      expect(feed).toBeInTheDocument();
      expect(screen.queryAllByRole('article')).toHaveLength(0);
    });

    it('単一記事を処理できる', () => {
      render(Feed, {
        props: {
          articles: [{ id: '1', title: 'Only Article', content: 'Content' }],
          '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');
    });
  });
});

リソース