Accordion
componentSections that open and close, on details and summary: they work without JavaScript, the browser keeping one open at a time; the controller adds the arrows, Home and End.
Preview
Is it free?
Yes.
Where?
In Montreal.
Code
<twig:Accordion id="faq" defaultValue="price">
<twig:Accordion:Item value="price">
<twig:Accordion:Trigger>Is it free?</twig:Accordion:Trigger>
<twig:Accordion:Content>Yes.</twig:Accordion:Content>
</twig:Accordion:Item>
<twig:Accordion:Item value="where">
<twig:Accordion:Trigger>Where?</twig:Accordion:Trigger>
<twig:Accordion:Content>In Montreal.</twig:Accordion:Content>
</twig:Accordion:Item>
</twig:Accordion>
Is it free?
Yes.
Where?
In Montreal.
Code
<twig:Accordion id="faq" defaultValue="price">
<twig:Accordion:Item value="price">
<twig:Accordion:Trigger>Is it free?</twig:Accordion:Trigger>
<twig:Accordion:Content>Yes.</twig:Accordion:Content>
</twig:Accordion:Item>
<twig:Accordion:Item value="where">
<twig:Accordion:Trigger>Where?</twig:Accordion:Trigger>
<twig:Accordion:Content>In Montreal.</twig:Accordion:Content>
</twig:Accordion:Item>
</twig:Accordion>
Try it
Change the props; the example is rendered again by the server.
Is it free?
Yes.
Where?
In Montreal.
<twig:Accordion id="faq" defaultValue="price">
<twig:Accordion:Item value="price">
<twig:Accordion:Trigger>Is it free?</twig:Accordion:Trigger>
<twig:Accordion:Content>Yes.</twig:Accordion:Content>
</twig:Accordion:Item>
<twig:Accordion:Item value="where">
<twig:Accordion:Trigger>Where?</twig:Accordion:Trigger>
<twig:Accordion:Content>In Montreal.</twig:Accordion:Content>
</twig:Accordion:Item>
</twig:Accordion>
<twig:Accordion id="faq" defaultValue="price">
<twig:Accordion:Item value="price">
<twig:Accordion:Trigger>Is it free?</twig:Accordion:Trigger>
<twig:Accordion:Content>Yes.</twig:Accordion:Content>
</twig:Accordion:Item>
<twig:Accordion:Item value="where">
<twig:Accordion:Trigger>Where?</twig:Accordion:Trigger>
<twig:Accordion:Content>In Montreal.</twig:Accordion:Content>
</twig:Accordion:Item>
</twig:Accordion>
Variants
multiple
Whether multiple items can be opened at once.
Is it free?
Yes.
Where?
In Montreal.
Is it free?
Yes.
Where?
In Montreal.
Is it free?
Yes.
Where?
In Montreal.
Is it free?
Yes.
Where?
In Montreal.
orientation
The visual orientation, controls whether keyboard navigation uses up/down or left/right arrow keys.
Is it free?
Yes.
Where?
In Montreal.
Is it free?
Yes.
Where?
In Montreal.
Is it free?
Yes.
Where?
In Montreal.
Is it free?
Yes.
Where?
In Montreal.
Installation
php bin/console ux:install accordion --kit=github.com/parler-haut-interagir-librement/ux-kit
Props
| Prop | Type | Default | Description |
|---|---|---|---|
multiple |
bool |
false |
Whether multiple items can be opened at once. |
defaultValue |
string|array<string>|null |
null |
Value(s) of the item(s) to open by default. |
orientation |
'vertical'|'horizontal' |
'vertical' |
The visual orientation, controls whether keyboard navigation uses up/down or left/right arrow keys. |
Accessibility
summary is focused and toggled by the keyboard natively. A disabled item is inert: it can neither be focused nor opened.
Files to copy
assets/controllers/accordion_controller.js
import { Controller } from '@hotwired/stimulus';
/**
* The trigger a key moves to from `index` among `count`: along the orientation, the side arrows mirrored when the page
* reads right to left, Home and End the first and the last; `null` for any other key.
*/
export function moved(key, index, count, { orientation = 'vertical', rtl = false } = {}) {
const next = (index + 1) % count;
const previous = (index - 1 + count) % count;
const horizontal = orientation === 'horizontal';
switch (key) {
case 'ArrowDown':
return horizontal ? null : next;
case 'ArrowUp':
return horizontal ? null : previous;
case 'ArrowRight':
return horizontal ? (rtl ? previous : next) : null;
case 'ArrowLeft':
return horizontal ? (rtl ? next : previous) : null;
case 'Home':
return 0;
case 'End':
return count - 1;
default:
return null;
}
}
/*
* The arrows, Home and End between the summaries of an accordion. Opening and closing, and keeping one item open at a
* time through the `name` of its `details`, are the browser's work.
*/
export default class extends Controller {
static targets = ['item', 'trigger', 'content'];
static values = {
multiple: { type: Boolean, default: false },
orientation: { type: String, default: 'vertical' },
};
toggle(event) {
const trigger = event.currentTarget;
if (trigger.tagName === 'SUMMARY') {
return;
}
const item = trigger.closest('details');
if (item && !item.inert) {
item.open = !item.open;
}
}
handleKeydown(event) {
const triggers = this.triggerTargets.filter((trigger) => !trigger.closest('details')?.inert);
const index = moved(event.key, triggers.indexOf(event.currentTarget), triggers.length, {
orientation: this.orientationValue,
rtl: getComputedStyle(this.element).direction === 'rtl',
});
if (index !== null) {
event.preventDefault();
triggers[index].focus();
}
}
}
templates/components/Accordion.html.twig
{%- props
## string Unique identifier for the Accordion.
id,
## bool Whether multiple items can be opened at once.
multiple = false,
## string|array<string>|null Value(s) of the item(s) to open by default.
defaultValue = null,
## 'vertical'|'horizontal' The visual orientation, controls whether keyboard navigation uses up/down or left/right arrow keys.
orientation = 'vertical'
-%}
{%- do provide('accordion.id', id) -%}
{%- do provide('accordion.multiple', multiple) -%}
{%- do provide('accordion.orientation', orientation) -%}
{%- do provide('accordion.defaultValue', defaultValue) -%}
<div id="{{ id }}" data-slot="accordion" data-accordion-multiple-value="{{ multiple ? 'true' : 'false' }}" data-accordion-orientation-value="{{ orientation }}" data-orientation="{{ orientation }}"{{ attributes.defaults({class: 'isAccordion', 'data-controller': 'accordion'}) }}>
{##- The accordion items, typically multiple `Accordion:Item` components. -#}
{%- block content %}{% endblock -%}
</div>
templates/components/Accordion/Content.html.twig
<div id="{{ inject('accordionItem.contentId') }}" data-slot="accordion-content" data-accordion-target="content" role="region" aria-labelledby="{{ inject('accordionItem.triggerId') }}"{{ attributes.defaults({class: 'isAccordionContent'}) }}>
{##- The collapsible content of the accordion item. -#}
{%- block content %}{% endblock -%}
</div>
templates/components/Accordion/Item.html.twig
{%- props
## string Unique value for this accordion item.
value,
## bool Whether the item is open by default.
open = false,
## bool Whether the item is disabled.
disabled = false
-%}
{%- set _accordion_id = inject('accordion.id') -%}
{%- set _accordion_default = inject('accordion.defaultValue') -%}
{%- set _accordion_open = open or _accordion_default is same as(value) or (_accordion_default is iterable and value in _accordion_default) -%}
{%- set _accordion_item_id = _accordion_id ~ '-' ~ value -%}
{%- do provide('accordionItem.triggerId', _accordion_item_id ~ '-trigger') -%}
{%- do provide('accordionItem.contentId', _accordion_item_id ~ '-content') -%}
<details data-slot="accordion-item" data-accordion-target="item" data-value="{{ value }}" data-disabled="{{ disabled ? 'true' : 'false' }}"{% if not inject('accordion.multiple', false) %} name="{{ _accordion_id }}"{% endif %}{% if _accordion_open %} open{% endif %}{% if disabled %} inert{% endif %}{{ attributes.defaults({class: 'isAccordionItem'}) }}>
{##- The item content, typically an `Accordion:Trigger` and `Accordion:Content`. -#}
{%- block content %}{% endblock -%}
</details>
templates/components/Accordion/Trigger.html.twig
<summary id="{{ inject('accordionItem.triggerId') }}" data-slot="accordion-trigger" data-accordion-target="trigger" data-orientation="{{ inject('accordion.orientation', 'vertical') }}"{{ attributes.defaults({class: 'isAccordionTrigger', 'data-action': 'click->accordion#toggle keydown->accordion#handleKeydown'}) }}>
{##- The clickable trigger label that toggles the accordion item. -#}
{%- block content %}{% endblock -%}
</summary>