Skip to content

Commit

Permalink
Add synced tabs persistence (#2087)
Browse files Browse the repository at this point in the history
Co-authored-by: Chris Swithinbank <357379+delucis@users.noreply.github.com>
Co-authored-by: Chris Swithinbank <swithinbank@gmail.com>
  • Loading branch information
3 people committed Aug 16, 2024
1 parent e044fee commit caa84ea
Show file tree
Hide file tree
Showing 10 changed files with 284 additions and 7 deletions.
5 changes: 5 additions & 0 deletions .changeset/silver-houses-lick.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@astrojs/starlight': minor
---

Adds persistence to synced `<Tabs>` so that a user's choices are reflected across page navigations.
3 changes: 3 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,6 @@ pnpm-lock.yaml

# Test snapshots
**/__tests__/**/snapshots

# https://github.com/withastro/prettier-plugin-astro/issues/337
packages/starlight/user-components/Tabs.astro
2 changes: 1 addition & 1 deletion docs/src/content/docs/guides/components.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ The code above generates the following tabs on the page:

Keep multiple tab groups synchronized by adding the `syncKey` attribute.

All `<Tabs>` on a page with the same `syncKey` value will display the same active label. This allows your reader to choose once (e.g. their operating system or package manager), and see their choice reflected throughout the page.
All `<Tabs>` with the same `syncKey` value will display the same active label. This allows your reader to choose once (e.g. their operating system or package manager), and see their choice persisted across page navigations.

To synchronize related tabs, add an identical `syncKey` property to each `<Tabs>` component and ensure that they all use the same `<TabItem>` labels:

Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/guides/customization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -385,7 +385,7 @@ It provides npm modules you can install for the fonts you want to use and includ
2. Install the package for your chosen font.
You can find the package name by clicking “Install” on the Fontsource font page.

<Tabs>
<Tabs syncKey="pkg">

<TabItem label="npm">

Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/guides/site-search.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ If you have access to [Algolia’s DocSearch program](https://docsearch.algolia.

1. Install `@astrojs/starlight-docsearch`:

<Tabs>
<Tabs syncKey="pkg">

<TabItem label="npm">

Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/manual-setup.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ To follow this guide, you’ll need an existing Astro project.

Starlight is an [Astro integration](https://docs.astro.build/en/guides/integrations-guide/). Add it to your site by running the `astro add` command in your project’s root directory:

<Tabs>
<Tabs syncKey="pkg">
<TabItem label="npm">
```sh
npx astro add starlight
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
title: Tabs unsynced
---

import { Tabs, TabItem } from '@astrojs/starlight/components';

A basic set of tabs.

<Tabs>
<TabItem label="npm">npm command</TabItem>
<TabItem label="pnpm">pnpm command</TabItem>
<TabItem label="yarn">yarn command</TabItem>
</Tabs>

Another basic set of tabs.

<Tabs>
<TabItem label="one">tab 1</TabItem>
<TabItem label="two">tab 2</TabItem>
<TabItem label="three">tab 3</TabItem>
</Tabs>
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,19 @@ Another set of tabs using the `pkg` sync key and using icons.
another yarn command
</TabItem>
</Tabs>

A set of tabs using the `os` sync key.

<Tabs syncKey="os">
<TabItem label="macos">macOS</TabItem>
<TabItem label="windows">Windows</TabItem>
<TabItem label="linux">GNU/Linux</TabItem>
</Tabs>

Another set of tabs using the `os` sync key.

<Tabs syncKey="os">
<TabItem label="macos">ls</TabItem>
<TabItem label="windows">Get-ChildItem</TabItem>
<TabItem label="linux">ls</TabItem>
</Tabs>
154 changes: 154 additions & 0 deletions packages/starlight/__e2e__/tabs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,16 @@ test('syncs only tabs using the same sync key', async ({ page, starlight }) => {
const pkgTabsA = tabs.nth(0);
const unsyncedTabs = tabs.nth(1);
const styleTabs = tabs.nth(3);
const osTabsA = tabs.nth(5);
const osTabsB = tabs.nth(6);

// Select the pnpm tab in the set of tabs synced with the 'pkg' key.
await pkgTabsA.getByRole('tab').filter({ hasText: 'pnpm' }).click();

await expectSelectedTab(unsyncedTabs, 'one', 'tab 1');
await expectSelectedTab(styleTabs, 'css', 'css code');
await expectSelectedTab(osTabsA, 'macos', 'macOS');
await expectSelectedTab(osTabsB, 'macos', 'ls');
});

test('supports synced tabs with different tab items', async ({ page, starlight }) => {
Expand Down Expand Up @@ -139,6 +143,156 @@ test('syncs tabs with the same sync key if they do not consistenly use icons', a
await expectSelectedTab(pkgTabsA, 'yarn', 'yarn command');
});

test('restores tabs only for synced tabs with a persisted state', async ({ page, starlight }) => {
await starlight.goto('/tabs');

const tabs = page.locator('starlight-tabs');
const pkgTabsA = tabs.nth(0);
const pkgTabsB = tabs.nth(2);
const pkgTabsC = tabs.nth(4);
const unsyncedTabs = tabs.nth(1);
const styleTabs = tabs.nth(3);
const osTabsA = tabs.nth(5);
const osTabsB = tabs.nth(6);

// Select the pnpm tab in the set of tabs synced with the 'pkg' key.
await pkgTabsA.getByRole('tab').filter({ hasText: 'pnpm' }).click();

await expectSelectedTab(pkgTabsA, 'pnpm', 'pnpm command');
await expectSelectedTab(pkgTabsB, 'pnpm', 'another pnpm command');
await expectSelectedTab(pkgTabsC, 'pnpm', 'another pnpm command');

page.reload();

// The synced tabs with a persisted state should be restored.
await expectSelectedTab(pkgTabsA, 'pnpm', 'pnpm command');
await expectSelectedTab(pkgTabsB, 'pnpm', 'another pnpm command');
await expectSelectedTab(pkgTabsC, 'pnpm', 'another pnpm command');

// Other tabs should not be affected.
await expectSelectedTab(unsyncedTabs, 'one', 'tab 1');
await expectSelectedTab(styleTabs, 'css', 'css code');
await expectSelectedTab(osTabsA, 'macos', 'macOS');
await expectSelectedTab(osTabsB, 'macos', 'ls');
});

test('restores tabs for a single set of synced tabs with a persisted state', async ({
page,
starlight,
}) => {
await starlight.goto('/tabs');

const tabs = page.locator('starlight-tabs');
const styleTabs = tabs.nth(3);

// Select the tailwind tab in the set of tabs synced with the 'style' key.
await styleTabs.getByRole('tab').filter({ hasText: 'tailwind' }).click();

await expectSelectedTab(styleTabs, 'tailwind', 'tailwind code');

page.reload();

// The synced tabs with a persisted state should be restored.
await expectSelectedTab(styleTabs, 'tailwind', 'tailwind code');
});

test('restores tabs for multiple synced tabs with different sync keys', async ({
page,
starlight,
}) => {
await starlight.goto('/tabs');

const tabs = page.locator('starlight-tabs');
const pkgTabsA = tabs.nth(0);
const pkgTabsB = tabs.nth(2);
const pkgTabsC = tabs.nth(4);
const osTabsA = tabs.nth(5);
const osTabsB = tabs.nth(6);

// Select the pnpm tab in the set of tabs synced with the 'pkg' key.
await pkgTabsA.getByRole('tab').filter({ hasText: 'pnpm' }).click();

await expectSelectedTab(pkgTabsA, 'pnpm', 'pnpm command');
await expectSelectedTab(pkgTabsB, 'pnpm', 'another pnpm command');
await expectSelectedTab(pkgTabsC, 'pnpm', 'another pnpm command');

// Select the windows tab in the set of tabs synced with the 'os' key.
await osTabsB.getByRole('tab').filter({ hasText: 'windows' }).click();

page.reload();

// The synced tabs with a persisted state for the `pkg` sync key should be restored.
await expectSelectedTab(pkgTabsA, 'pnpm', 'pnpm command');
await expectSelectedTab(pkgTabsB, 'pnpm', 'another pnpm command');
await expectSelectedTab(pkgTabsC, 'pnpm', 'another pnpm command');

// The synced tabs with a persisted state for the `os` sync key should be restored.
await expectSelectedTab(osTabsA, 'windows', 'Windows');
await expectSelectedTab(osTabsB, 'windows', 'Get-ChildItem');
});

test('includes the `<starlight-tabs-restore>` element only for synced tabs', async ({
page,
starlight,
}) => {
await starlight.goto('/tabs');

// The page includes 7 sets of tabs.
await expect(page.locator('starlight-tabs')).toHaveCount(7);
// Only 6 sets of tabs are synced.
await expect(page.locator('starlight-tabs-restore')).toHaveCount(6);
});

test('includes the synced tabs restore script only when needed and at most once', async ({
page,
starlight,
}) => {
const syncedTabsRestoreScriptRegex = /customElements\.define\('starlight-tabs-restore',/g;

await starlight.goto('/tabs');

// The page includes at least one set of synced tabs.
expect((await page.content()).match(syncedTabsRestoreScriptRegex)?.length).toBe(1);

await starlight.goto('/tabs-unsynced');

// The page includes no set of synced tabs.
expect((await page.content()).match(syncedTabsRestoreScriptRegex)).toBeNull();
});

test('gracefully handles invalid persisted state for synced tabs', async ({ page, starlight }) => {
await starlight.goto('/tabs');

const tabs = page.locator('starlight-tabs');
const pkgTabsA = tabs.nth(0);

// Select the pnpm tab in the set of tabs synced with the 'pkg' key.
await pkgTabsA.getByRole('tab').filter({ hasText: 'pnpm' }).click();

await expectSelectedTab(pkgTabsA, 'pnpm', 'pnpm command');

// Replace the persisted state with a new invalid value.
await page.evaluate(
(value) => localStorage.setItem('starlight-synced-tabs__pkg', value),
'invalid-value'
);

page.reload();

// The synced tabs should not be restored due to the invalid persisted state.
await expectSelectedTab(pkgTabsA, 'npm', 'npm command');

// Select the pnpm tab in the set of tabs synced with the 'pkg' key.
await pkgTabsA.getByRole('tab').filter({ hasText: 'pnpm' }).click();

await expectSelectedTab(pkgTabsA, 'pnpm', 'pnpm command');

// The synced tabs should be restored with the new valid persisted state.
expect(await page.evaluate(() => localStorage.getItem('starlight-synced-tabs__pkg'))).toBe(
'pnpm'
);
});

async function expectSelectedTab(tabs: Locator, label: string, panel: string) {
expect((await tabs.getByRole('tab', { selected: true }).textContent())?.trim()).toBe(label);
expect((await tabs.getByRole('tabpanel').textContent())?.trim()).toBe(panel);
Expand Down
84 changes: 81 additions & 3 deletions packages/starlight/user-components/Tabs.astro
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,67 @@ interface Props {
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('[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 && (
Expand All @@ -35,6 +94,7 @@ const { html, panels } = processPanels(panelHtml);
)
}
<Fragment set:html={html} />
{isSynced && <starlight-tabs-restore />}
</starlight-tabs>

<style>
Expand Down Expand Up @@ -86,6 +146,8 @@ const { html, panels } = processPanels(panelHtml);
tabs: HTMLAnchorElement[];
panels: HTMLElement[];
#syncKey: string | undefined;
// The storage key prefix should be in sync with the one used in the restore script.
#storageKeyPrefix = 'starlight-synced-tabs__';

constructor() {
super();
Expand Down Expand Up @@ -159,25 +221,41 @@ const { html, panels } = processPanels(panelHtml);
newTab.setAttribute('aria-selected', 'true');
if (shouldSync) {
newTab.focus();
StarlightTabs.#syncTabs(this, newTab.innerText);
StarlightTabs.#syncTabs(this, newTab);
window.scrollTo({
top: window.scrollY + (this.getBoundingClientRect().top - previousTabsOffset),
});
}
}

static #syncTabs(emitter: StarlightTabs, label: string | null) {
#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) => tab.innerText === label);
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) {
// `textContent` returns the content of all elements. In the case of a tab with an icon, this
// could potentially include extra spaces due to the presence of the SVG icon.
// To sync tabs with the same sync key and label, no matter the presence of an icon, we trim
// these extra spaces.
return tab.textContent?.trim();
}
}

Expand Down

0 comments on commit caa84ea

Please sign in to comment.