diff --git a/libs/ui/src/lib/value/value.component.html b/libs/ui/src/lib/value/value.component.html
index d5476f42d..15a8e371e 100644
--- a/libs/ui/src/lib/value/value.component.html
+++ b/libs/ui/src/lib/value/value.component.html
@@ -13,13 +13,16 @@
@if (enableCopyToClipboardButton) {
}
diff --git a/libs/ui/src/lib/value/value.component.spec.ts b/libs/ui/src/lib/value/value.component.spec.ts
new file mode 100644
index 000000000..3bdceddb4
--- /dev/null
+++ b/libs/ui/src/lib/value/value.component.spec.ts
@@ -0,0 +1,170 @@
+import { Clipboard } from '@angular/cdk/clipboard';
+import { Component, Input } from '@angular/core';
+import {
+ ComponentFixture,
+ TestBed,
+ fakeAsync,
+ flush,
+ tick
+} from '@angular/core/testing';
+import '@angular/localize/init';
+import { MatSnackBar } from '@angular/material/snack-bar';
+import { By } from '@angular/platform-browser';
+
+import type { GfValueComponent as GfValueComponentType } from './value.component';
+
+@Component({
+ // eslint-disable-next-line @angular-eslint/component-selector
+ selector: 'ion-icon',
+ standalone: true,
+ template: ''
+})
+class MockIonIcon {
+ @Input() name = '';
+}
+
+jest.mock('@ionic/angular/standalone', () => ({ IonIcon: MockIonIcon }));
+
+const { GfValueComponent } =
+ require('./value.component') as typeof import('./value.component');
+
+describe('GfValueComponent', () => {
+ let clipboard: { copy: jest.Mock };
+ let fixture: ComponentFixture;
+ let snackBar: { dismiss: jest.Mock; open: jest.Mock };
+
+ const getCopyButton = () =>
+ fixture.nativeElement.querySelector(
+ 'button[aria-label]'
+ ) as HTMLButtonElement;
+
+ const getCopyIcon = () =>
+ fixture.debugElement.query(By.directive(MockIonIcon))
+ .componentInstance as MockIonIcon;
+
+ beforeEach(async () => {
+ clipboard = { copy: jest.fn().mockReturnValue(true) };
+ snackBar = { dismiss: jest.fn(), open: jest.fn() };
+
+ await TestBed.configureTestingModule({
+ imports: [GfValueComponent],
+ providers: [
+ { provide: Clipboard, useValue: clipboard },
+ { provide: MatSnackBar, useValue: snackBar }
+ ]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(GfValueComponent);
+ fixture.componentRef.setInput('enableCopyToClipboardButton', true);
+ fixture.componentRef.setInput('locale', 'en-US');
+ fixture.componentRef.setInput('value', 42);
+ fixture.detectChanges();
+ });
+
+ it('shows confirmation immediately after copying', () => {
+ getCopyButton().click();
+ fixture.detectChanges();
+
+ expect(clipboard.copy).toHaveBeenCalledWith('42');
+ expect(snackBar.open).toHaveBeenCalledTimes(1);
+ expect(snackBar.open).toHaveBeenCalledWith(
+ expect.any(String),
+ undefined,
+ expect.objectContaining({ politeness: 'polite' })
+ );
+ expect(getCopyButton().title).toBe('Copied!');
+ expect(getCopyIcon().name).toBe('checkmark-outline');
+ });
+
+ it('does not show confirmation when copying fails', () => {
+ clipboard.copy.mockReturnValue(false);
+
+ getCopyButton().click();
+ fixture.detectChanges();
+
+ expect(getCopyButton().title).toBe('Copy to clipboard');
+ expect(getCopyIcon().name).toBe('copy-outline');
+ expect(snackBar.dismiss).toHaveBeenCalledTimes(1);
+ expect(snackBar.open).not.toHaveBeenCalled();
+ });
+
+ it('clears active confirmation when a repeated copy fails', fakeAsync(() => {
+ getCopyButton().click();
+ fixture.detectChanges();
+
+ clipboard.copy.mockReturnValue(false);
+
+ getCopyButton().click();
+ fixture.detectChanges();
+
+ expect(clipboard.copy).toHaveBeenCalledTimes(2);
+ expect(snackBar.dismiss).toHaveBeenCalledTimes(1);
+ expect(snackBar.open).toHaveBeenCalledTimes(1);
+ expect(getCopyButton().title).toBe('Copy to clipboard');
+ expect(getCopyIcon().name).toBe('copy-outline');
+
+ tick(2000);
+
+ expect(getCopyButton().title).toBe('Copy to clipboard');
+ expect(getCopyIcon().name).toBe('copy-outline');
+ }));
+
+ it('keeps the copy action accessible during confirmation', () => {
+ getCopyButton().click();
+ fixture.detectChanges();
+
+ expect(getCopyButton().getAttribute('aria-label')).toBe(
+ 'Copy to clipboard'
+ );
+ expect(getCopyButton().disabled).toBe(false);
+ expect(snackBar.open).toHaveBeenCalledTimes(1);
+ });
+
+ it('restores the copy affordance after two seconds', fakeAsync(() => {
+ getCopyButton().click();
+ fixture.detectChanges();
+
+ tick(1999);
+ fixture.detectChanges();
+
+ expect(getCopyButton().title).toBe('Copied!');
+ expect(getCopyIcon().name).toBe('checkmark-outline');
+
+ tick(1);
+ fixture.detectChanges();
+
+ expect(getCopyButton().title).toBe('Copy to clipboard');
+ expect(getCopyIcon().name).toBe('copy-outline');
+ }));
+
+ it('restarts the confirmation timer when copied again', fakeAsync(() => {
+ getCopyButton().click();
+ tick(1999);
+
+ getCopyButton().click();
+ fixture.detectChanges();
+
+ expect(clipboard.copy).toHaveBeenCalledTimes(2);
+ expect(snackBar.open).toHaveBeenCalledTimes(2);
+
+ tick(1);
+ fixture.detectChanges();
+
+ expect(getCopyButton().title).toBe('Copied!');
+ expect(getCopyIcon().name).toBe('checkmark-outline');
+
+ tick(1999);
+ fixture.detectChanges();
+
+ expect(getCopyButton().title).toBe('Copy to clipboard');
+ expect(getCopyIcon().name).toBe('copy-outline');
+ }));
+
+ it('cancels the pending confirmation timer when destroyed', fakeAsync(() => {
+ getCopyButton().click();
+
+ fixture.destroy();
+
+ expect(flush()).toBe(0);
+ }));
+});
diff --git a/libs/ui/src/lib/value/value.component.ts b/libs/ui/src/lib/value/value.component.ts
index dffcb89e7..87dd5efca 100644
--- a/libs/ui/src/lib/value/value.component.ts
+++ b/libs/ui/src/lib/value/value.component.ts
@@ -13,13 +13,14 @@ import {
input,
Input,
OnChanges,
+ OnDestroy,
ViewChild
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatSnackBar } from '@angular/material/snack-bar';
import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons';
-import { copyOutline } from 'ionicons/icons';
+import { checkmarkOutline, copyOutline } from 'ionicons/icons';
import { isNumber } from 'lodash';
import ms from 'ms';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@@ -32,7 +33,7 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
styleUrls: ['./value.component.scss'],
templateUrl: './value.component.html'
})
-export class GfValueComponent implements AfterViewInit, OnChanges {
+export class GfValueComponent implements AfterViewInit, OnChanges, OnDestroy {
@Input() colorizeSign = false;
@Input() deviceType: string;
@Input() enableCopyToClipboardButton = false;
@@ -54,22 +55,29 @@ export class GfValueComponent implements AfterViewInit, OnChanges {
public absoluteValue = 0;
public formattedValue = '';
public hasLabel = false;
+ public isCopied = false;
public isNumber = false;
public isString = false;
public useAbsoluteValue = false;
+ public readonly copiedTitle = $localize`Copied!`;
+ public readonly copyToClipboardTitle = $localize`Copy to clipboard`;
+
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private clipboard: Clipboard,
private snackBar: MatSnackBar
) {
addIcons({
+ checkmarkOutline,
copyOutline
});
}
public readonly precision = input();
+ private copyConfirmationTimeout?: ReturnType;
+
private readonly formatOptions = computed(() => {
const digits = this.hasPrecision ? this.precision() : 2;
@@ -175,18 +183,47 @@ export class GfValueComponent implements AfterViewInit, OnChanges {
}
}
+ public ngOnDestroy() {
+ this.clearCopyConfirmation();
+ }
+
public onCopyValueToClipboard() {
- this.clipboard.copy(String(this.value));
+ this.clearCopyConfirmation();
+
+ const hasCopied = this.clipboard.copy(String(this.value));
+
+ if (!hasCopied) {
+ this.snackBar.dismiss();
+
+ return;
+ }
+
+ this.isCopied = true;
+ this.copyConfirmationTimeout = setTimeout(() => {
+ this.copyConfirmationTimeout = undefined;
+ this.isCopied = false;
+ this.changeDetectorRef.markForCheck();
+ }, ms('2 seconds'));
this.snackBar.open(
'✅ ' + $localize`${this.value} has been copied to the clipboard`,
undefined,
{
- duration: ms('3 seconds')
+ duration: ms('3 seconds'),
+ politeness: 'polite'
}
);
}
+ private clearCopyConfirmation() {
+ if (this.copyConfirmationTimeout) {
+ clearTimeout(this.copyConfirmationTimeout);
+ this.copyConfirmationTimeout = undefined;
+ }
+
+ this.isCopied = false;
+ }
+
private initializeVariables() {
this.absoluteValue = 0;
this.formattedValue = '';