fix: let the search filter sheet scroll above the keyboard and clear fully

This commit is contained in:
Linus Rath
2026-10-31 13:52:38 +01:00
parent ff878fc6bf
commit 66cdc07d31
4 changed files with 178 additions and 12 deletions
+4 -1
View File
@@ -1,9 +1,12 @@
{
"advanced_search": {
"advanced_filters_count": "{name}, {count} active",
"after": "After",
"before": "Before",
"larger_than_kb": "Larger than {size} KB",
"no_attachment": "No attachment",
"not_starred": "Not starred"
"not_starred": "Not starred",
"smaller_than_kb": "Smaller than {size} KB"
},
"calendar": {
"a11y": {
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { filterButtonLabel, filterSheetFrame, keepCustomSize, sizeChipLabel } from '../search-utils';
// The search filter sheet (walkthrough on a Pixel 7): it did not scroll and
// its last row was cut off, the size fields sat under the keyboard, the size
// chip read "Larger than (KB): 1 KB", and after Clear the filter button was
// still announced with a count while "Custom size" stayed picked.
const t = (_key: string, fallback: string, values?: Record<string, string | number>) =>
fallback.replace(/\{(\w+)\}/g, (_m, k: string) => String(values?.[k] ?? ''));
const read = (rel: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', rel), 'utf8');
describe('search filter sheet', () => {
it('names a size chip without the field placeholder', () => {
expect(sizeChipLabel('min', '1', t)).toBe('Larger than 1 KB');
expect(sizeChipLabel('max', '250', t)).toBe('Smaller than 250 KB');
});
it('puts the filter count in the button label, and drops it at zero', () => {
expect(filterButtonLabel(0, t)).toBe('Advanced search filters');
expect(filterButtonLabel(2, t)).toBe('Advanced search filters, 2 active');
});
it('lets "Custom size" go once the sheet is closed with no size left', () => {
expect(keepCustomSize(true, false, undefined, undefined)).toBe(false);
expect(keepCustomSize(true, false, '', '')).toBe(false);
// Picked in the open sheet, nothing typed yet: it stays.
expect(keepCustomSize(true, true, undefined, undefined)).toBe(true);
expect(keepCustomSize(true, false, '10', undefined)).toBe(true);
});
it('keeps the sheet between the system bars and above the keyboard', () => {
// Pixel 7: 915 high, 24 status bar, 48 navigation bar.
expect(filterSheetFrame(915, 24, 48, 0, 16)).toEqual({ paddingTop: 40, paddingBottom: 64, maxHeight: 811 });
// Keyboard up, window not resized (edge to edge): the sheet ends above it.
expect(filterSheetFrame(915, 24, 48, 300, 16)).toEqual({ paddingTop: 40, paddingBottom: 316, maxHeight: 559 });
// A window that already shrank for the keyboard does not lose it twice.
expect(filterSheetFrame(615, 24, 48, 300, 16, 300)).toEqual({ paddingTop: 40, paddingBottom: 64, maxHeight: 511 });
});
it('wires the helpers into the list screen', () => {
const list = read('screens/EmailListScreen.tsx');
expect(list).toMatch(/accessibilityLabel=\{filterButtonLabel\(activeFilterCount, t\)\}/);
expect(list).not.toMatch(/accessibilityValue=\{activeFilterCount/);
expect(list).toMatch(/label=\{sizeChipLabel\('min', filters\.minSizeKb \?\? '', t\)\}/);
expect(list).toMatch(/<View style=\{\[styles\.filterMenu, \{ maxHeight: filterFrame\.maxHeight \}\]\}>/);
expect(list).not.toMatch(/maxHeight: '85%'/);
expect(list).toMatch(/onPress=\{clearFilters\}/);
expect(list).toMatch(/key=\{filterFormKey\}/);
expect((list.match(/onFocus=\{revealSizeFields\}/g) ?? []).length).toBe(2);
});
});
+60
View File
@@ -29,6 +29,66 @@ export function sizePresetOf(minSizeKb: string | undefined, maxSizeKb: string |
}
/** Filter properties whose value the server reads as free text. */
type Translate = (key: string, fallback: string, values?: Record<string, string | number>) => string;
/**
* The chip for a custom size bound: "Larger than 1 KB". It read "Larger than
* (KB): 1 KB", built from the field's placeholder.
*/
export function sizeChipLabel(bound: 'min' | 'max', kb: string, t: Translate): string {
return bound === 'min'
? t('advanced_search.larger_than_kb', 'Larger than {size} KB', { size: kb })
: t('advanced_search.smaller_than_kb', 'Smaller than {size} KB', { size: kb });
}
/**
* The filter button's name for screen readers, with the number of active
* filters in it. It was given as an accessibility value, which Android left
* in the description ("Advanced search filters, 1") once the value went away
* after Clear; a changed label always replaces the description.
*/
export function filterButtonLabel(count: number, t: Translate): string {
const name = t('advanced_search.advanced_filters_tooltip', 'Advanced search filters');
return count > 0 ? t('advanced_search.advanced_filters_count', '{name}, {count} active', { name, count }) : name;
}
/**
* Whether "Custom size" stays picked. With the sheet closed and no bound
* left (the chip removed, the search cleared) it goes back to "Any size":
* it stayed picked, with empty fields, after the size chip was removed.
*/
export function keepCustomSize(
customSize: boolean,
sheetOpen: boolean,
minSizeKb: string | undefined,
maxSizeKb: string | undefined,
): boolean {
if (!customSize) return false;
return sheetOpen || !!minSizeKb || !!maxSizeKb;
}
/**
* Where the filter sheet sits: clear of the status bar, the navigation bar
* and the keyboard, and never taller than the space between them, so its
* body scrolls instead of running off the bottom (Pixel 7: the last row was
* cut off) and the size fields stay above the keyboard. `height` is the
* sheet's window; `shrunkBy` how much the window already gave way to the
* keyboard (adjustResize, which an edge-to-edge window doesn't get), so the
* keyboard is not taken off twice.
*/
export function filterSheetFrame(
height: number,
insetTop: number,
insetBottom: number,
keyboard: number,
gap: number,
shrunkBy = 0,
): { paddingTop: number; paddingBottom: number; maxHeight: number } {
const paddingTop = insetTop + gap;
const paddingBottom = Math.max(insetBottom, keyboard - Math.max(0, shrunkBy)) + gap;
return { paddingTop, paddingBottom, maxHeight: Math.max(120, height - paddingTop - paddingBottom) };
}
export type SearchTextProperty = 'text' | 'from' | 'to' | 'subject' | 'body';
// A "quoted phrase" (the closing quote may still be missing while the user
+60 -11
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { View, Text, StyleSheet, FlatList, Pressable, TextInput, Image, ActivityIndicator, Modal, Platform, ScrollView, TouchableWithoutFeedback, Alert } from 'react-native';
import { View, Text, StyleSheet, FlatList, Pressable, TextInput, Image, ActivityIndicator, Modal, Platform, ScrollView, TouchableWithoutFeedback, Alert, useWindowDimensions } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker';
import * as DocumentPicker from 'expo-document-picker';
@@ -71,7 +71,10 @@ import { confirmMailRemoval } from '../lib/mail-removal-confirm';
import { moveSheetCurrentId } from '../lib/move-targets';
import { listViewKey } from '../lib/list-view-key';
import { searchScopeFolders } from '../lib/search-scope-folders';
import { SIZE_PRESETS_MB, sizeFilterBytes, sizePresetOf } from '../lib/search-utils';
import {
SIZE_PRESETS_MB, sizeFilterBytes, sizePresetOf, sizeChipLabel, filterButtonLabel, keepCustomSize, filterSheetFrame,
} from '../lib/search-utils';
import { useKeyboardObstruction } from '../lib/use-keyboard-obstruction';
import { draftContextFromEmail, isDraftEmail } from '../lib/draft-context';
import { getFullEmail, emptyMailbox as apiEmptyMailbox } from '../api/email';
import type { RootStackParamList } from '../navigation/types';
@@ -1195,6 +1198,41 @@ export default function EmailListScreen({
// size" (webmail search-filter-panel).
const [customSize, setCustomSize] = React.useState(false);
const sizePreset = customSize ? 'custom' : sizePresetOf(filters.minSizeKb, filters.maxSizeKb);
React.useEffect(() => {
if (!keepCustomSize(customSize, filterMenuOpen, filters.minSizeKb, filters.maxSizeKb)) setCustomSize(false);
}, [customSize, filterMenuOpen, filters.minSizeKb, filters.maxSizeKb]);
// Clear empties every field of the sheet, also text not yet committed
// (the fields commit after a pause): a new key remounts them.
const [filterFormKey, setFilterFormKey] = React.useState(0);
const clearFilters = () => {
setCustomSize(false);
setFilterFormKey((k) => k + 1);
setFilters({});
};
// The sheet stays between the system bars and above the keyboard.
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const keyboardHeight = useKeyboardObstruction(insets.bottom);
// The sheet's window as laid out, and its tallest height in this
// orientation: a window that resizes for the keyboard is shorter by then.
const [filterWindow, setFilterWindow] = React.useState({ width: 0, full: 0, now: 0 });
const onFilterBackdropLayout = (height: number) => setFilterWindow((w) => {
const full = w.width === windowWidth ? Math.max(w.full, height) : height;
return w.width === windowWidth && w.full === full && w.now === height ? w : { width: windowWidth, full, now: height };
});
const filterWindowKnown = filterWindow.width === windowWidth && filterWindow.now > 0;
const filterFrame = filterSheetFrame(
filterWindowKnown ? filterWindow.now : windowHeight,
insets.top,
insets.bottom,
keyboardHeight,
spacing.lg,
filterWindowKnown ? filterWindow.full - filterWindow.now : 0,
);
const filterScrollRef = React.useRef<ScrollView>(null);
// The size fields are near the end: bring them up once the keyboard is out.
const revealSizeFields = () => {
setTimeout(() => filterScrollRef.current?.scrollToEnd({ animated: true }), 350);
};
const pickSizePreset = (key: string) => {
setCustomSize(key === 'custom');
if (key === 'custom') return;
@@ -1352,8 +1390,7 @@ export default function EmailListScreen({
style={[styles.filterButton, activeFilterCount > 0 && styles.filterButtonActive]}
onPress={() => setFilterMenuOpen(true)}
accessibilityRole="button"
accessibilityLabel={t('advanced_search.advanced_filters_tooltip', 'Advanced search filters')}
accessibilityValue={activeFilterCount > 0 ? { text: String(activeFilterCount) } : undefined}
accessibilityLabel={filterButtonLabel(activeFilterCount, t)}
>
<Filter size={18} color={activeFilterCount > 0 ? c.primary : c.textMuted} />
{activeFilterCount > 0 && (
@@ -1658,13 +1695,13 @@ export default function EmailListScreen({
)}
{sizeFilterBytes(filters.minSizeKb) !== null ? (
<FilterChip
label={t('email_list.filter_chip', '{field}: {value}', { field: t('advanced_search.size_min', 'Larger than (KB)'), value: `${filters.minSizeKb} KB` })}
label={sizeChipLabel('min', filters.minSizeKb ?? '', t)}
onRemove={() => setFilterField('minSizeKb', undefined)}
/>
) : null}
{sizeFilterBytes(filters.maxSizeKb) !== null ? (
<FilterChip
label={t('email_list.filter_chip', '{field}: {value}', { field: t('advanced_search.size_max', 'Smaller than (KB)'), value: `${filters.maxSizeKb} KB` })}
label={sizeChipLabel('max', filters.maxSizeKb ?? '', t)}
onRemove={() => setFilterField('maxSizeKb', undefined)}
/>
) : null}
@@ -1672,6 +1709,7 @@ export default function EmailListScreen({
onPress={() => {
setSearchInput('');
setCustomSize(false);
setFilterFormKey((k) => k + 1);
clearSearchAndFilters();
}}
style={styles.clearAllButton}
@@ -1829,14 +1867,17 @@ export default function EmailListScreen({
onRequestClose={() => setFilterMenuOpen(false)}
>
<TouchableWithoutFeedback accessible={false} onPress={() => setFilterMenuOpen(false)}>
<View style={styles.filterBackdrop}>
<View
style={[styles.filterBackdrop, { paddingTop: filterFrame.paddingTop, paddingBottom: filterFrame.paddingBottom }]}
onLayout={(e) => onFilterBackdropLayout(e.nativeEvent.layout.height)}
>
<TouchableWithoutFeedback accessible={false}>
<View style={styles.filterMenu}>
<View style={[styles.filterMenu, { maxHeight: filterFrame.maxHeight }]}>
<View style={styles.filterMenuHeader}>
<Text style={styles.filterMenuTitle}>{t('email_list.filter_title', 'Filter emails')}</Text>
<View style={styles.filterMenuHeaderActions}>
<Pressable
onPress={() => { setCustomSize(false); setFilters({}); }}
onPress={clearFilters}
style={styles.filterMenuHeaderBtn}
hitSlop={6}
accessibilityRole="button"
@@ -1856,7 +1897,13 @@ export default function EmailListScreen({
</View>
</View>
<ScrollView contentContainerStyle={styles.filterMenuBody} keyboardShouldPersistTaps="handled">
<ScrollView
key={filterFormKey}
ref={filterScrollRef}
style={styles.filterMenuScroll}
contentContainerStyle={styles.filterMenuBody}
keyboardShouldPersistTaps="handled"
>
<View style={styles.filterFieldRow}>
<View style={styles.filterFieldHalf}>
<Text style={styles.filterFieldLabel}>{t('advanced_search.from', 'From')}</Text>
@@ -2026,6 +2073,7 @@ export default function EmailListScreen({
accessibilityLabel={t('advanced_search.size_min', 'Larger than (KB)')}
placeholderTextColor={c.textMuted}
keyboardType="number-pad"
onFocus={revealSizeFields}
style={styles.filterFieldInput}
testID="advanced-search-min-size"
/>
@@ -2038,6 +2086,7 @@ export default function EmailListScreen({
accessibilityLabel={t('advanced_search.size_max', 'Smaller than (KB)')}
placeholderTextColor={c.textMuted}
keyboardType="number-pad"
onFocus={revealSizeFields}
style={styles.filterFieldInput}
testID="advanced-search-max-size"
/>
@@ -2463,7 +2512,6 @@ function makeStyles(c: ThemePalette) {
filterMenu: {
width: '100%',
maxWidth: 380,
maxHeight: '85%',
backgroundColor: c.popover,
borderRadius: radius.md,
borderWidth: 1,
@@ -2507,6 +2555,7 @@ function makeStyles(c: ThemePalette) {
justifyContent: 'center',
borderRadius: radius.xs,
},
filterMenuScroll: { flexGrow: 0, flexShrink: 1 },
filterMenuBody: {
padding: spacing.lg,
gap: spacing.md,