src/components/ui/Tabs.astro
---
/**
* Tabs
* Toegankelijke tab-interface. Tekst per tab via slot of `body`-prop.
* Tab-wisseling via vanilla JS; volledige ARIA (tablist/tab/tabpanel).
*
* Props:
* - tabs: Array<{ id: string; label: string; body?: string }>
* - variant?: 'underline' | 'pill' | 'boxed', default: underline
* - size?: 'sm' | 'md' | 'lg', default: md
* - active?: string, id van de standaard-actieve tab (default: eerste)
*/
interface Tab {
id: string;
label: string;
body?: string;
}
interface Props {
tabs?: Tab[];
variant?: 'underline' | 'pill' | 'boxed';
size?: 'sm' | 'md' | 'lg';
active?: string;
}
const {
tabs = [
{ id: 'tab-1', label: 'Eerste', body: 'Inhoud van de eerste tab.' },
{ id: 'tab-2', label: 'Tweede', body: 'Inhoud van de tweede tab.' },
{ id: 'tab-3', label: 'Derde', body: 'Inhoud van de derde tab.' },
],
variant = 'underline',
size = 'md',
active,
} = Astro.props;
const activeId = active && tabs.some(t => t.id === active) ? active : (tabs[0]?.id ?? '');
---
<section class="bl-section tabs-section">
<div class="bl-inner tabs-section__inner">
<div class={`tabs tabs--${variant} tabs--${size}`} data-component="tabs" data-active={activeId}>
<div class="tabs__list" role="tablist" aria-label="Tabs">
{tabs.map(tab => {
const isActive = tab.id === activeId;
return (
<button
class:list={['tabs__tab', { 'tabs__tab--active': isActive }]}
role="tab"
aria-selected={isActive ? 'true' : 'false'}
aria-controls={`tabpanel-${tab.id}`}
id={`tab-${tab.id}`}
data-tab={tab.id}
tabindex={isActive ? 0 : -1}
type="button"
>
<span>{tab.label}</span>
</button>
);
})}
</div>
<div class="tabs__panels">
{tabs.map(tab => {
const isActive = tab.id === activeId;
return (
<div
class:list={['tabs__panel', { 'tabs__panel--active': isActive }]}
role="tabpanel"
id={`tabpanel-${tab.id}`}
aria-labelledby={`tab-${tab.id}`}
data-tab={tab.id}
tabindex="0"
hidden={!isActive}
>
{tab.body && <p class="tabs__body">{tab.body}</p>}
</div>
);
})}
</div>
</div>
</div>
</section>
<style>
.tabs-section { background: var(--color-bg); }
.tabs__list {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
border-bottom: 1px solid color-mix(in srgb, var(--color-text) 12%, transparent);
}
.tabs__tab {
appearance: none;
background: none;
border: none;
font-family: inherit;
font-weight: 600;
color: var(--color-muted);
cursor: pointer;
padding: 0.75rem 1.125rem;
position: relative;
transition: color 0.2s, background 0.2s;
}
.tabs--sm .tabs__tab { font-size: 0.875rem; padding: 0.5rem 0.875rem; }
.tabs--md .tabs__tab { font-size: 0.9375rem; }
.tabs--lg .tabs__tab { font-size: 1.0625rem; padding: 0.875rem 1.375rem; }
.tabs__tab:hover { color: var(--color-text); }
.tabs__tab--active { color: var(--color-text); }
/* Underline variant */
.tabs--underline .tabs__tab--active::after {
content: '';
position: absolute;
left: 0; right: 0; bottom: -1px;
height: 2px;
background: var(--color-accent);
}
/* Pill variant */
.tabs--pill .tabs__list { border-bottom: none; gap: 0.375rem; }
.tabs--pill .tabs__tab { border-radius: 999px; }
.tabs--pill .tabs__tab--active {
background: var(--color-accent);
color: #fff;
}
/* Boxed variant */
.tabs--boxed .tabs__tab {
border: 1px solid transparent;
border-bottom: none;
border-radius: var(--radius) var(--radius) 0 0;
margin-bottom: -1px;
}
.tabs--boxed .tabs__tab--active {
border-color: color-mix(in srgb, var(--color-text) 12%, transparent);
background: var(--color-bg);
}
.tabs__panels { padding-top: 1.5rem; }
.tabs__panel { outline: none; }
.tabs__panel[hidden] { display: none; }
.tabs__body {
font-size: 0.9375rem;
color: var(--color-muted);
line-height: 1.7;
margin: 0;
}
</style>
<script>
document.querySelectorAll('[data-component="tabs"]').forEach(root => {
const tabsEl = root as HTMLElement;
const tabBtns = Array.from(tabsEl.querySelectorAll<HTMLButtonElement>('.tabs__tab'));
const panels = Array.from(tabsEl.querySelectorAll<HTMLElement>('.tabs__panel'));
if (!tabBtns.length) return;
function activate(id: string, focus = false) {
tabsEl.setAttribute('data-active', id);
tabBtns.forEach(btn => {
const on = btn.getAttribute('data-tab') === id;
btn.classList.toggle('tabs__tab--active', on);
btn.setAttribute('aria-selected', on ? 'true' : 'false');
btn.tabIndex = on ? 0 : -1;
if (on && focus) btn.focus();
});
panels.forEach(panel => {
const on = panel.getAttribute('data-tab') === id;
panel.classList.toggle('tabs__panel--active', on);
if (on) panel.removeAttribute('hidden');
else panel.setAttribute('hidden', '');
});
}
tabBtns.forEach((btn, i) => {
btn.addEventListener('click', () => {
const id = btn.getAttribute('data-tab');
if (id) activate(id);
});
btn.addEventListener('keydown', e => {
let next = -1;
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = (i + 1) % tabBtns.length;
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') next = (i - 1 + tabBtns.length) % tabBtns.length;
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = tabBtns.length - 1;
if (next < 0) return;
e.preventDefault();
const id = tabBtns[next].getAttribute('data-tab');
if (id) activate(id, true);
});
});
});
</script>