summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorHiDeoo2024-08-16 19:04:19 +0200
committerGitHub2024-08-16 19:04:19 +0200
commitcaa84eaa7dc653d27d539fd3a93df346a9f0f149 (patch)
tree2713757815c8e11275467307bf0d42eb10603d14
parente044feeae9a336a87db526107e5772b54ddc567f (diff)
downloadIT.starlight-caa84eaa7dc653d27d539fd3a93df346a9f0f149.tar.gz
IT.starlight-caa84eaa7dc653d27d539fd3a93df346a9f0f149.tar.bz2
IT.starlight-caa84eaa7dc653d27d539fd3a93df346a9f0f149.zip
Add synced tabs persistence (#2087)
Co-authored-by: Chris Swithinbank <357379+delucis@users.noreply.github.com> Co-authored-by: Chris Swithinbank <swithinbank@gmail.com>
-rw-r--r--.changeset/silver-houses-lick.md5
-rw-r--r--.prettierignore3
-rw-r--r--docs/src/content/docs/guides/components.mdx2
-rw-r--r--docs/src/content/docs/guides/customization.mdx2
-rw-r--r--docs/src/content/docs/guides/site-search.mdx2
-rw-r--r--docs/src/content/docs/manual-setup.mdx2
-rw-r--r--packages/starlight/__e2e__/fixtures/basics/src/content/docs/tabs-unsynced.mdx21
-rw-r--r--packages/starlight/__e2e__/fixtures/basics/src/content/docs/tabs.mdx16
-rw-r--r--packages/starlight/__e2e__/tabs.test.ts154
-rw-r--r--packages/starlight/user-components/Tabs.astro84
10 files changed, 284 insertions, 7 deletions
diff --git a/.changeset/silver-houses-lick.md b/.changeset/silver-houses-lick.md
new file mode 100644
index 00000000..413190fa
--- /dev/null
+++ b/.changeset/silver-houses-lick.md
@@ -0,0 +1,5 @@
+---
+'@astrojs/starlight': minor
+---
+
+Adds persistence to synced `<Tabs>` so that a user's choices are reflected across page navigations.
diff --git a/.prettierignore b/.prettierignore
index 871a65eb..12c98894 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -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
diff --git a/docs/src/content/docs/guides/components.mdx b/docs/src/content/docs/guides/components.mdx
index 4624a176..4776ae77 100644
--- a/docs/src/content/docs/guides/components.mdx
+++ b/docs/src/content/docs/guides/components.mdx
@@ -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:
diff --git a/docs/src/content/docs/guides/customization.mdx b/docs/src/content/docs/guides/customization.mdx
index 15ea1fef..c77b0cee 100644
--- a/docs/src/content/docs/guides/customization.mdx
+++ b/docs/src/content/docs/guides/customization.mdx
@@ -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">
diff --git a/docs/src/content/docs/guides/site-search.mdx b/docs/src/content/docs/guides/site-search.mdx
index 37430d3a..0de34add 100644
--- a/docs/src/content/docs/guides/site-search.mdx
+++ b/docs/src/content/docs/guides/site-search.mdx
@@ -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">
diff --git a/docs/src/content/docs/manual-setup.mdx b/docs/src/content/docs/manual-setup.mdx
index a6898d28..0d7d5092 100644
--- a/docs/src/content/docs/manual-setup.mdx
+++ b/docs/src/content/docs/manual-setup.mdx
@@ -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
diff --git a/packages/starlight/__e2e__/fixtures/basics/src/content/docs/tabs-unsynced.mdx b/packages/starlight/__e2e__/fixtures/basics/src/content/docs/tabs-unsynced.mdx
new file mode 100644
index 00000000..fa94b6ca
--- /dev/null
+++ b/packages/starlight/__e2e__/fixtures/basics/src/content/docs/tabs-unsynced.mdx
@@ -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>
diff --git a/packages/starlight/__e2e__/fixtures/basics/src/content/docs/tabs.mdx b/packages/starlight/__e2e__/fixtures/basics/src/content/docs/tabs.mdx
index c01ba8c1..ea542494 100644
--- a/packages/starlight/__e2e__/fixtures/basics/src/content/docs/tabs.mdx
+++ b/packages/starlight/__e2e__/fixtures/basics/src/content/docs/tabs.mdx
@@ -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>
diff --git a/packages/starlight/__e2e__/tabs.test.ts b/packages/starlight/__e2e__/tabs.test.ts
index f10f7972..6903efb3 100644
--- a/packages/starlight/__e2e__/tabs.test.ts
+++ b/packages/starlight/__e2e__/tabs.test.ts
@@ -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 }) => {
@@ -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);
diff --git a/packages/starlight/user-components/Tabs.astro b/packages/starlight/user-components/Tabs.astro
index 1f5e2efa..f3108806 100644
--- a/packages/starlight/user-components/Tabs.astro
+++ b/packages/starlight/user-components/Tabs.astro
@@ -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 && (
@@ -35,6 +94,7 @@ const { html, panels } = processPanels(panelHtml);
)
}
<Fragment set:html={html} />
+ {isSynced && <starlight-tabs-restore />}
</starlight-tabs>
<style>
@@ -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();
@@ -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();
}
}