feat: centre sheets and float toasts bottom right on tablets

This commit is contained in:
Linus Rath
2026-10-15 02:06:19 +02:00
parent 2288d28e46
commit f4ff07da7a
28 changed files with 213 additions and 41 deletions
+4 -1
View File
@@ -10,6 +10,7 @@ import { useSheetDrag } from '../lib/use-sheet-drag';
import type { Identity } from '../api/types';
import { useLocaleStore } from '../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
import { useSheetInsets } from '../lib/use-layout';
interface IdentitySheetProps {
visible: boolean;
@@ -26,6 +27,8 @@ export function IdentitySheet({
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
const slideY = React.useRef(new Animated.Value(500)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose });
const { t } = useLocaleStore();
@@ -52,7 +55,7 @@ export function IdentitySheet({
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }], ...sheetInsets },
]}
>
<View {...dragHandlers}>
+4 -1
View File
@@ -15,6 +15,7 @@ import { useLocaleStore } from '../stores/locale-store';
import { localizeMailboxName } from '../lib/mailbox-label';
import type { Mailbox } from '../api/types';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
import { useSheetInsets } from '../lib/use-layout';
function moveTargetIcon(role: string | null | undefined, name: string): LucideIcon {
const lower = name.toLowerCase();
@@ -44,6 +45,8 @@ export function MoveSheet({
const t = useLocaleStore((s) => s.t);
const insets = useSafeAreaInsets();
const slideY = React.useRef(new Animated.Value(500)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose });
@@ -84,7 +87,7 @@ export function MoveSheet({
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }], ...sheetInsets },
]}
>
<View {...dragHandlers}>
+4 -5
View File
@@ -1,6 +1,7 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable, Animated, Easing } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useFloatingCardFrame } from '../lib/use-layout';
import { BellRing, X } from 'lucide-react-native';
import Button from './Button';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
@@ -24,8 +25,6 @@ import {
// Mirrors the webmail's push-notification-prompt: a short delay after login
// so the inbox renders first, and never again for an account once dismissed.
const PROMPT_DELAY_MS = 1500;
// Clears the tab bar, like the toasts (ToastHost).
const TAB_BAR_CLEARANCE = 64;
/**
* Once-per-account invitation to turn on background notifications. Shown
@@ -51,6 +50,8 @@ export function PushOnboardingPrompt(): React.ReactElement | null {
const [busy, setBusy] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const insets = useSafeAreaInsets();
// Full width above the tab bar on a phone; bottom right on a tablet.
const frame = useFloatingCardFrame();
const duration = useAnimDuration(200);
const appear = React.useRef(new Animated.Value(0)).current;
@@ -117,7 +118,7 @@ export function PushOnboardingPrompt(): React.ReactElement | null {
style={[
styles.wrap,
{
bottom: insets.bottom + TAB_BAR_CLEARANCE,
left: frame.left, right: frame.right, bottom: insets.bottom + frame.bottom,
opacity: appear,
transform: [{ translateY: appear.interpolate({ inputRange: [0, 1], outputRange: [24, 0] }) }],
},
@@ -165,8 +166,6 @@ function makeStyles(c: ThemePalette) {
return StyleSheet.create({
wrap: {
position: 'absolute',
left: spacing.md,
right: spacing.md,
zIndex: 50,
},
card: {
+4 -1
View File
@@ -12,6 +12,7 @@ import { useLocaleStore } from '../stores/locale-store';
import { getEmailTagIds } from '../lib/thread-utils';
import type { Email } from '../api/types';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
import { useSheetInsets } from '../lib/use-layout';
interface TagSheetProps {
visible: boolean;
@@ -29,6 +30,8 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle
const t = useLocaleStore((s) => s.t);
const insets = useSafeAreaInsets();
const slideY = React.useRef(new Animated.Value(500)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose });
@@ -73,7 +76,7 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }], ...sheetInsets },
]}
>
<View {...dragHandlers}>
+4 -1
View File
@@ -11,6 +11,7 @@ import { useLocaleStore } from '../stores/locale-store';
import { useTemplatesStore, type EmailTemplate } from '../stores/templates-store';
import { filterTemplates } from '../lib/template-utils';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
import { useSheetInsets } from '../lib/use-layout';
interface TemplateSheetProps {
visible: boolean;
@@ -33,6 +34,8 @@ export function TemplateSheet({ visible, onClose, onPick }: TemplateSheetProps)
const hydrate = useTemplatesStore((s) => s.hydrate);
const [query, setQuery] = React.useState('');
const slideY = React.useRef(new Animated.Value(500)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose });
@@ -71,7 +74,7 @@ export function TemplateSheet({ visible, onClose, onPick }: TemplateSheetProps)
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }], ...sheetInsets },
]}
>
<View {...dragHandlers}>
+7 -3
View File
@@ -4,6 +4,7 @@ import {
type AccessibilityActionEvent, type LayoutChangeEvent,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useFloatingCardFrame } from '../lib/use-layout';
import { AlertCircle, AlertTriangle, CheckCircle2, Info } from 'lucide-react-native';
import { spacing, radius, type ThemePalette } from '../theme/tokens';
import { useColors, useResolvedTheme } from '../theme/colors';
@@ -21,9 +22,14 @@ import { shouldClaimToastSwipe, toastSwipeRelease } from './toast-swipe';
export function ToastHost(): React.ReactElement | null {
const toasts = useToastStore((s) => s.toasts);
const insets = useSafeAreaInsets();
// Full width above the tab bar on a phone; bottom right on a tablet.
const frame = useFloatingCardFrame();
if (toasts.length === 0) return null;
return (
<View pointerEvents="box-none" style={[styles.wrap, { bottom: insets.bottom + 64 }]}>
<View
pointerEvents="box-none"
style={[styles.wrap, { left: frame.left, right: frame.right, bottom: insets.bottom + frame.bottom }]}
>
{toasts.map((t) => <ToastCard key={t.id} toast={t} />)}
</View>
);
@@ -226,8 +232,6 @@ function ToastCard({ toast }: { toast: Toast }) {
const styles = StyleSheet.create({
wrap: {
position: 'absolute',
left: spacing.md,
right: spacing.md,
gap: spacing.sm,
zIndex: 60,
},
+4 -5
View File
@@ -1,6 +1,7 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable, Animated, Easing } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useFloatingCardFrame } from '../lib/use-layout';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
@@ -11,8 +12,6 @@ import { useLocaleStore } from '../stores/locale-store';
import type { RootStackParamList } from '../navigation/types';
const VISIBLE_MS = 5000;
// Clears the tab bar and the viewer's action bar, like the toasts (ToastHost).
const BOTTOM_CLEARANCE = 64;
interface Shown {
label: string;
@@ -32,6 +31,8 @@ export function UndoSnackbar() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
// Full width above the tab bar on a phone; bottom right on a tablet.
const frame = useFloatingCardFrame();
const t = useLocaleStore((s) => s.t);
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const entry = useEmailStore((s) => s.pendingUndo);
@@ -128,7 +129,7 @@ export function UndoSnackbar() {
pointerEvents="box-none"
style={[
styles.wrap,
{ bottom: insets.bottom + BOTTOM_CLEARANCE, opacity, transform: [{ translateY: slideY }] },
{ left: frame.left, right: frame.right, bottom: insets.bottom + frame.bottom, opacity, transform: [{ translateY: slideY }] },
]}
>
<View style={styles.bar}>
@@ -160,8 +161,6 @@ function makeStyles(c: ThemePalette) {
return StyleSheet.create({
wrap: {
position: 'absolute',
left: spacing.md,
right: spacing.md,
zIndex: 50,
},
bar: {
@@ -18,6 +18,7 @@ import { useLocaleStore } from '../../stores/locale-store';
import { CALENDAR_COLOR_PALETTE, calendarColorName, getCalendarColor } from '../../lib/calendar-utils';
import Button from '../Button';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
export interface CalendarEditValues {
name: string;
@@ -36,6 +37,8 @@ interface CalendarEditSheetProps {
// Create / rename / recolour / describe a calendar. The same fields the
// webmail's calendar-management settings offer, as a bottom sheet.
export function CalendarEditSheet({ visible, calendar, onSave, onClose }: CalendarEditSheetProps) {
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
@@ -72,7 +75,7 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback onPress={onClose}>
<View style={styles.backdrop}>
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<TouchableWithoutFeedback>
<View style={styles.sheet}>
<Text style={styles.title}>
@@ -18,6 +18,7 @@ import { spacing, radius, typography, type ThemePalette } from '../../theme/toke
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
type RolePreset = 'freeBusy' | 'read' | 'readWrite' | 'manager';
@@ -70,6 +71,8 @@ interface CalendarShareSheetProps {
// principal list comes from the same Principal/query the Files share sheet
// uses.
export function CalendarShareSheet({ calendar, onShare, onClose }: CalendarShareSheetProps) {
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
@@ -158,7 +161,7 @@ export function CalendarShareSheet({ calendar, onShare, onClose }: CalendarShare
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback onPress={onClose}>
<View style={styles.backdrop}>
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<TouchableWithoutFeedback>
<View style={styles.sheet}>
<View style={styles.titleRow}>
+4 -1
View File
@@ -61,6 +61,7 @@ import { useSheetDrag } from '../../lib/use-sheet-drag';
import { recurrenceEndLabel, recurrenceIntervalLabel } from '../../lib/recurrence';
import type { TranslateFn } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
type RsvpStatus = 'accepted' | 'declined' | 'tentative';
@@ -141,6 +142,8 @@ export function EventDetailSheet({
// name) render with the contact card's name instead of the raw email.
const resolveContactName = useContactNameResolver();
const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const visible = !!event;
const dragHandlers = useSheetDrag({
@@ -255,7 +258,7 @@ export function EventDetailSheet({
<Pressable style={styles.overlayPress} onPress={onClose} />
</Animated.View>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }], ...sheetInsets }]}>
<SafeAreaView edges={['bottom']} style={styles.sheetSafe}>
<View {...dragHandlers}>
<View style={styles.handleHit}>
+4 -1
View File
@@ -18,6 +18,7 @@ import { useLocaleStore } from '../../stores/locale-store';
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';
// expo-document-picker is loaded lazily on first use; its native module is not
// linked into every build (matches the FilesScreen / ContactImportSheet flow).
@@ -75,6 +76,8 @@ export function ICalImportSheet({ visible, onClose, calendars, onImport, onImpor
const [result, setResult] = React.useState<ImportResult | null>(null);
const slideY = React.useRef(new Animated.Value(900)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const resetState = React.useCallback(() => {
@@ -189,7 +192,7 @@ export function ICalImportSheet({ visible, onClose, calendars, onImport, onImpor
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={{ flex: 1 }} onPress={onClose} />
</Animated.View>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }], ...sheetInsets }]}>
<SafeAreaView edges={['bottom']} style={{ flex: 1 }}>
<View style={styles.header}>
<Text style={styles.title}>{t('calendar.import.title', 'Import Calendar')}</Text>
@@ -18,6 +18,7 @@ import {
} from '../../stores/calendar-subscriptions-store';
import { jmapClient } from '../../api/jmap-client';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
// The webmail's refresh intervals and their labels.
const INTERVAL_OPTIONS: { minutes: number; key: string; fallback: string }[] = [
@@ -76,6 +77,8 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
};
const slideY = React.useRef(new Animated.Value(900)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
React.useEffect(() => {
@@ -136,7 +139,7 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={{ flex: 1 }} onPress={onClose} />
</Animated.View>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }], ...sheetInsets }]}>
<SafeAreaView edges={['bottom']} style={{ flex: 1 }}>
<View style={styles.header}>
<Rss size={20} color={c.text} />
+4 -1
View File
@@ -43,6 +43,7 @@ import { isWritableCalendar } from '../../lib/calendar-editability';
import { useCalendarLocale } from '../../lib/calendar-locale';
import { useSheetDrag } from '../../lib/use-sheet-drag';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
type TaskFilter = 'all' | 'pending' | 'completed' | 'overdue';
@@ -108,6 +109,8 @@ export function TasksSheet({
const styles = React.useMemo(() => makeStyles(c), [c]);
const { locale, t } = useCalendarLocale();
const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({
slideY,
@@ -236,7 +239,7 @@ export function TasksSheet({
<Pressable style={styles.overlayPress} onPress={onClose} />
</Animated.View>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }], ...sheetInsets }]}>
<SafeAreaView edges={['bottom']} style={styles.sheetSafe}>
<View {...dragHandlers}>
<View style={styles.handleHit}>
@@ -10,6 +10,7 @@ import { useSheetDrag } from '../../lib/use-sheet-drag';
import { useContactsStore } from '../../stores/contacts-store';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
interface Props {
visible: boolean;
@@ -35,6 +36,8 @@ export default function AddressBookPickerSheet({
const [busy, setBusy] = React.useState(false);
const slideY = React.useRef(new Animated.Value(600)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({ slideY, closedY: 600, onClose });
@@ -78,7 +81,7 @@ export default function AddressBookPickerSheet({
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }], ...sheetInsets },
]}
>
<View {...dragHandlers}>
@@ -14,6 +14,7 @@ import { spacing, radius, typography, type ThemePalette } from '../../theme/toke
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
// expo-document-picker is loaded lazily on first use; its native module is not
// linked into every build (matches the FilesScreen upload flow).
@@ -57,6 +58,8 @@ export default function ContactImportSheet({
const [result, setResult] = React.useState<{ imported: number; failed: number } | null>(null);
const slideY = React.useRef(new Animated.Value(900)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const resetState = React.useCallback(() => {
@@ -165,7 +168,7 @@ export default function ContactImportSheet({
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={{ flex: 1 }} onPress={onClose} />
</Animated.View>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }], ...sheetInsets }]}>
<SafeAreaView edges={['bottom']} style={{ flex: 1 }}>
<View style={styles.header}>
<Text style={styles.title}>{t('contacts.import.title', 'Import Contacts')}</Text>
@@ -17,6 +17,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 { useSheetInsets } from '../../lib/use-layout';
interface Props {
visible: boolean;
@@ -42,6 +43,8 @@ export default function ContactPickerSheet({
const [selected, setSelected] = React.useState<Set<string>>(new Set());
const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlay = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({
slideY,
@@ -100,7 +103,7 @@ export default function ContactPickerSheet({
<Pressable style={styles.overlayPress} onPress={onClose} />
</Animated.View>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }], ...sheetInsets }]}>
<SafeAreaView style={{ flex: 1 }} edges={['bottom']}>
<View {...dragHandlers}>
<View style={styles.handleHit}>
+4 -1
View File
@@ -10,6 +10,7 @@ import { useSheetDrag } from '../../lib/use-sheet-drag';
import { useContactsStore, selectKeywordsUsed } from '../../stores/contacts-store';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
interface Props {
visible: boolean;
@@ -36,6 +37,8 @@ export default function TagAssignSheet({
const [input, setInput] = React.useState('');
const slideY = React.useRef(new Animated.Value(600)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({ slideY, closedY: 600, onClose });
@@ -68,7 +71,7 @@ export default function TagAssignSheet({
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }], ...sheetInsets },
]}
>
<View {...dragHandlers}>
+4 -1
View File
@@ -7,6 +7,7 @@ import { useColors } from '../../theme/colors';
import { useSheetDrag } from '../../lib/use-sheet-drag';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
export interface ActionSheetItem {
key: string;
@@ -38,6 +39,8 @@ export function ActionSheet({ visible, title, subtitle, items, onClose, children
const insets = useSafeAreaInsets();
const t = useLocaleStore((s) => s.t);
const slideY = React.useRef(new Animated.Value(500)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose });
@@ -63,7 +66,7 @@ export function ActionSheet({ visible, title, subtitle, items, onClose, children
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }], ...sheetInsets },
]}
>
<View {...dragHandlers}>
+4 -1
View File
@@ -19,6 +19,7 @@ import { spacing, radius, typography, type ThemePalette } from '../../theme/toke
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
type RolePreset = 'read' | 'readWrite' | 'manager';
@@ -66,6 +67,8 @@ 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 c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
@@ -158,7 +161,7 @@ export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback onPress={onClose}>
<View style={styles.backdrop}>
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<TouchableWithoutFeedback>
<View style={styles.sheet}>
<View style={styles.titleRow}>
+4 -1
View File
@@ -26,6 +26,7 @@ import { createMailbox, updateMailbox, deleteMailbox } from '../../api/email';
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';
const ROLE_ICON: Record<string, any> = {
inbox: Inbox, drafts: FileText, sent: Send, trash: Trash,
@@ -64,6 +65,8 @@ 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 c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
@@ -299,7 +302,7 @@ export function FolderSettings() {
</SettingsSection>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!editor} animationType="slide" transparent onRequestClose={closeEditor}>
<View style={styles.modalOverlay}>
<View style={[styles.modalOverlay, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<View style={styles.modalSheet}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>
+4 -1
View File
@@ -28,6 +28,7 @@ import { isValidEmail, parseRecipientList, formatRecipient } from '../../lib/rec
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';
type DraftIdentity = {
id: string;
@@ -93,6 +94,8 @@ 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 c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const identities = useSettingsStore((s) => s.identities);
@@ -300,7 +303,7 @@ export function IdentitySettings() {
))}
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!editing} animationType="slide" transparent onRequestClose={closeEditor}>
<View style={styles.modalOverlay}>
<View style={[styles.modalOverlay, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<View style={styles.modalSheet}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>
+5 -2
View File
@@ -18,8 +18,11 @@ import { useColors } from '../../theme/colors';
import { useTemplatesStore, type EmailTemplate } from '../../stores/templates-store';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
export function TemplateSettings() {
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const tr = useLocaleStore((s) => s.t);
@@ -198,7 +201,7 @@ export function TemplateSettings() {
{/* Edit/Create modal */}
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!editing} animationType="slide" transparent onRequestClose={closeEditor}>
<View style={styles.modalOverlay}>
<View style={[styles.modalOverlay, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<View style={styles.modalSheet}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>
@@ -272,7 +275,7 @@ export function TemplateSettings() {
{/* Import modal */}
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={importVisible} animationType="slide" transparent onRequestClose={() => setImportVisible(false)}>
<View style={styles.modalOverlay}>
<View style={[styles.modalOverlay, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<View style={styles.modalSheet}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>{tr('settings.templates.import', 'Import')}</Text>
@@ -25,6 +25,7 @@ import { otherAccountsSyncing, type SyncCollection } from '../../../device-sync/
import { CONTACTS_AUTHORITY, type Authority } from '../../../device-sync/types';
import { useSyncCollections } from './use-sync-collections';
import { MODAL_ORIENTATIONS } from '../../../lib/modal-orientations';
import { useSheetInsets } from '../../../lib/use-layout';
interface Props {
visible: boolean;
@@ -49,6 +50,8 @@ export function DeviceSyncCollectionsSheet({ visible, onClose, registryId, autho
const changed = React.useRef(false);
const slideY = React.useRef(new Animated.Value(600)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const close = React.useCallback(() => {
const wasChanged = changed.current;
@@ -116,7 +119,7 @@ export function DeviceSyncCollectionsSheet({ visible, onClose, registryId, autho
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }], ...sheetInsets },
]}
>
<View {...dragHandlers}>
+30 -1
View File
@@ -1,8 +1,9 @@
import { describe, it, expect, vi } from 'vitest';
vi.mock('react-native', () => ({ useWindowDimensions: () => ({ width: 411, height: 914 }) }));
vi.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }) }));
import { layoutFor, listPaneWidth } from '../use-layout';
import { floatingCardFrame, layoutFor, listPaneWidth, sheetSideInsets } from '../use-layout';
describe('layoutFor', () => {
it('keeps a phone in portrait on the single-pane layout', () => {
@@ -50,3 +51,31 @@ describe('listPaneWidth', () => {
expect(listPaneWidth(2000)).toBe(420);
});
});
describe('sheetSideInsets', () => {
it('keeps a phone sheet full width inside the side insets', () => {
expect(sheetSideInsets(411, 0, 0)).toEqual({ left: 0, right: 0 });
});
it('centres the sheet at 560 dp on a wide window', () => {
expect(sheetSideInsets(1280, 0, 0)).toEqual({ left: 360, right: 360 });
expect(sheetSideInsets(914, 0, 48)).toEqual({ left: 177, right: 177 });
});
it('never puts a centred sheet under a side inset', () => {
expect(sheetSideInsets(640, 0, 48)).toEqual({ left: 48, right: 48 });
});
});
describe('floatingCardFrame', () => {
it('spans a phone with 12 dp margins above the tab bar', () => {
expect(floatingCardFrame(411, false, { left: 0, right: 0 })).toEqual({ left: 12, right: 12, bottom: 64 });
});
it('sits bottom right at 360 dp on a split layout', () => {
const f = floatingCardFrame(1280, true, { left: 0, right: 0 });
expect(1280 - f.left - f.right).toBe(360);
expect(f.right).toBe(20);
expect(f.bottom).toBe(20);
});
});
+66
View File
@@ -1,4 +1,6 @@
import React from 'react';
import { useWindowDimensions } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
/**
* Window size classes for the adaptive layouts (docs/ux-audit-2026-10.md,
@@ -45,3 +47,67 @@ export function listPaneWidth(windowWidth: number): number {
/** Width for sheets, dialogs and floating cards that would span a wide window. */
export const SHEET_MAX_WIDTH = 560;
/**
* Left and right offsets of a bottom sheet: centred at SHEET_MAX_WIDTH on a
* window 600 dp or wider (a phone in landscape included), else full width
* inside the side safe-area insets (a landscape notch or navigation bar).
*/
export function sheetSideInsets(
width: number,
insetLeft: number,
insetRight: number,
): { left: number; right: number } {
if (width >= MEDIUM_MIN_WIDTH) {
const side = Math.max(Math.round((width - SHEET_MAX_WIDTH) / 2), insetLeft, insetRight);
return { left: side, right: side };
}
return { left: insetLeft, right: insetRight };
}
/** `sheetSideInsets` for the current window. */
export function useSheetInsets(): { left: number; right: number } {
const { width } = useWindowDimensions();
const insets = useSafeAreaInsets();
const { left, right } = sheetSideInsets(width, insets.left, insets.right);
return React.useMemo(() => ({ left, right }), [left, right]);
}
/** APP.md toast card: 360 wide, 20 from the edges on a large screen. */
const FLOATING_CARD_WIDTH = 360;
const FLOATING_CARD_MARGIN = 20;
/** Phone: full width with 12 dp margins, above the 64 dp tab bar. */
const PHONE_CARD_MARGIN = 12;
const TAB_BAR_CLEARANCE = 64;
/**
* Where toasts, the undo bar and the push prompt float. A phone gets the full
* width above the tab bar; a split layout (tabs in a side rail) gets the
* webmail's desktop placement, bottom right.
*/
export function floatingCardFrame(
width: number,
split: boolean,
insets: { left: number; right: number },
): { left: number; right: number; bottom: number } {
if (split) {
return {
left: Math.max(FLOATING_CARD_MARGIN, width - FLOATING_CARD_WIDTH - FLOATING_CARD_MARGIN - insets.right),
right: FLOATING_CARD_MARGIN + insets.right,
bottom: FLOATING_CARD_MARGIN,
};
}
return {
left: PHONE_CARD_MARGIN + insets.left,
right: PHONE_CARD_MARGIN + insets.right,
bottom: TAB_BAR_CLEARANCE,
};
}
/** `floatingCardFrame` for the current window; `bottom` excludes the safe-area inset. */
export function useFloatingCardFrame(): { left: number; right: number; bottom: number } {
const { width, height } = useWindowDimensions();
const insets = useSafeAreaInsets();
const { left, right, bottom } = floatingCardFrame(width, layoutFor(width, height).isSplit, insets);
return React.useMemo(() => ({ left, right, bottom }), [left, right, bottom]);
}
+4 -1
View File
@@ -33,6 +33,7 @@ import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'
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';
type Nav = NativeStackNavigationProp<RootStackParamList, 'ContactDetail'>;
type Route = RouteProp<RootStackParamList, 'ContactDetail'>;
@@ -789,6 +790,8 @@ function MoreActionsSheet({
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
React.useEffect(() => {
@@ -810,7 +813,7 @@ function MoreActionsSheet({
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
<Pressable style={{ flex: 1 }} onPress={onClose} />
</Animated.View>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }], ...sheetInsets }]}>
<SafeAreaView edges={['bottom']}>
<View style={styles.handleHit}>
<View style={styles.handle} />
+7 -2
View File
@@ -48,6 +48,7 @@ import { accountScopedId } from '../lib/thread-utils';
import type { Email, EmailAddress, Identity } from '../api/types';
import type { RootStackParamList } from '../navigation/types';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
import { useSheetInsets } from '../lib/use-layout';
type Props = NativeStackScreenProps<RootStackParamList, 'EmailThread'> & {
/** Shown in a split view's detail pane: no back button. */
@@ -1269,6 +1270,8 @@ function MoreMenuSheet({
const t = useLocaleStore((s) => s.t);
const insets = useSafeAreaInsets();
const slideY = React.useRef(new Animated.Value(600)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({ slideY, closedY: 600, onClose });
@@ -1294,7 +1297,7 @@ function MoreMenuSheet({
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }], ...sheetInsets },
]}
>
<View {...dragHandlers}>
@@ -1418,6 +1421,8 @@ function TagMenuSheet({ visible, onClose, keywords, activeKeywords, onToggle }:
const t = useLocaleStore((s) => s.t);
const insets = useSafeAreaInsets();
const slideY = React.useRef(new Animated.Value(500)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose });
@@ -1443,7 +1448,7 @@ function TagMenuSheet({ visible, onClose, keywords, activeKeywords, onToggle }:
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }], ...sheetInsets },
]}
>
<View {...dragHandlers}>
+10 -3
View File
@@ -66,6 +66,7 @@ import Dialog from '../components/Dialog';
import ShareSheet from '../components/files/ShareSheet';
import { FilePreviewModal, canPreviewInApp } from '../components/files/FilePreviewModal';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
import { useSheetInsets } from '../lib/use-layout';
// A file row carries the display name alongside the rest of the node.
interface FileRow extends FileNode {
@@ -1208,6 +1209,8 @@ interface ActionsSheetProps {
function ActionsSheet({
target, sharingEnabled, t, onClose, onPreview, onOpenWith, onDownload, onShare, onMove, onDuplicate, onRename, onDelete,
}: ActionsSheetProps) {
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const c = useColors();
const styles = React.useMemo(() => makeSheetStyles(c), [c]);
if (!target) return null;
@@ -1220,7 +1223,7 @@ function ActionsSheet({
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback onPress={onClose}>
<View style={styles.backdrop}>
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<TouchableWithoutFeedback>
<View style={styles.sheet}>
<Text style={styles.sheetTitle} numberOfLines={1}>
@@ -1302,6 +1305,8 @@ interface FolderPickerSheetProps {
// its descendants (a folder can't be moved into itself), flattened with
// indentation, plus the drive root.
function FolderPickerSheet({ target, allNodes, rootLabel, t, onClose, onPick }: FolderPickerSheetProps) {
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const c = useColors();
const styles = React.useMemo(() => makeSheetStyles(c), [c]);
const options = useMemo(() => {
@@ -1342,7 +1347,7 @@ function FolderPickerSheet({ target, allNodes, rootLabel, t, onClose, onPick }:
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback onPress={onClose}>
<View style={styles.backdrop}>
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<TouchableWithoutFeedback>
<View style={styles.sheet}>
<Text style={styles.sheetTitle} numberOfLines={1}>
@@ -1390,6 +1395,8 @@ 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 c = useColors();
const styles = React.useMemo(() => makeSheetStyles(c), [c]);
if (!visible) return null;
@@ -1397,7 +1404,7 @@ function SortSheet({ visible, sortKey, sortDir, t, onClose, onChangeKey, onChang
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback onPress={onClose}>
<View style={styles.backdrop}>
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<TouchableWithoutFeedback>
<View style={styles.sheet}>
<Text style={styles.sheetTitle}>{t('files.settings_default_sort', 'Sort')}</Text>