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.astro
---
/**
 * APG Feed Pattern - Astro Implementation
 *
 * 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.
 *
 * Uses Web Components for client-side keyboard navigation and infinite scroll.
 *
 * @see https://www.w3.org/WAI/ARIA/apg/patterns/feed/
 */

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

export interface Props {
  /** 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 */
  loading?: boolean;
  /** Additional CSS class */
  class?: string;
  /** Instance ID (optional, auto-generated if not provided) */
  id?: string;
  /** Test ID for E2E testing */
  'data-testid'?: string;
  /** Disable automatic infinite scroll (manual load only) */
  disableAutoLoad?: boolean;
  /** Intersection Observer root margin for triggering load (default: "200px") */
  loadMoreRootMargin?: string;
}

const {
  articles,
  'aria-label': ariaLabel,
  'aria-labelledby': ariaLabelledby,
  setSize,
  loading = false,
  class: className = '',
  id,
  'data-testid': testId,
  disableAutoLoad = false,
  loadMoreRootMargin = '200px',
} = Astro.props;

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

// Calculate set size
const computedSetSize = setSize !== undefined ? setSize : articles.length;
---

<apg-feed
  data-disable-auto-load={disableAutoLoad ? 'true' : undefined}
  data-load-more-root-margin={loadMoreRootMargin}
>
  <div
    role="feed"
    aria-label={ariaLabel}
    aria-labelledby={ariaLabelledby}
    aria-busy={loading}
    class:list={['apg-feed', className]}
    id={id}
    data-testid={testId}
  >
    {
      articles.map((article, index) => {
        const titleId = `${instanceId}-article-${article.id}-title`;
        const descId = article.description ? `${instanceId}-article-${article.id}-desc` : undefined;
        const isFirst = index === 0;

        return (
          <article
            role="article"
            class="apg-feed-article"
            tabindex={isFirst ? 0 : -1}
            aria-labelledby={titleId}
            aria-describedby={descId}
            aria-posinset={index + 1}
            aria-setsize={computedSetSize}
            data-article-id={article.id}
            data-article-index={index.toString()}
          >
            <h3 id={titleId}>
              <a href="#" class="apg-feed-article-title-link" onclick="event.preventDefault()">
                {article.title}
              </a>
            </h3>
            {article.description && <p id={descId}>{article.description}</p>}
            <div class="apg-feed-article-content">{article.content}</div>
          </article>
        );
      })
    }
    <!-- Sentinel element for infinite scroll detection -->
    {!disableAutoLoad && <div class="apg-feed-sentinel" aria-hidden="true" />}
  </div>

  <script>
    class ApgFeed extends HTMLElement {
      private feedElement: HTMLElement | null = null;
      private articles: HTMLElement[] = [];
      private focusedIndex = 0;
      private observer: IntersectionObserver | null = null;
      private sentinel: HTMLElement | null = null;
      private boundHandleKeyDown: ((event: KeyboardEvent) => void) | null = null;

      connectedCallback() {
        this.feedElement = this.querySelector('[role="feed"]');
        if (!this.feedElement) return;

        this.articles = Array.from(this.feedElement.querySelectorAll('[role="article"]'));
        if (this.articles.length === 0) return;

        // Set up event listeners (store bound function for cleanup)
        this.boundHandleKeyDown = this.handleKeyDown.bind(this);
        this.feedElement.addEventListener('keydown', this.boundHandleKeyDown);

        // Set up focus tracking
        this.articles.forEach((article, index) => {
          article.addEventListener('focus', () => this.handleArticleFocus(index));
        });

        // Set up Intersection Observer for infinite scroll
        this.setupIntersectionObserver();
      }

      disconnectedCallback() {
        // Clean up event listeners
        if (this.feedElement && this.boundHandleKeyDown) {
          this.feedElement.removeEventListener('keydown', this.boundHandleKeyDown);
        }
        // Clean up observer
        if (this.observer) {
          this.observer.disconnect();
        }
      }

      private setupIntersectionObserver() {
        const disableAutoLoad = this.dataset.disableAutoLoad === 'true';
        if (disableAutoLoad) return;

        this.sentinel = this.querySelector('.apg-feed-sentinel');
        if (!this.sentinel) return;

        const rootMargin = this.dataset.loadMoreRootMargin || '200px';

        this.observer = new IntersectionObserver(
          (entries) => {
            const entry = entries[0];
            const feedElement = this.feedElement;
            if (
              entry.isIntersecting &&
              feedElement &&
              feedElement.getAttribute('aria-busy') !== 'true'
            ) {
              // Dispatch custom event for infinite scroll
              this.dispatchEvent(
                new CustomEvent('feed:loadmore', {
                  bubbles: true,
                  composed: true,
                })
              );
            }
          },
          {
            rootMargin,
            threshold: 0,
          }
        );

        this.observer.observe(this.sentinel);
      }

      private handleKeyDown(event: KeyboardEvent) {
        const { target } = event;
        if (!(target instanceof HTMLElement)) return;

        let currentIndex = this.focusedIndex;

        // Find current article
        for (let i = 0; i < this.articles.length; i++) {
          const article = this.articles[i];
          if (article === target || article.contains(target)) {
            currentIndex = i;
            break;
          }
        }

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

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

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

          case 'Home':
            if (event.ctrlKey || event.metaKey) {
              event.preventDefault();
              this.focusOutsideFeed('before');
            }
            break;
        }
      }

      private focusArticle(index: number) {
        const article = this.articles[index];
        if (!article) return;

        // Update tabindex
        this.articles.forEach((a, i) => {
          a.setAttribute('tabindex', i === index ? '0' : '-1');
        });

        article.focus();
        this.focusedIndex = index;

        // Dispatch focus change event
        this.dispatchEvent(
          new CustomEvent('feed:focuschange', {
            bubbles: true,
            composed: true,
            detail: {
              articleId: article.dataset.articleId,
              index,
            },
          })
        );
      }

      private handleArticleFocus(index: number) {
        // Update tabindex
        this.articles.forEach((a, i) => {
          a.setAttribute('tabindex', i === index ? '0' : '-1');
        });
        this.focusedIndex = index;
      }

      private focusOutsideFeed(direction: 'before' | 'after') {
        if (!this.feedElement) return;

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

        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 (this.feedElement.contains(allFocusable[i]) || allFocusable[i] === this.feedElement) {
            if (feedStartIndex === -1) feedStartIndex = i;
            feedEndIndex = i;
          }
        }

        if (direction === 'before') {
          if (feedStartIndex > 0) {
            allFocusable[feedStartIndex - 1].focus();
          }
        } else {
          if (feedEndIndex >= 0 && feedEndIndex < allFocusable.length - 1) {
            allFocusable[feedEndIndex + 1].focus();
          }
        }
      }
    }

    customElements.define('apg-feed', ApgFeed);
  </script>
</apg-feed>

<style>
  /* Styles are in src/styles/patterns/feed.css */
  .apg-feed-sentinel {
    height: 1px;
    visibility: hidden;
  }
</style>

使い方

使用例
---
import Feed from './Feed.astro';

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

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

API

Feed Props

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

カスタムイベント

イベント 詳細 説明
feed:loadmore - 追加読み込み時にディスパッチ
feed:focuschange { articleId: string, index: number } フォーカス変更時にディスパッチ

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

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

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

  // Helper to render and parse HTML
  async function renderFeed(props: {
    articles: Array<{ id: string; title: string; description?: string; content: string }>;
    'aria-label'?: string;
    'aria-labelledby'?: string;
    setSize?: number;
    loading?: boolean;
    class?: string;
    id?: string;
  }): Promise<Document> {
    const html = await container.renderToString(Feed, { props });
    const dom = new JSDOM(html);
    return dom.window.document;
  }

  const basicArticles = [
    {
      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 = [
    { 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>' },
  ];

  // 🔴 High Priority: APG ARIA Structure
  describe('APG: ARIA Structure', () => {
    it('has role="feed" on container', async () => {
      const doc = await renderFeed({
        articles: basicArticles,
        'aria-label': 'News Feed',
      });
      const feed = doc.querySelector('[role="feed"]');
      expect(feed).not.toBeNull();
    });

    it('has role="article" on each article', async () => {
      const doc = await renderFeed({
        articles: basicArticles,
        'aria-label': 'News Feed',
      });
      const articles = doc.querySelectorAll('[role="article"]');
      expect(articles).toHaveLength(3);
    });

    it('has aria-label on feed when provided', async () => {
      const doc = await renderFeed({
        articles: basicArticles,
        'aria-label': 'News Feed',
      });
      const feed = doc.querySelector('[role="feed"]');
      expect(feed?.getAttribute('aria-label')).toBe('News Feed');
    });

    it('has aria-labelledby on feed when provided', async () => {
      const doc = await renderFeed({
        articles: basicArticles,
        'aria-labelledby': 'feed-title',
      });
      const feed = doc.querySelector('[role="feed"]');
      expect(feed?.getAttribute('aria-labelledby')).toBe('feed-title');
    });

    it('has aria-labelledby on each article referencing title', async () => {
      const doc = await renderFeed({
        articles: basicArticles,
        'aria-label': 'News Feed',
      });
      const articles = doc.querySelectorAll('[role="article"]');

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

        const titleElement = doc.getElementById(labelledby!);
        expect(titleElement).not.toBeNull();
      });
    });

    it('has aria-describedby on articles when description provided', async () => {
      const doc = await renderFeed({
        articles: basicArticles,
        'aria-label': 'News Feed',
      });
      const articles = doc.querySelectorAll('[role="article"]');

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

        const descElement = doc.getElementById(describedby!);
        expect(descElement).not.toBeNull();
      });
    });

    it('has aria-posinset starting from 1 and sequential', async () => {
      const doc = await renderFeed({
        articles: fiveArticles,
        'aria-label': 'News Feed',
      });
      const articles = doc.querySelectorAll('[role="article"]');

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

    it('has aria-setsize as total count when known', async () => {
      const doc = await renderFeed({
        articles: fiveArticles,
        'aria-label': 'News Feed',
      });
      const articles = doc.querySelectorAll('[role="article"]');

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

    it('has aria-setsize as -1 when setSize is -1', async () => {
      const doc = await renderFeed({
        articles: fiveArticles,
        'aria-label': 'News Feed',
        setSize: -1,
      });
      const articles = doc.querySelectorAll('[role="article"]');

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

    it('has aria-setsize as explicit value when provided', async () => {
      const doc = await renderFeed({
        articles: basicArticles,
        'aria-label': 'News Feed',
        setSize: 100,
      });
      const articles = doc.querySelectorAll('[role="article"]');

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

  // 🔴 High Priority: Focus Management
  describe('APG: Focus Management', () => {
    it('article elements have tabindex', async () => {
      const doc = await renderFeed({
        articles: basicArticles,
        'aria-label': 'News Feed',
      });
      const articles = doc.querySelectorAll('[role="article"]');

      articles.forEach((article) => {
        expect(article.hasAttribute('tabindex')).toBe(true);
      });
    });

    it('uses roving tabindex (only first article has tabindex="0")', async () => {
      const doc = await renderFeed({
        articles: basicArticles,
        'aria-label': 'News Feed',
      });
      const articles = doc.querySelectorAll('[role="article"]');

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

  // 🔴 High Priority: Dynamic Loading State
  describe('APG: Dynamic Loading', () => {
    it('has aria-busy="false" by default', async () => {
      const doc = await renderFeed({
        articles: basicArticles,
        'aria-label': 'News Feed',
      });
      const feed = doc.querySelector('[role="feed"]');
      expect(feed?.getAttribute('aria-busy')).toBe('false');
    });

    it('has aria-busy="true" when loading', async () => {
      const doc = await renderFeed({
        articles: basicArticles,
        'aria-label': 'News Feed',
        loading: true,
      });
      const feed = doc.querySelector('[role="feed"]');
      expect(feed?.getAttribute('aria-busy')).toBe('true');
    });
  });

  // 🟢 Low Priority: HTML Attributes
  describe('HTML Attributes', () => {
    it('applies class to container', async () => {
      const doc = await renderFeed({
        articles: basicArticles,
        'aria-label': 'News Feed',
        class: 'custom-feed',
      });
      const feed = doc.querySelector('[role="feed"]');
      expect(feed?.classList.contains('custom-feed')).toBe(true);
    });

    it('applies id to container', async () => {
      const doc = await renderFeed({
        articles: basicArticles,
        'aria-label': 'News Feed',
        id: 'my-feed',
      });
      const feed = doc.querySelector('[role="feed"]');
      expect(feed?.getAttribute('id')).toBe('my-feed');
    });
  });

  // Content Rendering
  describe('Content Rendering', () => {
    it('renders article titles', async () => {
      const doc = await renderFeed({
        articles: basicArticles,
        'aria-label': 'News Feed',
      });

      expect(doc.body.textContent).toContain('First Article');
      expect(doc.body.textContent).toContain('Second Article');
      expect(doc.body.textContent).toContain('Third Article');
    });

    it('renders article descriptions when provided', async () => {
      const doc = await renderFeed({
        articles: basicArticles,
        'aria-label': 'News Feed',
      });

      expect(doc.body.textContent).toContain('Description 1');
      expect(doc.body.textContent).toContain('Description 2');
      expect(doc.body.textContent).toContain('Description 3');
    });

    it('renders article content', async () => {
      const doc = await renderFeed({
        articles: basicArticles,
        'aria-label': 'News Feed',
      });

      expect(doc.body.innerHTML).toContain('Content 1');
      expect(doc.body.innerHTML).toContain('Content 2');
      expect(doc.body.innerHTML).toContain('Content 3');
    });
  });

  // Edge Cases
  describe('Edge Cases', () => {
    it('handles empty articles array', async () => {
      const doc = await renderFeed({
        articles: [],
        'aria-label': 'Empty Feed',
      });

      const feed = doc.querySelector('[role="feed"]');
      expect(feed).not.toBeNull();

      const articles = doc.querySelectorAll('[role="article"]');
      expect(articles).toHaveLength(0);
    });

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

      const articles = doc.querySelectorAll('[role="article"]');
      expect(articles).toHaveLength(1);
      expect(articles[0]?.getAttribute('aria-posinset')).toBe('1');
      expect(articles[0]?.getAttribute('aria-setsize')).toBe('1');
    });

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

      const article = doc.querySelector('[role="article"]');
      // Should not have aria-describedby when no description
      expect(article?.hasAttribute('aria-describedby')).toBe(false);
    });
  });
});

リソース