From ca7bb3deaea735dfbcadc95ba7f74540890418dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Dzivjak?= Date: Sun, 26 Apr 2026 21:53:38 +0200 Subject: [PATCH 1/6] feat(circuit-ui): add ClipboardText component (experimental) We have a few places in our dashboard where we render a text and allow user's to copy it into a clipboard using a button. Currently, those are implemented inconsistency, sometimes using the `Input` component, sometimes using custom styling of `Body` and `IconButton`. This change introduces a new ClipboardText component that render a copiable text. --- .changeset/green-snakes-sit.md | 5 + .../ClipboardText/ClipboardText.mdx | 29 +++ .../ClipboardText/ClipboardText.module.css | 53 +++++ .../ClipboardText/ClipboardText.spec.tsx | 188 ++++++++++++++++ .../ClipboardText/ClipboardText.stories.tsx | 65 ++++++ .../ClipboardText/ClipboardText.tsx | 206 ++++++++++++++++++ .../components/ClipboardText/index.ts | 18 ++ packages/circuit-ui/index.ts | 2 + skills/circuit-ui/references/components.md | 1 + .../references/components/ClipboardText.mdx | 29 +++ 10 files changed, 596 insertions(+) create mode 100644 .changeset/green-snakes-sit.md create mode 100644 packages/circuit-ui/components/ClipboardText/ClipboardText.mdx create mode 100644 packages/circuit-ui/components/ClipboardText/ClipboardText.module.css create mode 100644 packages/circuit-ui/components/ClipboardText/ClipboardText.spec.tsx create mode 100644 packages/circuit-ui/components/ClipboardText/ClipboardText.stories.tsx create mode 100644 packages/circuit-ui/components/ClipboardText/ClipboardText.tsx create mode 100644 packages/circuit-ui/components/ClipboardText/index.ts create mode 100644 skills/circuit-ui/references/components/ClipboardText.mdx diff --git a/.changeset/green-snakes-sit.md b/.changeset/green-snakes-sit.md new file mode 100644 index 0000000000..f34e26833a --- /dev/null +++ b/.changeset/green-snakes-sit.md @@ -0,0 +1,5 @@ +--- +'@sumup-oss/circuit-ui': minor +--- + +Added the `ClipboardText` component. diff --git a/packages/circuit-ui/components/ClipboardText/ClipboardText.mdx b/packages/circuit-ui/components/ClipboardText/ClipboardText.mdx new file mode 100644 index 0000000000..e1ef22e82a --- /dev/null +++ b/packages/circuit-ui/components/ClipboardText/ClipboardText.mdx @@ -0,0 +1,29 @@ +import { Meta, Status, Props, Story } from '../../../../.storybook/components'; +import * as Stories from './ClipboardText.stories'; + + + +# ClipboardText + + + +ClipboardText displays a value in an input-like field and provides an inline button to copy it to a clipboard. + + + + +## Usage + +Use ClipboardText when a value should be easy to copy but not directly editable, such as API tokens, webhook secrets, or reference IDs. + +The `value` prop is always the copied value. If you need to display a different string, such as a masked token, pass it through the `text` prop while keeping the original `value` intact. + + + +## Content + +Keep the visible label clear and specific so users understand what will be copied. Use concise button copy such as `"Copy token"` or `"Copy secret"` to make the action predictable for assistive technology and sighted users alike. + +For longer strings, ClipboardText truncates the visible text while preserving the full copied value. + + diff --git a/packages/circuit-ui/components/ClipboardText/ClipboardText.module.css b/packages/circuit-ui/components/ClipboardText/ClipboardText.module.css new file mode 100644 index 0000000000..42fcfdb5e5 --- /dev/null +++ b/packages/circuit-ui/components/ClipboardText/ClipboardText.module.css @@ -0,0 +1,53 @@ +.base { + display: flex; + align-items: center; + padding: 0; +} + +.content { + display: flex; + flex: 1; + align-items: center; + min-width: 0; + padding: var(--cui-spacings-kilo) var(--cui-spacings-mega); +} + +.value { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.action { + position: relative; + display: flex; + flex-shrink: 0; + align-items: center; + align-self: stretch; + justify-content: center; + width: calc( + var(--cui-body-m-line-height) + + var(--cui-spacings-kilo) * + 2 + + var(--cui-border-width-kilo) * + 2 + ); + min-width: calc( + var(--cui-body-m-line-height) + + var(--cui-spacings-kilo) * + 2 + + var(--cui-border-width-kilo) * + 2 + ); + border-left: var(--cui-border-width-kilo) solid var(--cui-border-normal); +} + +.button { + display: flex; + flex-shrink: 0; + justify-content: center; + width: 100%; + min-width: 0; + height: 100%; +} diff --git a/packages/circuit-ui/components/ClipboardText/ClipboardText.spec.tsx b/packages/circuit-ui/components/ClipboardText/ClipboardText.spec.tsx new file mode 100644 index 0000000000..a10395737f --- /dev/null +++ b/packages/circuit-ui/components/ClipboardText/ClipboardText.spec.tsx @@ -0,0 +1,188 @@ +/** + * 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 { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; + +import { act, axe, fireEvent, render, screen } from '../../util/test-utils.js'; + +import { ClipboardText } from './ClipboardText.js'; + +const defaultProps = { + label: 'API token', + value: 'secret-token', + copyLabel: 'Copy value', +}; + +describe('ClipboardText', () => { + beforeEach(() => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText: vi.fn().mockResolvedValue(undefined) }, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should render the value without using an input element', () => { + render(); + + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Copy value: API token' }), + ).toBeVisible(); + expect(screen.getByText(defaultProps.value)).toBeVisible(); + }); + + it('should expose the display text with label context', () => { + render(); + + expect(screen.getByText('API token:').className).toContain('hide-visually'); + expect(screen.getByText(defaultProps.value)).toBeVisible(); + }); + + it('should render text instead of value when provided', () => { + render(); + + expect(screen.getByText('••••••••••••token')).toBeVisible(); + expect(screen.queryByText(defaultProps.value)).not.toBeInTheDocument(); + }); + + it('should copy the current value when the button is clicked', async () => { + vi.useFakeTimers(); + const onCopied = vi.fn(); + + render( + , + ); + + fireEvent.click( + screen.getByRole('button', { name: 'Copy value: API token' }), + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(onCopied).toHaveBeenCalledTimes(1); + expect( + screen.getByRole('button', { name: 'Copy value: API token' }), + ).toBeVisible(); + expect(screen.getByRole('status')).toHaveTextContent('Copied value'); + + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + + fireEvent.click( + screen.getByRole('button', { name: 'Copy value: API token' }), + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(onCopied).toHaveBeenCalledTimes(2); + expect(screen.getByRole('status')).toHaveTextContent('Copied value'); + + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + + expect( + screen.getByRole('button', { name: 'Copy value: API token' }), + ).toBeVisible(); + expect(screen.getByRole('status')).toHaveTextContent('Copied value'); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + + expect(screen.getByText('', { selector: 'output' })).toBeEmptyDOMElement(); + }); + + it('should fall back to execCommand when navigator.clipboard is unavailable', async () => { + const execCommand = vi.fn(); + const selection = { + addRange: vi.fn(), + removeAllRanges: vi.fn(), + }; + + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: undefined, + }); + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: execCommand, + }); + Object.defineProperty(window, 'getSelection', { + configurable: true, + value: vi.fn(() => selection), + }); + + render(); + + fireEvent.click( + screen.getByRole('button', { name: 'Copy value: API token' }), + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(execCommand).toHaveBeenCalledWith('copy'); + expect(selection.removeAllRanges).toHaveBeenCalledTimes(2); + expect(selection.addRange).toHaveBeenCalledTimes(1); + expect(screen.getByRole('status')).toHaveTextContent('Copied value'); + }); + + it('should not announce copied state when clipboard write fails', async () => { + const onCopied = vi.fn(); + + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { + writeText: vi.fn().mockRejectedValue(new Error('clipboard denied')), + }, + }); + + render(); + + fireEvent.click( + screen.getByRole('button', { name: 'Copy value: API token' }), + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(onCopied).not.toHaveBeenCalled(); + expect(screen.getByText('', { selector: 'output' })).toBeEmptyDOMElement(); + }); + + it('should have no accessibility violations', async () => { + const { container } = render(); + + const actual = await axe(container); + + expect(actual).toHaveNoViolations(); + }); +}); diff --git a/packages/circuit-ui/components/ClipboardText/ClipboardText.stories.tsx b/packages/circuit-ui/components/ClipboardText/ClipboardText.stories.tsx new file mode 100644 index 0000000000..13a8c16a2e --- /dev/null +++ b/packages/circuit-ui/components/ClipboardText/ClipboardText.stories.tsx @@ -0,0 +1,65 @@ +/** + * 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 { ClipboardText, type ClipboardTextProps } from './ClipboardText.js'; + +export default { + title: 'Forms/ClipboardText', + component: ClipboardText, + tags: ['status:experimental'], + argTypes: { + copyLabel: { control: 'text' }, + readOnly: { control: 'boolean' }, + copiedLabel: { control: 'text' }, + text: { control: 'text' }, + }, + parameters: { + docs: { + description: { + component: + 'ClipboardText displays a value in an input-like field and provides a built-in copy action.', + }, + }, + }, +}; + +export const Base = (args: ClipboardTextProps) => ; + +Base.args = { + label: 'API token', + value: 'sk_live_1234567890', + copyLabel: 'Copy token', +}; + +export const MaskedValue = (args: ClipboardTextProps) => ( + +); + +MaskedValue.args = { + label: 'API token', + value: 'sk_live_1234567890', + text: 'sk_live_******', + copyLabel: 'Copy token', +}; + +export const LongValue = (args: ClipboardTextProps) => ( + +); + +LongValue.args = { + label: 'Webhook secret', + value: 'whsec_4VbX8i2LwY7nQp3Rk5Tm9Uc1Fd6Hs0Za', + copyLabel: 'Copy secret', +}; diff --git a/packages/circuit-ui/components/ClipboardText/ClipboardText.tsx b/packages/circuit-ui/components/ClipboardText/ClipboardText.tsx new file mode 100644 index 0000000000..6c8d5480c2 --- /dev/null +++ b/packages/circuit-ui/components/ClipboardText/ClipboardText.tsx @@ -0,0 +1,206 @@ +/** + * 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 { Checkmark, Copy } from '@sumup-oss/icons'; +import { useEffect, useId, useState, type CSSProperties } from 'react'; + +import { FieldWrapper, FieldLabelText } from '../Field/index.js'; +import fieldClasses from '../Field/Field.module.css'; +import { IconButton } from '../Button/index.js'; +import { utilClasses } from '../../styles/utility.js'; +import { + classes as inputClasses, + type BaseInputProps, +} from '../Input/index.js'; +import { clsx } from '../../styles/clsx.js'; +import type { ClickEvent } from '../../types/events.js'; +import { + AccessibilityError, + isSufficientlyLabelled, +} from '../../util/errors.js'; + +import classes from './ClipboardText.module.css'; + +export interface ClipboardTextProps + extends Omit { + /** + * The text value copied to the clipboard. + */ + value?: string; + /** + * Optional text rendered inside the field instead of the copied value. + */ + text?: string; + /** + * Text label for the copy button. + */ + copyLabel: string; + /** + * Callback function when the value was copied. + */ + onCopied?: (event: ClickEvent) => void; + /** + * Accessible label and status message after the value has been copied. + * + * @default Copied + */ + copiedLabel?: string; + /** + * Additional class name for the component wrapper. + */ + className?: string; + /** + * Inline styles for the component wrapper. + */ + style?: CSSProperties; +} + +async function copyToClipboard(text: string): Promise { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return; + } + + if (typeof document === 'undefined') { + return; + } + + const selection = window.getSelection(); + const range = document.createRange(); + const tempNode = document.createElement('span'); + tempNode.textContent = text; + tempNode.style.position = 'fixed'; + tempNode.style.opacity = '0'; + document.body.append(tempNode); + + range.selectNodeContents(tempNode); + selection?.removeAllRanges(); + selection?.addRange(range); + document.execCommand?.('copy'); + selection?.removeAllRanges(); + tempNode.remove(); +} + +/** + * The ClipboardText component displays text in an input-like field and + * provides a built-in action to copy the current value. + */ +export function ClipboardText({ + value = '', + text, + copyLabel, + copiedLabel = 'Copied', + onCopied, + optionalLabel, + invalid, + hasWarning, + readOnly, + label, + hideLabel, + id: customId, + className, + style, + inputClassName, +}: ClipboardTextProps) { + const id = useId(); + const fieldId = customId || id; + const labelId = useId(); + const [copiedCount, setCopiedCount] = useState(0); + const displayText = text ?? value; + const buttonLabel = `${copyLabel}: ${label}`; + const isCopied = copiedCount > 0; + + if ( + process.env.NODE_ENV !== 'production' && + process.env.NODE_ENV !== 'test' && + !isSufficientlyLabelled(label) + ) { + throw new AccessibilityError( + 'ClipboardText', + 'The `label` prop is missing or invalid. Pass `hideLabel` if you intend to hide the label visually.', + ); + } + + const handleCopy = async (event: ClickEvent) => { + try { + await copyToClipboard(value); + setCopiedCount((count) => count + 1); + onCopied?.(event); + } catch { + // Ignore clipboard failures so the UI does not enter a false success state. + } + }; + + useEffect(() => { + if (!copiedCount) { + return undefined; + } + + const timeout = setTimeout(() => { + setCopiedCount(0); + }, 3000); + + return () => { + clearTimeout(timeout); + }; + }, [copiedCount]); + + return ( + +
+ +
+
+
+ {`${label}: `} + {displayText} +
+
+ + {copyLabel} + +
+
+ + {isCopied ? copiedLabel : ''} + +
+ ); +} diff --git a/packages/circuit-ui/components/ClipboardText/index.ts b/packages/circuit-ui/components/ClipboardText/index.ts new file mode 100644 index 0000000000..7e05189511 --- /dev/null +++ b/packages/circuit-ui/components/ClipboardText/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 { ClipboardText } from './ClipboardText.js'; + +export type { ClipboardTextProps } from './ClipboardText.js'; diff --git a/packages/circuit-ui/index.ts b/packages/circuit-ui/index.ts index 62c24fc042..069275c280 100644 --- a/packages/circuit-ui/index.ts +++ b/packages/circuit-ui/index.ts @@ -44,6 +44,8 @@ export { CheckboxGroup } from './components/CheckboxGroup/index.js'; export type { CheckboxGroupProps } from './components/CheckboxGroup/index.js'; export { Input } from './components/Input/index.js'; export type { InputProps, InputElement } from './components/Input/index.js'; +export { ClipboardText } from './components/ClipboardText/index.js'; +export type { ClipboardTextProps } from './components/ClipboardText/index.js'; export { RadioButtonGroup } from './components/RadioButtonGroup/index.js'; export type { RadioButtonGroupProps } from './components/RadioButtonGroup/index.js'; export { SearchInput } from './components/SearchInput/index.js'; diff --git a/skills/circuit-ui/references/components.md b/skills/circuit-ui/references/components.md index e76c6950dd..c5ef59075f 100644 --- a/skills/circuit-ui/references/components.md +++ b/skills/circuit-ui/references/components.md @@ -25,6 +25,7 @@ | `Calendar` | `stable` | `@sumup-oss/circuit-ui` | `./components/Calendar/index.js` | [Read MDX reference](components/Calendar.mdx) | | `Checkbox` | `stable` | `@sumup-oss/circuit-ui` | `./components/Checkbox/index.js` | [Read MDX reference](components/Checkbox.mdx) | | `CheckboxGroup` | `stable` | `@sumup-oss/circuit-ui` | `./components/CheckboxGroup/index.js` | [Read MDX reference](components/CheckboxGroup.mdx) | +| `ClipboardText` | `experimental` | `@sumup-oss/circuit-ui` | `./components/ClipboardText/index.js` | [Read MDX reference](components/ClipboardText.mdx) | | `ColorInput` | `stable` | `@sumup-oss/circuit-ui` | `./components/ColorInput/index.js` | [Read MDX reference](components/ColorInput.mdx) | | `CurrencyInput` | `stable` | `@sumup-oss/circuit-ui` | `./components/CurrencyInput/index.js` | [Read MDX reference](components/CurrencyInput.mdx) | | `DateInput` | `stable` | `@sumup-oss/circuit-ui` | `./components/DateInput/index.js` | [Read MDX reference](components/DateInput.mdx) | diff --git a/skills/circuit-ui/references/components/ClipboardText.mdx b/skills/circuit-ui/references/components/ClipboardText.mdx new file mode 100644 index 0000000000..e1ef22e82a --- /dev/null +++ b/skills/circuit-ui/references/components/ClipboardText.mdx @@ -0,0 +1,29 @@ +import { Meta, Status, Props, Story } from '../../../../.storybook/components'; +import * as Stories from './ClipboardText.stories'; + + + +# ClipboardText + + + +ClipboardText displays a value in an input-like field and provides an inline button to copy it to a clipboard. + + + + +## Usage + +Use ClipboardText when a value should be easy to copy but not directly editable, such as API tokens, webhook secrets, or reference IDs. + +The `value` prop is always the copied value. If you need to display a different string, such as a masked token, pass it through the `text` prop while keeping the original `value` intact. + + + +## Content + +Keep the visible label clear and specific so users understand what will be copied. Use concise button copy such as `"Copy token"` or `"Copy secret"` to make the action predictable for assistive technology and sighted users alike. + +For longer strings, ClipboardText truncates the visible text while preserving the full copied value. + + From 2acd3fcdef7568ce3c4040e8db50f7b0af39b12c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Dzivjak?= Date: Tue, 19 May 2026 16:12:20 +0200 Subject: [PATCH 2/6] chore: address code review comments --- .changeset/green-snakes-sit.md | 2 +- .../ClipboardText/ClipboardText.mdx | 29 --- .../ClipboardText/ClipboardText.module.css | 53 ----- .../ClipboardText/ClipboardText.spec.tsx | 188 --------------- .../ClipboardText/ClipboardText.stories.tsx | 65 ----- .../ClipboardText/ClipboardText.tsx | 206 ---------------- .../components/CopyButton/CopyButton.mdx | 37 +++ .../components/CopyButton/CopyButton.spec.tsx | 200 ++++++++++++++++ .../CopyButton/CopyButton.stories.tsx | 140 +++++++++++ .../components/CopyButton/CopyButton.tsx | 224 ++++++++++++++++++ .../{ClipboardText => CopyButton}/index.ts | 4 +- packages/circuit-ui/index.ts | 4 +- skills/circuit-ui/references/components.md | 2 +- .../references/components/ClipboardText.mdx | 29 --- .../references/components/CopyButton.mdx | 37 +++ 15 files changed, 644 insertions(+), 576 deletions(-) delete mode 100644 packages/circuit-ui/components/ClipboardText/ClipboardText.mdx delete mode 100644 packages/circuit-ui/components/ClipboardText/ClipboardText.module.css delete mode 100644 packages/circuit-ui/components/ClipboardText/ClipboardText.spec.tsx delete mode 100644 packages/circuit-ui/components/ClipboardText/ClipboardText.stories.tsx delete mode 100644 packages/circuit-ui/components/ClipboardText/ClipboardText.tsx create mode 100644 packages/circuit-ui/components/CopyButton/CopyButton.mdx create mode 100644 packages/circuit-ui/components/CopyButton/CopyButton.spec.tsx create mode 100644 packages/circuit-ui/components/CopyButton/CopyButton.stories.tsx create mode 100644 packages/circuit-ui/components/CopyButton/CopyButton.tsx rename packages/circuit-ui/components/{ClipboardText => CopyButton}/index.ts (83%) delete mode 100644 skills/circuit-ui/references/components/ClipboardText.mdx create mode 100644 skills/circuit-ui/references/components/CopyButton.mdx diff --git a/.changeset/green-snakes-sit.md b/.changeset/green-snakes-sit.md index f34e26833a..eb5f2ae70b 100644 --- a/.changeset/green-snakes-sit.md +++ b/.changeset/green-snakes-sit.md @@ -2,4 +2,4 @@ '@sumup-oss/circuit-ui': minor --- -Added the `ClipboardText` component. +Added the `CopyButton` component. diff --git a/packages/circuit-ui/components/ClipboardText/ClipboardText.mdx b/packages/circuit-ui/components/ClipboardText/ClipboardText.mdx deleted file mode 100644 index e1ef22e82a..0000000000 --- a/packages/circuit-ui/components/ClipboardText/ClipboardText.mdx +++ /dev/null @@ -1,29 +0,0 @@ -import { Meta, Status, Props, Story } from '../../../../.storybook/components'; -import * as Stories from './ClipboardText.stories'; - - - -# ClipboardText - - - -ClipboardText displays a value in an input-like field and provides an inline button to copy it to a clipboard. - - - - -## Usage - -Use ClipboardText when a value should be easy to copy but not directly editable, such as API tokens, webhook secrets, or reference IDs. - -The `value` prop is always the copied value. If you need to display a different string, such as a masked token, pass it through the `text` prop while keeping the original `value` intact. - - - -## Content - -Keep the visible label clear and specific so users understand what will be copied. Use concise button copy such as `"Copy token"` or `"Copy secret"` to make the action predictable for assistive technology and sighted users alike. - -For longer strings, ClipboardText truncates the visible text while preserving the full copied value. - - diff --git a/packages/circuit-ui/components/ClipboardText/ClipboardText.module.css b/packages/circuit-ui/components/ClipboardText/ClipboardText.module.css deleted file mode 100644 index 42fcfdb5e5..0000000000 --- a/packages/circuit-ui/components/ClipboardText/ClipboardText.module.css +++ /dev/null @@ -1,53 +0,0 @@ -.base { - display: flex; - align-items: center; - padding: 0; -} - -.content { - display: flex; - flex: 1; - align-items: center; - min-width: 0; - padding: var(--cui-spacings-kilo) var(--cui-spacings-mega); -} - -.value { - display: block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.action { - position: relative; - display: flex; - flex-shrink: 0; - align-items: center; - align-self: stretch; - justify-content: center; - width: calc( - var(--cui-body-m-line-height) + - var(--cui-spacings-kilo) * - 2 + - var(--cui-border-width-kilo) * - 2 - ); - min-width: calc( - var(--cui-body-m-line-height) + - var(--cui-spacings-kilo) * - 2 + - var(--cui-border-width-kilo) * - 2 - ); - border-left: var(--cui-border-width-kilo) solid var(--cui-border-normal); -} - -.button { - display: flex; - flex-shrink: 0; - justify-content: center; - width: 100%; - min-width: 0; - height: 100%; -} diff --git a/packages/circuit-ui/components/ClipboardText/ClipboardText.spec.tsx b/packages/circuit-ui/components/ClipboardText/ClipboardText.spec.tsx deleted file mode 100644 index a10395737f..0000000000 --- a/packages/circuit-ui/components/ClipboardText/ClipboardText.spec.tsx +++ /dev/null @@ -1,188 +0,0 @@ -/** - * 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 { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; - -import { act, axe, fireEvent, render, screen } from '../../util/test-utils.js'; - -import { ClipboardText } from './ClipboardText.js'; - -const defaultProps = { - label: 'API token', - value: 'secret-token', - copyLabel: 'Copy value', -}; - -describe('ClipboardText', () => { - beforeEach(() => { - Object.defineProperty(navigator, 'clipboard', { - configurable: true, - value: { writeText: vi.fn().mockResolvedValue(undefined) }, - }); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it('should render the value without using an input element', () => { - render(); - - expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); - expect( - screen.getByRole('button', { name: 'Copy value: API token' }), - ).toBeVisible(); - expect(screen.getByText(defaultProps.value)).toBeVisible(); - }); - - it('should expose the display text with label context', () => { - render(); - - expect(screen.getByText('API token:').className).toContain('hide-visually'); - expect(screen.getByText(defaultProps.value)).toBeVisible(); - }); - - it('should render text instead of value when provided', () => { - render(); - - expect(screen.getByText('••••••••••••token')).toBeVisible(); - expect(screen.queryByText(defaultProps.value)).not.toBeInTheDocument(); - }); - - it('should copy the current value when the button is clicked', async () => { - vi.useFakeTimers(); - const onCopied = vi.fn(); - - render( - , - ); - - fireEvent.click( - screen.getByRole('button', { name: 'Copy value: API token' }), - ); - - await act(async () => { - await Promise.resolve(); - }); - - expect(onCopied).toHaveBeenCalledTimes(1); - expect( - screen.getByRole('button', { name: 'Copy value: API token' }), - ).toBeVisible(); - expect(screen.getByRole('status')).toHaveTextContent('Copied value'); - - await act(async () => { - await vi.advanceTimersByTimeAsync(2000); - }); - - fireEvent.click( - screen.getByRole('button', { name: 'Copy value: API token' }), - ); - - await act(async () => { - await Promise.resolve(); - }); - - expect(onCopied).toHaveBeenCalledTimes(2); - expect(screen.getByRole('status')).toHaveTextContent('Copied value'); - - await act(async () => { - await vi.advanceTimersByTimeAsync(2000); - }); - - expect( - screen.getByRole('button', { name: 'Copy value: API token' }), - ).toBeVisible(); - expect(screen.getByRole('status')).toHaveTextContent('Copied value'); - - await act(async () => { - await vi.advanceTimersByTimeAsync(1000); - }); - - expect(screen.getByText('', { selector: 'output' })).toBeEmptyDOMElement(); - }); - - it('should fall back to execCommand when navigator.clipboard is unavailable', async () => { - const execCommand = vi.fn(); - const selection = { - addRange: vi.fn(), - removeAllRanges: vi.fn(), - }; - - Object.defineProperty(navigator, 'clipboard', { - configurable: true, - value: undefined, - }); - Object.defineProperty(document, 'execCommand', { - configurable: true, - value: execCommand, - }); - Object.defineProperty(window, 'getSelection', { - configurable: true, - value: vi.fn(() => selection), - }); - - render(); - - fireEvent.click( - screen.getByRole('button', { name: 'Copy value: API token' }), - ); - - await act(async () => { - await Promise.resolve(); - }); - - expect(execCommand).toHaveBeenCalledWith('copy'); - expect(selection.removeAllRanges).toHaveBeenCalledTimes(2); - expect(selection.addRange).toHaveBeenCalledTimes(1); - expect(screen.getByRole('status')).toHaveTextContent('Copied value'); - }); - - it('should not announce copied state when clipboard write fails', async () => { - const onCopied = vi.fn(); - - Object.defineProperty(navigator, 'clipboard', { - configurable: true, - value: { - writeText: vi.fn().mockRejectedValue(new Error('clipboard denied')), - }, - }); - - render(); - - fireEvent.click( - screen.getByRole('button', { name: 'Copy value: API token' }), - ); - - await act(async () => { - await Promise.resolve(); - }); - - expect(onCopied).not.toHaveBeenCalled(); - expect(screen.getByText('', { selector: 'output' })).toBeEmptyDOMElement(); - }); - - it('should have no accessibility violations', async () => { - const { container } = render(); - - const actual = await axe(container); - - expect(actual).toHaveNoViolations(); - }); -}); diff --git a/packages/circuit-ui/components/ClipboardText/ClipboardText.stories.tsx b/packages/circuit-ui/components/ClipboardText/ClipboardText.stories.tsx deleted file mode 100644 index 13a8c16a2e..0000000000 --- a/packages/circuit-ui/components/ClipboardText/ClipboardText.stories.tsx +++ /dev/null @@ -1,65 +0,0 @@ -/** - * 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 { ClipboardText, type ClipboardTextProps } from './ClipboardText.js'; - -export default { - title: 'Forms/ClipboardText', - component: ClipboardText, - tags: ['status:experimental'], - argTypes: { - copyLabel: { control: 'text' }, - readOnly: { control: 'boolean' }, - copiedLabel: { control: 'text' }, - text: { control: 'text' }, - }, - parameters: { - docs: { - description: { - component: - 'ClipboardText displays a value in an input-like field and provides a built-in copy action.', - }, - }, - }, -}; - -export const Base = (args: ClipboardTextProps) => ; - -Base.args = { - label: 'API token', - value: 'sk_live_1234567890', - copyLabel: 'Copy token', -}; - -export const MaskedValue = (args: ClipboardTextProps) => ( - -); - -MaskedValue.args = { - label: 'API token', - value: 'sk_live_1234567890', - text: 'sk_live_******', - copyLabel: 'Copy token', -}; - -export const LongValue = (args: ClipboardTextProps) => ( - -); - -LongValue.args = { - label: 'Webhook secret', - value: 'whsec_4VbX8i2LwY7nQp3Rk5Tm9Uc1Fd6Hs0Za', - copyLabel: 'Copy secret', -}; diff --git a/packages/circuit-ui/components/ClipboardText/ClipboardText.tsx b/packages/circuit-ui/components/ClipboardText/ClipboardText.tsx deleted file mode 100644 index 6c8d5480c2..0000000000 --- a/packages/circuit-ui/components/ClipboardText/ClipboardText.tsx +++ /dev/null @@ -1,206 +0,0 @@ -/** - * 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 { Checkmark, Copy } from '@sumup-oss/icons'; -import { useEffect, useId, useState, type CSSProperties } from 'react'; - -import { FieldWrapper, FieldLabelText } from '../Field/index.js'; -import fieldClasses from '../Field/Field.module.css'; -import { IconButton } from '../Button/index.js'; -import { utilClasses } from '../../styles/utility.js'; -import { - classes as inputClasses, - type BaseInputProps, -} from '../Input/index.js'; -import { clsx } from '../../styles/clsx.js'; -import type { ClickEvent } from '../../types/events.js'; -import { - AccessibilityError, - isSufficientlyLabelled, -} from '../../util/errors.js'; - -import classes from './ClipboardText.module.css'; - -export interface ClipboardTextProps - extends Omit { - /** - * The text value copied to the clipboard. - */ - value?: string; - /** - * Optional text rendered inside the field instead of the copied value. - */ - text?: string; - /** - * Text label for the copy button. - */ - copyLabel: string; - /** - * Callback function when the value was copied. - */ - onCopied?: (event: ClickEvent) => void; - /** - * Accessible label and status message after the value has been copied. - * - * @default Copied - */ - copiedLabel?: string; - /** - * Additional class name for the component wrapper. - */ - className?: string; - /** - * Inline styles for the component wrapper. - */ - style?: CSSProperties; -} - -async function copyToClipboard(text: string): Promise { - if (navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(text); - return; - } - - if (typeof document === 'undefined') { - return; - } - - const selection = window.getSelection(); - const range = document.createRange(); - const tempNode = document.createElement('span'); - tempNode.textContent = text; - tempNode.style.position = 'fixed'; - tempNode.style.opacity = '0'; - document.body.append(tempNode); - - range.selectNodeContents(tempNode); - selection?.removeAllRanges(); - selection?.addRange(range); - document.execCommand?.('copy'); - selection?.removeAllRanges(); - tempNode.remove(); -} - -/** - * The ClipboardText component displays text in an input-like field and - * provides a built-in action to copy the current value. - */ -export function ClipboardText({ - value = '', - text, - copyLabel, - copiedLabel = 'Copied', - onCopied, - optionalLabel, - invalid, - hasWarning, - readOnly, - label, - hideLabel, - id: customId, - className, - style, - inputClassName, -}: ClipboardTextProps) { - const id = useId(); - const fieldId = customId || id; - const labelId = useId(); - const [copiedCount, setCopiedCount] = useState(0); - const displayText = text ?? value; - const buttonLabel = `${copyLabel}: ${label}`; - const isCopied = copiedCount > 0; - - if ( - process.env.NODE_ENV !== 'production' && - process.env.NODE_ENV !== 'test' && - !isSufficientlyLabelled(label) - ) { - throw new AccessibilityError( - 'ClipboardText', - 'The `label` prop is missing or invalid. Pass `hideLabel` if you intend to hide the label visually.', - ); - } - - const handleCopy = async (event: ClickEvent) => { - try { - await copyToClipboard(value); - setCopiedCount((count) => count + 1); - onCopied?.(event); - } catch { - // Ignore clipboard failures so the UI does not enter a false success state. - } - }; - - useEffect(() => { - if (!copiedCount) { - return undefined; - } - - const timeout = setTimeout(() => { - setCopiedCount(0); - }, 3000); - - return () => { - clearTimeout(timeout); - }; - }, [copiedCount]); - - return ( - -
- -
-
-
- {`${label}: `} - {displayText} -
-
- - {copyLabel} - -
-
- - {isCopied ? copiedLabel : ''} - -
- ); -} diff --git a/packages/circuit-ui/components/CopyButton/CopyButton.mdx b/packages/circuit-ui/components/CopyButton/CopyButton.mdx new file mode 100644 index 0000000000..02265c9dbb --- /dev/null +++ b/packages/circuit-ui/components/CopyButton/CopyButton.mdx @@ -0,0 +1,37 @@ +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. + + + + +## Usage + +Use CopyButton when copying is the primary action, such as copying API tokens, webhook secrets, reference IDs, or shareable links. + +Wrap your application in `ToastProvider` to display the success feedback toast after a copy action. + +## Variants + +Use the input variant when users need to inspect the copied value before copying it. + + + +Use the full button or icon-only button when the value itself does not need to stay visible in the UI. + + + +## 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 `copiedBody` when the success message should refer to a specific object. + +For longer strings, the input variant truncates the visible text 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..336a958023 --- /dev/null +++ b/packages/circuit-ui/components/CopyButton/CopyButton.spec.tsx @@ -0,0 +1,200 @@ +/** + * 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 { act, axe, fireEvent, render, screen } 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', + onCopyLabel: 'Copied to clipboard.', +}; + +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: API 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(); + }); + + it('should show a notification toast when the input variant copies successfully', async () => { + const onCopy = vi.fn(); + + renderWithToastProvider( + , + ); + + fireEvent.click( + screen.getByRole('button', { name: 'Copy token: API token' }), + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(onCopy).toHaveBeenCalledTimes(1); + expect(await screen.findByText('Token copied')).toBeInTheDocument(); + }); + + it('should render the button variant and show feedback after copying', async () => { + renderWithToastProvider( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Copy token' })); + + await act(async () => { + await Promise.resolve(); + }); + + expect(await screen.findByRole('status')).toHaveTextContent( + 'Copied to clipboard.', + ); + }); + + it('should render the icon variant', () => { + render( + , + ); + + expect(screen.getByRole('button', { name: 'Copy token' })).toBeVisible(); + }); + + it('should fall back to execCommand when navigator.clipboard is unavailable', async () => { + const execCommand = vi.fn(); + const selection = { + addRange: vi.fn(), + removeAllRanges: vi.fn(), + }; + + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: undefined, + }); + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: execCommand, + }); + Object.defineProperty(window, 'getSelection', { + configurable: true, + value: vi.fn(() => selection), + }); + + renderWithToastProvider( + , + ); + + fireEvent.click( + screen.getByRole('button', { name: 'Copy token: API token' }), + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(execCommand).toHaveBeenCalledWith('copy'); + expect(selection.removeAllRanges).toHaveBeenCalledTimes(2); + expect(selection.addRange).toHaveBeenCalledTimes(1); + expect(await screen.findByText('Token copied')).toBeInTheDocument(); + }); + + 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(); + + fireEvent.click( + screen.getByRole('button', { name: 'Copy token: API token' }), + ); + + await act(async () => { + await Promise.resolve(); + }); + + 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: API token' }), + ).toHaveAttribute('aria-disabled', 'true'); + }); + + it('should have no accessibility violations', async () => { + const { container } = renderWithToastProvider( + , + ); + + const actual = await axe(container); + + expect(actual).toHaveNoViolations(); + }); +}); 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..107f568e30 --- /dev/null +++ b/packages/circuit-ui/components/CopyButton/CopyButton.stories.tsx @@ -0,0 +1,140 @@ +/** + * 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: { + onCopyLabel: { control: 'text' }, + copyLabel: { control: 'text' }, + copyVariant: { + options: ['input', 'button', 'icon'], + control: { type: 'select' }, + }, + disabled: { control: 'boolean' }, + hideLabel: { control: 'boolean' }, + text: { control: 'text' }, + }, + 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 token', + onCopyLabel: 'Token copied', +} satisfies CopyButtonProps; + +export const Input = (args: CopyButtonProps) => ; + +Input.args = inputArgs; + +export const MaskedInput = (args: CopyButtonProps) => ; + +MaskedInput.args = { + ...inputArgs, + text: 'sk_live_******', +}; + +export const LongValue = (args: CopyButtonProps) => ; + +LongValue.args = { + label: 'Webhook secret', + value: 'whsec_4VbX8i2LwY7nQp3Rk5Tm9Uc1Fd6Hs0Za', + copyLabel: 'Copy secret', + onCopyLabel: 'Secret copied', +} satisfies CopyButtonProps; + +export const FullButton = (args: CopyButtonProps) => ; + +FullButton.args = { + copyVariant: 'button', + value: 'ref_1234567890', + copyLabel: 'Copy reference', + onCopyLabel: 'Reference copied', +} satisfies CopyButtonProps; + +export const IconOnly = (args: CopyButtonProps) => ; + +IconOnly.args = { + copyVariant: 'icon', + value: 'ref_1234567890', + copyLabel: 'Copy reference', + onCopyLabel: 'Reference copied', +} satisfies CopyButtonProps; + +export const Disabled = (args: CopyButtonProps) => ; + +Disabled.args = { + ...inputArgs, + disabled: true, +}; + +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..2ecbb2cf40 --- /dev/null +++ b/packages/circuit-ui/components/CopyButton/CopyButton.tsx @@ -0,0 +1,224 @@ +/** + * 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 { CopyPaste } from '@sumup-oss/icons'; +import { useId } from 'react'; + +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'; + +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. + */ + onCopyLabel: string; +}; + +type InputCopyButtonProps = CommonCopyButtonProps & + Omit< + InputProps, + 'defaultValue' | 'onCopy' | 'renderSuffix' | 'showValid' | 'type' | 'value' + > & { + /** + * The CopyButton variant. + * + * @default input + */ + copyVariant?: 'input'; + /** + * Optional text rendered inside the field instead of the copied value. + */ + text?: string; + }; + +type ButtonCopyButtonProps = CommonCopyButtonProps & + Omit & { + /** + * The CopyButton variant. + */ + copyVariant: 'button'; + }; + +type IconCopyButtonProps = CommonCopyButtonProps & + Omit & { + /** + * The CopyButton variant. + */ + copyVariant: 'icon'; + }; + +export type CopyButtonProps = + | InputCopyButtonProps + | ButtonCopyButtonProps + | IconCopyButtonProps; + +async function copyToClipboard(text: string): Promise { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return; + } + + // Fallback behaviour for older browsers, + // see: https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand#browser_compatibility + if (typeof document === 'undefined') { + return; + } + + const selection = window.getSelection(); + const range = document.createRange(); + const tempNode = document.createElement('span'); + tempNode.textContent = text; + tempNode.style.position = 'fixed'; + tempNode.style.opacity = '0'; + document.body.append(tempNode); + + range.selectNodeContents(tempNode); + selection?.removeAllRanges(); + selection?.addRange(range); + document.execCommand?.('copy'); + selection?.removeAllRanges(); + tempNode.remove(); +} + +/** + * 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 generatedId = useId(); + const { setToast } = useNotificationToast(); + const { onCopyLabel, copyLabel, onCopy, value } = props; + const isCopyDisabled = Boolean(props.disabled || value.length === 0); + + const handleCopy = async (event: ClickEvent) => { + if (isCopyDisabled) { + return; + } + + try { + await copyToClipboard(value); + setToast({ + body: onCopyLabel, + iconLabel: '', + }); + onCopy?.(event); + } catch { + // Ignore clipboard failures so the UI does not enter a false success state. + } + }; + + if (props.copyVariant === 'button') { + const { + copyVariant, + onCopyLabel: _onCopyLabel, + copyLabel: _copyLabel, + onCopy: _onCopy, + value: _value, + ...buttonProps + } = props; + + return ( + + ); + } + + if (props.copyVariant === 'icon') { + const { + copyVariant, + onCopyLabel: _onCopyLabel, + copyLabel: _copyLabel, + onCopy: _onCopy, + value: _value, + ...iconButtonProps + } = props; + + return ( + + {copyLabel} + + ); + } + + const { + copyVariant, + onCopyLabel: _onCopyLabel, + copyLabel: _copyLabel, + id: customId, + inputClassName, + onCopy: _onCopy, + text, + value: _value, + ...inputProps + } = props; + const fieldId = customId || generatedId; + const displayText = text ?? value; + const buttonLabel = `${copyLabel}: ${props.label}`; + + return ( + ( + + {copyLabel} + + )} + inputClassName={inputClassName} + /> + ); +} diff --git a/packages/circuit-ui/components/ClipboardText/index.ts b/packages/circuit-ui/components/CopyButton/index.ts similarity index 83% rename from packages/circuit-ui/components/ClipboardText/index.ts rename to packages/circuit-ui/components/CopyButton/index.ts index 7e05189511..c7a08bf053 100644 --- a/packages/circuit-ui/components/ClipboardText/index.ts +++ b/packages/circuit-ui/components/CopyButton/index.ts @@ -13,6 +13,6 @@ * limitations under the License. */ -export { ClipboardText } from './ClipboardText.js'; +export { CopyButton } from './CopyButton.js'; -export type { ClipboardTextProps } from './ClipboardText.js'; +export type { CopyButtonProps } from './CopyButton.js'; diff --git a/packages/circuit-ui/index.ts b/packages/circuit-ui/index.ts index 069275c280..9688eef283 100644 --- a/packages/circuit-ui/index.ts +++ b/packages/circuit-ui/index.ts @@ -44,8 +44,6 @@ export { CheckboxGroup } from './components/CheckboxGroup/index.js'; export type { CheckboxGroupProps } from './components/CheckboxGroup/index.js'; export { Input } from './components/Input/index.js'; export type { InputProps, InputElement } from './components/Input/index.js'; -export { ClipboardText } from './components/ClipboardText/index.js'; -export type { ClipboardTextProps } from './components/ClipboardText/index.js'; export { RadioButtonGroup } from './components/RadioButtonGroup/index.js'; export type { RadioButtonGroupProps } from './components/RadioButtonGroup/index.js'; export { SearchInput } from './components/SearchInput/index.js'; @@ -78,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 c5ef59075f..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) | @@ -25,7 +26,6 @@ | `Calendar` | `stable` | `@sumup-oss/circuit-ui` | `./components/Calendar/index.js` | [Read MDX reference](components/Calendar.mdx) | | `Checkbox` | `stable` | `@sumup-oss/circuit-ui` | `./components/Checkbox/index.js` | [Read MDX reference](components/Checkbox.mdx) | | `CheckboxGroup` | `stable` | `@sumup-oss/circuit-ui` | `./components/CheckboxGroup/index.js` | [Read MDX reference](components/CheckboxGroup.mdx) | -| `ClipboardText` | `experimental` | `@sumup-oss/circuit-ui` | `./components/ClipboardText/index.js` | [Read MDX reference](components/ClipboardText.mdx) | | `ColorInput` | `stable` | `@sumup-oss/circuit-ui` | `./components/ColorInput/index.js` | [Read MDX reference](components/ColorInput.mdx) | | `CurrencyInput` | `stable` | `@sumup-oss/circuit-ui` | `./components/CurrencyInput/index.js` | [Read MDX reference](components/CurrencyInput.mdx) | | `DateInput` | `stable` | `@sumup-oss/circuit-ui` | `./components/DateInput/index.js` | [Read MDX reference](components/DateInput.mdx) | diff --git a/skills/circuit-ui/references/components/ClipboardText.mdx b/skills/circuit-ui/references/components/ClipboardText.mdx deleted file mode 100644 index e1ef22e82a..0000000000 --- a/skills/circuit-ui/references/components/ClipboardText.mdx +++ /dev/null @@ -1,29 +0,0 @@ -import { Meta, Status, Props, Story } from '../../../../.storybook/components'; -import * as Stories from './ClipboardText.stories'; - - - -# ClipboardText - - - -ClipboardText displays a value in an input-like field and provides an inline button to copy it to a clipboard. - - - - -## Usage - -Use ClipboardText when a value should be easy to copy but not directly editable, such as API tokens, webhook secrets, or reference IDs. - -The `value` prop is always the copied value. If you need to display a different string, such as a masked token, pass it through the `text` prop while keeping the original `value` intact. - - - -## Content - -Keep the visible label clear and specific so users understand what will be copied. Use concise button copy such as `"Copy token"` or `"Copy secret"` to make the action predictable for assistive technology and sighted users alike. - -For longer strings, ClipboardText truncates the visible text while preserving the full copied value. - - diff --git a/skills/circuit-ui/references/components/CopyButton.mdx b/skills/circuit-ui/references/components/CopyButton.mdx new file mode 100644 index 0000000000..02265c9dbb --- /dev/null +++ b/skills/circuit-ui/references/components/CopyButton.mdx @@ -0,0 +1,37 @@ +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. + + + + +## Usage + +Use CopyButton when copying is the primary action, such as copying API tokens, webhook secrets, reference IDs, or shareable links. + +Wrap your application in `ToastProvider` to display the success feedback toast after a copy action. + +## Variants + +Use the input variant when users need to inspect the copied value before copying it. + + + +Use the full button or icon-only button when the value itself does not need to stay visible in the UI. + + + +## 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 `copiedBody` when the success message should refer to a specific object. + +For longer strings, the input variant truncates the visible text while preserving the full copied value. + + From e2bd56476dcee3f5f91146c6b9f66b55bc9583cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Dzivjak?= Date: Thu, 21 May 2026 15:33:15 +0200 Subject: [PATCH 3/6] chore: apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Connor Bär --- .changeset/green-snakes-sit.md | 2 +- packages/circuit-ui/components/CopyButton/CopyButton.spec.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/green-snakes-sit.md b/.changeset/green-snakes-sit.md index eb5f2ae70b..2e50c7cbcb 100644 --- a/.changeset/green-snakes-sit.md +++ b/.changeset/green-snakes-sit.md @@ -2,4 +2,4 @@ '@sumup-oss/circuit-ui': minor --- -Added the `CopyButton` component. +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.spec.tsx b/packages/circuit-ui/components/CopyButton/CopyButton.spec.tsx index 336a958023..7932c54890 100644 --- a/packages/circuit-ui/components/CopyButton/CopyButton.spec.tsx +++ b/packages/circuit-ui/components/CopyButton/CopyButton.spec.tsx @@ -94,7 +94,7 @@ describe('CopyButton', () => { />, ); - fireEvent.click(screen.getByRole('button', { name: 'Copy token' })); + await userEvent.click(screen.getByRole('button', { name: 'Copy token' })); await act(async () => { await Promise.resolve(); From 7b15690085c145631b8496a1c902a25ac800bc8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Dzivjak?= Date: Thu, 21 May 2026 15:55:25 +0200 Subject: [PATCH 4/6] chore: address first batch of code review --- .../components/CopyButton/CopyButton.spec.tsx | 99 +++++++++---------- .../CopyButton/CopyButton.stories.tsx | 20 ++-- .../components/CopyButton/CopyButton.tsx | 69 +++---------- 3 files changed, 72 insertions(+), 116 deletions(-) diff --git a/packages/circuit-ui/components/CopyButton/CopyButton.spec.tsx b/packages/circuit-ui/components/CopyButton/CopyButton.spec.tsx index 7932c54890..8d103e8126 100644 --- a/packages/circuit-ui/components/CopyButton/CopyButton.spec.tsx +++ b/packages/circuit-ui/components/CopyButton/CopyButton.spec.tsx @@ -15,7 +15,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { act, axe, fireEvent, render, screen } from '../../util/test-utils.js'; +import { + act, + axe, + fireEvent, + render, + screen, + userEvent, +} from '../../util/test-utils.js'; import { ToastProvider } from '../ToastContext/index.js'; import { CopyButton } from './CopyButton.js'; @@ -24,7 +31,7 @@ const defaultProps = { label: 'API token', value: 'secret-token', copyLabel: 'Copy token', - onCopyLabel: 'Copied to clipboard.', + successLabel: 'Copied to clipboard.', }; const renderWithToastProvider = (ui: React.ReactNode) => @@ -53,7 +60,7 @@ describe('CopyButton', () => { }); it('should render text instead of value in the input variant when provided', () => { - render(); + render(); expect(screen.getByDisplayValue('••••••••••••token')).toBeVisible(); expect( @@ -67,7 +74,7 @@ describe('CopyButton', () => { renderWithToastProvider( , ); @@ -90,7 +97,7 @@ describe('CopyButton', () => { copyVariant="button" value="secret-token" copyLabel="Copy token" - onCopyLabel="Copied to clipboard." + successLabel="Copied to clipboard." />, ); @@ -108,54 +115,16 @@ describe('CopyButton', () => { it('should render the icon variant', () => { render( , ); expect(screen.getByRole('button', { name: 'Copy token' })).toBeVisible(); }); - it('should fall back to execCommand when navigator.clipboard is unavailable', async () => { - const execCommand = vi.fn(); - const selection = { - addRange: vi.fn(), - removeAllRanges: vi.fn(), - }; - - Object.defineProperty(navigator, 'clipboard', { - configurable: true, - value: undefined, - }); - Object.defineProperty(document, 'execCommand', { - configurable: true, - value: execCommand, - }); - Object.defineProperty(window, 'getSelection', { - configurable: true, - value: vi.fn(() => selection), - }); - - renderWithToastProvider( - , - ); - - fireEvent.click( - screen.getByRole('button', { name: 'Copy token: API token' }), - ); - - await act(async () => { - await Promise.resolve(); - }); - - expect(execCommand).toHaveBeenCalledWith('copy'); - expect(selection.removeAllRanges).toHaveBeenCalledTimes(2); - expect(selection.addRange).toHaveBeenCalledTimes(1); - expect(await screen.findByText('Token copied')).toBeInTheDocument(); - }); - it('should not announce success when clipboard write fails', async () => { const onCopy = vi.fn(); @@ -181,20 +150,46 @@ describe('CopyButton', () => { }); it('should disable copying when the value is empty', () => { - render(); + render(); expect( screen.getByRole('button', { name: 'Copy token: API token' }), ).toHaveAttribute('aria-disabled', 'true'); }); - it('should have no accessibility violations', async () => { - const { container } = renderWithToastProvider( - , - ); + describe('as input', async () => { + it('should have no accessibility violations', async () => { + const { container } = renderWithToastProvider( + , + ); + + const actual = await axe(container); - const actual = await axe(container); + expect(actual).toHaveNoViolations(); + }); + }); - expect(actual).toHaveNoViolations(); + describe('as button', async () => { + it('should have no accessibility violations', async () => { + const { container } = renderWithToastProvider( + , + ); + + const actual = await axe(container); + + expect(actual).toHaveNoViolations(); + }); + }); + + describe('as icon button', async () => { + it('should have no accessibility violations', async () => { + const { container } = renderWithToastProvider( + , + ); + + const actual = await axe(container); + + expect(actual).toHaveNoViolations(); + }); }); }); diff --git a/packages/circuit-ui/components/CopyButton/CopyButton.stories.tsx b/packages/circuit-ui/components/CopyButton/CopyButton.stories.tsx index 107f568e30..bb9e71d8d7 100644 --- a/packages/circuit-ui/components/CopyButton/CopyButton.stories.tsx +++ b/packages/circuit-ui/components/CopyButton/CopyButton.stories.tsx @@ -30,10 +30,10 @@ export default { ), ], argTypes: { - onCopyLabel: { control: 'text' }, + successLabel: { control: 'text' }, copyLabel: { control: 'text' }, copyVariant: { - options: ['input', 'button', 'icon'], + options: ['input', 'button', 'icon-button'], control: { type: 'select' }, }, disabled: { control: 'boolean' }, @@ -54,7 +54,7 @@ const inputArgs = { label: 'API token', value: 'sk_live_1234567890', copyLabel: 'Copy token', - onCopyLabel: 'Token copied', + successLabel: 'Token copied', } satisfies CopyButtonProps; export const Input = (args: CopyButtonProps) => ; @@ -74,7 +74,7 @@ LongValue.args = { label: 'Webhook secret', value: 'whsec_4VbX8i2LwY7nQp3Rk5Tm9Uc1Fd6Hs0Za', copyLabel: 'Copy secret', - onCopyLabel: 'Secret copied', + successLabel: 'Secret copied', } satisfies CopyButtonProps; export const FullButton = (args: CopyButtonProps) => ; @@ -83,16 +83,16 @@ FullButton.args = { copyVariant: 'button', value: 'ref_1234567890', copyLabel: 'Copy reference', - onCopyLabel: 'Reference copied', + successLabel: 'Reference copied', } satisfies CopyButtonProps; export const IconOnly = (args: CopyButtonProps) => ; IconOnly.args = { - copyVariant: 'icon', + copyVariant: 'icon-button', value: 'ref_1234567890', copyLabel: 'Copy reference', - onCopyLabel: 'Reference copied', + successLabel: 'Reference copied', } satisfies CopyButtonProps; export const Disabled = (args: CopyButtonProps) => ; @@ -128,13 +128,13 @@ export const AllVariants = () => ( copyVariant="button" value="ref_1234567890" copyLabel="Copy reference" - onCopyLabel="Reference copied" + successLabel="Reference copied" /> ); diff --git a/packages/circuit-ui/components/CopyButton/CopyButton.tsx b/packages/circuit-ui/components/CopyButton/CopyButton.tsx index 2ecbb2cf40..19be3fe84c 100644 --- a/packages/circuit-ui/components/CopyButton/CopyButton.tsx +++ b/packages/circuit-ui/components/CopyButton/CopyButton.tsx @@ -16,7 +16,6 @@ 'use client'; import { CopyPaste } from '@sumup-oss/icons'; -import { useId } from 'react'; import { Button, IconButton } from '../Button/index.js'; import { Input, type InputProps } from '../Input/index.js'; @@ -40,7 +39,7 @@ type CommonCopyButtonProps = { /** * Test copy shown in as a notification after a successful copy action. */ - onCopyLabel: string; + successLabel: string; }; type InputCopyButtonProps = CommonCopyButtonProps & @@ -57,7 +56,7 @@ type InputCopyButtonProps = CommonCopyButtonProps & /** * Optional text rendered inside the field instead of the copied value. */ - text?: string; + visibleValue?: string; }; type ButtonCopyButtonProps = CommonCopyButtonProps & @@ -73,7 +72,7 @@ type IconCopyButtonProps = CommonCopyButtonProps & /** * The CopyButton variant. */ - copyVariant: 'icon'; + copyVariant: 'icon-button'; }; export type CopyButtonProps = @@ -81,54 +80,21 @@ export type CopyButtonProps = | ButtonCopyButtonProps | IconCopyButtonProps; -async function copyToClipboard(text: string): Promise { - if (navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(text); - return; - } - - // Fallback behaviour for older browsers, - // see: https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand#browser_compatibility - if (typeof document === 'undefined') { - return; - } - - const selection = window.getSelection(); - const range = document.createRange(); - const tempNode = document.createElement('span'); - tempNode.textContent = text; - tempNode.style.position = 'fixed'; - tempNode.style.opacity = '0'; - document.body.append(tempNode); - - range.selectNodeContents(tempNode); - selection?.removeAllRanges(); - selection?.addRange(range); - document.execCommand?.('copy'); - selection?.removeAllRanges(); - tempNode.remove(); -} - /** * 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 generatedId = useId(); const { setToast } = useNotificationToast(); - const { onCopyLabel, copyLabel, onCopy, value } = props; + const { successLabel, copyLabel, onCopy, value } = props; const isCopyDisabled = Boolean(props.disabled || value.length === 0); const handleCopy = async (event: ClickEvent) => { - if (isCopyDisabled) { - return; - } - try { - await copyToClipboard(value); + // eslint-disable-next-line compat/compat + await navigator.clipboard.writeText(value); setToast({ - body: onCopyLabel, - iconLabel: '', + body: successLabel, }); onCopy?.(event); } catch { @@ -139,7 +105,7 @@ export function CopyButton(props: CopyButtonProps) { if (props.copyVariant === 'button') { const { copyVariant, - onCopyLabel: _onCopyLabel, + successLabel: _successLabel, copyLabel: _copyLabel, onCopy: _onCopy, value: _value, @@ -159,10 +125,10 @@ export function CopyButton(props: CopyButtonProps) { ); } - if (props.copyVariant === 'icon') { + if (props.copyVariant === 'icon-button') { const { copyVariant, - onCopyLabel: _onCopyLabel, + successLabel: _successLabel, copyLabel: _copyLabel, onCopy: _onCopy, value: _value, @@ -184,23 +150,20 @@ export function CopyButton(props: CopyButtonProps) { const { copyVariant, - onCopyLabel: _onCopyLabel, + successLabel: _successLabel, copyLabel: _copyLabel, - id: customId, inputClassName, onCopy: _onCopy, - text, + visibleValue, value: _value, ...inputProps } = props; - const fieldId = customId || generatedId; - const displayText = text ?? value; + const displayText = visibleValue ?? value; const buttonLabel = `${copyLabel}: ${props.label}`; return ( ( @@ -208,14 +171,12 @@ export function CopyButton(props: CopyButtonProps) { className={renderProps.className} type="button" size="s" - variant="tertiary" + variant="secondary" disabled={isCopyDisabled} onClick={handleCopy} - aria-label={buttonLabel} - aria-controls={fieldId} icon={CopyPaste} > - {copyLabel} + {buttonLabel} )} inputClassName={inputClassName} From d949561ee7b8a2d1e35a9b5c1a49e5b2b4d1d3cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Dzivjak?= Date: Thu, 28 May 2026 18:13:40 +0200 Subject: [PATCH 5/6] feat: described by --- .../components/CopyButton/CopyButton.spec.tsx | 30 +++++++++++ .../components/CopyButton/CopyButton.tsx | 54 ++++++++++++------- 2 files changed, 66 insertions(+), 18 deletions(-) diff --git a/packages/circuit-ui/components/CopyButton/CopyButton.spec.tsx b/packages/circuit-ui/components/CopyButton/CopyButton.spec.tsx index 8d103e8126..b737726bb1 100644 --- a/packages/circuit-ui/components/CopyButton/CopyButton.spec.tsx +++ b/packages/circuit-ui/components/CopyButton/CopyButton.spec.tsx @@ -112,6 +112,21 @@ describe('CopyButton', () => { ); }); + it('should expose the copied value as a description for the button variant', () => { + render( + , + ); + + expect( + screen.getByRole('button', { name: 'Copy token' }), + ).toHaveAccessibleDescription('secret-token'); + }); + it('should render the icon variant', () => { render( { expect(screen.getByRole('button', { name: 'Copy token' })).toBeVisible(); }); + it('should expose the copied value as a description for the icon button variant', () => { + render( + , + ); + + expect( + screen.getByRole('button', { name: 'Copy token' }), + ).toHaveAccessibleDescription('secret-token'); + }); + it('should not announce success when clipboard write fails', async () => { const onCopy = vi.fn(); diff --git a/packages/circuit-ui/components/CopyButton/CopyButton.tsx b/packages/circuit-ui/components/CopyButton/CopyButton.tsx index 19be3fe84c..95c3ed8df7 100644 --- a/packages/circuit-ui/components/CopyButton/CopyButton.tsx +++ b/packages/circuit-ui/components/CopyButton/CopyButton.tsx @@ -15,6 +15,7 @@ 'use client'; +import { useId } from 'react'; import { CopyPaste } from '@sumup-oss/icons'; import { Button, IconButton } from '../Button/index.js'; @@ -22,6 +23,8 @@ 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 = { /** @@ -88,6 +91,7 @@ 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 { @@ -104,6 +108,7 @@ export function CopyButton(props: CopyButtonProps) { if (props.copyVariant === 'button') { const { + 'aria-describedby': ariaDescribedBy, copyVariant, successLabel: _successLabel, copyLabel: _copyLabel, @@ -113,20 +118,27 @@ export function CopyButton(props: CopyButtonProps) { } = props; return ( - + <> + + + {value} + + ); } if (props.copyVariant === 'icon-button') { const { + 'aria-describedby': ariaDescribedBy, copyVariant, successLabel: _successLabel, copyLabel: _copyLabel, @@ -136,15 +148,21 @@ export function CopyButton(props: CopyButtonProps) { } = props; return ( - - {copyLabel} - + <> + + {copyLabel} + + + {value} + + ); } From 0f0ad3dc84ca713c3ff961584a7bf2080ecd45fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Dzivjak?= Date: Fri, 3 Jul 2026 16:12:43 +0200 Subject: [PATCH 6/6] chore: address code review comments --- .../components/CopyButton/CopyButton.mdx | 24 ++- .../components/CopyButton/CopyButton.spec.tsx | 204 +++++++----------- .../CopyButton/CopyButton.stories.tsx | 133 +++++++++--- .../components/CopyButton/CopyButton.tsx | 28 ++- .../references/components/CopyButton.mdx | 24 ++- 5 files changed, 231 insertions(+), 182 deletions(-) diff --git a/packages/circuit-ui/components/CopyButton/CopyButton.mdx b/packages/circuit-ui/components/CopyButton/CopyButton.mdx index 02265c9dbb..a8c2d2786f 100644 --- a/packages/circuit-ui/components/CopyButton/CopyButton.mdx +++ b/packages/circuit-ui/components/CopyButton/CopyButton.mdx @@ -9,29 +9,33 @@ import * as Stories from './CopyButton.stories'; CopyButton copies a value to the clipboard and can render as a read-only input, a full button, or an icon button. - + -## Usage +## When to Use Use CopyButton when copying is the primary action, such as copying API tokens, webhook secrets, reference IDs, or shareable links. -Wrap your application in `ToastProvider` to display the success feedback toast after a copy action. +Use the input variant when users need to inspect the copied value before copying it. -## Variants +Use the button or icon button variants when the value itself does not need to stay visible in the UI. -Use the input variant when users need to inspect the copied value before copying it. +## How to Use + +Wrap your application in `ToastProvider` to display a feedback toast after a successful copy action. -Use the full button or icon-only button when the value itself does not need to stay visible in the UI. +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 `copiedBody` when the success message should refer to a specific object. +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 while preserving the full copied value. - - +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 index b737726bb1..fe17bb5824 100644 --- a/packages/circuit-ui/components/CopyButton/CopyButton.spec.tsx +++ b/packages/circuit-ui/components/CopyButton/CopyButton.spec.tsx @@ -16,12 +16,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { - act, axe, - fireEvent, render, screen, userEvent, + waitFor, } from '../../util/test-utils.js'; import { ToastProvider } from '../ToastContext/index.js'; @@ -34,6 +33,49 @@ const defaultProps = { 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}); @@ -53,9 +95,7 @@ describe('CopyButton', () => { render(); expect(screen.getByRole('textbox')).toHaveAttribute('readonly'); - expect( - screen.getByRole('button', { name: 'Copy token: API token' }), - ).toBeVisible(); + expect(screen.getByRole('button', { name: 'Copy token' })).toBeVisible(); expect(screen.getByDisplayValue(defaultProps.value)).toBeVisible(); }); @@ -68,91 +108,42 @@ describe('CopyButton', () => { ).not.toBeInTheDocument(); }); - it('should show a notification toast when the input variant copies successfully', async () => { - const onCopy = vi.fn(); + describe.each(variants)('as $name', ({ props, buttonName }) => { + it('should show a notification toast when copying succeeds', async () => { + const onCopy = vi.fn(); - renderWithToastProvider( - , - ); + renderWithToastProvider( + , + ); - fireEvent.click( - screen.getByRole('button', { name: 'Copy token: API token' }), - ); + await userEvent.click(screen.getByRole('button', { name: buttonName })); - await act(async () => { - await Promise.resolve(); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(props.value); + expect(onCopy).toHaveBeenCalledTimes(1); + expect(await screen.findByText('Token copied')).toBeInTheDocument(); }); - expect(onCopy).toHaveBeenCalledTimes(1); - expect(await screen.findByText('Token copied')).toBeInTheDocument(); - }); - - it('should render the button variant and show feedback after copying', async () => { - renderWithToastProvider( - , - ); + it('should have no accessibility violations', async () => { + const { container } = renderWithToastProvider(); - await userEvent.click(screen.getByRole('button', { name: 'Copy token' })); + const actual = await axe(container); - await act(async () => { - await Promise.resolve(); + expect(actual).toHaveNoViolations(); }); - - expect(await screen.findByRole('status')).toHaveTextContent( - 'Copied to clipboard.', - ); }); - it('should expose the copied value as a description for the button variant', () => { - render( - , - ); - - expect( - screen.getByRole('button', { name: 'Copy token' }), - ).toHaveAccessibleDescription('secret-token'); - }); - - it('should render the icon variant', () => { - render( - , - ); - - expect(screen.getByRole('button', { name: 'Copy token' })).toBeVisible(); - }); - - it('should expose the copied value as a description for the icon button variant', () => { - render( - , - ); - - expect( - screen.getByRole('button', { name: 'Copy token' }), - ).toHaveAccessibleDescription('secret-token'); + 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 () => { @@ -167,13 +158,11 @@ describe('CopyButton', () => { renderWithToastProvider(); - fireEvent.click( - screen.getByRole('button', { name: 'Copy token: API token' }), - ); + await userEvent.click(screen.getByRole('button', { name: 'Copy token' })); - await act(async () => { - await Promise.resolve(); - }); + await waitFor(() => + expect(navigator.clipboard.writeText).toHaveBeenCalledTimes(1), + ); expect(onCopy).not.toHaveBeenCalled(); expect(screen.queryByText('Copied to clipboard.')).not.toBeInTheDocument(); @@ -182,44 +171,9 @@ describe('CopyButton', () => { it('should disable copying when the value is empty', () => { render(); - expect( - screen.getByRole('button', { name: 'Copy token: API token' }), - ).toHaveAttribute('aria-disabled', 'true'); - }); - - describe('as input', async () => { - it('should have no accessibility violations', async () => { - const { container } = renderWithToastProvider( - , - ); - - const actual = await axe(container); - - expect(actual).toHaveNoViolations(); - }); - }); - - describe('as button', async () => { - it('should have no accessibility violations', async () => { - const { container } = renderWithToastProvider( - , - ); - - const actual = await axe(container); - - expect(actual).toHaveNoViolations(); - }); - }); - - describe('as icon button', async () => { - it('should have no accessibility violations', async () => { - const { container } = renderWithToastProvider( - , - ); - - const actual = await axe(container); - - expect(actual).toHaveNoViolations(); - }); + 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 index bb9e71d8d7..36255824d8 100644 --- a/packages/circuit-ui/components/CopyButton/CopyButton.stories.tsx +++ b/packages/circuit-ui/components/CopyButton/CopyButton.stories.tsx @@ -30,15 +30,77 @@ export default { ), ], argTypes: { - successLabel: { control: 'text' }, - copyLabel: { control: 'text' }, + 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', + }, }, - disabled: { control: 'boolean' }, - hideLabel: { control: 'boolean' }, - text: { control: 'text' }, }, parameters: { docs: { @@ -53,19 +115,25 @@ export default { const inputArgs = { label: 'API token', value: 'sk_live_1234567890', - copyLabel: 'Copy token', + copyLabel: 'Copy API token', successLabel: 'Token copied', } satisfies CopyButtonProps; -export const Input = (args: CopyButtonProps) => ; +export const Base = (args: CopyButtonProps) => ; -Input.args = inputArgs; +Base.args = inputArgs; + +Base.parameters = { + chromatic: { + disableSnapshot: true, + }, +}; export const MaskedInput = (args: CopyButtonProps) => ; MaskedInput.args = { ...inputArgs, - text: 'sk_live_******', + visibleValue: 'sk_live_******', }; export const LongValue = (args: CopyButtonProps) => ; @@ -77,31 +145,32 @@ LongValue.args = { successLabel: 'Secret copied', } satisfies CopyButtonProps; -export const FullButton = (args: CopyButtonProps) => ; - -FullButton.args = { - copyVariant: 'button', - value: 'ref_1234567890', - copyLabel: 'Copy reference', - successLabel: 'Reference copied', -} satisfies CopyButtonProps; - -export const IconOnly = (args: CopyButtonProps) => ; - -IconOnly.args = { - copyVariant: 'icon-button', - value: 'ref_1234567890', - copyLabel: 'Copy reference', - successLabel: 'Reference copied', -} satisfies CopyButtonProps; - -export const Disabled = (args: CopyButtonProps) => ; - -Disabled.args = { - ...inputArgs, - disabled: true, +LongValue.parameters = { + chromatic: { + disableSnapshot: true, + }, }; +export const Disabled = () => ( + + + + + +); + export const ValidationStates = (args: CopyButtonProps) => ( & { /** * The CopyButton variant. @@ -63,7 +70,16 @@ type InputCopyButtonProps = CommonCopyButtonProps & }; type ButtonCopyButtonProps = CommonCopyButtonProps & - Omit & { + Omit< + ButtonProps, + | 'children' + | 'destructive' + | 'icon' + | 'navigationIcon' + | 'onClick' + | 'type' + | 'value' + > & { /** * The CopyButton variant. */ @@ -71,7 +87,10 @@ type ButtonCopyButtonProps = CommonCopyButtonProps & }; type IconCopyButtonProps = CommonCopyButtonProps & - Omit & { + Omit< + IconButtonProps, + 'children' | 'icon' | 'label' | 'onClick' | 'type' | 'value' + > & { /** * The CopyButton variant. */ @@ -177,7 +196,6 @@ export function CopyButton(props: CopyButtonProps) { ...inputProps } = props; const displayText = visibleValue ?? value; - const buttonLabel = `${copyLabel}: ${props.label}`; return ( - {buttonLabel} + {copyLabel} )} inputClassName={inputClassName} diff --git a/skills/circuit-ui/references/components/CopyButton.mdx b/skills/circuit-ui/references/components/CopyButton.mdx index 02265c9dbb..a8c2d2786f 100644 --- a/skills/circuit-ui/references/components/CopyButton.mdx +++ b/skills/circuit-ui/references/components/CopyButton.mdx @@ -9,29 +9,33 @@ import * as Stories from './CopyButton.stories'; CopyButton copies a value to the clipboard and can render as a read-only input, a full button, or an icon button. - + -## Usage +## When to Use Use CopyButton when copying is the primary action, such as copying API tokens, webhook secrets, reference IDs, or shareable links. -Wrap your application in `ToastProvider` to display the success feedback toast after a copy action. +Use the input variant when users need to inspect the copied value before copying it. -## Variants +Use the button or icon button variants when the value itself does not need to stay visible in the UI. -Use the input variant when users need to inspect the copied value before copying it. +## How to Use + +Wrap your application in `ToastProvider` to display a feedback toast after a successful copy action. -Use the full button or icon-only button when the value itself does not need to stay visible in the UI. +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 `copiedBody` when the success message should refer to a specific object. +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 while preserving the full copied value. - - +For longer strings, the input variant truncates the visible text with an ellipsis while preserving the full copied value.