From 3c3274e4b757b2e37983eca0ecf9eaf4b68f502d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:27:19 +0100 Subject: [PATCH] fix: name the search and filter fields for screen readers --- src/screens/EmailListScreen.tsx | 11 ++++- src/screens/UnifiedInboxScreen.tsx | 1 + .../__tests__/search-field-names.test.ts | 41 +++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 src/screens/__tests__/search-field-names.test.ts diff --git a/src/screens/EmailListScreen.tsx b/src/screens/EmailListScreen.tsx index 0c7e4a1c..aa1c812c 100644 --- a/src/screens/EmailListScreen.tsx +++ b/src/screens/EmailListScreen.tsx @@ -393,10 +393,13 @@ const EmailListItem = React.memo(function EmailListItem({ function DebouncedFilterInput({ value, onCommit, + accessibilityLabel, ...props -}: Omit, 'value' | 'onChangeText'> & { +}: Omit, 'value' | 'onChangeText' | 'accessibilityLabel'> & { value: string; onCommit: (value: string) => void; + /** The field's name for screen readers: its label sits beside it as separate Text. */ + accessibilityLabel: string; }) { const [text, setText] = React.useState(value); React.useEffect(() => { setText(value); }, [value]); @@ -410,6 +413,7 @@ function DebouncedFilterInput({ return ( setFilterField('from', v)} + accessibilityLabel={t('advanced_search.from', 'From')} placeholder={t('advanced_search.from_placeholder', 'Sender email or name')} placeholderTextColor={c.textMuted} autoCapitalize="none" @@ -1843,6 +1849,7 @@ export default function EmailListScreen({ setFilterField('to', v)} + accessibilityLabel={t('advanced_search.to', 'To')} placeholder={t('advanced_search.to_placeholder', 'Recipient email or name')} placeholderTextColor={c.textMuted} autoCapitalize="none" @@ -1857,6 +1864,7 @@ export default function EmailListScreen({ setFilterField('subject', v)} + accessibilityLabel={t('advanced_search.subject', 'Subject')} placeholder={t('advanced_search.subject_placeholder', 'Subject contains...')} placeholderTextColor={c.textMuted} style={styles.filterFieldInput} @@ -1868,6 +1876,7 @@ export default function EmailListScreen({ setFilterField('body', v)} + accessibilityLabel={t('advanced_search.body', 'Body')} placeholder={t('advanced_search.body_placeholder', 'Body contains...')} placeholderTextColor={c.textMuted} style={styles.filterFieldInput} diff --git a/src/screens/UnifiedInboxScreen.tsx b/src/screens/UnifiedInboxScreen.tsx index d6e6a6c7..1855d9fd 100644 --- a/src/screens/UnifiedInboxScreen.tsx +++ b/src/screens/UnifiedInboxScreen.tsx @@ -517,6 +517,7 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) { // and the text around the field. disableFullscreenUI style={styles.searchInput} + accessibilityLabel={t('advanced_search.search', 'Search')} placeholder={t('email_list.search_placeholder', 'Search mail...')} placeholderTextColor={c.textMuted} value={searchInput} diff --git a/src/screens/__tests__/search-field-names.test.ts b/src/screens/__tests__/search-field-names.test.ts new file mode 100644 index 00000000..b070477e --- /dev/null +++ b/src/screens/__tests__/search-field-names.test.ts @@ -0,0 +1,41 @@ +// Text fields need a name a screen reader can say (webmail ef13556a0, +// dcc21acd0, 4e2affe40, f73643bbb). The search field of the folder list and +// the unified views, and the From/To/Subject/Body fields of the filter +// panel, had only a placeholder, which isn't read once the field holds +// text; the filter labels are separate Text beside the field. Same scan as +// components/__tests__/field-names.test.ts. +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const SRC = join(__dirname, '..', '..'); + +function elementEnd(source: string, from: number): number { + let depth = 0; + for (let i = from; i < source.length; i++) { + const ch = source[i]; + if (ch === '{') depth += 1; + else if (ch === '}') depth -= 1; + else if (ch === '/' && depth === 0 && source[i + 1] === '>') return i; + } + return source.length; +} + +/** `file:line` of every TextInput, Input or DebouncedFilterInput element without a name. */ +function unnamedFields(file: string): string[] { + const source = readFileSync(join(SRC, file), 'utf8'); + const out: string[] = []; + for (const m of source.matchAll(/<(TextInput|Input|DebouncedFilterInput)\b/g)) { + const element = source.slice(m.index!, elementEnd(source, m.index! + m[0].length)); + if (/\baccessibilityLabel=/.test(element)) continue; + if (m[1] === 'Input' && /\blabel=/.test(element)) continue; + out.push(`${file}:${source.slice(0, m.index!).split('\n').length}`); + } + return out; +} + +describe('search field names', () => { + it('every field of the mail list, its filter panel and the unified views has a name', () => { + expect(['screens/EmailListScreen.tsx', 'screens/UnifiedInboxScreen.tsx'].flatMap(unnamedFields)).toEqual([]); + }); +});