mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
fix: keep bottom sheet buttons above the tablet taskbar
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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')}>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(); }}>
|
||||
|
||||
Reference in New Issue
Block a user