diff --git a/src/components/Input.tsx b/src/components/Input.tsx index ce3e8b93..ddcf9583 100644 --- a/src/components/Input.tsx +++ b/src/components/Input.tsx @@ -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({ { setFocused(true); props.onFocus?.(e); }} diff --git a/src/components/__tests__/field-names.test.ts b/src/components/__tests__/field-names.test.ts new file mode 100644 index 00000000..81e4b6bd --- /dev/null +++ b/src/components/__tests__/field-names.test.ts @@ -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', + }); + }); +}); diff --git a/src/components/input-a11y.ts b/src/components/input-a11y.ts new file mode 100644 index 00000000..22d5dd13 --- /dev/null +++ b/src/components/input-a11y.ts @@ -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 } : {}), + }; +} diff --git a/src/components/settings/AccountSecuritySettings.tsx b/src/components/settings/AccountSecuritySettings.tsx index dc964670..88ee9544 100644 --- a/src/components/settings/AccountSecuritySettings.tsx +++ b/src/components/settings/AccountSecuritySettings.tsx @@ -178,7 +178,7 @@ function PasswordChangeSection({ otpEnabled }: { otpEnabled: boolean }) { {t('settings.security.password.current', "Current password")} - + setShowCurrent((v) => !v)} accessibilityRole="button" accessibilityLabel={t('settings.security.password.toggle_visibility', "Show or hide password")}> {showCurrent ? : } @@ -188,14 +188,14 @@ function PasswordChangeSection({ otpEnabled }: { otpEnabled: boolean }) { {otpEnabled && ( {t('settings.security.two_factor.code', "Verification code")} - + )} {t('settings.security.password.new', "New password")} - + setShowNew((v) => !v)} accessibilityRole="button" accessibilityLabel={t('settings.security.password.toggle_visibility', "Show or hide password")}> {showNew ? : } @@ -204,7 +204,7 @@ function PasswordChangeSection({ otpEnabled }: { otpEnabled: boolean }) { {t('settings.security.password.confirm', "Confirm password")} - + {error && {error}} @@ -252,7 +252,7 @@ function DisplayNameSection({ initial, onSaved }: { initial: string; onSaved: (n return ( - + @@ -356,11 +356,11 @@ function TotpSection({ enabled, onChanged }: { enabled: boolean; onChanged: () = {t('settings.security.password.current', "Current password")} - + {t('settings.security.two_factor.code', "Verification code")} - + {error ? {error} : null} @@ -375,8 +375,8 @@ function TotpSection({ enabled, onChanged }: { enabled: boolean; onChanged: () = {disableOpen && ( {t('settings.security.two_factor.disable_hint_code', "Enter your password and a code from your authenticator app to turn off two-factor authentication.")} - - + + {error ? {error} : null}