diff --git a/.changeset/silver-houses-lick.md b/.changeset/silver-houses-lick.md new file mode 100644 index 00000000000..413190fa48f --- /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 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/docs/src/content/docs/guides/components.mdx b/docs/src/content/docs/guides/components.mdx index 4624a1768a8..4776ae77a56 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 15ea1fef3d8..c77b0cee610 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 37430d3a49f..0de34add47e 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 a6898d28d92..0d7d50928d7 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 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__/fixtures/basics/src/content/docs/tabs.mdx b/packages/starlight/__e2e__/fixtures/basics/src/content/docs/tabs.mdx index c01ba8c12f7..ea542494830 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 f10f79729aa..6903efb335c 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 1f5e2efad15..f3108806686 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 && }