From 7db5ec0721ecaf5e08ac65b4ff6e391d6e93af4e Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 11 Nov 2026 20:41:24 +0000 Subject: [PATCH] fix(native): the keyboard's done key closes the keyboard A field outside a form gets enterkeyhint="done", but a web view only sends Enter and nothing listened, so the key did nothing. When no handler took the Enter (same text, still focused a moment later) the field lets go of the focus; a field that acts on Enter and clears itself keeps it. TestFlight feedback, 5.1.3. --- client/src/native/keyboardFields.test.ts | 54 +++++++++++++++++++++++- client/src/native/keyboardFields.ts | 21 +++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/client/src/native/keyboardFields.test.ts b/client/src/native/keyboardFields.test.ts index a66006451..2bac3d8ba 100644 --- a/client/src/native/keyboardFields.test.ts +++ b/client/src/native/keyboardFields.test.ts @@ -1,5 +1,5 @@ -import { afterEach, describe, expect, it } from 'vitest'; -import { prepareField, prepareKeyboardFields } from './keyboardFields'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { closeOnDone, prepareField, prepareKeyboardFields } from './keyboardFields'; function field(html: string): HTMLInputElement { document.body.innerHTML = html; @@ -89,3 +89,53 @@ describe('prepareKeyboardFields', () => { expect(b.hasAttribute('enterkeyhint')).toBe(false); }); }); + +describe('closeOnDone', () => { + const enter = (el: HTMLElement) => el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + + it('closes the keyboard when nothing took the Enter of a "done" field', () => { + vi.useFakeTimers(); + const el = field(''); + el.focus(); + el.addEventListener('keydown', closeOnDone); + enter(el); + vi.runAllTimers(); + expect(document.activeElement).not.toBe(el); + vi.useRealTimers(); + }); + + it('keeps the keyboard for a field that acted on the Enter and cleared itself', () => { + vi.useFakeTimers(); + const el = field(''); + el.value = 'Socks'; + el.focus(); + el.addEventListener('keydown', () => { + el.value = ''; + }); + el.addEventListener('keydown', closeOnDone); + enter(el); + vi.runAllTimers(); + expect(document.activeElement).toBe(el); + vi.useRealTimers(); + }); + + it('leaves "go" and "search" fields and handled Enters alone', () => { + vi.useFakeTimers(); + for (const hint of ['go', 'search']) { + const el = field(``); + el.focus(); + el.addEventListener('keydown', closeOnDone); + enter(el); + vi.runAllTimers(); + expect(document.activeElement).toBe(el); + } + const handled = field(''); + handled.focus(); + handled.addEventListener('keydown', (e) => e.preventDefault()); + handled.addEventListener('keydown', closeOnDone); + handled.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); + vi.runAllTimers(); + expect(document.activeElement).toBe(handled); + vi.useRealTimers(); + }); +}); diff --git a/client/src/native/keyboardFields.ts b/client/src/native/keyboardFields.ts index 3a7777f1f..aa8f04ce0 100644 --- a/client/src/native/keyboardFields.ts +++ b/client/src/native/keyboardFields.ts @@ -18,6 +18,12 @@ * * An attribute the field sets itself always wins, and textareas, which take * Enter as a new line, keep the return key. Only the app calls this. + * + * The "done" key also has to close the keyboard: a web view only sends Enter, + * and a field outside a form has nothing listening for it, so the key did + * nothing. When no handler took the Enter (the field still has focus and the + * same text a moment later) the field lets go of the focus. A field that acts + * on Enter and clears itself, such as a list that adds the next item, keeps it. */ const SKIP_TYPES = new Set([ @@ -104,14 +110,29 @@ export function prepareField(target: EventTarget | null): void { } } +/** Enter on a "done" field that nothing acted on closes the keyboard. */ +export function closeOnDone(e: KeyboardEvent): void { + const el = e.target; + if (e.key !== 'Enter' || e.isComposing || e.defaultPrevented) return; + if (!(el instanceof HTMLInputElement) || el.getAttribute('enterkeyhint') !== 'done') return; + const before = el.value; + // After every handler of this Enter, including ones React schedules. + setTimeout(() => { + if (el.ownerDocument.activeElement === el && el.value === before) el.blur(); + }, 0); +} + export function prepareKeyboardFields(doc: Document = document): () => void { // On the touch, before the focus: the keyboard is set up as the field takes it. const onTouch = (e: Event) => prepareField(e.target instanceof Element ? e.target.closest('input') : null); const onFocus = (e: FocusEvent) => prepareField(e.target); doc.addEventListener('touchstart', onTouch, { capture: true, passive: true }); doc.addEventListener('focusin', onFocus, true); + // Bubbling, so a field's own handler runs first and can take the Enter. + doc.addEventListener('keydown', closeOnDone); return () => { doc.removeEventListener('touchstart', onTouch, { capture: true }); doc.removeEventListener('focusin', onFocus, true); + doc.removeEventListener('keydown', closeOnDone); }; }