-
Notifications
You must be signed in to change notification settings - Fork 129
feat(circuit-ui): add CopyButton component #3583
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
ca7bb3d
2acd3fc
e2bd564
7b15690
d949561
0f0ad3d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| 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 /> | ||
|
|
||
| ## 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. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do you mind adding tests for merging a custom class and forwarding a ref properly as we do on other components ? |
| 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', () => { | ||||||||||||||||||||||||||
|
Comment on lines
+135
to
+140
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||||||||||
| 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', () => { | ||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. we can move this test into |
||||||||||||||||||||||||||
| render(<CopyButton {...defaultProps} value="" visibleValue="N/A" />); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| expect(screen.getByRole('button', { name: 'Copy token' })).toHaveAttribute( | ||||||||||||||||||||||||||
| 'aria-disabled', | ||||||||||||||||||||||||||
| 'true', | ||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, done, thanks!