Skip to content

Commit 65f4a27

Browse files
committed
fix(ui): harden codetabs deep links
1 parent de7898f commit 65f4a27

14 files changed

Lines changed: 435 additions & 202 deletions

File tree

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
---
2-
'@node-core/ui-components': minor
2+
'@node-core/ui-components': major
33
---
44

5-
Add HTML/CSS `:target` deep linking to CodeTabs and replace Radix Tabs for that component so fragments work without JavaScript hash listeners.
5+
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.
6+
7+
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.
8+
9+
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.
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { expect, test } from '@playwright/test';
2+
3+
test('code tabs support keyboard selection, deep links, and browser history', async ({
4+
page,
5+
}) => {
6+
await page.goto('/en');
7+
const tabs = page
8+
.getByRole('tablist', { name: 'Code samples' })
9+
.getByRole('tab');
10+
const first = tabs.first();
11+
const second = tabs.nth(1);
12+
const firstId = await first.getAttribute('aria-controls');
13+
const secondId = await second.getAttribute('aria-controls');
14+
15+
await first.focus();
16+
await page.keyboard.press('ArrowRight');
17+
await expect(second).toBeFocused();
18+
await expect(second).toHaveAttribute('aria-selected', 'true');
19+
await expect(page.locator(`[id="${secondId}"]`)).toBeVisible();
20+
await expect(page.locator(`[id="${firstId}"]`)).toBeHidden();
21+
22+
await page.reload();
23+
await expect(second).toHaveAttribute('aria-selected', 'true');
24+
await expect(page.locator(`[id="${secondId}"]`)).toBeVisible();
25+
await first.click();
26+
await expect(page.locator(`[id="${firstId}"]`)).toBeVisible();
27+
await page.goBack();
28+
await expect(second).toHaveAttribute('aria-selected', 'true');
29+
await expect(page.locator(`[id="${secondId}"]`)).toBeVisible();
30+
await page.goForward();
31+
await expect(first).toHaveAttribute('aria-selected', 'true');
32+
await expect(page.locator(`[id="${firstId}"]`)).toBeVisible();
33+
});
34+
35+
test.describe('without JavaScript', () => {
36+
test.use({ javaScriptEnabled: false });
37+
38+
test('native links select visible panels and survive a reload', async ({
39+
page,
40+
}) => {
41+
await page.goto('/en');
42+
const links = page
43+
.getByRole('navigation', { name: 'Code samples' })
44+
.getByRole('link');
45+
const firstId = await links.first().getAttribute('aria-controls');
46+
const second = links.nth(1);
47+
const secondId = await second.getAttribute('aria-controls');
48+
await expect(page.locator(`[id="${firstId}"]`)).toBeVisible();
49+
await expect(page.locator(`[id="${secondId}"]`)).toBeHidden();
50+
await second.click();
51+
await expect(page.locator(`[id="${secondId}"]`)).toBeVisible();
52+
await expect(page.locator(`[id="${firstId}"]`)).toBeHidden();
53+
await page.reload();
54+
await expect(page.locator(`[id="${secondId}"]`)).toBeVisible();
55+
await expect(page.locator(`[id="${firstId}"]`)).toBeHidden();
56+
});
57+
});

packages/ui-components/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@
8585
"postcss-calc": "~10.1.1",
8686
"postcss-cli": "^11.0.1",
8787
"postcss-loader": "8.2.1",
88+
"react-dom": "^19.2.8",
8889
"storybook": "~10.5.4",
8990
"style-loader": "4.0.0",
9091
"stylelint": "17.14.1",

packages/ui-components/src/Common/CodeTabs/__tests__/getCodeTabId.test.mjs

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,27 @@ import { describe, it } from 'node:test';
44
import { getCodeTabId, slugifyIdSegment } from '../getCodeTabId';
55

66
describe('getCodeTabId', () => {
7-
it('builds `{groupId}-{tabKey}` fragments', () => {
8-
assert.equal(getCodeTabId('install', 'js-0'), 'install-js-0');
9-
assert.equal(getCodeTabId('install', 'cjs-1'), 'install-cjs-1');
7+
it('includes the tab index in fragments', () => {
8+
assert.equal(getCodeTabId('install', 'js', 0), 'install-js-0');
9+
assert.equal(getCodeTabId('install', 'cjs', 1), 'install-cjs-1');
1010
});
1111

1212
it('slugifies labels and prefixes numeric segments', () => {
1313
assert.equal(slugifyIdSegment('Hello World'), 'hello-world');
1414
assert.equal(slugifyIdSegment('123'), 'id-123');
1515
assert.equal(slugifyIdSegment('codetabs-:r1:'), 'codetabs-r1');
16-
assert.equal(getCodeTabId('Install Steps', 'C++'), 'install-steps-c');
16+
assert.equal(getCodeTabId('install-steps', 'C++', 0), 'install-steps-c-0');
1717
});
1818

1919
it('falls back to `tab` for empty input', () => {
2020
assert.equal(slugifyIdSegment(' '), 'tab');
21-
assert.equal(getCodeTabId('', 'js'), 'tab-js');
21+
assert.equal(getCodeTabId('install', '', 0), 'install-tab-0');
22+
});
23+
24+
it('preserves case in the prepared React instance prefix', () => {
25+
assert.notEqual(
26+
getCodeTabId('codetabs-R1', 'js', 0),
27+
getCodeTabId('codetabs-r1', 'js', 0)
28+
);
2229
});
2330
});

0 commit comments

Comments
 (0)