Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/codetabs-anchor-deeplinks.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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');
});
});
157 changes: 157 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,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 } = {}) => (
<CodeTabs
tabs={tabs}
defaultValue={defaultValue}
groupId={groupId}
addons={addons}
>
<div>mjs panel</div>
<div>cjs panel</div>
</CodeTabs>
);

const resetHash = () => {
window.history.replaceState(null, '', '/');
};

describe('CodeTabs', () => {
afterEach(resetHash);

it('renders panel content for each tab', () => {
render(<Sut groupId="hello-world" />);

assert.ok(screen.getByText('mjs panel'));
assert.ok(screen.getByText('cjs panel'));
});

it('assigns fragment ids and hrefs using groupId', () => {
render(<Sut groupId="hello-world" />);

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(<Sut groupId="hello-world" />);

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(<Sut groupId="hello-world" defaultValue="cjs" />);

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(<Sut groupId="hello-world" />);

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(<Sut groupId="hello-world" />);

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(<Sut groupId="hello-world" />);

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(<Sut groupId="hello-world" />);

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(
<>
<Sut />
<Sut />
</>
);

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(<Sut groupId="hello-world" addons={<a href="/docs">addon</a>} />);

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/);
});
});
28 changes: 28 additions & 0 deletions packages/ui-components/src/Common/CodeTabs/getCodeTabId.ts
Original file line number Diff line number Diff line change
@@ -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)}`;
}
114 changes: 103 additions & 11 deletions packages/ui-components/src/Common/CodeTabs/index.module.css
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
Loading