Skip to content

Commit 6c51995

Browse files
xkxxmmalerba
authored andcommitted
feat(clipboard): add cdk-experimental clipboard service + directive (#16704)
(cherry picked from commit a8e0b48)
1 parent cfc3746 commit 6c51995

File tree

6 files changed

+360
-0
lines changed

6 files changed

+360
-0
lines changed
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package(default_visibility = ["//visibility:public"])
2+
3+
load("//tools:defaults.bzl", "ng_module", "ng_test_library", "ng_web_test_suite")
4+
5+
ng_module(
6+
name = "clipboard",
7+
srcs = glob(
8+
["**/*.ts"],
9+
exclude = ["**/*.spec.ts"],
10+
),
11+
assets = glob(["**/*.html"]),
12+
module_name = "@angular/cdk-experimental/clipboard",
13+
deps = [
14+
"@npm//@angular/common",
15+
"@npm//@angular/core",
16+
"@npm//rxjs",
17+
],
18+
)
19+
20+
ng_test_library(
21+
name = "unit_test_sources",
22+
srcs = glob(
23+
["**/*.spec.ts"],
24+
exclude = ["**/*.e2e.spec.ts"],
25+
),
26+
deps = [
27+
":clipboard",
28+
"//src/cdk/testing",
29+
"@npm//@angular/common",
30+
"@npm//@angular/platform-browser",
31+
],
32+
)
33+
34+
ng_web_test_suite(
35+
name = "unit_tests",
36+
deps = [":unit_test_sources"],
37+
)
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {CommonModule} from '@angular/common';
10+
import {NgModule} from '@angular/core';
11+
12+
import {CdkCopyToClipboard} from './copy-to-clipboard';
13+
14+
@NgModule({
15+
declarations: [CdkCopyToClipboard],
16+
imports: [CommonModule],
17+
exports: [CdkCopyToClipboard],
18+
})
19+
export class ClipboardModule {
20+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import {DOCUMENT} from '@angular/common';
2+
import {TestBed} from '@angular/core/testing';
3+
4+
import {Clipboard, PendingCopy} from './clipboard';
5+
6+
const COPY_CONTENT = 'copy content';
7+
8+
describe('Clipboard', () => {
9+
let clipboard: Clipboard;
10+
11+
let execCommand: jasmine.Spy;
12+
let document: Document;
13+
let body: HTMLElement;
14+
let focusedInput: HTMLElement;
15+
16+
beforeEach(() => {
17+
TestBed.configureTestingModule({});
18+
19+
clipboard = TestBed.get(Clipboard);
20+
document = TestBed.get(DOCUMENT);
21+
execCommand = spyOn(document, 'execCommand').and.returnValue(true);
22+
body = document.body;
23+
24+
focusedInput = document.createElement('input');
25+
body.appendChild(focusedInput);
26+
focusedInput.focus();
27+
});
28+
29+
afterEach(() => {
30+
focusedInput.remove();
31+
});
32+
33+
describe('#beginCopy', () => {
34+
let pendingCopy: PendingCopy;
35+
36+
beforeEach(() => {
37+
pendingCopy = clipboard.beginCopy(COPY_CONTENT);
38+
});
39+
40+
afterEach(() => {
41+
pendingCopy.destroy();
42+
});
43+
44+
it('loads the copy content in textarea', () => {
45+
expect(body.querySelector('textarea')!.value).toBe(COPY_CONTENT);
46+
});
47+
48+
it('removes the textarea after destroy()', () => {
49+
pendingCopy.destroy();
50+
51+
expect(body.querySelector('textarea')).toBeNull();
52+
});
53+
});
54+
55+
describe('#copy', () => {
56+
it('returns true when execCommand succeeds', () => {
57+
expect(clipboard.copy(COPY_CONTENT)).toBe(true);
58+
59+
expect(body.querySelector('textarea')).toBeNull();
60+
});
61+
62+
it('does not move focus away from focused element', () => {
63+
expect(clipboard.copy(COPY_CONTENT)).toBe(true);
64+
65+
expect(document.activeElement).toBe(focusedInput);
66+
});
67+
68+
describe('when execCommand fails', () => {
69+
beforeEach(() => {
70+
execCommand.and.throwError('could not copy');
71+
});
72+
73+
it('returns false', () => {
74+
expect(clipboard.copy(COPY_CONTENT)).toBe(false);
75+
});
76+
77+
it('removes the text area', () => {
78+
expect(body.querySelector('textarea')).toBeNull();
79+
});
80+
});
81+
82+
it('returns false when execCommand returns false', () => {
83+
execCommand.and.returnValue(false);
84+
85+
expect(clipboard.copy(COPY_CONTENT)).toBe(false);
86+
});
87+
});
88+
});
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {DOCUMENT} from '@angular/common';
10+
import {Inject, Injectable} from '@angular/core';
11+
12+
/**
13+
* A service for copying text to the clipboard.
14+
*
15+
* Example usage:
16+
*
17+
* clipboard.copy("copy this text");
18+
*/
19+
@Injectable({providedIn: 'root'})
20+
export class Clipboard {
21+
private _document: Document;
22+
23+
constructor(@Inject(DOCUMENT) document: any) {
24+
this._document = document;
25+
}
26+
27+
/**
28+
* Copies the provided text into the user's clipboard.
29+
*
30+
* @param text The string to copy.
31+
* @returns Whether the operation was successful.
32+
*/
33+
copy(text: string): boolean {
34+
const pendingCopy = this.beginCopy(text);
35+
const successful = pendingCopy.copy();
36+
pendingCopy.destroy();
37+
38+
return successful;
39+
}
40+
41+
/**
42+
* Prepares a string to be copied later. This is useful for large strings
43+
* which take too long to successfully render and be copied in the same tick.
44+
*
45+
* The caller must call `destroy` on the returned `PendingCopy`.
46+
*
47+
* @param text The string to copy.
48+
* @returns the pending copy operation.
49+
*/
50+
beginCopy(text: string): PendingCopy {
51+
return new PendingCopy(text, this._document);
52+
}
53+
}
54+
55+
/**
56+
* A pending copy-to-clipboard operation.
57+
*
58+
* The implementation of copying text to the clipboard modifies the DOM and
59+
* forces a relayout. This relayout can take too long if the string is large,
60+
* causing the execCommand('copy') to happen too long after the user clicked.
61+
* This results in the browser refusing to copy. This object lets the
62+
* relayout happen in a separate tick from copying by providing a copy function
63+
* that can be called later.
64+
*
65+
* Destroy must be called when no longer in use, regardless of whether `copy` is
66+
* called.
67+
*/
68+
export class PendingCopy {
69+
private _textarea: HTMLTextAreaElement|undefined;
70+
71+
constructor(text: string, private readonly _document: Document) {
72+
const textarea = this._textarea = this._document.createElement('textarea');
73+
74+
// Hide the element for display and accessibility.
75+
textarea.setAttribute('style', 'opacity: 0;');
76+
textarea.setAttribute('aria-hidden', 'true');
77+
78+
textarea.value = text;
79+
this._document.body.appendChild(textarea);
80+
}
81+
82+
/** Finishes copying the text. */
83+
copy(): boolean {
84+
const textarea = this._textarea;
85+
let successful = false;
86+
87+
try { // Older browsers could throw if copy is not supported.
88+
if (textarea) {
89+
const currentFocus = document.activeElement;
90+
91+
textarea.select();
92+
successful = this._document.execCommand('copy');
93+
94+
if (currentFocus instanceof HTMLElement) {
95+
currentFocus.focus();
96+
}
97+
}
98+
} catch {
99+
// Discard error.
100+
// Initial setting of {@code successful} will represent failure here.
101+
}
102+
103+
return successful;
104+
}
105+
106+
/** Cleans up DOM changes used to perform the copy operation. */
107+
destroy() {
108+
if (this._textarea) {
109+
this._document.body.removeChild(this._textarea);
110+
this._textarea = undefined;
111+
}
112+
}
113+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import {Component, EventEmitter, Input, Output} from '@angular/core';
2+
import {ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing';
3+
4+
import {Clipboard} from './clipboard';
5+
import {ClipboardModule} from './clipboard-module';
6+
7+
const COPY_CONTENT = 'copy content';
8+
9+
@Component({
10+
selector: 'copy-to-clipboard-host',
11+
template: `<button [cdkCopyToClipboard]="content" (copied)="copied.emit($event)"></button>`,
12+
})
13+
class CopyToClipboardHost {
14+
@Input() content = '';
15+
@Output() copied = new EventEmitter<boolean>();
16+
}
17+
18+
describe('CdkCopyToClipboard', () => {
19+
let fixture: ComponentFixture<CopyToClipboardHost>;
20+
let mockCopy: jasmine.Spy;
21+
let copiedOutput: jasmine.Spy;
22+
23+
beforeEach(fakeAsync(() => {
24+
TestBed.configureTestingModule({
25+
declarations: [CopyToClipboardHost],
26+
imports: [ClipboardModule],
27+
});
28+
29+
TestBed.compileComponents();
30+
}));
31+
32+
beforeEach(() => {
33+
fixture = TestBed.createComponent(CopyToClipboardHost);
34+
35+
const host = fixture.componentInstance;
36+
host.content = COPY_CONTENT;
37+
copiedOutput = jasmine.createSpy('copied');
38+
host.copied.subscribe(copiedOutput);
39+
mockCopy = spyOn(TestBed.get(Clipboard), 'copy');
40+
41+
fixture.detectChanges();
42+
});
43+
44+
it('copies content to clipboard upon click', () => {
45+
fixture.nativeElement.querySelector('button')!.click();
46+
47+
expect(mockCopy).toHaveBeenCalledWith(COPY_CONTENT);
48+
});
49+
50+
it('emits copied event true when copy succeeds', fakeAsync(() => {
51+
mockCopy.and.returnValue(true);
52+
fixture.nativeElement.querySelector('button')!.click();
53+
54+
expect(copiedOutput).toHaveBeenCalledWith(true);
55+
}));
56+
57+
it('emits copied event false when copy fails', fakeAsync(() => {
58+
mockCopy.and.returnValue(false);
59+
fixture.nativeElement.querySelector('button')!.click();
60+
tick();
61+
62+
expect(copiedOutput).toHaveBeenCalledWith(false);
63+
}));
64+
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {Directive, EventEmitter, Input, Output} from '@angular/core';
10+
11+
import {Clipboard} from './clipboard';
12+
13+
/**
14+
* Provides behavior for a button that when clicked copies content into user's
15+
* clipboard.
16+
*
17+
* Example usage:
18+
*
19+
* `<button copyToClipboard="Content to be copied">Copy me!</button>`
20+
*/
21+
@Directive({
22+
selector: '[cdkCopyToClipboard]',
23+
host: {
24+
'(click)': 'doCopy()',
25+
}
26+
})
27+
export class CdkCopyToClipboard {
28+
/** Content to be copied. */
29+
@Input('cdkCopyToClipboard') text = '';
30+
31+
@Output() copied = new EventEmitter<boolean>();
32+
33+
constructor(private readonly clipboard: Clipboard) {}
34+
35+
doCopy() {
36+
this.copied.emit(this.clipboard.copy(this.text));
37+
}
38+
}

0 commit comments

Comments
 (0)