diff --git a/mobile/src/platform/keyboard-close-blur.web.test.ts b/mobile/src/platform/keyboard-close-blur.web.test.ts index 62b14b1021f..52de9babec7 100644 --- a/mobile/src/platform/keyboard-close-blur.web.test.ts +++ b/mobile/src/platform/keyboard-close-blur.web.test.ts @@ -37,7 +37,7 @@ function focusedField(userAgent: string): HTMLInputElement { describe('a keyboard the phone closes', () => { beforeEach(() => { - vi.useFakeTimers({ toFake: ['setTimeout', 'performance'] }) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] }) publishShellKeyboardSource({ read: () => shellHeight, subscribe: (listener) => { @@ -62,10 +62,24 @@ describe('a keyboard the phone closes', () => { expect(document.activeElement).toBe(field) shellReports(0) + vi.advanceTimersByTime(1000) expect(document.activeElement).not.toBe(field) }) + it('keeps the field focused when the keyboard closes and reopens for a switch to a voice IME', () => { + const field = focusedField(ANDROID) + startBlurringOnKeyboardClose() + shellReports(300) + + shellReports(0) + vi.advanceTimersByTime(250) + shellReports(200) + vi.advanceTimersByTime(5000) + + expect(document.activeElement).toBe(field) + }) + // The second case is tapping the underlined word after Back: that tap both focuses the field and opens the menu. it.each([ ['already focused', false], @@ -105,7 +119,7 @@ describe('a keyboard the phone closes', () => { target.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })) vi.advanceTimersByTime(waitMs) shellReports(0) - vi.advanceTimersByTime(100) + vi.advanceTimersByTime(1000) expect(document.activeElement).not.toBe(field) }) diff --git a/mobile/src/platform/keyboard-close-blur.web.ts b/mobile/src/platform/keyboard-close-blur.web.ts index 2611184750e..54087c6929e 100644 --- a/mobile/src/platform/keyboard-close-blur.web.ts +++ b/mobile/src/platform/keyboard-close-blur.web.ts @@ -5,6 +5,8 @@ import { subscribeSoftKeyboard } from './keyboard-occlusion.web' // Chromium hides the IME itself to show a tapped autocorrected word's menu; Back comes with no touch. const FIELD_TAP_HIDE_WINDOW_MS = 1000 const KEYBOARD_REOPEN_DELAY_MS = 50 +// A keyboard's mic switches to a voice IME, which closes the keyboard and opens again ~250 ms later; Back opens none. +const IME_SWITCH_WINDOW_MS = 1000 // Android raises the IME only for a new focus session, as for the terminal live input. function reopenKeyboard(field: HTMLElement): void { @@ -14,12 +16,13 @@ function reopenKeyboard(field: HTMLElement): void { } } -// Android Back hides the IME without blurring; blur now so the next terminal touch cannot reopen it. +// Android Back hides the IME without blurring; blur once it stays closed so the next terminal touch cannot reopen it. export function startBlurringOnKeyboardClose(): void { if (hostOs() !== 'android') { return } let lastTouch: { target: Node; at: number } | null = null + let pendingBlur: ReturnType | undefined document.addEventListener( 'pointerdown', (event) => { @@ -29,7 +32,7 @@ export function startBlurringOnKeyboardClose(): void { { capture: true } ) subscribeSoftKeyboard( - () => {}, + () => clearTimeout(pendingBlur), () => { const field = document.activeElement const touch = lastTouch @@ -42,7 +45,12 @@ export function startBlurringOnKeyboardClose(): void { ) { setTimeout(() => reopenKeyboard(field), KEYBOARD_REOPEN_DELAY_MS) } else { - Keyboard.dismiss() + // Leave alone a field focus moved to meanwhile. + pendingBlur = setTimeout(() => { + if (document.activeElement === field) { + Keyboard.dismiss() + } + }, IME_SWITCH_WINDOW_MS) } } )