From f3d3cc5d0dad777835e1cab9e3ffe971c5e78807 Mon Sep 17 00:00:00 2001 From: wmcparla <183443443+wmcparla@users.noreply.github.com> Date: Sat, 14 Nov 2026 15:24:14 -0500 Subject: [PATCH] fix: keep a side drawer's header below the status bar from its first frame #130 --- src/components/DrawerPanel.tsx | 21 +++++++++++++++++++ src/components/SafeAreaModal.tsx | 3 ++- src/components/SidebarDrawer.tsx | 11 +++++----- .../__tests__/modal-safe-area.test.ts | 21 +++++++++++++++++-- .../calendar/CalendarSidebarDrawer.tsx | 6 +++--- .../contacts/ContactsSidebarDrawer.tsx | 6 +++--- 6 files changed, 53 insertions(+), 15 deletions(-) create mode 100644 src/components/DrawerPanel.tsx diff --git a/src/components/DrawerPanel.tsx b/src/components/DrawerPanel.tsx new file mode 100644 index 000000000..4834b651a --- /dev/null +++ b/src/components/DrawerPanel.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { View, type StyleProp, type ViewStyle } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +/** + * The inside of a side drawer, kept clear of the status bar, the home + * indicator and the left edge. The padding comes from the provider's insets, + * which are known on the first render (a new provider starts with its + * parent's). The native SafeAreaView only learned them a few frames later on + * Fabric, and every time the drawer opened: its header first drew under the + * status bar or Dynamic Island and then jumped down, while the drawer was + * still sliding in. + */ +export function DrawerPanel({ style, children }: { style?: StyleProp; children: React.ReactNode }) { + const insets = useSafeAreaInsets(); + return ( + + {children} + + ); +} diff --git a/src/components/SafeAreaModal.tsx b/src/components/SafeAreaModal.tsx index 13750bb0e..140c099bc 100644 --- a/src/components/SafeAreaModal.tsx +++ b/src/components/SafeAreaModal.tsx @@ -9,7 +9,8 @@ import { SafeAreaProvider } from 'react-native-safe-area-context'; * 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. + * this for any Modal with a SafeAreaView inside, or with content that reads + * the insets of the Modal's window (the side drawers' DrawerPanel). */ export function SafeAreaModal({ children, ...props }: ModalProps) { return ( diff --git a/src/components/SidebarDrawer.tsx b/src/components/SidebarDrawer.tsx index 0abef9c73..05e1afefc 100644 --- a/src/components/SidebarDrawer.tsx +++ b/src/components/SidebarDrawer.tsx @@ -4,7 +4,6 @@ import { Animated, Dimensions, Easing, ActivityIndicator, } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; -import { SafeAreaView } from 'react-native-safe-area-context'; import { Inbox, Send, File as FileIcon, Trash2, Ban, Archive, Star, Folder, FolderOpen, ChevronDown, ChevronRight, X, Settings, LogOut, Check, Plus, @@ -46,6 +45,7 @@ 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 { DrawerPanel } from './DrawerPanel'; import { setPendingSettingsTab } from '../navigation/pending-settings-tab'; import { isAccountMuted, isMailboxMuted, mailboxOverridesForAccount, resetMailboxMutes, setAccountMuted, setMailboxMuted, @@ -881,7 +881,7 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side const tagViewActive = !!filters.keyword; const panel = ( - + {/* Header: close + account switcher */} {!docked && ( @@ -1311,7 +1311,7 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side )} - + ); // On a wide screen the drawer is a permanent sidebar next to the list. @@ -1335,9 +1335,8 @@ 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. + // The Modal is its own window, with its own safe-area provider; the panel + // (DrawerPanel) pads itself from that provider's insets. return ( { 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. + // The drawers' panels read their insets from the modal's own provider + // (DrawerPanel, below), so they need SafeAreaModal as well. it('include the side drawers', () => { const drawers = sourceFiles(SRC).filter((file) => /SidebarDrawer\.tsx$/.test(file)); expect(drawers.length).toBeGreaterThanOrEqual(3); @@ -78,6 +78,23 @@ describe('modals that slide in with a SafeAreaView', () => { } }); + // #130: inside the provider the native SafeAreaView still learned the + // insets a few frames late, each time a drawer opened, so its header jumped + // down while the drawer slid in. DrawerPanel pads from the provider's + // insets, which are there on the first render. + it('pad the side drawers from the insets, not with a native SafeAreaView', () => { + const drawers = sourceFiles(SRC).filter((file) => /SidebarDrawer\.tsx$/.test(file)); + for (const file of drawers) { + const source = fs.readFileSync(file, 'utf8'); + expect(source, path.relative(SRC, file)).not.toContain(''); + } + const panel = fs.readFileSync(path.join(SRC, 'components/DrawerPanel.tsx'), 'utf8'); + expect(panel).toContain('const insets = useSafeAreaInsets();'); + expect(panel).toContain('paddingTop: insets.top, paddingBottom: insets.bottom, paddingLeft: insets.left'); + expect(panel).not.toContain(' { const tags = modalTags(' close()} animationType="slide">\n'); expect(tags).toHaveLength(1); diff --git a/src/components/calendar/CalendarSidebarDrawer.tsx b/src/components/calendar/CalendarSidebarDrawer.tsx index 2ead2a936..574023094 100644 --- a/src/components/calendar/CalendarSidebarDrawer.tsx +++ b/src/components/calendar/CalendarSidebarDrawer.tsx @@ -9,7 +9,6 @@ import { Text, View, } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; import { Check, X, Upload, Rss, Shuffle, Star, Plus, Pencil, Share2, Eraser, Trash2, RefreshCw, ArrowUp, ArrowDown, type AppIcon, @@ -24,6 +23,7 @@ import { isWritableCalendar } from '../../lib/calendar-editability'; import { useLocaleStore } from '../../stores/locale-store'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; import { SafeAreaModal } from '../SafeAreaModal'; +import { DrawerPanel } from '../DrawerPanel'; /** A calendar view listed at the top of the drawer. */ export interface DrawerViewOption { @@ -172,7 +172,7 @@ export function CalendarSidebarDrawer({ - + )} - + ); diff --git a/src/components/contacts/ContactsSidebarDrawer.tsx b/src/components/contacts/ContactsSidebarDrawer.tsx index 491d39948..6f6b5383b 100644 --- a/src/components/contacts/ContactsSidebarDrawer.tsx +++ b/src/components/contacts/ContactsSidebarDrawer.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { 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'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { @@ -21,6 +20,7 @@ import { useColors } from '../../theme/colors'; import { useLocaleStore } from '../../stores/locale-store'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; import { SafeAreaModal } from '../SafeAreaModal'; +import { DrawerPanel } from '../DrawerPanel'; type Nav = NativeStackNavigationProp; @@ -197,7 +197,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) { - + {t('contacts.rename_tag_hint_mobile', 'Long-press a tag to rename it')} )} - + );