Skip to content

Commit aa0d9d6

Browse files
devjvaoovflowdcanerakdas
authored
feat: add CodeBox component and code tabs plugin (#6038)
Co-authored-by: Claudio Wunder <[email protected]> Co-authored-by: Caner Akdas <[email protected]>
1 parent d6cf107 commit aa0d9d6

39 files changed

+866
-93
lines changed

.storybook/main.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ const rootClasses = classNames(
55
// note: this is hard-coded sadly as next/font can only be loaded within next.js context
66
'__variable_open-sans-normal',
77
// note: this is hard-coded sadly as next/font can only be loaded within next.js context
8-
'__variable_ibm-plex-mono-normal-600'
8+
'__variable_ibm-plex-mono-normal'
99
);
1010

1111
const config: StorybookConfig = {
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
.root {
2+
@apply w-full
3+
rounded
4+
border
5+
border-neutral-900
6+
bg-neutral-950;
7+
8+
.content {
9+
@apply m-0
10+
p-4;
11+
12+
& > code {
13+
@apply grid
14+
bg-transparent
15+
p-0
16+
font-ibm-plex-mono
17+
text-sm
18+
font-regular
19+
leading-snug
20+
text-neutral-400
21+
[counter-reset:line];
22+
23+
& > [class='line'] {
24+
@apply relative
25+
min-w-0
26+
pl-8;
27+
28+
& > span {
29+
@apply whitespace-break-spaces
30+
break-words;
31+
}
32+
33+
&:not(:empty:last-child)::before {
34+
@apply inline-block
35+
content-[''];
36+
}
37+
38+
&:not(:empty:last-child)::after {
39+
@apply absolute
40+
left-0
41+
top-0
42+
mr-4
43+
w-4.5
44+
text-right
45+
text-neutral-600
46+
[content:counter(line)]
47+
[counter-increment:line];
48+
}
49+
}
50+
}
51+
}
52+
53+
& > .footer {
54+
@apply flex
55+
items-center
56+
justify-between
57+
border-t
58+
border-t-neutral-900
59+
px-4
60+
py-3
61+
text-sm
62+
font-medium;
63+
64+
& > .language {
65+
@apply text-neutral-400;
66+
}
67+
68+
& > .action {
69+
@apply flex
70+
cursor-pointer
71+
items-center
72+
gap-2
73+
px-3
74+
py-1.5;
75+
}
76+
}
77+
}
78+
79+
.notification {
80+
@apply flex
81+
items-center
82+
gap-3;
83+
}
84+
85+
.icon {
86+
@apply h-4
87+
w-4;
88+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import type { Meta as MetaObj, StoryObj } from '@storybook/react';
2+
3+
import CodeBox from '@/components/Common/CodeBox';
4+
5+
type Story = StoryObj<typeof CodeBox>;
6+
type Meta = MetaObj<typeof CodeBox>;
7+
8+
const content = `const http = require('http');
9+
10+
const hostname = '127.0.0.1';
11+
const port = 3000;
12+
13+
const server = http.createServer((req, res) => {
14+
res.statusCode = 200;
15+
res.setHeader('Content-Type', 'text/plain');
16+
res.end('Hello World');
17+
});
18+
19+
server.listen(port, hostname, () => {
20+
console.log(\`Server running at http://\${hostname}:\${port}/\`);
21+
});`;
22+
23+
export const Default: Story = {
24+
args: {
25+
language: 'JavaScript (CJS)',
26+
children: <code>{content}</code>,
27+
},
28+
};
29+
30+
export const WithCopyButton: Story = {
31+
args: {
32+
language: 'JavaScript (CJS)',
33+
showCopyButton: true,
34+
children: <code>{content}</code>,
35+
},
36+
};
37+
38+
export default { component: CodeBox } as Meta;
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
'use client';
2+
3+
import {
4+
DocumentDuplicateIcon,
5+
CodeBracketIcon,
6+
} from '@heroicons/react/24/outline';
7+
import { useTranslations } from 'next-intl';
8+
import type { FC, PropsWithChildren, ReactNode } from 'react';
9+
import { Fragment, isValidElement, useRef } from 'react';
10+
11+
import Button from '@/components/Common/Button';
12+
import { useCopyToClipboard, useNotification } from '@/hooks';
13+
import { ENABLE_WEBSITE_REDESIGN } from '@/next.constants.mjs';
14+
15+
import styles from './index.module.css';
16+
17+
// Transforms a code element with plain text content into a more structured
18+
// format for rendering with line numbers
19+
const transformCode = (code: ReactNode): ReactNode => {
20+
if (!isValidElement(code)) {
21+
// Early return when the `CodeBox` child is not a valid element since the
22+
// type is a ReactNode, and can assume any value
23+
return code;
24+
}
25+
26+
const content = code.props?.children;
27+
28+
if (code.type !== 'code' || typeof content !== 'string') {
29+
// There is no need to transform an element that is not a code element or
30+
// a content that is not a string
31+
return code;
32+
}
33+
34+
const lines = content.split('\n');
35+
36+
return (
37+
<code>
38+
{lines.flatMap((line, lineIndex) => {
39+
const columns = line.split(' ');
40+
41+
return [
42+
<span key={lineIndex} className="line">
43+
{columns.map((column, columnIndex) => (
44+
<Fragment key={columnIndex}>
45+
<span>{column}</span>
46+
{columnIndex < columns.length - 1 && <span> </span>}
47+
</Fragment>
48+
))}
49+
</span>,
50+
// Add a break line so the text content is formatted correctly
51+
// when copying to clipboard
52+
'\n',
53+
];
54+
})}
55+
</code>
56+
);
57+
};
58+
59+
type CodeBoxProps = { language: string; showCopyButton?: boolean };
60+
61+
const CodeBox: FC<PropsWithChildren<CodeBoxProps>> = ({
62+
children,
63+
language,
64+
// For now we only want to render the Copy Button by default
65+
// if the Website Redesign is Enabled
66+
showCopyButton = ENABLE_WEBSITE_REDESIGN,
67+
}) => {
68+
const ref = useRef<HTMLPreElement>(null);
69+
70+
const notify = useNotification();
71+
const [, copyToClipboard] = useCopyToClipboard();
72+
const t = useTranslations();
73+
74+
const onCopy = async () => {
75+
if (ref.current?.textContent) {
76+
copyToClipboard(ref.current.textContent);
77+
78+
notify({
79+
duration: 3000,
80+
message: (
81+
<div className={styles.notification}>
82+
<CodeBracketIcon className={styles.icon} />
83+
{t('components.common.codebox.copied')}
84+
</div>
85+
),
86+
});
87+
}
88+
};
89+
90+
return (
91+
<div className={styles.root}>
92+
<pre ref={ref} className={styles.content} tabIndex={0}>
93+
{transformCode(children)}
94+
</pre>
95+
96+
{language && (
97+
<div className={styles.footer}>
98+
<span className={styles.language}>{language}</span>
99+
100+
{showCopyButton && (
101+
<Button type="button" className={styles.action} onClick={onCopy}>
102+
<DocumentDuplicateIcon className={styles.icon} />
103+
{t('components.common.codebox.copy')}
104+
</Button>
105+
)}
106+
</div>
107+
)}
108+
</div>
109+
);
110+
};
111+
112+
export default CodeBox;
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
.root > [role='tabpanel'] > :first-child {
2+
@apply rounded-t-none;
3+
}
4+
5+
.header {
6+
@apply flex
7+
rounded-t
8+
border-x
9+
border-t
10+
border-neutral-900
11+
bg-neutral-950
12+
px-4
13+
pr-5
14+
pt-3;
15+
16+
& [role='tab'] {
17+
@apply border-b
18+
border-b-transparent
19+
px-1
20+
text-neutral-200;
21+
22+
&[aria-selected='true'] {
23+
@apply border-b-green-400
24+
text-green-400;
25+
}
26+
}
27+
}
28+
29+
.link {
30+
@apply flex
31+
items-center
32+
gap-2
33+
text-center
34+
text-neutral-200;
35+
36+
& > .icon {
37+
@apply h-4
38+
w-4
39+
text-neutral-300;
40+
}
41+
42+
&:is(:link, :visited) {
43+
&:hover {
44+
@apply text-neutral-400;
45+
46+
& > .icon {
47+
@apply text-neutral-600;
48+
}
49+
}
50+
}
51+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import * as TabsPrimitive from '@radix-ui/react-tabs';
2+
import type { Meta as MetaObj, StoryObj } from '@storybook/react';
3+
import type { FC } from 'react';
4+
5+
import CodeBox from '@/components/Common/CodeBox';
6+
import CodeTabs from '@/components/Common/CodeTabs';
7+
8+
type Story = StoryObj<typeof CodeTabs>;
9+
type Meta = MetaObj<typeof CodeTabs>;
10+
11+
const mjsContent = `import * as http from 'http';
12+
13+
const hostname = '127.0.0.1';
14+
const port = 3000;
15+
16+
const server = http.createServer((req, res) => {
17+
res.statusCode = 200;
18+
res.setHeader('Content-Type', 'text/plain');
19+
res.end('Hello World');
20+
});
21+
22+
server.listen(port, hostname, () => {
23+
console.log(\`Server running at http://\${hostname}:\${port}/\`);
24+
});`;
25+
26+
const cjsContent = `const http = require('http');
27+
28+
const hostname = '127.0.0.1';
29+
const port = 3000;
30+
31+
const server = http.createServer((req, res) => {
32+
res.statusCode = 200;
33+
res.setHeader('Content-Type', 'text/plain');
34+
res.end('Hello World');
35+
});
36+
37+
server.listen(port, hostname, () => {
38+
console.log(\`Server running at http://\${hostname}:\${port}/\`);
39+
});`;
40+
41+
const TabsContent: FC = () => (
42+
<>
43+
<TabsPrimitive.Content key="mjs" value="mjs">
44+
<CodeBox language="JavaScript (MJS)" showCopyButton>
45+
<code>{mjsContent}</code>
46+
</CodeBox>
47+
</TabsPrimitive.Content>
48+
<TabsPrimitive.Content key="cjs" value="cjs">
49+
<CodeBox language="JavaScript (CJS)" showCopyButton>
50+
<code>{cjsContent}</code>
51+
</CodeBox>
52+
</TabsPrimitive.Content>
53+
</>
54+
);
55+
56+
export const Default: Story = {};
57+
58+
export const WithMoreOptions: Story = {
59+
args: {
60+
linkUrl: 'https://github.com/nodejs/nodejs.org',
61+
linkText: 'More options',
62+
},
63+
};
64+
65+
export default {
66+
component: CodeTabs,
67+
args: {
68+
children: <TabsContent />,
69+
defaultValue: 'mjs',
70+
tabs: [
71+
{ key: 'mjs', label: 'MJS' },
72+
{ key: 'cjs', label: 'CJS' },
73+
],
74+
},
75+
} as Meta;

0 commit comments

Comments
 (0)