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 && }