Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add CodeBox component and code tabs plugin #6038

Merged
merged 12 commits into from
Jan 4, 2024
2 changes: 1 addition & 1 deletion .storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const rootClasses = classNames(
// note: this is hard-coded sadly as next/font can only be loaded within next.js context
'__variable_open-sans-normal',
// note: this is hard-coded sadly as next/font can only be loaded within next.js context
'__variable_ibm-plex-mono-normal-600'
'__variable_ibm-plex-mono-normal'
);

const config: StorybookConfig = {
Expand Down
80 changes: 80 additions & 0 deletions components/Common/CodeBox/index.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
.root {
@apply w-full
rounded
border
border-neutral-900
bg-neutral-950;

.content {
@apply m-0
p-4;

& > code {
@apply grid
overflow-x-auto
bg-transparent
p-0
font-ibm-plex-mono
text-sm
font-regular
text-neutral-400
[counter-reset:line];

& > [class='line'] {
@apply w-max;

& > span {
@apply whitespace-break-spaces
break-words;
}

&:not(:empty:last-child)::before {
@apply mr-4
inline-block
w-4.5
text-right
text-neutral-600
[content:counter(line)]
[counter-increment:line];
}
}
}
}

& > .footer {
@apply flex
min-h-14
items-center
justify-between
border-t
border-t-neutral-900
px-4
py-3
text-sm
font-medium;

& > .language {
@apply text-neutral-400;
}

& > .action {
@apply flex
cursor-pointer
items-center
gap-2
px-3
py-1.5;
}
}
}

.notification {
@apply flex
items-center
gap-3;
}

.icon {
@apply h-4
w-4;
}
38 changes: 38 additions & 0 deletions components/Common/CodeBox/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { Meta as MetaObj, StoryObj } from '@storybook/react';

import CodeBox from '@/components/Common/CodeBox';

type Story = StoryObj<typeof CodeBox>;
type Meta = MetaObj<typeof CodeBox>;

const content = `const http = require('http');

const hostname = '127.0.0.1';
const port = 3000;

const server = http.createServer((req, res) => {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.end('Hello World');
});

server.listen(port, hostname, () => {
console.log(\`Server running at http://\${hostname}:\${port}/\`);
});`;

export const Default: Story = {
args: {
language: 'JavaScript (CJS)',
children: <code>{content}</code>,
},
};

export const WithCopyButton: Story = {
args: {
language: 'JavaScript (CJS)',
showCopyButton: true,
children: <code>{content}</code>,
},
};

export default { component: CodeBox } as Meta;
109 changes: 109 additions & 0 deletions components/Common/CodeBox/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
'use client';

import {
DocumentDuplicateIcon,
CodeBracketIcon,
} from '@heroicons/react/24/outline';
import { useTranslations } from 'next-intl';
import type { FC, PropsWithChildren, ReactNode } from 'react';
import { isValidElement, useRef } from 'react';

import Button from '@/components/Common/Button';
import { useCopyToClipboard, useNotification } from '@/hooks';

import styles from './index.module.css';

// Transforms a code element with plain text content into a more structured
// format for rendering with line numbers
const transformCode = (code: ReactNode): ReactNode => {
if (!isValidElement(code)) {
devjvao marked this conversation as resolved.
Show resolved Hide resolved
// Early return when the `CodeBox` child is not a valid element since the
// type is a ReactNode, and can assume any value
return code;
}

const content = code.props?.children;

if (code.type !== 'code' || typeof content !== 'string') {
devjvao marked this conversation as resolved.
Show resolved Hide resolved
// There is no need to transform an element that is not a code element or
// a content that is not a string
return code;
}

const lines = content.split('\n');

return (
<code>
{lines.flatMap((line, lineIndex) => {
const columns = line.split(' ');

return [
<span key={lineIndex} className="line">
{columns.map((column, columnIndex) => (
<>
<span>{column}</span>
{columnIndex < columns.length - 1 && <span> </span>}
</>
))}
</span>,
// Add a break line so the text content is formatted correctly
// when copying to clipboard
'\n',
];
})}
</code>
);
};

type CodeBoxProps = { language: string; showCopyButton?: boolean };

const CodeBox: FC<PropsWithChildren<CodeBoxProps>> = ({
children,
language,
showCopyButton = false,
}) => {
const ref = useRef<HTMLPreElement>(null);

const notify = useNotification();
const [, copyToClipboard] = useCopyToClipboard();
const t = useTranslations();

const onCopy = () => {
if (ref.current?.textContent) {
copyToClipboard(ref.current.textContent);

notify({
duration: 3000,
message: (
<div className={styles.notification}>
<CodeBracketIcon className={styles.icon} />
{t('components.common.codebox.copied')}
</div>
),
});
}
};

return (
<div className={styles.root}>
<pre ref={ref} className={styles.content} tabIndex={0}>
{transformCode(children)}
</pre>

{language && (
<div className={styles.footer}>
<span className={styles.language}>{language}</span>

{showCopyButton && (
<Button type="button" className={styles.action} onClick={onCopy}>
<DocumentDuplicateIcon className={styles.icon} />
{t('components.common.codebox.copy')}
</Button>
)}
</div>
)}
</div>
);
};

export default CodeBox;
51 changes: 51 additions & 0 deletions components/Common/CodeTabs/index.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
.root > [role='tabpanel'] > :first-child {
@apply rounded-t-none;
}

.header {
@apply flex
rounded-t
border-x
border-t
border-neutral-900
bg-neutral-950
px-4
pr-5
pt-3;

& [role='tab'] {
@apply border-b
border-b-transparent
px-1
text-neutral-200;

&[aria-selected='true'] {
@apply border-b-green-400
text-green-400;
}
}
}

.link {
@apply flex
items-center
gap-2
text-center
text-neutral-200;

& > .icon {
@apply h-4
w-4
text-neutral-300;
}

&:is(:link, :visited) {
&:hover {
@apply text-neutral-400;

& > .icon {
@apply text-neutral-600;
}
}
}
}
75 changes: 75 additions & 0 deletions components/Common/CodeTabs/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import * as TabsPrimitive from '@radix-ui/react-tabs';
import type { Meta as MetaObj, StoryObj } from '@storybook/react';
import type { FC } from 'react';

import CodeBox from '@/components/Common/CodeBox';
import CodeTabs from '@/components/Common/CodeTabs';

type Story = StoryObj<typeof CodeTabs>;
type Meta = MetaObj<typeof CodeTabs>;

const mjsContent = `import * as http from 'http';

const hostname = '127.0.0.1';
const port = 3000;

const server = http.createServer((req, res) => {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.end('Hello World');
});

server.listen(port, hostname, () => {
console.log(\`Server running at http://\${hostname}:\${port}/\`);
});`;

const cjsContent = `const http = require('http');

const hostname = '127.0.0.1';
const port = 3000;

const server = http.createServer((req, res) => {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.end('Hello World');
});

server.listen(port, hostname, () => {
console.log(\`Server running at http://\${hostname}:\${port}/\`);
});`;

const TabsContent: FC = () => (
<>
<TabsPrimitive.Content key="mjs" value="mjs">
<CodeBox language="JavaScript (MJS)" showCopyButton>
<code>{mjsContent}</code>
</CodeBox>
</TabsPrimitive.Content>
<TabsPrimitive.Content key="cjs" value="cjs">
<CodeBox language="JavaScript (CJS)" showCopyButton>
<code>{cjsContent}</code>
</CodeBox>
</TabsPrimitive.Content>
</>
);

export const Default: Story = {};

export const WithMoreOptions: Story = {
args: {
linkUrl: 'https://github.com/nodejs/nodejs.org',
linkText: 'More options',
},
};

export default {
component: CodeTabs,
args: {
children: <TabsContent />,
defaultValue: 'mjs',
tabs: [
{ key: 'mjs', label: 'MJS' },
{ key: 'cjs', label: 'CJS' },
],
},
} as Meta;
Loading
Loading