APG Patterns
ๆ—ฅๆœฌ่ชž
ๆ—ฅๆœฌ่ชž

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.

Demo

Keyboard Navigation
Page Down / Page Up
Move between articles
Ctrl + End
Move focus after feed
Ctrl + Home
Move focus before feed

๐Ÿช— Accordion

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. View Accordion pattern: /patterns/accordion/svelte/

โš ๏ธ Alert

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. View Alert pattern: /patterns/alert/svelte/

๐Ÿšจ Alert Dialog

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. View Alert Dialog pattern: /patterns/alert-dialog/svelte/

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 Properties

aria-label

Accessible name for the feed (conditional*)

Values
Text
Required
No

aria-labelledby

References visible heading for the feed (conditional*)

Values
ID reference
Required
No

aria-labelledby

References the article title element

Values
ID reference
Required
Yes

aria-describedby

References the article description or content (recommended)

Values
ID reference
Required
No

aria-posinset

Position of article in the feed (starts at 1)

Values
Number (1-based)
Required
Yes

aria-setsize

Total articles in feed, or -1 if unknown

Values
Number or -1
Required
Yes

WAI-ARIA States

aria-busy

Target Element
Feed container
Values
true | false
Required
No
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.

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
  • Either aria-label or aria-labelledby is required on the feed container. Use aria-labelledby when a visible heading exists.
  • 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.
  • Set aria-busy to true when adding multiple articles to prevent premature announcements. Set to false after all DOM updates are complete.

Focus Management

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

References

Source Code

Feed.svelte
<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)[] = [];
  // eslint-disable-next-line svelte/valid-compile -- sentinelRef is only used in effects, not reactive context
  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)}
    <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
    <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`}>
        {article.title}
      </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>

Usage

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

  const articles = [
    {
      id: 'article-1',
      title: 'Getting Started with Svelte',
      description: 'Learn the basics of Svelte development',
      content: 'Full article content here...'
    },
    {
      id: 'article-2',
      title: 'Advanced Patterns',
      description: 'Explore advanced Svelte 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>

<Feed
  {articles}
  aria-label="Blog posts"
  setSize={-1}
  loading={false}
  onloadmore={handleLoadMore}
  onfocuschange={handleFocusChange}
/>

API

PropTypeDefaultDescription
articlesFeedArticle[]requiredArray of article items
aria-labelstringconditionalAccessible name (required if no aria-labelledby)
aria-labelledbystringconditionalID reference to visible heading
setSizenumberarticles.lengthTotal count or -1 if unknown
loadingbooleanfalseLoading state (sets aria-busy)
onloadmore() => void-Callback to load more articles
onfocuschange(articleId: string, index: number) => void-Callback when focus changes

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 (Unit + E2E)

TestDescription
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-posinsetSequential starting from 1
aria-setsizeTotal count or -1 if unknown

High Priority: Keyboard Interaction (E2E)

TestDescription
Page DownMoves focus to next article
Page UpMoves focus to previous article
No wrapDoes not loop at first/last article
Ctrl+EndMoves focus after the feed
Ctrl+HomeMoves focus before the feed
Inside articlePage Down works from inside article elements

High Priority: Focus Management (E2E)

TestDescription
Roving tabindexOnly one article has tabindex="0"
tabindex updatetabindex updates when focus moves
Initial stateFirst article has tabindex="0" by default

High Priority: Dynamic Loading (Unit + E2E)

TestDescription
aria-busy="false"Default state when not loading
aria-busy="true"Set during loading
Focus maintenanceFocus maintained during loading

Medium Priority: Accessibility (Unit + E2E)

TestDescription
axe violationsNo WCAG 2.1 AA violations
Loading stateNo axe violations during loading
aria-describedbyPresent when description provided

Running Tests

# 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

See the Testing Strategy guide for details.

Feed.test.svelte.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');
    });
  });
});

Resources