diff --git a/apps/electron-backend-e2e/src/parental-lock-pin-dialog.e2e.ts b/apps/electron-backend-e2e/src/parental-lock-pin-dialog.e2e.ts index 584832bb0..5980d6292 100644 --- a/apps/electron-backend-e2e/src/parental-lock-pin-dialog.e2e.ts +++ b/apps/electron-backend-e2e/src/parental-lock-pin-dialog.e2e.ts @@ -161,6 +161,33 @@ test.describe('Electron parental-lock PIN dialog', () => { expect(await pressEnterAndCatchShake(confirm)).toContain( 'pin-dialog-shake' ); + // Refused again while it still shakes, the field shakes from + // the start: its class never comes off, so the animation is + // rewound. Held at 300ms, the second refusal must reset it. + const replayedAt = await dialog + .locator('form') + .evaluate(async (form: HTMLFormElement) => { + const nextFrame = () => + new Promise((resolve) => + requestAnimationFrame(() => + requestAnimationFrame(resolve) + ) + ); + const field = form + .querySelector( + '[data-test-id="parental-lock-pin-confirm"]' + ) + ?.closest('mat-form-field') as HTMLElement; + form.requestSubmit(); + await nextFrame(); + const [shake] = field.getAnimations(); + shake.pause(); + shake.currentTime = 300; + form.requestSubmit(); + await nextFrame(); + return shake.currentTime; + }); + expect(replayedAt).toBe(0); await expect(dialog).toBeVisible(); await expect(mismatch).toBeVisible(); await expect(toggle).toHaveAttribute('aria-checked', 'false'); diff --git a/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.spec.ts b/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.spec.ts index 22cfc43bd..73697e7f5 100644 --- a/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.spec.ts +++ b/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.spec.ts @@ -243,6 +243,29 @@ describe('ParentalLockPinDialogComponent', () => { } }); + it('replays the shake when the same field is refused again', async () => { + const dialog = await openDialog({ mode: 'set' }); + const field = dialog + .query('parental-lock-pin') + ?.closest('mat-form-field') as HTMLElement & { + getAnimations?: () => Partial[]; + }; + // jsdom has no Web Animations: stand in for the running shake. + const shake: Partial = { + animationName: '_ngcontent-x_pin-dialog-shake', + currentTime: 250, + }; + field.getAnimations = () => [shake]; + + await dialog.type('parental-lock-pin', '24'); + await dialog.pressEnter(); + expect(shake.currentTime).toBe(250); + + await dialog.pressEnter(); + expect(dialog.component.shake()).toBe('pin'); + expect(shake.currentTime).toBe(0); + }); + it('refuses a too-short PIN on Enter with an error on the PIN field', async () => { const dialog = await openDialog({ mode: 'set' }); const pin = () => diff --git a/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.ts b/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.ts index c647f0add..42fb878d9 100644 --- a/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.ts +++ b/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.ts @@ -252,9 +252,12 @@ export class ParentalLockPinDialogComponent { private refuse(field: PinDialogField): void { this.refused.set(field); this.shakeField(field); - const input = - field === 'pin' ? this.pinInput() : this.confirmationInput(); - input?.nativeElement.focus(); + this.fieldInput(field)?.focus(); + } + + private fieldInput(field: PinDialogField): HTMLInputElement | undefined { + return (field === 'pin' ? this.pinInput() : this.confirmationInput()) + ?.nativeElement; } private clearRefusal(field: PinDialogField): void { @@ -269,6 +272,19 @@ export class ParentalLockPinDialogComponent { if (this.shakeTimer !== null) { window.clearTimeout(this.shakeTimer); } + if (this.shake() === field) { + // Same field again: its class stays on, so the running shake + // would only finish. Rewind it (none under reduced motion). + this.fieldInput(field) + ?.closest('mat-form-field') + ?.getAnimations?.() + .filter((animation) => + (animation as CSSAnimation).animationName?.includes( + 'pin-dialog-shake' + ) + ) + .forEach((animation) => (animation.currentTime = 0)); + } this.shake.set(field); this.shakeTimer = window.setTimeout(() => { this.shakeTimer = null;