fix: name the search and filter fields for screen readers

This commit is contained in:
Linus Rath
2026-10-31 05:27:19 +01:00
parent aac8a40367
commit 3c3274e4b7
3 changed files with 52 additions and 1 deletions
+10 -1
View File
@@ -393,10 +393,13 @@ const EmailListItem = React.memo(function EmailListItem({
function DebouncedFilterInput({
value,
onCommit,
accessibilityLabel,
...props
}: Omit<React.ComponentProps<typeof TextInput>, 'value' | 'onChangeText'> & {
}: Omit<React.ComponentProps<typeof TextInput>, '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 (
<TextInput
{...props}
accessibilityLabel={accessibilityLabel}
value={text}
onChangeText={setText}
returnKeyType="search"
@@ -1280,6 +1284,7 @@ export default function EmailListScreen({
disableFullscreenUI
ref={searchInputRef}
style={styles.searchInput}
accessibilityLabel={t('advanced_search.search', 'Search')}
placeholder={t('email_list.search_placeholder', 'Search mail...')}
placeholderTextColor={c.textMuted}
value={searchInput}
@@ -1831,6 +1836,7 @@ export default function EmailListScreen({
<DebouncedFilterInput
value={filters.from ?? ''}
onCommit={(v) => 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({
<DebouncedFilterInput
value={filters.to ?? ''}
onCommit={(v) => 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({
<DebouncedFilterInput
value={filters.subject ?? ''}
onCommit={(v) => 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({
<DebouncedFilterInput
value={filters.body ?? ''}
onCommit={(v) => setFilterField('body', v)}
accessibilityLabel={t('advanced_search.body', 'Body')}
placeholder={t('advanced_search.body_placeholder', 'Body contains...')}
placeholderTextColor={c.textMuted}
style={styles.filterFieldInput}
+1
View File
@@ -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}
@@ -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([]);
});
});