fix: keep bottom sheet buttons above the tablet taskbar

This commit is contained in:
Linus Rath
2026-10-31 13:15:21 +01:00
parent 7139390a85
commit 426f740359
7 changed files with 120 additions and 9 deletions
@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
// With edge-to-edge on, every RN Modal on Android reaches under the
// navigation bar (a tablet's taskbar, a phone's gesture bar). Bottom sheets
// that ended at the screen edge without padding for it put their last row
// under the taskbar on a Pixel Tablet: New folder's Save and Cancel, the
// Files item actions' Delete and Delete permanently. A tap there opened an
// app from the taskbar instead. Every sheet surface inside a plain Modal pads
// its bottom by the inset (useSheetBottomInset, or insets.bottom).
const SRC = path.resolve(__dirname, '../..');
// Sheets in files other agents own; their fix is reported, not made here.
const OWNED_ELSEWHERE = new Set([
'components/SidebarDrawer.tsx',
'components/calendar/CalendarEditSheet.tsx',
'components/sharing/ShareCollectionSheet.tsx',
]);
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] : [];
});
}
/** What is between each plain <Modal ...> and its </Modal>. */
function modalBodies(source: string): string[] {
const out: string[] = [];
const re = /<Modal[\s>]/g;
let m: RegExpExecArray | null;
while ((m = re.exec(source))) {
const end = source.indexOf('</Modal>', m.index);
out.push(source.slice(m.index, end < 0 ? undefined : end));
}
return out;
}
/** A body that renders a bottom sheet surface (styles.sheet / styles.modalSheet). */
function rendersSheet(body: string): boolean {
return /styles\.(sheet|modalSheet)\b/.test(body);
}
function padsBottom(body: string): boolean {
return /bottomInset|insets\.bottom/.test(body);
}
describe('bottom sheets in a Modal', () => {
it('pad their content above the navigation bar or taskbar', () => {
const offenders = sourceFiles(SRC).flatMap((file) => {
const rel = path.relative(SRC, file).split(path.sep).join('/');
if (OWNED_ELSEWHERE.has(rel)) return [];
const source = fs.readFileSync(file, 'utf8');
return modalBodies(source)
.filter((body) => rendersSheet(body) && !padsBottom(body))
.map(() => rel);
});
expect(offenders).toEqual([]);
});
it('covers the sheets the tablet walkthrough found', () => {
for (const rel of ['components/settings/FolderSettings.tsx', 'screens/FilesScreen.tsx', 'components/files/ShareSheet.tsx']) {
const bodies = modalBodies(fs.readFileSync(path.join(SRC, rel), 'utf8')).filter(rendersSheet);
expect(bodies.length, rel).toBeGreaterThan(0);
}
});
it('finds the sheet in a Modal body', () => {
const src = '<Modal visible transparent>\n<View style={[styles.sheet, { paddingBottom: bottomInset }]} />\n</Modal>';
const bodies = modalBodies(src);
expect(bodies).toHaveLength(1);
expect(rendersSheet(bodies[0])).toBe(true);
expect(padsBottom(bodies[0])).toBe(true);
expect(padsBottom('<Modal><View style={styles.sheet} /></Modal>')).toBe(false);
});
});
+3 -1
View File
@@ -22,6 +22,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 { useSheetBottomInset } from '../../lib/sheet-bottom-inset';
type RolePreset = 'read' | 'readWrite' | 'manager';
@@ -71,6 +72,7 @@ interface ShareSheetProps {
export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps) {
// 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);
@@ -189,7 +191,7 @@ export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<TouchableWithoutFeedback accessible={false}>
<View style={styles.sheet}>
<View style={[styles.sheet, { paddingBottom: spacing.xxl + bottomInset }]}>
<View style={styles.titleRow}>
<Icon size={18} color={c.textMuted} />
<Text style={styles.title} numberOfLines={1}>
+3 -1
View File
@@ -27,6 +27,7 @@ import { jmapClient } from '../../api/jmap-client';
import type { Mailbox } from '../../api/types';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
import { useSheetBottomInset } from '../../lib/sheet-bottom-inset';
import Badge from '../Badge';
const ROLE_ICON: Record<string, any> = {
@@ -68,6 +69,7 @@ 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);
@@ -300,7 +302,7 @@ export function FolderSettings() {
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!editor} animationType="slide" transparent onRequestClose={closeEditor}>
<View style={[styles.modalOverlay, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<View style={styles.modalSheet}>
<View style={[styles.modalSheet, { paddingBottom: bottomInset }]}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>
{editor?.kind === 'edit'
+3 -1
View File
@@ -29,6 +29,7 @@ import { sanitizeDisplayName } from '../../lib/rfc5322-mailbox';
import { sanitizeSignatureHtml } from '../../lib/signature-utils';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
import { useSheetBottomInset } from '../../lib/sheet-bottom-inset';
import Badge from '../Badge';
import { identityDraftChanged } from './identity-draft';
@@ -98,6 +99,7 @@ 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);
@@ -330,7 +332,7 @@ export function IdentitySettings() {
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!editing} animationType="slide" transparent onRequestClose={requestClose}>
<View style={[styles.modalOverlay, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<View style={styles.modalSheet}>
<View style={[styles.modalSheet, { paddingBottom: bottomInset }]}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>
{editing?.id ? t('settings.identities.edit', "Edit identity") : t('settings.identities.create', "New identity")}
+4 -2
View File
@@ -19,10 +19,12 @@ import { useTemplatesStore, type EmailTemplate } from '../../stores/templates-st
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
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);
@@ -213,7 +215,7 @@ export function TemplateSettings() {
{/* Edit/Create modal */}
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!editing} animationType="slide" transparent onRequestClose={closeEditor}>
<View style={[styles.modalOverlay, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<View style={styles.modalSheet}>
<View style={[styles.modalSheet, { paddingBottom: bottomInset }]}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>
{editing?.id ? tr('settings.templates.edit', 'Edit Template') : tr('settings.templates.add', 'New Template')}
@@ -287,7 +289,7 @@ export function TemplateSettings() {
{/* Import modal */}
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={importVisible} animationType="slide" transparent onRequestClose={() => setImportVisible(false)}>
<View style={[styles.modalOverlay, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<View style={styles.modalSheet}>
<View style={[styles.modalSheet, { paddingBottom: bottomInset }]}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>{tr('settings.templates.import', 'Import')}</Text>
<Pressable onPress={() => setImportVisible(false)} hitSlop={8} accessibilityRole="button" accessibilityLabel={tr('common.close', 'Close')}>
+20
View File
@@ -0,0 +1,20 @@
import { useSafeAreaInsets } from 'react-native-safe-area-context';
/**
* How far the content of a bottom sheet or dialog has to stay above the
* screen bottom.
*
* Every RN Modal on Android is edge to edge here: with edge-to-edge on (Expo
* SDK 54) the Modal forces statusBarTranslucent and navigationBarTranslucent,
* so its window reaches under the navigation bar, a phone's gesture bar or a
* tablet's taskbar. A sheet that ends at `bottom: 0` (or a backdrop that
* 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.
*
* 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.
*/
export function useSheetBottomInset(): number {
return useSafeAreaInsets().bottom;
}
+8 -4
View File
@@ -69,6 +69,7 @@ import { filesGridLayout } from '../lib/files-grid';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { formatListDate } from '../lib/date-format';
import { useLayout, useSheetInsets } from '../lib/use-layout';
import { useSheetBottomInset } from '../lib/sheet-bottom-inset';
import { useBackWhileFocused } from '../lib/use-back-while-focused';
import { filesBackStep } from '../lib/files-back';
import { driveChange, type DriveScope } from '../lib/files-drive';
@@ -1807,6 +1808,7 @@ function ActionsSheet({
}: ActionsSheetProps) {
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const bottomInset = useSheetBottomInset();
const formatModified = useFormatModified();
const c = useColors();
const styles = React.useMemo(() => makeSheetStyles(c), [c]);
@@ -1828,7 +1830,7 @@ function ActionsSheet({
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<TouchableWithoutFeedback accessible={false}>
<View style={styles.sheet}>
<View style={[styles.sheet, { paddingBottom: spacing.xxl + bottomInset }]}>
<Text style={styles.sheetTitle} numberOfLines={1}>
{target.displayName}
</Text>
@@ -1871,7 +1873,7 @@ function ActionsSheet({
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<TouchableWithoutFeedback accessible={false}>
<View style={styles.sheet}>
<View style={[styles.sheet, { paddingBottom: spacing.xxl + bottomInset }]}>
<Text style={styles.sheetTitle} numberOfLines={1}>
{target.displayName}
</Text>
@@ -1961,6 +1963,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 c = useColors();
const styles = React.useMemo(() => makeSheetStyles(c), [c]);
const options = useMemo(() => {
@@ -2003,7 +2006,7 @@ function FolderPickerSheet({ target, allNodes, rootLabel, t, onClose, onPick }:
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<TouchableWithoutFeedback accessible={false}>
<View style={styles.sheet}>
<View style={[styles.sheet, { paddingBottom: spacing.xxl + bottomInset }]}>
<Text style={styles.sheetTitle} numberOfLines={1}>
{t('files.move_to', 'Move to…')} · {target.displayName}
</Text>
@@ -2051,6 +2054,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 c = useColors();
const styles = React.useMemo(() => makeSheetStyles(c), [c]);
if (!visible) return null;
@@ -2060,7 +2064,7 @@ function SortSheet({ visible, sortKey, sortDir, t, onClose, onChangeKey, onChang
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<TouchableWithoutFeedback accessible={false}>
<View style={styles.sheet}>
<View style={[styles.sheet, { paddingBottom: spacing.xxl + bottomInset }]}>
<Text style={styles.sheetTitle}>{t('files.settings_default_sort', 'Sort')}</Text>
{keys.map((k) => (
<Pressable key={k} style={styles.action} onPress={() => { onChangeKey(k); onClose(); }}>