From 48060cbf98f66347cfa0c8049855ac60d69b9df7 Mon Sep 17 00:00:00 2001
From: HiDeoo <494699+HiDeoo@users.noreply.github.com>
Date: Thu, 4 Jul 2024 14:29:03 +0200
Subject: [PATCH 01/13] feat: add synced tabs persistence
---
.prettierignore | 3 +
.../basics/src/content/docs/tabs-unsynced.mdx | 21 ++++
packages/starlight/__e2e__/tabs.test.ts | 111 ++++++++++++++++++
packages/starlight/user-components/Tabs.astro | 83 ++++++++++++-
4 files changed, 217 insertions(+), 1 deletion(-)
create mode 100644 packages/starlight/__e2e__/fixtures/basics/src/content/docs/tabs-unsynced.mdx
diff --git a/.prettierignore b/.prettierignore
index 871a65eb470..12c98894584 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/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 00000000000..fa94b6cae8f
--- /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.
+
+
+ npm command
+ pnpm command
+ yarn command
+
+
+Another basic set of tabs.
+
+
+ tab 1
+ tab 2
+ tab 3
+
diff --git a/packages/starlight/__e2e__/tabs.test.ts b/packages/starlight/__e2e__/tabs.test.ts
index f10f79729aa..eb228b0350c 100644
--- a/packages/starlight/__e2e__/tabs.test.ts
+++ b/packages/starlight/__e2e__/tabs.test.ts
@@ -139,6 +139,117 @@ 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);
+
+ // 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');
+});
+
+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('includes the `` element only for synced tabs', async ({
+ page,
+ starlight,
+}) => {
+ await starlight.goto('/tabs');
+
+ // The page includes 5 sets of tabs.
+ await expect(page.locator('starlight-tabs')).toHaveCount(5);
+ // Only 4 sets of tabs are synced.
+ await expect(page.locator('starlight-tabs-restore')).toHaveCount(4);
+});
+
+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', 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'))).toBe(
+ '{"pkg":"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 1f5e2efad15..7ed0817a020 100644
--- a/packages/starlight/user-components/Tabs.astro
+++ b/packages/starlight/user-components/Tabs.astro
@@ -9,8 +9,70 @@ 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 not typed on purpose to avoid
+ * Starlight users to get autocomplete for it.
+ *
+ * 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
+ * `connectCallback` 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.
+ */
+// @ts-expect-error - See above
+const { didRenderSyncedTabsRestoreScript } = Astro.locals;
+const isSynced = syncKey !== undefined;
+const shouldRenderSyncedTabsRestoreScript = isSynced && didRenderSyncedTabsRestoreScript !== true;
+
+if (isSynced) {
+ // @ts-expect-error - See above
+ Astro.locals.didRenderSyncedTabsRestoreScript = true;
+}
---
+{/* Inlined to avoid a flash of invalid active tab. */}
+{shouldRenderSyncedTabsRestoreScript && }
+
{
panels && (
@@ -35,6 +97,7 @@ const { html, panels } = processPanels(panelHtml);
)
}
+ {isSynced && }