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' + ); + }); +});