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/green-snakes-sit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sumup-oss/circuit-ui': minor
---

Added a new CopyButton component to enable users to copy a predefined string of text to their clipboard.
41 changes: 41 additions & 0 deletions packages/circuit-ui/components/CopyButton/CopyButton.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Meta, Status, Props, Story } from '../../../../.storybook/components';
import * as Stories from './CopyButton.stories';

<Meta of={Stories} />

# CopyButton

<Status variant="experimental" />

CopyButton copies a value to the clipboard and can render as a read-only input, a full button, or an icon button.

<Story of={Stories.Base} />
<Props />

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Do you mind restructuring this page into When to use and How to use sections (as per this comment)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, done, thanks!

## When to Use

Use CopyButton when copying is the primary action, such as copying API tokens, webhook secrets, reference IDs, or shareable links.

Use the input variant when users need to inspect the copied value before copying it.

Use the button or icon button variants when the value itself does not need to stay visible in the UI.

## How to Use

Wrap your application in `ToastProvider` to display a feedback toast after a successful copy action.

<Story of={Stories.MaskedInput} />

Use `visibleValue` to show a masked or shortened value while preserving the full copied `value`.

<Story of={Stories.LongValue} />

## Variants

<Story of={Stories.AllVariants} />

## Content

Keep the visible label clear and specific so users understand what will be copied. Use concise copy such as `"Copy token"` or `"Copy secret"`, and customize `successLabel` when the success message should refer to a specific object.

For longer strings, the input variant truncates the visible text with an ellipsis while preserving the full copied value.
179 changes: 179 additions & 0 deletions packages/circuit-ui/components/CopyButton/CopyButton.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
/**
* Copyright 2026, SumUp Ltd.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import {
axe,
render,
screen,
userEvent,
waitFor,
} from '../../util/test-utils.js';
import { ToastProvider } from '../ToastContext/index.js';

import { CopyButton } from './CopyButton.js';

const defaultProps = {
label: 'API token',
value: 'secret-token',
copyLabel: 'Copy token',
successLabel: 'Copied to clipboard.',
};

const buttonVariants = [
{
name: 'button',
props: {
copyVariant: 'button',
value: 'secret-token',
copyLabel: 'Copy token',
successLabel: 'Copied to clipboard.',
},
buttonName: 'Copy token',
description: 'secret-token',
},
{
name: 'icon button',
props: {
copyVariant: 'icon-button',
value: 'secret-token',
copyLabel: 'Copy token',
successLabel: 'Copied to clipboard.',
},
buttonName: 'Copy token',
description: 'secret-token',
},
] satisfies {
name: string;
props: React.ComponentProps<typeof CopyButton>;
buttonName: string;
description: string;
}[];

const variants = [
{
name: 'input',
props: defaultProps,
buttonName: 'Copy token',
},
...buttonVariants,
] satisfies {
name: string;
props: React.ComponentProps<typeof CopyButton>;
buttonName: string;
}[];

const renderWithToastProvider = (ui: React.ReactNode) =>
render(<ToastProvider>{ui}</ToastProvider>);

describe('CopyButton', () => {
beforeEach(() => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: vi.fn().mockResolvedValue(undefined) },
});
});

afterEach(() => {
vi.useRealTimers();
});

it('should render the input variant in a readonly input', () => {
render(<CopyButton {...defaultProps} />);

expect(screen.getByRole('textbox')).toHaveAttribute('readonly');
expect(screen.getByRole('button', { name: 'Copy token' })).toBeVisible();
expect(screen.getByDisplayValue(defaultProps.value)).toBeVisible();
});

it('should render text instead of value in the input variant when provided', () => {
render(<CopyButton {...defaultProps} visibleValue="••••••••••••token" />);

expect(screen.getByDisplayValue('••••••••••••token')).toBeVisible();
expect(
screen.queryByDisplayValue(defaultProps.value),
).not.toBeInTheDocument();
});

describe.each(variants)('as $name', ({ props, buttonName }) => {
it('should show a notification toast when copying succeeds', async () => {
const onCopy = vi.fn();

renderWithToastProvider(
<CopyButton {...props} successLabel="Token copied" onCopy={onCopy} />,
);

await userEvent.click(screen.getByRole('button', { name: buttonName }));

expect(navigator.clipboard.writeText).toHaveBeenCalledWith(props.value);
expect(onCopy).toHaveBeenCalledTimes(1);
expect(await screen.findByText('Token copied')).toBeInTheDocument();
});

it('should have no accessibility violations', async () => {
const { container } = renderWithToastProvider(<CopyButton {...props} />);

const actual = await axe(container);

expect(actual).toHaveNoViolations();
});
});

describe.each(buttonVariants)('as $name', ({
props,
buttonName,
description,
}) => {
it('should describe the copied value when it is not visible', () => {
render(<CopyButton {...props} />);

expect(
screen.getByRole('button', { name: buttonName }),
).toHaveAccessibleDescription(description);
});
});

it('should not announce success when clipboard write fails', async () => {
const onCopy = vi.fn();

Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: {
writeText: vi.fn().mockRejectedValue(new Error('clipboard denied')),
},
});

renderWithToastProvider(<CopyButton {...defaultProps} onCopy={onCopy} />);

await userEvent.click(screen.getByRole('button', { name: 'Copy token' }));

await waitFor(() =>
expect(navigator.clipboard.writeText).toHaveBeenCalledTimes(1),
);

expect(onCopy).not.toHaveBeenCalled();
expect(screen.queryByText('Copied to clipboard.')).not.toBeInTheDocument();
});

it('should disable copying when the value is empty', () => {
render(<CopyButton {...defaultProps} value="" visibleValue="N/A" />);

expect(screen.getByRole('button', { name: 'Copy token' })).toHaveAttribute(
'aria-disabled',
'true',
);
});
});
Loading
Loading