mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +00:00
fix: keep sheet and dialog fields and buttons above the keyboard
This commit is contained in:
@@ -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' && (
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user