From 9b1bf89382bb20e9668e5319b41afc5291a01656 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 8 Nov 2026 12:07:57 +0100 Subject: [PATCH] fix(native): a field low on a screen scrolls above the keyboard when it comes up --- client/src/native/keepFieldInView.test.ts | 36 +++++++++++++++++++++++ client/src/native/keepFieldInView.ts | 32 ++++++++++++++++++++ client/src/native/nativeApp.test.ts | 4 ++- client/src/native/nativeApp.ts | 2 ++ 4 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 client/src/native/keepFieldInView.test.ts create mode 100644 client/src/native/keepFieldInView.ts diff --git a/client/src/native/keepFieldInView.test.ts b/client/src/native/keepFieldInView.test.ts new file mode 100644 index 000000000..864bb44d7 --- /dev/null +++ b/client/src/native/keepFieldInView.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from 'vitest'; +import { keepFieldInView, revealFocusedField } from './keepFieldInView'; + +function field(tag: string, top: number, bottom: number): HTMLElement { + const el = document.createElement(tag); + document.body.appendChild(el); + el.getBoundingClientRect = () => ({ top, bottom }) as DOMRect; + el.scrollIntoView = vi.fn(); + if (tag === 'div') el.tabIndex = 0; + el.focus(); + return el; +} + +describe('keepFieldInView', () => { + it('FE-NATIVE-KBFIELD-001: a field the keyboard covers is scrolled to the middle, one in view or not for typing stays', () => { + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 500 }); + + const covered = field('input', 620, 660); + revealFocusedField(); + expect(covered.scrollIntoView).toHaveBeenCalledWith({ block: 'center', behavior: 'smooth' }); + + const visible = field('textarea', 100, 160); + revealFocusedField(); + expect(visible.scrollIntoView).not.toHaveBeenCalled(); + + const notTyping = field('div', 620, 660); + revealFocusedField(); + expect(notTyping.scrollIntoView).not.toHaveBeenCalled(); + }); + + it('FE-NATIVE-KBFIELD-002: listens for the keyboard having come up', () => { + const addListener = vi.fn(async () => ({ remove: vi.fn() })); + keepFieldInView({ addListener }); + expect(addListener).toHaveBeenCalledWith('keyboardDidShow', expect.any(Function)); + }); +}); diff --git a/client/src/native/keepFieldInView.ts b/client/src/native/keepFieldInView.ts new file mode 100644 index 000000000..063f181d1 --- /dev/null +++ b/client/src/native/keepFieldInView.ts @@ -0,0 +1,32 @@ +/** + * The field being typed into stays above the keyboard, on every screen. + * + * The app resizes the page body for the keyboard, but a field low in a + * scrolling pane (the settings, a long form) is not moved by that: it ends up + * under the keyboard and the user types blind. Once the keyboard is up, the + * focused field is scrolled to the middle of what is left of the window. + * Screens that keep their composer above the keyboard themselves (the chat, + * useKeyboardInset) are unaffected: a field already in view does not move. + */ + +const TYPING_TARGET = 'input, textarea, select, [contenteditable="true"], [contenteditable=""]'; + +interface KeyboardEvents { + addListener(event: 'keyboardDidShow', listener: () => void): Promise; +} + +/** Scrolls the focused field into view when it is one the user types into and it sits outside the window. */ +export function revealFocusedField(doc: Document = document): void { + const field = doc.activeElement; + if (!(field instanceof HTMLElement) || !field.matches(TYPING_TARGET)) return; + const box = field.getBoundingClientRect(); + const view = doc.defaultView; + const height = view?.visualViewport?.height ?? view?.innerHeight ?? 0; + if (box.top >= 0 && box.bottom <= height) return; + field.scrollIntoView({ block: 'center', behavior: 'smooth' }); +} + +/** Wires it to the keyboard of the app. */ +export function keepFieldInView(keyboard: KeyboardEvents): void { + void keyboard.addListener('keyboardDidShow', () => revealFocusedField()); +} diff --git a/client/src/native/nativeApp.test.ts b/client/src/native/nativeApp.test.ts index 6f5b531be..be5d7f446 100644 --- a/client/src/native/nativeApp.test.ts +++ b/client/src/native/nativeApp.test.ts @@ -12,7 +12,9 @@ import { keepOfflineShellFresh } from './offlineShell'; import { isNativeApp, isNativeIos } from './platform'; vi.mock('@capacitor/app', () => ({ App: { addListener: vi.fn(), minimizeApp: vi.fn() } })); -vi.mock('@capacitor/keyboard', () => ({ Keyboard: { setAccessoryBarVisible: vi.fn() } })); +vi.mock('@capacitor/keyboard', () => ({ + Keyboard: { setAccessoryBarVisible: vi.fn(), addListener: vi.fn(async () => ({ remove: vi.fn() })) }, +})); vi.mock('@capacitor/splash-screen', () => ({ SplashScreen: { hide: vi.fn() } })); vi.mock('@capacitor/status-bar', () => ({ StatusBar: { setStyle: vi.fn() }, Style: { Dark: 'DARK', Light: 'LIGHT' } })); vi.mock('./platform', () => ({ isNativeApp: vi.fn(), isNativeIos: vi.fn() })); diff --git a/client/src/native/nativeApp.ts b/client/src/native/nativeApp.ts index 5c14c10c1..d29c12fbd 100644 --- a/client/src/native/nativeApp.ts +++ b/client/src/native/nativeApp.ts @@ -7,6 +7,7 @@ import { useSettingsStore } from '../store/settingsStore'; import { isEffectivelyOffline } from '../sync/networkMode'; import { checkAppUpdate, loadNativeCapabilities } from './capabilities'; import { installNativeGeolocation } from './geolocation'; +import { keepFieldInView } from './keepFieldInView'; import { keepOfflineShellFresh } from './offlineShell'; import { isNativeApp, isNativeIos } from './platform'; import { openPushTarget, startNativePush } from './push'; @@ -61,6 +62,7 @@ export function startNativeApp(): void { if (isNativeIos()) { // The "‹ › Done" strip above the keyboard is a web form's, not an app's. void Keyboard.setAccessoryBarVisible({ isVisible: false }); + keepFieldInView(Keyboard); window.setTimeout(() => { if (isEffectivelyOffline()) return; // Not refreshed this time is no harm: the next launch tries again. An app