111 lines
3.1 KiB
TypeScript
111 lines
3.1 KiB
TypeScript
---
|
|
// Interactive island template. Use ONLY when the component genuinely needs
|
|
// client-side behaviour, and write the justification in your PR description.
|
|
//
|
|
// Islands are LEAVES. Do not wrap static children that could have been
|
|
// server-rendered — hydrate the tab panel, not the page.
|
|
//
|
|
// Hydration preference, in order:
|
|
// (none) → client:visible → client:idle → client:load
|
|
//
|
|
// Usage: <Island client:visible items={items} />
|
|
|
|
interface Props {
|
|
items: { id: string; label: string; body: string }[];
|
|
initialId?: string;
|
|
}
|
|
|
|
const { items, initialId = items[0]?.id } = Astro.props;
|
|
---
|
|
|
|
<div class="island" data-initial={initialId}>
|
|
<div class="tabs" role="tablist" aria-label="Sections">
|
|
{
|
|
items.map((item) => (
|
|
<button
|
|
role="tab"
|
|
id={`tab-${item.id}`}
|
|
aria-controls={`panel-${item.id}`}
|
|
aria-selected={item.id === initialId}
|
|
data-tab={item.id}
|
|
>
|
|
{item.label}
|
|
</button>
|
|
))
|
|
}
|
|
</div>
|
|
{
|
|
items.map((item) => (
|
|
<div
|
|
role="tabpanel"
|
|
id={`panel-${item.id}`}
|
|
aria-labelledby={`tab-${item.id}`}
|
|
data-panel={item.id}
|
|
hidden={item.id !== initialId}
|
|
>
|
|
{item.body}
|
|
</div>
|
|
))
|
|
}
|
|
</div>
|
|
|
|
<script>
|
|
// Scoped to this island's own root so multiple instances never collide.
|
|
document.querySelectorAll<HTMLElement>('.island').forEach((root) => {
|
|
const tabs = root.querySelectorAll<HTMLButtonElement>('[data-tab]');
|
|
const select = (id: string) => {
|
|
tabs.forEach((tab) => tab.setAttribute('aria-selected', String(tab.dataset.tab === id)));
|
|
root.querySelectorAll<HTMLElement>('[data-panel]').forEach((panel) => {
|
|
panel.hidden = panel.dataset.panel !== id;
|
|
});
|
|
};
|
|
tabs.forEach((tab) => tab.addEventListener('click', () => select(tab.dataset.tab!)));
|
|
|
|
// Arrow-key navigation is required for role="tablist" — see
|
|
// .agents/rules/accessibility.md
|
|
root.querySelector('[role="tablist"]')?.addEventListener('keydown', (event) => {
|
|
const key = (event as KeyboardEvent).key;
|
|
if (key !== 'ArrowRight' && key !== 'ArrowLeft') return;
|
|
const list = [...tabs];
|
|
const current = list.findIndex((tab) => tab.getAttribute('aria-selected') === 'true');
|
|
const next = list[(current + (key === 'ArrowRight' ? 1 : -1) + list.length) % list.length];
|
|
select(next.dataset.tab!);
|
|
next.focus();
|
|
});
|
|
});
|
|
</script>
|
|
|
|
<style>
|
|
.tabs {
|
|
display: grid;
|
|
gap: 8px;
|
|
}
|
|
|
|
button {
|
|
padding: 12px;
|
|
color: var(--ink);
|
|
background: transparent;
|
|
border: 1px solid var(--line);
|
|
text-align: left;
|
|
cursor: pointer;
|
|
/* transform/opacity only — never animate layout properties */
|
|
transition: background 180ms cubic-bezier(0.2, 0, 0, 1);
|
|
}
|
|
|
|
button[aria-selected='true'] {
|
|
color: var(--paper);
|
|
background: var(--ink);
|
|
}
|
|
|
|
button:focus-visible {
|
|
outline: 3px solid var(--red);
|
|
outline-offset: 2px;
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
button {
|
|
transition-duration: 0.01ms;
|
|
}
|
|
}
|
|
</style>
|