From dde504f825fced92683254e46ed51b34b142a4a9 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 22 Oct 2026 08:18:41 +0200 Subject: [PATCH] fix: let screen readers reach each option in sheets and menus --- .../__tests__/sheet-accessibility.test.ts | 73 +++++++++++++++++++ src/components/calendar/CalendarEditSheet.tsx | 4 +- .../calendar/CalendarShareSheet.tsx | 4 +- src/components/files/ShareSheet.tsx | 4 +- .../settings/ContentSendersSettings.tsx | 4 +- src/components/settings/settings-section.tsx | 2 +- src/screens/ComposeScreen.tsx | 16 ++-- src/screens/EmailListScreen.tsx | 4 +- src/screens/FilesScreen.tsx | 16 ++-- src/screens/ScheduledScreen.tsx | 12 +-- 10 files changed, 106 insertions(+), 33 deletions(-) create mode 100644 src/components/__tests__/sheet-accessibility.test.ts diff --git a/src/components/__tests__/sheet-accessibility.test.ts b/src/components/__tests__/sheet-accessibility.test.ts new file mode 100644 index 00000000..9fecb08b --- /dev/null +++ b/src/components/__tests__/sheet-accessibility.test.ts @@ -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(' \{\}\}/.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('')).toBe(true); + expect(isHidingWrapper(' {}}>')).toBe(true); + expect(isHidingWrapper('')).toBe(true); + expect(isHidingWrapper('')).toBe(false); + expect(isHidingWrapper('')).toBe(false); + expect(isHidingWrapper('')).toBe(false); + }); +}); diff --git a/src/components/calendar/CalendarEditSheet.tsx b/src/components/calendar/CalendarEditSheet.tsx index 0944073f..420cfe8a 100644 --- a/src/components/calendar/CalendarEditSheet.tsx +++ b/src/components/calendar/CalendarEditSheet.tsx @@ -74,9 +74,9 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend return ( - + - + {calendar diff --git a/src/components/calendar/CalendarShareSheet.tsx b/src/components/calendar/CalendarShareSheet.tsx index 9c745ab9..4e955b5f 100644 --- a/src/components/calendar/CalendarShareSheet.tsx +++ b/src/components/calendar/CalendarShareSheet.tsx @@ -160,9 +160,9 @@ export function CalendarShareSheet({ calendar, onShare, onClose }: CalendarShare return ( - + - + diff --git a/src/components/files/ShareSheet.tsx b/src/components/files/ShareSheet.tsx index c11d01e1..56a7b1b9 100644 --- a/src/components/files/ShareSheet.tsx +++ b/src/components/files/ShareSheet.tsx @@ -160,9 +160,9 @@ export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps return ( - + - + diff --git a/src/components/settings/ContentSendersSettings.tsx b/src/components/settings/ContentSendersSettings.tsx index e89d0745..94973a6a 100644 --- a/src/components/settings/ContentSendersSettings.tsx +++ b/src/components/settings/ContentSendersSettings.tsx @@ -196,8 +196,8 @@ export function ContentSendersSettings() { animationType="fade" onRequestClose={() => setModalOpen(false)} > - setModalOpen(false)}> - {}}> + setModalOpen(false)}> + {}}> {t('settings.email_behavior.trusted_senders.modal_title', 'Trusted Senders')} setOpen(false)} > - setOpen(false)}> + setOpen(false)}> {options.map((opt) => { diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index 51085d7c..acbf0809 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -377,8 +377,8 @@ function OptionsSheet({ const styles = React.useMemo(() => makeStyles(c), [c]); return ( - - {}}> + + {}}> {!!title && {title}} {options.map((opt) => ( setColorTarget(null)}> - setColorTarget(null)}> - {}}> + setColorTarget(null)}> + {}}> {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)} > - setScheduleSheetOpen(false)}> - {}}> + setScheduleSheetOpen(false)}> + {}}> {t('email_composer.schedule_send', 'Schedule send')} @@ -2750,8 +2750,8 @@ export default function ComposeScreen({ route, navigation }: Props) { {customStage !== null && Platform.OS === 'ios' && ( setCustomStage(null)}> - setCustomStage(null)}> - {}}> + setCustomStage(null)}> + {}}> setFilterMenuOpen(false)} > - setFilterMenuOpen(false)}> + setFilterMenuOpen(false)}> - + {t('email_list.filter_title', 'Filter emails')} diff --git a/src/screens/FilesScreen.tsx b/src/screens/FilesScreen.tsx index a05cc7f2..e5af446a 100644 --- a/src/screens/FilesScreen.tsx +++ b/src/screens/FilesScreen.tsx @@ -1179,9 +1179,9 @@ function PromptModal(props: PromptModalProps) { animationType="fade" onRequestClose={props.onCancel} > - + - + {props.title} - + - + {target.displayName} @@ -1370,9 +1370,9 @@ function FolderPickerSheet({ target, allNodes, rootLabel, t, onClose, onPick }: const currentParent = target.parentId ?? null; return ( - + - + {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 ( - + - + {t('files.settings_default_sort', 'Sort')} {keys.map((k) => ( diff --git a/src/screens/ScheduledScreen.tsx b/src/screens/ScheduledScreen.tsx index 4522848e..fb2eece1 100644 --- a/src/screens/ScheduledScreen.tsx +++ b/src/screens/ScheduledScreen.tsx @@ -326,8 +326,8 @@ export default function ScheduledScreen({ navigation }: Props) { {/* Per-message actions */} setActionsFor(null)}> - setActionsFor(null)}> - {}}> + setActionsFor(null)}> + {}}> {actionsFor?.subject || t('email_composer.no_subject', '(No Subject)')} @@ -349,8 +349,8 @@ export default function ScheduledScreen({ navigation }: Props) { {/* Reschedule presets */} setRescheduleFor(null)}> - setRescheduleFor(null)}> - {}}> + setRescheduleFor(null)}> + {}}> {t('email_list.reschedule_send', 'Reschedule')} {reschedulePresets.map((preset) => ( setCustomStage(null)}> - setCustomStage(null)}> - {}}> + setCustomStage(null)}> + {}}>