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
+5 -1
View File
@@ -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 (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="fade" onRequestClose={onCancel}>
<View style={styles.backdrop}>
<View style={[styles.backdrop, { paddingBottom: spacing.lg + bottomInset }]}>
<View style={styles.dialog}>
<View style={styles.content}>
{variant === 'destructive' && (
+4 -3
View File
@@ -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
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }], ...sheetInsets },
{ paddingBottom: Math.max(bottomInset, spacing.md), transform: [{ translateY: slideY }], ...sheetInsets },
]}
>
<View {...dragHandlers}>
+4 -1
View File
@@ -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)
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }], ...sheetInsets },
{ paddingBottom: Math.max(bottomInset, spacing.md), transform: [{ translateY: slideY }], ...sheetInsets },
]}
>
<View {...dragHandlers}>
+4 -1
View File
@@ -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({
</View>
<KeyboardAvoidingView
style={{ flex: 1 }}
style={{ flex: 1, paddingBottom: keyboardPadding }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView contentContainerStyle={styles.body} keyboardShouldPersistTaps="handled">
+4 -1
View File
@@ -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
</View>
<KeyboardAvoidingView
style={{ flex: 1 }}
style={{ flex: 1, paddingBottom: keyboardPadding }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<View style={styles.body}>
@@ -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<string | null>(null);
@@ -197,7 +200,7 @@ export function ContentSendersSettings() {
animationType="fade"
onRequestClose={() => setModalOpen(false)}
>
<Pressable accessible={false} style={styles.modalBackdrop} onPress={() => setModalOpen(false)}>
<Pressable accessible={false} style={[styles.modalBackdrop, { paddingBottom: bottomInset }]} onPress={() => setModalOpen(false)}>
<Pressable accessible={false} style={styles.modalCard} onPress={() => {}}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>{t('settings.email_behavior.trusted_senders.modal_title', 'Trusted Senders')}</Text>
+4 -1
View File
@@ -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<Principal | null>(initial?.principal ?? null);
@@ -154,7 +157,7 @@ export function DelegateDialog({
return (
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible animationType="slide" onRequestClose={close}>
<SafeAreaView style={styles.screen} edges={['top', 'bottom', 'left', 'right']}>
<KeyboardAvoidingView style={styles.screen} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
<KeyboardAvoidingView style={[styles.screen, { paddingBottom: keyboardPadding }]} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
<View style={styles.header}>
<Text style={styles.title} numberOfLines={1} accessibilityRole="header">
{changing && principal
+3 -1
View File
@@ -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<Editor | null>(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<string>(NO_PARENT);
const [draftAccount, setDraftAccount] = useState<string>(OWN_ACCOUNT);
+2 -1
View File
@@ -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<DraftIdentity | null>(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<string | null>(null);
+2 -1
View File
@@ -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<EmailTemplate | null>(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('');
@@ -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);
}
+6 -4
View File
@@ -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 (
<Modal supportedOrientations={MODAL_ORIENTATIONS}
@@ -1745,7 +1747,7 @@ function PromptModal(props: PromptModalProps) {
onRequestClose={props.onCancel}
>
<TouchableWithoutFeedback accessible={false} onPress={props.onCancel}>
<View style={styles.backdrop}>
<View style={[styles.backdrop, { paddingBottom: spacing.lg + bottomInset }]}>
<TouchableWithoutFeedback accessible={false}>
<View style={styles.dialog}>
<Text style={styles.title}>{props.title}</Text>
@@ -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;