---
/**
* Tabs: a pill tab list with a sliding thumb and one panel per tab.
* Follows the WAI-ARIA tabs pattern: arrow keys move between tabs, Home and
* End jump to the ends, and the panel switches as focus moves.
*
* Each panel is a named slot matching the tab's `id`.
*
* @example
* <Tabs label="Report views" tabs={[{ id: "week", label: "This week" }, { id: "month", label: "This month" }]}>
* <div slot="week">…</div>
* <div slot="month">…</div>
* </Tabs>
*/
interface Tab {
id: string;
label: string;
}
interface Props {
tabs: Tab[];
/** Accessible name for the tab list. */
label: string;
/** Id of the tab selected on load (default: first). */
selected?: string;
/** Stretch the tab list to the full width. */
full?: boolean;
class?: string;
}
const { tabs, label, selected = tabs[0]?.id, full = false, class: className } = Astro.props;
const base = `vs-tabs-${Math.random().toString(36).slice(2, 8)}`;
const panels = await Promise.all(tabs.map((t) => (Astro.slots.has(t.id) ? Astro.slots.render(t.id) : "")));
---
<div class:list={["vs-tabs", { "is-full": full }, className]}>
<div class="vs-tabs__list" role="tablist" aria-label={label}>
<span class="vs-tabs__thumb" aria-hidden="true"></span>
{
tabs.map((t) => (
<button
type="button"
role="tab"
class="vs-tabs__tab"
id={`${base}-tab-${t.id}`}
aria-controls={`${base}-panel-${t.id}`}
aria-selected={t.id === selected ? "true" : "false"}
tabindex={t.id === selected ? 0 : -1}
>
{t.label}
</button>
))
}
</div>
{
tabs.map((t, i) => (
<div
class="vs-tabs__panel"
role="tabpanel"
id={`${base}-panel-${t.id}`}
aria-labelledby={`${base}-tab-${t.id}`}
tabindex="0"
hidden={t.id !== selected}
set:html={panels[i]}
/>
))
}
</div>
<style>
.vs-tabs {
color: var(--vs-ink);
font-family: var(--vs-font-sans);
font-stretch: var(--vs-font-stretch);
}
.vs-tabs__list {
position: relative;
display: inline-flex;
max-width: 100%;
overflow-x: auto;
scrollbar-width: none;
padding: 4px;
gap: 2px;
border-radius: var(--vs-radius-pill);
background: var(--vs-field);
box-shadow: var(--vs-shadow-inset-field);
}
.vs-tabs.is-full .vs-tabs__list {
display: flex;
}
.vs-tabs.is-full .vs-tabs__tab {
flex: 1;
}
.vs-tabs__thumb {
position: absolute;
top: 4px;
bottom: 4px;
left: 0;
width: 0;
border-radius: var(--vs-radius-pill);
background: var(--vs-surface);
box-shadow: var(--vs-shadow-btn);
opacity: 0;
transition:
transform var(--vs-duration) var(--vs-ease-out),
width var(--vs-duration) var(--vs-ease-out);
}
.vs-tabs[data-vs-ready] .vs-tabs__thumb {
opacity: 1;
}
.vs-tabs__tab {
position: relative;
z-index: 1;
flex: none;
min-height: 36px;
padding: 0 18px;
border: none;
border-radius: var(--vs-radius-pill);
background: transparent;
color: var(--vs-ink-2);
font: inherit;
font-size: 0.9375rem;
font-weight: 500;
white-space: nowrap;
cursor: pointer;
transition:
color var(--vs-duration-fast) ease-out,
background-color var(--vs-duration-fast) ease-out;
}
.vs-tabs__tab:hover {
color: var(--vs-ink);
}
.vs-tabs__tab[aria-selected="true"] {
color: var(--vs-ink);
}
/* Before the script runs, the selected tab draws its own fill. */
.vs-tabs:not([data-vs-ready]) .vs-tabs__tab[aria-selected="true"] {
background: var(--vs-surface);
box-shadow: var(--vs-shadow-btn);
}
.vs-tabs__tab:focus-visible,
.vs-tabs__panel:focus-visible {
outline: none;
box-shadow: var(--vs-focus-ring);
}
.vs-tabs__panel {
margin-top: 20px;
border-radius: var(--vs-radius-card);
animation: vs-fade-up var(--vs-duration) var(--vs-ease-out);
}
</style>
<script>
function init(root: HTMLElement) {
const list = root.querySelector<HTMLElement>(".vs-tabs__list")!;
const thumb = root.querySelector<HTMLElement>(".vs-tabs__thumb")!;
const tabs = [...root.querySelectorAll<HTMLButtonElement>(".vs-tabs__tab")];
const panels = tabs.map((t) => root.querySelector<HTMLElement>(`#${CSS.escape(t.getAttribute("aria-controls")!)}`));
const place = (tab: HTMLElement) => {
thumb.style.width = `${tab.offsetWidth}px`;
thumb.style.transform = `translateX(${tab.offsetLeft}px)`;
};
const select = (i: number, focus = true) => {
tabs.forEach((t, j) => {
const on = i === j;
t.setAttribute("aria-selected", String(on));
t.tabIndex = on ? 0 : -1;
if (panels[j]) panels[j]!.hidden = !on;
});
place(tabs[i]);
if (focus) tabs[i].focus();
tabs[i].scrollIntoView({ block: "nearest", inline: "nearest" });
root.dispatchEvent(new CustomEvent("vs-change", { detail: { id: tabs[i].id, index: i }, bubbles: true }));
};
tabs.forEach((t, i) => t.addEventListener("click", () => select(i, false)));
list.addEventListener("keydown", (e) => {
const i = tabs.indexOf(document.activeElement as HTMLButtonElement);
if (i < 0) return;
const n = tabs.length;
const next = { ArrowRight: (i + 1) % n, ArrowLeft: (i - 1 + n) % n, Home: 0, End: n - 1 }[e.key];
if (next === undefined) return;
e.preventDefault();
select(next);
});
const current = () => tabs.find((t) => t.getAttribute("aria-selected") === "true") ?? tabs[0];
// Place without animating, then let later moves slide.
thumb.style.transition = "none";
place(current());
void thumb.offsetWidth; // commit the position before re-enabling the slide
thumb.style.transition = "";
new ResizeObserver(() => place(current())).observe(list);
}
const boot = () =>
document.querySelectorAll<HTMLElement>(".vs-tabs:not([data-vs-ready])").forEach((root) => {
root.dataset.vsReady = "";
init(root);
});
boot();
document.addEventListener("astro:page-load", boot);
</script>