Skip to content

Commit 2a44318

Browse files
DebugStevenJesse Haigh
andauthored
add copy-to-clipboard button on code listings with success/failure UI (#961)
- Show copy-to-clipboard button on hover for devices that support it - Persistently display button on non-hover devices - Add copyCodeToClipboard function to write code block text to clipboard - Add Copy/Checkmark/Cross icons with CopyState for state-specific styling Co-authored-by: Jesse Haigh <jhaigh@apple.com>
1 parent deca402 commit 2a44318

File tree

8 files changed

+273
-3
lines changed

8 files changed

+273
-3
lines changed

src/components/ContentNode.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,7 @@ function renderNode(createElement, references) {
274274
fileType: node.fileType,
275275
content: node.code,
276276
showLineNumbers: node.showLineNumbers,
277+
copyToClipboard: node.copyToClipboard ?? false,
277278
};
278279
return createElement(CodeListing, { props });
279280
}

src/components/ContentNode/CodeListing.vue

Lines changed: 110 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<!--
22
This source file is part of the Swift.org open source project
33
4-
Copyright (c) 2021-2023 Apple Inc. and the Swift project authors
4+
Copyright (c) 2021-2025 Apple Inc. and the Swift project authors
55
Licensed under Apache License v2.0 with Runtime Library Exception
66
77
See https://swift.org/LICENSE.txt for license information
@@ -22,6 +22,19 @@
2222
>{{ fileName }}
2323
</Filename>
2424
<div class="container-general">
25+
<button
26+
v-if="copyToClipboard"
27+
class="copy-button"
28+
:class="copyState"
29+
@click="copyCodeToClipboard"
30+
:aria-label="$t('icons.copy')"
31+
:title="$t('icons.copy')"
32+
>
33+
<CopyIcon v-if="copyState === CopyState.idle" class="copy-icon"/>
34+
<CheckmarkIcon v-else-if="copyState === CopyState.success" class="checkmark-icon"/>
35+
<CrossIcon v-else-if="copyState === CopyState.failure" class="cross-icon"/>
36+
37+
</button>
2538
<!-- Do not add newlines in <pre>, as they'll appear in the rendered HTML. -->
2639
<pre><CodeBlock><template
2740
v-for="(line, index) in syntaxHighlightedLines"
@@ -45,16 +58,33 @@
4558
import { escapeHtml } from 'docc-render/utils/strings';
4659
import Language from 'docc-render/constants/Language';
4760
import CodeBlock from 'docc-render/components/CodeBlock.vue';
61+
import CopyIcon from 'theme/components/Icons/CopyIcon.vue';
62+
import CheckmarkIcon from 'theme/components/Icons/CheckmarkIcon.vue';
63+
import CrossIcon from 'theme/components/Icons/CrossIcon.vue';
4864
import { highlightContent, registerHighlightLanguage } from 'docc-render/utils/syntax-highlight';
4965
5066
import CodeListingFilename from './CodeListingFilename.vue';
5167
68+
const CopyState = {
69+
idle: 'idle',
70+
success: 'success',
71+
failure: 'failure',
72+
};
73+
5274
export default {
5375
name: 'CodeListing',
54-
components: { Filename: CodeListingFilename, CodeBlock },
76+
components: {
77+
Filename: CodeListingFilename,
78+
CodeBlock,
79+
CopyIcon,
80+
CheckmarkIcon,
81+
CrossIcon,
82+
},
5583
data() {
5684
return {
5785
syntaxHighlightedLines: [],
86+
copyState: CopyState.idle,
87+
CopyState,
5888
};
5989
},
6090
props: {
@@ -69,6 +99,10 @@ export default {
6999
type: Array,
70100
required: true,
71101
},
102+
copyToClipboard: {
103+
type: Boolean,
104+
default: () => false,
105+
},
72106
startLineNumber: {
73107
type: Number,
74108
default: () => 1,
@@ -92,6 +126,9 @@ export default {
92126
const fallbackMap = { occ: Language.objectiveC.key.url };
93127
return fallbackMap[this.syntax] || this.syntax;
94128
},
129+
copyableText() {
130+
return this.content.join('\n');
131+
},
95132
},
96133
watch: {
97134
content: {
@@ -122,6 +159,21 @@ export default {
122159
line === '' ? '\n' : line
123160
));
124161
},
162+
copyCodeToClipboard() {
163+
navigator.clipboard.writeText(this.copyableText)
164+
.then(() => {
165+
this.copyState = CopyState.success;
166+
})
167+
.catch((err) => {
168+
console.error('Failed to copy text: ', err);
169+
this.copyState = CopyState.failure;
170+
})
171+
.finally(() => {
172+
setTimeout(() => {
173+
this.copyState = CopyState.idle;
174+
}, 1000);
175+
});
176+
},
125177
},
126178
};
127179
</script>
@@ -187,6 +239,7 @@ code {
187239
flex-direction: column;
188240
border-radius: var(--code-border-radius, $border-radius);
189241
overflow: hidden;
242+
position: relative;
190243
// we need to establish a new stacking context to resolve a Safari bug where
191244
// the scrollbar is not clipped by this element depending on its border-radius
192245
@include new-stacking-context;
@@ -205,4 +258,59 @@ pre {
205258
flex-grow: 1;
206259
}
207260
261+
.copy-button {
262+
position: absolute;
263+
top: 0.2em;
264+
right: 0.2em;
265+
width: 1.5em;
266+
height: 1.5em;
267+
background: var(--color-fill-gray-tertiary);
268+
border: none;
269+
border-radius: var(--button-border-radius, $button-radius);
270+
padding: 4px;
271+
}
272+
273+
@media (hover: hover) {
274+
.copy-button {
275+
opacity: 0;
276+
transition: all 0.2s ease-in-out;
277+
}
278+
279+
.copy-button:hover {
280+
background-color: var(--color-fill-gray);
281+
}
282+
283+
.copy-button .copy-icon {
284+
opacity: 0.8;
285+
}
286+
287+
.copy-button:hover .copy-icon {
288+
opacity: 1;
289+
}
290+
291+
.container-general:hover .copy-button {
292+
opacity: 1;
293+
}
294+
}
295+
296+
@media (hover: none) {
297+
.copy-button {
298+
opacity: 1;
299+
}
300+
}
301+
302+
.copy-button .copy-icon {
303+
fill: var(--color-figure-gray);
304+
}
305+
306+
.copy-button.success .checkmark-icon {
307+
color: var(--color-figure-blue);
308+
fill: currentColor;
309+
}
310+
311+
.copy-button.failure .cross-icon {
312+
color: var(--color-figure-red);
313+
fill: currentColor;
314+
}
315+
208316
</style>
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<!--
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright (c) 2025 Apple Inc. and the Swift project authors
5+
Licensed under Apache License v2.0 with Runtime Library Exception
6+
7+
See https://swift.org/LICENSE.txt for license information
8+
See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
-->
10+
11+
<template>
12+
<SVGIcon
13+
class="CheckmarkIcon"
14+
viewBox="0 0 24 24"
15+
themeId="checkmark"
16+
>
17+
<path d="M9 16.2L4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4L9 16.2z"/>
18+
</SVGIcon>
19+
</template>
20+
21+
<script>
22+
import SVGIcon from 'docc-render/components/SVGIcon.vue';
23+
24+
export default {
25+
name: 'CheckmarkIcon',
26+
components: { SVGIcon },
27+
};
28+
</script>
29+
30+
<style scoped lang="scss">
31+
.CheckmarkIcon {
32+
opacity: 1;
33+
stroke: currentColor;
34+
}
35+
</style>

src/components/Icons/CopyIcon.vue

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<!--
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright (c) 2025 Apple Inc. and the Swift project authors
5+
Licensed under Apache License v2.0 with Runtime Library Exception
6+
7+
See https://swift.org/LICENSE.txt for license information
8+
See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
-->
10+
11+
<template>
12+
<SVGIcon
13+
class="CopyIcon"
14+
viewBox="0 0 24 24"
15+
themeId="copy"
16+
>
17+
<title>{{ $t('icons.copy') }}</title>
18+
<path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2
19+
.9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0
20+
16H8V7h11v14z"/>
21+
</SVGIcon>
22+
</template>
23+
24+
<script>
25+
import SVGIcon from 'docc-render/components/SVGIcon.vue';
26+
27+
export default {
28+
name: 'CopyIcon',
29+
components: { SVGIcon },
30+
};
31+
</script>
32+
33+
<style scoped lang="scss">
34+
.CopyIcon {
35+
opacity: 0.8;
36+
}
37+
</style>

src/components/Icons/CrossIcon.vue

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<!--
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright (c) 2025 Apple Inc. and the Swift project authors
5+
Licensed under Apache License v2.0 with Runtime Library Exception
6+
7+
See https://swift.org/LICENSE.txt for license information
8+
See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
-->
10+
11+
<template>
12+
<SVGIcon
13+
class="CrossIcon"
14+
viewBox="0 0 24 24"
15+
themeId="cross"
16+
>
17+
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41
18+
10.59 12 5 17.59 6.41 19 12 13.41
19+
17.59 19 19 17.59 13.41 12z"/>
20+
</SVGIcon>
21+
</template>
22+
23+
<script>
24+
import SVGIcon from 'docc-render/components/SVGIcon.vue';
25+
26+
export default {
27+
name: 'CrossIcon',
28+
components: { SVGIcon },
29+
};
30+
</script>
31+
32+
<style scoped lang="scss">
33+
.CrossIcon {
34+
opacity: 1;
35+
stroke: currentColor;
36+
}
37+
</style>

src/lang/locales/en-US.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,8 @@
207207
"icons": {
208208
"clear": "Clear",
209209
"web-service-endpoint": "Web Service Endpoint",
210-
"search": "Search"
210+
"search": "Search",
211+
"copy": "Copy code to clipboard"
211212
},
212213
"formats": {
213214
"parenthesis": "({content})",

tests/unit/components/ContentNode.spec.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ describe('ContentNode', () => {
101101
syntax: 'swift',
102102
fileType: 'swift',
103103
code: ['foobar'],
104+
copyToClipboard: false,
104105
};
105106

106107
it('renders a `CodeListing`', () => {
@@ -111,6 +112,7 @@ describe('ContentNode', () => {
111112
expect(codeListing.props('syntax')).toBe(listing.syntax);
112113
expect(codeListing.props('fileType')).toBe(listing.fileType);
113114
expect(codeListing.props('content')).toEqual(listing.code);
115+
expect(codeListing.props('copyToClipboard')).toEqual(listing.copyToClipboard);
114116
expect(codeListing.isEmpty()).toBe(true);
115117
});
116118

@@ -138,6 +140,29 @@ describe('ContentNode', () => {
138140
});
139141
});
140142

143+
describe('with type="codeListing" and copy set', () => {
144+
const listing = {
145+
type: 'codeListing',
146+
syntax: 'swift',
147+
fileType: 'swift',
148+
code: ['foobar'],
149+
copyToClipboard: true,
150+
};
151+
152+
// renders a copy button
153+
it('renders a copy button', () => {
154+
const wrapper = mountWithItem(listing);
155+
156+
const codeListing = wrapper.find('.content').find(CodeListing);
157+
expect(codeListing.exists()).toBe(true);
158+
expect(codeListing.props('syntax')).toBe(listing.syntax);
159+
expect(codeListing.props('fileType')).toBe(listing.fileType);
160+
expect(codeListing.props('content')).toEqual(listing.code);
161+
expect(codeListing.props('copyToClipboard')).toEqual(listing.copyToClipboard);
162+
expect(codeListing.isEmpty()).toBe(true);
163+
});
164+
});
165+
141166
describe('with type="endpointExample"', () => {
142167
it('renders an `EndpointExample`', () => {
143168
const request = {

tests/unit/components/ContentNode/CodeListing.spec.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,32 @@ describe('CodeListing', () => {
139139
expect(wrapper.html().includes('.syntax')).toBe(false);
140140
});
141141

142+
it('does not show copy button when its disabled', async () => {
143+
const wrapper = shallowMount(CodeListing, {
144+
propsData: {
145+
syntax: 'swift',
146+
content: ['let foo = "bar"'],
147+
copyToClipboard: false,
148+
},
149+
});
150+
await flushPromises();
151+
152+
expect(wrapper.find('.copy-button').exists()).toBe(false);
153+
});
154+
155+
it('shows copy button when its enabled', async () => {
156+
const wrapper = shallowMount(CodeListing, {
157+
propsData: {
158+
syntax: 'swift',
159+
content: ['let foo = "bar"'],
160+
copyToClipboard: true,
161+
},
162+
});
163+
await flushPromises();
164+
165+
expect(wrapper.find('.copy-button').exists()).toBe(true);
166+
});
167+
142168
it('renders code with empty spaces', async () => {
143169
const wrapper = shallowMount(CodeListing, {
144170
propsData: {

0 commit comments

Comments
 (0)