mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
fix: keep folder, calendar and share sheets and compose prompts above the taskbar and keyboard
This commit is contained in:
@@ -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}>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user