Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ghost-copy-button.md
Original file line number Diff line number Diff line change
@@ -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\`
Original file line number Diff line number Diff line change
@@ -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(
<BaseCodeBox
language="JavaScript"
copyButtonLabel="Copy code"
copiedButtonLabel="Copied!"
>
<code>const x = 1;</code>
</BaseCodeBox>
);

assert.ok(screen.getByText('JavaScript'));
assert.ok(screen.getByText('Copy code'));
});

it('renders ghost copy button when no-footer is specified', () => {
render(
<BaseCodeBox
language="bash"
className="no-footer"
copyButtonLabel="Copy code"
copiedButtonLabel="Copied!"
>
<code>node --no-deprecation app.js</code>
</BaseCodeBox>
);

// 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(
<BaseCodeBox
language="bash"
className="no-footer"
copyButtonLabel="Copy code"
copiedButtonLabel="Copied!"
>
<code>node --no-deprecation app.js</code>
</BaseCodeBox>
);

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);
});
});
32 changes: 31 additions & 1 deletion packages/ui-components/src/Common/BaseCodeBox/index.module.css
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
@reference "../../styles/index.css";

.root {
@apply w-full
@apply relative
w-full
max-w-full
overflow-x-hidden
rounded-sm
Expand Down Expand Up @@ -71,6 +72,35 @@
[counter-reset:none];
}
}

&[class*='no-footer'] {
@apply pr-12;
}
}

& > .ghostAction {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add screenshots on how it looks on the PR description for dark/light theme 🙇

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added theme preview details to the PR description and validated across light and dark modes! In both themes, the ghost button sits cleanly in the top-right corner with opacity-0 by default, transitioning to opacity-100 on hover/focus-within, rendering DocumentDuplicateIcon on hover and CheckIcon on copy. You can also test it live directly in the Vercel deploy preview.

@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 {
Expand Down
10 changes: 10 additions & 0 deletions packages/ui-components/src/Common/BaseCodeBox/index.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,14 @@ export const Default: Story = {
},
};

export const NoFooter: Story = {
args: {
language: 'bash',
className: 'no-footer',
copyButtonLabel: 'Copy code',
copiedButtonLabel: 'Copied!',
children: <code>node --no-deprecation app.js</code>,
},
};

export default { component: BaseCodeBox } as Meta;
28 changes: 25 additions & 3 deletions packages/ui-components/src/Common/BaseCodeBox/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import {
DocumentDuplicateIcon,
CodeBracketIcon,
CheckIcon,
} from '@heroicons/react/24/outline';
import classNames from 'classnames';
import { Fragment, isValidElement, useRef } from 'react';
Expand Down Expand Up @@ -88,10 +89,13 @@ const BaseCodeBox: FC<PropsWithChildren<CodeBoxProps>> = ({

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 (
<div className={styles.root}>
<pre
Expand All @@ -102,7 +106,25 @@ const BaseCodeBox: FC<PropsWithChildren<CodeBoxProps>> = ({
{transformCode(children as ReactElement<PropsWithChildren>, language)}
</pre>

{!language || hideFooter || (
{hideFooter && (
<BaseButton
as={as}
className={styles.ghostAction}
kind="neutral"
size="small"
aria-label={ghostLabel}
title={ghostLabel}
onClick={handleCopy}
>
{copied ? (
<CheckIcon className="size-4" />
) : (
<DocumentDuplicateIcon className="size-4" />
)}
</BaseButton>
)}

{!hideFooter && Boolean(language) && (
<div className={styles.footer}>
<span className={styles.language}>{language}</span>

Expand All @@ -112,7 +134,7 @@ const BaseCodeBox: FC<PropsWithChildren<CodeBoxProps>> = ({
kind="neutral"
onClick={handleCopy}
>
<ButtonIcon className="size-4" />
<FooterButtonIcon className="size-4" />
{copied ? copiedButtonLabel : copyButtonLabel}
</BaseButton>
</div>
Expand Down