🪗 Accordion
A vertically stacked set of interactive headings that each reveal a section of content.
スクロールに応じて新しいコンテンツが追加される記事リストで、Page Up/Down キーで記事間をナビゲートできます。
A vertically stacked set of interactive headings that each reveal a section of content.
An element that displays a brief, important message in a way that attracts the user's attention without interrupting the user's task.
A modal dialog that interrupts the user's workflow to communicate an important message and acquire a response.
| ロール | 対象要素 | 説明 |
|---|---|---|
feed | コンテナ要素 | スクロールでコンテンツが追加/削除される記事の動的リスト |
article | 各記事要素 | フィード内の独立したコンテンツアイテム |
aria-labelフィードのアクセシブルな名前(条件付き*)
aria-labelledbyフィードの可視見出しを参照(条件付き*)
aria-labelledby記事タイトル要素を参照
aria-describedby記事の説明またはコンテンツを参照(推奨)
aria-posinsetフィード内の記事の位置(1から開始)
aria-setsizeフィード内の総記事数、不明な場合は-1
aria-busy読み込み開始(true)、読み込み完了(false)。フィードが新しいコンテンツを読み込み中であることを示します。スクリーンリーダーは読み込みが完了するまで変更の通知を待機します。
| キー | アクション |
|---|---|
| Page Down | フォーカスをフィード内の次の記事に移動 |
| Page Up | フォーカスをフィード内の前の記事に移動 |
| Ctrl + End | フォーカスをフィードの後の最初のフォーカス可能要素に移動 |
| Ctrl + Home | フォーカスをフィードの前の最初のフォーカス可能要素に移動 |
| イベント | 振る舞い |
|---|---|
| ローヴィング tabindex | 1つの記事のみが tabindex="0"、他は tabindex="-1" |
| 初期フォーカス | デフォルトで最初の記事が tabindex="0" |
| フォーカス追跡 | 記事間のフォーカス移動に伴い tabindex が更新される |
| ラップなし | 最初から最後、または最後から最初の記事へのラップはしない |
| 記事内のコンテンツ | 記事内のインタラクティブ要素はキーボードでアクセス可能 |
<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`">
{{ article.title }}
</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> <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> | プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
articles | FeedArticle[] | required | 記事アイテムの配列 |
aria-label | string | conditional | アクセシブルな名前(aria-labelledby がない場合必須) |
aria-labelledby | string | conditional | 可視見出しへの ID 参照 |
setSize | number | articles.length | 総数、または不明な場合は -1 |
loading | boolean | false | 読み込み状態(aria-busy を設定) |
| イベント | Detail | 説明 |
|---|---|---|
load-more | - | 追加読み込み時に発火 |
focus-change | [articleId: string, index: number] | フォーカス変更時に発火 |
テストは 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 | 前の記事にフォーカスを移動 |
No wrap | 最初/最後の記事でループしない |
Ctrl+End | フィードの後にフォーカスを移動 |
Ctrl+Home | フィードの前にフォーカスを移動 |
Inside article | 記事内要素からも Page Down が動作 |
| テスト | 説明 |
|---|---|
Roving tabindex | 1つの記事のみが tabindex="0" |
tabindex update | フォーカス移動時に tabindex が更新される |
Initial state | 最初の記事がデフォルトで tabindex="0" |
| テスト | 説明 |
|---|---|
aria-busy="false" | 読み込み中でない場合のデフォルト状態 |
aria-busy="true" | 読み込み中に設定 |
Focus maintenance | 読み込み中もフォーカスが維持される |
| テスト | 説明 |
|---|---|
axe violations | WCAG 2.1 AA 違反なし |
Loading state | 読み込み中も axe 違反なし |
aria-describedby | 説明がある場合に存在 |
# すべての Feed ユニットテストを実行
npm run test:unit -- Feed
# フレームワーク別のテスト
npm run test:react -- Feed.test.tsx
# すべての Feed E2E テストを実行
npm run test:e2e -- feed.spec.ts
# UI モードで実行
npm run test:e2e:ui -- feed.spec.ts