From caa84eaa7dc653d27d539fd3a93df346a9f0f149 Mon Sep 17 00:00:00 2001 From: HiDeoo <494699+HiDeoo@users.noreply.github.com> Date: Fri, 16 Aug 2024 19:04:19 +0200 Subject: [PATCH] Add synced tabs persistence (#2087) Co-authored-by: Chris Swithinbank <357379+delucis@users.noreply.github.com> Co-authored-by: Chris Swithinbank --- .changeset/silver-houses-lick.md | 5 + .prettierignore | 3 + docs/src/content/docs/guides/components.mdx | 2 +- .../src/content/docs/guides/customization.mdx | 2 +- docs/src/content/docs/guides/site-search.mdx | 2 +- docs/src/content/docs/manual-setup.mdx | 2 +- .../basics/src/content/docs/tabs-unsynced.mdx | 21 +++ .../fixtures/basics/src/content/docs/tabs.mdx | 16 ++ packages/starlight/__e2e__/tabs.test.ts | 154 ++++++++++++++++++ packages/starlight/user-components/Tabs.astro | 84 +++++++++- 10 files changed, 284 insertions(+), 7 deletions(-) create mode 100644 .changeset/silver-houses-lick.md create mode 100644 packages/starlight/__e2e__/fixtures/basics/src/content/docs/tabs-unsynced.mdx diff --git a/.changeset/silver-houses-lick.md b/.changeset/silver-houses-lick.md new file mode 100644 index 0000000000..413190fa48 --- /dev/null +++ b/.changeset/silver-houses-lick.md @@ -0,0 +1,5 @@ +--- +'@astrojs/starlight': minor +--- + +Adds persistence to synced `` so that a user's choices are reflected across page navigations. diff --git a/.prettierignore b/.prettierignore index 871a65eb47..12c9889458 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 4624a1768a..4776ae77a5 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 `` 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 `` 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 `` component and ensure that they all use the same `` labels: diff --git a/docs/src/content/docs/guides/customization.mdx b/docs/src/content/docs/guides/customization.mdx index 15ea1fef3d..c77b0cee61 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. - + diff --git a/docs/src/content/docs/guides/site-search.mdx b/docs/src/content/docs/guides/site-search.mdx index 37430d3a49..0de34add47 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`: - + diff --git a/docs/src/content/docs/manual-setup.mdx b/docs/src/content/docs/manual-setup.mdx index a6898d28d9..0d7d50928d 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: - + ```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 0000000000..fa94b6cae8 --- /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__/fixtures/basics/src/content/docs/tabs.mdx b/packages/starlight/__e2e__/fixtures/basics/src/content/docs/tabs.mdx index c01ba8c12f..ea54249483 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 + +A set of tabs using the `os` sync key. + + + macOS + Windows + GNU/Linux + + +Another set of tabs using the `os` sync key. + + + ls + Get-ChildItem + ls + diff --git a/packages/starlight/__e2e__/tabs.test.ts b/packages/starlight/__e2e__/tabs.test.ts index f10f79729a..6903efb335 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 `` 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 1f5e2efad1..f310880668 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 && } + { panels && ( @@ -35,6 +94,7 @@ const { html, panels } = processPanels(panelHtml); ) } + {isSynced && }