-
Notifications
You must be signed in to change notification settings - Fork 6.3k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add CodeBox component and code tabs plugin (#6038)
Co-authored-by: Claudio Wunder <[email protected]> Co-authored-by: Caner Akdas <[email protected]>
- Loading branch information
1 parent
d6cf107
commit aa0d9d6
Showing
39 changed files
with
866 additions
and
93 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
Oops, something went wrong.