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

RoleTarget ElementDescription
feedContainer elementA dynamic list of articles where scrolling may add/remove content
articleEach article elementIndependent 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

KeyAction
Page DownMove focus to next article in the feed
Page UpMove focus to previous article in the feed
Ctrl + EndMove focus to first focusable element after the feed
Ctrl + HomeMove 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

EventBehavior
Roving tabindexOnly one article has tabindex="0", others have tabindex="-1"
Initial focusFirst article has tabindex="0" by default
Focus trackingtabindex updates as focus moves between articles
No wrapFocus does not wrap from first to last article or vice versa
Content inside articlesInteractive 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

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

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)

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

High Priority : Keyboard Interaction (E2E)

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

High Priority : Focus Management (E2E)

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

High Priority : Dynamic Loading (Unit + E2E)

Test Description
aria-busy="false" Default state when not loading
aria-busy="true" Set during loading
Focus maintenance Focus maintained during loading

Medium Priority : Accessibility (Unit + E2E)

Test Description
axe violations No WCAG 2.1 AA violations
Loading state No axe violations during loading
aria-describedby Present 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
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