diff --git a/.changeset/codetabs-anchor-deeplinks.md b/.changeset/codetabs-anchor-deeplinks.md new file mode 100644 index 0000000000000..9e34a2a707788 --- /dev/null +++ b/.changeset/codetabs-anchor-deeplinks.md @@ -0,0 +1,9 @@ +--- +'@node-core/ui-components': major +--- + +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 new file mode 100644 index 0000000000000..000df8f6f4291 --- /dev/null +++ b/packages/ui-components/src/Common/CodeTabs/__tests__/getCodeTabId.test.mjs @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { getCodeTabId, slugifyIdSegment } from '../getCodeTabId'; + +describe('getCodeTabId', () => { + 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++', 0), 'install-steps-c-0'); + }); + + it('falls back to `tab` for empty input', () => { + assert.equal(slugifyIdSegment(' '), 'tab'); + 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 new file mode 100644 index 0000000000000..06e780a0334dc --- /dev/null +++ b/packages/ui-components/src/Common/CodeTabs/__tests__/index.test.jsx @@ -0,0 +1,213 @@ +import { afterEach, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +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'; + +const tabs = [ + { key: 'mjs', label: 'MJS' }, + { key: 'cjs', label: 'CJS' }, +]; +const Sut = ({ + groupId = 'hello-world', + defaultValue = 'mjs', + addons, +} = {}) => ( + +
mjs panel
+
cjs panel
+
+); + +describe('CodeTabs', () => { + afterEach(() => { + window.history.replaceState(null, '', '/'); + }); + + 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('tab', { name: 'MJS' }).getAttribute('aria-selected'), + 'true' + ); + }); + + it('unwraps nested fragments and arrays into separate panels', () => { + render( + + <> + {[
mjs panel
]} + <> +
cjs panel
+ + +
+ ); + assert.deepEqual( + screen.getAllByRole('tabpanel').map(panel => panel.textContent), + ['mjs panel', 'cjs panel'] + ); + }); + + 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('selects an initial deep link before any click', () => { + window.history.replaceState(null, '', '/#hello-world-cjs-1'); + render(); + assert.equal( + screen.getByRole('tab', { name: 'CJS' }).getAttribute('aria-selected'), + 'true' + ); + assert.equal( + document.querySelector(':target'), + screen.getByRole('tabpanel', { name: '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('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('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' + ); + }); + }); + + 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); + }); + + 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('keeps addons outside the tablist', () => { + render(Documentation} />); + assert.equal( + screen + .getByRole('tablist') + .contains(screen.getByRole('link', { name: 'Documentation' })), + false + ); + }); + + 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 new file mode 100644 index 0000000000000..6b6bf2b2fc2e6 --- /dev/null +++ b/packages/ui-components/src/Common/CodeTabs/getCodeTabId.ts @@ -0,0 +1,32 @@ +/** + * Builds stable, URL-safe HTML ids for CodeTabs triggers. + * + * Scheme: + * 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 + * 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, + 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 0c15b4775d1f0..801dfbbfb28b2 100644 --- a/packages/ui-components/src/Common/CodeTabs/index.module.css +++ b/packages/ui-components/src/Common/CodeTabs/index.module.css @@ -1,17 +1,35 @@ @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 panel, not JavaScript. + * Default (no matching hash in this group): [data-default]. + */ + > .panel { @apply hidden; + + scroll-margin-top: calc(var(--header-height) + var(--spacing, 0.25rem) * 6); + + > :first-child { + @apply rounded-t-none; + } } - > [role='tabpanel'] > :first-child { - @apply rounded-t-none; + &:not(:has(> .panel:target)) > .panel[data-default], + > .panel:target { + @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 @@ -23,19 +41,86 @@ dark:border-neutral-900 dark:bg-neutral-950; + .triggers { + @apply flex + gap-2; + } + .trigger { @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; + } + } + + /* 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 + 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 { diff --git a/packages/ui-components/src/Common/CodeTabs/index.stories.tsx b/packages/ui-components/src/Common/CodeTabs/index.stories.tsx index 844e3f8e73582..5b5bdf33b1e9d 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,29 @@ export const WithExtension: Story = { }, }; +export const WithGroupId: Story = { + args: { + groupId: 'hello-world', + }, +}; + +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: { - 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..468293174daaf 100644 --- a/packages/ui-components/src/Common/CodeTabs/index.tsx +++ b/packages/ui-components/src/Common/CodeTabs/index.tsx @@ -1,16 +1,123 @@ -import Tabs from '#ui/Common/Tabs'; +'use client'; -import type { ComponentProps, FC } from 'react'; +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'; -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)}-{index}`. 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) + : `codetabs-${reactId.replace(/[^a-zA-Z0-9_-]/g, '')}`; + + const panels = getPanels(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, 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) + ); -const CodeTabs: FC = ({ ...props }) => ( - -); + return ( + + ); +}; export default CodeTabs; 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 a4092ae6ece73..820765b325a82 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 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. + */ + 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..cdfb9b3887e4c --- /dev/null +++ b/packages/ui-components/src/MDX/__tests__/CodeTabs.test.jsx @@ -0,0 +1,62 @@ +import { afterEach, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import MDXCodeTabs from '../CodeTabs'; + +describe('MDXCodeTabs', () => { + afterEach(() => window.history.replaceState(null, '', '/')); + + it('deep-links to a language and associates its panel', async () => { + render( + +
js source
+
cjs source
+
+ ); + 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, cjs.getAttribute('href')); + assert.equal(document.querySelector(':target').textContent, 'cjs source'); + }); + + it('keeps repeated languages in a group distinct', () => { + render( + +
first
+
second
+
+ ); + const tabs = screen.getAllByRole('tab'); + assert.notEqual(tabs[0].getAttribute('href'), tabs[1].getAttribute('href')); + assert.equal(tabs[1].textContent, 'JS (2)'); + }); + + 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('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)