Accordion
A vertically stacked set of interactive headings that each reveal a section of content.
Demo
Single Expansion (Default)
Only one panel can be expanded at a time. Opening a new panel closes the previously open one.
Multiple Expansion
Multiple panels can be expanded simultaneously using the allowMultiple prop.
With Disabled Items
Individual accordion items can be disabled. Keyboard navigation automatically skips disabled items.
Accessibility Features
WAI-ARIA Roles
| Role | Target Element | Description |
|---|---|---|
heading | Header wrapper (h2-h6) | Contains the accordion trigger button |
button | Header trigger | Interactive element that toggles panel visibility |
region | Panel (optional) | Content area associated with header (omit for 6+ panels) |
WAI-ARIA Accordion Pattern (opens in new tab)
WAI-ARIA Properties
| Attribute | Target | Values | Required | Configuration |
|---|---|---|---|---|
aria-level | heading | 2 - 6 | Yes | headingLevel prop |
aria-controls | button | ID reference to associated panel | Yes | Auto-generated |
aria-labelledby | region (panel) | ID reference to header button | Yes (if region used) | Auto-generated |
WAI-ARIA States
aria-expanded
| Target | button element |
| Values | true | false |
| Required | Yes |
| Change Trigger | Click, Enter, Space |
| Reference | aria-expanded (opens in new tab) |
aria-disabled
| Target | button element |
| Values | true | false |
| Required | No |
| Change Trigger | Only when disabled |
| Reference | aria-disabled (opens in new tab) |
Keyboard Support
| Key | Action |
|---|---|
| Tab | Move focus to the next focusable element |
| Space / Enter | Toggle the expansion of the focused accordion header |
| Arrow Down | Move focus to the next accordion header (optional) |
| Arrow Up | Move focus to the previous accordion header (optional) |
| Home | Move focus to the first accordion header (optional) |
| End | Move focus to the last accordion header (optional) |
Arrow key navigation is optional but recommended. Focus does not wrap around at the end of the list.
Implementation Notes
Structure
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ [โผ] Section 1 โ โ button (aria-expanded="true")
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ Panel 1 content... โ โ region (aria-labelledby)
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ [โถ] Section 2 โ โ button (aria-expanded="false")
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ [โถ] Section 3 โ โ button (aria-expanded="false")
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
ID Relationships:
- Button: id="header-1", aria-controls="panel-1"
- Panel: id="panel-1", aria-labelledby="header-1"
Region Role Rule:
- โค6 panels: use role="region" on panels
- >6 panels: omit role="region" (too many landmarks) Accordion component structure with ID relationships
Source Code
<script lang="ts">
/**
* APG Accordion Pattern - Svelte Implementation
*
* A vertically stacked set of interactive headings that each reveal a section of content.
*
* @see https://www.w3.org/WAI/ARIA/apg/patterns/accordion/
*/
import { onMount } from 'svelte';
/**
* Accordion item configuration
*/
export interface AccordionItem {
/** Unique identifier for the item */
id: string;
/** Content displayed in the accordion header button */
header: string;
/** Content displayed in the collapsible panel (HTML string) */
content?: string;
/** When true, the item cannot be expanded/collapsed */
disabled?: boolean;
/** When true, the panel is expanded on initial render */
defaultExpanded?: boolean;
}
/**
* Props for the Accordion component
*/
interface AccordionProps {
items: AccordionItem[];
allowMultiple?: boolean;
headingLevel?: 2 | 3 | 4 | 5 | 6;
enableArrowKeys?: boolean;
onExpandedChange?: (expandedIds: string[]) => void;
className?: string;
}
let {
items = [],
allowMultiple = false,
headingLevel = 3,
enableArrowKeys = true,
onExpandedChange = () => {},
className = '',
}: AccordionProps = $props();
let expandedIds = $state<string[]>([]);
let instanceId = $state('');
let buttonRefs = $state<Record<string, HTMLButtonElement | undefined>>({});
onMount(() => {
instanceId = `accordion-${Math.random().toString(36).substring(2, 11)}`;
// Initialize with defaultExpanded items
if (Array.isArray(items)) {
expandedIds = items
.filter((item) => item.defaultExpanded && !item.disabled)
.map((item) => item.id);
}
});
// Derived values
let safeItems = $derived(Array.isArray(items) ? items : []);
let availableItems = $derived(safeItems.filter((item) => !item.disabled));
let useRegion = $derived(safeItems.length <= 6);
function isExpanded(itemId: string): boolean {
return expandedIds.includes(itemId);
}
function handleToggle(itemId: string) {
const item = safeItems.find((i) => i.id === itemId);
if (item?.disabled) return;
const isCurrentlyExpanded = expandedIds.includes(itemId);
if (isCurrentlyExpanded) {
expandedIds = expandedIds.filter((id) => id !== itemId);
} else {
if (allowMultiple) {
expandedIds = [...expandedIds, itemId];
} else {
expandedIds = [itemId];
}
}
onExpandedChange(expandedIds);
}
function handleKeyDown(event: KeyboardEvent, currentItemId: string) {
if (!enableArrowKeys) return;
const currentIndex = availableItems.findIndex((item) => item.id === currentItemId);
if (currentIndex === -1) return;
let newIndex = currentIndex;
let shouldPreventDefault = false;
switch (event.key) {
case 'ArrowDown':
if (currentIndex < availableItems.length - 1) {
newIndex = currentIndex + 1;
}
shouldPreventDefault = true;
break;
case 'ArrowUp':
if (currentIndex > 0) {
newIndex = currentIndex - 1;
}
shouldPreventDefault = true;
break;
case 'Home':
newIndex = 0;
shouldPreventDefault = true;
break;
case 'End':
newIndex = availableItems.length - 1;
shouldPreventDefault = true;
break;
}
if (shouldPreventDefault) {
event.preventDefault();
if (newIndex !== currentIndex) {
const newItem = availableItems[newIndex];
if (newItem) {
buttonRefs[newItem.id]?.focus();
}
}
}
}
function getItemClass(item: AccordionItem): string {
let cls = 'apg-accordion-item';
if (isExpanded(item.id)) cls += ' apg-accordion-item--expanded';
if (item.disabled) cls += ' apg-accordion-item--disabled';
return cls;
}
function getTriggerClass(itemId: string): string {
return isExpanded(itemId)
? 'apg-accordion-trigger apg-accordion-trigger--expanded'
: 'apg-accordion-trigger';
}
function getIconClass(itemId: string): string {
return isExpanded(itemId)
? 'apg-accordion-icon apg-accordion-icon--expanded'
: 'apg-accordion-icon';
}
function getPanelClass(itemId: string): string {
return isExpanded(itemId)
? 'apg-accordion-panel apg-accordion-panel--expanded'
: 'apg-accordion-panel apg-accordion-panel--collapsed';
}
</script>
{#if safeItems.length > 0}
<div class="apg-accordion {className}">
{#each safeItems as item (item.id)}
{@const headerId = `${instanceId}-header-${item.id}`}
{@const panelId = `${instanceId}-panel-${item.id}`}
<div class={getItemClass(item)}>
{#if headingLevel === 2}
<h2 class="apg-accordion-header">
<button
bind:this={buttonRefs[item.id]}
type="button"
id={headerId}
aria-expanded={isExpanded(item.id)}
aria-controls={panelId}
aria-disabled={item.disabled || undefined}
disabled={item.disabled}
class={getTriggerClass(item.id)}
onclick={() => handleToggle(item.id)}
onkeydown={(e) => handleKeyDown(e, item.id)}
>
<span class="apg-accordion-trigger-content">{item.header}</span>
<span class={getIconClass(item.id)} aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
</button>
</h2>
{:else if headingLevel === 3}
<h3 class="apg-accordion-header">
<button
bind:this={buttonRefs[item.id]}
type="button"
id={headerId}
aria-expanded={isExpanded(item.id)}
aria-controls={panelId}
aria-disabled={item.disabled || undefined}
disabled={item.disabled}
class={getTriggerClass(item.id)}
onclick={() => handleToggle(item.id)}
onkeydown={(e) => handleKeyDown(e, item.id)}
>
<span class="apg-accordion-trigger-content">{item.header}</span>
<span class={getIconClass(item.id)} aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
</button>
</h3>
{:else if headingLevel === 4}
<h4 class="apg-accordion-header">
<button
bind:this={buttonRefs[item.id]}
type="button"
id={headerId}
aria-expanded={isExpanded(item.id)}
aria-controls={panelId}
aria-disabled={item.disabled || undefined}
disabled={item.disabled}
class={getTriggerClass(item.id)}
onclick={() => handleToggle(item.id)}
onkeydown={(e) => handleKeyDown(e, item.id)}
>
<span class="apg-accordion-trigger-content">{item.header}</span>
<span class={getIconClass(item.id)} aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
</button>
</h4>
{:else if headingLevel === 5}
<h5 class="apg-accordion-header">
<button
bind:this={buttonRefs[item.id]}
type="button"
id={headerId}
aria-expanded={isExpanded(item.id)}
aria-controls={panelId}
aria-disabled={item.disabled || undefined}
disabled={item.disabled}
class={getTriggerClass(item.id)}
onclick={() => handleToggle(item.id)}
onkeydown={(e) => handleKeyDown(e, item.id)}
>
<span class="apg-accordion-trigger-content">{item.header}</span>
<span class={getIconClass(item.id)} aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
</button>
</h5>
{:else}
<h6 class="apg-accordion-header">
<button
bind:this={buttonRefs[item.id]}
type="button"
id={headerId}
aria-expanded={isExpanded(item.id)}
aria-controls={panelId}
aria-disabled={item.disabled || undefined}
disabled={item.disabled}
class={getTriggerClass(item.id)}
onclick={() => handleToggle(item.id)}
onkeydown={(e) => handleKeyDown(e, item.id)}
>
<span class="apg-accordion-trigger-content">{item.header}</span>
<span class={getIconClass(item.id)} aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
</button>
</h6>
{/if}
<div
role={useRegion ? 'region' : undefined}
id={panelId}
aria-labelledby={useRegion ? headerId : undefined}
class={getPanelClass(item.id)}
>
<div class="apg-accordion-panel-content">
{#if item.content}
<!-- eslint-disable-next-line svelte/no-at-html-tags -- Content is provided by the consuming application -->
{@html item.content}
{/if}
</div>
</div>
</div>
{/each}
</div>
{/if} Usage
<script>
import Accordion from './Accordion.svelte';
const items = [
{
id: 'section1',
header: 'First Section',
content: 'Content for the first section...',
defaultExpanded: true,
},
{
id: 'section2',
header: 'Second Section',
content: 'Content for the second section...',
},
];
function handleExpandedChange(ids) {
console.log('Expanded:', ids);
}
</script>
<Accordion
{items}
headingLevel={3}
allowMultiple={false}
onExpandedChange={handleExpandedChange}
/> API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | AccordionItem[] | required | Array of accordion items |
allowMultiple | boolean | false | Allow multiple panels to be expanded |
headingLevel | 2 | 3 | 4 | 5 | 6 | 3 | Heading level for accessibility |
enableArrowKeys | boolean | true | Enable arrow key navigation |
onExpandedChange | (ids: string[]) => void | - | Callback when expansion changes |
AccordionItem Interface
interface AccordionItem {
id: string;
header: string;
content?: string;
disabled?: boolean;
defaultExpanded?: boolean;
} Testing
Tests verify APG compliance across keyboard interaction, ARIA attributes, and accessibility requirements. The Accordion component uses a two-layer testing strategy.
Testing Strategy
Unit Tests (Testing Library)
Verify the component's rendered output using framework-specific testing libraries. These tests ensure correct HTML structure and ARIA attributes.
- ARIA attributes (aria-expanded, aria-controls, aria-labelledby)
- Keyboard interaction (Enter, Space, Arrow keys)
- Expand/collapse behavior
- Accessibility via jest-axe
E2E Tests (Playwright)
Verify component behavior in a real browser environment across all frameworks. These tests cover interactions and cross-framework consistency.
- Click interactions
- Arrow key navigation
- Home/End key navigation
- ARIA structure validation in live browser
- axe-core accessibility scanning
- Cross-framework consistency checks
Test Categories
High Priority : APG Keyboard Interaction (Unit + E2E)
| Test | Description |
|---|---|
Enter key | Expands/collapses the focused panel |
Space key | Expands/collapses the focused panel |
ArrowDown | Moves focus to next header |
ArrowUp | Moves focus to previous header |
Home | Moves focus to first header |
End | Moves focus to last header |
No loop | Focus stops at edges (no wrapping) |
Disabled skip | Skips disabled headers during navigation |
High Priority : APG ARIA Attributes (Unit + E2E)
| Test | Description |
|---|---|
aria-expanded | Header button reflects expand/collapse state |
aria-controls | Header references its panel via aria-controls |
aria-labelledby | Panel references its header via aria-labelledby |
role="region" | Panel has region role (6 or fewer panels) |
No region (7+) | Panel omits region role when 7+ panels |
aria-disabled | Disabled items have aria-disabled="true" |
High Priority : Click Interaction (Unit + E2E)
| Test | Description |
|---|---|
Click expands | Clicking header expands panel |
Click collapses | Clicking expanded header collapses panel |
Single expansion | Opening panel closes other panels (default) |
Multiple expansion | Multiple panels can be open with allowMultiple |
High Priority : Heading Structure (Unit + E2E)
| Test | Description |
|---|---|
headingLevel prop | Uses correct heading element (h2, h3, etc.) |
Medium Priority : Disabled State (Unit + E2E)
| Test | Description |
|---|---|
Disabled no click | Clicking disabled header does not expand |
Disabled no keyboard | Enter/Space does not activate disabled header |
Medium Priority : Accessibility (Unit + E2E)
| Test | Description |
|---|---|
axe violations | No WCAG 2.1 AA violations (via jest-axe/axe-core) |
Low Priority : Cross-framework Consistency (E2E)
| Test | Description |
|---|---|
All frameworks render | React, Vue, Svelte, Astro all render accordions |
Consistent ARIA | All frameworks have consistent ARIA structure |
Example Test Code
The following is the actual E2E test file (e2e/accordion.spec.ts).
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
/**
* E2E Tests for Accordion Pattern
*
* A vertically stacked set of interactive headings that each reveal
* a section of content.
*
* APG Reference: https://www.w3.org/WAI/ARIA/apg/patterns/accordion/
*/
const frameworks = ['react', 'vue', 'svelte', 'astro'] as const;
// ============================================
// Helper Functions
// ============================================
const getAccordion = (page: import('@playwright/test').Page) => {
return page.locator('.apg-accordion');
};
const getAccordionHeaders = (page: import('@playwright/test').Page) => {
return page.locator('.apg-accordion-trigger');
};
// ============================================
// Framework-specific Tests
// ============================================
for (const framework of frameworks) {
test.describe(`Accordion (${framework})`, () => {
test.beforeEach(async ({ page }) => {
await page.goto(`patterns/accordion/${framework}/demo/`);
await getAccordion(page).first().waitFor();
// Wait for hydration to complete - aria-controls should have a proper ID (not starting with hyphen)
const firstHeader = getAccordionHeaders(page).first();
await expect
.poll(async () => {
const id = await firstHeader.getAttribute('aria-controls');
// ID should be non-empty and not start with hyphen (Svelte pre-hydration)
return id && id.length > 1 && !id.startsWith('-');
})
.toBe(true);
});
// ------------------------------------------
// ๐ด High Priority: APG ARIA Structure
// ------------------------------------------
test.describe('APG: ARIA Structure', () => {
test('accordion headers have aria-expanded attribute', async ({ page }) => {
const headers = getAccordionHeaders(page);
const firstHeader = headers.first();
// Should have aria-expanded (either true or false)
const expanded = await firstHeader.getAttribute('aria-expanded');
expect(['true', 'false']).toContain(expanded);
});
test('accordion headers have aria-controls referencing panel', async ({ page }) => {
const headers = getAccordionHeaders(page);
const firstHeader = headers.first();
// Wait for aria-controls to be set
await expect(firstHeader).toHaveAttribute('aria-controls', /.+/);
const controlsId = await firstHeader.getAttribute('aria-controls');
expect(controlsId).toBeTruthy();
// Panel with that ID should exist
const panel = page.locator(`[id="${controlsId}"]`);
await expect(panel).toBeAttached();
});
test('panels have role="region" when 6 or fewer items', async ({ page }) => {
const accordion = getAccordion(page).first();
const headers = accordion.locator('.apg-accordion-trigger');
const count = await headers.count();
if (count <= 6) {
const panels = accordion.locator('.apg-accordion-panel');
const firstPanel = panels.first();
await expect(firstPanel).toHaveRole('region');
}
});
test('panels have aria-labelledby referencing header', async ({ page }) => {
const accordion = getAccordion(page).first();
const headers = accordion.locator('.apg-accordion-trigger');
const count = await headers.count();
// Only check aria-labelledby when role="region" is present (โค6 items)
if (count <= 6) {
const firstHeader = headers.first();
// Wait for aria-controls to be set
await expect(firstHeader).toHaveAttribute('aria-controls', /.+/);
const headerId = await firstHeader.getAttribute('id');
const controlsId = await firstHeader.getAttribute('aria-controls');
const panel = page.locator(`[id="${controlsId}"]`);
await expect(panel).toHaveAttribute('aria-labelledby', headerId!);
}
});
});
// ------------------------------------------
// ๐ด High Priority: Click Interaction
// ------------------------------------------
test.describe('APG: Click Interaction', () => {
test('clicking header toggles panel expansion', async ({ page }) => {
const accordion = getAccordion(page).first();
// Use second header which is not expanded by default
const header = accordion.locator('.apg-accordion-trigger').nth(1);
// Wait for component to be interactive (hydration complete)
await expect(header).toHaveAttribute('aria-expanded', 'false');
await header.click();
await expect(header).toHaveAttribute('aria-expanded', 'true');
});
test('single expansion mode: opening one panel closes others', async ({ page }) => {
// First accordion uses single expansion mode
const accordion = getAccordion(page).first();
const headers = accordion.locator('.apg-accordion-trigger');
// Wait for hydration - first header should be expanded by default
const firstHeader = headers.first();
await expect(firstHeader).toHaveAttribute('aria-expanded', 'true');
// Click second header
const secondHeader = headers.nth(1);
await secondHeader.click();
// Second should be open, first should be closed
await expect(secondHeader).toHaveAttribute('aria-expanded', 'true');
await expect(firstHeader).toHaveAttribute('aria-expanded', 'false');
});
});
// ------------------------------------------
// ๐ด High Priority: Keyboard Interaction
// ------------------------------------------
test.describe('APG: Keyboard Interaction', () => {
test('Enter/Space toggles panel expansion', async ({ page }) => {
const accordion = getAccordion(page).first();
// Use second header which is collapsed by default
const header = accordion.locator('.apg-accordion-trigger').nth(1);
// Wait for component to be ready
await expect(header).toHaveAttribute('aria-expanded', 'false');
// Click to set focus (this also opens the panel)
await header.click();
await expect(header).toBeFocused();
await expect(header).toHaveAttribute('aria-expanded', 'true');
// Press Enter to toggle (should collapse)
await expect(header).toBeFocused();
await header.press('Enter');
await expect(header).toHaveAttribute('aria-expanded', 'false');
// Press Space to toggle (should expand)
await expect(header).toBeFocused();
await header.press('Space');
await expect(header).toHaveAttribute('aria-expanded', 'true');
});
test('ArrowDown moves focus to next header', async ({ page }) => {
const accordion = getAccordion(page).first();
const headers = accordion.locator('.apg-accordion-trigger');
// Click to set focus
const firstHeader = headers.first();
await firstHeader.click();
await expect(firstHeader).toBeFocused();
await firstHeader.press('ArrowDown');
await expect(headers.nth(1)).toBeFocused();
});
test('ArrowUp moves focus to previous header', async ({ page }) => {
const accordion = getAccordion(page).first();
const headers = accordion.locator('.apg-accordion-trigger');
// Click to ensure focus is properly set
const secondHeader = headers.nth(1);
await secondHeader.click();
await expect(secondHeader).toBeFocused();
await secondHeader.press('ArrowUp');
await expect(headers.first()).toBeFocused();
});
test('Home moves focus to first header', async ({ page }) => {
const accordion = getAccordion(page).first();
const headers = accordion.locator('.apg-accordion-trigger');
// Click to set focus
const thirdHeader = headers.nth(2);
await thirdHeader.click();
await expect(thirdHeader).toBeFocused();
await thirdHeader.press('Home');
await expect(headers.first()).toBeFocused();
});
test('End moves focus to last header', async ({ page }) => {
const accordion = getAccordion(page).first();
const headers = accordion.locator('.apg-accordion-trigger');
const count = await headers.count();
// Click to set focus
const firstHeader = headers.first();
await firstHeader.click();
await expect(firstHeader).toBeFocused();
await firstHeader.press('End');
await expect(headers.nth(count - 1)).toBeFocused();
});
});
// ------------------------------------------
// ๐ก Medium Priority: Disabled State
// ------------------------------------------
test.describe('Disabled State', () => {
test('disabled header cannot be clicked to expand', async ({ page }) => {
// Third accordion has disabled items
const accordions = getAccordion(page);
const count = await accordions.count();
// Find accordion with disabled item
for (let i = 0; i < count; i++) {
const accordion = accordions.nth(i);
const disabledHeader = accordion.locator('.apg-accordion-trigger[disabled]');
if ((await disabledHeader.count()) > 0) {
const header = disabledHeader.first();
const initialExpanded = await header.getAttribute('aria-expanded');
await header.click({ force: true });
// State should not change
await expect(header).toHaveAttribute('aria-expanded', initialExpanded!);
break;
}
}
});
test('arrow key navigation skips disabled headers', async ({ page }) => {
// Find accordion with disabled item (third accordion)
const accordions = getAccordion(page);
const count = await accordions.count();
for (let i = 0; i < count; i++) {
const accordion = accordions.nth(i);
const disabledHeader = accordion.locator('.apg-accordion-trigger[disabled]');
if ((await disabledHeader.count()) > 0) {
const headers = accordion.locator('.apg-accordion-trigger:not([disabled])');
const firstEnabled = headers.first();
// Click to set focus reliably
await firstEnabled.click();
await expect(firstEnabled).toBeFocused();
await firstEnabled.press('ArrowDown');
// Should skip disabled and go to next enabled
const secondEnabled = headers.nth(1);
await expect(secondEnabled).toBeFocused();
break;
}
}
});
});
// ------------------------------------------
// ๐ข Low Priority: Accessibility
// ------------------------------------------
test.describe('Accessibility', () => {
test('has no axe-core violations', async ({ page }) => {
const accordion = getAccordion(page);
await accordion.first().waitFor();
const results = await new AxeBuilder({ page })
.include('.apg-accordion')
.disableRules(['color-contrast'])
.analyze();
expect(results.violations).toEqual([]);
});
});
});
}
// ============================================
// Cross-framework Consistency Tests
// ============================================
test.describe('Accordion - Cross-framework Consistency', () => {
test('all frameworks have accordions', async ({ page }) => {
for (const framework of frameworks) {
await page.goto(`patterns/accordion/${framework}/demo/`);
await getAccordion(page).first().waitFor();
const accordions = getAccordion(page);
const count = await accordions.count();
expect(count).toBeGreaterThan(0);
}
});
test('all frameworks support click to expand', async ({ page }) => {
for (const framework of frameworks) {
await page.goto(`patterns/accordion/${framework}/demo/`);
await getAccordion(page).first().waitFor();
const accordion = getAccordion(page).first();
// Use second header which is not expanded by default
const header = accordion.locator('.apg-accordion-trigger').nth(1);
// Wait for the component to be interactive (not expanded by default)
await expect(header).toHaveAttribute('aria-expanded', 'false');
// Click to toggle
await header.click();
// State should change to expanded
await expect(header).toHaveAttribute('aria-expanded', 'true');
}
});
test('all frameworks have consistent ARIA structure', async ({ page }) => {
for (const framework of frameworks) {
await page.goto(`patterns/accordion/${framework}/demo/`);
await getAccordion(page).first().waitFor();
const accordion = getAccordion(page).first();
const header = accordion.locator('.apg-accordion-trigger').first();
// Wait for hydration - aria-controls should be set
await expect(header).toHaveAttribute('aria-controls', /.+/);
// Check aria-expanded exists
const expanded = await header.getAttribute('aria-expanded');
expect(['true', 'false']).toContain(expanded);
// Check aria-controls exists and references valid panel
const controlsId = await header.getAttribute('aria-controls');
expect(controlsId).toBeTruthy();
const panel = page.locator(`[id="${controlsId}"]`);
await expect(panel).toBeAttached();
}
});
}); Running Tests
# Run unit tests for Accordion
npm run test -- accordion
# Run E2E tests for Accordion (all frameworks)
npm run test:e2e:pattern --pattern=accordion
# Run E2E tests for specific framework
npm run test:e2e:react:pattern --pattern=accordion
npm run test:e2e:vue:pattern --pattern=accordion
npm run test:e2e:svelte:pattern --pattern=accordion
npm run test:e2e:astro:pattern --pattern=accordion Testing Tools
- Vitest (opens in new tab) - Test runner for unit tests
- Testing Library (opens in new tab) - Framework-specific testing utilities (React, Vue, Svelte)
- Playwright (opens in new tab) - Browser automation for E2E tests
- axe-core/playwright (opens in new tab) - Automated accessibility testing in E2E
See testing-strategy.md (opens in new tab) for full documentation.
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 Accordion from './Accordion.svelte';
import type { AccordionItem } from './Accordion.svelte';
// ใในใ็จใฎใขใณใผใใฃใชใณใใผใฟ
const defaultItems: AccordionItem[] = [
{ id: 'section1', header: 'Section 1', content: 'Content 1' },
{ id: 'section2', header: 'Section 2', content: 'Content 2' },
{ id: 'section3', header: 'Section 3', content: 'Content 3' },
];
const itemsWithDisabled: AccordionItem[] = [
{ id: 'section1', header: 'Section 1', content: 'Content 1' },
{ id: 'section2', header: 'Section 2', content: 'Content 2', disabled: true },
{ id: 'section3', header: 'Section 3', content: 'Content 3' },
];
const itemsWithDefaultExpanded: AccordionItem[] = [
{ id: 'section1', header: 'Section 1', content: 'Content 1', defaultExpanded: true },
{ id: 'section2', header: 'Section 2', content: 'Content 2' },
{ id: 'section3', header: 'Section 3', content: 'Content 3' },
];
// 7ๅไปฅไธใฎใขใคใใ ๏ผregion role ใในใ็จ๏ผ
const manyItems: AccordionItem[] = Array.from({ length: 7 }, (_, i) => ({
id: `section${i + 1}`,
header: `Section ${i + 1}`,
content: `Content ${i + 1}`,
}));
describe('Accordion (Svelte)', () => {
// ๐ด High Priority: APG ๆบๆ ใฎๆ ธๅฟ
describe('APG: ใญใผใใผใๆไฝ', () => {
it('Enter ใงใใใซใ้้ใใ', async () => {
const user = userEvent.setup();
render(Accordion, { props: { items: defaultItems } });
const button = screen.getByRole('button', { name: 'Section 1' });
button.focus();
expect(button).toHaveAttribute('aria-expanded', 'false');
await user.keyboard('{Enter}');
expect(button).toHaveAttribute('aria-expanded', 'true');
await user.keyboard('{Enter}');
expect(button).toHaveAttribute('aria-expanded', 'false');
});
it('Space ใงใใใซใ้้ใใ', async () => {
const user = userEvent.setup();
render(Accordion, { props: { items: defaultItems } });
const button = screen.getByRole('button', { name: 'Section 1' });
button.focus();
expect(button).toHaveAttribute('aria-expanded', 'false');
await user.keyboard(' ');
expect(button).toHaveAttribute('aria-expanded', 'true');
});
it('ArrowDown ใงๆฌกใฎใใใใผใซใใฉใผใซใน็งปๅ', async () => {
const user = userEvent.setup();
render(Accordion, { props: { items: defaultItems } });
const button1 = screen.getByRole('button', { name: 'Section 1' });
button1.focus();
await user.keyboard('{ArrowDown}');
const button2 = screen.getByRole('button', { name: 'Section 2' });
expect(button2).toHaveFocus();
});
it('ArrowUp ใงๅใฎใใใใผใซใใฉใผใซใน็งปๅ', async () => {
const user = userEvent.setup();
render(Accordion, { props: { items: defaultItems } });
const button2 = screen.getByRole('button', { name: 'Section 2' });
button2.focus();
await user.keyboard('{ArrowUp}');
const button1 = screen.getByRole('button', { name: 'Section 1' });
expect(button1).toHaveFocus();
});
it('ArrowDown ใงๆๅพใฎใใใใผใซใใๅ ดๅใ็งปๅใใชใ๏ผใซใผใใชใ๏ผ', async () => {
const user = userEvent.setup();
render(Accordion, { props: { items: defaultItems } });
const button3 = screen.getByRole('button', { name: 'Section 3' });
button3.focus();
await user.keyboard('{ArrowDown}');
expect(button3).toHaveFocus();
});
it('ArrowUp ใงๆๅใฎใใใใผใซใใๅ ดๅใ็งปๅใใชใ๏ผใซใผใใชใ๏ผ', async () => {
const user = userEvent.setup();
render(Accordion, { props: { items: defaultItems } });
const button1 = screen.getByRole('button', { name: 'Section 1' });
button1.focus();
await user.keyboard('{ArrowUp}');
expect(button1).toHaveFocus();
});
it('Home ใงๆๅใฎใใใใผใซ็งปๅ', async () => {
const user = userEvent.setup();
render(Accordion, { props: { items: defaultItems } });
const button3 = screen.getByRole('button', { name: 'Section 3' });
button3.focus();
await user.keyboard('{Home}');
const button1 = screen.getByRole('button', { name: 'Section 1' });
expect(button1).toHaveFocus();
});
it('End ใงๆๅพใฎใใใใผใซ็งปๅ', async () => {
const user = userEvent.setup();
render(Accordion, { props: { items: defaultItems } });
const button1 = screen.getByRole('button', { name: 'Section 1' });
button1.focus();
await user.keyboard('{End}');
const button3 = screen.getByRole('button', { name: 'Section 3' });
expect(button3).toHaveFocus();
});
it('disabled ใใใใผใในใญใใใใฆ็งปๅ', async () => {
const user = userEvent.setup();
render(Accordion, { props: { items: itemsWithDisabled } });
const button1 = screen.getByRole('button', { name: 'Section 1' });
button1.focus();
await user.keyboard('{ArrowDown}');
const button3 = screen.getByRole('button', { name: 'Section 3' });
expect(button3).toHaveFocus();
});
it('enableArrowKeys=false ใง็ขๅฐใญใผใใใฒใผใทใงใณ็กๅน', async () => {
const user = userEvent.setup();
render(Accordion, { props: { items: defaultItems, enableArrowKeys: false } });
const button1 = screen.getByRole('button', { name: 'Section 1' });
button1.focus();
await user.keyboard('{ArrowDown}');
expect(button1).toHaveFocus();
});
});
describe('APG: ARIA ๅฑๆง', () => {
it('ใใใใผใใฟใณใ aria-expanded ใๆใค', () => {
render(Accordion, { props: { items: defaultItems } });
const buttons = screen.getAllByRole('button');
buttons.forEach((button) => {
expect(button).toHaveAttribute('aria-expanded');
});
});
it('้ใใใใใซใง aria-expanded="true"', async () => {
const user = userEvent.setup();
render(Accordion, { props: { items: defaultItems } });
const button = screen.getByRole('button', { name: 'Section 1' });
await user.click(button);
expect(button).toHaveAttribute('aria-expanded', 'true');
});
it('้ใใใใใซใง aria-expanded="false"', () => {
render(Accordion, { props: { items: defaultItems } });
const button = screen.getByRole('button', { name: 'Section 1' });
expect(button).toHaveAttribute('aria-expanded', 'false');
});
it('ใใใใผใฎ aria-controls ใใใใซ id ใจไธ่ด', () => {
render(Accordion, { props: { items: defaultItems } });
const button = screen.getByRole('button', { name: 'Section 1' });
const ariaControls = button.getAttribute('aria-controls');
expect(ariaControls).toBeTruthy();
expect(document.getElementById(ariaControls!)).toBeInTheDocument();
});
it('6ๅไปฅไธใฎใใใซใง role="region" ใๆใค', () => {
render(Accordion, { props: { items: defaultItems } });
const regions = screen.getAllByRole('region');
expect(regions).toHaveLength(3);
});
it('7ๅไปฅไธใฎใใใซใง role="region" ใๆใใชใ', () => {
render(Accordion, { props: { items: manyItems } });
const regions = screen.queryAllByRole('region');
expect(regions).toHaveLength(0);
});
it('ใใใซใฎ aria-labelledby ใใใใใผ id ใจไธ่ด', () => {
render(Accordion, { props: { items: defaultItems } });
const button = screen.getByRole('button', { name: 'Section 1' });
const regions = screen.getAllByRole('region');
expect(regions[0]).toHaveAttribute('aria-labelledby', button.id);
});
it('disabled ้
็ฎใ aria-disabled="true" ใๆใค', () => {
render(Accordion, { props: { items: itemsWithDisabled } });
const disabledButton = screen.getByRole('button', { name: 'Section 2' });
expect(disabledButton).toHaveAttribute('aria-disabled', 'true');
});
});
describe('APG: ่ฆๅบใๆง้ ', () => {
it('headingLevel=3 ใง h3 ่ฆ็ด ใไฝฟ็จ', () => {
render(Accordion, { props: { items: defaultItems, headingLevel: 3 } });
const headings = document.querySelectorAll('h3');
expect(headings).toHaveLength(3);
});
it('headingLevel=2 ใง h2 ่ฆ็ด ใไฝฟ็จ', () => {
render(Accordion, { props: { items: defaultItems, headingLevel: 2 } });
const headings = document.querySelectorAll('h2');
expect(headings).toHaveLength(3);
});
});
// ๐ก Medium Priority: ใขใฏใปใทใใชใใฃๆค่จผ
describe('ใขใฏใปใทใใชใใฃ', () => {
it('axe ใซใใ WCAG 2.1 AA ้ๅใใชใ', async () => {
const { container } = render(Accordion, { props: { items: defaultItems } });
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
describe('Props', () => {
it('defaultExpanded ใงๅๆๅฑ้็ถๆ
ใๆๅฎใงใใ', () => {
render(Accordion, { props: { items: itemsWithDefaultExpanded } });
const button = screen.getByRole('button', { name: 'Section 1' });
expect(button).toHaveAttribute('aria-expanded', 'true');
});
it('allowMultiple=false ใง1ใคใฎใฟๅฑ้๏ผใใใฉใซใ๏ผ', async () => {
const user = userEvent.setup();
render(Accordion, { props: { items: defaultItems } });
const button1 = screen.getByRole('button', { name: 'Section 1' });
const button2 = screen.getByRole('button', { name: 'Section 2' });
await user.click(button1);
expect(button1).toHaveAttribute('aria-expanded', 'true');
await user.click(button2);
expect(button1).toHaveAttribute('aria-expanded', 'false');
expect(button2).toHaveAttribute('aria-expanded', 'true');
});
it('allowMultiple=true ใง่คๆฐๅฑ้ๅฏ่ฝ', async () => {
const user = userEvent.setup();
render(Accordion, { props: { items: defaultItems, allowMultiple: true } });
const button1 = screen.getByRole('button', { name: 'Section 1' });
const button2 = screen.getByRole('button', { name: 'Section 2' });
await user.click(button1);
await user.click(button2);
expect(button1).toHaveAttribute('aria-expanded', 'true');
expect(button2).toHaveAttribute('aria-expanded', 'true');
});
it('onExpandedChange ใๅฑ้็ถๆ
ๅคๅๆใซๅผใณๅบใใใ', async () => {
const handleExpandedChange = vi.fn();
const user = userEvent.setup();
render(Accordion, {
props: { items: defaultItems, onExpandedChange: handleExpandedChange },
});
await user.click(screen.getByRole('button', { name: 'Section 1' }));
expect(handleExpandedChange).toHaveBeenCalledWith(['section1']);
});
});
describe('็ฐๅธธ็ณป', () => {
it('disabled ้
็ฎใฏใฏใชใใฏใง้้ใใชใ', async () => {
const user = userEvent.setup();
render(Accordion, { props: { items: itemsWithDisabled } });
const disabledButton = screen.getByRole('button', { name: 'Section 2' });
expect(disabledButton).toHaveAttribute('aria-expanded', 'false');
await user.click(disabledButton);
expect(disabledButton).toHaveAttribute('aria-expanded', 'false');
});
it('disabled ใใค defaultExpanded ใฎ้
็ฎใฏๅฑ้ใใใชใ', () => {
const items: AccordionItem[] = [
{
id: 'section1',
header: 'Section 1',
content: 'Content 1',
disabled: true,
defaultExpanded: true,
},
];
render(Accordion, { props: { items } });
const button = screen.getByRole('button', { name: 'Section 1' });
expect(button).toHaveAttribute('aria-expanded', 'false');
});
});
}); Resources
- WAI-ARIA APG: Accordion Pattern (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist