Skip to content

Commit caa84ea

Browse files
HiDeoodelucis
andauthored
Add synced tabs persistence (#2087)
Co-authored-by: Chris Swithinbank <357379+delucis@users.noreply.github.com> Co-authored-by: Chris Swithinbank <swithinbank@gmail.com>
1 parent e044fee commit caa84ea

10 files changed

Lines changed: 284 additions & 7 deletions

File tree

.changeset/silver-houses-lick.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@astrojs/starlight': minor
3+
---
4+
5+
Adds persistence to synced `<Tabs>` so that a user's choices are reflected across page navigations.

.prettierignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,6 @@ pnpm-lock.yaml
1414

1515
# Test snapshots
1616
**/__tests__/**/snapshots
17+
18+
# https://github.com/withastro/prettier-plugin-astro/issues/337
19+
packages/starlight/user-components/Tabs.astro

docs/src/content/docs/guides/components.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ The code above generates the following tabs on the page:
9191

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

94-
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.
94+
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.
9595

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

docs/src/content/docs/guides/customization.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -385,7 +385,7 @@ It provides npm modules you can install for the fonts you want to use and includ
385385
2. Install the package for your chosen font.
386386
You can find the package name by clicking “Install” on the Fontsource font page.
387387

388-
<Tabs>
388+
<Tabs syncKey="pkg">
389389

390390
<TabItem label="npm">
391391

docs/src/content/docs/guides/site-search.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ If you have access to [Algolia’s DocSearch program](https://docsearch.algolia.
5252

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

55-
<Tabs>
55+
<Tabs syncKey="pkg">
5656

5757
<TabItem label="npm">
5858

docs/src/content/docs/manual-setup.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ To follow this guide, you’ll need an existing Astro project.
1616

1717
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:
1818

19-
<Tabs>
19+
<Tabs syncKey="pkg">
2020
<TabItem label="npm">
2121
```sh
2222
npx astro add starlight
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
title: Tabs unsynced
3+
---
4+
5+
import { Tabs, TabItem } from '@astrojs/starlight/components';
6+
7+
A basic set of tabs.
8+
9+
<Tabs>
10+
<TabItem label="npm">npm command</TabItem>
11+
<TabItem label="pnpm">pnpm command</TabItem>
12+
<TabItem label="yarn">yarn command</TabItem>
13+
</Tabs>
14+
15+
Another basic set of tabs.
16+
17+
<Tabs>
18+
<TabItem label="one">tab 1</TabItem>
19+
<TabItem label="two">tab 2</TabItem>
20+
<TabItem label="three">tab 3</TabItem>
21+
</Tabs>

packages/starlight/__e2e__/fixtures/basics/src/content/docs/tabs.mdx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,19 @@ Another set of tabs using the `pkg` sync key and using icons.
5252
another yarn command
5353
</TabItem>
5454
</Tabs>
55+
56+
A set of tabs using the `os` sync key.
57+
58+
<Tabs syncKey="os">
59+
<TabItem label="macos">macOS</TabItem>
60+
<TabItem label="windows">Windows</TabItem>
61+
<TabItem label="linux">GNU/Linux</TabItem>
62+
</Tabs>
63+
64+
Another set of tabs using the `os` sync key.
65+
66+
<Tabs syncKey="os">
67+
<TabItem label="macos">ls</TabItem>
68+
<TabItem label="windows">Get-ChildItem</TabItem>
69+
<TabItem label="linux">ls</TabItem>
70+
</Tabs>

packages/starlight/__e2e__/tabs.test.ts

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,16 @@ test('syncs only tabs using the same sync key', async ({ page, starlight }) => {
5252
const pkgTabsA = tabs.nth(0);
5353
const unsyncedTabs = tabs.nth(1);
5454
const styleTabs = tabs.nth(3);
55+
const osTabsA = tabs.nth(5);
56+
const osTabsB = tabs.nth(6);
5557

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

5961
await expectSelectedTab(unsyncedTabs, 'one', 'tab 1');
6062
await expectSelectedTab(styleTabs, 'css', 'css code');
63+
await expectSelectedTab(osTabsA, 'macos', 'macOS');
64+
await expectSelectedTab(osTabsB, 'macos', 'ls');
6165
});
6266

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

146+
test('restores tabs only for synced tabs with a persisted state', async ({ page, starlight }) => {
147+
await starlight.goto('/tabs');
148+
149+
const tabs = page.locator('starlight-tabs');
150+
const pkgTabsA = tabs.nth(0);
151+
const pkgTabsB = tabs.nth(2);
152+
const pkgTabsC = tabs.nth(4);
153+
const unsyncedTabs = tabs.nth(1);
154+
const styleTabs = tabs.nth(3);
155+
const osTabsA = tabs.nth(5);
156+
const osTabsB = tabs.nth(6);
157+
158+
// Select the pnpm tab in the set of tabs synced with the 'pkg' key.
159+
await pkgTabsA.getByRole('tab').filter({ hasText: 'pnpm' }).click();
160+
161+
await expectSelectedTab(pkgTabsA, 'pnpm', 'pnpm command');
162+
await expectSelectedTab(pkgTabsB, 'pnpm', 'another pnpm command');
163+
await expectSelectedTab(pkgTabsC, 'pnpm', 'another pnpm command');
164+
165+
page.reload();
166+
167+
// The synced tabs with a persisted state should be restored.
168+
await expectSelectedTab(pkgTabsA, 'pnpm', 'pnpm command');
169+
await expectSelectedTab(pkgTabsB, 'pnpm', 'another pnpm command');
170+
await expectSelectedTab(pkgTabsC, 'pnpm', 'another pnpm command');
171+
172+
// Other tabs should not be affected.
173+
await expectSelectedTab(unsyncedTabs, 'one', 'tab 1');
174+
await expectSelectedTab(styleTabs, 'css', 'css code');
175+
await expectSelectedTab(osTabsA, 'macos', 'macOS');
176+
await expectSelectedTab(osTabsB, 'macos', 'ls');
177+
});
178+
179+
test('restores tabs for a single set of synced tabs with a persisted state', async ({
180+
page,
181+
starlight,
182+
}) => {
183+
await starlight.goto('/tabs');
184+
185+
const tabs = page.locator('starlight-tabs');
186+
const styleTabs = tabs.nth(3);
187+
188+
// Select the tailwind tab in the set of tabs synced with the 'style' key.
189+
await styleTabs.getByRole('tab').filter({ hasText: 'tailwind' }).click();
190+
191+
await expectSelectedTab(styleTabs, 'tailwind', 'tailwind code');
192+
193+
page.reload();
194+
195+
// The synced tabs with a persisted state should be restored.
196+
await expectSelectedTab(styleTabs, 'tailwind', 'tailwind code');
197+
});
198+
199+
test('restores tabs for multiple synced tabs with different sync keys', async ({
200+
page,
201+
starlight,
202+
}) => {
203+
await starlight.goto('/tabs');
204+
205+
const tabs = page.locator('starlight-tabs');
206+
const pkgTabsA = tabs.nth(0);
207+
const pkgTabsB = tabs.nth(2);
208+
const pkgTabsC = tabs.nth(4);
209+
const osTabsA = tabs.nth(5);
210+
const osTabsB = tabs.nth(6);
211+
212+
// Select the pnpm tab in the set of tabs synced with the 'pkg' key.
213+
await pkgTabsA.getByRole('tab').filter({ hasText: 'pnpm' }).click();
214+
215+
await expectSelectedTab(pkgTabsA, 'pnpm', 'pnpm command');
216+
await expectSelectedTab(pkgTabsB, 'pnpm', 'another pnpm command');
217+
await expectSelectedTab(pkgTabsC, 'pnpm', 'another pnpm command');
218+
219+
// Select the windows tab in the set of tabs synced with the 'os' key.
220+
await osTabsB.getByRole('tab').filter({ hasText: 'windows' }).click();
221+
222+
page.reload();
223+
224+
// The synced tabs with a persisted state for the `pkg` sync key should be restored.
225+
await expectSelectedTab(pkgTabsA, 'pnpm', 'pnpm command');
226+
await expectSelectedTab(pkgTabsB, 'pnpm', 'another pnpm command');
227+
await expectSelectedTab(pkgTabsC, 'pnpm', 'another pnpm command');
228+
229+
// The synced tabs with a persisted state for the `os` sync key should be restored.
230+
await expectSelectedTab(osTabsA, 'windows', 'Windows');
231+
await expectSelectedTab(osTabsB, 'windows', 'Get-ChildItem');
232+
});
233+
234+
test('includes the `<starlight-tabs-restore>` element only for synced tabs', async ({
235+
page,
236+
starlight,
237+
}) => {
238+
await starlight.goto('/tabs');
239+
240+
// The page includes 7 sets of tabs.
241+
await expect(page.locator('starlight-tabs')).toHaveCount(7);
242+
// Only 6 sets of tabs are synced.
243+
await expect(page.locator('starlight-tabs-restore')).toHaveCount(6);
244+
});
245+
246+
test('includes the synced tabs restore script only when needed and at most once', async ({
247+
page,
248+
starlight,
249+
}) => {
250+
const syncedTabsRestoreScriptRegex = /customElements\.define\('starlight-tabs-restore',/g;
251+
252+
await starlight.goto('/tabs');
253+
254+
// The page includes at least one set of synced tabs.
255+
expect((await page.content()).match(syncedTabsRestoreScriptRegex)?.length).toBe(1);
256+
257+
await starlight.goto('/tabs-unsynced');
258+
259+
// The page includes no set of synced tabs.
260+
expect((await page.content()).match(syncedTabsRestoreScriptRegex)).toBeNull();
261+
});
262+
263+
test('gracefully handles invalid persisted state for synced tabs', async ({ page, starlight }) => {
264+
await starlight.goto('/tabs');
265+
266+
const tabs = page.locator('starlight-tabs');
267+
const pkgTabsA = tabs.nth(0);
268+
269+
// Select the pnpm tab in the set of tabs synced with the 'pkg' key.
270+
await pkgTabsA.getByRole('tab').filter({ hasText: 'pnpm' }).click();
271+
272+
await expectSelectedTab(pkgTabsA, 'pnpm', 'pnpm command');
273+
274+
// Replace the persisted state with a new invalid value.
275+
await page.evaluate(
276+
(value) => localStorage.setItem('starlight-synced-tabs__pkg', value),
277+
'invalid-value'
278+
);
279+
280+
page.reload();
281+
282+
// The synced tabs should not be restored due to the invalid persisted state.
283+
await expectSelectedTab(pkgTabsA, 'npm', 'npm command');
284+
285+
// Select the pnpm tab in the set of tabs synced with the 'pkg' key.
286+
await pkgTabsA.getByRole('tab').filter({ hasText: 'pnpm' }).click();
287+
288+
await expectSelectedTab(pkgTabsA, 'pnpm', 'pnpm command');
289+
290+
// The synced tabs should be restored with the new valid persisted state.
291+
expect(await page.evaluate(() => localStorage.getItem('starlight-synced-tabs__pkg'))).toBe(
292+
'pnpm'
293+
);
294+
});
295+
142296
async function expectSelectedTab(tabs: Locator, label: string, panel: string) {
143297
expect((await tabs.getByRole('tab', { selected: true }).textContent())?.trim()).toBe(label);
144298
expect((await tabs.getByRole('tabpanel').textContent())?.trim()).toBe(panel);

packages/starlight/user-components/Tabs.astro

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,67 @@ interface Props {
99
const { syncKey } = Astro.props;
1010
const panelHtml = await Astro.slots.render('default');
1111
const { html, panels } = processPanels(panelHtml);
12+
13+
/**
14+
* Synced tabs are persisted across page using `localStorage`. The script used to restore the
15+
* active tab for a given sync key has a few requirements:
16+
*
17+
* - The script should only be included when at least one set of synced tabs is present on the page.
18+
* - The script should be inlined to avoid a flash of invalid active tab.
19+
* - The script should only be included once per page.
20+
*
21+
* To do so, we keep track of whether the script has been rendered using a variable stored using
22+
* `Astro.locals` which will be reset for each new page. The value is tracked using an untyped
23+
* symbol on purpose to avoid Starlight users to get autocomplete for it and avoid potential
24+
* clashes with user-defined variables.
25+
*
26+
* The restore script defines a custom element `starlight-tabs-restore` that will be included in
27+
* each set of synced tabs to restore the active tab based on the persisted value using the
28+
* `connectedCallback` lifecycle method. To ensure this callback can access all tabs and panels for
29+
* the current set of tabs, the script should be rendered before the tabs themselves.
30+
*/
31+
const isSynced = syncKey !== undefined;
32+
const didRenderSyncedTabsRestoreScriptSymbol = Symbol.for('starlight:did-render-synced-tabs-restore-script');
33+
// @ts-expect-error - See above
34+
const shouldRenderSyncedTabsRestoreScript = isSynced && Astro.locals[didRenderSyncedTabsRestoreScriptSymbol] !== true;
35+
36+
if (isSynced) {
37+
// @ts-expect-error - See above
38+
Astro.locals[didRenderSyncedTabsRestoreScriptSymbol] = true
39+
}
1240
---
1341

42+
{/* Inlined to avoid a flash of invalid active tab. */}
43+
{shouldRenderSyncedTabsRestoreScript && <script is:inline>
44+
(() => {
45+
class StarlightTabsRestore extends HTMLElement {
46+
connectedCallback() {
47+
const starlightTabs = this.closest('starlight-tabs');
48+
if (!(starlightTabs instanceof HTMLElement) || typeof localStorage === 'undefined') return;
49+
const syncKey = starlightTabs.dataset.syncKey;
50+
if (!syncKey) return;
51+
const label = localStorage.getItem(`starlight-synced-tabs__${syncKey}`);
52+
if (!label) return;
53+
const tabs = [...starlightTabs?.querySelectorAll('[role="tab"]')];
54+
const tabIndexToRestore = tabs.findIndex(
55+
(tab) => tab instanceof HTMLAnchorElement && tab.textContent?.trim() === label
56+
);
57+
const panels = starlightTabs?.querySelectorAll('[role="tabpanel"]');
58+
const newTab = tabs[tabIndexToRestore];
59+
const newPanel = panels[tabIndexToRestore];
60+
if (tabIndexToRestore < 1 || !newTab || !newPanel) return;
61+
tabs[0]?.setAttribute('aria-selected', 'false');
62+
tabs[0]?.setAttribute('tabindex', '-1');
63+
panels?.[0]?.setAttribute('hidden', 'true');
64+
newTab.removeAttribute('tabindex');
65+
newTab.setAttribute('aria-selected', 'true');
66+
newPanel.removeAttribute('hidden');
67+
}
68+
}
69+
customElements.define('starlight-tabs-restore', StarlightTabsRestore);
70+
})()
71+
</script>}
72+
1473
<starlight-tabs data-sync-key={syncKey}>
1574
{
1675
panels && (
@@ -35,6 +94,7 @@ const { html, panels } = processPanels(panelHtml);
3594
)
3695
}
3796
<Fragment set:html={html} />
97+
{isSynced && <starlight-tabs-restore />}
3898
</starlight-tabs>
3999

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

90152
constructor() {
91153
super();
@@ -159,25 +221,41 @@ const { html, panels } = processPanels(panelHtml);
159221
newTab.setAttribute('aria-selected', 'true');
160222
if (shouldSync) {
161223
newTab.focus();
162-
StarlightTabs.#syncTabs(this, newTab.innerText);
224+
StarlightTabs.#syncTabs(this, newTab);
163225
window.scrollTo({
164226
top: window.scrollY + (this.getBoundingClientRect().top - previousTabsOffset),
165227
});
166228
}
167229
}
168230

169-
static #syncTabs(emitter: StarlightTabs, label: string | null) {
231+
#persistSyncedTabs(label: string) {
232+
if (!this.#syncKey || typeof localStorage === 'undefined') return;
233+
localStorage.setItem(this.#storageKeyPrefix + this.#syncKey, label);
234+
}
235+
236+
static #syncTabs(emitter: StarlightTabs, newTab: HTMLAnchorElement) {
170237
const syncKey = emitter.#syncKey;
238+
const label = StarlightTabs.#getTabLabel(newTab);
171239
if (!syncKey || !label) return;
172240
const syncedTabs = StarlightTabs.#syncedTabs.get(syncKey);
173241
if (!syncedTabs) return;
174242

175243
for (const receiver of syncedTabs) {
176244
if (receiver === emitter) continue;
177-
const labelIndex = receiver.tabs.findIndex((tab) => tab.innerText === label);
245+
const labelIndex = receiver.tabs.findIndex((tab) => StarlightTabs.#getTabLabel(tab) === label);
178246
if (labelIndex === -1) continue;
179247
receiver.switchTab(receiver.tabs[labelIndex], labelIndex, false);
180248
}
249+
250+
emitter.#persistSyncedTabs(label);
251+
}
252+
253+
static #getTabLabel(tab: HTMLAnchorElement) {
254+
// `textContent` returns the content of all elements. In the case of a tab with an icon, this
255+
// could potentially include extra spaces due to the presence of the SVG icon.
256+
// To sync tabs with the same sync key and label, no matter the presence of an icon, we trim
257+
// these extra spaces.
258+
return tab.textContent?.trim();
181259
}
182260
}
183261

0 commit comments

Comments
 (0)