Files
ai-for-dummies/.agents/templates/components/island.astro
T

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>