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 パターンを見る: /ja/patterns/accordion/astro/

⚠️ 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 パターンを見る: /ja/patterns/alert/astro/

🚨 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 パターンを見る: /ja/patterns/alert-dialog/astro/

デモのみ表示 →

アクセシビリティ

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.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}>{article.title}</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 前の記事にフォーカスを移動
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.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);
    });
  });
});

リソース