fix: let screen readers reach each option in sheets and menus

This commit is contained in:
Linus Rath
2026-10-22 22:04:28 +02:00
parent f5f3637d01
commit dde504f825
10 changed files with 106 additions and 33 deletions
@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
// Sheets and menus were built as a tappable backdrop (Pressable or
// TouchableWithoutFeedback) wrapping a card that swallows taps. Both are
// accessible elements by default, and an accessible element hides its
// children from VoiceOver and TalkBack: the compose "Attach" and "Schedule
// send" sheets read as one element ("Attach, Photos & Videos, Camera, Files,
// Cancel") whose options couldn't be picked one by one. The wrappers that
// only catch taps are marked accessible={false}, so the options inside stay
// reachable.
const SRC = path.resolve(__dirname, '../..');
function sourceFiles(dir: string): string[] {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
const p = path.join(dir, e.name);
if (e.isDirectory()) return e.name === '__tests__' ? [] : sourceFiles(p);
return e.name.endsWith('.tsx') ? [p] : [];
});
}
function openingTags(source: string, name: string): { tag: string; line: number }[] {
const out: { tag: string; line: number }[] = [];
const re = new RegExp(`<${name}\\b`, 'g');
let m: RegExpExecArray | null;
while ((m = re.exec(source))) {
let depth = 0;
let i = m.index;
for (; i < source.length; i++) {
const ch = source[i];
if (ch === '{') depth++;
else if (ch === '}') depth--;
else if (ch === '>' && depth === 0 && source[i - 1] !== '=') break;
}
out.push({ tag: source.slice(m.index, i + 1), line: source.slice(0, m.index).split('\n').length });
}
return out;
}
/** A wrapper that only catches taps and still hides what it wraps. */
export function isHidingWrapper(tag: string): boolean {
if (/accessible=\{false\}/.test(tag)) return false;
if (/\/\s*>$/.test(tag)) return false; // self-closing: wraps nothing
if (tag.startsWith('<TouchableWithoutFeedback')) return true;
if (/onPress=\{\(\) => \{\}\}/.test(tag)) return true;
return /style=\{styles\.\w*(Backdrop|backdrop|Overlay|overlay)\}/.test(tag);
}
describe('sheet backdrops', () => {
it('do not hide the options they wrap from screen readers', () => {
const offenders: string[] = [];
for (const file of sourceFiles(SRC)) {
const source = fs.readFileSync(file, 'utf8');
for (const name of ['Pressable', 'TouchableWithoutFeedback']) {
for (const { tag, line } of openingTags(source, name)) {
if (isHidingWrapper(tag)) offenders.push(`${path.relative(SRC, file)}:${line}`);
}
}
}
expect(offenders).toEqual([]);
});
it('recognises the wrappers it looks for', () => {
expect(isHidingWrapper('<Pressable style={styles.modalBackdrop} onPress={onClose}>')).toBe(true);
expect(isHidingWrapper('<Pressable style={styles.card} onPress={() => {}}>')).toBe(true);
expect(isHidingWrapper('<TouchableWithoutFeedback onPress={onClose}>')).toBe(true);
expect(isHidingWrapper('<Pressable accessible={false} style={styles.modalBackdrop} onPress={onClose}>')).toBe(false);
expect(isHidingWrapper('<Pressable style={styles.pickerOverlay} onPress={close} />')).toBe(false);
expect(isHidingWrapper('<Pressable style={styles.row} onPress={open}>')).toBe(false);
});
});
@@ -74,9 +74,9 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback onPress={onClose}>
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<TouchableWithoutFeedback>
<TouchableWithoutFeedback accessible={false}>
<View style={styles.sheet}>
<Text style={styles.title}>
{calendar
@@ -160,9 +160,9 @@ export function CalendarShareSheet({ calendar, onShare, onClose }: CalendarShare
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback onPress={onClose}>
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<TouchableWithoutFeedback>
<TouchableWithoutFeedback accessible={false}>
<View style={styles.sheet}>
<View style={styles.titleRow}>
<Users size={18} color={c.textMuted} />
+2 -2
View File
@@ -160,9 +160,9 @@ export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback onPress={onClose}>
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<TouchableWithoutFeedback>
<TouchableWithoutFeedback accessible={false}>
<View style={styles.sheet}>
<View style={styles.titleRow}>
<Icon size={18} color={c.textMuted} />
@@ -196,8 +196,8 @@ export function ContentSendersSettings() {
animationType="fade"
onRequestClose={() => setModalOpen(false)}
>
<Pressable style={styles.modalBackdrop} onPress={() => setModalOpen(false)}>
<Pressable style={styles.modalCard} onPress={() => {}}>
<Pressable accessible={false} style={styles.modalBackdrop} onPress={() => setModalOpen(false)}>
<Pressable accessible={false} style={styles.modalCard} onPress={() => {}}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>{t('settings.email_behavior.trusted_senders.modal_title', 'Trusted Senders')}</Text>
<Pressable
+1 -1
View File
@@ -298,7 +298,7 @@ export function Select({ value, onChange, options, style }: SelectProps) {
animationType="fade"
onRequestClose={() => setOpen(false)}
>
<Pressable style={styles.modalBackdrop} onPress={() => setOpen(false)}>
<Pressable accessible={false} style={styles.modalBackdrop} onPress={() => setOpen(false)}>
<View style={styles.modalContent}>
<ScrollView>
{options.map((opt) => {
+8 -8
View File
@@ -377,8 +377,8 @@ function OptionsSheet({
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<Pressable style={styles.modalBackdrop} onPress={onClose}>
<Pressable style={styles.scheduleCard} onPress={() => {}}>
<Pressable accessible={false} style={styles.modalBackdrop} onPress={onClose}>
<Pressable accessible={false} style={styles.scheduleCard} onPress={() => {}}>
{!!title && <Text style={styles.modalTitle} numberOfLines={2}>{title}</Text>}
{options.map((opt) => (
<Pressable
@@ -2647,8 +2647,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
{/* Text / background colour palette */}
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!colorTarget} transparent animationType="fade" onRequestClose={() => setColorTarget(null)}>
<Pressable style={styles.modalBackdrop} onPress={() => setColorTarget(null)}>
<Pressable style={styles.scheduleCard} onPress={() => {}}>
<Pressable accessible={false} style={styles.modalBackdrop} onPress={() => setColorTarget(null)}>
<Pressable accessible={false} style={styles.scheduleCard} onPress={() => {}}>
<Text style={styles.modalTitle}>
{colorTarget === 'background'
? t('email_composer.toolbar.background_color', 'Background color')
@@ -2716,8 +2716,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
animationType="fade"
onRequestClose={() => setScheduleSheetOpen(false)}
>
<Pressable style={styles.modalBackdrop} onPress={() => setScheduleSheetOpen(false)}>
<Pressable style={styles.scheduleCard} onPress={() => {}}>
<Pressable accessible={false} style={styles.modalBackdrop} onPress={() => setScheduleSheetOpen(false)}>
<Pressable accessible={false} style={styles.scheduleCard} onPress={() => {}}>
<Text style={styles.modalTitle}>
{t('email_composer.schedule_send', 'Schedule send')}
</Text>
@@ -2750,8 +2750,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
{customStage !== null && Platform.OS === 'ios' && (
<Modal supportedOrientations={MODAL_ORIENTATIONS} transparent animationType="fade" onRequestClose={() => setCustomStage(null)}>
<Pressable style={styles.modalBackdrop} onPress={() => setCustomStage(null)}>
<Pressable style={styles.scheduleCard} onPress={() => {}}>
<Pressable accessible={false} style={styles.modalBackdrop} onPress={() => setCustomStage(null)}>
<Pressable accessible={false} style={styles.scheduleCard} onPress={() => {}}>
<DateTimePicker
value={customDraftRef.current}
mode="datetime"
+2 -2
View File
@@ -1609,9 +1609,9 @@ export default function EmailListScreen({
animationType="fade"
onRequestClose={() => setFilterMenuOpen(false)}
>
<TouchableWithoutFeedback onPress={() => setFilterMenuOpen(false)}>
<TouchableWithoutFeedback accessible={false} onPress={() => setFilterMenuOpen(false)}>
<View style={styles.filterBackdrop}>
<TouchableWithoutFeedback>
<TouchableWithoutFeedback accessible={false}>
<View style={styles.filterMenu}>
<View style={styles.filterMenuHeader}>
<Text style={styles.filterMenuTitle}>{t('email_list.filter_title', 'Filter emails')}</Text>
+8 -8
View File
@@ -1179,9 +1179,9 @@ function PromptModal(props: PromptModalProps) {
animationType="fade"
onRequestClose={props.onCancel}
>
<TouchableWithoutFeedback onPress={props.onCancel}>
<TouchableWithoutFeedback accessible={false} onPress={props.onCancel}>
<View style={styles.backdrop}>
<TouchableWithoutFeedback>
<TouchableWithoutFeedback accessible={false}>
<View style={styles.dialog}>
<Text style={styles.title}>{props.title}</Text>
<TextInput
@@ -1246,9 +1246,9 @@ function ActionsSheet({
const modified = modifiedOf(target);
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback onPress={onClose}>
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<TouchableWithoutFeedback>
<TouchableWithoutFeedback accessible={false}>
<View style={styles.sheet}>
<Text style={styles.sheetTitle} numberOfLines={1}>
{target.displayName}
@@ -1370,9 +1370,9 @@ function FolderPickerSheet({ target, allNodes, rootLabel, t, onClose, onPick }:
const currentParent = target.parentId ?? null;
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback onPress={onClose}>
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<TouchableWithoutFeedback>
<TouchableWithoutFeedback accessible={false}>
<View style={styles.sheet}>
<Text style={styles.sheetTitle} numberOfLines={1}>
{t('files.move_to', 'Move to…')} · {target.displayName}
@@ -1427,9 +1427,9 @@ function SortSheet({ visible, sortKey, sortDir, t, onClose, onChangeKey, onChang
const keys: FilesSortKey[] = ['name', 'size', 'modified'];
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback onPress={onClose}>
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<TouchableWithoutFeedback>
<TouchableWithoutFeedback accessible={false}>
<View style={styles.sheet}>
<Text style={styles.sheetTitle}>{t('files.settings_default_sort', 'Sort')}</Text>
{keys.map((k) => (
+6 -6
View File
@@ -326,8 +326,8 @@ export default function ScheduledScreen({ navigation }: Props) {
{/* Per-message actions */}
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!actionsFor} transparent animationType="fade" onRequestClose={() => setActionsFor(null)}>
<Pressable style={styles.modalBackdrop} onPress={() => setActionsFor(null)}>
<Pressable style={styles.card} onPress={() => {}}>
<Pressable accessible={false} style={styles.modalBackdrop} onPress={() => setActionsFor(null)}>
<Pressable accessible={false} style={styles.card} onPress={() => {}}>
<Text style={styles.cardTitle} numberOfLines={2}>
{actionsFor?.subject || t('email_composer.no_subject', '(No Subject)')}
</Text>
@@ -349,8 +349,8 @@ export default function ScheduledScreen({ navigation }: Props) {
{/* Reschedule presets */}
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!rescheduleFor && customStage === null} transparent animationType="fade" onRequestClose={() => setRescheduleFor(null)}>
<Pressable style={styles.modalBackdrop} onPress={() => setRescheduleFor(null)}>
<Pressable style={styles.card} onPress={() => {}}>
<Pressable accessible={false} style={styles.modalBackdrop} onPress={() => setRescheduleFor(null)}>
<Pressable accessible={false} style={styles.card} onPress={() => {}}>
<Text style={styles.cardTitle}>{t('email_list.reschedule_send', 'Reschedule')}</Text>
{reschedulePresets.map((preset) => (
<Pressable
@@ -379,8 +379,8 @@ export default function ScheduledScreen({ navigation }: Props) {
{customStage !== null && Platform.OS === 'ios' && (
<Modal supportedOrientations={MODAL_ORIENTATIONS} transparent animationType="fade" onRequestClose={() => setCustomStage(null)}>
<Pressable style={styles.modalBackdrop} onPress={() => setCustomStage(null)}>
<Pressable style={styles.card} onPress={() => {}}>
<Pressable accessible={false} style={styles.modalBackdrop} onPress={() => setCustomStage(null)}>
<Pressable accessible={false} style={styles.card} onPress={() => {}}>
<DateTimePicker
value={customDraftRef.current}
mode="datetime"