diff --git a/.changeset/seven-clocks-jog.md b/.changeset/seven-clocks-jog.md new file mode 100644 index 000000000..87bccd489 --- /dev/null +++ b/.changeset/seven-clocks-jog.md @@ -0,0 +1,5 @@ +--- +'@hashicorp/react-code-block': minor +--- + +Update to modern clipboard API diff --git a/packages/code-block/index.test.js b/packages/code-block/index.test.js index 090d1baf5..30b50b852 100644 --- a/packages/code-block/index.test.js +++ b/packages/code-block/index.test.js @@ -12,16 +12,21 @@ import { } from '@testing-library/react' import CodeBlock from './' import { heapAttributes } from './analytics' -// Mock copy-to-clipboard -import copyToClipboard from './partials/clipboard-button/copy-to-clipboard' -jest.mock('./partials/clipboard-button/copy-to-clipboard', () => - jest.fn().mockImplementation(() => true) -) // We want to make sure copied code is passed through processSnippet, // we import it so that we don't have to manually recreate its output import processSnippet from './utils/process-snippet' -afterEach(cleanup) +// mock the clipboard API +Object.assign(navigator, { + clipboard: { + writeText: jest.fn(), + }, +}) + +afterEach(() => { + cleanup() + jest.clearAllMocks() +}) it('should render a root element with a `g-code-block` class', () => { const { container } = render() @@ -108,9 +113,10 @@ it('should use the `Copy` button to copy code to the clipboard', async () => { fireEvent.click(buttonElem) // Expect copyToClipboard to have been called with our code snippet // (note: this function is mocked at the top of this test file) - await waitFor(() => expect(copyToClipboard).toHaveBeenCalledTimes(1)) - expect(copyToClipboard).toHaveBeenCalledWith(codeString) - copyToClipboard.mockClear() + await waitFor(() => + expect(navigator.clipboard.writeText).toHaveBeenCalledTimes(1) + ) + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(codeString) }) it('should track a "Copy" event when the "Copy" button is clicked', async () => { @@ -136,7 +142,6 @@ it('should track a "Copy" event when the "Copy" button is clicked', async () => }) // Cleanup window.analytics = forMockRestore - copyToClipboard.mockClear() }) it('should call "onCopyCallback" when the "Copy" button is clicked', async () => { @@ -156,9 +161,10 @@ it('should call "onCopyCallback" when the "Copy" button is clicked', async () => expect(buttonElem).toBeInTheDocument() fireEvent.click(buttonElem) // Expect onCopyCallback to have been called - await waitFor(() => expect(onCopyCallback).toHaveBeenCalledTimes(1)) + await waitFor(() => + expect(navigator.clipboard.writeText).toHaveBeenCalledTimes(1) + ) expect(onCopyCallback).toBeCalledWith(true) - copyToClipboard.mockClear() }) it('should track a "Click" event when the root element is clicked', async () => { @@ -192,11 +198,12 @@ it('should use process-snippet to strip the leading $ from shell snippets', asyn const buttonElem = screen.getByText('Copy') expect(buttonElem).toBeInTheDocument() fireEvent.click(buttonElem) - // Expect copyToClipboard to have been called with our code snippet + // Expect navigator.clipboard.writeText to have been called with our code snippet // (note: this function is mocked at the top of this test file) // We also expect the code to have been modified by processSnippet const expectedCode = processSnippet(codeString) - await waitFor(() => expect(copyToClipboard).toHaveBeenCalledTimes(1)) - expect(copyToClipboard).toHaveBeenCalledWith(expectedCode) - copyToClipboard.mockClear() + await waitFor(() => + expect(navigator.clipboard.writeText).toHaveBeenCalledTimes(1) + ) + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(expectedCode) }) diff --git a/packages/code-block/partials/clipboard-button/copy-to-clipboard.js b/packages/code-block/partials/clipboard-button/copy-to-clipboard.js deleted file mode 100644 index 1d8846e8e..000000000 --- a/packages/code-block/partials/clipboard-button/copy-to-clipboard.js +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: MPL-2.0 - */ - -/** - * Copies a string of text to the clipboard - * @param {string} string - string of text to copy - * @returns {boolean} - `true` if successful, or `false` otherwise - */ -function copyToClipboard(string) { - // We can't do this if there's no `document`, so don't try - if (typeof document === 'undefined') { - console.error('copyToClipboard failed, as document is undefined') - return false - } - // We also can't do this if the argument is not a string - if (typeof string !== 'string') { - console.error('copyToClipboard received non-string argument: ' + string) - return false - } - - let copyElem - - try { - // Create a temporary `textarea` from which to select & copy - let copyElem = document.createElement('textarea') - copyElem.style.fontSize = '12pt' // Prevent zooming on iOS - copyElem.value = string - document.body.appendChild(copyElem) - copyElem.select() - document.execCommand('copy') - document.body.removeChild(copyElem) - return true - } catch (err) { - // We should try to clean up the tempElem - // just in case it did get created - console.error(err) - document.body.removeChild(copyElem) - return false - } -} - -export default copyToClipboard diff --git a/packages/code-block/partials/clipboard-button/index.tsx b/packages/code-block/partials/clipboard-button/index.tsx index dde662c83..d4fdbfdea 100644 --- a/packages/code-block/partials/clipboard-button/index.tsx +++ b/packages/code-block/partials/clipboard-button/index.tsx @@ -8,7 +8,6 @@ import classnames from 'classnames' import { IconCheckSquare16 } from '@hashicorp/flight-icons/svg-react/check-square-16' import { IconDuplicate16 } from '@hashicorp/flight-icons/svg-react/duplicate-16' import { IconXSquare16 } from '@hashicorp/flight-icons/svg-react/x-square-16' -import copyToClipboard from './copy-to-clipboard' import analytics, { heapAttributes } from '../../analytics' import s from './style.module.css' @@ -43,8 +42,14 @@ function ClipboardButton({ } // Otherwise, continue on... - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const isCopied = copyToClipboard(text!) + let isCopied = false + try { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + await navigator.clipboard.writeText(text!) + isCopied = true + } catch (err) { + // noop + } // If there's an internal failure copying text, exit early to handle the error if (!isCopied) { diff --git a/packages/code-block/provider/use-indexed-tabs.test.js b/packages/code-block/provider/use-indexed-tabs.test.js index 837ca7692..fe7463be2 100644 --- a/packages/code-block/provider/use-indexed-tabs.test.js +++ b/packages/code-block/provider/use-indexed-tabs.test.js @@ -17,12 +17,8 @@ afterEach(() => { * useIndexedTabs hook works as expected. */ function TestComponent({ tabGroupIds, defaultTabIdx }) { - const [ - localTabIdx, - setActiveTabIdx, - activeTabGroup, - setActiveTabGroup, - ] = useIndexedTabs(tabGroupIds, defaultTabIdx) + const [localTabIdx, setActiveTabIdx, activeTabGroup, setActiveTabGroup] = + useIndexedTabs(tabGroupIds, defaultTabIdx) return (

localTabIdx