fix: give the text fields in settings and sign-in a name for screen readers

This commit is contained in:
Linus Rath
2026-10-31 03:45:35 +01:00
parent 1152299583
commit ff5a8c91d6
15 changed files with 138 additions and 31 deletions
+2
View File
@@ -2,6 +2,7 @@ import React from 'react';
import { TextInput as RNTextInput, View, Text, StyleSheet, ViewStyle, TextInputProps } from 'react-native';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { inputAccessibility } from './input-a11y';
interface InputProps extends TextInputProps {
label?: string;
@@ -48,6 +49,7 @@ export default function Input({
<RNTextInput
ref={ref}
{...props}
{...inputAccessibility({ label, error, placeholder: props.placeholder, accessibilityLabel: props.accessibilityLabel, accessibilityHint: props.accessibilityHint })}
style={[styles.input, style]}
placeholderTextColor={c.mutedForeground}
onFocus={(e) => { setFocused(true); props.onFocus?.(e); }}
@@ -0,0 +1,74 @@
// Text fields need a name a screen reader can say (webmail ef13556a0,
// dcc21acd0, 4e2affe40, f73643bbb). The password, code and new-password
// fields under Security had none: no label prop, no placeholder, no
// accessibilityLabel. Settings and the sign-in steps are scanned so that a
// bare field doesn't creep back in; Input itself names its field from its
// label, then its placeholder.
import { describe, expect, it } from 'vitest';
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { inputAccessibility } from '../input-a11y';
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 or Input 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)\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;
}
const SETTINGS = readdirSync(join(SRC, 'components', 'settings'))
.filter((name) => name.endsWith('.tsx'))
// The calendar pass owns the auto-reply editor.
.filter((name) => name !== 'VacationSettings.tsx')
.map((name) => `components/settings/${name}`);
const FILES = [
...SETTINGS,
'screens/SettingsScreen.tsx',
'screens/login/EmailStep.tsx',
'screens/login/PasswordStep.tsx',
'screens/login/ServerStep.tsx',
];
describe('field names', () => {
it('every field in Settings and the sign-in steps has a name', () => {
expect(FILES.flatMap(unnamedFields)).toEqual([]);
});
});
describe('inputAccessibility', () => {
it('names the field after its label', () => {
expect(inputAccessibility({ label: 'Folder name', placeholder: 'Receipts' })).toEqual({ accessibilityLabel: 'Folder name' });
});
it('falls back to the placeholder, and lets an explicit name win', () => {
expect(inputAccessibility({ placeholder: 'Search' })).toEqual({ accessibilityLabel: 'Search' });
expect(inputAccessibility({ label: 'Name', accessibilityLabel: 'Display name' })).toEqual({ accessibilityLabel: 'Display name' });
});
it('reads the error as the hint', () => {
expect(inputAccessibility({ label: 'Email', error: 'Not an address' })).toEqual({
accessibilityLabel: 'Email',
accessibilityHint: 'Not an address',
});
});
});
+26
View File
@@ -0,0 +1,26 @@
// The name a screen reader gives a text field (webmail components/ui/field.tsx,
// ef13556a0, dcc21acd0, 4e2affe40). Input drew its label as a Text of its
// own, not tied to the field, so TalkBack and VoiceOver announced an
// unnamed "edit box"; a placeholder alone stops being read once the field
// has a value. The label names the field, then the placeholder; an error
// is read as the hint so it is heard on focus.
export interface InputAccessibilityProps {
label?: string;
error?: string;
placeholder?: string;
accessibilityLabel?: string;
accessibilityHint?: string;
}
export function inputAccessibility(props: InputAccessibilityProps): {
accessibilityLabel?: string;
accessibilityHint?: string;
} {
const name = props.accessibilityLabel || props.label || props.placeholder || undefined;
const hint = props.error || props.accessibilityHint || undefined;
return {
...(name ? { accessibilityLabel: name } : {}),
...(hint ? { accessibilityHint: hint } : {}),
};
}
@@ -178,7 +178,7 @@ function PasswordChangeSection({ otpEnabled }: { otpEnabled: boolean }) {
<View style={styles.pwField}>
<Text style={styles.pwLabel}>{t('settings.security.password.current', "Current password")}</Text>
<View style={styles.pwInputRow}>
<Input value={currentPwd} onChangeText={setCurrentPwd} secureTextEntry={!showCurrent} autoCapitalize="none" containerStyle={{ flex: 1 }} />
<Input accessibilityLabel={t('settings.security.password.current', "Current password")} value={currentPwd} onChangeText={setCurrentPwd} secureTextEntry={!showCurrent} autoCapitalize="none" containerStyle={{ flex: 1 }} />
<Pressable style={styles.eyeBtn} onPress={() => setShowCurrent((v) => !v)} accessibilityRole="button" accessibilityLabel={t('settings.security.password.toggle_visibility', "Show or hide password")}>
{showCurrent ? <EyeOff size={16} color={c.mutedForeground} /> : <Eye size={16} color={c.mutedForeground} />}
</Pressable>
@@ -188,14 +188,14 @@ function PasswordChangeSection({ otpEnabled }: { otpEnabled: boolean }) {
{otpEnabled && (
<View style={styles.pwField}>
<Text style={styles.pwLabel}>{t('settings.security.two_factor.code', "Verification code")}</Text>
<Input value={otpCode} onChangeText={setOtpCode} keyboardType="number-pad" maxLength={6} textContentType="oneTimeCode" autoComplete="one-time-code" />
<Input accessibilityLabel={t('settings.security.two_factor.code', "Verification code")} value={otpCode} onChangeText={setOtpCode} keyboardType="number-pad" maxLength={6} textContentType="oneTimeCode" autoComplete="one-time-code" />
</View>
)}
<View style={styles.pwField}>
<Text style={styles.pwLabel}>{t('settings.security.password.new', "New password")}</Text>
<View style={styles.pwInputRow}>
<Input value={newPwd} onChangeText={setNewPwd} secureTextEntry={!showNew} autoCapitalize="none" containerStyle={{ flex: 1 }} />
<Input accessibilityLabel={t('settings.security.password.new', "New password")} value={newPwd} onChangeText={setNewPwd} secureTextEntry={!showNew} autoCapitalize="none" containerStyle={{ flex: 1 }} />
<Pressable style={styles.eyeBtn} onPress={() => setShowNew((v) => !v)} accessibilityRole="button" accessibilityLabel={t('settings.security.password.toggle_visibility', "Show or hide password")}>
{showNew ? <EyeOff size={16} color={c.mutedForeground} /> : <Eye size={16} color={c.mutedForeground} />}
</Pressable>
@@ -204,7 +204,7 @@ function PasswordChangeSection({ otpEnabled }: { otpEnabled: boolean }) {
<View style={styles.pwField}>
<Text style={styles.pwLabel}>{t('settings.security.password.confirm', "Confirm password")}</Text>
<Input value={confirmPwd} onChangeText={setConfirmPwd} secureTextEntry={!showNew} autoCapitalize="none" />
<Input accessibilityLabel={t('settings.security.password.confirm', "Confirm password")} value={confirmPwd} onChangeText={setConfirmPwd} secureTextEntry={!showNew} autoCapitalize="none" />
</View>
{error && <Text style={styles.errorText}>{error}</Text>}
@@ -252,7 +252,7 @@ function DisplayNameSection({ initial, onSaved }: { initial: string; onSaved: (n
return (
<SettingItem label={t('settings.security.display_name.label', "Display name")} description={t('settings.security.display_name.description', "The name shown on this account.")}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: spacing.sm }}>
<Input value={name} onChangeText={setName} placeholder={t('settings.security.display_name.label', "Display name")} containerStyle={{ width: 150 }} />
<Input accessibilityLabel={t('settings.security.display_name.label', "Display name")} value={name} onChangeText={setName} placeholder={t('settings.security.display_name.label', "Display name")} containerStyle={{ width: 150 }} />
<Button size="sm" loading={saving} disabled={saving || name === initial} onPress={() => { void save(); }}>
{saved ? <Check size={14} color={c.primaryForeground} /> : t('common.save', "Save")}
</Button>
@@ -356,11 +356,11 @@ function TotpSection({ enabled, onChanged }: { enabled: boolean; onChanged: () =
</View>
<View>
<Text style={styles.pwLabel}>{t('settings.security.password.current', "Current password")}</Text>
<Input value={password} onChangeText={setPassword} secureTextEntry autoCapitalize="none" />
<Input accessibilityLabel={t('settings.security.password.current', "Current password")} value={password} onChangeText={setPassword} secureTextEntry autoCapitalize="none" />
</View>
<View>
<Text style={styles.pwLabel}>{t('settings.security.two_factor.code', "Verification code")}</Text>
<Input value={otpCode} onChangeText={setOtpCode} keyboardType="number-pad" maxLength={6} />
<Input accessibilityLabel={t('settings.security.two_factor.code', "Verification code")} value={otpCode} onChangeText={setOtpCode} keyboardType="number-pad" maxLength={6} />
</View>
{error ? <Text style={styles.errorText}>{error}</Text> : null}
<View style={styles.rowGap}>
@@ -375,8 +375,8 @@ function TotpSection({ enabled, onChanged }: { enabled: boolean; onChanged: () =
{disableOpen && (
<View style={styles.panel}>
<Text style={styles.panelHint}>{t('settings.security.two_factor.disable_hint_code', "Enter your password and a code from your authenticator app to turn off two-factor authentication.")}</Text>
<Input value={password} onChangeText={setPassword} secureTextEntry autoCapitalize="none" placeholder={t('settings.security.password.current', "Current password")} />
<Input value={otpCode} onChangeText={setOtpCode} keyboardType="number-pad" maxLength={6} textContentType="oneTimeCode" autoComplete="one-time-code" placeholder={t('settings.security.two_factor.code', "Verification code")} />
<Input accessibilityLabel={t('settings.security.password.current', "Current password")} value={password} onChangeText={setPassword} secureTextEntry autoCapitalize="none" placeholder={t('settings.security.password.current', "Current password")} />
<Input accessibilityLabel={t('settings.security.two_factor.code', "Verification code")} value={otpCode} onChangeText={setOtpCode} keyboardType="number-pad" maxLength={6} textContentType="oneTimeCode" autoComplete="one-time-code" placeholder={t('settings.security.two_factor.code', "Verification code")} />
{error ? <Text style={styles.errorText}>{error}</Text> : null}
<View style={styles.rowGap}>
<Button variant="destructive" size="sm" loading={saving} disabled={saving || !password || !otpCode.trim()} onPress={() => { void confirmDisable(); }}>
@@ -488,7 +488,7 @@ function CredentialSection({ icon: Icon, title, description, nameLabel, namePlac
<View style={styles.panel}>
<View>
<Text style={styles.pwLabel}>{nameLabel}</Text>
<Input value={name} onChangeText={setName} placeholder={namePlaceholder} />
<Input accessibilityLabel={nameLabel} value={name} onChangeText={setName} placeholder={namePlaceholder} />
</View>
<View>
<Text style={styles.pwLabel}>{t('settings.security.credentials.expires', "Expires (optional)")}</Text>
@@ -508,7 +508,7 @@ function CredentialSection({ icon: Icon, title, description, nameLabel, namePlac
</View>
<View>
<Text style={styles.pwLabel}>{t('settings.security.credentials.allowed_ips', "Allowed IPs (optional)")}</Text>
<Input value={allowedIpsRaw} onChangeText={setAllowedIpsRaw} placeholder="1.2.3.4, 10.0.0.0/8" autoCapitalize="none" />
<Input accessibilityLabel={t('settings.security.credentials.allowed_ips', "Allowed IPs (optional)")} value={allowedIpsRaw} onChangeText={setAllowedIpsRaw} placeholder="1.2.3.4, 10.0.0.0/8" autoCapitalize="none" />
<Text style={styles.fieldHint}>{t('settings.security.credentials.allowed_ips_hint', "Comma or space separated. Leave empty to allow any IP.")}</Text>
</View>
<View style={styles.rowGap}>
@@ -656,11 +656,11 @@ function PublicKeysSection({
<View style={styles.panel}>
<View>
<Text style={styles.pwLabel}>{t('settings.security.public_keys.name_label', 'Public Key name')}</Text>
<Input value={name} onChangeText={setName} placeholder={t('settings.security.public_keys.name_placeholder', 'e.g. My Key')} />
<Input accessibilityLabel={t('settings.security.public_keys.name_label', 'Public Key name')} value={name} onChangeText={setName} placeholder={t('settings.security.public_keys.name_placeholder', 'e.g. My Key')} />
</View>
<View>
<Text style={styles.pwLabel}>{t('settings.security.public_keys.key_label', 'Public Key (S/MIME or PGP ASCII-armored)')}</Text>
<Input
<Input accessibilityLabel={t('settings.security.public_keys.key_label', 'Public Key (S/MIME or PGP ASCII-armored)')}
value={keyText}
onChangeText={setKeyText}
placeholder={t('settings.security.public_keys.key_placeholder', 'Paste your public key here')}
@@ -226,7 +226,7 @@ export function ComposingSettings() {
</View>
<View style={styles.addRow}>
<TextInput
<TextInput accessibilityLabel={t('settings.email_behavior.attachment_reminder.add_placeholder', "Add keyword...")}
value={newKeyword}
onChangeText={setNewKeyword}
placeholder={t('settings.email_behavior.attachment_reminder.add_placeholder', "Add keyword...")}
+2 -1
View File
@@ -224,6 +224,7 @@ export function ContactsSettings() {
{editingId === book.id ? (
<>
<TextInput
accessibilityLabel={t('contacts.address_books.name_label', 'Address book name')}
style={styles.bookInput}
value={editName}
onChangeText={setEditName}
@@ -311,7 +312,7 @@ export function ContactsSettings() {
{adding ? (
<View style={styles.bookRow}>
<BookUser size={16} color={c.textSecondary} />
<TextInput
<TextInput accessibilityLabel={t('contacts.address_books.name_label', 'Address book name')}
style={styles.bookInput}
value={newName}
onChangeText={setNewName}
@@ -211,7 +211,7 @@ export function ContentSendersSettings() {
</View>
<View style={styles.addRow}>
<TextInput
<TextInput accessibilityLabel={t('settings.email_behavior.trusted_senders.add_placeholder', 'Enter email address')}
value={newSender}
onChangeText={setNewSender}
placeholder={t('settings.email_behavior.trusted_senders.add_placeholder', 'Enter email address')}
@@ -52,6 +52,7 @@ export function DownloadsSettings() {
description={t('settings.downloads.email_template.description_mobile', "Template used when exporting a message as an .eml file.")}
>
<Input
accessibilityLabel={t('settings.downloads.email_template.label', "Email (.eml) filename")}
value={emailTemplate}
onChangeText={(v) => update('emailExportTemplate', v)}
autoCapitalize="none"
@@ -76,6 +77,7 @@ export function DownloadsSettings() {
description={t('settings.downloads.attachment_template.description_mobile', "Template used when saving or sharing an attachment.")}
>
<Input
accessibilityLabel={t('settings.downloads.attachment_template.label', "Attachment filename")}
value={attachmentTemplate}
onChangeText={(v) => update('attachmentExportTemplate', v)}
autoCapitalize="none"
+1 -1
View File
@@ -318,7 +318,7 @@ export function FolderSettings() {
</View>
<ScrollView contentContainerStyle={styles.modalBody} keyboardShouldPersistTaps="handled">
<Text style={styles.fieldLabel}>{t('settings.folders.folder_name', 'Folder name')}</Text>
<TextInput
<TextInput accessibilityLabel={t('settings.folders.folder_name', 'Folder name')}
value={draftName}
onChangeText={setDraftName}
placeholder={t('settings.folders.name_placeholder', 'Receipts')}
+6 -6
View File
@@ -314,7 +314,7 @@ export function IdentitySettings() {
</View>
<ScrollView contentContainerStyle={styles.modalBody} keyboardShouldPersistTaps="handled">
<Text style={styles.fieldLabel}>{t('settings.identities.display_name', "Display name")}</Text>
<TextInput
<TextInput accessibilityLabel={t('settings.identities.display_name', "Display name")}
value={editing?.name ?? ''}
onChangeText={(name) => setEditing((d) => (d ? { ...d, name } : d))}
placeholder={t('settings.identities.display_name_placeholder', 'Jane Doe')}
@@ -322,7 +322,7 @@ export function IdentitySettings() {
style={styles.input}
/>
<Text style={styles.fieldLabel}>{t('settings.identities.email_address', "Email address")}</Text>
<TextInput
<TextInput accessibilityLabel={t('settings.identities.email_address', "Email address")}
value={editing?.email ?? ''}
onChangeText={(email) => setEditing((d) => (d ? { ...d, email } : d))}
placeholder={t('identities.form.email_placeholder', 'your.email@example.com')}
@@ -337,7 +337,7 @@ export function IdentitySettings() {
<Text style={styles.hint}>{t('settings.identities.email_locked', "JMAP does not allow changing an identity's email; create a new one instead.")}</Text>
)}
<Text style={styles.fieldLabel}>{t('identities.form.reply_to_label', 'Reply-To (optional)')}</Text>
<TextInput
<TextInput accessibilityLabel={t('identities.form.reply_to_label', 'Reply-To (optional)')}
value={editing?.replyTo ?? ''}
onChangeText={(replyTo) => setEditing((d) => (d ? { ...d, replyTo } : d))}
placeholder={t('identities.form.reply_to_placeholder', 'different@example.com')}
@@ -348,7 +348,7 @@ export function IdentitySettings() {
keyboardType="email-address"
/>
<Text style={styles.fieldLabel}>{t('identities.form.bcc_label', 'Auto BCC (optional)')}</Text>
<TextInput
<TextInput accessibilityLabel={t('identities.form.bcc_label', 'Auto BCC (optional)')}
value={editing?.bcc ?? ''}
onChangeText={(bcc) => setEditing((d) => (d ? { ...d, bcc } : d))}
placeholder={t('identities.form.bcc_placeholder', 'archive@example.com')}
@@ -359,7 +359,7 @@ export function IdentitySettings() {
keyboardType="email-address"
/>
<Text style={styles.fieldLabel}>{t('identities.form.text_signature_label', "Text Signature")}</Text>
<TextInput
<TextInput accessibilityLabel={t('identities.form.text_signature_label', "Text Signature")}
value={editing?.textSignature ?? ''}
onChangeText={(textSignature) => setEditing((d) => (d ? { ...d, textSignature } : d))}
placeholder="--&#10;Jane Doe&#10;Bulwark Mail"
@@ -371,7 +371,7 @@ export function IdentitySettings() {
{t('identities.form.signature_byte_counter', '{bytes} / {max} bytes', { bytes: textBytes, max: SIGNATURE_MAX_BYTES })}
</Text>
<Text style={styles.fieldLabel}>{t('identities.form.html_signature_label', "HTML Signature")}</Text>
<TextInput
<TextInput accessibilityLabel={t('identities.form.html_signature_label', "HTML Signature")}
value={editing?.htmlSignature ?? ''}
onChangeText={(htmlSignature) => setEditing((d) => (d ? { ...d, htmlSignature } : d))}
placeholder="<p><b>Jane Doe</b><br>Bulwark Mail</p>"
+1 -1
View File
@@ -304,7 +304,7 @@ function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProp
<View style={styles.form}>
<View>
<Text style={styles.formLabel}>{t('settings.keywords.label_field', "Display Name")}</Text>
<TextInput
<TextInput accessibilityLabel={t('settings.keywords.label_field', "Display Name")}
value={label}
onChangeText={setLabel}
placeholder={t('settings.keywords.label_placeholder', "e.g. Work, Personal, Urgent")}
+1 -1
View File
@@ -246,7 +246,7 @@ export function ReadingSettings() {
))}
</View>
<View style={styles.addKeywordRow}>
<TextInput
<TextInput accessibilityLabel={t('settings.email_behavior.attachment_reminder.add_placeholder', "Add keyword...")}
value={newKeyword}
onChangeText={setNewKeyword}
placeholder={t('settings.email_behavior.attachment_reminder.add_placeholder', "Add keyword...")}
@@ -133,7 +133,7 @@ function AppForm({ initial, onSave, onCancel }: AppFormProps) {
<View style={formStyles.form}>
<View>
<Text style={formStyles.label}>{t('settings.sidebar_apps.form.name', "Name")}</Text>
<TextInput
<TextInput accessibilityLabel={t('settings.sidebar_apps.form.name', "Name")}
value={name}
onChangeText={setName}
placeholder={t('settings.sidebar_apps.form.name_placeholder', "App name")}
@@ -144,7 +144,7 @@ function AppForm({ initial, onSave, onCancel }: AppFormProps) {
<View>
<Text style={formStyles.label}>{t('settings.sidebar_apps.form.url', "URL")}</Text>
<TextInput
<TextInput accessibilityLabel={t('settings.sidebar_apps.form.url', "URL")}
value={url}
onChangeText={setUrl}
placeholder="https://example.com"
+5 -4
View File
@@ -224,7 +224,7 @@ export function TemplateSettings() {
</View>
<ScrollView contentContainerStyle={styles.modalBody}>
<Text style={styles.fieldLabel}>{tr('settings.templates.name', 'Template Name')}</Text>
<TextInput
<TextInput accessibilityLabel={tr('settings.templates.name', 'Template Name')}
value={draftName}
onChangeText={setDraftName}
placeholder={tr('settings.templates.name_placeholder', 'e.g., Follow-up email')}
@@ -232,7 +232,7 @@ export function TemplateSettings() {
style={styles.input}
/>
<Text style={styles.fieldLabel}>{tr('settings.templates.subject', 'Subject')}</Text>
<TextInput
<TextInput accessibilityLabel={tr('settings.templates.subject', 'Subject')}
value={draftSubject}
onChangeText={setDraftSubject}
placeholder={tr('settings.templates.subject_placeholder', 'Email subject line')}
@@ -240,7 +240,7 @@ export function TemplateSettings() {
style={styles.input}
/>
<Text style={styles.fieldLabel}>{tr('settings.templates.category', 'Category')}</Text>
<TextInput
<TextInput accessibilityLabel={tr('settings.templates.category', 'Category')}
value={draftCategory}
onChangeText={setDraftCategory}
placeholder={tr('settings.templates.category_placeholder', 'e.g., Work, Personal')}
@@ -248,7 +248,7 @@ export function TemplateSettings() {
style={styles.input}
/>
<Text style={styles.fieldLabel}>{tr('settings.templates.body', 'Body')}</Text>
<TextInput
<TextInput accessibilityLabel={tr('settings.templates.body', 'Body')}
value={draftBody}
onChangeText={setDraftBody}
placeholder={tr('settings.templates.body_placeholder', 'Email body content...')}
@@ -297,6 +297,7 @@ export function TemplateSettings() {
<ScrollView contentContainerStyle={styles.modalBody}>
<Text style={styles.fieldLabel}>JSON</Text>
<TextInput
accessibilityLabel={tr('settings.templates.import_json_label', 'Templates as JSON')}
value={importText}
onChangeText={setImportText}
placeholder='{"templates": [...]}'
+1
View File
@@ -49,6 +49,7 @@ export default function EmailStep({
</View>
<Input
accessibilityLabel={t('login.username_label', 'Email')}
placeholder={t('login.username_placeholder', 'user@example.com')}
value={value}
onChangeText={onChange}