fix: name the composer, quick reply, folder and template search fields for screen readers

This commit is contained in:
Linus Rath
2026-10-31 05:29:38 +01:00
parent a7a3757378
commit 98b962a8d4
5 changed files with 67 additions and 0 deletions
+1
View File
@@ -291,6 +291,7 @@ function NamePrompt({ title, message, initial, confirmLabel, onSubmit, onClose }
<Text style={styles.sheetTitle}>{title}</Text>
{message ? <Text style={styles.promptMessage}>{message}</Text> : null}
<TextInput
accessibilityLabel={t('mailbox_context_menu.placeholder_folder_name', 'Folder name')}
value={value}
onChangeText={setValue}
placeholder={t('mailbox_context_menu.placeholder_folder_name', 'Folder name')}
+1
View File
@@ -149,6 +149,7 @@ export function TemplateSheet({ visible, onClose, onPick }: TemplateSheetProps)
</View>
{total > 4 && (
<TextInput
accessibilityLabel={t('templates.search_placeholder', 'Search templates...')}
value={query}
onChangeText={setQuery}
placeholder={t('templates.search_placeholder', 'Search templates...')}
@@ -0,0 +1,56 @@
// Text fields need a name a screen reader can say (parity 11 "Form fields
// have no name for screen readers"; field-names.test.ts covers Settings and
// the sign-in steps). The composer, quick reply, folder prompt and template
// search had fields named only by their placeholder, or by nothing.
import { describe, expect, it } from 'vitest';
import { readdirSync, 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 element without an accessibilityLabel. */
function unnamedFields(file: string): string[] {
const source = readFileSync(join(SRC, file), 'utf8');
const out: string[] = [];
// `<TextInput` opening a JSX element, not `useRef<TextInput>`.
for (const m of source.matchAll(/<TextInput(?=[\s/])/g)) {
const element = source.slice(m.index!, elementEnd(source, m.index! + m[0].length));
if (/\baccessibilityLabel=/.test(element)) continue;
out.push(`${file}:${source.slice(0, m.index!).split('\n').length}`);
}
return out;
}
const COMPOSE = readdirSync(join(SRC, 'components', 'compose'))
.filter((name) => name.endsWith('.tsx'))
.map((name) => `components/compose/${name}`);
const FILES = [
'screens/ComposeScreen.tsx',
'components/email/QuickReplyBox.tsx',
'components/SidebarDrawer.tsx',
'components/TemplateSheet.tsx',
...COMPOSE,
];
describe('field names in mail and the composer', () => {
it('every field has a name', () => {
expect(FILES.flatMap(unnamedFields)).toEqual([]);
});
it('finds the composer fields at all', () => {
const source = readFileSync(join(SRC, 'screens/ComposeScreen.tsx'), 'utf8');
expect([...source.matchAll(/<TextInput(?=[\s/])/g)].length).toBeGreaterThanOrEqual(8);
});
});
+1
View File
@@ -267,6 +267,7 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent, onF
// No full-screen keyboard in landscape: it would hide the suggestions
// and the text around the field.
disableFullscreenUI
accessibilityLabel={t('email_viewer.quick_reply_placeholder', 'Write a quick reply...')}
value={text}
onChangeText={setText}
onFocus={onFocus}
+8
View File
@@ -2708,6 +2708,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
// No full-screen keyboard in landscape: it would hide the suggestions
// and the text around the field.
disableFullscreenUI
accessibilityLabel={label}
style={[styles.recipientInput, invalidInput && styles.recipientInputInvalid]}
placeholder={placeholder}
placeholderTextColor={c.textMuted}
@@ -2900,6 +2901,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
<TextInput
ref={subjectRef}
disableFullscreenUI
accessibilityLabel={t('email_composer.subject', 'Subject')}
style={styles.subjectInput}
placeholder={t('email_composer.subject_placeholder', 'Subject')}
placeholderTextColor={c.textMuted}
@@ -2936,6 +2938,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
<TextInput
ref={plainBodyRef}
disableFullscreenUI
accessibilityLabel={t('email_composer.body_label', 'Message')}
style={styles.plainEditor}
multiline
value={plainBody}
@@ -3098,6 +3101,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
{t('email_composer.link_url_prompt', 'Enter the URL')}
</Text>
<TextInput
accessibilityLabel={t('email_composer.link_url_prompt', 'Enter the URL')}
style={styles.modalInput}
value={linkPromptValue}
onChangeText={setLinkPromptValue}
@@ -3345,6 +3349,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
{t(`templates.placeholders.${name}`, name.replace(/_/g, ' '))}
</Text>
<TextInput
accessibilityLabel={t(`templates.placeholders.${name}`, name.replace(/_/g, ' '))}
style={styles.modalInput}
value={placeholderPrompt?.values[name] ?? ''}
onChangeText={(v) => setPlaceholderPrompt((p) => (p ? { ...p, values: { ...p.values, [name]: v } } : p))}
@@ -3392,6 +3397,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
<>
<Text style={styles.modalLabel}>{t('identities.sub_address.popover_title', 'Add Sub-Address Tag')}</Text>
<TextInput
accessibilityLabel={t('identities.sub_address.popover_title', 'Add Sub-Address Tag')}
style={styles.modalInput}
value={tagDraft}
onChangeText={setTagDraft}
@@ -3437,6 +3443,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
<>
<Text style={styles.modalLabel}>{t('email_composer.from_override.toggle_tooltip', 'Edit the From name and address freely. Mail is still sent through your identity - only the visible From header changes.')}</Text>
<TextInput
accessibilityLabel={t('email_composer.from_override.name_label', 'From name')}
style={styles.modalInput}
value={overrideName}
onChangeText={setOverrideName}
@@ -3444,6 +3451,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
placeholderTextColor={c.textMuted}
/>
<TextInput
accessibilityLabel={t('email_composer.from_override.email_label', 'From email address')}
style={styles.modalInput}
value={overrideEmail}
onChangeText={setOverrideEmail}