diff --git a/src/cdk/clipboard/clipboard.md b/src/cdk/clipboard/clipboard.md index 5293a1280a56..67d3b9b5c4db 100644 --- a/src/cdk/clipboard/clipboard.md +++ b/src/cdk/clipboard/clipboard.md @@ -55,3 +55,10 @@ class HeroProfile { } } ``` + +If you're using the `cdkCopyToClipboard` you can pass in the `cdkCopyToClipboardAttempts` input +to automatically attempt to copy some text a certain number of times. + +```html + +``` diff --git a/src/cdk/clipboard/copy-to-clipboard.spec.ts b/src/cdk/clipboard/copy-to-clipboard.spec.ts index a3537ca3c1ae..34df05595ce4 100644 --- a/src/cdk/clipboard/copy-to-clipboard.spec.ts +++ b/src/cdk/clipboard/copy-to-clipboard.spec.ts @@ -1,8 +1,9 @@ -import {Component, EventEmitter, Input, Output} from '@angular/core'; +import {Component} from '@angular/core'; import {ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing'; import {Clipboard} from './clipboard'; import {ClipboardModule} from './clipboard-module'; +import {PendingCopy} from './pending-copy'; const COPY_CONTENT = 'copy content'; @@ -11,17 +12,18 @@ const COPY_CONTENT = 'copy content'; template: ` `, + [cdkCopyToClipboardAttempts]="attempts" + (cdkCopyToClipboardCopied)="copied($event)">`, }) class CopyToClipboardHost { - @Input() content = ''; - @Output() copied = new EventEmitter(); + content = ''; + attempts = 1; + copied = jasmine.createSpy('copied spy'); } describe('CdkCopyToClipboard', () => { let fixture: ComponentFixture; - let mockCopy: jasmine.Spy; - let copiedOutput: jasmine.Spy; + let clipboard: Clipboard; beforeEach(fakeAsync(() => { TestBed.configureTestingModule({ @@ -37,31 +39,69 @@ describe('CdkCopyToClipboard', () => { const host = fixture.componentInstance; host.content = COPY_CONTENT; - copiedOutput = jasmine.createSpy('copied'); - host.copied.subscribe(copiedOutput); - mockCopy = spyOn(TestBed.get(Clipboard), 'copy'); - + clipboard = TestBed.get(Clipboard); fixture.detectChanges(); }); it('copies content to clipboard upon click', () => { + spyOn(clipboard, 'copy'); fixture.nativeElement.querySelector('button')!.click(); - - expect(mockCopy).toHaveBeenCalledWith(COPY_CONTENT); + expect(clipboard.copy).toHaveBeenCalledWith(COPY_CONTENT); }); it('emits copied event true when copy succeeds', fakeAsync(() => { - mockCopy.and.returnValue(true); - fixture.nativeElement.querySelector('button')!.click(); + spyOn(clipboard, 'copy').and.returnValue(true); + fixture.nativeElement.querySelector('button')!.click(); - expect(copiedOutput).toHaveBeenCalledWith(true); - })); + expect(fixture.componentInstance.copied).toHaveBeenCalledWith(true); + })); it('emits copied event false when copy fails', fakeAsync(() => { - mockCopy.and.returnValue(false); - fixture.nativeElement.querySelector('button')!.click(); - tick(); + spyOn(clipboard, 'copy').and.returnValue(false); + fixture.nativeElement.querySelector('button')!.click(); + tick(); + + expect(fixture.componentInstance.copied).toHaveBeenCalledWith(false); + })); + + it('should be able to attempt multiple times before succeeding', fakeAsync(() => { + const maxAttempts = 3; + let attempts = 0; + spyOn(clipboard, 'beginCopy').and.returnValue({ + copy: () => ++attempts >= maxAttempts, + destroy: () => {} + } as PendingCopy); + fixture.componentInstance.attempts = maxAttempts; + fixture.detectChanges(); + + fixture.nativeElement.querySelector('button')!.click(); + fixture.detectChanges(); + tick(); + + expect(attempts).toBe(maxAttempts); + expect(fixture.componentInstance.copied).toHaveBeenCalledTimes(1); + expect(fixture.componentInstance.copied).toHaveBeenCalledWith(true); + })); + + it('should be able to attempt multiple times before failing', fakeAsync(() => { + const maxAttempts = 3; + let attempts = 0; + spyOn(clipboard, 'beginCopy').and.returnValue({ + copy: () => { + attempts++; + return false; + }, + destroy: () => {} + } as PendingCopy); + fixture.componentInstance.attempts = maxAttempts; + fixture.detectChanges(); - expect(copiedOutput).toHaveBeenCalledWith(false); - })); + fixture.nativeElement.querySelector('button')!.click(); + fixture.detectChanges(); + tick(); + + expect(attempts).toBe(maxAttempts); + expect(fixture.componentInstance.copied).toHaveBeenCalledTimes(1); + expect(fixture.componentInstance.copied).toHaveBeenCalledWith(false); + })); }); diff --git a/src/cdk/clipboard/copy-to-clipboard.ts b/src/cdk/clipboard/copy-to-clipboard.ts index 869fb8ee3469..9a6f90560a5c 100644 --- a/src/cdk/clipboard/copy-to-clipboard.ts +++ b/src/cdk/clipboard/copy-to-clipboard.ts @@ -6,10 +6,28 @@ * found in the LICENSE file at https://angular.io/license */ -import {Directive, EventEmitter, Input, Output} from '@angular/core'; - +import { + Directive, + EventEmitter, + Input, + Output, + NgZone, + InjectionToken, + Inject, + Optional, +} from '@angular/core'; import {Clipboard} from './clipboard'; +/** Object that can be used to configure the default options for `CdkCopyToClipboard`. */ +export interface CdkCopyToClipboardConfig { + /** Default number of attempts to make when copying text to the clipboard. */ + attempts?: number; +} + +/** Injection token that can be used to provide the default options to `CdkCopyToClipboard`. */ +export const CKD_COPY_TO_CLIPBOARD_CONFIG = + new InjectionToken('CKD_COPY_TO_CLIPBOARD_CONFIG'); + /** * Provides behavior for a button that when clicked copies content into user's * clipboard. @@ -24,6 +42,12 @@ export class CdkCopyToClipboard { /** Content to be copied. */ @Input('cdkCopyToClipboard') text: string = ''; + /** + * How many times to attempt to copy the text. This may be necessary for longer text, because + * the browser needs time to fill an intermediate textarea element and copy the content. + */ + @Input('cdkCopyToClipboardAttempts') attempts: number = 1; + /** * Emits when some text is copied to the clipboard. The * emitted value indicates whether copying was successful. @@ -38,10 +62,42 @@ export class CdkCopyToClipboard { */ @Output('copied') _deprecatedCopied = this.copied; - constructor(private readonly _clipboard: Clipboard) {} + constructor( + private _clipboard: Clipboard, + /** + * @deprecated _ngZone parameter to become required. + * @breaking-change 10.0.0 + */ + private _ngZone?: NgZone, + @Optional() @Inject(CKD_COPY_TO_CLIPBOARD_CONFIG) config?: CdkCopyToClipboardConfig) { + + if (config && config.attempts != null) { + this.attempts = config.attempts; + } + } /** Copies the current text to the clipboard. */ - copy() { - this.copied.emit(this._clipboard.copy(this.text)); + copy(attempts: number = this.attempts): void { + if (attempts > 1) { + let remainingAttempts = attempts; + const pending = this._clipboard.beginCopy(this.text); + const attempt = () => { + const successful = pending.copy(); + if (!successful && --remainingAttempts) { + // @breaking-change 10.0.0 Remove null check for `_ngZone`. + if (this._ngZone) { + this._ngZone.runOutsideAngular(() => setTimeout(attempt)); + } else { + setTimeout(attempt); + } + } else { + pending.destroy(); + this.copied.emit(successful); + } + }; + attempt(); + } else { + this.copied.emit(this._clipboard.copy(this.text)); + } } } diff --git a/tools/public_api_guard/cdk/clipboard.d.ts b/tools/public_api_guard/cdk/clipboard.d.ts index 8a62e784eff1..0373a94b92db 100644 --- a/tools/public_api_guard/cdk/clipboard.d.ts +++ b/tools/public_api_guard/cdk/clipboard.d.ts @@ -1,13 +1,21 @@ export declare class CdkCopyToClipboard { _deprecatedCopied: EventEmitter; + attempts: number; copied: EventEmitter; text: string; - constructor(_clipboard: Clipboard); - copy(): void; - static ɵdir: i0.ɵɵDirectiveDefWithMeta; + constructor(_clipboard: Clipboard, + _ngZone?: NgZone | undefined, config?: CdkCopyToClipboardConfig); + copy(attempts?: number): void; + static ɵdir: i0.ɵɵDirectiveDefWithMeta; static ɵfac: i0.ɵɵFactoryDef; } +export interface CdkCopyToClipboardConfig { + attempts?: number; +} + +export declare const CKD_COPY_TO_CLIPBOARD_CONFIG: InjectionToken; + export declare class Clipboard { constructor(document: any); beginCopy(text: string): PendingCopy;