Skip to content

Commit

Permalink
feat: add CodeBox component and code tabs plugin (#6038)
Browse files Browse the repository at this point in the history
Co-authored-by: Claudio Wunder <[email protected]>
Co-authored-by: Caner Akdas <[email protected]>
  • Loading branch information
3 people authored Jan 4, 2024
1 parent d6cf107 commit aa0d9d6
Show file tree
Hide file tree
Showing 39 changed files with 866 additions and 93 deletions.
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
88 changes: 88 additions & 0 deletions components/Common/CodeBox/index.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
.root {
@apply w-full
rounded
border
border-neutral-900
bg-neutral-950;

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

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

& > [class='line'] {
@apply relative
min-w-0
pl-8;

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

&:not(:empty:last-child)::before {
@apply inline-block
content-[''];
}

&:not(:empty:last-child)::after {
@apply absolute
left-0
top-0
mr-4
w-4.5
text-right
text-neutral-600
[content:counter(line)]
[counter-increment:line];
}
}
}
}

& > .footer {
@apply flex
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;
112 changes: 112 additions & 0 deletions components/Common/CodeBox/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
'use client';

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

import Button from '@/components/Common/Button';
import { useCopyToClipboard, useNotification } from '@/hooks';
import { ENABLE_WEBSITE_REDESIGN } from '@/next.constants.mjs';

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)) {
// 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') {
// 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) => (
<Fragment key={columnIndex}>
<span>{column}</span>
{columnIndex < columns.length - 1 && <span> </span>}
</Fragment>
))}
</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,
// For now we only want to render the Copy Button by default
// if the Website Redesign is Enabled
showCopyButton = ENABLE_WEBSITE_REDESIGN,
}) => {
const ref = useRef<HTMLPreElement>(null);

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

const onCopy = async () => {
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

0 comments on commit aa0d9d6

Please sign in to comment.