app/components/Tabs/tabs.latte
{*
Tabs — záložky s panely.
Parametry:
- $id (string): unikátní ID
- $items (array): pole [['key' => 'k', 'label' => 'X', 'body' => 'html'], ...]
- $active (string|null): klíč aktivního tabu (default první)
*}
{var $active = $active ?? ($items[0]['key'] ?? null)}
<div class="tabs" id="{$id}" data-tabs>
<div class="tabs__list" role="tablist">
{foreach $items as $item}
{var $isActive = $item['key'] === $active}
<button type="button"
class="tabs__tab {$isActive ? 'is-active' : ''}"
role="tab"
data-tab="{$item['key']}"
aria-selected="{$isActive ? 'true' : 'false'}"
aria-controls="{$id}-panel-{$item['key']}"
id="{$id}-tab-{$item['key']}"
tabindex="{$isActive ? '0' : '-1'}">
{$item['label']}
</button>
{/foreach}
</div>
{foreach $items as $item}
{var $isActive = $item['key'] === $active}
<section class="tabs__panel"
role="tabpanel"
data-panel="{$item['key']}"
id="{$id}-panel-{$item['key']}"
aria-labelledby="{$id}-tab-{$item['key']}"
n:attr="hidden: !$isActive">
{$item['body']|noescape}
</section>
{/foreach}
</div>
resources/sass/components/_tabs.scss
.tabs {
&__list {
display: flex;
gap: 4px;
border-bottom: 2px solid $cgui-color-border;
margin-bottom: 16px;
overflow-x: auto;
}
&__tab {
flex-shrink: 0;
background: transparent;
border: none;
padding: 10px 16px;
font-size: 0.95em;
font-weight: 500;
color: $cgui-color-text-muted;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: color 0.15s, border-color 0.15s;
@include cgui-hover() {
color: $cgui-color-text;
}
&:focus-visible {
outline: 2px solid $cgui-color-primary;
outline-offset: 2px;
border-radius: 2px;
}
&.is-active,
&[aria-selected="true"] {
color: $cgui-color-primary;
border-bottom-color: $cgui-color-primary;
font-weight: 600;
}
}
&__panel {
animation: czg-fade-in 0.2s ease-out;
&[hidden] { display: none; }
}
}
resources/js/components/tabs.js
// Tabs — auto-init pro [data-tabs]. Roving tabindex, keyboard nav, deep-link via location.hash.
import { $, $$, on } from './base/utils.js';
class Tabs {
constructor(root) {
this.root = root;
this.tabs = $$('.tabs__tab', root);
this.panels = $$('.tabs__panel', root);
if (!this.tabs.length) return;
this.tabs.forEach((tab, idx) => {
on(tab, 'click', () => this.activate(idx));
on(tab, 'keydown', (e) => this._onKey(e, idx));
});
// Deep-link: pokud URL hash matchuje data-tab, aktivuj
this._activateFromHash();
on(window, 'hashchange', () => this._activateFromHash());
}
activate(idx) {
this.tabs.forEach((tab, i) => {
const active = i === idx;
tab.classList.toggle('is-active', active);
tab.setAttribute('aria-selected', active ? 'true' : 'false');
tab.setAttribute('tabindex', active ? '0' : '-1');
});
const activeKey = this.tabs[idx].dataset.tab;
this.panels.forEach((p) => {
p.hidden = p.dataset.panel !== activeKey;
});
}
_activateFromHash() {
const hash = (location.hash || '').slice(1);
if (!hash) return;
const idx = this.tabs.findIndex(t => t.dataset.tab === hash);
if (idx >= 0) this.activate(idx);
}
_onKey(e, idx) {
const keys = {
ArrowRight: () => this._focus((idx + 1) % this.tabs.length),
ArrowLeft: () => this._focus((idx - 1 + this.tabs.length) % this.tabs.length),
Home: () => this._focus(0),
End: () => this._focus(this.tabs.length - 1),
};
const handler = keys[e.key];
if (handler) {
e.preventDefault();
handler();
}
}
_focus(idx) {
this.tabs[idx].focus();
this.activate(idx);
}
}
export function initTabs(root = document) {
$$('[data-tabs]', root).forEach(el => new Tabs(el));
}
if (typeof document !== 'undefined') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => initTabs());
} else {
initTabs();
}
}