diff --git a/.changeset/red-monkeys-tickle.md b/.changeset/red-monkeys-tickle.md new file mode 100644 index 00000000000..526b1f82efa --- /dev/null +++ b/.changeset/red-monkeys-tickle.md @@ -0,0 +1,32 @@ +--- +'@astrojs/starlight': minor +--- + +⚠️ **BREAKING CHANGE:** Ensures that the `` and `` components no longer render with a trailing space. + +In Astro, components that include styles render with a trailing space which can prevent some use cases from working as expected, e.g. when using such components inlined with text. This change ensures that the `` and `` components no longer render with a trailing space. + +If you were previously relying on that implementation detail, you may need to update your code to account for this change. For example, considering the following code: + +```mdx +Feature +``` + +The rendered text would previously include a space between the badge and the text due to the trailing space automatically added by the component: + +``` +New Feature +``` + +Such code will now render the badge and text without a space: + +``` +NewFeature +``` + +To fix this, you can add a space between the badge and the text: + +```diff +- Feature ++ Feature +``` diff --git a/docs/src/content/docs/components/badges.mdx b/docs/src/content/docs/components/badges.mdx index 1c95f570763..a247edd6063 100644 --- a/docs/src/content/docs/components/badges.mdx +++ b/docs/src/content/docs/components/badges.mdx @@ -33,31 +33,31 @@ To use a built-in badge color, set the [`variant`](#variant) attribute to one of ```mdx import { Badge } from '@astrojs/starlight/components'; - - - - - +- +- +- +- +- ``` ```markdoc -{% badge text="Note" variant="note" /%} -{% badge text="Success" variant="success" /%} -{% badge text="Tip" variant="tip" /%} -{% badge text="Caution" variant="caution" /%} -{% badge text="Danger" variant="danger" /%} +- {% badge text="Note" variant="note" /%} +- {% badge text="Success" variant="success" /%} +- {% badge text="Tip" variant="tip" /%} +- {% badge text="Caution" variant="caution" /%} +- {% badge text="Danger" variant="danger" /%} ``` - - - - - + - + - + - + - + - @@ -71,25 +71,25 @@ Use the [`size`](#size) attribute to control the size of the badge text. ```mdx /size="\w+"/ import { Badge } from '@astrojs/starlight/components'; - - - +- +- +- ``` ```markdoc /size="\w+"/ -{% badge text="New" size="small" /%} -{% badge text="New and improved" size="medium" /%} -{% badge text="New, improved, and bigger" size="large" /%} +- {% badge text="New" size="small" /%} +- {% badge text="New and improved" size="medium" /%} +- {% badge text="New, improved, and bigger" size="large" /%} ``` - - - + - + - + - diff --git a/packages/starlight/__e2e__/components.test.ts b/packages/starlight/__e2e__/components.test.ts new file mode 100644 index 00000000000..b4021c47d48 --- /dev/null +++ b/packages/starlight/__e2e__/components.test.ts @@ -0,0 +1,389 @@ +import { expect, testFactory, type Locator } from './test-utils'; + +const test = testFactory('./fixtures/basics/'); + +test.describe('tabs', () => { + test('syncs tabs with a click event', async ({ page, getProdServer }) => { + const starlight = await getProdServer(); + await starlight.goto('/tabs'); + + const tabs = page.locator('starlight-tabs'); + const pkgTabsA = tabs.nth(0); + const pkgTabsB = tabs.nth(2); + + // Select the pnpm tab in the first set of synced tabs. + await pkgTabsA.getByRole('tab').filter({ hasText: 'pnpm' }).click(); + + await expectSelectedTab(pkgTabsA, 'pnpm', 'pnpm command'); + await expectSelectedTab(pkgTabsB, 'pnpm', 'another pnpm command'); + + // Select the yarn tab in the second set of synced tabs. + await pkgTabsB.getByRole('tab').filter({ hasText: 'yarn' }).click(); + + await expectSelectedTab(pkgTabsB, 'yarn', 'another yarn command'); + await expectSelectedTab(pkgTabsA, 'yarn', 'yarn command'); + }); + + test('syncs tabs with a keyboard event', async ({ page, getProdServer }) => { + const starlight = await getProdServer(); + await starlight.goto('/tabs'); + + const tabs = page.locator('starlight-tabs'); + const pkgTabsA = tabs.nth(0); + const pkgTabsB = tabs.nth(2); + + // Select the pnpm tab in the first set of synced tabs with the keyboard. + await pkgTabsA.getByRole('tab', { selected: true }).press('ArrowRight'); + + await expectSelectedTab(pkgTabsA, 'pnpm', 'pnpm command'); + await expectSelectedTab(pkgTabsB, 'pnpm', 'another pnpm command'); + + // Select back the npm tab in the second set of synced tabs with the keyboard. + const selectedTabB = pkgTabsB.getByRole('tab', { selected: true }); + await selectedTabB.press('ArrowRight'); + await selectedTabB.press('ArrowLeft'); + await selectedTabB.press('ArrowLeft'); + + await expectSelectedTab(pkgTabsA, 'npm', 'npm command'); + await expectSelectedTab(pkgTabsB, 'npm', 'another npm command'); + }); + + test('syncs only tabs using the same sync key', async ({ page, getProdServer }) => { + const starlight = await getProdServer(); + await starlight.goto('/tabs'); + + const tabs = page.locator('starlight-tabs'); + 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, getProdServer }) => { + const starlight = await getProdServer(); + await starlight.goto('/tabs'); + + const tabs = page.locator('starlight-tabs'); + const pkgTabsA = tabs.nth(0); + const pkgTabsB = tabs.nth(2); // This set contains an extra tab item. + + // Select the bun tab in the second set of synced tabs. + await pkgTabsB.getByRole('tab').filter({ hasText: 'bun' }).click(); + + await expectSelectedTab(pkgTabsA, 'npm', 'npm command'); + await expectSelectedTab(pkgTabsB, 'bun', 'another bun command'); + }); + + test('persists the focus when syncing tabs', async ({ page, getProdServer }) => { + const starlight = await getProdServer(); + await starlight.goto('/tabs'); + + const pkgTabsA = page.locator('starlight-tabs').nth(0); + + // Focus the selected tab in the set of tabs synced with the 'pkg' key. + await pkgTabsA.getByRole('tab', { selected: true }).focus(); + // Select the pnpm tab in the set of tabs synced with the 'pkg' key using the keyboard. + await page.keyboard.press('ArrowRight'); + + expect( + await pkgTabsA + .getByRole('tab', { selected: true }) + .evaluate((node) => document.activeElement === node) + ).toBe(true); + }); + + test('preserves tabs position when alternating between tabs with different content heights', async ({ + page, + getProdServer, + }) => { + const starlight = await getProdServer(); + await starlight.goto('/tabs-variable-height'); + + const tabs = page.locator('starlight-tabs').nth(1); + const selectedTab = tabs.getByRole('tab', { selected: true }); + + // Scroll to the second set of synced tabs and focus the selected tab. + await tabs.scrollIntoViewIfNeeded(); + await selectedTab.focus(); + + // Get the bounding box of the tabs. + const initialBoundingBox = await tabs.boundingBox(); + + // Select the second tab which has a different height. + await selectedTab.press('ArrowRight'); + + // Ensure the tabs vertical position is exactly the same after selecting the second tab. + // Note that a small difference could be the result of the base line-height having a fractional part which can cause a + // sub-pixel difference in some browsers like Chrome or Firefox. + expect((await tabs.boundingBox())?.y).toBe(initialBoundingBox?.y); + }); + + test('syncs tabs with the same sync key if they do not consistenly use icons', async ({ + page, + getProdServer, + }) => { + const starlight = await getProdServer(); + await starlight.goto('/tabs'); + + const tabs = page.locator('starlight-tabs'); + const pkgTabsA = tabs.nth(0); // This set does not use icons for tab items. + const pkgTabsB = tabs.nth(4); // This set uses icons for tab items. + + // Select the pnpm tab in the first set of synced tabs. + await pkgTabsA.getByRole('tab').filter({ hasText: 'pnpm' }).click(); + + await expectSelectedTab(pkgTabsA, 'pnpm', 'pnpm command'); + await expectSelectedTab(pkgTabsB, 'pnpm', 'another pnpm command'); + + // Select the yarn tab in the second set of synced tabs. + await pkgTabsB.getByRole('tab').filter({ hasText: 'yarn' }).click(); + + await expectSelectedTab(pkgTabsB, 'yarn', 'another yarn command'); + await expectSelectedTab(pkgTabsA, 'yarn', 'yarn command'); + }); + + test('restores tabs only for synced tabs with a persisted state', async ({ + page, + getProdServer, + }) => { + const starlight = await getProdServer(); + 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, + getProdServer, + }) => { + const starlight = await getProdServer(); + 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, + getProdServer, + }) => { + const starlight = await getProdServer(); + 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, + getProdServer, + }) => { + const starlight = await getProdServer(); + 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, + getProdServer, + }) => { + const starlight = await getProdServer(); + 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, + getProdServer, + }) => { + const starlight = await getProdServer(); + 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' + ); + }); + + test('syncs and restores nested tabs', async ({ page, getProdServer }) => { + const starlight = await getProdServer(); + await starlight.goto('/tabs-nested'); + + const tabs = page.locator('starlight-tabs'); + const pkgTabs = tabs.nth(0); + const osTabsA = tabs.nth(1); + const osTabsB = tabs.nth(2); + + // Select the linux tab in the npm tab. + await osTabsA.getByRole('tab').filter({ hasText: 'linux' }).click(); + + await expectSelectedTab(osTabsA, 'linux', 'npm GNU/Linux'); + + // Select the pnpm tab. + await pkgTabs.getByRole('tab').filter({ hasText: 'pnpm' }).click(); + + await expectSelectedTab(pkgTabs, 'pnpm'); + await expectSelectedTab(osTabsB, 'linux', 'pnpm GNU/Linux'); + + page.reload(); + + // The synced tabs should be restored. + await expectSelectedTab(pkgTabs, 'pnpm'); + await expectSelectedTab(osTabsB, 'linux', 'pnpm GNU/Linux'); + }); +}); + +test.describe('whitespaces', () => { + /** + * Components including styles include a trailing whitespace which can be problematic when used + * inline, e.g.: + * + * ```mdx + * Badge () + * ``` + * + * The example above would render as: + * + * ``` + * Badge (test ) + * ``` + * + * Having a component being responsible for its own spacing is not ideal and should be avoided + * especially when used inline. + * To work around this issue, such components can be wrapped in a fragment. + * + * @see https://github.com/withastro/compiler/issues/1003 + */ + test('does not include components having trailing whitespaces when used inline', async ({ + page, + getProdServer, + }) => { + const starlight = await getProdServer(); + await starlight.goto('/whitespaces'); + + expect(await page.getByTestId('badge').textContent()).toContain('Badge (Note)'); + expect(await page.getByTestId('icon').textContent()).toContain('Icon ()'); + }); +}); + +async function expectSelectedTab(tabs: Locator, label: string, panel?: string) { + expect( + ( + await tabs.locator(':scope > div:first-child [role=tab][aria-selected=true]').textContent() + )?.trim() + ).toBe(label); + + if (panel) { + const tabPanel = tabs.locator(':scope > [role=tabpanel]:not([hidden])'); + await expect(tabPanel).toBeVisible(); + expect((await tabPanel.textContent())?.trim()).toBe(panel); + } +} diff --git a/packages/starlight/__e2e__/fixtures/basics/src/components/Test.astro b/packages/starlight/__e2e__/fixtures/basics/src/components/Test.astro new file mode 100644 index 00000000000..e04d26faa02 --- /dev/null +++ b/packages/starlight/__e2e__/fixtures/basics/src/components/Test.astro @@ -0,0 +1,11 @@ +--- +interface Props { + id: string; +} + +const { id } = Astro.props; +--- + +
+ +
diff --git a/packages/starlight/__e2e__/fixtures/basics/src/content/docs/whitespaces.mdx b/packages/starlight/__e2e__/fixtures/basics/src/content/docs/whitespaces.mdx new file mode 100644 index 00000000000..0ee24b83e04 --- /dev/null +++ b/packages/starlight/__e2e__/fixtures/basics/src/content/docs/whitespaces.mdx @@ -0,0 +1,14 @@ +--- +title: Whitespaces +--- + +import { Badge, Icon } from '@astrojs/starlight/components'; +import Test from '../../components/Test.astro'; + + + Badge () + + + + Icon () + diff --git a/packages/starlight/__e2e__/tabs.test.ts b/packages/starlight/__e2e__/tabs.test.ts deleted file mode 100644 index 14b2079177a..00000000000 --- a/packages/starlight/__e2e__/tabs.test.ts +++ /dev/null @@ -1,354 +0,0 @@ -import { expect, testFactory, type Locator } from './test-utils'; - -const test = testFactory('./fixtures/basics/'); - -test('syncs tabs with a click event', async ({ page, getProdServer }) => { - const starlight = await getProdServer(); - await starlight.goto('/tabs'); - - const tabs = page.locator('starlight-tabs'); - const pkgTabsA = tabs.nth(0); - const pkgTabsB = tabs.nth(2); - - // Select the pnpm tab in the first set of synced tabs. - await pkgTabsA.getByRole('tab').filter({ hasText: 'pnpm' }).click(); - - await expectSelectedTab(pkgTabsA, 'pnpm', 'pnpm command'); - await expectSelectedTab(pkgTabsB, 'pnpm', 'another pnpm command'); - - // Select the yarn tab in the second set of synced tabs. - await pkgTabsB.getByRole('tab').filter({ hasText: 'yarn' }).click(); - - await expectSelectedTab(pkgTabsB, 'yarn', 'another yarn command'); - await expectSelectedTab(pkgTabsA, 'yarn', 'yarn command'); -}); - -test('syncs tabs with a keyboard event', async ({ page, getProdServer }) => { - const starlight = await getProdServer(); - await starlight.goto('/tabs'); - - const tabs = page.locator('starlight-tabs'); - const pkgTabsA = tabs.nth(0); - const pkgTabsB = tabs.nth(2); - - // Select the pnpm tab in the first set of synced tabs with the keyboard. - await pkgTabsA.getByRole('tab', { selected: true }).press('ArrowRight'); - - await expectSelectedTab(pkgTabsA, 'pnpm', 'pnpm command'); - await expectSelectedTab(pkgTabsB, 'pnpm', 'another pnpm command'); - - // Select back the npm tab in the second set of synced tabs with the keyboard. - const selectedTabB = pkgTabsB.getByRole('tab', { selected: true }); - await selectedTabB.press('ArrowRight'); - await selectedTabB.press('ArrowLeft'); - await selectedTabB.press('ArrowLeft'); - - await expectSelectedTab(pkgTabsA, 'npm', 'npm command'); - await expectSelectedTab(pkgTabsB, 'npm', 'another npm command'); -}); - -test('syncs only tabs using the same sync key', async ({ page, getProdServer }) => { - const starlight = await getProdServer(); - await starlight.goto('/tabs'); - - const tabs = page.locator('starlight-tabs'); - 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, getProdServer }) => { - const starlight = await getProdServer(); - await starlight.goto('/tabs'); - - const tabs = page.locator('starlight-tabs'); - const pkgTabsA = tabs.nth(0); - const pkgTabsB = tabs.nth(2); // This set contains an extra tab item. - - // Select the bun tab in the second set of synced tabs. - await pkgTabsB.getByRole('tab').filter({ hasText: 'bun' }).click(); - - await expectSelectedTab(pkgTabsA, 'npm', 'npm command'); - await expectSelectedTab(pkgTabsB, 'bun', 'another bun command'); -}); - -test('persists the focus when syncing tabs', async ({ page, getProdServer }) => { - const starlight = await getProdServer(); - await starlight.goto('/tabs'); - - const pkgTabsA = page.locator('starlight-tabs').nth(0); - - // Focus the selected tab in the set of tabs synced with the 'pkg' key. - await pkgTabsA.getByRole('tab', { selected: true }).focus(); - // Select the pnpm tab in the set of tabs synced with the 'pkg' key using the keyboard. - await page.keyboard.press('ArrowRight'); - - expect( - await pkgTabsA - .getByRole('tab', { selected: true }) - .evaluate((node) => document.activeElement === node) - ).toBe(true); -}); - -test('preserves tabs position when alternating between tabs with different content heights', async ({ - page, - getProdServer, -}) => { - const starlight = await getProdServer(); - await starlight.goto('/tabs-variable-height'); - - const tabs = page.locator('starlight-tabs').nth(1); - const selectedTab = tabs.getByRole('tab', { selected: true }); - - // Scroll to the second set of synced tabs and focus the selected tab. - await tabs.scrollIntoViewIfNeeded(); - await selectedTab.focus(); - - // Get the bounding box of the tabs. - const initialBoundingBox = await tabs.boundingBox(); - - // Select the second tab which has a different height. - await selectedTab.press('ArrowRight'); - - // Ensure the tabs vertical position is exactly the same after selecting the second tab. - // Note that a small difference could be the result of the base line-height having a fractional part which can cause a - // sub-pixel difference in some browsers like Chrome or Firefox. - expect((await tabs.boundingBox())?.y).toBe(initialBoundingBox?.y); -}); - -test('syncs tabs with the same sync key if they do not consistenly use icons', async ({ - page, - getProdServer, -}) => { - const starlight = await getProdServer(); - await starlight.goto('/tabs'); - - const tabs = page.locator('starlight-tabs'); - const pkgTabsA = tabs.nth(0); // This set does not use icons for tab items. - const pkgTabsB = tabs.nth(4); // This set uses icons for tab items. - - // Select the pnpm tab in the first set of synced tabs. - await pkgTabsA.getByRole('tab').filter({ hasText: 'pnpm' }).click(); - - await expectSelectedTab(pkgTabsA, 'pnpm', 'pnpm command'); - await expectSelectedTab(pkgTabsB, 'pnpm', 'another pnpm command'); - - // Select the yarn tab in the second set of synced tabs. - await pkgTabsB.getByRole('tab').filter({ hasText: 'yarn' }).click(); - - await expectSelectedTab(pkgTabsB, 'yarn', 'another yarn command'); - await expectSelectedTab(pkgTabsA, 'yarn', 'yarn command'); -}); - -test('restores tabs only for synced tabs with a persisted state', async ({ - page, - getProdServer, -}) => { - const starlight = await getProdServer(); - 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, - getProdServer, -}) => { - const starlight = await getProdServer(); - 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, - getProdServer, -}) => { - const starlight = await getProdServer(); - 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, - getProdServer, -}) => { - const starlight = await getProdServer(); - 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, - getProdServer, -}) => { - const starlight = await getProdServer(); - 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, - getProdServer, -}) => { - const starlight = await getProdServer(); - 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' - ); -}); - -test('syncs and restores nested tabs', async ({ page, getProdServer }) => { - const starlight = await getProdServer(); - await starlight.goto('/tabs-nested'); - - const tabs = page.locator('starlight-tabs'); - const pkgTabs = tabs.nth(0); - const osTabsA = tabs.nth(1); - const osTabsB = tabs.nth(2); - - // Select the linux tab in the npm tab. - await osTabsA.getByRole('tab').filter({ hasText: 'linux' }).click(); - - await expectSelectedTab(osTabsA, 'linux', 'npm GNU/Linux'); - - // Select the pnpm tab. - await pkgTabs.getByRole('tab').filter({ hasText: 'pnpm' }).click(); - - await expectSelectedTab(pkgTabs, 'pnpm'); - await expectSelectedTab(osTabsB, 'linux', 'pnpm GNU/Linux'); - - page.reload(); - - // The synced tabs should be restored. - await expectSelectedTab(pkgTabs, 'pnpm'); - await expectSelectedTab(osTabsB, 'linux', 'pnpm GNU/Linux'); -}); - -async function expectSelectedTab(tabs: Locator, label: string, panel?: string) { - expect( - ( - await tabs.locator(':scope > div:first-child [role=tab][aria-selected=true]').textContent() - )?.trim() - ).toBe(label); - - if (panel) { - const tabPanel = tabs.locator(':scope > [role=tabpanel]:not([hidden])'); - await expect(tabPanel).toBeVisible(); - expect((await tabPanel.textContent())?.trim()).toBe(panel); - } -} diff --git a/packages/starlight/user-components/Badge.astro b/packages/starlight/user-components/Badge.astro index 821cd0a32e3..0d08c3b5884 100644 --- a/packages/starlight/user-components/Badge.astro +++ b/packages/starlight/user-components/Badge.astro @@ -16,9 +16,14 @@ const { Astro.props, 'Invalid prop passed to the `` component.' ); + +/** + * The fragment around the element is used as a workaround to avoid a trailing whitespace in the output. + * @see https://github.com/withastro/compiler/issues/1003 + */ --- -{text} +<>{text}