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.
This commit is contained in:
Maurice
2026-11-11 20:41:24 +00:00
parent a2b9d96e86
commit 7db5ec0721
2 changed files with 73 additions and 2 deletions
+52 -2
View File
@@ -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('<input name="title" enterkeyhint="done">');
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('<input name="item" enterkeyhint="done">');
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(`<input enterkeyhint="${hint}">`);
el.focus();
el.addEventListener('keydown', closeOnDone);
enter(el);
vi.runAllTimers();
expect(document.activeElement).toBe(el);
}
const handled = field('<input enterkeyhint="done">');
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();
});
});
+21
View File
@@ -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);
};
}