summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorHiDeoo2025-04-07 17:07:02 +0200
committerGitHub2025-04-07 17:07:02 +0200
commit6a56d1b80d9d67e63e930177cf085a25864e1952 (patch)
treec358172af0a0bb8c4a369e237d96c40c45254d97
parent47da577fcace94f7545aa2e62b121fc13c5bdd15 (diff)
downloadIT.starlight-6a56d1b80d9d67e63e930177cf085a25864e1952.tar.gz
IT.starlight-6a56d1b80d9d67e63e930177cf085a25864e1952.tar.bz2
IT.starlight-6a56d1b80d9d67e63e930177cf085a25864e1952.zip
Remove trailing whitespace from `<Badge>` and `<Icon>` components (#2924)
Co-authored-by: Chris Swithinbank <swithinbank@gmail.com>
-rw-r--r--.changeset/red-monkeys-tickle.md32
-rw-r--r--docs/src/content/docs/components/badges.mdx48
-rw-r--r--packages/starlight/__e2e__/components.test.ts389
-rw-r--r--packages/starlight/__e2e__/fixtures/basics/src/components/Test.astro11
-rw-r--r--packages/starlight/__e2e__/fixtures/basics/src/content/docs/whitespaces.mdx14
-rw-r--r--packages/starlight/__e2e__/tabs.test.ts354
-rw-r--r--packages/starlight/user-components/Badge.astro7
-rw-r--r--packages/starlight/user-components/Icon.astro25
8 files changed, 492 insertions, 388 deletions
diff --git a/.changeset/red-monkeys-tickle.md b/.changeset/red-monkeys-tickle.md
new file mode 100644
index 00000000..526b1f82
--- /dev/null
+++ b/.changeset/red-monkeys-tickle.md
@@ -0,0 +1,32 @@
+---
+'@astrojs/starlight': minor
+---
+
+⚠️ **BREAKING CHANGE:** Ensures that the `<Badge>` and `<Icon>` 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 `<Badge>` and `<Icon>` 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
+<Badge text="New" />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
+- <Badge text="New" />Feature
++ <Badge text="New" /> Feature
+```
diff --git a/docs/src/content/docs/components/badges.mdx b/docs/src/content/docs/components/badges.mdx
index 1c95f570..a247edd6 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';
-<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" />
```
<Fragment slot="markdoc">
```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" /%}
```
</Fragment>
<Fragment slot="preview">
- <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" />
</Fragment>
</Preview>
@@ -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';
-<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" />
```
<Fragment slot="markdoc">
```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" /%}
```
</Fragment>
<Fragment slot="preview">
- <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" />
</Fragment>
</Preview>
diff --git a/packages/starlight/__e2e__/components.test.ts b/packages/starlight/__e2e__/components.test.ts
new file mode 100644
index 00000000..b4021c47
--- /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 `<starlight-tabs-restore>` 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 (<Badge text="test" />)
+ * ```
+ *
+ * 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 00000000..e04d26fa
--- /dev/null
+++ b/packages/starlight/__e2e__/fixtures/basics/src/components/Test.astro
@@ -0,0 +1,11 @@
+---
+interface Props {
+ id: string;
+}
+
+const { id } = Astro.props;
+---
+
+<div data-testid={id}>
+ <slot />
+</div>
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 00000000..0ee24b83
--- /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';
+
+<Test id="badge">
+ Badge (<Badge text="Note" variant="note" />)
+</Test>
+
+<Test id="icon">
+ Icon (<Icon name="star" />)
+</Test>
diff --git a/packages/starlight/__e2e__/tabs.test.ts b/packages/starlight/__e2e__/tabs.test.ts
deleted file mode 100644
index 14b20791..00000000
--- 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 `<starlight-tabs-restore>` 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 821cd0a3..0d08c3b5 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 `<Badge/>` 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
+ */
---
-<span class:list={['sl-badge', variant, size, customClass]} {...attrs}>{text}</span>
+<><span class:list={['sl-badge', variant, size, customClass]} {...attrs}>{text}</span></>
<style>
:global(:root) {
diff --git a/packages/starlight/user-components/Icon.astro b/packages/starlight/user-components/Icon.astro
index ee7c443d..f9e8a874 100644
--- a/packages/starlight/user-components/Icon.astro
+++ b/packages/starlight/user-components/Icon.astro
@@ -11,17 +11,24 @@ interface Props {
const { name, label, size = '1em', color } = Astro.props;
const a11yAttrs = label ? ({ 'aria-label': label } as const) : ({ 'aria-hidden': 'true' } as const);
+
+/**
+ * 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
+ */
---
-<svg
- {...a11yAttrs}
- class={Astro.props.class}
- width="16"
- height="16"
- viewBox="0 0 24 24"
- fill="currentColor"
- set:html={Icons[name]}
-/>
+<>
+ <svg
+ {...a11yAttrs}
+ class={Astro.props.class}
+ width="16"
+ height="16"
+ viewBox="0 0 24 24"
+ fill="currentColor"
+ set:html={Icons[name]}
+ />
+</>
<style define:vars={{ 'sl-icon-color': color, 'sl-icon-size': size }}>
svg {