From 980243469cd0decfc27905e00a71b5e1d049be8e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:19:56 +0100 Subject: [PATCH] fix: keep sheet and dialog fields and buttons above the keyboard --- src/components/Dialog.tsx | 6 +- src/components/TagSheet.tsx | 7 +- src/components/TemplateSheet.tsx | 5 +- src/components/filters/FilterRuleModal.tsx | 5 +- src/components/filters/SieveEditorSheet.tsx | 5 +- .../settings/ContentSendersSettings.tsx | 5 +- src/components/settings/DelegateDialog.tsx | 5 +- src/components/settings/FolderSettings.tsx | 4 +- src/components/settings/IdentitySettings.tsx | 3 +- src/components/settings/TemplateSettings.tsx | 3 +- src/lib/__tests__/sheet-bottom-inset.test.ts | 164 ++++++++++++++++++ src/lib/sheet-bottom-inset.ts | 94 +++++++++- src/screens/FilesScreen.tsx | 10 +- 13 files changed, 298 insertions(+), 18 deletions(-) create mode 100644 src/lib/__tests__/sheet-bottom-inset.test.ts diff --git a/src/components/Dialog.tsx b/src/components/Dialog.tsx index 99fb47d7..0df26e58 100644 --- a/src/components/Dialog.tsx +++ b/src/components/Dialog.tsx @@ -6,6 +6,7 @@ import { spacing, radius, typography, type ThemePalette } from '../theme/tokens' import { useColors } from '../theme/colors'; import { useLocaleStore } from '../stores/locale-store'; import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; +import { useSheetBottomInset } from '../lib/sheet-bottom-inset'; interface DialogInput { value: string; @@ -53,11 +54,14 @@ export default function Dialog({ const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); + // The card centres in the space above the keyboard (a prompt's field and + // its confirm button stay visible) and above the navigation bar. + const bottomInset = useSheetBottomInset(visible); const confirmLabel = confirmText ?? t('common.confirm', 'Confirm'); const cancelLabel = cancelText ?? t('common.cancel', 'Cancel'); return ( - + {variant === 'destructive' && ( diff --git a/src/components/TagSheet.tsx b/src/components/TagSheet.tsx index 7c08467f..ebd3ad7b 100644 --- a/src/components/TagSheet.tsx +++ b/src/components/TagSheet.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { X, Check, Tag } from './icons'; import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'; import { useColors } from '../theme/colors'; @@ -13,6 +12,7 @@ import { getEmailTagIds } from '../lib/thread-utils'; import type { Email } from '../api/types'; import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; import { useSheetInsets } from '../lib/use-layout'; +import { useSheetBottomInset } from '../lib/sheet-bottom-inset'; import { NewTagRow } from './NewTagRow'; interface TagSheetProps { @@ -29,7 +29,8 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); - const insets = useSafeAreaInsets(); + // Above the taskbar, and above the keyboard while "New tag" is typed in. + const bottomInset = useSheetBottomInset(visible); const slideY = React.useRef(new Animated.Value(500)).current; // Centred on a wide window, clear of side insets in landscape. const sheetInsets = useSheetInsets(); @@ -77,7 +78,7 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle diff --git a/src/components/TemplateSheet.tsx b/src/components/TemplateSheet.tsx index 2faef8e6..bcde0be3 100644 --- a/src/components/TemplateSheet.tsx +++ b/src/components/TemplateSheet.tsx @@ -12,6 +12,7 @@ import { useTemplatesStore, type EmailTemplate } from '../stores/templates-store import { filterTemplates } from '../lib/template-utils'; import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; import { useSheetInsets } from '../lib/use-layout'; +import { useSheetBottomInset } from '../lib/sheet-bottom-inset'; import { TemplateSettings } from './settings/TemplateSettings'; interface TemplateSheetProps { @@ -41,6 +42,8 @@ export function TemplateSheet({ visible, onClose, onPick }: TemplateSheetProps) const [query, setQuery] = React.useState(''); // The template manager, shown in place of the picker. const [managing, setManaging] = React.useState(false); + // Above the keyboard while the search field is typed in. + const bottomInset = useSheetBottomInset(visible && !managing); const slideY = React.useRef(new Animated.Value(500)).current; // Centred on a wide window, clear of side insets in landscape. const sheetInsets = useSheetInsets(); @@ -130,7 +133,7 @@ export function TemplateSheet({ visible, onClose, onPick }: TemplateSheetProps) diff --git a/src/components/filters/FilterRuleModal.tsx b/src/components/filters/FilterRuleModal.tsx index 7968b597..0bfb53a2 100644 --- a/src/components/filters/FilterRuleModal.tsx +++ b/src/components/filters/FilterRuleModal.tsx @@ -45,6 +45,7 @@ import { SafeAreaModal } from '../SafeAreaModal'; import { useFilterStore } from '../../stores/filter-store'; import { forwardsOfEditedRule, ruleForwardLimit } from '../../lib/sieve/forward-limit'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; +import { useModalKeyboardPadding } from '../../lib/sheet-bottom-inset'; const ALL_FIELDS: FilterConditionField[] = ['from', 'to', 'cc', 'subject', 'header', 'size', 'body', 'attachment', 'all']; @@ -91,6 +92,8 @@ export function FilterRuleModal({ const c = useColors(); const styles = useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); + // The Android dialog doesn't shrink for the keyboard; the body ends above it. + const keyboardPadding = useModalKeyboardPadding(visible); const keywords = useKeywordsStore((s) => s.keywords); const isEdit = !!editedRule; const rule = editedRule ?? initialRule; @@ -315,7 +318,7 @@ export function FilterRuleModal({ diff --git a/src/components/filters/SieveEditorSheet.tsx b/src/components/filters/SieveEditorSheet.tsx index 97337e49..db97d1c0 100644 --- a/src/components/filters/SieveEditorSheet.tsx +++ b/src/components/filters/SieveEditorSheet.tsx @@ -11,6 +11,7 @@ import Button from '../Button'; import { useLocaleStore } from '../../stores/locale-store'; import { SafeAreaModal } from '../SafeAreaModal'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; +import { useModalKeyboardPadding } from '../../lib/sheet-bottom-inset'; import { seedsOnRender } from '../../lib/sieve/editor-seed'; interface SieveEditorSheetProps { @@ -25,6 +26,8 @@ export function SieveEditorSheet({ visible, content, onSave, onClose, onValidate const c = useColors(); const styles = useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); + // The Android dialog doesn't shrink for the keyboard; the body ends above it. + const keyboardPadding = useModalKeyboardPadding(visible); const [script, setScript] = useState(content); const [isValidating, setIsValidating] = useState(false); @@ -85,7 +88,7 @@ export function SieveEditorSheet({ visible, content, onSave, onClose, onValidate diff --git a/src/components/settings/ContentSendersSettings.tsx b/src/components/settings/ContentSendersSettings.tsx index f8a14461..e33824c4 100644 --- a/src/components/settings/ContentSendersSettings.tsx +++ b/src/components/settings/ContentSendersSettings.tsx @@ -14,6 +14,7 @@ import { isTrustedSendersSyncOn } from '../../lib/trusted-senders'; import { removeTrustedSenderWithUndo } from '../../lib/settings-undo'; import { useLocaleStore } from '../../stores/locale-store'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; +import { useSheetBottomInset } from '../../lib/sheet-bottom-inset'; import { ToastHost } from '../ToastHost'; interface TrustedRow { @@ -48,6 +49,8 @@ export function ContentSendersSettings() { const removeFromTrustedSendersBook = useContactsStore((s) => s.removeFromTrustedSendersBook); const [modalOpen, setModalOpen] = useState(false); + // The card centres above the keyboard while an address is typed in. + const bottomInset = useSheetBottomInset(modalOpen); const [newSender, setNewSender] = useState(''); const [busy, setBusy] = useState(null); @@ -197,7 +200,7 @@ export function ContentSendersSettings() { animationType="fade" onRequestClose={() => setModalOpen(false)} > - setModalOpen(false)}> + setModalOpen(false)}> {}}> {t('settings.email_behavior.trusted_senders.modal_title', 'Trusted Senders')} diff --git a/src/components/settings/DelegateDialog.tsx b/src/components/settings/DelegateDialog.tsx index 28bd89ff..76cf9553 100644 --- a/src/components/settings/DelegateDialog.tsx +++ b/src/components/settings/DelegateDialog.tsx @@ -19,6 +19,7 @@ import { spacing, radius, typography, type ThemePalette } from '../../theme/toke import { useColors } from '../../theme/colors'; import { useLocaleStore } from '../../stores/locale-store'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; +import { useModalKeyboardPadding } from '../../lib/sheet-bottom-inset'; import { findPrincipalByEmail } from '../../api/delegation'; import type { Principal } from '../../api/types'; import type { PrincipalListing } from '../../api/files'; @@ -74,6 +75,8 @@ export function DelegateDialog({ const c = useColors(); const styles = useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); + // The Android dialog doesn't shrink for the keyboard; the body ends above it. + const keyboardPadding = useModalKeyboardPadding(); const changing = !!initial; const [principal, setPrincipal] = useState(initial?.principal ?? null); @@ -154,7 +157,7 @@ export function DelegateDialog({ return ( - + {changing && principal diff --git a/src/components/settings/FolderSettings.tsx b/src/components/settings/FolderSettings.tsx index e51722ec..2268cb5e 100644 --- a/src/components/settings/FolderSettings.tsx +++ b/src/components/settings/FolderSettings.tsx @@ -69,7 +69,6 @@ function pathOf(all: Mailbox[], mb: Mailbox, t: (k: string, f?: string) => strin export function FolderSettings() { // Centred on a wide window, clear of side insets in landscape. const sheetInsets = useSheetInsets(); - const bottomInset = useSheetBottomInset(); const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); @@ -78,6 +77,9 @@ export function FolderSettings() { const fetchMailboxes = useEmailStore((s) => s.fetchMailboxes); const [editor, setEditor] = useState(null); + // Save and Cancel above the taskbar, and above the keyboard while the + // name is typed in. + const bottomInset = useSheetBottomInset(!!editor); const [draftName, setDraftName] = useState(''); const [draftParent, setDraftParent] = useState(NO_PARENT); const [draftAccount, setDraftAccount] = useState(OWN_ACCOUNT); diff --git a/src/components/settings/IdentitySettings.tsx b/src/components/settings/IdentitySettings.tsx index 96c6bb6b..7daaa775 100644 --- a/src/components/settings/IdentitySettings.tsx +++ b/src/components/settings/IdentitySettings.tsx @@ -99,7 +99,6 @@ function parseAddressList(value: string): { list: EmailAddress[]; invalid: strin export function IdentitySettings() { // Centred on a wide window, clear of side insets in landscape. const sheetInsets = useSheetInsets(); - const bottomInset = useSheetBottomInset(); const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const identities = useSettingsStore((s) => s.identities); @@ -113,6 +112,8 @@ export function IdentitySettings() { const preferredId = accountId ? preferredIdentityIds[accountId] : undefined; const [editing, setEditing] = useState(null); + // Save above the taskbar, and above the keyboard while a field is typed in. + const bottomInset = useSheetBottomInset(!!editing); const [saving, setSaving] = useState(false); const [deletingId, setDeletingId] = useState(null); diff --git a/src/components/settings/TemplateSettings.tsx b/src/components/settings/TemplateSettings.tsx index a5c249d5..e94008fe 100644 --- a/src/components/settings/TemplateSettings.tsx +++ b/src/components/settings/TemplateSettings.tsx @@ -24,7 +24,6 @@ import { useSheetBottomInset } from '../../lib/sheet-bottom-inset'; export function TemplateSettings() { // Centred on a wide window, clear of side insets in landscape. const sheetInsets = useSheetInsets(); - const bottomInset = useSheetBottomInset(); const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const tr = useLocaleStore((s) => s.t); @@ -41,6 +40,8 @@ export function TemplateSettings() { const [editing, setEditing] = useState(null); const [importVisible, setImportVisible] = useState(false); + // Save above the taskbar, and above the keyboard while a field is typed in. + const bottomInset = useSheetBottomInset(!!editing || importVisible); const [importText, setImportText] = useState(''); const [draftName, setDraftName] = useState(''); const [draftSubject, setDraftSubject] = useState(''); diff --git a/src/lib/__tests__/sheet-bottom-inset.test.ts b/src/lib/__tests__/sheet-bottom-inset.test.ts new file mode 100644 index 00000000..7c10a467 --- /dev/null +++ b/src/lib/__tests__/sheet-bottom-inset.test.ts @@ -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(); +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: (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.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); + } + }); +}); diff --git a/src/lib/sheet-bottom-inset.ts b/src/lib/sheet-bottom-inset.ts index 0c9eab9c..8ff5cf35 100644 --- a/src/lib/sheet-bottom-inset.ts +++ b/src/lib/sheet-bottom-inset.ts @@ -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 | 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); } diff --git a/src/screens/FilesScreen.tsx b/src/screens/FilesScreen.tsx index 32d5c400..92aa7fbd 100644 --- a/src/screens/FilesScreen.tsx +++ b/src/screens/FilesScreen.tsx @@ -1736,6 +1736,8 @@ interface PromptModalProps { function PromptModal(props: PromptModalProps) { const c = useColors(); + // Centred above the keyboard, so the name field and its button stay visible. + const bottomInset = useSheetBottomInset(props.visible); const styles = React.useMemo(() => makePromptStyles(c), [c]); return ( - + {props.title} @@ -1808,7 +1810,7 @@ function ActionsSheet({ }: ActionsSheetProps) { // Centred on a wide window, clear of side insets in landscape. const sheetInsets = useSheetInsets(); - const bottomInset = useSheetBottomInset(); + const bottomInset = useSheetBottomInset(!!target); const formatModified = useFormatModified(); const c = useColors(); const styles = React.useMemo(() => makeSheetStyles(c), [c]); @@ -1963,7 +1965,7 @@ interface FolderPickerSheetProps { function FolderPickerSheet({ target, allNodes, rootLabel, t, onClose, onPick }: FolderPickerSheetProps) { // Centred on a wide window, clear of side insets in landscape. const sheetInsets = useSheetInsets(); - const bottomInset = useSheetBottomInset(); + const bottomInset = useSheetBottomInset(!!target); const c = useColors(); const styles = React.useMemo(() => makeSheetStyles(c), [c]); const options = useMemo(() => { @@ -2054,7 +2056,7 @@ interface SortSheetProps { function SortSheet({ visible, sortKey, sortDir, t, onClose, onChangeKey, onChangeDir }: SortSheetProps) { // Centred on a wide window, clear of side insets in landscape. const sheetInsets = useSheetInsets(); - const bottomInset = useSheetBottomInset(); + const bottomInset = useSheetBottomInset(visible); const c = useColors(); const styles = React.useMemo(() => makeSheetStyles(c), [c]); if (!visible) return null;