accordion — Rozbalovací sekce s ARIA, keyboard nav a smooth animation

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