Skip to content

Commit d8c793e

Browse files
[6.x] Copy to clipboard improvements and fixes throughout the CP (#13791)
Co-authored-by: Jason Varga <jason@pixelfear.com>
1 parent 5ff9f38 commit d8c793e

File tree

10 files changed

+181
-28
lines changed

10 files changed

+181
-28
lines changed

resources/js/components/two-factor/RecoveryCodesModal.vue

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { ref, onMounted } from 'vue';
33
import axios from 'axios';
44
import { Modal, Button, Icon } from '@/components/ui';
5+
import useCopy from '@/composables/copy';
56
67
const emit = defineEmits(['cancel', 'close']);
78
@@ -14,7 +15,7 @@ const props = defineProps({
1415
const loading = ref(true);
1516
const confirming = ref(false);
1617
const recoveryCodes = ref(null);
17-
const canCopy = !!navigator.clipboard;
18+
const { copySupported, copy } = useCopy();
1819
1920
onMounted(() => getRecoveryCodes());
2021
@@ -34,15 +35,6 @@ function regenerate() {
3435
Statamic.$toast.success(__('Refreshed recovery codes'));
3536
});
3637
}
37-
38-
function copyToClipboard() {
39-
if (!canCopy) return Statamic.$toast.error(__('Unable to copy to clipboard'));
40-
41-
navigator.clipboard
42-
.writeText(recoveryCodes.value.join('\n'))
43-
.then(() => Statamic.$toast.success(__('Copied to clipboard')))
44-
.catch((error) => Statamic.$toast.error(__('Unable to copy to clipboard')));
45-
}
4638
</script>
4739

4840
<template>
@@ -67,7 +59,7 @@ function copyToClipboard() {
6759
</div>
6860

6961
<div class="flex items-center space-x-4">
70-
<Button v-if="canCopy" @click="copyToClipboard">{{ __('Copy') }}</Button>
62+
<Button v-if="copySupported" @click="copy(recoveryCodes.join('\n'))">{{ __('Copy') }}</Button>
7163

7264
<Button :href="downloadUrl" download>{{ __('Download') }}</Button>
7365

resources/js/components/two-factor/Setup.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ function complete() {
6666
<div class="bg-white" v-html="qrCode"></div>
6767
<div class="space-y-6 w-full">
6868
<ui-field :label="__('Setup Key')">
69-
<ui-input copyable readonly :value="secretKey" />
69+
<ui-input copyable readonly :model-value="secretKey" />
7070
</ui-field>
7171

7272
<ui-field :label="__('Verification Code')" :error="error">

resources/js/components/ui/Input/Input.vue

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { twMerge } from 'tailwind-merge';
55
import Icon from '../Icon/Icon.vue';
66
import Button from '../Button/Button.vue';
77
import CharacterCounter from '../CharacterCounter.vue';
8+
import useCopy from '@/composables/copy';
89
910
defineOptions({ inheritAttrs: false });
1011
@@ -90,7 +91,7 @@ const inputAttrs = computed(() => {
9091
});
9192
9293
const hasPrependedIcon = computed(() => !!props.iconPrepend || !!props.icon || !!slots.prepend);
93-
const hasAppendedIcon = computed(() => !!props.iconAppend || !!slots.append || clearable.value || props.viewable || copyable.value || props.loading);
94+
const hasAppendedIcon = computed(() => !!props.iconAppend || !!slots.append || clearable.value || props.viewable || canCopy.value || props.loading);
9495
9596
const inputClasses = computed(() => {
9697
const classes = cva({
@@ -188,15 +189,8 @@ const togglePassword = () => {
188189
inputType.value = inputType.value === 'password' ? 'text' : 'password';
189190
};
190191
191-
const copySupported = computed(() => 'clipboard' in navigator && typeof navigator.clipboard.writeText === 'function');
192-
const copyable = computed(() => props.copyable && copySupported.value)
193-
const copied = ref(false);
194-
const copy = () => {
195-
if (!copyable.value || !props.modelValue) return;
196-
navigator.clipboard.writeText(props.modelValue);
197-
copied.value = true;
198-
setTimeout(() => (copied.value = false), 1000);
199-
};
192+
const { copySupported, copied, copy } = useCopy();
193+
const canCopy = computed(() => props.copyable && copySupported.value);
200194
201195
const clearable = computed(() => props.clearable && !props.readOnly && !props.disabled && !!props.modelValue);
202196
@@ -249,8 +243,8 @@ defineExpose({ focus });
249243
size="sm"
250244
:icon="copied ? 'clipboard-check' : 'clipboard'"
251245
variant="subtle"
252-
v-else-if="copyable"
253-
@click="copy"
246+
v-else-if="canCopy"
247+
@click="copy(modelValue)"
254248
class="animate"
255249
:class="copied ? 'animate-wiggle' : ''"
256250
/>

resources/js/components/ui/Textarea.vue

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
<script setup>
22
import { cva } from 'cva';
33
import CharacterCounter from './CharacterCounter.vue';
4+
import Button from './Button/Button.vue';
45
import autosize from 'autosize/dist/autosize.js';
5-
import { nextTick, onBeforeUnmount, onMounted, useTemplateRef } from 'vue';
6+
import { computed, nextTick, onBeforeUnmount, onMounted, useTemplateRef } from 'vue';
7+
import useCopy from '@/composables/copy';
68
79
defineEmits(['update:modelValue']);
810
911
const props = defineProps({
1012
/** When `true`, the textarea will automatically grow/shrink to fit content */
1113
elastic: { type: Boolean, default: false },
14+
/** When `true`, shows a copy button to copy the value to clipboard */
15+
copyable: { type: Boolean, default: false },
1216
disabled: { type: Boolean, default: false },
1317
/** ID attribute for the textarea element */
1418
id: { type: String, default: null },
@@ -24,6 +28,9 @@ const props = defineProps({
2428
limit: { type: Number, default: null },
2529
});
2630
31+
const { copySupported, copied, copy } = useCopy();
32+
const canCopy = computed(() => props.copyable && copySupported.value);
33+
2734
const classes = cva({
2835
base: [
2936
'w-full block bg-white dark:bg-gray-900 px-3 pt-2.5 pb-3 rounded-lg',
@@ -74,6 +81,16 @@ onBeforeUnmount(() => {
7481
data-ui-control
7582
@input="$emit('update:modelValue', $event.target.value)"
7683
/>
84+
<div class="absolute right-2 top-2" v-if="canCopy">
85+
<Button
86+
size="sm"
87+
:icon="copied ? 'clipboard-check' : 'clipboard'"
88+
variant="subtle"
89+
@click="copy(modelValue)"
90+
class="animate"
91+
:class="copied ? 'animate-wiggle' : ''"
92+
/>
93+
</div>
7794
<div class="absolute right-2 bottom-2" v-if="limit">
7895
<CharacterCounter :text="modelValue" :limit />
7996
</div>

resources/js/components/updater/Release.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
</template>
1919
<div class="prose prose-sm prose-zinc prose-headings:font-medium space-y-3">
2020
<p v-text="confirmationText" />
21-
<ui-input v-model="command" readonly copyable class="font-mono text-sm dark" />
21+
<ui-input readonly copyable :model-value="command" class="dark" inputClass="font-mono text-sm" />
2222
<p v-html="link" />
2323
</div>
2424
</ui-modal>

resources/js/components/users/Wizard.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@
143143
<ui-input
144144
readonly
145145
copyable
146-
:value="activationUrl"
146+
:model-value="activationUrl"
147147
id="activation_url"
148148
/>
149149
</div>
@@ -152,7 +152,7 @@
152152
<ui-input
153153
readonly
154154
copyable
155-
:value="values.email"
155+
:model-value="values.email"
156156
id="email"
157157
/>
158158
</div>

resources/js/composables/copy.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { ref, computed } from 'vue';
2+
3+
export default function useCopy() {
4+
const copySupported = computed(() => 'clipboard' in navigator && typeof navigator.clipboard.writeText === 'function');
5+
const copiedValue = ref(null);
6+
const copied = computed(() => copiedValue.value !== null);
7+
const isCopied = (value) => copiedValue.value === value;
8+
let timeout;
9+
10+
const copy = (value) => {
11+
if (!value) return Promise.resolve();
12+
13+
return navigator.clipboard
14+
.writeText(value)
15+
.then(() => {
16+
copiedValue.value = value;
17+
clearTimeout(timeout);
18+
timeout = setTimeout(() => (copiedValue.value = null), 1000);
19+
Statamic.$toast.success(__('Copied to clipboard'));
20+
})
21+
.catch(() => {
22+
Statamic.$toast.error(__('Unable to copy to clipboard'));
23+
});
24+
};
25+
26+
return {
27+
copySupported,
28+
copied,
29+
isCopied,
30+
copy,
31+
};
32+
}

resources/js/stories/Textarea.stories.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,3 +126,20 @@ export const _ResizeControls: Story = {
126126
template: resizeCode,
127127
}),
128128
};
129+
130+
const copyableCode = `
131+
<Textarea copyable read-only model-value="Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." />
132+
`;
133+
134+
export const _Copyable: Story = {
135+
tags: ['!dev'],
136+
parameters: {
137+
docs: {
138+
source: { code: copyableCode }
139+
}
140+
},
141+
render: () => ({
142+
components: { Textarea },
143+
template: copyableCode,
144+
}),
145+
};

resources/js/stories/docs/Textarea.mdx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,5 +27,9 @@ Add a placeholder to the textarea.
2727
If you want to restrict the user from resizing the textarea, you can use the `resize` prop.
2828
<Canvas of={TextareaStories._ResizeControls} sourceState={'shown'} />
2929

30+
## Copyable
31+
Add a copy button to your textarea by passing the `copyable` prop.
32+
<Canvas of={TextareaStories._Copyable} sourceState={'shown'} />
33+
3034
## Arguments
3135
<ArgTypes of={TextareaStories} />

resources/js/tests/Copy.test.js

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { test, expect, vi, beforeEach, afterEach } from 'vitest';
2+
import useCopy from '../composables/copy';
3+
4+
beforeEach(() => {
5+
global.Statamic = { $toast: { success: vi.fn(), error: vi.fn() } };
6+
global.__ = (str) => str;
7+
8+
Object.assign(navigator, {
9+
clipboard: { writeText: vi.fn(() => Promise.resolve()) },
10+
});
11+
});
12+
13+
afterEach(() => {
14+
vi.restoreAllMocks();
15+
});
16+
17+
test('it reports clipboard as supported when available', () => {
18+
const { copySupported } = useCopy();
19+
expect(copySupported.value).toBe(true);
20+
});
21+
22+
test('it copies a value to the clipboard', async () => {
23+
const { copy, copied } = useCopy();
24+
25+
await copy('hello');
26+
27+
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('hello');
28+
expect(copied.value).toBe(true);
29+
expect(Statamic.$toast.success).toHaveBeenCalledWith('Copied to clipboard');
30+
});
31+
32+
test('it resets copied state after timeout', async () => {
33+
vi.useFakeTimers();
34+
35+
const { copy, copied } = useCopy();
36+
37+
await copy('hello');
38+
expect(copied.value).toBe(true);
39+
40+
vi.advanceTimersByTime(1000);
41+
expect(copied.value).toBe(false);
42+
43+
vi.useRealTimers();
44+
});
45+
46+
test('it does not copy when value is empty', async () => {
47+
const { copy, copied } = useCopy();
48+
49+
await copy('');
50+
await copy(null);
51+
await copy(undefined);
52+
53+
expect(navigator.clipboard.writeText).not.toHaveBeenCalled();
54+
expect(copied.value).toBe(false);
55+
});
56+
57+
test('it shows error toast when clipboard write fails', async () => {
58+
const rejection = Promise.reject();
59+
navigator.clipboard.writeText = vi.fn(() => rejection);
60+
61+
const { copy, copied } = useCopy();
62+
63+
copy('hello');
64+
await rejection.catch(() => {});
65+
await vi.dynamicImportSettled();
66+
67+
expect(copied.value).toBe(false);
68+
expect(Statamic.$toast.error).toHaveBeenCalledWith('Unable to copy to clipboard');
69+
});
70+
71+
test('it tracks which value was copied via isCopied', async () => {
72+
const { copy, isCopied } = useCopy();
73+
74+
await copy('first');
75+
76+
expect(isCopied('first')).toBe(true);
77+
expect(isCopied('second')).toBe(false);
78+
79+
await copy('second');
80+
81+
expect(isCopied('first')).toBe(false);
82+
expect(isCopied('second')).toBe(true);
83+
});
84+
85+
test('isCopied resets after timeout', async () => {
86+
vi.useFakeTimers();
87+
88+
const { copy, isCopied } = useCopy();
89+
90+
await copy('hello');
91+
expect(isCopied('hello')).toBe(true);
92+
93+
vi.advanceTimersByTime(1000);
94+
expect(isCopied('hello')).toBe(false);
95+
96+
vi.useRealTimers();
97+
});

0 commit comments

Comments
 (0)