Skip to content

Commit 6e3644b

Browse files
moshams272ovflowd
andauthored
feat(ui): implement deep linking in CodeTabs via URL hash (#9159)
* feat(ui): implement deep linking in CodeTabs via URL hash * feat(site,ui): add HashContext and HashProvider for global URL state * refactor(ui): isolate window hash logic from CodeTabs * chore: improve code documentation for useHash hook * feat(ui): use native DOM id for CodeTabs scrolling * fix(site): dispatch global hashchange event on hash update * refactor(ui-components): revert CodeTabs and use derived state in CodeTabsWithHash * feat(rehype-shiki): route to CodeTabsWithHash if groupId exists --------- Co-authored-by: Claudio Wunder <cwunder@gnome.org>
1 parent c059a31 commit 6e3644b

10 files changed

Lines changed: 161 additions & 5 deletions

File tree

‎apps/site/app/[locale]/layout.tsx‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { getLocale } from 'next-intl/server';
66

77
import BaseLayout from '#site/layouts/Base';
88
import { IBM_PLEX_MONO, OPEN_SANS } from '#site/next.fonts';
9+
import { HashProvider } from '#site/providers/hashProvider';
910
import { ThemeProvider } from '#site/providers/themeProvider';
1011

1112
import type { FC, PropsWithChildren } from 'react';
@@ -29,9 +30,11 @@ const RootLayout: FC<PropsWithChildren> = async ({ children }) => {
2930
>
3031
<body suppressHydrationWarning>
3132
<NextIntlClientProvider>
32-
<ThemeProvider>
33-
<BaseLayout>{children}</BaseLayout>
34-
</ThemeProvider>
33+
<HashProvider>
34+
<ThemeProvider>
35+
<BaseLayout>{children}</BaseLayout>
36+
</ThemeProvider>
37+
</HashProvider>
3538
</NextIntlClientProvider>
3639

3740
<a

‎apps/site/hooks/useHash.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
'use client';
2+
3+
import { useSyncExternalStore, useCallback } from 'react';
4+
5+
const subscribe = (callback: () => void) => {
6+
window.addEventListener('hashchange', callback);
7+
return () => window.removeEventListener('hashchange', callback);
8+
};
9+
10+
const getSnapshot = () => window.location.hash.slice(1);
11+
const getServerSnapshot = () => '';
12+
13+
const useHash = () => {
14+
// Works in both client and server environments, provides the initial value for the store during server rendering and hydration, avoiding hydration mismatches
15+
const hash = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
16+
17+
const setHash = useCallback((newHash: string) => {
18+
window.history.replaceState(null, '', `#${newHash}`);
19+
window.dispatchEvent(new Event('hashchange'));
20+
}, []);
21+
22+
return [hash, setHash] as const;
23+
};
24+
25+
export default useHash;

‎apps/site/mdx/components.mjs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import AlertBox from '@node-core/ui-components/Common/AlertBox';
44
import BadgeGroup from '@node-core/ui-components/Common/BadgeGroup';
55
import Blockquote from '@node-core/ui-components/Common/Blockquote';
66
import MDXCodeTabs from '@node-core/ui-components/MDX/CodeTabs';
7+
import CodeTabsWithHash from '@node-core/ui-components/MDX/CodeTabs/CodeTabsWithHash';
78
import {
89
MDXTooltip,
910
MDXTooltipContent,
@@ -58,6 +59,7 @@ export default {
5859
AlertBox,
5960
// Renders MDX CodeTabs
6061
CodeTabs: MDXCodeTabs,
62+
CodeTabsWithHash,
6163
// Renders Tooltips
6264
MDXTooltip,
6365
MDXTooltipContent,
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
'use client';
2+
3+
import { HashContext } from '@node-core/ui-components/contexts/HashContext';
4+
5+
import useHash from '#site/hooks/useHash';
6+
7+
import type { FC, PropsWithChildren } from 'react';
8+
9+
export const HashProvider: FC<PropsWithChildren> = ({ children }) => {
10+
const [hash, setHash] = useHash();
11+
12+
return <HashContext value={{ hash, setHash }}>{children}</HashContext>;
13+
};

‎packages/rehype-shiki/src/plugin.mjs‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export default async function rehypeShikiji(options) {
6767
const codeTabsChildren = [];
6868

6969
let defaultTab = '0';
70+
let groupId;
7071
let currentIndex = index;
7172

7273
while (isCodeBlock(parent?.children[currentIndex])) {
@@ -84,6 +85,11 @@ export default async function rehypeShikiji(options) {
8485
// Map the display names of each variant for the CodeTab
8586
displayNames.push(meta.displayName?.replaceAll('|', '') ?? '');
8687

88+
// Extract groupId if it exists
89+
if (meta.groupId) {
90+
groupId = meta.groupId;
91+
}
92+
8793
codeTabsChildren.push(parent?.children[currentIndex]);
8894

8995
// If `active="true"` is provided in a CodeBox
@@ -102,12 +108,13 @@ export default async function rehypeShikiji(options) {
102108
if (codeTabsChildren.length >= 2) {
103109
const codeTabElement = {
104110
type: 'element',
105-
tagName: 'CodeTabs',
111+
tagName: groupId ? 'CodeTabsWithHash' : 'CodeTabs',
106112
children: codeTabsChildren,
107113
properties: {
108114
languages: languages.join('|'),
109115
displayNames: displayNames.join('|'),
110116
defaultTab,
117+
groupId,
111118
},
112119
};
113120

‎packages/ui-components/src/Common/CodeTabs/index.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import styles from './index.module.css';
66

77
type CodeTabsProps = Pick<
88
ComponentProps<typeof Tabs>,
9-
'tabs' | 'defaultValue' | 'children' | 'addons'
9+
'tabs' | 'defaultValue' | 'value' | 'onValueChange' | 'children' | 'addons'
1010
>;
1111

1212
const CodeTabs: FC<CodeTabsProps> = ({ ...props }) => (

‎packages/ui-components/src/Common/Tabs/index.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import styles from './index.module.css';
77

88
type Tab = {
99
key: string;
10+
id?: string;
1011
label: string;
1112
secondaryLabel?: string;
1213
value?: string;
@@ -34,6 +35,7 @@ const Tabs: FC<PropsWithChildren<TabsProps>> = ({
3435
{tabs.map(tab => (
3536
<TabsPrimitive.Trigger
3637
key={tab.key}
38+
id={tab.id}
3739
value={tab.value ?? tab.key}
3840
className={classNames(styles.tabsTrigger, triggerClassName)}
3941
>
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
'use client';
2+
3+
import * as TabsPrimitive from '@radix-ui/react-tabs';
4+
import { useCallback, useMemo } from 'react';
5+
6+
import CodeTabs from '#ui/Common/CodeTabs';
7+
import { useHashContext } from '#ui/contexts/HashContext';
8+
9+
import type { ComponentProps, FC } from 'react';
10+
11+
import MDXCodeTabs from './index';
12+
13+
type CodeTabsWithHashProps = ComponentProps<typeof MDXCodeTabs> & {
14+
groupId: string;
15+
};
16+
17+
const NAME_OVERRIDES: Record<string, string | undefined> = {
18+
mjs: 'ESM',
19+
};
20+
21+
const CodeTabsWithHash: FC<CodeTabsWithHashProps> = ({
22+
languages: rawLanguages,
23+
displayNames: rawDisplayNames,
24+
children: codes,
25+
defaultTab = '0',
26+
groupId,
27+
...props
28+
}) => {
29+
const { hash, setHash } = useHashContext();
30+
31+
const { tabs, languages } = useMemo(() => {
32+
const occurrences: Record<string, number> = {};
33+
const languages = rawLanguages.split('|');
34+
const displayNames = rawDisplayNames?.split('|') ?? [];
35+
36+
const tabs = languages.map((language, index) => {
37+
const base =
38+
displayNames[index]?.trim() ||
39+
NAME_OVERRIDES[language] ||
40+
language.toUpperCase();
41+
42+
const count = occurrences[base] ?? 0;
43+
occurrences[base] = count + 1;
44+
45+
const label = count > 0 ? `${base} (${count + 1})` : base;
46+
47+
return {
48+
key: `${language}-${index}`,
49+
label,
50+
id: `${groupId}-${language}-${index}`.replace(/[^a-zA-Z0-9-_]/g, '-'),
51+
};
52+
});
53+
54+
return { tabs, languages };
55+
}, [rawLanguages, rawDisplayNames, groupId]);
56+
57+
const activeTab =
58+
tabs.find(t => t.id === hash)?.key ?? tabs[Number(defaultTab)].key;
59+
60+
const handleValueChange = useCallback(
61+
(value: string) => {
62+
const matched = tabs.find(t => t.key === value);
63+
if (matched?.id) {
64+
setHash(matched.id);
65+
}
66+
},
67+
[tabs, setHash]
68+
);
69+
70+
return (
71+
<CodeTabs
72+
tabs={tabs}
73+
value={activeTab}
74+
onValueChange={handleValueChange}
75+
{...props}
76+
>
77+
{languages.map((_, index) => (
78+
<TabsPrimitive.Content
79+
forceMount
80+
key={tabs[index].key}
81+
value={tabs[index].key}
82+
>
83+
{codes[index]}
84+
</TabsPrimitive.Content>
85+
))}
86+
</CodeTabs>
87+
);
88+
};
89+
export default CodeTabsWithHash;
File renamed without changes.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
'use client';
2+
3+
import { createContext, use } from 'react';
4+
5+
type HashContextType = {
6+
hash: string;
7+
setHash: (newHash: string) => void;
8+
};
9+
10+
export const HashContext = createContext<HashContextType>({
11+
hash: '',
12+
setHash: () => {},
13+
});
14+
15+
export const useHashContext = () => use(HashContext);

0 commit comments

Comments
 (0)