Skip to content

Commit 3810417

Browse files
committed
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.
1 parent 2a68283 commit 3810417

8 files changed

Lines changed: 490 additions & 0 deletions

File tree

.changeset/green-snakes-sit.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sumup-oss/circuit-ui': minor
3+
---
4+
5+
Added the `ClipboardText` component.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { Meta, Status, Props, Story } from '../../../../.storybook/components';
2+
import * as Stories from './ClipboardText.stories';
3+
4+
<Meta of={Stories} />
5+
6+
# ClipboardText
7+
8+
<Status variant="experimental" />
9+
10+
ClipboardText displays a value in an input-like field and provides an inline button to copy it to a clipboard.
11+
12+
<Story of={Stories.Base} />
13+
<Props />
14+
15+
## Usage
16+
17+
Use ClipboardText when a value should be easy to copy but not directly editable, such as API tokens, webhook secrets, or reference IDs.
18+
19+
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.
20+
21+
<Story of={Stories.MaskedValue} />
22+
23+
## Content
24+
25+
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.
26+
27+
For longer strings, ClipboardText truncates the visible text while preserving the full copied value.
28+
29+
<Story of={Stories.LongValue} />
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
.base {
2+
display: flex;
3+
align-items: center;
4+
padding: 0;
5+
}
6+
7+
.content {
8+
display: flex;
9+
flex: 1;
10+
align-items: center;
11+
min-width: 0;
12+
padding: var(--cui-spacings-kilo) var(--cui-spacings-mega);
13+
}
14+
15+
.value {
16+
display: block;
17+
overflow: hidden;
18+
text-overflow: ellipsis;
19+
white-space: nowrap;
20+
}
21+
22+
.action {
23+
position: relative;
24+
display: flex;
25+
flex-shrink: 0;
26+
align-items: center;
27+
align-self: stretch;
28+
justify-content: center;
29+
width: calc(var(--cui-body-m-line-height) + var(--cui-spacings-kilo) * 2 + var(--cui-border-width-kilo) * 2);
30+
min-width: calc(var(--cui-body-m-line-height) + var(--cui-spacings-kilo) * 2 + var(--cui-border-width-kilo) * 2);
31+
border-left: var(--cui-border-width-kilo) solid var(--cui-border-normal);
32+
}
33+
34+
.button {
35+
display: flex;
36+
flex-shrink: 0;
37+
justify-content: center;
38+
width: 100%;
39+
min-width: 0;
40+
height: 100%;
41+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/**
2+
* Copyright 2026, SumUp Ltd.
3+
* Licensed under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License.
5+
* You may obtain a copy of the License at
6+
*
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
*
9+
* Unless required by applicable law or agreed to in writing, software
10+
* distributed under the License is distributed on an "AS IS" BASIS,
11+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
* See the License for the specific language governing permissions and
13+
* limitations under the License.
14+
*/
15+
16+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
17+
18+
import { act, axe, fireEvent, render, screen } from '../../util/test-utils.js';
19+
20+
import { ClipboardText } from './ClipboardText.js';
21+
22+
const defaultProps = {
23+
label: 'API token',
24+
value: 'secret-token',
25+
copyLabel: 'Copy value',
26+
};
27+
28+
describe('ClipboardText', () => {
29+
beforeEach(() => {
30+
Object.defineProperty(navigator, 'clipboard', {
31+
configurable: true,
32+
value: { writeText: vi.fn().mockResolvedValue(undefined) },
33+
});
34+
});
35+
36+
afterEach(() => {
37+
vi.useRealTimers();
38+
});
39+
40+
it('should render the value without using an input element', () => {
41+
render(<ClipboardText {...defaultProps} />);
42+
43+
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
44+
expect(
45+
screen.getByRole('button', { name: 'Copy value: API token' }),
46+
).toBeVisible();
47+
expect(screen.getByText(defaultProps.value)).toBeVisible();
48+
});
49+
50+
it('should expose the display text with label context', () => {
51+
render(<ClipboardText {...defaultProps} />);
52+
53+
expect(screen.getByText('API token:').className).toContain('hide-visually');
54+
expect(screen.getByText(defaultProps.value)).toBeVisible();
55+
});
56+
57+
it('should render text instead of value when provided', () => {
58+
render(<ClipboardText {...defaultProps} text="••••••••••••token" />);
59+
60+
expect(screen.getByText('••••••••••••token')).toBeVisible();
61+
expect(screen.queryByText(defaultProps.value)).not.toBeInTheDocument();
62+
});
63+
64+
it('should copy the current value when the button is clicked', async () => {
65+
vi.useFakeTimers();
66+
const onCopied = vi.fn();
67+
68+
render(
69+
<ClipboardText
70+
{...defaultProps}
71+
copiedLabel="Copied value"
72+
onCopied={onCopied}
73+
/>,
74+
);
75+
76+
fireEvent.click(
77+
screen.getByRole('button', { name: 'Copy value: API token' }),
78+
);
79+
80+
await act(async () => {
81+
await Promise.resolve();
82+
});
83+
84+
expect(onCopied).toHaveBeenCalledTimes(1);
85+
expect(
86+
screen.getByRole('button', { name: 'Copy value: API token' }),
87+
).toBeVisible();
88+
expect(screen.getByRole('status')).toHaveTextContent('Copied value');
89+
90+
await act(async () => {
91+
await vi.advanceTimersByTimeAsync(2000);
92+
});
93+
94+
fireEvent.click(
95+
screen.getByRole('button', { name: 'Copy value: API token' }),
96+
);
97+
98+
await act(async () => {
99+
await Promise.resolve();
100+
});
101+
102+
expect(onCopied).toHaveBeenCalledTimes(2);
103+
expect(screen.getByRole('status')).toHaveTextContent('Copied value');
104+
105+
await act(async () => {
106+
await vi.advanceTimersByTimeAsync(2000);
107+
});
108+
109+
expect(
110+
screen.getByRole('button', { name: 'Copy value: API token' }),
111+
).toBeVisible();
112+
expect(screen.getByRole('status')).toHaveTextContent('Copied value');
113+
114+
await act(async () => {
115+
await vi.advanceTimersByTimeAsync(1000);
116+
});
117+
118+
expect(screen.getByText('', { selector: 'output' })).toBeEmptyDOMElement();
119+
});
120+
121+
it('should have no accessibility violations', async () => {
122+
const { container } = render(<ClipboardText {...defaultProps} />);
123+
124+
const actual = await axe(container);
125+
126+
expect(actual).toHaveNoViolations();
127+
});
128+
});
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/**
2+
* Copyright 2026, SumUp Ltd.
3+
* Licensed under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License.
5+
* You may obtain a copy of the License at
6+
*
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
*
9+
* Unless required by applicable law or agreed to in writing, software
10+
* distributed under the License is distributed on an "AS IS" BASIS,
11+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
* See the License for the specific language governing permissions and
13+
* limitations under the License.
14+
*/
15+
16+
import { ClipboardText, type ClipboardTextProps } from './ClipboardText.js';
17+
18+
export default {
19+
title: 'Forms/ClipboardText',
20+
component: ClipboardText,
21+
tags: ['status:experimental'],
22+
argTypes: {
23+
copyLabel: { control: 'text' },
24+
readOnly: { control: 'boolean' },
25+
copiedLabel: { control: 'text' },
26+
text: { control: 'text' },
27+
},
28+
parameters: {
29+
docs: {
30+
description: {
31+
component:
32+
'ClipboardText displays a value in an input-like field and provides a built-in copy action.',
33+
},
34+
},
35+
},
36+
};
37+
38+
export const Base = (args: ClipboardTextProps) => <ClipboardText {...args} />;
39+
40+
Base.args = {
41+
label: 'API token',
42+
value: 'sk_live_1234567890',
43+
copyLabel: 'Copy token',
44+
};
45+
46+
export const MaskedValue = (args: ClipboardTextProps) => (
47+
<ClipboardText {...args} />
48+
);
49+
50+
MaskedValue.args = {
51+
label: 'API token',
52+
value: 'sk_live_1234567890',
53+
text: 'sk_live_******',
54+
copyLabel: 'Copy token',
55+
};
56+
57+
export const LongValue = (args: ClipboardTextProps) => (
58+
<ClipboardText {...args} />
59+
);
60+
61+
LongValue.args = {
62+
label: 'Webhook secret',
63+
value: 'whsec_4VbX8i2LwY7nQp3Rk5Tm9Uc1Fd6Hs0Za',
64+
copyLabel: 'Copy secret',
65+
};

0 commit comments

Comments
 (0)