Tabs
Tabbed panels.
8pm: doors open. 9pm: demos. Late: building.
Noon: brunch and show and tell.
Code
Copy into src/components/ph/ .
Tabs.astro
---
type Tab = {
value: string;
label: string;
};
type Props = {
tabs: Tab[];
value?: string;
label?: string;
class?: string;
};
const { tabs, value = tabs[0]?.value, label, class: className } = Astro.props;
---
<div data-ph-tabs data-value={value} class:list={["flex flex-col gap-4 [&:not([data-ready])>[data-ph-tab-panel]~[data-ph-tab-panel]]:hidden", className]}>
<div role="tablist" aria-label={label} class="flex flex-wrap gap-1 border-b border-foreground">
{
tabs.map((t) => (
<button
type="button"
role="tab"
data-value={t.value}
aria-selected={t.value === value ? "true" : "false"}
tabindex={t.value === value ? 0 : -1}
class="-mb-px cursor-pointer px-3 py-1.5 font-mono text-[13px] font-semibold tracking-wide uppercase transition-colors duration-150 ease-quint hover:bg-foreground/[0.08] aria-selected:bg-foreground aria-selected:text-background"
>
{t.label}
</button>
))
}
</div>
<slot />
</div>
<script>
for (const root of document.querySelectorAll<HTMLElement>("[data-ph-tabs]")) {
const tabs = [...root.querySelectorAll<HTMLButtonElement>(':scope > [role="tablist"] > [role="tab"]')];
const panels = [...root.querySelectorAll<HTMLElement>(":scope > [data-ph-tab-panel]")];
const uid = Math.random().toString(36).slice(2, 8);
const select = (value: string, focus = false) => {
for (const t of tabs) {
const on = t.dataset.value === value;
t.setAttribute("aria-selected", String(on));
t.tabIndex = on ? 0 : -1;
if (on && focus) t.focus();
}
for (const p of panels) p.hidden = p.dataset.phTabPanel !== value;
};
for (const [i, t] of tabs.entries()) {
t.id = `ph-tab-${uid}-${t.dataset.value}`;
t.setAttribute("aria-controls", `ph-panel-${uid}-${t.dataset.value}`);
t.addEventListener("click", () => select(t.dataset.value!));
t.addEventListener("keydown", (e) => {
const step = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
if (!step) return;
e.preventDefault();
select(tabs[(i + step + tabs.length) % tabs.length].dataset.value!, true);
});
}
for (const p of panels) {
p.id = `ph-panel-${uid}-${p.dataset.phTabPanel}`;
p.setAttribute("aria-labelledby", `ph-tab-${uid}-${p.dataset.phTabPanel}`);
}
select(root.dataset.value!);
root.dataset.ready = "";
}
</script>
TabPanel.astro
---
type Props = {
value: string;
class?: string;
};
const { value, class: className } = Astro.props;
---
<div role="tabpanel" data-ph-tab-panel={value} tabindex="0" class:list={["outline-none", className]}>
<slot />
</div>
Copy into components/ph/ (or src/components/ph/).
tabs.tsx
"use client";
import { createContext, useContext, useId, useRef, useState, type ReactNode } from "react";
import { cn } from "./cn";
type Tab = {
value: string;
label: string;
};
const TabsContext = createContext<{ value: string; id: string } | null>(null);
type TabsProps = {
tabs: Tab[];
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
label?: string;
children: ReactNode;
className?: string;
};
export function Tabs({ tabs, value, defaultValue, onValueChange, label, children, className }: TabsProps) {
const id = useId();
const [inner, setInner] = useState(defaultValue ?? tabs[0]?.value);
const current = value ?? inner;
const refs = useRef<(HTMLButtonElement | null)[]>([]);
const select = (next: string) => {
setInner(next);
onValueChange?.(next);
};
return (
<TabsContext.Provider value={{ value: current, id }}>
<div className={cn("flex flex-col gap-4", className)}>
<div role="tablist" aria-label={label} className="flex flex-wrap gap-1 border-b border-foreground">
{tabs.map((t, i) => (
<button
key={t.value}
ref={(el) => {
refs.current[i] = el;
}}
type="button"
role="tab"
id={`${id}-tab-${t.value}`}
aria-controls={`${id}-panel-${t.value}`}
aria-selected={t.value === current}
tabIndex={t.value === current ? 0 : -1}
onClick={() => select(t.value)}
onKeyDown={(e) => {
const step = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
if (!step) return;
e.preventDefault();
const next = (i + step + tabs.length) % tabs.length;
select(tabs[next].value);
refs.current[next]?.focus();
}}
className="-mb-px cursor-pointer px-3 py-1.5 font-mono text-[13px] font-semibold tracking-wide uppercase transition-colors duration-150 ease-quint hover:bg-foreground/[0.08] aria-selected:bg-foreground aria-selected:text-background"
>
{t.label}
</button>
))}
</div>
{children}
</div>
</TabsContext.Provider>
);
}
export function TabPanel({ value, children, className }: { value: string; children: ReactNode; className?: string }) {
const ctx = useContext(TabsContext);
if (!ctx) throw new Error("TabPanel must be inside Tabs");
return (
<div
role="tabpanel"
id={`${ctx.id}-panel-${value}`}
aria-labelledby={`${ctx.id}-tab-${value}`}
hidden={ctx.value !== value}
tabIndex={0}
className={cn("outline-none", className)}
>
{children}
</div>
);
}
cn.ts
export const cn = (...parts: (string | false | null | undefined)[]) => parts.filter(Boolean).join(" ");