dropdown — Dropdown menu s keyboard navigation, ARIA, ESC a klik mimo

app/components/Dropdown/dropdown.latte
{*
	Dropdown — toggle button + menu s odkazy.

	Parametry:
	- $id (string): unikátní ID dropdown
	- $label (string): text triggeru
	- $items (array): pole [['label' => 'X', 'href' => '/x'], ...]
	- $align (string): start|end (default start) — zarovnání menu
*}
{var $align = $align ?? 'start'}

<div class="dropdown" data-dropdown id="{$id}">
	<button type="button"
	        class="dropdown__trigger"
	        data-dropdown-trigger
	        aria-haspopup="true"
	        aria-expanded="false"
	        aria-controls="{$id}-menu">
		{$label}
		<svg class="dropdown__chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
			<path d="M6 9l6 6 6-6"/>
		</svg>
	</button>

	<ul class="dropdown__menu dropdown__menu--{$align}" id="{$id}-menu" data-dropdown-menu role="menu" aria-hidden="true">
		{foreach $items as $item}
			<li role="none">
				<a href="{$item['href']}" class="dropdown__item" role="menuitem" tabindex="-1">
					{$item['label']}
				</a>
			</li>
		{/foreach}
	</ul>
</div>
resources/sass/components/_dropdown.scss
.dropdown {
	position: relative;
	display: inline-block;

	&__trigger {
		display: inline-flex;
		align-items: center;
		gap: 6px;
		background: $cgui-color-bg;
		color: $cgui-color-text;
		border: 1px solid $cgui-color-border;
		border-radius: 4px;
		padding: 6px 12px;
		font-weight: 500;
		cursor: pointer;
		transition: background 0.15s, border-color 0.15s;

		@include cgui-hover() {
			background: $cgui-color-bg-hover;
			border-color: $cgui-color-primary;
		}

		&[aria-expanded="true"] .dropdown__chevron {
			transform: rotate(180deg);
		}
	}

	&__chevron {
		transition: transform 0.15s;
	}

	&__menu {
		position: absolute;
		top: calc(100% + 4px);
		min-width: 100%;
		max-height: 320px;
		overflow-y: auto;
		background: $cgui-color-bg;
		border: 1px solid $cgui-color-border;
		border-radius: 4px;
		box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
		list-style: none;
		padding: 4px 0;
		margin: 0;
		z-index: $cgui-z-dropdown;
		display: none;

		&--start { left: 0; }
		&--end   { right: 0; }
	}

	&.is-open &__menu {
		display: block;
		animation: czg-slide-down 0.15s ease-out;
	}

	&__item {
		display: block;
		padding: 8px 14px;
		color: $cgui-color-text;
		text-decoration: none;
		white-space: nowrap;

		@include cgui-hover() {
			background: $cgui-color-bg-hover;
		}

		&:focus {
			outline: 2px solid $cgui-color-primary;
			outline-offset: -2px;
			background: $cgui-color-bg-hover;
		}
	}
}
resources/js/components/dropdown.js
// Dropdown — auto-init pro [data-dropdown] s keyboard nav (Arrow Up/Down, Home/End, Enter, ESC).

import { $, $$, on } from './base/utils.js';
import { Overlay } from './overlay.js';

class Dropdown {
	constructor(root) {
		this.root = root;
		this.trigger = $('[data-dropdown-trigger]', root);
		this.menu = $('[data-dropdown-menu]', root);
		this.items = $$('.dropdown__item', root);
		this.activeIndex = -1;

		if (!this.trigger || !this.menu || !this.items.length) return;

		this.overlay = new Overlay(root, {
			onOpen: () => {
				this.trigger.setAttribute('aria-expanded', 'true');
				this.menu.setAttribute('aria-hidden', 'false');
				this.items.forEach(i => i.setAttribute('tabindex', '0'));
				this.activeIndex = 0;
				this.items[0].focus();
			},
			onClose: () => {
				this.trigger.setAttribute('aria-expanded', 'false');
				this.menu.setAttribute('aria-hidden', 'true');
				this.items.forEach(i => i.setAttribute('tabindex', '-1'));
				this.activeIndex = -1;
			},
		});

		on(this.trigger, 'click', (e) => {
			e.stopPropagation();
			this.overlay.toggle();
		});

		on(this.menu, 'keydown', (e) => this._onKeydown(e));
	}

	_onKeydown(e) {
		const keys = {
			ArrowDown: () => this._move(1),
			ArrowUp: () => this._move(-1),
			Home: () => this._focusItem(0),
			End: () => this._focusItem(this.items.length - 1),
			Escape: () => { this.overlay.close(); this.trigger.focus(); },
		};
		const handler = keys[e.key];
		if (handler) {
			e.preventDefault();
			handler();
		}
	}

	_move(delta) {
		const next = (this.activeIndex + delta + this.items.length) % this.items.length;
		this._focusItem(next);
	}

	_focusItem(idx) {
		this.activeIndex = idx;
		this.items[idx].focus();
	}
}

export function initDropdowns(root = document) {
	$$('[data-dropdown]', root).forEach(el => new Dropdown(el));
}

if (typeof document !== 'undefined') {
	if (document.readyState === 'loading') {
		document.addEventListener('DOMContentLoaded', () => initDropdowns());
	} else {
		initDropdowns();
	}
}