From de7898fcf862586b1d1af34651c7adfbad3e791e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 4 Sep 2026 16:22:22 +0000 Subject: [PATCH 1/2] feat(ui): add css :target deeplinks to CodeTabs Replace Radix tabs in CodeTabs with a pure HTML/CSS implementation so URL fragments select a tab via :target without hash event listeners. --- .changeset/codetabs-anchor-deeplinks.md | 5 + .../CodeTabs/__tests__/getCodeTabId.test.mjs | 23 +++ .../Common/CodeTabs/__tests__/index.test.jsx | 157 ++++++++++++++++++ .../src/Common/CodeTabs/getCodeTabId.ts | 28 ++++ .../src/Common/CodeTabs/index.module.css | 114 +++++++++++-- .../src/Common/CodeTabs/index.stories.tsx | 29 ++-- .../src/Common/CodeTabs/index.tsx | 100 ++++++++++- packages/ui-components/src/MDX/CodeTabs.tsx | 25 ++- .../src/MDX/__tests__/CodeTabs.test.jsx | 73 ++++++++ 9 files changed, 506 insertions(+), 48 deletions(-) create mode 100644 .changeset/codetabs-anchor-deeplinks.md create mode 100644 packages/ui-components/src/Common/CodeTabs/__tests__/getCodeTabId.test.mjs create mode 100644 packages/ui-components/src/Common/CodeTabs/__tests__/index.test.jsx create mode 100644 packages/ui-components/src/Common/CodeTabs/getCodeTabId.ts create mode 100644 packages/ui-components/src/MDX/__tests__/CodeTabs.test.jsx diff --git a/.changeset/codetabs-anchor-deeplinks.md b/.changeset/codetabs-anchor-deeplinks.md new file mode 100644 index 0000000000000..a1470f9786929 --- /dev/null +++ b/.changeset/codetabs-anchor-deeplinks.md @@ -0,0 +1,5 @@ +--- +'@node-core/ui-components': minor +--- + +Add HTML/CSS `:target` deep linking to CodeTabs and replace Radix Tabs for that component so fragments work without JavaScript hash listeners. diff --git a/packages/ui-components/src/Common/CodeTabs/__tests__/getCodeTabId.test.mjs b/packages/ui-components/src/Common/CodeTabs/__tests__/getCodeTabId.test.mjs new file mode 100644 index 0000000000000..6a18804b43776 --- /dev/null +++ b/packages/ui-components/src/Common/CodeTabs/__tests__/getCodeTabId.test.mjs @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { getCodeTabId, slugifyIdSegment } from '../getCodeTabId'; + +describe('getCodeTabId', () => { + it('builds `{groupId}-{tabKey}` fragments', () => { + assert.equal(getCodeTabId('install', 'js-0'), 'install-js-0'); + assert.equal(getCodeTabId('install', 'cjs-1'), 'install-cjs-1'); + }); + + it('slugifies labels and prefixes numeric segments', () => { + assert.equal(slugifyIdSegment('Hello World'), 'hello-world'); + assert.equal(slugifyIdSegment('123'), 'id-123'); + assert.equal(slugifyIdSegment('codetabs-:r1:'), 'codetabs-r1'); + assert.equal(getCodeTabId('Install Steps', 'C++'), 'install-steps-c'); + }); + + it('falls back to `tab` for empty input', () => { + assert.equal(slugifyIdSegment(' '), 'tab'); + assert.equal(getCodeTabId('', 'js'), 'tab-js'); + }); +}); diff --git a/packages/ui-components/src/Common/CodeTabs/__tests__/index.test.jsx b/packages/ui-components/src/Common/CodeTabs/__tests__/index.test.jsx new file mode 100644 index 0000000000000..ce533dc1afc27 --- /dev/null +++ b/packages/ui-components/src/Common/CodeTabs/__tests__/index.test.jsx @@ -0,0 +1,157 @@ +import { afterEach, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import CodeTabs from '../index'; + +const tabs = [ + { key: 'mjs', label: 'MJS' }, + { key: 'cjs', label: 'CJS' }, +]; + +const Sut = ({ groupId, defaultValue = 'mjs', addons } = {}) => ( + +
mjs panel
+
cjs panel
+
+); + +const resetHash = () => { + window.history.replaceState(null, '', '/'); +}; + +describe('CodeTabs', () => { + afterEach(resetHash); + + it('renders panel content for each tab', () => { + render(); + + assert.ok(screen.getByText('mjs panel')); + assert.ok(screen.getByText('cjs panel')); + }); + + it('assigns fragment ids and hrefs using groupId', () => { + render(); + + const mjs = screen.getByRole('link', { name: 'MJS' }); + const cjs = screen.getByRole('link', { name: 'CJS' }); + + assert.equal(mjs.id, 'hello-world-mjs'); + assert.equal(mjs.getAttribute('href'), '#hello-world-mjs'); + assert.equal(cjs.id, 'hello-world-cjs'); + assert.equal(cjs.getAttribute('href'), '#hello-world-cjs'); + }); + + it('marks the first tab as default when no hash is present', () => { + render(); + + assert.equal( + screen.getByRole('link', { name: 'MJS' }).getAttribute('data-default'), + 'true' + ); + assert.equal( + screen.getByRole('link', { name: 'CJS' }).getAttribute('data-default'), + null + ); + }); + + it('marks the requested default tab when defaultValue is set', () => { + render(); + + assert.equal( + screen.getByRole('link', { name: 'CJS' }).getAttribute('data-default'), + 'true' + ); + assert.equal( + screen.getByRole('link', { name: 'MJS' }).getAttribute('data-default'), + null + ); + }); + + it('selects the matching tab as :target on an initial deep link', () => { + window.history.replaceState(null, '', '/#hello-world-cjs'); + + render(); + + const target = document.querySelector(':target'); + + assert.ok(target); + assert.equal(target.id, 'hello-world-cjs'); + assert.equal(target, screen.getByRole('link', { name: 'CJS' })); + }); + + it('keeps the default tab when the hash does not match a tab', () => { + window.history.replaceState(null, '', '/#not-a-code-tab'); + + render(); + + assert.equal(document.querySelector(':target'), null); + assert.equal( + screen.getByRole('link', { name: 'MJS' }).getAttribute('data-default'), + 'true' + ); + }); + + it('updates the URL hash when a tab is clicked', async () => { + render(); + + await userEvent.click(screen.getByRole('link', { name: 'CJS' })); + + assert.equal(window.location.hash, '#hello-world-cjs'); + assert.equal(document.querySelector(':target')?.id, 'hello-world-cjs'); + }); + + it('navigates between tab hashes', async () => { + render(); + + await userEvent.click(screen.getByRole('link', { name: 'CJS' })); + assert.equal(window.location.hash, '#hello-world-cjs'); + + await userEvent.click(screen.getByRole('link', { name: 'MJS' })); + assert.equal(window.location.hash, '#hello-world-mjs'); + assert.equal(document.querySelector(':target')?.id, 'hello-world-mjs'); + }); + + it('does not collide when multiple CodeTabs share languages', () => { + render( + <> + + + + ); + + const links = screen.getAllByRole('link'); + const ids = links.map(link => link.id).filter(Boolean); + + assert.equal(ids.length, 4); + assert.equal(new Set(ids).size, ids.length); + assert.ok(ids.every(id => id.startsWith('codetabs-'))); + }); + + it('renders addons in the tab list', () => { + render(addon} />); + + assert.ok(screen.getByRole('link', { name: 'addon' }).ownerDocument); + }); + + it('uses CSS :target to switch the active tab without JavaScript listeners', () => { + const css = readFileSync( + join(dirname(fileURLToPath(import.meta.url)), '../index.module.css'), + 'utf8' + ); + + assert.match(css, /:target/); + assert.match(css, /:has\(\.trigger:target\)/); + assert.match(css, /\.trigger:target/); + }); +}); diff --git a/packages/ui-components/src/Common/CodeTabs/getCodeTabId.ts b/packages/ui-components/src/Common/CodeTabs/getCodeTabId.ts new file mode 100644 index 0000000000000..9d4024cc3c577 --- /dev/null +++ b/packages/ui-components/src/Common/CodeTabs/getCodeTabId.ts @@ -0,0 +1,28 @@ +/** + * Builds stable, URL-safe HTML ids for CodeTabs triggers. + * + * Scheme: + * - With `groupId`: `{slug(groupId)}-{slug(tabKey)}` (e.g. `install-js-0`) + * - Without: `{slug(instancePrefix)}-{slug(tabKey)}` (e.g. `codetabs-r1-js-0`) + * + * `tabKey` is the tab's language/key (MDX already uses `${language}-${index}`). + * `instancePrefix` is unique per CodeTabs on the page so identical language + * groups do not collide. + */ +export function slugifyIdSegment(value: string): string { + const slug = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + + if (!slug) { + return 'tab'; + } + + return /^[a-z]/.test(slug) ? slug : `id-${slug}`; +} + +export function getCodeTabId(prefix: string, tabKey: string): string { + return `${slugifyIdSegment(prefix)}-${slugifyIdSegment(tabKey)}`; +} diff --git a/packages/ui-components/src/Common/CodeTabs/index.module.css b/packages/ui-components/src/Common/CodeTabs/index.module.css index 0c15b4775d1f0..bb64c895f0dc0 100644 --- a/packages/ui-components/src/Common/CodeTabs/index.module.css +++ b/packages/ui-components/src/Common/CodeTabs/index.module.css @@ -1,17 +1,43 @@ @reference "../../styles/index.css"; .root { - /* `forceMount` keeps every panel in the DOM, so hide the inactive ones here */ - > [role='tabpanel'][data-state='inactive'] { + @apply grid + max-w-full; + + /* + * Panels stay in the DOM (copy buttons, no layout jump). Visibility is + * driven by CSS :target on the tab trigger, not JavaScript. + * Default (no matching hash in this group): [data-default]. + * Up to 10 tabs are wired via :nth-child; CodeTabs are typically 2–4. + */ + > .panel { @apply hidden; + + > :first-child { + @apply rounded-t-none; + } } - > [role='tabpanel'] > :first-child { - @apply rounded-t-none; + &:not(:has(.trigger:target)) > .panel[data-default], + &:has(.trigger:nth-child(1):target) > .panel:nth-child(2), + &:has(.trigger:nth-child(2):target) > .panel:nth-child(3), + &:has(.trigger:nth-child(3):target) > .panel:nth-child(4), + &:has(.trigger:nth-child(4):target) > .panel:nth-child(5), + &:has(.trigger:nth-child(5):target) > .panel:nth-child(6), + &:has(.trigger:nth-child(6):target) > .panel:nth-child(7), + &:has(.trigger:nth-child(7):target) > .panel:nth-child(8), + &:has(.trigger:nth-child(8):target) > .panel:nth-child(9), + &:has(.trigger:nth-child(9):target) > .panel:nth-child(10), + &:has(.trigger:nth-child(10):target) > .panel:nth-child(11) { + @apply block; } - > div:nth-of-type(1) { - @apply flex + > .tabList { + @apply font-open-sans + scrollbar-thin + flex + gap-2 + overflow-x-auto rounded-t border-x border-t @@ -27,17 +53,83 @@ @apply border-b border-b-transparent px-1 + pt-0 + pb-2 + text-sm + font-semibold + whitespace-nowrap text-neutral-800 + no-underline dark:text-neutral-200; - &[data-state='active'] { - @apply border-b-brand-600 - text-brand-700 - dark:border-b-brand-400 - dark:text-brand-400; + scroll-margin-top: calc( + var(--header-height) + var(--spacing, 0.25rem) * 6 + ); + + &:focus-visible { + @apply outline-brand-600 + rounded-xs + outline-2 + outline-offset-2; + } + + &:is(:link, :visited):hover { + @apply text-neutral-800 + dark:text-neutral-200; + } + + .tabExtension { + @apply ml-1 + rounded-xs + border + border-neutral-200 + px-1 + py-0 + text-xs + font-normal + text-neutral-200; + } + + .tabSecondaryLabel { + @apply pl-1 + text-neutral-500 + dark:text-neutral-800; } } + /* + * Active tab: the :target trigger, or the default trigger when this + * CodeTabs instance does not contain the current fragment. + */ + &:not(:has(.trigger:target)) .trigger[data-default], + .trigger:target { + @apply border-b-brand-600 + text-brand-700 + dark:border-b-brand-400 + dark:text-brand-400 + no-underline; + + .tabExtension { + @apply border-brand-400 + text-brand-400; + } + + .tabSecondaryLabel { + @apply text-brand-800 + dark:text-brand-600; + } + } + + .addons { + @apply ml-auto + border-b-2 + border-b-transparent + px-1 + pb-[11px] + text-sm + font-semibold; + } + .link { @apply hidden items-center diff --git a/packages/ui-components/src/Common/CodeTabs/index.stories.tsx b/packages/ui-components/src/Common/CodeTabs/index.stories.tsx index 844e3f8e73582..d832fb10a9f85 100644 --- a/packages/ui-components/src/Common/CodeTabs/index.stories.tsx +++ b/packages/ui-components/src/Common/CodeTabs/index.stories.tsx @@ -1,10 +1,7 @@ -import * as TabsPrimitive from '@radix-ui/react-tabs'; - import BaseCodeBox from '#ui/Common/BaseCodeBox'; import CodeTabs from '#ui/Common/CodeTabs'; import type { Meta as MetaObj, StoryObj } from '@storybook/react-webpack5'; -import type { FC } from 'react'; type Story = StoryObj; type Meta = MetaObj; @@ -44,18 +41,14 @@ const boxProps = { buttonContent: '[Button Text]', }; -const TabsContent: FC = () => ( +const tabsContent = ( <> - - - {mjsContent} - - - - - {cjsContent} - - + + {mjsContent} + + + {cjsContent} + ); @@ -70,10 +63,16 @@ export const WithExtension: Story = { }, }; +export const WithGroupId: Story = { + args: { + groupId: 'hello-world', + }, +}; + export default { component: CodeTabs, args: { - children: , + children: tabsContent, defaultValue: 'mjs', tabs: [ { key: 'mjs', label: 'MJS' }, diff --git a/packages/ui-components/src/Common/CodeTabs/index.tsx b/packages/ui-components/src/Common/CodeTabs/index.tsx index 12ff05973037e..ed15d32b5531c 100644 --- a/packages/ui-components/src/Common/CodeTabs/index.tsx +++ b/packages/ui-components/src/Common/CodeTabs/index.tsx @@ -1,16 +1,98 @@ -import Tabs from '#ui/Common/Tabs'; +import { Children, useId } from 'react'; -import type { ComponentProps, FC } from 'react'; +import type { FC, ReactNode } from 'react'; + +import { getCodeTabId, slugifyIdSegment } from './getCodeTabId'; import styles from './index.module.css'; -type CodeTabsProps = Pick< - ComponentProps, - 'tabs' | 'defaultValue' | 'children' | 'addons' ->; +type CodeTab = { + key: string; + label: string; + secondaryLabel?: string; + value?: string; + extension?: string; +}; + +type CodeTabsProps = { + tabs: Array; + defaultValue?: string; + /** + * Optional id prefix for this group. When set, tab fragments are + * `{slug(groupId)}-{slug(tabKey)}`. When omitted, a per-instance prefix is + * used so multiple CodeTabs on one page cannot collide. + */ + groupId?: string; + addons?: ReactNode; + children?: ReactNode; +}; + +const CodeTabs: FC = ({ + tabs, + defaultValue, + groupId, + addons, + children, +}) => { + const reactId = useId(); + const instancePrefix = groupId + ? slugifyIdSegment(groupId) + : slugifyIdSegment(`codetabs-${reactId}`); + + // Flatten fragments/arrays so each tab maps to one panel (MDX + stories). + // eslint-disable-next-line @eslint-react/no-children-to-array + const panels = Children.toArray(children); + const hasExplicitDefault = tabs.some( + tab => (tab.value ?? tab.key) === defaultValue + ); + const defaultKey = hasExplicitDefault + ? defaultValue + : (tabs[0]?.value ?? tabs[0]?.key); + + const items = tabs.map((tab, index) => { + const tabKey = tab.value ?? tab.key; + const tabId = getCodeTabId(instancePrefix, tabKey); + const isDefault = tabKey === defaultKey; + + return { tab, tabId, isDefault, panel: panels[index] }; + }); -const CodeTabs: FC = ({ ...props }) => ( - -); + return ( +
+ + {items.map(({ tab, tabId, isDefault, panel }) => ( +
+ {panel} +
+ ))} +
+ ); +}; export default CodeTabs; diff --git a/packages/ui-components/src/MDX/CodeTabs.tsx b/packages/ui-components/src/MDX/CodeTabs.tsx index a4092ae6ece73..90bfa5dbfef67 100644 --- a/packages/ui-components/src/MDX/CodeTabs.tsx +++ b/packages/ui-components/src/MDX/CodeTabs.tsx @@ -1,4 +1,3 @@ -import * as TabsPrimitive from '@radix-ui/react-tabs'; import { useMemo } from 'react'; import CodeTabs from '#ui/Common/CodeTabs'; @@ -10,6 +9,12 @@ type MDXCodeTabsProps = { languages: string; displayNames?: string; defaultTab?: string; + /** + * Optional fragment prefix. Tab ids become `{slug(groupId)}-{language}-{index}`. + * When omitted, a unique per-instance prefix is used so multiple CodeTabs + * on one page do not collide. + */ + groupId?: string; }; const NAME_OVERRIDES: Record = { @@ -21,9 +26,10 @@ const MDXCodeTabs: FC = ({ displayNames: rawDisplayNames, children: codes, defaultTab = '0', + groupId, ...props }) => { - const { tabs, languages } = useMemo(() => { + const { tabs } = useMemo(() => { const occurrences: Record = {}; const languages = rawLanguages.split('|'); @@ -47,24 +53,17 @@ const MDXCodeTabs: FC = ({ }; }); - return { tabs, languages }; + return { tabs }; }, [rawLanguages, rawDisplayNames]); return ( - {languages.map((_, index) => ( - - {codes[index]} - - ))} + {codes} ); }; diff --git a/packages/ui-components/src/MDX/__tests__/CodeTabs.test.jsx b/packages/ui-components/src/MDX/__tests__/CodeTabs.test.jsx new file mode 100644 index 0000000000000..d6a14482bac7c --- /dev/null +++ b/packages/ui-components/src/MDX/__tests__/CodeTabs.test.jsx @@ -0,0 +1,73 @@ +import { afterEach, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import MDXCodeTabs from '../CodeTabs'; + +const resetHash = () => { + window.history.replaceState(null, '', '/'); +}; + +describe('MDXCodeTabs', () => { + afterEach(resetHash); + + it('deep-links to a language tab via groupId', async () => { + render( + +
js source
+
cjs source
+
+ ); + + const js = screen.getByRole('link', { name: 'JS' }); + const cjs = screen.getByRole('link', { name: 'CJS' }); + + assert.equal(js.id, 'install-js-0'); + assert.equal(cjs.id, 'install-cjs-1'); + assert.equal(js.getAttribute('data-default'), 'true'); + + await userEvent.click(cjs); + + assert.equal(window.location.hash, '#install-cjs-1'); + assert.equal(document.querySelector(':target')?.id, 'install-cjs-1'); + }); + + it('keeps unique ids when multiple CodeTabs share languages', () => { + render( + <> + +
one js
+
one cjs
+
+ +
two js
+
two cjs
+
+ + ); + + const ids = screen + .getAllByRole('link') + .map(link => link.id) + .filter(Boolean); + + assert.equal(ids.length, 4); + assert.equal(new Set(ids).size, ids.length); + }); + + it('uses the defaultTab index when no hash is present', () => { + render( + +
js source
+
cjs source
+
+ ); + + assert.equal( + screen.getByRole('link', { name: 'CJS' }).getAttribute('data-default'), + 'true' + ); + }); +}); From 65f4a276b814380f30ababe9ba65774f2cfbd7e1 Mon Sep 17 00:00:00 2001 From: Joseph B <289838966+joebasrawi@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:45:47 -0400 Subject: [PATCH 2/2] fix(ui): harden codetabs deep links --- .changeset/codetabs-anchor-deeplinks.md | 8 +- apps/site/tests/e2e/code-tabs.spec.ts | 57 ++++ packages/ui-components/package.json | 1 + .../CodeTabs/__tests__/getCodeTabId.test.mjs | 17 +- .../Common/CodeTabs/__tests__/index.test.jsx | 250 +++++++++++------- .../src/Common/CodeTabs/getCodeTabId.ts | 12 +- .../src/Common/CodeTabs/getPanels.ts | 22 ++ .../src/Common/CodeTabs/index.module.css | 31 +-- .../src/Common/CodeTabs/index.stories.tsx | 13 + .../src/Common/CodeTabs/index.tsx | 83 ++++-- .../Common/CodeTabs/useCodeTabNavigation.ts | 59 +++++ packages/ui-components/src/MDX/CodeTabs.tsx | 2 +- .../src/MDX/__tests__/CodeTabs.test.jsx | 79 +++--- pnpm-lock.yaml | 3 + 14 files changed, 435 insertions(+), 202 deletions(-) create mode 100644 apps/site/tests/e2e/code-tabs.spec.ts create mode 100644 packages/ui-components/src/Common/CodeTabs/getPanels.ts create mode 100644 packages/ui-components/src/Common/CodeTabs/useCodeTabNavigation.ts diff --git a/.changeset/codetabs-anchor-deeplinks.md b/.changeset/codetabs-anchor-deeplinks.md index a1470f9786929..9e34a2a707788 100644 --- a/.changeset/codetabs-anchor-deeplinks.md +++ b/.changeset/codetabs-anchor-deeplinks.md @@ -1,5 +1,9 @@ --- -'@node-core/ui-components': minor +'@node-core/ui-components': major --- -Add HTML/CSS `:target` deep linking to CodeTabs and replace Radix Tabs for that component so fragments work without JavaScript hash listeners. +Add URL-fragment deep links to CodeTabs. CSS selects the visible panel without JavaScript; a client enhancement keeps keyboard navigation and ARIA state in sync with the fragment. + +CodeTabs now expects one raw child per tab, in tab order. Replace Radix `Tabs.Content` children with their contents. Arrays and fragments are supported; components that internally render multiple panels must be expanded at the call site. This replaces the previous Radix context and is a breaking change for direct CodeTabs consumers. The MDX wrapper remains compatible. + +Use a unique `groupId` for durable links. Fragments are `{slug(groupId)}-{slug(tabKey)}-{index}`; reordering tabs changes them. Generated instance prefixes avoid collisions but are not a permanent URL contract. diff --git a/apps/site/tests/e2e/code-tabs.spec.ts b/apps/site/tests/e2e/code-tabs.spec.ts new file mode 100644 index 0000000000000..38d1495999ca5 --- /dev/null +++ b/apps/site/tests/e2e/code-tabs.spec.ts @@ -0,0 +1,57 @@ +import { expect, test } from '@playwright/test'; + +test('code tabs support keyboard selection, deep links, and browser history', async ({ + page, +}) => { + await page.goto('/en'); + const tabs = page + .getByRole('tablist', { name: 'Code samples' }) + .getByRole('tab'); + const first = tabs.first(); + const second = tabs.nth(1); + const firstId = await first.getAttribute('aria-controls'); + const secondId = await second.getAttribute('aria-controls'); + + await first.focus(); + await page.keyboard.press('ArrowRight'); + await expect(second).toBeFocused(); + await expect(second).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator(`[id="${secondId}"]`)).toBeVisible(); + await expect(page.locator(`[id="${firstId}"]`)).toBeHidden(); + + await page.reload(); + await expect(second).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator(`[id="${secondId}"]`)).toBeVisible(); + await first.click(); + await expect(page.locator(`[id="${firstId}"]`)).toBeVisible(); + await page.goBack(); + await expect(second).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator(`[id="${secondId}"]`)).toBeVisible(); + await page.goForward(); + await expect(first).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator(`[id="${firstId}"]`)).toBeVisible(); +}); + +test.describe('without JavaScript', () => { + test.use({ javaScriptEnabled: false }); + + test('native links select visible panels and survive a reload', async ({ + page, + }) => { + await page.goto('/en'); + const links = page + .getByRole('navigation', { name: 'Code samples' }) + .getByRole('link'); + const firstId = await links.first().getAttribute('aria-controls'); + const second = links.nth(1); + const secondId = await second.getAttribute('aria-controls'); + await expect(page.locator(`[id="${firstId}"]`)).toBeVisible(); + await expect(page.locator(`[id="${secondId}"]`)).toBeHidden(); + await second.click(); + await expect(page.locator(`[id="${secondId}"]`)).toBeVisible(); + await expect(page.locator(`[id="${firstId}"]`)).toBeHidden(); + await page.reload(); + await expect(page.locator(`[id="${secondId}"]`)).toBeVisible(); + await expect(page.locator(`[id="${firstId}"]`)).toBeHidden(); + }); +}); diff --git a/packages/ui-components/package.json b/packages/ui-components/package.json index 8ae05440c5c4f..69a5b9a5b9407 100644 --- a/packages/ui-components/package.json +++ b/packages/ui-components/package.json @@ -85,6 +85,7 @@ "postcss-calc": "~10.1.1", "postcss-cli": "^11.0.1", "postcss-loader": "8.2.1", + "react-dom": "^19.2.8", "storybook": "~10.5.4", "style-loader": "4.0.0", "stylelint": "17.14.1", diff --git a/packages/ui-components/src/Common/CodeTabs/__tests__/getCodeTabId.test.mjs b/packages/ui-components/src/Common/CodeTabs/__tests__/getCodeTabId.test.mjs index 6a18804b43776..000df8f6f4291 100644 --- a/packages/ui-components/src/Common/CodeTabs/__tests__/getCodeTabId.test.mjs +++ b/packages/ui-components/src/Common/CodeTabs/__tests__/getCodeTabId.test.mjs @@ -4,20 +4,27 @@ import { describe, it } from 'node:test'; import { getCodeTabId, slugifyIdSegment } from '../getCodeTabId'; describe('getCodeTabId', () => { - it('builds `{groupId}-{tabKey}` fragments', () => { - assert.equal(getCodeTabId('install', 'js-0'), 'install-js-0'); - assert.equal(getCodeTabId('install', 'cjs-1'), 'install-cjs-1'); + it('includes the tab index in fragments', () => { + assert.equal(getCodeTabId('install', 'js', 0), 'install-js-0'); + assert.equal(getCodeTabId('install', 'cjs', 1), 'install-cjs-1'); }); it('slugifies labels and prefixes numeric segments', () => { assert.equal(slugifyIdSegment('Hello World'), 'hello-world'); assert.equal(slugifyIdSegment('123'), 'id-123'); assert.equal(slugifyIdSegment('codetabs-:r1:'), 'codetabs-r1'); - assert.equal(getCodeTabId('Install Steps', 'C++'), 'install-steps-c'); + assert.equal(getCodeTabId('install-steps', 'C++', 0), 'install-steps-c-0'); }); it('falls back to `tab` for empty input', () => { assert.equal(slugifyIdSegment(' '), 'tab'); - assert.equal(getCodeTabId('', 'js'), 'tab-js'); + assert.equal(getCodeTabId('install', '', 0), 'install-tab-0'); + }); + + it('preserves case in the prepared React instance prefix', () => { + assert.notEqual( + getCodeTabId('codetabs-R1', 'js', 0), + getCodeTabId('codetabs-r1', 'js', 0) + ); }); }); diff --git a/packages/ui-components/src/Common/CodeTabs/__tests__/index.test.jsx b/packages/ui-components/src/Common/CodeTabs/__tests__/index.test.jsx index ce533dc1afc27..06e780a0334dc 100644 --- a/packages/ui-components/src/Common/CodeTabs/__tests__/index.test.jsx +++ b/packages/ui-components/src/Common/CodeTabs/__tests__/index.test.jsx @@ -1,11 +1,9 @@ import { afterEach, describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { render, screen } from '@testing-library/react'; +import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { renderToString } from 'react-dom/server'; import CodeTabs from '../index'; @@ -13,8 +11,11 @@ const tabs = [ { key: 'mjs', label: 'MJS' }, { key: 'cjs', label: 'CJS' }, ]; - -const Sut = ({ groupId, defaultValue = 'mjs', addons } = {}) => ( +const Sut = ({ + groupId = 'hello-world', + defaultValue = 'mjs', + addons, +} = {}) => ( ( ); -const resetHash = () => { - window.history.replaceState(null, '', '/'); -}; - describe('CodeTabs', () => { - afterEach(resetHash); - - it('renders panel content for each tab', () => { - render(); - - assert.ok(screen.getByText('mjs panel')); - assert.ok(screen.getByText('cjs panel')); - }); - - it('assigns fragment ids and hrefs using groupId', () => { - render(); - - const mjs = screen.getByRole('link', { name: 'MJS' }); - const cjs = screen.getByRole('link', { name: 'CJS' }); - - assert.equal(mjs.id, 'hello-world-mjs'); - assert.equal(mjs.getAttribute('href'), '#hello-world-mjs'); - assert.equal(cjs.id, 'hello-world-cjs'); - assert.equal(cjs.getAttribute('href'), '#hello-world-cjs'); + afterEach(() => { + window.history.replaceState(null, '', '/'); }); - it('marks the first tab as default when no hash is present', () => { - render(); - + it('connects each tab to its labelled panel', () => { + render(); + for (const tab of screen.getAllByRole('tab')) { + const panel = document.getElementById(tab.getAttribute('aria-controls')); + assert.equal(tab.getAttribute('href'), '#' + panel.id); + assert.equal(panel.getAttribute('aria-labelledby'), tab.id); + assert.equal(panel.getAttribute('role'), 'tabpanel'); + } assert.equal( - screen.getByRole('link', { name: 'MJS' }).getAttribute('data-default'), + screen.getByRole('tab', { name: 'MJS' }).getAttribute('aria-selected'), 'true' ); - assert.equal( - screen.getByRole('link', { name: 'CJS' }).getAttribute('data-default'), - null - ); }); - it('marks the requested default tab when defaultValue is set', () => { - render(); - - assert.equal( - screen.getByRole('link', { name: 'CJS' }).getAttribute('data-default'), - 'true' + it('unwraps nested fragments and arrays into separate panels', () => { + render( + + <> + {[
mjs panel
]} + <> +
cjs panel
+ + +
); - assert.equal( - screen.getByRole('link', { name: 'MJS' }).getAttribute('data-default'), - null + assert.deepEqual( + screen.getAllByRole('tabpanel').map(panel => panel.textContent), + ['mjs panel', 'cjs panel'] ); }); - it('selects the matching tab as :target on an initial deep link', () => { - window.history.replaceState(null, '', '/#hello-world-cjs'); - - render(); - - const target = document.querySelector(':target'); - - assert.ok(target); - assert.equal(target.id, 'hello-world-cjs'); - assert.equal(target, screen.getByRole('link', { name: 'CJS' })); + it('uses the requested default and falls back for an unknown hash', () => { + window.history.replaceState(null, '', '/#unrelated-heading'); + render(); + assert.equal( + screen.getByRole('tab', { name: 'CJS' }).getAttribute('aria-selected'), + 'true' + ); }); - it('keeps the default tab when the hash does not match a tab', () => { - window.history.replaceState(null, '', '/#not-a-code-tab'); - - render(); - - assert.equal(document.querySelector(':target'), null); + it('selects an initial deep link before any click', () => { + window.history.replaceState(null, '', '/#hello-world-cjs-1'); + render(); assert.equal( - screen.getByRole('link', { name: 'MJS' }).getAttribute('data-default'), + screen.getByRole('tab', { name: 'CJS' }).getAttribute('aria-selected'), 'true' ); + assert.equal( + document.querySelector(':target'), + screen.getByRole('tabpanel', { name: 'CJS' }) + ); }); - it('updates the URL hash when a tab is clicked', async () => { - render(); - - await userEvent.click(screen.getByRole('link', { name: 'CJS' })); - - assert.equal(window.location.hash, '#hello-world-cjs'); - assert.equal(document.querySelector(':target')?.id, 'hello-world-cjs'); + it('updates the URL and selected state on click', async () => { + render(); + const cjs = screen.getByRole('tab', { name: 'CJS' }); + await userEvent.click(cjs); + await waitFor(() => + assert.equal(cjs.getAttribute('aria-selected'), 'true') + ); + assert.equal(window.location.hash, '#hello-world-cjs-1'); + assert.equal(cjs.tabIndex, 0); + assert.equal(screen.getByRole('tab', { name: 'MJS' }).tabIndex, -1); }); - it('navigates between tab hashes', async () => { - render(); - - await userEvent.click(screen.getByRole('link', { name: 'CJS' })); - assert.equal(window.location.hash, '#hello-world-cjs'); - - await userEvent.click(screen.getByRole('link', { name: 'MJS' })); - assert.equal(window.location.hash, '#hello-world-mjs'); - assert.equal(document.querySelector(':target')?.id, 'hello-world-mjs'); + it('supports arrow keys, wrapping, Home, End, and Space', async () => { + render(); + const mjs = screen.getByRole('tab', { name: 'MJS' }); + const cjs = screen.getByRole('tab', { name: 'CJS' }); + mjs.focus(); + for (const [key, expected] of [ + ['{ArrowLeft}', cjs], + ['{ArrowRight}', mjs], + ['{End}', cjs], + ['{Home}', mjs], + [' ', mjs], + ]) { + await userEvent.keyboard(key); + await waitFor(() => + assert.equal(expected.getAttribute('aria-selected'), 'true') + ); + assert.equal(document.activeElement, expected); + assert.equal(window.location.hash, expected.getAttribute('href')); + } }); - it('does not collide when multiple CodeTabs share languages', () => { + it('tracks external hash changes and resets unrelated groups', async () => { render( <> - + ); + await act(async () => { + window.location.hash = 'hello-world-cjs-1'; + }); + await waitFor(() => + assert.equal( + screen + .getAllByRole('tab', { name: 'CJS' })[0] + .getAttribute('aria-selected'), + 'true' + ) + ); + await act(async () => { + window.location.hash = 'other-cjs-1'; + }); + await waitFor(() => { + assert.equal( + screen + .getAllByRole('tab', { name: 'MJS' })[0] + .getAttribute('aria-selected'), + 'true' + ); + assert.equal( + screen + .getAllByRole('tab', { name: 'CJS' })[1] + .getAttribute('aria-selected'), + 'true' + ); + }); + }); - const links = screen.getAllByRole('link'); - const ids = links.map(link => link.id).filter(Boolean); - - assert.equal(ids.length, 4); + it('keeps generated instance ids unique', () => { + const { container } = render( + <> + + + + ); + const ids = [...container.querySelectorAll('[id]')].map( + element => element.id + ); assert.equal(new Set(ids).size, ids.length); - assert.ok(ids.every(id => id.startsWith('codetabs-'))); }); - it('renders addons in the tab list', () => { - render(addon} />); - - assert.ok(screen.getByRole('link', { name: 'addon' }).ownerDocument); + it('disambiguates tab keys with the same slug', () => { + render( + + {[ +
cpp
, +
cs
, +
c
, + ]} +
+ ); + assert.deepEqual( + screen.getAllByRole('tab').map(tab => tab.getAttribute('href')), + ['#languages-c-0', '#languages-c-1', '#languages-c-2'] + ); }); - it('uses CSS :target to switch the active tab without JavaScript listeners', () => { - const css = readFileSync( - join(dirname(fileURLToPath(import.meta.url)), '../index.module.css'), - 'utf8' + it('keeps addons outside the tablist', () => { + render(Documentation} />); + assert.equal( + screen + .getByRole('tablist') + .contains(screen.getByRole('link', { name: 'Documentation' })), + false ); + }); - assert.match(css, /:target/); - assert.match(css, /:has\(\.trigger:target\)/); - assert.match(css, /\.trigger:target/); + it('server-renders native links and all panels without claiming enhanced tab semantics', () => { + const html = renderToString(); + assert.match(html, /role="navigation"/); + assert.match(html, /href="#hello-world-cjs-1"/); + assert.match(html, /id="hello-world-cjs-1"/); + assert.match(html, /mjs panel/); + assert.match(html, /cjs panel/); + assert.doesNotMatch(html, /aria-selected|role="tab"/); }); }); diff --git a/packages/ui-components/src/Common/CodeTabs/getCodeTabId.ts b/packages/ui-components/src/Common/CodeTabs/getCodeTabId.ts index 9d4024cc3c577..6b6bf2b2fc2e6 100644 --- a/packages/ui-components/src/Common/CodeTabs/getCodeTabId.ts +++ b/packages/ui-components/src/Common/CodeTabs/getCodeTabId.ts @@ -2,8 +2,8 @@ * Builds stable, URL-safe HTML ids for CodeTabs triggers. * * Scheme: - * - With `groupId`: `{slug(groupId)}-{slug(tabKey)}` (e.g. `install-js-0`) - * - Without: `{slug(instancePrefix)}-{slug(tabKey)}` (e.g. `codetabs-r1-js-0`) + * The index keeps distinct keys unique even when their slugs are equal. + * The prefix is prepared by CodeTabs; preserve case in React-generated ids. * * `tabKey` is the tab's language/key (MDX already uses `${language}-${index}`). * `instancePrefix` is unique per CodeTabs on the page so identical language @@ -23,6 +23,10 @@ export function slugifyIdSegment(value: string): string { return /^[a-z]/.test(slug) ? slug : `id-${slug}`; } -export function getCodeTabId(prefix: string, tabKey: string): string { - return `${slugifyIdSegment(prefix)}-${slugifyIdSegment(tabKey)}`; +export function getCodeTabId( + prefix: string, + tabKey: string, + index: number +): string { + return `${prefix}-${slugifyIdSegment(tabKey)}-${index}`; } diff --git a/packages/ui-components/src/Common/CodeTabs/getPanels.ts b/packages/ui-components/src/Common/CodeTabs/getPanels.ts new file mode 100644 index 0000000000000..f25489f5cb32b --- /dev/null +++ b/packages/ui-components/src/Common/CodeTabs/getPanels.ts @@ -0,0 +1,22 @@ +import { Children, Fragment, isValidElement } from 'react'; + +import type { ReactNode } from 'react'; + +export function getPanels(children: ReactNode): Array { + const panels: Array = []; + + // The public children API accepts arrays and fragments in tab order. + // eslint-disable-next-line @eslint-react/no-children-for-each + Children.forEach(children, child => { + if ( + isValidElement<{ children?: ReactNode }>(child) && + child.type === Fragment + ) { + panels.push(...getPanels(child.props.children)); + } else if (child != null) { + panels.push(child); + } + }); + + return panels; +} diff --git a/packages/ui-components/src/Common/CodeTabs/index.module.css b/packages/ui-components/src/Common/CodeTabs/index.module.css index bb64c895f0dc0..801dfbbfb28b2 100644 --- a/packages/ui-components/src/Common/CodeTabs/index.module.css +++ b/packages/ui-components/src/Common/CodeTabs/index.module.css @@ -6,29 +6,21 @@ /* * Panels stay in the DOM (copy buttons, no layout jump). Visibility is - * driven by CSS :target on the tab trigger, not JavaScript. + * driven by CSS :target on the panel, not JavaScript. * Default (no matching hash in this group): [data-default]. - * Up to 10 tabs are wired via :nth-child; CodeTabs are typically 2–4. */ > .panel { @apply hidden; + scroll-margin-top: calc(var(--header-height) + var(--spacing, 0.25rem) * 6); + > :first-child { @apply rounded-t-none; } } - &:not(:has(.trigger:target)) > .panel[data-default], - &:has(.trigger:nth-child(1):target) > .panel:nth-child(2), - &:has(.trigger:nth-child(2):target) > .panel:nth-child(3), - &:has(.trigger:nth-child(3):target) > .panel:nth-child(4), - &:has(.trigger:nth-child(4):target) > .panel:nth-child(5), - &:has(.trigger:nth-child(5):target) > .panel:nth-child(6), - &:has(.trigger:nth-child(6):target) > .panel:nth-child(7), - &:has(.trigger:nth-child(7):target) > .panel:nth-child(8), - &:has(.trigger:nth-child(8):target) > .panel:nth-child(9), - &:has(.trigger:nth-child(9):target) > .panel:nth-child(10), - &:has(.trigger:nth-child(10):target) > .panel:nth-child(11) { + &:not(:has(> .panel:target)) > .panel[data-default], + > .panel:target { @apply block; } @@ -49,6 +41,11 @@ dark:border-neutral-900 dark:bg-neutral-950; + .triggers { + @apply flex + gap-2; + } + .trigger { @apply border-b border-b-transparent @@ -97,12 +94,8 @@ } } - /* - * Active tab: the :target trigger, or the default trigger when this - * CodeTabs instance does not contain the current fragment. - */ - &:not(:has(.trigger:target)) .trigger[data-default], - .trigger:target { + /* The enhancement mirrors the fragment in the accessible selected state. */ + .trigger[aria-selected='true'] { @apply border-b-brand-600 text-brand-700 dark:border-b-brand-400 diff --git a/packages/ui-components/src/Common/CodeTabs/index.stories.tsx b/packages/ui-components/src/Common/CodeTabs/index.stories.tsx index d832fb10a9f85..5b5bdf33b1e9d 100644 --- a/packages/ui-components/src/Common/CodeTabs/index.stories.tsx +++ b/packages/ui-components/src/Common/CodeTabs/index.stories.tsx @@ -69,6 +69,19 @@ export const WithGroupId: Story = { }, }; +export const ManyTabs: Story = { + args: { + groupId: 'many-tabs', + tabs: Array.from({ length: 12 }, (_, index) => ({ + key: `example-${index}`, + label: `Example ${index + 1}`, + })), + children: Array.from({ length: 12 }, (_, index) => ( +
Example {index + 1} content
+ )), + }, +}; + export default { component: CodeTabs, args: { diff --git a/packages/ui-components/src/Common/CodeTabs/index.tsx b/packages/ui-components/src/Common/CodeTabs/index.tsx index ed15d32b5531c..468293174daaf 100644 --- a/packages/ui-components/src/Common/CodeTabs/index.tsx +++ b/packages/ui-components/src/Common/CodeTabs/index.tsx @@ -1,8 +1,12 @@ -import { Children, useId } from 'react'; +'use client'; + +import { useId } from 'react'; import type { FC, ReactNode } from 'react'; import { getCodeTabId, slugifyIdSegment } from './getCodeTabId'; +import { getPanels } from './getPanels'; +import { useCodeTabNavigation } from './useCodeTabNavigation'; import styles from './index.module.css'; @@ -19,7 +23,7 @@ type CodeTabsProps = { defaultValue?: string; /** * Optional id prefix for this group. When set, tab fragments are - * `{slug(groupId)}-{slug(tabKey)}`. When omitted, a per-instance prefix is + * `{slug(groupId)}-{slug(tabKey)}-{index}`. When omitted, a per-instance prefix is * used so multiple CodeTabs on one page cannot collide. */ groupId?: string; @@ -37,11 +41,9 @@ const CodeTabs: FC = ({ const reactId = useId(); const instancePrefix = groupId ? slugifyIdSegment(groupId) - : slugifyIdSegment(`codetabs-${reactId}`); + : `codetabs-${reactId.replace(/[^a-zA-Z0-9_-]/g, '')}`; - // Flatten fragments/arrays so each tab maps to one panel (MDX + stories). - // eslint-disable-next-line @eslint-react/no-children-to-array - const panels = Children.toArray(children); + const panels = getPanels(children); const hasExplicitDefault = tabs.some( tab => (tab.value ?? tab.key) === defaultValue ); @@ -51,42 +53,65 @@ const CodeTabs: FC = ({ const items = tabs.map((tab, index) => { const tabKey = tab.value ?? tab.key; - const tabId = getCodeTabId(instancePrefix, tabKey); + const tabId = getCodeTabId(instancePrefix, tabKey, index); const isDefault = tabKey === defaultKey; return { tab, tabId, isDefault, panel: panels[index] }; }); + const { enhanced, activeIndex, linksRef, onClick, onKeyDown } = + useCodeTabNavigation( + items.map(item => item.tabId), + items.findIndex(item => item.isDefault) + ); return ( {items.map(({ tab, tabId, isDefault, panel }) => (
{panel}
diff --git a/packages/ui-components/src/Common/CodeTabs/useCodeTabNavigation.ts b/packages/ui-components/src/Common/CodeTabs/useCodeTabNavigation.ts new file mode 100644 index 0000000000000..932a10ae5f05c --- /dev/null +++ b/packages/ui-components/src/Common/CodeTabs/useCodeTabNavigation.ts @@ -0,0 +1,59 @@ +import { useRef, useSyncExternalStore } from 'react'; + +import type { KeyboardEvent, MouseEvent } from 'react'; + +const subscribe = (onChange: () => void) => { + window.addEventListener('hashchange', onChange); + return () => window.removeEventListener('hashchange', onChange); +}; + +const getSnapshot = () => window.location.hash; +const getServerSnapshot = () => null; + +// CSS owns visibility. This enhancement keeps ARIA and keyboard navigation +// aligned with the URL, including browser Back/Forward and external links. +export function useCodeTabNavigation(ids: Array, defaultIndex: number) { + const hash = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + const linksRef = useRef>([]); + const targetedIndex = ids.findIndex(id => `#${id}` === hash); + const activeIndex = targetedIndex < 0 ? defaultIndex : targetedIndex; + + const activate = (index: number) => { + window.location.hash = ids[index]; + linksRef.current[index]?.focus({ preventScroll: true }); + }; + + const onClick = (event: MouseEvent, index: number) => { + if ( + event.button || + event.metaKey || + event.ctrlKey || + event.altKey || + event.shiftKey + ) { + return; + } + event.preventDefault(); + activate(index); + }; + + const onKeyDown = ( + event: KeyboardEvent, + index: number + ) => { + const nextIndex = { + ArrowRight: (index + 1) % ids.length, + ArrowLeft: (index + ids.length - 1) % ids.length, + Home: 0, + End: ids.length - 1, + ' ': index, + }[event.key]; + + if (nextIndex !== undefined) { + event.preventDefault(); + activate(nextIndex); + } + }; + + return { enhanced: hash !== null, activeIndex, linksRef, onClick, onKeyDown }; +} diff --git a/packages/ui-components/src/MDX/CodeTabs.tsx b/packages/ui-components/src/MDX/CodeTabs.tsx index 90bfa5dbfef67..820765b325a82 100644 --- a/packages/ui-components/src/MDX/CodeTabs.tsx +++ b/packages/ui-components/src/MDX/CodeTabs.tsx @@ -10,7 +10,7 @@ type MDXCodeTabsProps = { displayNames?: string; defaultTab?: string; /** - * Optional fragment prefix. Tab ids become `{slug(groupId)}-{language}-{index}`. + * Optional fragment prefix. Tab ids include the language key and tab index. * When omitted, a unique per-instance prefix is used so multiple CodeTabs * on one page do not collide. */ diff --git a/packages/ui-components/src/MDX/__tests__/CodeTabs.test.jsx b/packages/ui-components/src/MDX/__tests__/CodeTabs.test.jsx index d6a14482bac7c..cdfb9b3887e4c 100644 --- a/packages/ui-components/src/MDX/__tests__/CodeTabs.test.jsx +++ b/packages/ui-components/src/MDX/__tests__/CodeTabs.test.jsx @@ -1,72 +1,61 @@ import { afterEach, describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import MDXCodeTabs from '../CodeTabs'; -const resetHash = () => { - window.history.replaceState(null, '', '/'); -}; - describe('MDXCodeTabs', () => { - afterEach(resetHash); + afterEach(() => window.history.replaceState(null, '', '/')); - it('deep-links to a language tab via groupId', async () => { + it('deep-links to a language and associates its panel', async () => { render( - +
js source
cjs source
); - - const js = screen.getByRole('link', { name: 'JS' }); - const cjs = screen.getByRole('link', { name: 'CJS' }); - - assert.equal(js.id, 'install-js-0'); - assert.equal(cjs.id, 'install-cjs-1'); - assert.equal(js.getAttribute('data-default'), 'true'); - + const cjs = screen.getByRole('tab', { name: 'CJS' }); await userEvent.click(cjs); - - assert.equal(window.location.hash, '#install-cjs-1'); - assert.equal(document.querySelector(':target')?.id, 'install-cjs-1'); + await waitFor(() => + assert.equal(cjs.getAttribute('aria-selected'), 'true') + ); + assert.equal(window.location.hash, cjs.getAttribute('href')); + assert.equal(document.querySelector(':target').textContent, 'cjs source'); }); - it('keeps unique ids when multiple CodeTabs share languages', () => { + it('keeps repeated languages in a group distinct', () => { render( - <> - -
one js
-
one cjs
-
- -
two js
-
two cjs
-
- + +
first
+
second
+
); - - const ids = screen - .getAllByRole('link') - .map(link => link.id) - .filter(Boolean); - - assert.equal(ids.length, 4); - assert.equal(new Set(ids).size, ids.length); + const tabs = screen.getAllByRole('tab'); + assert.notEqual(tabs[0].getAttribute('href'), tabs[1].getAttribute('href')); + assert.equal(tabs[1].textContent, 'JS (2)'); }); - it('uses the defaultTab index when no hash is present', () => { - render( - -
js source
-
cjs source
+ it('uses defaultTab and falls back for an invalid index', () => { + const { rerender } = render( + +
js
+
cjs
+
+ ); + assert.equal( + screen.getByRole('tab', { name: 'CJS' }).getAttribute('aria-selected'), + 'true' + ); + rerender( + +
js
+
cjs
); - assert.equal( - screen.getByRole('link', { name: 'CJS' }).getAttribute('data-default'), + screen.getByRole('tab', { name: 'JS' }).getAttribute('aria-selected'), 'true' ); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d32f7b7ce65ba..56f7d650f6d48 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -755,6 +755,9 @@ importers: postcss-loader: specifier: 8.2.1 version: 8.2.1(postcss@8.5.25)(typescript@5.9.3)(webpack@5.109.2(@swc/core@1.15.40)(clean-css@5.3.3)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.32.0)(postcss@8.5.25)(uglify-js@3.19.3)) + react-dom: + specifier: ^19.2.8 + version: 19.2.8(react@19.2.8) storybook: specifier: ~10.5.4 version: 10.5.4(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)