fix(native): a field low on a screen scrolls above the keyboard when it comes up

This commit is contained in:
Maurice
2026-11-08 12:07:57 +01:00
parent c7f144d3ff
commit 9b1bf89382
4 changed files with 73 additions and 1 deletions
+36
View File
@@ -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));
});
});
+32
View File
@@ -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<unknown>;
}
/** 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());
}
+3 -1
View File
@@ -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() }));
+2
View File
@@ -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