---
import Icon from './Icon.astro';
import { processPanels } from './rehype-tabs';
interface Props {
syncKey?: string;
}
const { syncKey } = Astro.props;
const panelHtml = await Astro.slots.render('default');
const { html, panels } = processPanels(panelHtml);
/**
* Synced tabs are persisted across page using `localStorage`. The script used to restore the
* active tab for a given sync key has a few requirements:
*
* - The script should only be included when at least one set of synced tabs is present on the page.
* - The script should be inlined to avoid a flash of invalid active tab.
* - The script should only be included once per page.
*
* To do so, we keep track of whether the script has been rendered using a variable stored using
* `Astro.locals` which will be reset for each new page. The value is tracked using an untyped
* symbol on purpose to avoid Starlight users to get autocomplete for it and avoid potential
* clashes with user-defined variables.
*
* The restore script defines a custom element `starlight-tabs-restore` that will be included in
* each set of synced tabs to restore the active tab based on the persisted value using the
* `connectedCallback` lifecycle method. To ensure this callback can access all tabs and panels for
* the current set of tabs, the script should be rendered before the tabs themselves.
*/
const isSynced = syncKey !== undefined;
const didRenderSyncedTabsRestoreScriptSymbol = Symbol.for('starlight:did-render-synced-tabs-restore-script');
// @ts-expect-error - See above
const shouldRenderSyncedTabsRestoreScript = isSynced && Astro.locals[didRenderSyncedTabsRestoreScriptSymbol] !== true;
if (isSynced) {
// @ts-expect-error - See above
Astro.locals[didRenderSyncedTabsRestoreScriptSymbol] = true
}
---
{/* Inlined to avoid a flash of invalid active tab. */}
{shouldRenderSyncedTabsRestoreScript && <script is:inline>
(() => {
class StarlightTabsRestore extends HTMLElement {
connectedCallback() {
const starlightTabs = this.closest('starlight-tabs');
if (!(starlightTabs instanceof HTMLElement) || typeof localStorage === 'undefined') return;
const syncKey = starlightTabs.dataset.syncKey;
if (!syncKey) return;
const label = localStorage.getItem(`starlight-synced-tabs__${syncKey}`);
if (!label) return;
const tabs = [...starlightTabs?.querySelectorAll('[role="tab"]')];
const tabIndexToRestore = tabs.findIndex(
(tab) => tab instanceof HTMLAnchorElement && tab.textContent?.trim() === label
);
const panels = starlightTabs?.querySelectorAll(':scope > [role="tabpanel"]');
const newTab = tabs[tabIndexToRestore];
const newPanel = panels[tabIndexToRestore];
if (tabIndexToRestore < 1 || !newTab || !newPanel) return;
tabs[0]?.setAttribute('aria-selected', 'false');
tabs[0]?.setAttribute('tabindex', '-1');
panels?.[0]?.setAttribute('hidden', 'true');
newTab.removeAttribute('tabindex');
newTab.setAttribute('aria-selected', 'true');
newPanel.removeAttribute('hidden');
}
}
customElements.define('starlight-tabs-restore', StarlightTabsRestore);
})()
</script>}
<starlight-tabs data-sync-key={syncKey}>
{
panels && (
<div class="tablist-wrapper not-content">
<ul role="tablist">
{panels.map(({ icon, label, panelId, tabId }, idx) => (
<li role="presentation" class="tab">
<a
role="tab"
href={'#' + panelId}
id={tabId}
aria-selected={idx === 0 ? 'true' : 'false'}
tabindex={idx !== 0 ? -1 : 0}
>
{icon && <Icon name={icon} />}
{label}
</a>
</li>
))}
</ul>
</div>
)
}
<Fragment set:html={html} />
{isSynced && <starlight-tabs-restore />}
</starlight-tabs>
<style>
starlight-tabs {
display: block;
}
.tablist-wrapper {
overflow-x: auto;
}
[role='tablist'] {
display: flex;
list-style: none;
border-bottom: 2px solid var(--sl-color-gray-5);
padding: 0;
}
.tab {
margin-bottom: -2px;
}
.tab > [role='tab'] {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0 1.25rem;
text-decoration: none;
border-bottom: 2px solid var(--sl-color-gray-5);
color: var(--sl-color-gray-3);
outline-offset: var(--sl-outline-offset-inside);
overflow-wrap: initial;
}
.tab [role='tab'][aria-selected='true'] {
color: var(--sl-color-white);
border-color: var(--sl-color-text-accent);
font-weight: 600;
}
.tablist-wrapper ~ :global([role='tabpanel']) {
margin-top: 1rem;
}
</style>
<script>
class StarlightTabs extends HTMLElement {
static #syncedTabs = new Map<string, StarlightTabs[]>();
tabs: HTMLAnchorElement[];
panels: HTMLElement[];
#syncKey: string | undefined;
#storageKeyPrefix = 'starlight-synced-tabs__';
constructor() {
super();
const tablist = this.querySelector<HTMLUListElement>('[role="tablist"]')!;
this.tabs = [...tablist.querySelectorAll<HTMLAnchorElement>('[role="tab"]')];
this.panels = [...this.querySelectorAll<HTMLElement>(':scope > [role="tabpanel"]')];
this.#syncKey = this.dataset.syncKey;
if (this.#syncKey) {
const syncedTabs = StarlightTabs.#syncedTabs.get(this.#syncKey) ?? [];
syncedTabs.push(this);
StarlightTabs.#syncedTabs.set(this.#syncKey, syncedTabs);
}
this.tabs.forEach((tab, i) => {
tab.addEventListener('click', (e) => {
e.preventDefault();
const currentTab = tablist.querySelector('[aria-selected="true"]');
if (e.currentTarget !== currentTab) {
this.switchTab(e.currentTarget as HTMLAnchorElement, i);
}
});
tab.addEventListener('keydown', (e) => {
const index = this.tabs.indexOf(e.currentTarget as any);
const nextIndex =
e.key === 'ArrowLeft'
? index - 1
: e.key === 'ArrowRight'
? index + 1
: e.key === 'Home'
? 0
: e.key === 'End'
? this.tabs.length - 1
: null;
if (nextIndex === null) return;
if (this.tabs[nextIndex]) {
e.preventDefault();
this.switchTab(this.tabs[nextIndex], nextIndex);
}
});
});
}
switchTab(newTab: HTMLAnchorElement | null | undefined, index: number, shouldSync = true) {
if (!newTab) return;
const previousTabsOffset = shouldSync ? this.getBoundingClientRect().top : 0;
this.tabs.forEach((tab) => {
tab.setAttribute('aria-selected', 'false');
tab.setAttribute('tabindex', '-1');
});
this.panels.forEach((oldPanel) => {
oldPanel.hidden = true;
});
const newPanel = this.panels[index];
if (newPanel) newPanel.hidden = false;
newTab.removeAttribute('tabindex');
newTab.setAttribute('aria-selected', 'true');
if (shouldSync) {
newTab.focus();
StarlightTabs.#syncTabs(this, newTab);
window.scrollTo({
top: window.scrollY + (this.getBoundingClientRect().top - previousTabsOffset),
behavior: 'instant',
});
}
}
#persistSyncedTabs(label: string) {
if (!this.#syncKey || typeof localStorage === 'undefined') return;
localStorage.setItem(this.#storageKeyPrefix + this.#syncKey, label);
}
static #syncTabs(emitter: StarlightTabs, newTab: HTMLAnchorElement) {
const syncKey = emitter.#syncKey;
const label = StarlightTabs.#getTabLabel(newTab);
if (!syncKey || !label) return;
const syncedTabs = StarlightTabs.#syncedTabs.get(syncKey);
if (!syncedTabs) return;
for (const receiver of syncedTabs) {
if (receiver === emitter) continue;
const labelIndex = receiver.tabs.findIndex((tab) => StarlightTabs.#getTabLabel(tab) === label);
if (labelIndex === -1) continue;
receiver.switchTab(receiver.tabs[labelIndex], labelIndex, false);
}
emitter.#persistSyncedTabs(label);
}
static #getTabLabel(tab: HTMLAnchorElement) {
return tab.textContent?.trim();
}
}
customElements.define('starlight-tabs', StarlightTabs);
</script>