Getting Started with Accessible Components
Learn the fundamentals of building accessible web components.
スクロールに応じて新しいコンテンツが追加される記事リストで、Page Up/Down キーで記事間をナビゲートできます。
🤖 AI 実装ガイドPage Down(次へ)と Page Up(前へ)で記事間を移動できます。Ctrl+End でフィードの後、Ctrl+Home でフィードの前にフォーカスを移動します。 下にスクロールすると自動的に記事が追加されます。
aria-busy: 新しい記事を読み込む際、スクリーンリーダーが不完全なコンテンツを読み上げないよう
aria-busy="true" が設定されます。読み込み完了後、false に戻ります。
Learn the fundamentals of building accessible web components.
Deep dive into the APG Feed pattern implementation.
Tips for implementing effective keyboard navigation.
| ロール | 対象要素 | 説明 |
|---|---|---|
feed | コンテナ要素 | スクロールでコンテンツが追加/削除される記事の動的リスト |
article | 各記事要素 | フィード内の独立したコンテンツアイテム |
WAI-ARIA APG Feed パターン (opens in new tab)
注意: Feed はウィジェットではなくストラクチャー
ウィジェットパターン(例: Listbox、Menu)とは異なり、Feed パターンはストラクチャーです。これは支援技術がフィードコンテンツをナビゲートする際にデフォルトの読み上げモードを使用できることを意味します。Feed ロールにより、ユーザーは Page Up/Down で記事間を移動しながら、各記事内では自然な読み上げが可能になります。
| 属性 | 対象 | 値 | 必須 | 説明 |
|---|---|---|---|---|
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 を使用してください。
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 が更新される |
| ラップなし | 最初から最後、または最後から最初の記事へのラップはしない |
| 記事内のコンテンツ | 記事内のインタラクティブ要素はキーボードでアクセス可能 |
<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}
/> | プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
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 | - | フォーカス変更時のコールバック |
interface FeedArticle {
id: string;
title: string;
description?: string;
content: string;
} テストは ARIA 構造、キーボードナビゲーション、フォーカス管理、動的読み込み状態の観点で APG 準拠を検証します。Feed コンポーネントは2層のテスト戦略を使用しています。
コンポーネントの HTML 出力と基本的なインタラクションを検証します。テンプレートレンダリングと ARIA 属性の正確性を確認します。
実際のブラウザ環境でコンポーネントの動作を検証します。JavaScript の実行が必要なインタラクションをカバーします。
| テスト | 説明 |
|---|---|
role="feed" | コンテナが feed ロールを持つ |
role="article" | 各アイテムが article ロールを持つ |
aria-label/labelledby (feed) | Feed コンテナがアクセシブルな名前を持つ |
aria-labelledby (article) | 各記事がタイトルを参照 |
aria-posinset | 1から始まる連番 |
aria-setsize | 総数または不明な場合は-1 |
| テスト | 説明 |
|---|---|
Page Down | 次の記事にフォーカスを移動 |
Page Up | 前の記事にフォーカスを移動 |
ラップなし | 最初/最後の記事でループしない |
Ctrl+End | フィードの後にフォーカスを移動 |
Ctrl+Home | フィードの前にフォーカスを移動 |
記事内から | 記事内要素からも Page Down が動作 |
| テスト | 説明 |
|---|---|
ローヴィング tabindex | 1つの記事のみが tabindex="0" |
tabindex 更新 | フォーカス移動時に tabindex が更新される |
初期状態 | 最初の記事がデフォルトで tabindex="0" |
| テスト | 説明 |
|---|---|
aria-busy="false" | 読み込み中でない場合のデフォルト状態 |
aria-busy="true" | 読み込み中に設定 |
フォーカス維持 | 読み込み中もフォーカスが維持される |
| テスト | 説明 |
|---|---|
axe 違反 | WCAG 2.1 AA 違反なし |
読み込み状態 | 読み込み中も axe 違反なし |
aria-describedby | 説明がある場合に存在 |
# すべての Feed ユニットテストを実行
npm run test:unit -- Feed
# フレームワーク別のテスト
npm run test:react -- Feed.test.tsx
npm run test:vue -- Feed.test.vue.ts
npm run test:svelte -- Feed.test.svelte.ts
npm run test:astro # すべての Feed E2E テストを実行
npm run test:e2e -- feed.spec.ts
# UI モードで実行
npm run test:e2e:ui -- feed.spec.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');
});
});
});