diff --git a/locales/rn/en.json b/locales/rn/en.json index 176c2bf2..b6b8fc78 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -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": { diff --git a/src/lib/__tests__/search-filter-sheet.test.ts b/src/lib/__tests__/search-filter-sheet.test.ts new file mode 100644 index 00000000..7e0b10c1 --- /dev/null +++ b/src/lib/__tests__/search-filter-sheet.test.ts @@ -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) => + 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(//); + 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); + }); +}); diff --git a/src/lib/search-utils.ts b/src/lib/search-utils.ts index a8960622..eb105d10 100644 --- a/src/lib/search-utils.ts +++ b/src/lib/search-utils.ts @@ -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; + +/** + * 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 diff --git a/src/screens/EmailListScreen.tsx b/src/screens/EmailListScreen.tsx index bd6b9bfa..ebbc5682 100644 --- a/src/screens/EmailListScreen.tsx +++ b/src/screens/EmailListScreen.tsx @@ -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(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)} > 0 ? c.primary : c.textMuted} /> {activeFilterCount > 0 && ( @@ -1658,13 +1695,13 @@ export default function EmailListScreen({ )} {sizeFilterBytes(filters.minSizeKb) !== null ? ( setFilterField('minSizeKb', undefined)} /> ) : null} {sizeFilterBytes(filters.maxSizeKb) !== null ? ( 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)} > setFilterMenuOpen(false)}> - + onFilterBackdropLayout(e.nativeEvent.layout.height)} + > - + {t('email_list.filter_title', 'Filter emails')} { setCustomSize(false); setFilters({}); }} + onPress={clearFilters} style={styles.filterMenuHeaderBtn} hitSlop={6} accessibilityRole="button" @@ -1856,7 +1897,13 @@ export default function EmailListScreen({ - + {t('advanced_search.from', 'From')} @@ -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,