diff --git a/.changeset/ghost-copy-button.md b/.changeset/ghost-copy-button.md new file mode 100644 index 0000000000000..1593b20d52041 --- /dev/null +++ b/.changeset/ghost-copy-button.md @@ -0,0 +1,5 @@ +--- +'@node-core/ui-components': minor +--- + +Add ghost copy button visible on hover and focus for single-line code blocks with \`no-footer\` diff --git a/packages/ui-components/src/Common/BaseCodeBox/__tests__/index.test.jsx b/packages/ui-components/src/Common/BaseCodeBox/__tests__/index.test.jsx new file mode 100644 index 0000000000000..52762ae4dff5b --- /dev/null +++ b/packages/ui-components/src/Common/BaseCodeBox/__tests__/index.test.jsx @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { render, fireEvent, screen } from '@testing-library/react'; + +import BaseCodeBox from '../index'; + +describe('BaseCodeBox', () => { + let originalClipboard; + + beforeEach(() => { + originalClipboard = navigator.clipboard; + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: () => Promise.resolve() }, + writable: true, + configurable: true, + }); + }); + + afterEach(() => { + Object.defineProperty(navigator, 'clipboard', { + value: originalClipboard, + writable: true, + configurable: true, + }); + }); + + it('renders code box with language and footer copy button by default', () => { + render( + + const x = 1; + + ); + + assert.ok(screen.getByText('JavaScript')); + assert.ok(screen.getByText('Copy code')); + }); + + it('renders ghost copy button when no-footer is specified', () => { + render( + + node --no-deprecation app.js + + ); + + // Language in footer toolbar should NOT be rendered + assert.strictEqual(screen.queryByText('bash'), null); + + // Ghost copy button should be rendered with accessible label and no visible text + const button = screen.getByRole('button', { name: 'Copy code' }); + assert.ok(button); + assert.strictEqual(screen.queryByText('Copy code'), null); + }); + + it('copies code content when ghost copy button is clicked', async t => { + t.mock.method(navigator.clipboard, 'writeText', () => Promise.resolve()); + + render( + + node --no-deprecation app.js + + ); + + const button = screen.getByRole('button', { name: 'Copy code' }); + fireEvent.click(button); + + const copiedButton = await screen.findByRole('button', { name: 'Copied!' }); + assert.ok(copiedButton); + assert.strictEqual(navigator.clipboard.writeText.mock.callCount(), 1); + }); +}); diff --git a/packages/ui-components/src/Common/BaseCodeBox/index.module.css b/packages/ui-components/src/Common/BaseCodeBox/index.module.css index da0f2e11de2a0..7143555631204 100644 --- a/packages/ui-components/src/Common/BaseCodeBox/index.module.css +++ b/packages/ui-components/src/Common/BaseCodeBox/index.module.css @@ -1,7 +1,8 @@ @reference "../../styles/index.css"; .root { - @apply w-full + @apply relative + w-full max-w-full overflow-x-hidden rounded-sm @@ -71,6 +72,35 @@ [counter-reset:none]; } } + + &[class*='no-footer'] { + @apply pr-12; + } + } + + & > .ghostAction { + @apply absolute + top-2 + right-2 + z-10 + p-1.5 + opacity-0 + transition-opacity + duration-150; + + > svg { + @apply size-4; + } + + &:focus, + &:focus-visible { + @apply opacity-100; + } + } + + &:hover > .ghostAction, + &:focus-within > .ghostAction { + @apply opacity-100; } & > .footer { diff --git a/packages/ui-components/src/Common/BaseCodeBox/index.stories.tsx b/packages/ui-components/src/Common/BaseCodeBox/index.stories.tsx index 6cc32c8b24169..bdbff01b6908a 100644 --- a/packages/ui-components/src/Common/BaseCodeBox/index.stories.tsx +++ b/packages/ui-components/src/Common/BaseCodeBox/index.stories.tsx @@ -27,4 +27,14 @@ export const Default: Story = { }, }; +export const NoFooter: Story = { + args: { + language: 'bash', + className: 'no-footer', + copyButtonLabel: 'Copy code', + copiedButtonLabel: 'Copied!', + children: node --no-deprecation app.js, + }, +}; + export default { component: BaseCodeBox } as Meta; diff --git a/packages/ui-components/src/Common/BaseCodeBox/index.tsx b/packages/ui-components/src/Common/BaseCodeBox/index.tsx index af31bcdf3c37f..b966a48143640 100644 --- a/packages/ui-components/src/Common/BaseCodeBox/index.tsx +++ b/packages/ui-components/src/Common/BaseCodeBox/index.tsx @@ -3,6 +3,7 @@ import { DocumentDuplicateIcon, CodeBracketIcon, + CheckIcon, } from '@heroicons/react/24/outline'; import classNames from 'classnames'; import { Fragment, isValidElement, useRef } from 'react'; @@ -88,10 +89,13 @@ const BaseCodeBox: FC> = ({ const handleCopy = () => copy(containerRef.current?.textContent); - const ButtonIcon = copied ? DocumentDuplicateIcon : CodeBracketIcon; + const FooterButtonIcon = copied ? DocumentDuplicateIcon : CodeBracketIcon; const hideFooter = className?.includes('no-footer'); + const activeLabel = copied ? copiedButtonLabel : copyButtonLabel; + const ghostLabel = typeof activeLabel === 'string' ? activeLabel : undefined; + return (
> = ({
         {transformCode(children as ReactElement, language)}
       
- {!language || hideFooter || ( + {hideFooter && ( + + {copied ? ( + + ) : ( + + )} + + )} + + {!hideFooter && Boolean(language) && (
{language} @@ -112,7 +134,7 @@ const BaseCodeBox: FC> = ({ kind="neutral" onClick={handleCopy} > - + {copied ? copiedButtonLabel : copyButtonLabel}