fix: keep folder, calendar and share sheets and compose prompts above the taskbar and keyboard

This commit is contained in:
Linus Rath
2026-10-31 14:12:58 +01:00
parent 5ecf6f3256
commit 9b0bba7c15
5 changed files with 87 additions and 35 deletions
+35 -25
View File
@@ -48,6 +48,7 @@ import {
} from '../lib/notification-mutes';
import { serverSupportsEmailPush } from '../lib/push-notifications';
import { mailboxIconKind, type MailboxIconKind } from '../lib/mailbox-icon';
import { useSheetBottomInset } from '../lib/sheet-bottom-inset';
const CHEVRON_SLOT = 20;
const INDENT_STEP = 12;
@@ -242,10 +243,12 @@ interface SheetAction {
function ActionSheet({ title, actions, onClose }: { title: string; actions: SheetAction[]; onClose: () => void }) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
// The Modal reaches under a tablet's taskbar: keep the last action above it.
const bottomInset = useSheetBottomInset();
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="fade" onRequestClose={onClose} statusBarTranslucent>
<Pressable style={styles.sheetOverlay} onPress={onClose} />
<View style={styles.sheet}>
<View style={[styles.sheet, { paddingBottom: spacing.xl + bottomInset }]}>
<Text style={styles.sheetTitle} numberOfLines={1}>{title}</Text>
{actions.map((a) => {
const Icon = a.icon;
@@ -278,6 +281,8 @@ function NamePrompt({ title, message, initial, confirmLabel, onSubmit, onClose }
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const [value, setValue] = React.useState(initial ?? '');
// Centred above the keyboard (the field autofocuses) and the taskbar.
const bottomInset = useSheetBottomInset();
const submit = () => {
const name = value.trim();
if (!name) return;
@@ -287,27 +292,29 @@ function NamePrompt({ title, message, initial, confirmLabel, onSubmit, onClose }
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="fade" onRequestClose={onClose} statusBarTranslucent>
<Pressable style={styles.sheetOverlay} onPress={onClose} />
<View style={styles.promptCard}>
<Text style={styles.sheetTitle}>{title}</Text>
{message ? <Text style={styles.promptMessage}>{message}</Text> : null}
<TextInput
accessibilityLabel={t('mailbox_context_menu.placeholder_folder_name', 'Folder name')}
value={value}
onChangeText={setValue}
placeholder={t('mailbox_context_menu.placeholder_folder_name', 'Folder name')}
placeholderTextColor={c.textMuted}
style={styles.promptInput}
autoFocus
onSubmitEditing={submit}
returnKeyType="done"
/>
<View style={styles.promptActions}>
<Pressable onPress={onClose} style={styles.promptButton} hitSlop={6}>
<Text style={styles.promptButtonText}>{t('common.cancel', 'Cancel')}</Text>
</Pressable>
<Pressable onPress={submit} style={styles.promptButton} hitSlop={6} disabled={!value.trim()}>
<Text style={[styles.promptButtonText, { color: c.primary }, !value.trim() && { opacity: 0.5 }]}>{confirmLabel}</Text>
</Pressable>
<View style={[styles.promptCenter, { paddingBottom: bottomInset }]} pointerEvents="box-none">
<View style={styles.promptCard}>
<Text style={styles.sheetTitle}>{title}</Text>
{message ? <Text style={styles.promptMessage}>{message}</Text> : null}
<TextInput
accessibilityLabel={t('mailbox_context_menu.placeholder_folder_name', 'Folder name')}
value={value}
onChangeText={setValue}
placeholder={t('mailbox_context_menu.placeholder_folder_name', 'Folder name')}
placeholderTextColor={c.textMuted}
style={styles.promptInput}
autoFocus
onSubmitEditing={submit}
returnKeyType="done"
/>
<View style={styles.promptActions}>
<Pressable onPress={onClose} style={styles.promptButton} hitSlop={6}>
<Text style={styles.promptButtonText}>{t('common.cancel', 'Cancel')}</Text>
</Pressable>
<Pressable onPress={submit} style={styles.promptButton} hitSlop={6} disabled={!value.trim()}>
<Text style={[styles.promptButtonText, { color: c.primary }, !value.trim() && { opacity: 0.5 }]}>{confirmLabel}</Text>
</Pressable>
</View>
</View>
</View>
</Modal>
@@ -1610,10 +1617,13 @@ function makeStyles(c: ThemePalette) {
minHeight: 44,
},
sheetRowText: { ...typography.body, color: c.text },
// Centres the prompt in the space above the keyboard and the navigation bar.
promptCenter: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
paddingHorizontal: spacing.lg,
},
promptCard: {
position: 'absolute',
left: spacing.lg, right: spacing.lg,
top: '30%',
backgroundColor: c.popover,
borderRadius: radius.md,
borderWidth: 1,
@@ -13,11 +13,7 @@ import path from 'node:path';
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',
]);
const OWNED_ELSEWHERE = new Set<string>([]);
function sourceFiles(dir: string): string[] {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
@@ -48,6 +44,16 @@ function padsBottom(body: string): boolean {
return /bottomInset|insets\.bottom/.test(body);
}
/** A body with a text field that opens the keyboard as the Modal opens. */
function autoFocuses(body: string): boolean {
return /\bautoFocus\b/.test(body);
}
/** Padded by useSheetBottomInset under any name (bottomInset, altInset, ...). */
function padsForKeyboard(body: string): boolean {
return /[a-zA-Z]*Inset\b|insets\.bottom/.test(body.replace(/\bsheetInsets\b/g, ''));
}
describe('bottom sheets in a Modal', () => {
it('pad their content above the navigation bar or taskbar', () => {
const offenders = sourceFiles(SRC).flatMap((file) => {
@@ -61,8 +67,32 @@ describe('bottom sheets in a Modal', () => {
expect(offenders).toEqual([]);
});
// A dialog whose field autofocuses opens with the keyboard up. The Modal
// window doesn't shrink for it, so a card at a fixed spot (the folder name
// prompt at top 30%) or centred in the full screen (Compose's image
// description) had its field or buttons under the keyboard.
it('keep a dialog with an autofocused field above the keyboard', () => {
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) => autoFocuses(body) && !padsForKeyboard(body))
.map(() => rel);
});
expect(offenders).toEqual([]);
});
it('centres the folder name prompt instead of pinning it at 30 %', () => {
const source = fs.readFileSync(path.join(SRC, 'components/SidebarDrawer.tsx'), 'utf8');
expect(source).not.toMatch(/top:\s*'30%'/);
});
it('covers the sheets the tablet walkthrough found', () => {
for (const rel of ['components/settings/FolderSettings.tsx', 'screens/FilesScreen.tsx', 'components/files/ShareSheet.tsx']) {
for (const rel of [
'components/settings/FolderSettings.tsx', 'screens/FilesScreen.tsx', 'components/files/ShareSheet.tsx',
'components/SidebarDrawer.tsx', 'components/calendar/CalendarEditSheet.tsx', 'components/sharing/ShareCollectionSheet.tsx',
]) {
const bodies = modalBodies(fs.readFileSync(path.join(SRC, rel), 'utf8')).filter(rendersSheet);
expect(bodies.length, rel).toBeGreaterThan(0);
}
@@ -75,5 +105,7 @@ describe('bottom sheets in a Modal', () => {
expect(rendersSheet(bodies[0])).toBe(true);
expect(padsBottom(bodies[0])).toBe(true);
expect(padsBottom('<Modal><View style={styles.sheet} /></Modal>')).toBe(false);
expect(padsForKeyboard('<View style={[styles.backdrop, { paddingBottom: spacing.lg + altInset }]}>')).toBe(true);
expect(padsForKeyboard('<View style={[styles.backdrop, { paddingLeft: sheetInsets.left }]}>')).toBe(false);
});
});
@@ -33,6 +33,7 @@ const CUSTOM_COLOR_GRID = customColorGrid();
import Button from '../Button';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
import { useSheetBottomInset } from '../../lib/sheet-bottom-inset';
export interface CalendarEditValues {
name: string;
@@ -147,6 +148,8 @@ interface CalendarEditSheetProps {
export function CalendarEditSheet({ visible, calendar, onSave, onClose }: CalendarEditSheetProps) {
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
// Create above a tablet's taskbar, the hex field above the keyboard.
const bottomInset = useSheetBottomInset(visible);
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
@@ -216,7 +219,7 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend
<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.title}>
{calendar
? t('calendar.management.edit_calendar', 'Edit calendar')
@@ -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';
/** A role the sheet offers: its rights and the webmail's name for it. */
export interface SharePreset<R> {
@@ -64,6 +65,8 @@ export function ShareCollectionSheet<R extends object>({
}: ShareCollectionSheetProps<R>) {
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
// Done above a tablet's taskbar, the search field above the keyboard.
const bottomInset = useSheetBottomInset();
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
@@ -208,7 +211,7 @@ export function ShareCollectionSheet<R extends object>({
<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}>
<Users size={18} color={c.textMuted} />
<Text style={styles.title} numberOfLines={1}>
+6 -2
View File
@@ -32,6 +32,7 @@ import { getEmailInitials } from '../lib/avatar-utils';
import { useContactsStore, type RecipientSuggestion } from '../stores/contacts-store';
import { useKeyboardObstruction } from '../lib/use-keyboard-obstruction';
import { useLayout } from '../lib/use-layout';
import { useSheetBottomInset } from '../lib/sheet-bottom-inset';
import { createAfterDismissQueue } from '../lib/after-modal-dismiss';
import { useLocaleStore } from '../stores/locale-store';
import { useSettingsStore } from '../stores/settings-store';
@@ -2084,6 +2085,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
// readers skip it; Skip leaves it undescribed.
const [altQueue, setAltQueue] = React.useState<Array<{ cid: string; src: string }>>([]);
const altTarget = altQueue[0] ?? null;
// The field autofocuses: the card centres above the keyboard and the taskbar.
const altInset = useSheetBottomInset(!!altTarget);
const [altText, setAltText] = React.useState('');
const [altDecorative, setAltDecorative] = React.useState(false);
React.useEffect(() => {
@@ -2102,6 +2105,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
// ── Link prompt (Modal) ──────────────────────────────────────────────
const [linkPromptVisible, setLinkPromptVisible] = React.useState(false);
const [linkPromptValue, setLinkPromptValue] = React.useState('');
const linkInset = useSheetBottomInset(linkPromptVisible);
const openLinkPrompt = () => {
setLinkPromptValue('https://');
@@ -3092,7 +3096,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
animationType="fade"
onRequestClose={() => setLinkPromptVisible(false)}
>
<View style={styles.modalBackdrop}>
<View style={[styles.modalBackdrop, { paddingBottom: spacing.lg + linkInset }]}>
<View style={styles.modalCard}>
<Text style={styles.modalTitle}>
{t('email_composer.add_link', 'Add link')}
@@ -3143,7 +3147,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
animationType="fade"
onRequestClose={closeAltPrompt}
>
<View style={styles.modalBackdrop}>
<View style={[styles.modalBackdrop, { paddingBottom: spacing.lg + altInset }]}>
<View style={styles.modalCard}>
<Text style={styles.modalTitle}>{t('email_composer.image_alt.title', 'Describe this image')}</Text>
<Text style={styles.modalLabel}>