A vertically stacked set of interactive headings that each reveal a section of content.
🪗 Accordion
A vertically stacked set of interactive headings that each reveal a section of content.
A scrollable list of articles where new content may be added as the user scrolls, enabling keyboard navigation between articles using Page Up/Down.
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.
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.
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.
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.
| 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 | No | References the article description or content |
aria-posinset | Each article | Number (1-based) | Yes | Position of article in the feed |
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-busy | Target | Feed container |
| Values | true | false |
| Required | Conditional (when loading occurs) |
| Change Trigger | Loading starts (true), loading completes (false). Indicates when the feed is loading new content. Screen readers will wait to announce changes until loading completes. |
| Reference | aria-busy (opens in new tab) |
Note: Set aria-busy 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.
| Event | Behavior |
|---|---|
| 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 (
// disabled to allow div with role="feed" to have keyboard events for capture children elements events
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
<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(({ id, title, description, content }, index) => {
const titleId = `${baseId}-article-${id}-title`;
const descId = description ? `${baseId}-article-${id}-desc` : undefined;
return (
<article
key={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}>{title}</h3>
{description && <p id={descId}>{description}</p>}
<div className="apg-feed-article-content">{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
# 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');
});
});
});