Skip to content

Accordion

accordion() is a behavior for a list of headers that each show and hide a panel, following the WAI-ARIA accordion pattern. You render the header buttons and the panels; accordionAttrs() gives them aria-expanded, the ids that link each button to its panel, and hidden on the closed panels.

import { accordion, accordionAttrs } from 'sygnal/ui'
const FAQ = [
{ id: 'shipping', question: 'How long does shipping take?', answer: 'Two to four working days.' },
{ id: 'returns', question: 'Can I return an item?', answer: 'Within 30 days, unused.' },
]
function Faq({ state, uid }) {
const a = accordionAttrs(state.faq, uid)
return (
<div className="faq">
{FAQ.map((item) => (
<div className="faq-item">
<h3><button className="faq-question" {...a.trigger(item.id)}>{item.question}</button></h3>
<div className="faq-answer" {...a.panel(item.id)}><p>{item.answer}</p></div>
</div>
))}
</div>
)
}
Faq.uses = { faq: accordion({ trigger: '.faq-question' }) }

Wrap each button in a heading of the level that fits the page, so the headers are in the page outline.

OptionDefault
trigger(required)The header buttons: a selector or a control
multiplefalseSeveral panels open at once; without it, opening one closes the others
collapsibletruefalse: the last open panel can’t be closed (its button gets aria-disabled)
expanded[]The panels open at the start: a value or an array
looptrueDown on the last header goes to the first
idthe key in usesThe prefix of the ids
Key
Enter / SpaceOpens or closes the focused header’s panel
Down / UpThe next or previous header
Home / EndThe first or last header
TabThe next focusable element, through the open panels

state.faq is { id, expanded, collapsible }: expanded lists the open panels’ values. Actions: faq.TOGGLE, faq.EXPAND and faq.COLLAPSE take a value (a click on a header toggles its own), faq.MOVE focuses a header. Open a panel from your model with next('faq.EXPAND', 'returns').

accordionAttrs(state.faq, uid) returns:

Attributes
trigger(value)id, aria-expanded, aria-controls, aria-disabled (only on the open panel that can’t close), type="button", data-value, data-state (open / closed)
panel(value)id, role="region", aria-labelledby, hidden unless open, data-state
.faq-question { width: 100%; text-align: start; }
.faq-question[data-state='open']::after { content: '−'; }
.faq-question[data-state='closed']::after { content: '+'; }
import { it, expect } from 'vitest'
import { renderComponent } from 'sygnal'
import Faq from './Faq.jsx'
it('opens one answer at a time', async () => {
const t = renderComponent(Faq)
await t.ready()
t.simulateEvent('.faq-question', 'click', { data: { value: 'shipping' } })
await t.next((s) => s.faq.expanded.includes('shipping'))
t.simulateAction('faq.TOGGLE', 'returns')
await t.next((s) => s.faq.expanded.includes('returns'))
expect(t.state.faq.expanded).toEqual(['returns'])
expect(t.query('.faq-question[data-value="returns"]').getAttribute('aria-expanded')).toBe('true')
expect(t.actions.map((a) => a.type)).toEqual(['INITIALIZE', 'faq.TOGGLE', 'faq.TOGGLE'])
t.dispose()
})

The arrow keys read the order of the headers on the page: test them with dom: 'real'.