fix(parental-lock): replay the shake when the same field is refused again

A second refusal of the same field within 400ms set `shake` to the value
it already had, so the class never came off and the running CSS animation
only finished instead of replaying. Re-adding the class would not help
either: styles are not recomputed in between. The running shake animation
is now rewound through the Web Animations API. Under reduced motion there
is no animation, so this does nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-01 06:51:36 +02:00
1 parent 24fcea999d
commit 2e49c6feaf
3 files changed
+69 -3

No files matched your search

@@ -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');
@@ -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<CSSAnimation>[];
};
// jsdom has no Web Animations: stand in for the running shake.
const shake: Partial<CSSAnimation> = {
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 = () =>
@@ -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;