mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +00:00
fix: let the search filter sheet scroll above the keyboard and clear fully
This commit is contained in:
+4
-1
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user