fix: keep sheet and dialog fields and buttons above the keyboard

This commit is contained in:
Linus Rath
2026-10-31 13:19:56 +01:00
parent 426f740359
commit 980243469c
13 changed files with 298 additions and 18 deletions
@@ -0,0 +1,164 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
// A Modal on Android is an edge-to-edge window the keyboard doesn't resize
// (and an iOS Modal never resizes), so the keyboard covered sheet inputs and
// their buttons: the New folder name and Save, the tag picker's "New tag"
// field, the Files new-folder prompt. useSheetBottomInset pads a sheet by the
// keyboard's overlap while it is open; full-screen modals pad their body by
// the part above the safe-area inset (useModalKeyboardPadding).
type Listener = (e: unknown) => void;
const keyboardListeners = new Map<string, Listener>();
let keyboardVisible = false;
let keyboardMetrics: { screenY: number; height: number } | undefined;
let os = 'android';
let safeBottom = 0;
vi.mock('react-native', () => ({
Dimensions: {
get: () => ({ width: 800, height: 1280 }),
addEventListener: () => ({ remove() {} }),
},
Keyboard: {
addListener: (name: string, fn: Listener) => {
keyboardListeners.set(name, fn);
return { remove() { keyboardListeners.delete(name); } };
},
isVisible: () => keyboardVisible,
metrics: () => keyboardMetrics,
dismiss: vi.fn(),
},
get Platform() {
return { OS: os };
},
}));
vi.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ top: 0, left: 0, right: 0, bottom: safeBottom }),
}));
// Just enough React to run the hook: one state slot that survives re-runs.
let state = 0;
vi.mock('react', () => ({
default: {
useState: (initial: number) => [state ?? initial, (v: number) => { state = v; }],
useRef: <T,>(v: T) => ({ current: v }),
useEffect: (fn: () => void) => { fn(); },
},
}));
import {
modalKeyboardPadding, sheetBottomInset, useModalKeyboardPadding, useSheetBottomInset,
} from '../sheet-bottom-inset';
beforeEach(() => {
keyboardListeners.clear();
keyboardVisible = false;
keyboardMetrics = undefined;
os = 'android';
safeBottom = 0;
state = 0;
});
describe('sheetBottomInset', () => {
it('keeps the navigation bar or taskbar clear without a keyboard', () => {
expect(sheetBottomInset(60, 0)).toBe(60);
});
it('keeps the keyboard clear while it is open', () => {
expect(sheetBottomInset(24, 324)).toBe(324);
});
});
describe('modalKeyboardPadding', () => {
it('adds the part of the keyboard above the safe-area inset on Android', () => {
expect(modalKeyboardPadding(324, 24, 'android')).toBe(300);
expect(modalKeyboardPadding(0, 24, 'android')).toBe(0);
});
it('leaves iOS to its KeyboardAvoidingView', () => {
expect(modalKeyboardPadding(324, 34, 'ios')).toBe(0);
});
});
describe('useSheetBottomInset', () => {
it('pads by the taskbar, then by the keyboard while it is open', () => {
safeBottom = 60;
expect(useSheetBottomInset(true)).toBe(60);
// Tablet: 1280 tall, keyboard top at 880.
keyboardListeners.get('keyboardDidShow')!({ endCoordinates: { screenY: 880, height: 340 } });
expect(useSheetBottomInset(true)).toBe(400);
keyboardListeners.get('keyboardDidHide')!({});
expect(useSheetBottomInset(true)).toBe(60);
});
it('uses a keyboard that is already open when the sheet opens', () => {
keyboardVisible = true;
keyboardMetrics = { screenY: 980, height: 300 };
useSheetBottomInset(true); // the effect reads the open keyboard
expect(useSheetBottomInset(true)).toBe(300);
});
it('follows the iOS frame events', () => {
os = 'ios';
safeBottom = 34;
useSheetBottomInset(true);
keyboardListeners.get('keyboardWillChangeFrame')!({ endCoordinates: { screenY: 944, height: 336 } });
expect(useSheetBottomInset(true)).toBe(336);
});
it("doesn't listen while the sheet is closed", () => {
safeBottom = 24;
expect(useSheetBottomInset(false)).toBe(24);
expect(keyboardListeners.size).toBe(0);
});
});
describe('useModalKeyboardPadding', () => {
it('ends a full-screen modal body above the keyboard', () => {
safeBottom = 24;
expect(useModalKeyboardPadding(true)).toBe(0);
keyboardListeners.get('keyboardDidShow')!({ endCoordinates: { screenY: 956, height: 300 } });
expect(useModalKeyboardPadding(true)).toBe(300);
});
});
// The sheets and dialogs with a text field in the files this fix covers.
const SRC = path.resolve(__dirname, '../..');
const WITH_INPUTS = [
'components/Dialog.tsx',
'components/TagSheet.tsx',
'components/TemplateSheet.tsx',
'components/files/ShareSheet.tsx',
'components/filters/FilterRuleModal.tsx',
'components/filters/SieveEditorSheet.tsx',
'components/settings/ContentSendersSettings.tsx',
'components/settings/DelegateDialog.tsx',
'components/settings/FolderSettings.tsx',
'components/settings/IdentitySettings.tsx',
'components/settings/TemplateSettings.tsx',
'screens/FilesScreen.tsx',
];
function modalBodies(source: string): string[] {
const out: string[] = [];
const re = /<(SafeAreaModal|Modal)[\s>]/g;
let m: RegExpExecArray | null;
while ((m = re.exec(source))) {
const end = source.indexOf(`</${m[1]}>`, m.index);
out.push(source.slice(m.index, end < 0 ? undefined : end));
}
return out;
}
describe('modals with a text field', () => {
it('make room for the keyboard', () => {
for (const rel of WITH_INPUTS) {
const bodies = modalBodies(fs.readFileSync(path.join(SRC, rel), 'utf8'))
.filter((b) => /<(TextInput|Input|NewTagRow)\b/.test(b));
expect(bodies.length, rel).toBeGreaterThan(0);
for (const body of bodies) expect(/bottomInset|keyboardPadding/.test(body), rel).toBe(true);
}
});
});
+92 -2
View File
@@ -1,4 +1,77 @@
import React from 'react';
import { Dimensions, Keyboard, Platform, type KeyboardMetrics } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { keyboardObstruction } from './use-keyboard-obstruction';
/**
* Bottom padding for a sheet whose surface reaches the screen bottom: clear
* of the navigation bar, and of the keyboard while it is open (the keyboard's
* overlap is measured from the screen bottom, so it already covers the bar).
*/
export function sheetBottomInset(safeBottom: number, keyboard: number): number {
return Math.max(0, safeBottom, keyboard);
}
/**
* Extra bottom padding for a full-screen modal whose SafeAreaView already
* keeps `safeBottom` clear: the part of the keyboard above that. Android only;
* on iOS those modals use a KeyboardAvoidingView.
*/
export function modalKeyboardPadding(keyboard: number, safeBottom: number, os: string): number {
if (os !== 'android') return 0;
return Math.max(0, Math.round(keyboard - safeBottom));
}
/**
* How much of the screen bottom the keyboard covers while `enabled`. A Modal
* on Android is an edge-to-edge window the keyboard does not resize, and an
* iOS Modal never resizes, so a sheet has to make room itself. Tracked only
* while the sheet is open, so a hidden sheet doesn't re-render on every
* keyboard change. A rotation leaves a stale frame (Android sends no new
* keyboard event), so it closes the keyboard like use-keyboard-obstruction.
*/
function useKeyboardOverlap(enabled: boolean, safeBottom: number): number {
const [overlap, setOverlap] = React.useState(0);
const insetRef = React.useRef(safeBottom);
insetRef.current = safeBottom;
React.useEffect(() => {
if (!enabled) {
setOverlap(0);
return undefined;
}
const apply = (frame: Pick<KeyboardMetrics, 'screenY' | 'height'> | undefined | null) => {
if (!frame) {
setOverlap(0);
return;
}
setOverlap(keyboardObstruction(Dimensions.get('screen').height, frame.screenY, frame.height, insetRef.current));
};
// The keyboard may already be up when the sheet opens.
if (Keyboard.isVisible()) apply(Keyboard.metrics());
const subs =
Platform.OS === 'ios'
? [
Keyboard.addListener('keyboardWillChangeFrame', (e) => apply(e.endCoordinates)),
Keyboard.addListener('keyboardWillHide', () => setOverlap(0)),
]
: [
Keyboard.addListener('keyboardDidShow', (e) => apply(e.endCoordinates)),
Keyboard.addListener('keyboardDidHide', () => setOverlap(0)),
];
let lastWindow = Dimensions.get('window');
const dims = Dimensions.addEventListener('change', ({ window }) => {
if (window.width === lastWindow.width && window.height === lastWindow.height) return;
lastWindow = window;
setOverlap(0);
if (Keyboard.isVisible()) Keyboard.dismiss();
});
return () => {
for (const s of subs) s.remove();
dims.remove();
};
}, [enabled]);
return enabled ? overlap : 0;
}
/**
* How far the content of a bottom sheet or dialog has to stay above the
@@ -11,10 +84,27 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
* aligns it with `justifyContent: 'flex-end'`) puts its last row under that
* bar, where a tap goes to the taskbar instead (an app icon opened). The sheet
* keeps its surface down to the screen edge and pads its content by this.
* The same window doesn't shrink for the keyboard, so while the keyboard is
* open this is its overlap instead: a sheet's text field and its Save button
* stay above it, and a centred dialog padded by it moves up.
*
* Inside a plain Modal the insets come from the app's provider (React context
* reaches into the Modal); inside a SafeAreaModal from the Modal's own.
* `enabled` is the sheet's visibility, for sheets that stay mounted.
*/
export function useSheetBottomInset(): number {
return useSafeAreaInsets().bottom;
export function useSheetBottomInset(enabled = true): number {
const safeBottom = useSafeAreaInsets().bottom;
const keyboard = useKeyboardOverlap(enabled, safeBottom);
return sheetBottomInset(safeBottom, keyboard);
}
/**
* `modalKeyboardPadding` for a full-screen modal (a SafeAreaView with the
* bottom edge inside a SafeAreaModal): pad the scrolling body by this so the
* focused field and the last row stay above the keyboard on Android.
*/
export function useModalKeyboardPadding(enabled = true): number {
const safeBottom = useSafeAreaInsets().bottom;
const keyboard = useKeyboardOverlap(enabled, safeBottom);
return modalKeyboardPadding(keyboard, safeBottom, Platform.OS);
}