fix: keep full-screen modal headers below the status bar on iPhones with a notch

This commit is contained in:
Linus Rath
2026-10-22 22:04:28 +02:00
parent bdade8503d
commit f5f3637d01
8 changed files with 101 additions and 18 deletions
+3 -3
View File
@@ -5,7 +5,6 @@ import {
TextInput,
Pressable,
StyleSheet,
Modal,
Platform,
KeyboardAvoidingView,
ScrollView,
@@ -19,6 +18,7 @@ import Button from './Button';
import { useLocaleStore } from '../stores/locale-store';
import { insecurePairingLinkError, parsePastedSignInLink, type QrLoginPayload } from '../lib/oauth';
import { describeLoginError } from '../lib/login-errors';
import { SafeAreaModal } from './SafeAreaModal';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
interface PasteSignInLinkModalProps {
@@ -81,7 +81,7 @@ export function PasteSignInLinkModal({ visible, onClose, onSubmit }: PasteSignIn
const title = t('login.mobile.paste_title', 'Paste sign-in link');
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<SafeAreaView style={styles.container}>
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.flex}>
<View style={styles.header}>
@@ -145,7 +145,7 @@ export function PasteSignInLinkModal({ visible, onClose, onSubmit }: PasteSignIn
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
</Modal>
</SafeAreaModal>
);
}
+4 -3
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { View, Text, Pressable, StyleSheet, Modal, Platform, Linking } from 'react-native';
import { View, Text, Pressable, StyleSheet, Platform, Linking } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { CameraView, useCameraPermissions, type BarcodeScanningResult } from 'expo-camera';
import { X } from './icons';
@@ -7,6 +7,7 @@ import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'
import { useColors, useResolvedTheme } from '../theme/colors';
import Button from './Button';
import { useLocaleStore } from '../stores/locale-store';
import { SafeAreaModal } from './SafeAreaModal';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
interface QrScanModalProps {
@@ -43,7 +44,7 @@ export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) {
);
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<View style={styles.container}>
{permission?.granted ? (
<CameraView
@@ -105,7 +106,7 @@ export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) {
) : null}
</SafeAreaView>
</View>
</Modal>
</SafeAreaModal>
);
}
+19
View File
@@ -0,0 +1,19 @@
import React from 'react';
import { Modal, type ModalProps } from 'react-native';
import { SafeAreaProvider } from 'react-native-safe-area-context';
/**
* A Modal with its own safe-area provider. A Modal is a separate native
* window, and a SafeAreaView inside a slide-up Modal measured no insets: the
* view starts off screen when the slide begins, so on iPhones with a notch or
* Dynamic Island the header (and its close button) ended up under the status
* bar, where taps don't reach it. Use this for full-screen modals with a
* SafeAreaView header.
*/
export function SafeAreaModal({ children, ...props }: ModalProps) {
return (
<Modal {...props}>
<SafeAreaProvider>{children}</SafeAreaProvider>
</Modal>
);
}
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
// A SafeAreaView inside a slide-up react-native Modal measured no insets on
// iOS: the Modal is its own window and its content starts off screen, so on
// iPhones with a notch or Dynamic Island the header and its close button sat
// under the status bar, out of reach (attachment preview, QR scanner, paste
// sign-in link, filter and event editors). Such modals use SafeAreaModal,
// which gives the window its own SafeAreaProvider.
const SRC = path.resolve(__dirname, '../..');
function sourceFiles(dir: string): string[] {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
const p = path.join(dir, e.name);
if (e.isDirectory()) return e.name === '__tests__' ? [] : sourceFiles(p);
return e.name.endsWith('.tsx') ? [p] : [];
});
}
/** Opening tags of plain <Modal> elements, attributes included. */
function modalTags(source: string): string[] {
const tags: string[] = [];
let at = source.indexOf('<Modal');
while (at >= 0) {
if (/[\s>]/.test(source[at + 6] ?? '')) {
let depth = 0;
let i = at;
for (; i < source.length; i++) {
const ch = source[i];
if (ch === '{') depth++;
else if (ch === '}') depth--;
else if (ch === '>' && depth === 0 && source[i - 1] !== '=') break;
}
tags.push(source.slice(at, i + 1));
}
at = source.indexOf('<Modal', at + 6);
}
return tags;
}
describe('slide-up modals with a SafeAreaView', () => {
it('use SafeAreaModal so their header clears the status bar', () => {
const offenders = sourceFiles(SRC)
.filter((file) => {
const source = fs.readFileSync(file, 'utf8');
if (!source.includes('SafeAreaView')) return false;
return modalTags(source).some((tag) => /animationType="slide"/.test(tag));
})
.map((file) => path.relative(SRC, file));
expect(offenders).toEqual([]);
});
it('finds the tags it is looking for', () => {
const tags = modalTags('<View><Modal visible={x} onRequestClose={() => close()} animationType="slide">\n</Modal></View>');
expect(tags).toHaveLength(1);
expect(tags[0]).toContain('animationType="slide"');
});
});
+3 -3
View File
@@ -1,6 +1,5 @@
import React from 'react';
import {
Modal,
View,
Text,
ScrollView,
@@ -69,6 +68,7 @@ import { ParticipantInput } from './ParticipantInput';
import { useContactNameResolver } from '../../lib/contact-name-resolver';
import { RecurrenceEditor } from './RecurrenceEditor';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { SafeAreaModal } from '../SafeAreaModal';
import ToggleSwitch from '../ToggleSwitch';
type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom';
@@ -419,7 +419,7 @@ export function EventModal({
);
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS}
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS}
visible={visible}
animationType="slide"
presentationStyle="fullScreen"
@@ -838,7 +838,7 @@ export function EventModal({
/>
)}
</SafeAreaView>
</Modal>
</SafeAreaModal>
);
}
@@ -1,6 +1,6 @@
import React from 'react';
import {
View, Text, StyleSheet, Modal, Pressable, ScrollView, Image, ActivityIndicator, Platform, PixelRatio, Alert,
View, Text, StyleSheet, Pressable, ScrollView, Image, ActivityIndicator, Platform, PixelRatio, Alert,
useWindowDimensions,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
@@ -21,6 +21,7 @@ import { formatFullDateTime } from '../../lib/email-date';
import type { EmlPreview } from './use-body-override';
import { useFilePreview } from './use-file-preview';
import { ArchiveSummary, PreviewPartList, type PreviewPart } from './PreviewPartList';
import { SafeAreaModal } from '../SafeAreaModal';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
export interface PreviewItem {
@@ -432,9 +433,9 @@ export function AttachmentPreviewModal({
);
if (docked) return item || loading ? content : null;
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!item || loading} animationType="slide" onRequestClose={onClose}>
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={!!item || loading} animationType="slide" onRequestClose={onClose}>
{content}
</Modal>
</SafeAreaModal>
);
}
+4 -3
View File
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Alert,
View, Text, StyleSheet, ScrollView, Pressable, Alert,
KeyboardAvoidingView, Platform,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
@@ -37,6 +37,7 @@ import type {
FilterComparator,
FilterActionType,
} from '../../lib/sieve/types';
import { SafeAreaModal } from '../SafeAreaModal';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
const ALL_FIELDS: FilterConditionField[] = ['from', 'to', 'cc', 'subject', 'header', 'size', 'body', 'attachment'];
@@ -227,7 +228,7 @@ export function FilterRuleModal({ visible, rule, mailboxes, onSave, onClose }: F
}, [name, conditions, actions, matchType, stopProcessing, includeSpam, rule, onSave, t, mailboxTargets]);
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<View style={styles.header}>
<Pressable
@@ -490,7 +491,7 @@ export function FilterRuleModal({ visible, rule, mailboxes, onSave, onClose }: F
</Button>
</View>
</SafeAreaView>
</Modal>
</SafeAreaModal>
);
}
+4 -3
View File
@@ -1,6 +1,6 @@
import React, { useCallback, useMemo, useState } from 'react';
import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, TextInput,
View, Text, StyleSheet, ScrollView, Pressable, TextInput,
KeyboardAvoidingView, Platform, ActivityIndicator,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
@@ -9,6 +9,7 @@ import { spacing, radius, typography, componentSizes, type ThemePalette } from '
import { useColors } from '../../theme/colors';
import Button from '../Button';
import { useLocaleStore } from '../../stores/locale-store';
import { SafeAreaModal } from '../SafeAreaModal';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
interface SieveEditorSheetProps {
@@ -63,7 +64,7 @@ export function SieveEditorSheet({ visible, content, onSave, onClose, onValidate
}, [script, showSaveWarning, onSave]);
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<View style={styles.header}>
<Pressable
@@ -174,7 +175,7 @@ export function SieveEditorSheet({ visible, content, onSave, onClose, onValidate
</View>
</View>
</SafeAreaView>
</Modal>
</SafeAreaModal>
);
}