Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/codetabs-anchor-deeplinks.md
Original file line number Diff line number Diff line change
@@ -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.
57 changes: 57 additions & 0 deletions apps/site/tests/e2e/code-tabs.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
1 change: 1 addition & 0 deletions packages/ui-components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
);
});
});
213 changes: 213 additions & 0 deletions packages/ui-components/src/Common/CodeTabs/__tests__/index.test.jsx
Original file line number Diff line number Diff line change
@@ -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,
} = {}) => (
<CodeTabs
tabs={tabs}
defaultValue={defaultValue}
groupId={groupId}
addons={addons}
>
<div>mjs panel</div>
<div>cjs panel</div>
</CodeTabs>
);

describe('CodeTabs', () => {
afterEach(() => {
window.history.replaceState(null, '', '/');
});

it('connects each tab to its labelled panel', () => {
render(<Sut />);
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(
<CodeTabs tabs={tabs}>
<>
{[<div key="mjs">mjs panel</div>]}
<>
<div>cjs panel</div>
</>
</>
</CodeTabs>
);
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(<Sut defaultValue="cjs" />);
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(<Sut />);
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(<Sut />);
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(<Sut />);
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(
<>
<Sut />
<Sut groupId="other" />
</>
);
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(
<>
<Sut groupId={null} />
<Sut groupId={null} />
</>
);
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(
<CodeTabs
groupId="languages"
tabs={[
{ key: 'c++', label: 'C++' },
{ key: 'c#', label: 'C#' },
{ key: 'C', label: 'C' },
]}
>
{[
<pre key="cpp">cpp</pre>,
<pre key="cs">cs</pre>,
<pre key="c">c</pre>,
]}
</CodeTabs>
);
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(<Sut addons={<a href="/docs">Documentation</a>} />);
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(<Sut />);
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"/);
});
});
32 changes: 32 additions & 0 deletions packages/ui-components/src/Common/CodeTabs/getCodeTabId.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}
22 changes: 22 additions & 0 deletions packages/ui-components/src/Common/CodeTabs/getPanels.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Children, Fragment, isValidElement } from 'react';

import type { ReactNode } from 'react';

export function getPanels(children: ReactNode): Array<ReactNode> {
const panels: Array<ReactNode> = [];

// 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;
}
Loading