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/astro/

⚠️ 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/astro/

🚨 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/astro/

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.astro
---
/**
 * APG Feed Pattern - Astro Implementation
 *
 * 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.
 *
 * Uses Web Components for client-side keyboard navigation and infinite scroll.
 *
 * @see https://www.w3.org/WAI/ARIA/apg/patterns/feed/
 */

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;
}

export interface Props {
  /** 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 */
  loading?: boolean;
  /** Additional CSS class */
  class?: string;
  /** Instance ID (optional, auto-generated if not provided) */
  id?: string;
  /** Test ID for E2E testing */
  'data-testid'?: string;
  /** Disable automatic infinite scroll (manual load only) */
  disableAutoLoad?: boolean;
  /** Intersection Observer root margin for triggering load (default: "200px") */
  loadMoreRootMargin?: string;
}

const {
  articles,
  'aria-label': ariaLabel,
  'aria-labelledby': ariaLabelledby,
  setSize,
  loading = false,
  class: className = '',
  id,
  'data-testid': testId,
  disableAutoLoad = false,
  loadMoreRootMargin = '200px',
} = Astro.props;

// Generate unique ID for this instance
const instanceId = id || `feed-${Math.random().toString(36).substring(2, 11)}`;

// Calculate set size
const computedSetSize = setSize !== undefined ? setSize : articles.length;
---

<apg-feed
  data-disable-auto-load={disableAutoLoad ? 'true' : undefined}
  data-load-more-root-margin={loadMoreRootMargin}
>
  <div
    role="feed"
    aria-label={ariaLabel}
    aria-labelledby={ariaLabelledby}
    aria-busy={loading}
    class:list={['apg-feed', className]}
    id={id}
    data-testid={testId}
  >
    {
      articles.map((article, index) => {
        const titleId = `${instanceId}-article-${article.id}-title`;
        const descId = article.description ? `${instanceId}-article-${article.id}-desc` : undefined;
        const isFirst = index === 0;

        return (
          <article
            role="article"
            class="apg-feed-article"
            tabindex={isFirst ? 0 : -1}
            aria-labelledby={titleId}
            aria-describedby={descId}
            aria-posinset={index + 1}
            aria-setsize={computedSetSize}
            data-article-id={article.id}
            data-article-index={index.toString()}
          >
            <h3 id={titleId}>{article.title}</h3>
            {article.description && <p id={descId}>{article.description}</p>}
            <div class="apg-feed-article-content">{article.content}</div>
          </article>
        );
      })
    }
    <!-- Sentinel element for infinite scroll detection -->
    {!disableAutoLoad && <div class="apg-feed-sentinel" aria-hidden="true" />}
  </div>

  <script>
    class ApgFeed extends HTMLElement {
      private feedElement: HTMLElement | null = null;
      private articles: HTMLElement[] = [];
      private focusedIndex = 0;
      private observer: IntersectionObserver | null = null;
      private sentinel: HTMLElement | null = null;
      private boundHandleKeyDown: ((event: KeyboardEvent) => void) | null = null;

      connectedCallback() {
        this.feedElement = this.querySelector('[role="feed"]');
        if (!this.feedElement) return;

        this.articles = Array.from(this.feedElement.querySelectorAll('[role="article"]'));
        if (this.articles.length === 0) return;

        // Set up event listeners (store bound function for cleanup)
        this.boundHandleKeyDown = this.handleKeyDown.bind(this);
        this.feedElement.addEventListener('keydown', this.boundHandleKeyDown);

        // Set up focus tracking
        this.articles.forEach((article, index) => {
          article.addEventListener('focus', () => this.handleArticleFocus(index));
        });

        // Set up Intersection Observer for infinite scroll
        this.setupIntersectionObserver();
      }

      disconnectedCallback() {
        // Clean up event listeners
        if (this.feedElement && this.boundHandleKeyDown) {
          this.feedElement.removeEventListener('keydown', this.boundHandleKeyDown);
        }
        // Clean up observer
        if (this.observer) {
          this.observer.disconnect();
        }
      }

      private setupIntersectionObserver() {
        const disableAutoLoad = this.dataset.disableAutoLoad === 'true';
        if (disableAutoLoad) return;

        this.sentinel = this.querySelector('.apg-feed-sentinel');
        if (!this.sentinel) return;

        const rootMargin = this.dataset.loadMoreRootMargin || '200px';

        this.observer = new IntersectionObserver(
          (entries) => {
            const entry = entries[0];
            const feedElement = this.feedElement;
            if (
              entry.isIntersecting &&
              feedElement &&
              feedElement.getAttribute('aria-busy') !== 'true'
            ) {
              // Dispatch custom event for infinite scroll
              this.dispatchEvent(
                new CustomEvent('feed:loadmore', {
                  bubbles: true,
                  composed: true,
                })
              );
            }
          },
          {
            rootMargin,
            threshold: 0,
          }
        );

        this.observer.observe(this.sentinel);
      }

      private handleKeyDown(event: KeyboardEvent) {
        const { target } = event;
        if (!(target instanceof HTMLElement)) return;

        let currentIndex = this.focusedIndex;

        // Find current article
        for (let i = 0; i < this.articles.length; i++) {
          const article = this.articles[i];
          if (article === target || article.contains(target)) {
            currentIndex = i;
            break;
          }
        }

        switch (event.key) {
          case 'PageDown':
            event.preventDefault();
            if (currentIndex < this.articles.length - 1) {
              this.focusArticle(currentIndex + 1);
            }
            break;

          case 'PageUp':
            event.preventDefault();
            if (currentIndex > 0) {
              this.focusArticle(currentIndex - 1);
            }
            break;

          case 'End':
            if (event.ctrlKey || event.metaKey) {
              event.preventDefault();
              this.focusOutsideFeed('after');
            }
            break;

          case 'Home':
            if (event.ctrlKey || event.metaKey) {
              event.preventDefault();
              this.focusOutsideFeed('before');
            }
            break;
        }
      }

      private focusArticle(index: number) {
        const article = this.articles[index];
        if (!article) return;

        // Update tabindex
        this.articles.forEach((a, i) => {
          a.setAttribute('tabindex', i === index ? '0' : '-1');
        });

        article.focus();
        this.focusedIndex = index;

        // Dispatch focus change event
        this.dispatchEvent(
          new CustomEvent('feed:focuschange', {
            bubbles: true,
            composed: true,
            detail: {
              articleId: article.dataset.articleId,
              index,
            },
          })
        );
      }

      private handleArticleFocus(index: number) {
        // Update tabindex
        this.articles.forEach((a, i) => {
          a.setAttribute('tabindex', i === index ? '0' : '-1');
        });
        this.focusedIndex = index;
      }

      private focusOutsideFeed(direction: 'before' | 'after') {
        if (!this.feedElement) return;

        const focusableSelector =
          'a[href], button:not([disabled]), input:not([disabled]), ' +
          'select:not([disabled]), textarea:not([disabled]), ' +
          '[tabindex]:not([tabindex="-1"])';

        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 (this.feedElement.contains(allFocusable[i]) || allFocusable[i] === this.feedElement) {
            if (feedStartIndex === -1) feedStartIndex = i;
            feedEndIndex = i;
          }
        }

        if (direction === 'before') {
          if (feedStartIndex > 0) {
            allFocusable[feedStartIndex - 1].focus();
          }
        } else {
          if (feedEndIndex >= 0 && feedEndIndex < allFocusable.length - 1) {
            allFocusable[feedEndIndex + 1].focus();
          }
        }
      }
    }

    customElements.define('apg-feed', ApgFeed);
  </script>
</apg-feed>

<style>
  /* Styles are in src/styles/patterns/feed.css */
  .apg-feed-sentinel {
    height: 1px;
    visibility: hidden;
  }
</style>

Usage

Example
---
import Feed from './Feed.astro';

const articles = [
  {
    id: 'article-1',
    title: 'Getting Started with Astro',
    description: 'Learn the basics of Astro development',
    content: '<p>Full article content here...</p>'
  },
  {
    id: 'article-2',
    title: 'Advanced Patterns',
    description: 'Explore advanced Astro patterns',
    content: '<p>Full article content here...</p>'
  }
];
---

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

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)

Custom Events

EventDetailDescription
feed:loadmore-Dispatched to load more articles
feed:focuschange{ articleId: string, index: number }Dispatched 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.astro.ts
/**
 * Feed Astro Component Tests using Container API
 *
 * These tests verify the Feed.astro component output using Astro's Container API.
 * This ensures the component renders correct ARIA structure and attributes.
 *
 * Note: Interactive behavior (keyboard navigation, Ctrl+Home/End) is tested in E2E tests.
 *
 * @see https://docs.astro.build/en/reference/container-reference/
 */
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { describe, it, expect, beforeEach } from 'vitest';
import { JSDOM } from 'jsdom';
import Feed from './Feed.astro';

describe('Feed (Astro Container API)', () => {
  let container: AstroContainer;

  beforeEach(async () => {
    container = await AstroContainer.create();
  });

  // Helper to render and parse HTML
  async function renderFeed(props: {
    articles: Array<{ id: string; title: string; description?: string; content: string }>;
    'aria-label'?: string;
    'aria-labelledby'?: string;
    setSize?: number;
    loading?: boolean;
    class?: string;
    id?: string;
  }): Promise<Document> {
    const html = await container.renderToString(Feed, { props });
    const dom = new JSDOM(html);
    return dom.window.document;
  }

  const basicArticles = [
    {
      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 = [
    { 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>' },
  ];

  // 🔴 High Priority: APG ARIA Structure
  describe('APG: ARIA Structure', () => {
    it('has role="feed" on container', async () => {
      const doc = await renderFeed({
        articles: basicArticles,
        'aria-label': 'News Feed',
      });
      const feed = doc.querySelector('[role="feed"]');
      expect(feed).not.toBeNull();
    });

    it('has role="article" on each article', async () => {
      const doc = await renderFeed({
        articles: basicArticles,
        'aria-label': 'News Feed',
      });
      const articles = doc.querySelectorAll('[role="article"]');
      expect(articles).toHaveLength(3);
    });

    it('has aria-label on feed when provided', async () => {
      const doc = await renderFeed({
        articles: basicArticles,
        'aria-label': 'News Feed',
      });
      const feed = doc.querySelector('[role="feed"]');
      expect(feed?.getAttribute('aria-label')).toBe('News Feed');
    });

    it('has aria-labelledby on feed when provided', async () => {
      const doc = await renderFeed({
        articles: basicArticles,
        'aria-labelledby': 'feed-title',
      });
      const feed = doc.querySelector('[role="feed"]');
      expect(feed?.getAttribute('aria-labelledby')).toBe('feed-title');
    });

    it('has aria-labelledby on each article referencing title', async () => {
      const doc = await renderFeed({
        articles: basicArticles,
        'aria-label': 'News Feed',
      });
      const articles = doc.querySelectorAll('[role="article"]');

      articles.forEach((article) => {
        const labelledby = article.getAttribute('aria-labelledby');
        expect(labelledby).toBeTruthy();

        const titleElement = doc.getElementById(labelledby!);
        expect(titleElement).not.toBeNull();
      });
    });

    it('has aria-describedby on articles when description provided', async () => {
      const doc = await renderFeed({
        articles: basicArticles,
        'aria-label': 'News Feed',
      });
      const articles = doc.querySelectorAll('[role="article"]');

      articles.forEach((article) => {
        const describedby = article.getAttribute('aria-describedby');
        expect(describedby).toBeTruthy();

        const descElement = doc.getElementById(describedby!);
        expect(descElement).not.toBeNull();
      });
    });

    it('has aria-posinset starting from 1 and sequential', async () => {
      const doc = await renderFeed({
        articles: fiveArticles,
        'aria-label': 'News Feed',
      });
      const articles = doc.querySelectorAll('[role="article"]');

      articles.forEach((article, index) => {
        expect(article.getAttribute('aria-posinset')).toBe(String(index + 1));
      });
    });

    it('has aria-setsize as total count when known', async () => {
      const doc = await renderFeed({
        articles: fiveArticles,
        'aria-label': 'News Feed',
      });
      const articles = doc.querySelectorAll('[role="article"]');

      articles.forEach((article) => {
        expect(article.getAttribute('aria-setsize')).toBe('5');
      });
    });

    it('has aria-setsize as -1 when setSize is -1', async () => {
      const doc = await renderFeed({
        articles: fiveArticles,
        'aria-label': 'News Feed',
        setSize: -1,
      });
      const articles = doc.querySelectorAll('[role="article"]');

      articles.forEach((article) => {
        expect(article.getAttribute('aria-setsize')).toBe('-1');
      });
    });

    it('has aria-setsize as explicit value when provided', async () => {
      const doc = await renderFeed({
        articles: basicArticles,
        'aria-label': 'News Feed',
        setSize: 100,
      });
      const articles = doc.querySelectorAll('[role="article"]');

      articles.forEach((article) => {
        expect(article.getAttribute('aria-setsize')).toBe('100');
      });
    });
  });

  // 🔴 High Priority: Focus Management
  describe('APG: Focus Management', () => {
    it('article elements have tabindex', async () => {
      const doc = await renderFeed({
        articles: basicArticles,
        'aria-label': 'News Feed',
      });
      const articles = doc.querySelectorAll('[role="article"]');

      articles.forEach((article) => {
        expect(article.hasAttribute('tabindex')).toBe(true);
      });
    });

    it('uses roving tabindex (only first article has tabindex="0")', async () => {
      const doc = await renderFeed({
        articles: basicArticles,
        'aria-label': 'News Feed',
      });
      const articles = doc.querySelectorAll('[role="article"]');

      expect(articles[0]?.getAttribute('tabindex')).toBe('0');
      expect(articles[1]?.getAttribute('tabindex')).toBe('-1');
      expect(articles[2]?.getAttribute('tabindex')).toBe('-1');
    });
  });

  // 🔴 High Priority: Dynamic Loading State
  describe('APG: Dynamic Loading', () => {
    it('has aria-busy="false" by default', async () => {
      const doc = await renderFeed({
        articles: basicArticles,
        'aria-label': 'News Feed',
      });
      const feed = doc.querySelector('[role="feed"]');
      expect(feed?.getAttribute('aria-busy')).toBe('false');
    });

    it('has aria-busy="true" when loading', async () => {
      const doc = await renderFeed({
        articles: basicArticles,
        'aria-label': 'News Feed',
        loading: true,
      });
      const feed = doc.querySelector('[role="feed"]');
      expect(feed?.getAttribute('aria-busy')).toBe('true');
    });
  });

  // 🟢 Low Priority: HTML Attributes
  describe('HTML Attributes', () => {
    it('applies class to container', async () => {
      const doc = await renderFeed({
        articles: basicArticles,
        'aria-label': 'News Feed',
        class: 'custom-feed',
      });
      const feed = doc.querySelector('[role="feed"]');
      expect(feed?.classList.contains('custom-feed')).toBe(true);
    });

    it('applies id to container', async () => {
      const doc = await renderFeed({
        articles: basicArticles,
        'aria-label': 'News Feed',
        id: 'my-feed',
      });
      const feed = doc.querySelector('[role="feed"]');
      expect(feed?.getAttribute('id')).toBe('my-feed');
    });
  });

  // Content Rendering
  describe('Content Rendering', () => {
    it('renders article titles', async () => {
      const doc = await renderFeed({
        articles: basicArticles,
        'aria-label': 'News Feed',
      });

      expect(doc.body.textContent).toContain('First Article');
      expect(doc.body.textContent).toContain('Second Article');
      expect(doc.body.textContent).toContain('Third Article');
    });

    it('renders article descriptions when provided', async () => {
      const doc = await renderFeed({
        articles: basicArticles,
        'aria-label': 'News Feed',
      });

      expect(doc.body.textContent).toContain('Description 1');
      expect(doc.body.textContent).toContain('Description 2');
      expect(doc.body.textContent).toContain('Description 3');
    });

    it('renders article content', async () => {
      const doc = await renderFeed({
        articles: basicArticles,
        'aria-label': 'News Feed',
      });

      expect(doc.body.innerHTML).toContain('Content 1');
      expect(doc.body.innerHTML).toContain('Content 2');
      expect(doc.body.innerHTML).toContain('Content 3');
    });
  });

  // Edge Cases
  describe('Edge Cases', () => {
    it('handles empty articles array', async () => {
      const doc = await renderFeed({
        articles: [],
        'aria-label': 'Empty Feed',
      });

      const feed = doc.querySelector('[role="feed"]');
      expect(feed).not.toBeNull();

      const articles = doc.querySelectorAll('[role="article"]');
      expect(articles).toHaveLength(0);
    });

    it('handles single article', async () => {
      const singleArticle = [{ id: '1', title: 'Only Article', content: '<p>Content</p>' }];
      const doc = await renderFeed({
        articles: singleArticle,
        'aria-label': 'Single Article Feed',
      });

      const articles = doc.querySelectorAll('[role="article"]');
      expect(articles).toHaveLength(1);
      expect(articles[0]?.getAttribute('aria-posinset')).toBe('1');
      expect(articles[0]?.getAttribute('aria-setsize')).toBe('1');
    });

    it('handles article without description', async () => {
      const noDescArticles = [{ id: '1', title: 'No Description', content: '<p>Content</p>' }];
      const doc = await renderFeed({
        articles: noDescArticles,
        'aria-label': 'Feed',
      });

      const article = doc.querySelector('[role="article"]');
      // Should not have aria-describedby when no description
      expect(article?.hasAttribute('aria-describedby')).toBe(false);
    });
  });
});

Resources