diff --git a/src/components/__tests__/sheet-bottom-inset.test.ts b/src/components/__tests__/sheet-bottom-inset.test.ts new file mode 100644 index 00000000..112c920e --- /dev/null +++ b/src/components/__tests__/sheet-bottom-inset.test.ts @@ -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 and its . */ +function modalBodies(source: string): string[] { + const out: string[] = []; + const re = /]/g; + let m: RegExpExecArray | null; + while ((m = re.exec(source))) { + const end = source.indexOf('', 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 = '\n\n'; + const bodies = modalBodies(src); + expect(bodies).toHaveLength(1); + expect(rendersSheet(bodies[0])).toBe(true); + expect(padsBottom(bodies[0])).toBe(true); + expect(padsBottom('')).toBe(false); + }); +}); diff --git a/src/components/files/ShareSheet.tsx b/src/components/files/ShareSheet.tsx index 9eafa3dc..7e06f5a3 100644 --- a/src/components/files/ShareSheet.tsx +++ b/src/components/files/ShareSheet.tsx @@ -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 - + diff --git a/src/components/settings/FolderSettings.tsx b/src/components/settings/FolderSettings.tsx index 628b85b6..e51722ec 100644 --- a/src/components/settings/FolderSettings.tsx +++ b/src/components/settings/FolderSettings.tsx @@ -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 = { @@ -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() { - + {editor?.kind === 'edit' diff --git a/src/components/settings/IdentitySettings.tsx b/src/components/settings/IdentitySettings.tsx index 480e8217..96c6bb6b 100644 --- a/src/components/settings/IdentitySettings.tsx +++ b/src/components/settings/IdentitySettings.tsx @@ -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() { - + {editing?.id ? t('settings.identities.edit', "Edit identity") : t('settings.identities.create', "New identity")} diff --git a/src/components/settings/TemplateSettings.tsx b/src/components/settings/TemplateSettings.tsx index c9285fad..a5c249d5 100644 --- a/src/components/settings/TemplateSettings.tsx +++ b/src/components/settings/TemplateSettings.tsx @@ -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 */} - + {editing?.id ? tr('settings.templates.edit', 'Edit Template') : tr('settings.templates.add', 'New Template')} @@ -287,7 +289,7 @@ export function TemplateSettings() { {/* Import modal */} setImportVisible(false)}> - + {tr('settings.templates.import', 'Import')} setImportVisible(false)} hitSlop={8} accessibilityRole="button" accessibilityLabel={tr('common.close', 'Close')}> diff --git a/src/lib/sheet-bottom-inset.ts b/src/lib/sheet-bottom-inset.ts new file mode 100644 index 00000000..0c9eab9c --- /dev/null +++ b/src/lib/sheet-bottom-inset.ts @@ -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; +} diff --git a/src/screens/FilesScreen.tsx b/src/screens/FilesScreen.tsx index a97e9434..32d5c400 100644 --- a/src/screens/FilesScreen.tsx +++ b/src/screens/FilesScreen.tsx @@ -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({ - + {target.displayName} @@ -1871,7 +1873,7 @@ function ActionsSheet({ - + {target.displayName} @@ -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 }: - + {t('files.move_to', 'Move to…')} · {target.displayName} @@ -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 - + {t('files.settings_default_sort', 'Sort')} {keys.map((k) => ( { onChangeKey(k); onClose(); }}>