From 24fcea999d5cfa7877ef43cc824e365531e5cf3d Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 1 Oct 2026 06:43:33 +0200 Subject: [PATCH] fix(parental-lock): tell Enter in the PIN field from a click on Save Moving on to an empty repeat was decided by document.activeElement in submit(). WebKit does not focus a clicked button, so a click on Save with the PIN field focused looked like Enter: focus moved and no error showed. The advance is now a keydown.enter handler on the PIN field that prevents the submission, and submit() refuses an empty repeat on every path. Co-Authored-By: Claude Opus 5.5 --- .../src/parental-lock-pin-dialog.e2e.ts | 9 +++++++ docs/architecture/parental-lock.md | 3 ++- .../parental-lock-pin-dialog.component.html | 1 + ...parental-lock-pin-dialog.component.spec.ts | 25 +++++++++++++++++-- .../parental-lock-pin-dialog.component.ts | 23 ++++++++++++----- 5 files changed, 52 insertions(+), 9 deletions(-) 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 414a6c0fc..584832bb0 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 @@ -110,7 +110,16 @@ test.describe('Electron parental-lock PIN dialog', () => { 'new-password' ); + // Enter after the PIN moves on to the empty repeat; Save + // with the repeat still empty is refused. await pin.fill(PIN); + await pin.press('Enter'); + await expect(confirm).toBeFocused(); + await expect(mismatch).toBeHidden(); + await submit.click(); + await expect(mismatch).toBeVisible(); + await expect(dialog).toBeVisible(); + await confirm.fill(PIN.slice(0, 3)); await expect(mismatch).toBeHidden(); await expect(confirm).toHaveAttribute('aria-invalid', 'false'); diff --git a/docs/architecture/parental-lock.md b/docs/architecture/parental-lock.md index 09502168b..8ffda3a1d 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -429,7 +429,8 @@ so a cancelled or refused PIN leaves it showing the real state. disabled default button; `submit()` refuses instead, shakes the field (no shake under `prefers-reduced-motion: reduce`) and focuses it — except that Enter in the PIN field with the repeat still empty only moves focus - to the repeat. Set + to the repeat (a `keydown.enter` handler: focus cannot tell Enter from a + click on Save, since WebKit does not focus a clicked button). Set mode marks both inputs `autocomplete="new-password"`. The Electron `parental-lock-pin-dialog.e2e.ts` covers the labels and the mismatch → fix → save flow. diff --git a/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.html b/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.html index e932a6ec3..0a7a63987 100644 --- a/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.html +++ b/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.html @@ -26,6 +26,7 @@ [errorStateMatcher]="pinErrorState" [ngModel]="pin()" (ngModelChange)="onPinInput($event)" + (keydown.enter)="onPinEnter($event)" name="pin" /> 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 f21e3160a..22cfc43bd 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 @@ -171,16 +171,37 @@ describe('ParentalLockPinDialogComponent', () => { it('moves on to an empty repeat on Enter in the PIN field', async () => { const dialog = await openDialog({ mode: 'set' }); + const enter = new KeyboardEvent('keydown', { + key: 'Enter', + cancelable: true, + }); await dialog.type('parental-lock-pin', '2468'); - dialog.query('parental-lock-pin')?.focus(); - await dialog.pressEnter(); + dialog.query('parental-lock-pin')?.dispatchEvent(enter); + await dialog.fixture.whenStable(); + // Handled before the form sees it: no implicit submission. + expect(enter.defaultPrevented).toBe(true); expect(document.activeElement).toBe( dialog.query('parental-lock-pin-confirm') ); expect(dialog.query('parental-lock-pin-mismatch')).toBeNull(); expect(dialog.component.shake()).toBeNull(); + }); + + it('refuses an empty repeat on Save while the PIN field keeps focus', async () => { + // WebKit does not focus a clicked button, so focus says nothing + // about how the form was submitted. + const dialog = await openDialog({ mode: 'set' }); + + await dialog.type('parental-lock-pin', '2468'); + dialog.query('parental-lock-pin')?.focus(); + await dialog.pressEnter(); + + expect(text(dialog.query('parental-lock-pin-mismatch'))).toBe( + KEYS.mismatch + ); + expect(dialog.component.shake()).toBe('confirmation'); expect(dialog.close).not.toHaveBeenCalled(); }); 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 ce1393e4a..c647f0add 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 @@ -186,6 +186,23 @@ export class ParentalLockPinDialogComponent { this.clearRefusal('confirmation'); } + /** + * Set mode: Enter after a complete first PIN moves on to an empty repeat + * instead of submitting. Handled on the key, not in `submit()`: focus + * cannot tell Enter from a click on Save, as WebKit does not focus a + * clicked button. + */ + onPinEnter(event: Event): void { + if ( + this.isSetMode() && + !this.confirmation() && + isValidParentalLockPin(this.pin()) + ) { + event.preventDefault(); + this.confirmationInput()?.nativeElement.focus(); + } + } + async submit(): Promise { if (this.busy() || this.inCooldown()) { return; @@ -197,12 +214,6 @@ export class ParentalLockPinDialogComponent { return; } if (this.isSetMode()) { - const pinField = this.pinInput()?.nativeElement; - if (!this.confirmation() && document.activeElement === pinField) { - // Enter after the first PIN moves on to the repeat. - this.confirmationInput()?.nativeElement.focus(); - return; - } if (this.confirmation() !== pin) { this.refuse('confirmation'); return;