diff --git a/.changeset/green-snakes-sit.md b/.changeset/green-snakes-sit.md new file mode 100644 index 0000000000..2e50c7cbcb --- /dev/null +++ b/.changeset/green-snakes-sit.md @@ -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. diff --git a/packages/circuit-ui/components/CopyButton/CopyButton.mdx b/packages/circuit-ui/components/CopyButton/CopyButton.mdx new file mode 100644 index 0000000000..a8c2d2786f --- /dev/null +++ b/packages/circuit-ui/components/CopyButton/CopyButton.mdx @@ -0,0 +1,41 @@ +import { Meta, Status, Props, Story } from '../../../../.storybook/components'; +import * as Stories from './CopyButton.stories'; + + + +# CopyButton + + + +CopyButton copies a value to the clipboard and can render as a read-only input, a full button, or an icon button. + + + + +## 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. + + + +Use `visibleValue` to show a masked or shortened value while preserving the full copied `value`. + + + +## Variants + + + +## 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. diff --git a/packages/circuit-ui/components/CopyButton/CopyButton.spec.tsx b/packages/circuit-ui/components/CopyButton/CopyButton.spec.tsx new file mode 100644 index 0000000000..fe17bb5824 --- /dev/null +++ b/packages/circuit-ui/components/CopyButton/CopyButton.spec.tsx @@ -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; + buttonName: string; + description: string; +}[]; + +const variants = [ + { + name: 'input', + props: defaultProps, + buttonName: 'Copy token', + }, + ...buttonVariants, +] satisfies { + name: string; + props: React.ComponentProps; + buttonName: string; +}[]; + +const renderWithToastProvider = (ui: React.ReactNode) => + render({ui}); + +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(); + + 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(); + + 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( + , + ); + + 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(); + + 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(); + + 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(); + + 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(); + + expect(screen.getByRole('button', { name: 'Copy token' })).toHaveAttribute( + 'aria-disabled', + 'true', + ); + }); +}); diff --git a/packages/circuit-ui/components/CopyButton/CopyButton.stories.tsx b/packages/circuit-ui/components/CopyButton/CopyButton.stories.tsx new file mode 100644 index 0000000000..36255824d8 --- /dev/null +++ b/packages/circuit-ui/components/CopyButton/CopyButton.stories.tsx @@ -0,0 +1,209 @@ +/** + * 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 { Stack } from '../../../../.storybook/components/index.js'; +import { ToastProvider } from '../ToastContext/index.js'; + +import { CopyButton, type CopyButtonProps } from './CopyButton.js'; + +export default { + title: 'Components/CopyButton', + component: CopyButton, + tags: ['status:experimental'], + decorators: [ + (Story: () => JSX.Element) => ( + + + + ), + ], + argTypes: { + value: { + control: 'text', + table: { + category: 'Copy action', + }, + }, + copyLabel: { + control: 'text', + table: { + category: 'Copy action', + }, + }, + successLabel: { + control: 'text', + table: { + category: 'Copy action', + }, + }, + onCopy: { + table: { + category: 'Copy action', + }, + }, + copyVariant: { + options: ['input', 'button', 'icon-button'], + control: { type: 'select' }, + table: { + category: 'Display', + }, + }, + visibleValue: { + control: 'text', + table: { + category: 'Display', + }, + }, + label: { + table: { + category: 'Input', + }, + }, + hideLabel: { + control: 'boolean', + table: { + category: 'Input', + }, + }, + disabled: { + control: 'boolean', + table: { + category: 'State', + }, + }, + invalid: { + control: 'boolean', + table: { + category: 'State', + }, + }, + hasWarning: { + control: 'boolean', + table: { + category: 'State', + }, + }, + validationHint: { + control: 'text', + table: { + category: 'State', + }, + }, + }, + parameters: { + docs: { + description: { + component: + 'CopyButton copies a value to the clipboard and can render as a read-only input, a button, or an icon button.', + }, + }, + }, +}; + +const inputArgs = { + label: 'API token', + value: 'sk_live_1234567890', + copyLabel: 'Copy API token', + successLabel: 'Token copied', +} satisfies CopyButtonProps; + +export const Base = (args: CopyButtonProps) => ; + +Base.args = inputArgs; + +Base.parameters = { + chromatic: { + disableSnapshot: true, + }, +}; + +export const MaskedInput = (args: CopyButtonProps) => ; + +MaskedInput.args = { + ...inputArgs, + visibleValue: 'sk_live_******', +}; + +export const LongValue = (args: CopyButtonProps) => ; + +LongValue.args = { + label: 'Webhook secret', + value: 'whsec_4VbX8i2LwY7nQp3Rk5Tm9Uc1Fd6Hs0Za', + copyLabel: 'Copy secret', + successLabel: 'Secret copied', +} satisfies CopyButtonProps; + +LongValue.parameters = { + chromatic: { + disableSnapshot: true, + }, +}; + +export const Disabled = () => ( + + + + + +); + +export const ValidationStates = (args: CopyButtonProps) => ( + + + + +); + +ValidationStates.args = inputArgs; + +export const AllVariants = () => ( + + + + + +); diff --git a/packages/circuit-ui/components/CopyButton/CopyButton.tsx b/packages/circuit-ui/components/CopyButton/CopyButton.tsx new file mode 100644 index 0000000000..6a4f0652a5 --- /dev/null +++ b/packages/circuit-ui/components/CopyButton/CopyButton.tsx @@ -0,0 +1,221 @@ +/** + * 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. + */ + +'use client'; + +import { useId } from 'react'; +import { CopyPaste } from '@sumup-oss/icons'; + +import { Button, IconButton } from '../Button/index.js'; +import { Input, type InputProps } from '../Input/index.js'; +import { useNotificationToast } from '../NotificationToast/index.js'; +import type { ClickEvent } from '../../types/events.js'; +import type { ButtonProps, IconButtonProps } from '../Button/index.js'; +import { utilClasses } from '../../styles/utility.js'; +import { idx } from '../../util/idx.js'; + +type CommonCopyButtonProps = { + /** + * The text value copied to the clipboard. + */ + value: string; + /** + * Text label for the copy action. + */ + copyLabel: string; + /** + * Callback function when the value was copied. + */ + onCopy?: (event: ClickEvent) => void; + /** + * Test copy shown in as a notification after a successful copy action. + */ + successLabel: string; +}; + +type InputCopyButtonProps = CommonCopyButtonProps & + Omit< + InputProps, + | 'defaultValue' + | 'onCopy' + | 'passwordManagerIgnore' + | 'readOnly' + | 'renderSuffix' + | 'showValid' + | 'type' + | 'value' + > & { + /** + * The CopyButton variant. + * + * @default input + */ + copyVariant?: 'input'; + /** + * Optional text rendered inside the field instead of the copied value. + */ + visibleValue?: string; + }; + +type ButtonCopyButtonProps = CommonCopyButtonProps & + Omit< + ButtonProps, + | 'children' + | 'destructive' + | 'icon' + | 'navigationIcon' + | 'onClick' + | 'type' + | 'value' + > & { + /** + * The CopyButton variant. + */ + copyVariant: 'button'; + }; + +type IconCopyButtonProps = CommonCopyButtonProps & + Omit< + IconButtonProps, + 'children' | 'icon' | 'label' | 'onClick' | 'type' | 'value' + > & { + /** + * The CopyButton variant. + */ + copyVariant: 'icon-button'; + }; + +export type CopyButtonProps = + | InputCopyButtonProps + | ButtonCopyButtonProps + | IconCopyButtonProps; + +/** + * The CopyButton component copies a provided value to the clipboard and + * can render as a read-only input, a button, or an icon button. + */ +export function CopyButton(props: CopyButtonProps) { + const { setToast } = useNotificationToast(); + const { successLabel, copyLabel, onCopy, value } = props; + const isCopyDisabled = Boolean(props.disabled || value.length === 0); + const valueDescriptionId = useId(); + + const handleCopy = async (event: ClickEvent) => { + try { + // eslint-disable-next-line compat/compat + await navigator.clipboard.writeText(value); + setToast({ + body: successLabel, + }); + onCopy?.(event); + } catch { + // Ignore clipboard failures so the UI does not enter a false success state. + } + }; + + if (props.copyVariant === 'button') { + const { + 'aria-describedby': ariaDescribedBy, + copyVariant, + successLabel: _successLabel, + copyLabel: _copyLabel, + onCopy: _onCopy, + value: _value, + ...buttonProps + } = props; + + return ( + <> + + + {value} + + + ); + } + + if (props.copyVariant === 'icon-button') { + const { + 'aria-describedby': ariaDescribedBy, + copyVariant, + successLabel: _successLabel, + copyLabel: _copyLabel, + onCopy: _onCopy, + value: _value, + ...iconButtonProps + } = props; + + return ( + <> + + {copyLabel} + + + {value} + + + ); + } + + const { + copyVariant, + successLabel: _successLabel, + copyLabel: _copyLabel, + inputClassName, + onCopy: _onCopy, + visibleValue, + value: _value, + ...inputProps + } = props; + const displayText = visibleValue ?? value; + + return ( + ( + + {copyLabel} + + )} + inputClassName={inputClassName} + /> + ); +} diff --git a/packages/circuit-ui/components/CopyButton/index.ts b/packages/circuit-ui/components/CopyButton/index.ts new file mode 100644 index 0000000000..c7a08bf053 --- /dev/null +++ b/packages/circuit-ui/components/CopyButton/index.ts @@ -0,0 +1,18 @@ +/** + * 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. + */ + +export { CopyButton } from './CopyButton.js'; + +export type { CopyButtonProps } from './CopyButton.js'; diff --git a/packages/circuit-ui/index.ts b/packages/circuit-ui/index.ts index 62c24fc042..9688eef283 100644 --- a/packages/circuit-ui/index.ts +++ b/packages/circuit-ui/index.ts @@ -76,6 +76,8 @@ export { ButtonGroup } from './components/ButtonGroup/index.js'; export type { ButtonGroupProps } from './components/ButtonGroup/index.js'; export { CloseButton } from './components/CloseButton/index.js'; export type { CloseButtonProps } from './components/CloseButton/index.js'; +export { CopyButton } from './components/CopyButton/index.js'; +export type { CopyButtonProps } from './components/CopyButton/index.js'; export { IconButton } from './components/Button/index.js'; export type { IconButtonProps } from './components/Button/index.js'; export { Toggle } from './components/Toggle/index.js'; diff --git a/skills/circuit-ui/references/components.md b/skills/circuit-ui/references/components.md index e76c6950dd..65f40baa2b 100644 --- a/skills/circuit-ui/references/components.md +++ b/skills/circuit-ui/references/components.md @@ -7,6 +7,7 @@ | `Button` | `stable` | `@sumup-oss/circuit-ui` | `./components/Button/index.js` | [Read MDX reference](components/Button.mdx) | | `ButtonGroup` | `stable` | `@sumup-oss/circuit-ui` | `./components/ButtonGroup/index.js` | [Read MDX reference](components/ButtonGroup.mdx) | | `CloseButton` | `stable` | `@sumup-oss/circuit-ui` | `./components/CloseButton/index.js` | Not available | +| `CopyButton` | `experimental` | `@sumup-oss/circuit-ui` | `./components/CopyButton/index.js` | [Read MDX reference](components/CopyButton.mdx) | | `IconButton` | `stable` | `@sumup-oss/circuit-ui` | `./components/Button/index.js` | [Read MDX reference](components/IconButton.mdx) | | `SelectorGroup` | `stable` | `@sumup-oss/circuit-ui` | `./components/SelectorGroup/index.js` | [Read MDX reference](components/SelectorGroup.mdx) | | `Toggle` | `stable` | `@sumup-oss/circuit-ui` | `./components/Toggle/index.js` | [Read MDX reference](components/Toggle.mdx) | diff --git a/skills/circuit-ui/references/components/CopyButton.mdx b/skills/circuit-ui/references/components/CopyButton.mdx new file mode 100644 index 0000000000..a8c2d2786f --- /dev/null +++ b/skills/circuit-ui/references/components/CopyButton.mdx @@ -0,0 +1,41 @@ +import { Meta, Status, Props, Story } from '../../../../.storybook/components'; +import * as Stories from './CopyButton.stories'; + + + +# CopyButton + + + +CopyButton copies a value to the clipboard and can render as a read-only input, a full button, or an icon button. + + + + +## 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. + + + +Use `visibleValue` to show a masked or shortened value while preserving the full copied `value`. + + + +## Variants + + + +## 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.