app/components/Accordion/accordion.latte
{*
Accordion — rozbalovací sekce.
Parametry:
- $id (string): unikátní ID accordionu
- $items (array): pole [['title' => 'X', 'body' => 'html'], ...]
- $multiple (bool): povolit více otevřených zároveň (default false)
*}
{var $multiple = $multiple ?? false}
<div class="accordion"
id="{$id}"
data-accordion
data-multiple="{$multiple ? 'true' : 'false'}">
{foreach $items as $i => $item}
{var $itemId = "$id-item-$i"}
<section class="accordion__item">
<h3 class="accordion__heading">
<button type="button"
class="accordion__trigger"
data-accordion-trigger
aria-expanded="false"
aria-controls="{$itemId}-panel"
id="{$itemId}-trigger">
<span class="accordion__title">{$item['title']}</span>
<svg class="accordion__icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 9l6 6 6-6"/>
</svg>
</button>
</h3>
<div class="accordion__panel"
id="{$itemId}-panel"
data-accordion-panel
role="region"
aria-labelledby="{$itemId}-trigger"
hidden>
<div class="accordion__body">
{$item['body']|noescape}
</div>
</div>
</section>
{/foreach}
</div>
resources/sass/components/_accordion.scss
.accordion {
border: 1px solid $cgui-color-border;
border-radius: 4px;
overflow: hidden;
&__item {
& + & { border-top: 1px solid $cgui-color-border; }
}
&__heading { margin: 0; }
&__trigger {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 18px;
background: $cgui-color-bg;
border: none;
text-align: left;
font-size: 1em;
font-weight: 600;
color: $cgui-color-text;
cursor: pointer;
transition: background 0.15s;
@include cgui-hover() {
background: $cgui-color-bg-hover;
}
&:focus-visible {
outline: 2px solid $cgui-color-primary;
outline-offset: -2px;
}
&[aria-expanded="true"] .accordion__icon {
transform: rotate(180deg);
}
}
&__icon {
flex-shrink: 0;
transition: transform 0.2s;
color: $cgui-color-text-muted;
}
&__panel {
overflow: hidden;
transition: height 0.25s ease;
&[hidden] {
display: none;
}
}
&__body {
padding: 4px 18px 18px;
color: $cgui-color-text;
}
}
resources/js/components/accordion.js
// Accordion — auto-init pro [data-accordion]. Smooth height animation, ARIA, keyboard nav.
import { $, $$, on } from './base/utils.js';
class Accordion {
constructor(root) {
this.root = root;
this.multiple = root.dataset.multiple === 'true';
this.items = $$('.accordion__item', root);
this.items.forEach((item, idx) => {
const trigger = $('[data-accordion-trigger]', item);
const panel = $('[data-accordion-panel]', item);
if (!trigger || !panel) return;
on(trigger, 'click', () => this.toggle(idx));
on(trigger, 'keydown', (e) => this._onKey(e, idx));
});
}
toggle(idx) {
const item = this.items[idx];
const trigger = $('[data-accordion-trigger]', item);
const panel = $('[data-accordion-panel]', item);
const isOpen = trigger.getAttribute('aria-expanded') === 'true';
if (isOpen) {
this._close(trigger, panel);
} else {
if (!this.multiple) {
this.items.forEach((other) => {
if (other === item) return;
this._close($('[data-accordion-trigger]', other), $('[data-accordion-panel]', other));
});
}
this._open(trigger, panel);
}
}
_open(trigger, panel) {
trigger.setAttribute('aria-expanded', 'true');
panel.hidden = false;
const h = panel.scrollHeight;
panel.style.height = '0px';
requestAnimationFrame(() => { panel.style.height = h + 'px'; });
const cleanup = () => {
panel.style.height = '';
panel.removeEventListener('transitionend', cleanup);
};
panel.addEventListener('transitionend', cleanup);
}
_close(trigger, panel) {
trigger.setAttribute('aria-expanded', 'false');
const h = panel.scrollHeight;
panel.style.height = h + 'px';
requestAnimationFrame(() => { panel.style.height = '0px'; });
const cleanup = () => {
panel.hidden = true;
panel.style.height = '';
panel.removeEventListener('transitionend', cleanup);
};
panel.addEventListener('transitionend', cleanup);
}
_onKey(e, idx) {
const triggers = this.items.map(i => $('[data-accordion-trigger]', i));
const keys = {
ArrowDown: () => triggers[(idx + 1) % triggers.length].focus(),
ArrowUp: () => triggers[(idx - 1 + triggers.length) % triggers.length].focus(),
Home: () => triggers[0].focus(),
End: () => triggers[triggers.length - 1].focus(),
};
const handler = keys[e.key];
if (handler) {
e.preventDefault();
handler();
}
}
}
export function initAccordions(root = document) {
$$('[data-accordion]', root).forEach(el => new Accordion(el));
}
if (typeof document !== 'undefined') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => initAccordions());
} else {
initAccordions();
}
}