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