tabs — Tabs s ARIA, keyboard nav (Arrow keys, Home/End) a deep-link via URL hash

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