diff --git a/src/components/SidebarDrawer.tsx b/src/components/SidebarDrawer.tsx index b23e627a..1f8e5f1f 100644 --- a/src/components/SidebarDrawer.tsx +++ b/src/components/SidebarDrawer.tsx @@ -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 ( - + {title} {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 ( - - {title} - {message ? {message} : null} - - - - {t('common.cancel', 'Cancel')} - - - {confirmLabel} - + + + {title} + {message ? {message} : null} + + + + {t('common.cancel', 'Cancel')} + + + {confirmLabel} + + @@ -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, diff --git a/src/components/__tests__/sheet-bottom-inset.test.ts b/src/components/__tests__/sheet-bottom-inset.test.ts index 112c920e..37d24618 100644 --- a/src/components/__tests__/sheet-bottom-inset.test.ts +++ b/src/components/__tests__/sheet-bottom-inset.test.ts @@ -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([]); 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('')).toBe(false); + expect(padsForKeyboard('')).toBe(true); + expect(padsForKeyboard('')).toBe(false); }); }); diff --git a/src/components/calendar/CalendarEditSheet.tsx b/src/components/calendar/CalendarEditSheet.tsx index 739aebc0..4fbc9aca 100644 --- a/src/components/calendar/CalendarEditSheet.tsx +++ b/src/components/calendar/CalendarEditSheet.tsx @@ -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 - + {calendar ? t('calendar.management.edit_calendar', 'Edit calendar') diff --git a/src/components/sharing/ShareCollectionSheet.tsx b/src/components/sharing/ShareCollectionSheet.tsx index 470f9523..102bf117 100644 --- a/src/components/sharing/ShareCollectionSheet.tsx +++ b/src/components/sharing/ShareCollectionSheet.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'; /** A role the sheet offers: its rights and the webmail's name for it. */ export interface SharePreset { @@ -64,6 +65,8 @@ export function ShareCollectionSheet({ }: ShareCollectionSheetProps) { // 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({ - + diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index d604f9b1..b0997960 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -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>([]); 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)} > - + {t('email_composer.add_link', 'Add link')} @@ -3143,7 +3147,7 @@ export default function ComposeScreen({ route, navigation }: Props) { animationType="fade" onRequestClose={closeAltPrompt} > - + {t('email_composer.image_alt.title', 'Describe this image')}