Understanding the Feed Pattern
Deep dive into the APG Feed pattern implementation.
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 GuideNavigate 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.
Deep dive into the APG Feed pattern implementation.
Tips for implementing effective keyboard navigation.
Understanding ARIA roles, states, and properties.
| 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.
| 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.
aria-busyIndicates 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.
| 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.
| 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 |
import { useCallback, useEffect, useId, useRef, useState } from 'react';
/**
* 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 (React elements are safe from XSS) */
content: React.ReactNode;
}
/**
* Feed component props
*/
export interface FeedProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'role'> {
/** 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;
/** Callback when more content should be loaded (called automatically on scroll) */
onLoadMore?: () => void;
/**
* Callback when focus changes between articles
* @param articleId - ID of the focused article
* @param index - Index of the focused article (0-based)
*/
onFocusChange?: (articleId: string, index: number) => void;
/** Disable automatic infinite scroll (manual load only) */
disableAutoLoad?: boolean;
/** Intersection Observer root margin for triggering load (default: "200px") */
loadMoreRootMargin?: string;
}
/**
* Feed Pattern Component
*
* 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.
*
* @see https://www.w3.org/WAI/ARIA/apg/patterns/feed/
*
* Features:
* - Page Up/Down navigation between articles (not Arrow keys)
* - Ctrl+Home/End to escape the feed
* - Roving tabindex on articles
* - aria-busy during dynamic loading
* - aria-posinset/aria-setsize on each article
*/
export function Feed({
articles,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledby,
setSize,
loading = false,
onLoadMore,
onFocusChange,
disableAutoLoad = false,
loadMoreRootMargin = '200px',
className,
...rest
}: FeedProps) {
const baseId = useId();
const containerRef = useRef<HTMLDivElement>(null);
const articleRefs = useRef<(HTMLElement | null)[]>([]);
const sentinelRef = useRef<HTMLDivElement>(null);
const [focusedIndex, setFocusedIndex] = useState(0);
// Warn if no accessible name is provided
useEffect(() => {
if (!ariaLabel && !ariaLabelledby) {
console.warn(
'Feed: An accessible name is required. ' +
'Provide either aria-label or aria-labelledby prop.'
);
}
}, [ariaLabel, ariaLabelledby]);
// Calculate aria-setsize
const computedSetSize = setSize !== undefined ? setSize : articles.length;
// Intersection Observer for infinite scroll
useEffect(() => {
if (disableAutoLoad || !onLoadMore || !sentinelRef.current) return;
const sentinel = sentinelRef.current;
const observer = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (entry.isIntersecting && !loading) {
onLoadMore();
}
},
{
rootMargin: loadMoreRootMargin,
threshold: 0,
}
);
observer.observe(sentinel);
return () => {
observer.disconnect();
};
}, [disableAutoLoad, loading, onLoadMore, loadMoreRootMargin]);
// Focus an article by index
const focusArticle = useCallback(
(index: number) => {
const article = articleRefs.current[index];
if (article) {
article.focus();
setFocusedIndex(index);
if (onFocusChange && articles[index]) {
onFocusChange(articles[index].id, index);
}
}
},
[articles, onFocusChange]
);
// Find focusable element outside the feed
const focusOutsideFeed = useCallback((direction: 'before' | 'after') => {
const feedElement = containerRef.current;
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 = useCallback(
(event: React.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.current.length; i++) {
const article = articleRefs.current[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;
}
},
[articles.length, focusArticle, focusOutsideFeed, focusedIndex]
);
// Handle focus on article
const handleArticleFocus = useCallback(
(index: number) => {
setFocusedIndex(index);
if (onFocusChange && articles[index]) {
onFocusChange(articles[index].id, index);
}
},
[articles, onFocusChange]
);
return (
<div
ref={containerRef}
role="feed"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
aria-busy={loading}
className={['apg-feed', className].filter(Boolean).join(' ')}
onKeyDown={handleKeyDown}
{...rest}
>
{articles.map((article, index) => {
const titleId = `${baseId}-article-${article.id}-title`;
const descId = article.description ? `${baseId}-article-${article.id}-desc` : undefined;
return (
<article
key={article.id}
ref={(el) => {
articleRefs.current[index] = el;
}}
className="apg-feed-article"
tabIndex={index === focusedIndex ? 0 : -1}
aria-labelledby={titleId}
aria-describedby={descId}
aria-posinset={index + 1}
aria-setsize={computedSetSize}
onFocus={() => handleArticleFocus(index)}
>
<h3 id={titleId}>
<a
href="#"
className="apg-feed-article-title-link"
onClick={(e) => e.preventDefault()}
>
{article.title}
</a>
</h3>
{article.description && <p id={descId}>{article.description}</p>}
<div className="apg-feed-article-content">{article.content}</div>
</article>
);
})}
{/* Sentinel element for infinite scroll detection */}
{onLoadMore && !disableAutoLoad && (
<div ref={sentinelRef} aria-hidden="true" style={{ height: '1px', visibility: 'hidden' }} />
)}
</div>
);
}
export default Feed; import { Feed } from './Feed';
const articles = [
{
id: 'article-1',
title: 'Getting Started with React',
description: 'Learn the basics of React development',
content: <p>Full article content here...</p>
},
{
id: 'article-2',
title: 'Advanced Patterns',
description: 'Explore advanced React patterns',
content: <p>Full article content here...</p>
}
];
function App() {
return (
<Feed
articles={articles}
aria-label="Blog posts"
setSize={-1}
loading={false}
onLoadMore={() => console.log('Load more articles')}
onFocusChange={(id, index) => console.log('Focused:', id, index)}
/>
);
} | 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) |
onLoadMore | () => void | - | Callback to load more articles |
onFocusChange | (articleId: string, index: number) => void | - | Callback when focus changes |
interface FeedArticle {
id: string;
title: string;
description?: string;
content: React.ReactNode;
} Tests verify APG compliance across ARIA structure, keyboard navigation, focus management, and dynamic loading states. The Feed component uses a two-layer testing strategy.
Verify the component's HTML output and basic interactions. These tests ensure correct template rendering and ARIA attributes.
Verify component behavior in a real browser environment. These tests cover interactions that require JavaScript execution.
| 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 |
| 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 |
| 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 |
| Test | Description |
|---|---|
aria-busy="false" | Default state when not loading |
aria-busy="true" | Set during loading |
Focus maintenance | Focus maintained during loading |
| Test | Description |
|---|---|
axe violations | No WCAG 2.1 AA violations |
Loading state | No axe violations during loading |
aria-describedby | Present when description provided |
# 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 # Run all Feed E2E tests
npm run test:e2e -- feed.spec.ts
# Run in UI mode
npm run test:e2e:ui -- feed.spec.ts import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import { Feed, type FeedArticle } from './Feed';
// Test article data
const defaultArticles: FeedArticle[] = [
{
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: FeedArticle[] = [
{ 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> },
];
// Helper to render Feed with focusable elements before/after for Ctrl+Home/End tests
const renderWithSurroundingElements = (props: React.ComponentProps<typeof Feed>) => {
return render(
<div>
<button data-testid="before-feed">Before Feed</button>
<Feed {...props} />
<button data-testid="after-feed">After Feed</button>
</div>
);
};
describe('Feed', () => {
// 🔴 High Priority: APG ARIA Structure
describe('APG: ARIA Structure', () => {
it('has role="feed" on container', () => {
render(<Feed articles={defaultArticles} aria-label="News Feed" />);
expect(screen.getByRole('feed')).toBeInTheDocument();
});
it('has role="article" on each article', () => {
render(<Feed articles={defaultArticles} aria-label="News Feed" />);
const articles = screen.getAllByRole('article');
expect(articles).toHaveLength(3);
});
it('has aria-label on feed when provided', () => {
render(<Feed articles={defaultArticles} aria-label="News Feed" />);
const feed = screen.getByRole('feed');
expect(feed).toHaveAttribute('aria-label', 'News Feed');
});
it('has aria-labelledby on feed when provided', () => {
render(
<div>
<h2 id="feed-title">Latest News</h2>
<Feed articles={defaultArticles} aria-labelledby="feed-title" />
</div>
);
const feed = screen.getByRole('feed');
expect(feed).toHaveAttribute('aria-labelledby', 'feed-title');
});
it('warns when feed has no accessible name', () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
render(<Feed articles={defaultArticles} />);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('accessible name'));
consoleSpy.mockRestore();
});
it('has aria-labelledby on each article referencing title', () => {
render(<Feed articles={defaultArticles} aria-label="News Feed" />);
const articles = screen.getAllByRole('article');
articles.forEach((article) => {
const labelledby = article.getAttribute('aria-labelledby');
expect(labelledby).toBeTruthy();
// Verify the referenced element exists and contains the title
const titleElement = document.getElementById(labelledby!);
expect(titleElement).toBeInTheDocument();
});
});
it('has aria-describedby on articles when description provided', () => {
render(<Feed 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('has aria-posinset starting from 1 and sequential', () => {
render(<Feed articles={fiveArticles} aria-label="News Feed" />);
const articles = screen.getAllByRole('article');
articles.forEach((article, index) => {
expect(article).toHaveAttribute('aria-posinset', String(index + 1));
});
});
it('has aria-setsize as total count when known', () => {
render(<Feed articles={fiveArticles} aria-label="News Feed" />);
const articles = screen.getAllByRole('article');
articles.forEach((article) => {
expect(article).toHaveAttribute('aria-setsize', '5');
});
});
it('has aria-setsize as -1 when setSize is -1 (unknown)', () => {
render(<Feed articles={fiveArticles} aria-label="News Feed" setSize={-1} />);
const articles = screen.getAllByRole('article');
articles.forEach((article) => {
expect(article).toHaveAttribute('aria-setsize', '-1');
});
});
it('has aria-setsize as explicit value when provided', () => {
render(<Feed articles={defaultArticles} aria-label="News Feed" setSize={100} />);
const articles = screen.getAllByRole('article');
articles.forEach((article) => {
expect(article).toHaveAttribute('aria-setsize', '100');
});
});
});
// 🔴 High Priority: Keyboard Interaction
describe('APG: Keyboard Interaction', () => {
it('moves focus to next article on Page Down', async () => {
const user = userEvent.setup();
render(<Feed articles={defaultArticles} aria-label="News Feed" />);
const articles = screen.getAllByRole('article');
articles[0].focus();
await user.keyboard('{PageDown}');
expect(articles[1]).toHaveFocus();
});
it('moves focus to previous article on Page Up', async () => {
const user = userEvent.setup();
render(<Feed articles={defaultArticles} aria-label="News Feed" />);
const articles = screen.getAllByRole('article');
articles[1].focus();
await user.keyboard('{PageUp}');
expect(articles[0]).toHaveFocus();
});
it('does not loop at first article on Page Up', async () => {
const user = userEvent.setup();
render(<Feed articles={defaultArticles} aria-label="News Feed" />);
const articles = screen.getAllByRole('article');
articles[0].focus();
await user.keyboard('{PageUp}');
expect(articles[0]).toHaveFocus(); // Still on first
});
it('does not loop at last article on Page Down', async () => {
const user = userEvent.setup();
render(<Feed articles={defaultArticles} aria-label="News Feed" />);
const articles = screen.getAllByRole('article');
articles[2].focus();
await user.keyboard('{PageDown}');
expect(articles[2]).toHaveFocus(); // Still on last
});
it('moves focus to next article even when focus is inside article element', async () => {
const user = userEvent.setup();
render(
<Feed
articles={[
{ id: '1', title: 'Article 1', content: <button>Inside Button 1</button> },
{ id: '2', title: 'Article 2', content: <button>Inside Button 2</button> },
]}
aria-label="News Feed"
/>
);
// Focus on button inside first article
const insideButton = screen.getByRole('button', { name: 'Inside Button 1' });
insideButton.focus();
await user.keyboard('{PageDown}');
const articles = screen.getAllByRole('article');
expect(articles[1]).toHaveFocus();
});
it('moves focus outside feed (after) on Ctrl+End', async () => {
const user = userEvent.setup();
renderWithSurroundingElements({
articles: defaultArticles,
'aria-label': '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('moves focus outside feed (before) on Ctrl+Home', async () => {
const user = userEvent.setup();
renderWithSurroundingElements({
articles: defaultArticles,
'aria-label': '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: Focus Management', () => {
it('article elements are focusable with tabindex', () => {
render(<Feed articles={defaultArticles} aria-label="News Feed" />);
const articles = screen.getAllByRole('article');
articles.forEach((article) => {
expect(article).toHaveAttribute('tabindex');
});
});
it('uses roving tabindex (only one article has tabindex="0")', () => {
render(<Feed 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('updates tabindex when focus moves', async () => {
const user = userEvent.setup();
render(<Feed 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('first article has tabindex="0" by default', () => {
render(<Feed 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: Dynamic Loading', () => {
it('has aria-busy="false" by default', () => {
render(<Feed articles={defaultArticles} aria-label="News Feed" />);
const feed = screen.getByRole('feed');
expect(feed).toHaveAttribute('aria-busy', 'false');
});
it('sets aria-busy="true" during loading', () => {
render(<Feed articles={defaultArticles} aria-label="News Feed" loading />);
const feed = screen.getByRole('feed');
expect(feed).toHaveAttribute('aria-busy', 'true');
});
it('sets aria-busy="false" after loading complete', () => {
const { rerender } = render(
<Feed articles={defaultArticles} aria-label="News Feed" loading />
);
expect(screen.getByRole('feed')).toHaveAttribute('aria-busy', 'true');
rerender(<Feed articles={defaultArticles} aria-label="News Feed" loading={false} />);
expect(screen.getByRole('feed')).toHaveAttribute('aria-busy', 'false');
});
it('updates aria-posinset/aria-setsize when articles are added', () => {
const { rerender } = render(<Feed articles={defaultArticles} aria-label="News Feed" />);
let articles = screen.getAllByRole('article');
expect(articles).toHaveLength(3);
expect(articles[2]).toHaveAttribute('aria-setsize', '3');
// Add more articles
rerender(<Feed articles={fiveArticles} aria-label="News Feed" />);
articles = screen.getAllByRole('article');
expect(articles).toHaveLength(5);
articles.forEach((article, index) => {
expect(article).toHaveAttribute('aria-posinset', String(index + 1));
expect(article).toHaveAttribute('aria-setsize', '5');
});
});
it('maintains focus during loading', async () => {
const user = userEvent.setup();
const { rerender } = render(<Feed articles={defaultArticles} aria-label="News Feed" />);
const articles = screen.getAllByRole('article');
articles[1].focus();
// Start loading
rerender(<Feed articles={defaultArticles} aria-label="News Feed" loading />);
expect(articles[1]).toHaveFocus();
});
it('calls onLoadMore when provided', async () => {
const handleLoadMore = vi.fn();
render(
<Feed articles={defaultArticles} aria-label="News Feed" onLoadMore={handleLoadMore} />
);
const articles = screen.getAllByRole('article');
articles[2].focus(); // Focus on last article
// Implementation should call onLoadMore when user reaches end
// This is tested in E2E for scroll behavior
});
it('does not call onLoadMore during loading', async () => {
const handleLoadMore = vi.fn();
render(
<Feed
articles={defaultArticles}
aria-label="News Feed"
loading
onLoadMore={handleLoadMore}
/>
);
// Even if we try to trigger load more, it should be suppressed
expect(handleLoadMore).not.toHaveBeenCalled();
});
});
// 🟡 Medium Priority: Accessibility
describe('Accessibility', () => {
it('has no axe violations', async () => {
const { container } = render(<Feed articles={defaultArticles} aria-label="News Feed" />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations during loading state', async () => {
const { container } = render(
<Feed articles={defaultArticles} aria-label="News Feed" loading />
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations with setSize=-1', async () => {
const { container } = render(
<Feed articles={defaultArticles} aria-label="News Feed" setSize={-1} />
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
// 🟡 Medium Priority: Props & Callbacks
describe('Props & Callbacks', () => {
it('renders articles from data', () => {
render(<Feed 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('renders article content', () => {
render(<Feed articles={defaultArticles} aria-label="News Feed" />);
expect(screen.getByText('Content 1')).toBeInTheDocument();
expect(screen.getByText('Content 2')).toBeInTheDocument();
expect(screen.getByText('Content 3')).toBeInTheDocument();
});
it('calls onFocusChange with articleId and index on focus', async () => {
const handleFocusChange = vi.fn();
const user = userEvent.setup();
render(
<Feed articles={defaultArticles} aria-label="News Feed" 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 Attribute Inheritance', () => {
it('merges className', () => {
const { container } = render(
<Feed articles={defaultArticles} aria-label="News Feed" className="custom-feed" />
);
const feed = container.querySelector('[role="feed"]');
expect(feed).toHaveClass('custom-feed');
});
it('passes through data-* attributes', () => {
render(<Feed articles={defaultArticles} aria-label="News Feed" data-testid="my-feed" />);
expect(screen.getByTestId('my-feed')).toBeInTheDocument();
});
});
// Edge Cases
describe('Edge Cases', () => {
it('handles empty articles array', () => {
render(<Feed articles={[]} aria-label="Empty Feed" />);
const feed = screen.getByRole('feed');
expect(feed).toBeInTheDocument();
expect(screen.queryAllByRole('article')).toHaveLength(0);
});
it('handles single article', () => {
render(
<Feed
articles={[{ id: '1', title: 'Only Article', content: <p>Content</p> }]}
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');
});
it('handles article without description', () => {
render(
<Feed
articles={[{ id: '1', title: 'No Description', content: <p>Content</p> }]}
aria-label="Feed"
/>
);
const article = screen.getByRole('article');
// Should not have aria-describedby when no description
expect(article).not.toHaveAttribute('aria-describedby');
});
});
});