APG Patterns
日本語 GitHub
日本語 GitHub

Feed

A scrollable list of articles where new content may be added as the user scrolls, enabling keyboard navigation between articles using Page Up/Down.

🤖 AI Implementation Guide

Demo

Navigate articles using Page Down (next) and Page Up (previous). Use Ctrl+End to move focus after the feed, Ctrl+Home to move focus before the feed. Scroll to the bottom to automatically load more articles.

aria-busy: When loading new articles, aria-busy="true" is set on the feed to prevent screen readers from announcing incomplete content. Once loading completes, it's set back to 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.

Open demo only →

Accessibility Features

WAI-ARIA Roles

Role Target Element Description
feed Container element A dynamic list of articles where scrolling may add/remove content
article Each article element Independent content item within the feed

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

Note: Feed is a Structure, Not a Widget

Unlike widget patterns (e.g., Listbox, Menu), the feed pattern is a structure. This means assistive technologies can use their default reading mode when navigating feed content. The feed role enables users to navigate articles using Page Up/Down while still allowing natural reading within each article.

WAI-ARIA Properties

Attribute Target Values Required Description
aria-label Feed container Text Conditional* Accessible name for the feed
aria-labelledby Feed container ID reference Conditional* References visible heading for the feed
aria-labelledby Each article ID reference Yes References the article title element
aria-describedby Each article ID reference Recommended References the article description or content
aria-posinset Each article Number (1-based) Yes Position of article in the feed (starts at 1)
aria-setsize Each article Number or -1 Yes Total articles in feed, or -1 if unknown

* Either aria-label or aria-labelledby is required on the feed container. Use aria-labelledby when a visible heading exists.

WAI-ARIA States

aria-busy

Indicates when the feed is loading new content. Screen readers will wait to announce changes until loading completes.

Target Feed container
Values true | false
Required Conditional (when loading occurs)
Change Trigger Loading starts (true), loading completes (false)
Reference aria-busy (opens in new tab)

Note: Set to true when adding multiple articles to prevent premature announcements. Set to false after all DOM updates are complete.

Keyboard Support

Key Action
Page Down Move focus to next article in the feed
Page Up Move focus to previous article in the feed
Ctrl + End Move focus to first focusable element after the feed
Ctrl + Home Move focus to first focusable element before the feed

Why Page Up/Down instead of Arrow keys? Feeds contain long-form content like articles. Using Page Up/Down allows users to navigate between articles while Arrow keys remain available for reading within articles.

Important: Keyboard Documentation

The Feed pattern uses Page Up/Down for navigation, which differs from common widget patterns that use Arrow keys. As the APG specification (opens in new tab) notes: "Due to the lack of convention, providing easily discoverable keyboard interface documentation is especially important." Always display keyboard hints near the feed to help users discover these navigation options.

Focus Management

Behavior Description
Roving tabindex Only one article has tabindex="0", others have tabindex="-1"
Initial focus First article has tabindex="0" by default
Focus tracking tabindex updates as focus moves between articles
No wrap Focus does not wrap from first to last article or vice versa
Content inside articles Interactive elements inside articles remain keyboard accessible

Source Code

Feed.vue
<template>
  <div
    ref="containerRef"
    role="feed"
    :aria-label="ariaLabel"
    :aria-labelledby="ariaLabelledby"
    :aria-busy="loading"
    :class="['apg-feed', props.class].filter(Boolean)"
    @keydown="handleKeyDown"
  >
    <article
      v-for="(article, index) in articles"
      :key="article.id"
      :ref="(el) => setArticleRef(index, el)"
      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"
      @focus="handleArticleFocus(index)"
    >
      <h3 :id="`${baseId}-article-${article.id}-title`">
        <a href="#" class="apg-feed-article-title-link" @click.prevent>{{ article.title }}</a>
      </h3>
      <p v-if="article.description" :id="`${baseId}-article-${article.id}-desc`">
        {{ article.description }}
      </p>
      <div class="apg-feed-article-content">{{ article.content }}</div>
    </article>
    <!-- Sentinel element for infinite scroll detection -->
    <div
      v-if="!disableAutoLoad"
      ref="sentinelRef"
      aria-hidden="true"
      style="height: 1px; visibility: hidden"
    />
  </div>
</template>

<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';

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

/**
 * Feed component props
 */
export interface FeedProps {
  /** Array of article data */
  articles: FeedArticle[];
  /** Accessible name for the feed (mutually exclusive with ariaLabelledby) */
  ariaLabel?: string;
  /** ID reference to visible label (mutually exclusive with ariaLabel) */
  ariaLabelledby?: 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;
  /** Disable automatic infinite scroll (manual load only) */
  disableAutoLoad?: boolean;
  /** Intersection Observer root margin for triggering load (default: "200px") */
  loadMoreRootMargin?: string;
}

const props = withDefaults(defineProps<FeedProps>(), {
  loading: false,
  disableAutoLoad: false,
  loadMoreRootMargin: '200px',
});

const emit = defineEmits<{
  /**
   * Emitted when focus changes between articles
   * @param articleId - ID of the focused article
   * @param index - Index of the focused article (0-based)
   */
  focusChange: [articleId: string, index: number];
  /**
   * Emitted when more content should be loaded (called automatically on scroll)
   */
  loadMore: [];
}>();

// Generate unique base ID
const baseId = ref('');
onMounted(() => {
  baseId.value = `feed-${Math.random().toString(36).slice(2, 9)}`;
});

// Refs
const containerRef = ref<HTMLDivElement | null>(null);
const articleRefs = ref<(HTMLElement | null)[]>([]);
const sentinelRef = ref<HTMLDivElement | null>(null);

// State
const focusedIndex = ref(0);

// Intersection Observer
let observer: IntersectionObserver | null = null;

const setupObserver = () => {
  if (props.disableAutoLoad || !sentinelRef.value) return;

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

  observer.observe(sentinelRef.value);
};

const cleanupObserver = () => {
  if (observer) {
    observer.disconnect();
    observer = null;
  }
};

onMounted(() => {
  setupObserver();
});

onUnmounted(() => {
  cleanupObserver();
});

// Re-setup observer when relevant props change
watch(
  () => [props.disableAutoLoad, props.loadMoreRootMargin],
  () => {
    cleanupObserver();
    setupObserver();
  }
);

// Computed
const computedSetSize = computed(() =>
  props.setSize !== undefined ? props.setSize : props.articles.length
);

// Set article ref
const setArticleRef = (index: number, el: unknown) => {
  if (el instanceof HTMLElement) {
    articleRefs.value[index] = el;
  }
};

// Focus an article by index
const focusArticle = (index: number) => {
  const article = articleRefs.value[index];
  if (article) {
    article.focus();
    focusedIndex.value = index;
    if (props.articles[index]) {
      emit('focusChange', props.articles[index].id, index);
    }
  }
};

// Find focusable element outside the feed
const focusOutsideFeed = (direction: 'before' | 'after') => {
  const feedElement = containerRef.value;
  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 = (event: KeyboardEvent) => {
  // Find which article (or element inside article) has focus
  const { target } = event;
  if (!(target instanceof HTMLElement)) return;

  let currentIndex = focusedIndex.value;

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

  switch (event.key) {
    case 'PageDown':
      event.preventDefault();
      if (currentIndex < props.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
const handleArticleFocus = (index: number) => {
  focusedIndex.value = index;
  if (props.articles[index]) {
    emit('focusChange', props.articles[index].id, index);
  }
};
</script>

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

Usage

Example
<script setup lang="ts">
import Feed from './Feed.vue';

const articles = [
  {
    id: 'article-1',
    title: 'Getting Started with Vue',
    description: 'Learn the basics of Vue development',
    content: 'Full article content here...'
  },
  {
    id: 'article-2',
    title: 'Advanced Patterns',
    description: 'Explore advanced Vue patterns',
    content: 'Full article content here...'
  }
];

function handleLoadMore() {
  console.log('Load more articles');
}

function handleFocusChange(id: string, index: number) {
  console.log('Focused:', id, index);
}
</script>

<template>
  <Feed
    :articles="articles"
    aria-label="Blog posts"
    :set-size="-1"
    :loading="false"
    @load-more="handleLoadMore"
    @focus-change="handleFocusChange"
  />
</template>

API

Feed Props

Prop Type Default Description
articles FeedArticle[] required Array of article items
aria-label string conditional Accessible name (required if no aria-labelledby)
aria-labelledby string conditional ID reference to visible heading
setSize number articles.length Total count or -1 if unknown
loading boolean false Loading state (sets aria-busy)

Events

Event Payload Description
load-more - Emitted to load more articles
focus-change [articleId: string, index: number] Emitted when focus changes

FeedArticle Interface

Types
interface FeedArticle {
  id: string;
  title: string;
  description?: string;
  content: string;
}

Testing

Tests verify APG compliance across ARIA structure, keyboard navigation, focus management, and dynamic loading states. The Feed component uses a two-layer testing strategy.

Testing Strategy

Unit Tests (Container API / Testing Library)

Verify the component's HTML output and basic interactions. These tests ensure correct template rendering and ARIA attributes.

  • HTML structure with feed/article roles
  • ARIA attributes (aria-labelledby, aria-posinset, aria-setsize)
  • Initial tabindex values (roving tabindex)
  • Dynamic loading state (aria-busy)
  • CSS class application

E2E Tests (Playwright)

Verify component behavior in a real browser environment. These tests cover interactions that require JavaScript execution.

  • Page Up/Down navigation between articles
  • Ctrl+Home/End for escaping the feed
  • Focus management and tabindex updates
  • Navigation from inside article elements

Test Categories

High Priority: ARIA Structure

Test Description
role="feed" Container has feed role
role="article" Each item has article role
aria-label/labelledby (feed) Feed container has accessible name
aria-labelledby (article) Each article references its title
aria-posinset Sequential starting from 1
aria-setsize Total count or -1 if unknown

High Priority: Keyboard Interaction

Test Description
Page Down Moves focus to next article
Page Up Moves focus to previous article
No wrap Does not loop at first/last article
Ctrl+End Moves focus after the feed
Ctrl+Home Moves focus before the feed
Inside article Page Down works from inside article elements

High Priority: Focus Management

Test Description
Roving tabindex Only one article has tabindex="0"
tabindex update tabindex updates when focus moves
Initial state First article has tabindex="0" by default

High Priority: Dynamic Loading

Test Description
aria-busy="false" Default state when not loading
aria-busy="true" Set during loading
Focus maintenance Focus maintained during loading

Medium Priority: Accessibility

Test Description
axe violations No WCAG 2.1 AA violations
Loading state No axe violations during loading
aria-describedby Present when description provided

Running Tests

Unit Tests

# Run all Feed unit tests
npm run test:unit -- Feed

# Run framework-specific tests
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 Tests

# Run all Feed E2E tests
npm run test:e2e -- feed.spec.ts

# Run in UI mode
npm run test:e2e:ui -- feed.spec.ts
Feed.test.vue.ts
import { render, screen } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import Feed from './Feed.vue';
import type { FeedArticle } from './Feed.vue';

// テスト用記事データ
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' },
];

// Ctrl+Home/End テスト用のヘルパー
const renderWithSurroundingElements = (props: Record<string, unknown>) => {
  return render({
    components: { Feed },
    template: `
      <div>
        <button data-testid="before-feed">Before Feed</button>
        <Feed v-bind="props" />
        <button data-testid="after-feed">After Feed</button>
      </div>
    `,
    setup() {
      return { props };
    },
  });
};

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

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

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

    it('フィードに aria-labelledby がある(提供時)', () => {
      render({
        components: { Feed },
        template: `
          <div>
            <h2 id="feed-title">Latest News</h2>
            <Feed :articles="articles" aria-labelledby="feed-title" />
          </div>
        `,
        setup() {
          return { articles: defaultArticles };
        },
      });
      const feed = screen.getByRole('feed');
      expect(feed).toHaveAttribute('aria-labelledby', 'feed-title');
    });

    it('各記事に aria-labelledby がありタイトルを参照している', () => {
      render(Feed, { props: { articles: defaultArticles, ariaLabel: '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, ariaLabel: '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, ariaLabel: '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, ariaLabel: '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, ariaLabel: '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, ariaLabel: '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, ariaLabel: '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, ariaLabel: '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, ariaLabel: 'News Feed' } });

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

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

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

    it('Ctrl+End でフィード後の要素にフォーカスが移動する', async () => {
      const user = userEvent.setup();
      renderWithSurroundingElements({
        articles: defaultArticles,
        ariaLabel: '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('Ctrl+Home でフィード前の要素にフォーカスが移動する', async () => {
      const user = userEvent.setup();
      renderWithSurroundingElements({
        articles: defaultArticles,
        ariaLabel: '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: フォーカス管理', () => {
    it('記事要素が tabindex で フォーカス可能', () => {
      render(Feed, { props: { articles: defaultArticles, ariaLabel: '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, ariaLabel: '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, ariaLabel: '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');
    });
  });

  // 🔴 High Priority: Dynamic Loading
  describe('APG: 動的読み込み', () => {
    it('デフォルトで aria-busy="false"', () => {
      render(Feed, { props: { articles: defaultArticles, ariaLabel: 'News Feed' } });
      const feed = screen.getByRole('feed');
      expect(feed).toHaveAttribute('aria-busy', 'false');
    });

    it('loading 時に aria-busy="true"', () => {
      render(Feed, { props: { articles: defaultArticles, ariaLabel: '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, ariaLabel: 'News Feed', loading: true },
      });

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

      await rerender({ articles: defaultArticles, ariaLabel: '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, ariaLabel: 'News Feed' },
      });
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('loading 状態で axe 違反がない', async () => {
      const { container } = render(Feed, {
        props: { articles: defaultArticles, ariaLabel: '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, ariaLabel: '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, ariaLabel: 'News Feed' },
        attrs: { 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 属性継承', () => {
    it('className をマージする', () => {
      const { container } = render(Feed, {
        props: { articles: defaultArticles, ariaLabel: 'News Feed', class: 'custom-feed' },
      });
      const feed = container.querySelector('[role="feed"]');
      expect(feed).toHaveClass('custom-feed');
    });
  });

  // Edge Cases
  describe('異常系', () => {
    it('空の記事配列を処理できる', () => {
      render(Feed, { props: { articles: [], ariaLabel: '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' }],
          ariaLabel: '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');
    });
  });
});

Resources