diff --git a/components/Common/ShellBox/__tests__/__snapshots__/index.test.tsx.snap b/components/Common/ShellBox/__tests__/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000000..0a18a22852632 --- /dev/null +++ b/components/Common/ShellBox/__tests__/__snapshots__/index.test.tsx.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ShellBox should render 1`] = ` +
+
+    
+ + SHELL + + +
+ + test + +
+
+`; diff --git a/components/Common/ShellBox/__tests__/index.test.tsx b/components/Common/ShellBox/__tests__/index.test.tsx new file mode 100644 index 0000000000000..9d764926a5d86 --- /dev/null +++ b/components/Common/ShellBox/__tests__/index.test.tsx @@ -0,0 +1,58 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { IntlProvider } from 'react-intl'; +import ShellBox from '../index'; + +const mockWriteText = jest.fn(); +const originalNavigator = { ...window.navigator }; + +describe('ShellBox', () => { + beforeEach(() => { + Object.defineProperty(window, 'navigator', { + value: { + clipboard: { + writeText: mockWriteText, + }, + }, + }); + }); + + afterEach(() => { + Object.defineProperty(window, 'navigator', { + value: originalNavigator, + }); + }); + + it('should render', () => { + const { container } = render( + {}}> + test + + ); + expect(container).toMatchSnapshot(); + }); + + it('should call clipboard API with `test` once', async () => { + const user = userEvent.setup(); + const navigatorClipboardWriteTextSpy = jest + .fn() + .mockImplementation(() => Promise.resolve()); + + Object.defineProperty(window.navigator, 'clipboard', { + writable: true, + value: { + writeText: navigatorClipboardWriteTextSpy, + }, + }); + + render( + {}}> + test + + ); + const button = screen.getByRole('button'); + await user.click(button); + expect(navigatorClipboardWriteTextSpy).toHaveBeenCalledTimes(1); + expect(navigatorClipboardWriteTextSpy).toHaveBeenCalledWith('test'); + }); +}); diff --git a/components/Common/ShellBox/index.module.scss b/components/Common/ShellBox/index.module.scss new file mode 100644 index 0000000000000..f69a169efabc8 --- /dev/null +++ b/components/Common/ShellBox/index.module.scss @@ -0,0 +1,75 @@ +.shellBox { + background-color: var(--black10); + border-radius: 0.4rem; + box-sizing: border-box; + display: flex; + flex-direction: column; + font-family: var(--mono); + padding: 0 0 var(--space-48) var(--space-16); + position: relative; + + code { + color: var(--pink5); + font-family: inherit; + line-height: 30px; + overflow-x: hidden; + position: absolute; + top: 30px; + width: calc(100% - 20px); + + &:hover { + overflow-x: auto; + } + + &::-webkit-scrollbar { + height: 0.5em; + } + + &::-webkit-scrollbar, + &::-webkit-scrollbar-thumb { + border-radius: 4px; + overflow: visible; + } + + &::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.2); + } + + > span.function { + color: var(--warning5); + } + } + + .top { + display: inherit; + flex-direction: row; + justify-content: space-between; + margin-bottom: var(--space-08); + + span, + button { + align-items: center; + display: inherit; + font-size: var(--font-size-code); + height: 23px; + justify-content: center; + width: 86px; + } + + span { + background-color: var(--black3); + border-radius: 0 0 0.3rem 0.3rem; + color: var(--black9); + margin-left: 1.6rem; + } + + button { + background-color: var(--brand); + border-radius: 0 0.3rem 0.3rem 0.3rem; + border-width: 0; + i { + padding: 0; + } + } + } +} diff --git a/components/Common/ShellBox/index.stories.tsx b/components/Common/ShellBox/index.stories.tsx new file mode 100644 index 0000000000000..4e5fa06699427 --- /dev/null +++ b/components/Common/ShellBox/index.stories.tsx @@ -0,0 +1,31 @@ +import ShellBox from './index'; +import type { Meta as MetaObj, StoryObj } from '@storybook/react'; + +type Story = StoryObj; +type Meta = MetaObj; + +export const Default: Story = { + args: { + children: 'echo hello world', + textToCopy: 'echo hello world', + }, +}; + +export const WithoutTextToCopy: Story = { + args: { + children: 'echo hello world', + }, +}; + +export const WithTextToCopyJsx: Story = { + args: { + children: ( + + $echo hello world + + ), + textToCopy: 'echo hello world', + }, +}; + +export default { component: ShellBox } as Meta; diff --git a/components/Common/ShellBox/index.tsx b/components/Common/ShellBox/index.tsx new file mode 100644 index 0000000000000..273828e738ba1 --- /dev/null +++ b/components/Common/ShellBox/index.tsx @@ -0,0 +1,45 @@ +import { useRef } from 'react'; +import { FormattedMessage } from 'react-intl'; +import styles from './index.module.scss'; +import { useCopyToClipboard } from '../../../hooks/useCopyToClipboard'; +import type { FC, PropsWithChildren, MouseEvent, ReactNode } from 'react'; + +type ShellBoxProps = { + children: string | ReactNode; + textToCopy?: string; +}; + +const ShellBox: FC> = ({ + children, + textToCopy, +}: PropsWithChildren) => { + const [copied, copyText] = useCopyToClipboard(); + + const shellBoxRef = useRef(null); + + const handleCopyCode = async (event: MouseEvent) => { + event.preventDefault(); + // By default we want to use the textToCopy props but if it's absent + // we allow the user to copy by getting the inner HTML content of the Element + const _textToCopy = textToCopy || shellBoxRef.current?.innerHTML || ''; + + await copyText(_textToCopy.replace('$', '')); + }; + + return ( +
+      
+ SHELL + +
+ {children} +
+ ); +}; + +export default ShellBox; diff --git a/components/Common/index.ts b/components/Common/index.ts new file mode 100644 index 0000000000000..d49eb3b30e135 --- /dev/null +++ b/components/Common/index.ts @@ -0,0 +1 @@ +export { default as ShellBox } from './ShellBox'; diff --git a/hooks/__test__/useCopyToClipboard.test.tsx b/hooks/__test__/useCopyToClipboard.test.tsx new file mode 100644 index 0000000000000..76756f9a3522d --- /dev/null +++ b/hooks/__test__/useCopyToClipboard.test.tsx @@ -0,0 +1,59 @@ +import { render, fireEvent, screen } from '@testing-library/react'; +import { FormattedMessage } from 'react-intl'; +import { IntlProvider } from 'react-intl'; +import { useCopyToClipboard } from '../useCopyToClipboard'; + +const mockWriteText = jest.fn(); +const originalNavigator = { ...window.navigator }; + +describe('useCopyToClipboard', () => { + beforeEach(() => { + Object.defineProperty(window, 'navigator', { + value: { + clipboard: { + writeText: mockWriteText, + }, + }, + }); + }); + + afterEach(() => { + Object.defineProperty(window, 'navigator', { + value: originalNavigator, + }); + }); + + const TestComponent = ({ textToCopy }: { textToCopy: string }) => { + const [copied, copyText] = useCopyToClipboard(); + + return ( + {}}> + + + ); + }; + + it('should call clipboard API with `test` once', () => { + const navigatorClipboardWriteTextSpy = jest + .fn() + .mockImplementation(() => Promise.resolve()); + + Object.defineProperty(window.navigator, 'clipboard', { + writable: true, + value: { + writeText: navigatorClipboardWriteTextSpy, + }, + }); + + render(); + const button = screen.getByRole('button'); + fireEvent.click(button); + expect(navigatorClipboardWriteTextSpy).toHaveBeenCalledTimes(1); + expect(navigatorClipboardWriteTextSpy).toHaveBeenCalledWith('test'); + }); +}); diff --git a/hooks/useCopyToClipboard.ts b/hooks/useCopyToClipboard.ts new file mode 100644 index 0000000000000..bd7b3a4cdeb33 --- /dev/null +++ b/hooks/useCopyToClipboard.ts @@ -0,0 +1,31 @@ +import { useEffect, useState } from 'react'; + +const copyToClipboard = (value: string | undefined) => { + if (!value || typeof navigator === 'undefined') { + return Promise.resolve(false); + } + + return navigator.clipboard + .writeText(value) + .then(() => true) + .catch(() => false); +}; + +export const useCopyToClipboard = () => { + const [copied, setCopied] = useState(false); + + const copyText = (text: string | undefined) => + copyToClipboard(text).then(setCopied); + + useEffect(() => { + if (copied) { + const timerId = setTimeout(() => setCopied(false), 3000); + + return () => clearTimeout(timerId); + } + + return undefined; + }, [copied]); + + return [copied, copyText] as const; +}; diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 9ea9b0148717c..9f0bbb6bd6f7e 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -57,5 +57,6 @@ "pages.index.features.openSource.title": "Open Source", "pages.index.features.openSource.description": "Node.js is open source and actively maintained by contributors all over the world", "pages.index.features.everywhere.title": "Everywhere", - "pages.index.features.everywhere.description": "Node.js has been adapted to work in a wide variety of places" + "pages.index.features.everywhere.description": "Node.js has been adapted to work in a wide variety of places", + "components.common.shellBox.copy": "{copied, select, true {copied}other {copy}}" }