fix: keep the drawers' account header below the status bar and sheet rows above the home indicator on iPhone

This commit is contained in:
Linus Rath
2026-10-23 19:51:48 +02:00
parent b3bd19cc8c
commit 470e7fc3ba
12 changed files with 80 additions and 44 deletions
+6 -5
View File
@@ -4,11 +4,12 @@ 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.
* window, and a SafeAreaView inside a Modal whose content slides in (a
* slide-up Modal, or a drawer or sheet the app animates itself) measured no
* insets: the view starts off screen. On iPhones with a notch or Dynamic
* Island headers (and their close buttons) ended up under the status bar,
* where taps don't reach them, and bottom rows on the home indicator. Use
* this for any Modal with a SafeAreaView inside.
*/
export function SafeAreaModal({ children, ...props }: ModalProps) {
return (
+6 -2
View File
@@ -39,6 +39,7 @@ import type { RootStackParamList } from '../navigation/types';
import type { Mailbox } from '../api/types';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
import { confirmSignOut } from '../lib/sign-out-confirm';
import { SafeAreaModal } from './SafeAreaModal';
import { setPendingSettingsTab } from '../navigation/pending-settings-tab';
import {
isAccountMuted, isMailboxMuted, mailboxOverridesForAccount, resetMailboxMutes, setAccountMuted, setMailboxMuted,
@@ -1240,8 +1241,11 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
);
}
// Its own safe-area provider: the panel's SafeAreaView starts off screen
// (it slides in), and without one it measured no insets on iOS, so the
// account header sat under the status bar.
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS}
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS}
visible={visible}
transparent
animationType="none"
@@ -1268,7 +1272,7 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
onClose={() => setPrompt(null)}
/>
)}
</Modal>
</SafeAreaModal>
);
}
@@ -2,12 +2,15 @@ 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.
// A SafeAreaView inside a react-native Modal whose content starts off screen
// measured no insets on iOS: the Modal is its own window, and the view was
// measured before it slid in. 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), the drawers' account headers did too, and the bottom sheets'
// last rows sat on the home indicator. Such modals use SafeAreaModal, which
// gives the window its own SafeAreaProvider. Slid in means animationType
// "slide", or "none" with the app's own slide animation (drawers, sheets).
const SRC = path.resolve(__dirname, '../..');
@@ -19,6 +22,15 @@ function sourceFiles(dir: string): string[] {
});
}
/** Plain <Modal> elements: the opening tag and what is between the tags. */
function modals(source: string): { tag: string; body: string }[] {
return modalTags(source).map((tag) => {
const start = source.indexOf(tag) + tag.length;
const end = source.indexOf('</Modal>', start);
return { tag, body: source.slice(start, end < 0 ? undefined : end) };
});
}
/** Opening tags of plain <Modal> elements, attributes included. */
function modalTags(source: string): string[] {
const tags: string[] = [];
@@ -40,18 +52,32 @@ function modalTags(source: string): string[] {
return tags;
}
describe('slide-up modals with a SafeAreaView', () => {
it('use SafeAreaModal so their header clears the status bar', () => {
describe('modals that slide in with a SafeAreaView', () => {
it('use SafeAreaModal so their content clears the status bar and home indicator', () => {
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));
return modals(source).some(({ tag, body }) =>
/animationType="slide"/.test(tag)
|| (/animationType="none"/.test(tag) && body.includes('<SafeAreaView')));
})
.map((file) => path.relative(SRC, file));
expect(offenders).toEqual([]);
});
// The mail drawer builds its panel (with the SafeAreaView) outside the
// modal's tags, so the check above can't see it there.
it('include the side drawers', () => {
const drawers = sourceFiles(SRC).filter((file) => /SidebarDrawer\.tsx$/.test(file));
expect(drawers.length).toBeGreaterThanOrEqual(3);
for (const file of drawers) {
const source = fs.readFileSync(file, 'utf8');
expect(modalTags(source).filter((tag) => /animationType="none"/.test(tag)), path.relative(SRC, file)).toEqual([]);
expect(source).toContain('<SafeAreaModal');
}
});
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);
@@ -3,7 +3,6 @@ import {
Animated,
Dimensions,
Easing,
Modal,
Pressable,
ScrollView,
StyleSheet,
@@ -24,6 +23,7 @@ import { BIRTHDAY_CALENDAR_ID } from '../../lib/birthday-calendar';
import { isWritableCalendar } from '../../lib/calendar-editability';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { SafeAreaModal } from '../SafeAreaModal';
/** A calendar view listed at the top of the drawer. */
export interface DrawerViewOption {
@@ -157,7 +157,7 @@ export function CalendarSidebarDrawer({
};
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS}
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS}
visible={visible}
transparent
animationType="none"
@@ -271,7 +271,7 @@ export function CalendarSidebarDrawer({
</ScrollView>
</SafeAreaView>
</Animated.View>
</Modal>
</SafeAreaModal>
);
}
+3 -3
View File
@@ -3,7 +3,6 @@ import {
Animated,
Dimensions,
Easing,
Modal,
Platform,
Pressable,
ScrollView,
@@ -63,6 +62,7 @@ import { recurrenceEndLabel, recurrenceIntervalLabel } from '../../lib/recurrenc
import type { TranslateFn } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
import { SafeAreaModal } from '../SafeAreaModal';
type RsvpStatus = 'accepted' | 'declined' | 'tentative';
@@ -248,7 +248,7 @@ export function EventDetailSheet({
};
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS}
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS}
visible={visible}
transparent
animationType="none"
@@ -464,7 +464,7 @@ export function EventDetailSheet({
{/* The screen's own toast host sits under this Modal; this one shows
the copy confirmation above the sheet. */}
<ToastHost />
</Modal>
</SafeAreaModal>
);
}
+4 -3
View File
@@ -1,6 +1,6 @@
import React from 'react';
import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, Alert, ActivityIndicator,
View, Text, StyleSheet, ScrollView, Pressable, Animated, Easing, Alert, ActivityIndicator,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { Upload, X, Check, CalendarPlus, AlertTriangle, Clock } from '../icons';
@@ -19,6 +19,7 @@ import { useCalendarLocale } from '../../lib/calendar-locale';
import type { Locale } from 'date-fns';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
import { SafeAreaModal } from '../SafeAreaModal';
// expo-document-picker is loaded lazily on first use; its native module is not
// linked into every build (matches the FilesScreen / ContactImportSheet flow).
@@ -188,7 +189,7 @@ export function ICalImportSheet({ visible, onClose, calendars, onImport, onImpor
};
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={{ flex: 1 }} onPress={onClose} />
</Animated.View>
@@ -378,7 +379,7 @@ export function ICalImportSheet({ visible, onClose, calendars, onImport, onImpor
)}
</SafeAreaView>
</Animated.View>
</Modal>
</SafeAreaModal>
);
}
@@ -1,6 +1,6 @@
import React from 'react';
import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, TextInput, ActivityIndicator, Alert,
View, Text, StyleSheet, ScrollView, Pressable, Animated, Easing, TextInput, ActivityIndicator, Alert,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { X, Plus, RefreshCw, Trash2, Rss, AlertTriangle } from '../icons';
@@ -19,6 +19,7 @@ import {
import { jmapClient } from '../../api/jmap-client';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
import { SafeAreaModal } from '../SafeAreaModal';
// The webmail's refresh intervals and their labels.
const INTERVAL_OPTIONS: { minutes: number; key: string; fallback: string }[] = [
@@ -135,7 +136,7 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
};
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={{ flex: 1 }} onPress={onClose} />
</Animated.View>
@@ -313,7 +314,7 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
<View style={{ height: insets.bottom }} />
</SafeAreaView>
</Animated.View>
</Modal>
</SafeAreaModal>
);
}
+3 -3
View File
@@ -4,7 +4,6 @@ import {
Animated,
Dimensions,
Easing,
Modal,
Platform,
Pressable,
ScrollView,
@@ -45,6 +44,7 @@ import { formatCalendarDate } from '../../lib/date-format';
import { useSheetDrag } from '../../lib/use-sheet-drag';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
import { SafeAreaModal } from '../SafeAreaModal';
type TaskFilter = 'all' | 'pending' | 'completed' | 'overdue';
@@ -235,7 +235,7 @@ export function TasksSheet({
];
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.overlayPress} onPress={onClose} />
</Animated.View>
@@ -480,7 +480,7 @@ export function TasksSheet({
}}
/>
)}
</Modal>
</SafeAreaModal>
);
}
@@ -1,6 +1,6 @@
import React from 'react';
import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, Alert, ActivityIndicator,
View, Text, StyleSheet, ScrollView, Pressable, Animated, Easing, Alert, ActivityIndicator,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import * as FileSystem from 'expo-file-system/legacy';
@@ -15,6 +15,7 @@ import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
import { SafeAreaModal } from '../SafeAreaModal';
// expo-document-picker is loaded lazily on first use; its native module is not
// linked into every build (matches the FilesScreen upload flow).
@@ -164,7 +165,7 @@ export default function ContactImportSheet({
};
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={{ flex: 1 }} onPress={onClose} />
</Animated.View>
@@ -304,7 +305,7 @@ export default function ContactImportSheet({
)}
</SafeAreaView>
</Animated.View>
</Modal>
</SafeAreaModal>
);
}
@@ -1,6 +1,6 @@
import React from 'react';
import {
View, Text, StyleSheet, Pressable, Modal, TextInput, FlatList,
View, Text, StyleSheet, Pressable, TextInput, FlatList,
Animated, Dimensions, Easing,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
@@ -18,6 +18,7 @@ import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
import { SafeAreaModal } from '../SafeAreaModal';
interface Props {
visible: boolean;
@@ -98,7 +99,7 @@ export default function ContactPickerSheet({
};
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
<Animated.View style={[styles.overlay, { opacity: overlay }]}>
<Pressable style={styles.overlayPress} onPress={onClose} />
</Animated.View>
@@ -179,7 +180,7 @@ export default function ContactPickerSheet({
/>
</SafeAreaView>
</Animated.View>
</Modal>
</SafeAreaModal>
);
}
@@ -1,6 +1,6 @@
import React from 'react';
import {
View, Text, StyleSheet, Pressable, ScrollView, Modal, Animated, Dimensions, Easing, TextInput, Alert,
View, Text, StyleSheet, Pressable, ScrollView, Animated, Dimensions, Easing, TextInput, Alert,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native';
@@ -19,6 +19,7 @@ import { spacing, radius, typography, componentSizes, type ThemePalette } from '
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { SafeAreaModal } from '../SafeAreaModal';
type Nav = NativeStackNavigationProp<RootStackParamList>;
@@ -162,7 +163,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
);
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose} onShow={runOpen}>
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose} onShow={runOpen}>
<Animated.View style={[styles.overlay, { opacity: overlay }]}>
<Pressable style={styles.overlayPress} onPress={onClose} />
</Animated.View>
@@ -310,7 +311,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
</ScrollView>
</SafeAreaView>
</Animated.View>
</Modal>
</SafeAreaModal>
);
}
+4 -4
View File
@@ -1,8 +1,7 @@
import React from 'react';
import {
View, Text, StyleSheet, Pressable, ScrollView, Linking, Alert, Image, Share,
Animated, Dimensions, Easing, Modal,
} from 'react-native';
Animated, Dimensions, Easing, } from 'react-native';
import * as FileSystem from 'expo-file-system/legacy';
import * as Sharing from 'expo-sharing';
import { SafeAreaView } from 'react-native-safe-area-context';
@@ -35,6 +34,7 @@ import { useColors } from '../theme/colors';
import { useLocaleStore, type TranslateFn } from '../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
import { useSheetInsets } from '../lib/use-layout';
import { SafeAreaModal } from '../components/SafeAreaModal';
type Nav = NativeStackNavigationProp<RootStackParamList, 'ContactDetail'>;
type Route = RouteProp<RootStackParamList, 'ContactDetail'>;
@@ -814,7 +814,7 @@ function MoreActionsSheet({
}, [visible, slideY, overlayOpacity]);
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
<Pressable style={{ flex: 1 }} onPress={onClose} />
</Animated.View>
@@ -846,7 +846,7 @@ function MoreActionsSheet({
})}
</SafeAreaView>
</Animated.View>
</Modal>
</SafeAreaModal>
);
}