fix: give the contact, event and files fields a name for screen readers

This commit is contained in:
Linus Rath
2026-10-31 05:51:52 +01:00
parent 01578ac2fb
commit 7ba00be60e
15 changed files with 111 additions and 0 deletions
@@ -0,0 +1,58 @@
// Text fields need a name a screen reader can say (file 11 "Form fields
// have no name for screen readers"; field-names.test.ts covers Settings and
// sign-in). The contact form, the event editor and the calendar, contacts
// and files sheets had fields whose only name was their placeholder, or
// their value (the custom reminder's "30"). Scanned so a bare field doesn't
// creep back in.
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 (depth === 0 && (ch === '>' || (ch === '/' && 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>` as a type argument (useRef<TextInput>) is not an element.
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 dir = (rel: string) => readdirSync(join(SRC, rel))
.filter((name) => name.endsWith('.tsx'))
.map((name) => `${rel}/${name}`);
const FILES = [
...dir('components/calendar'),
...dir('components/contacts'),
...dir('components/files'),
...dir('components/sharing'),
'components/settings/VacationSettings.tsx',
'screens/CalendarScreen.tsx',
'screens/ContactFormScreen.tsx',
'screens/ContactsScreen.tsx',
'screens/ContactDetailScreen.tsx',
'screens/FilesScreen.tsx',
];
describe('field names in calendar, contacts and files', () => {
it('every field has a name', () => {
expect(FILES.flatMap(unnamedFields)).toEqual([]);
});
});
@@ -225,6 +225,7 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend
<ScrollView keyboardShouldPersistTaps="handled" contentContainerStyle={styles.body}>
<Text style={styles.label}>{t('calendar.management.name', 'Name')}</Text>
<TextInput
accessibilityLabel={t('calendar.management.name', 'Name')}
value={name}
onChangeText={setName}
placeholder={t('calendar.management.name_placeholder', 'Calendar name')}
@@ -308,6 +309,7 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend
)}
<Text style={styles.label}>{t('calendar.management.description', 'Description')}</Text>
<TextInput
accessibilityLabel={t('calendar.management.description', 'Description')}
value={description}
onChangeText={setDescription}
placeholder={t('calendar.management.description_placeholder', 'Optional')}
+5
View File
@@ -558,6 +558,7 @@ export function EventModal({
automaticallyAdjustKeyboardInsets
>
<TextInput
accessibilityLabel={t('calendar.form.title', 'Title')}
value={title}
onChangeText={setTitle}
placeholder={t('calendar.form.title', 'Title')}
@@ -797,6 +798,7 @@ export function EventModal({
{customReminderOpen && (
<View style={styles.customReminderRow}>
<TextInput
accessibilityLabel={t('calendar.alerts.amount', 'Reminder amount')}
value={customAmount}
onChangeText={(v) => setCustomAmount(v.replace(/[^0-9]/g, ''))}
keyboardType="number-pad"
@@ -834,6 +836,7 @@ export function EventModal({
<Section icon={<MapPin size={16} color={c.textMuted} />} label={t('calendar.form.location', 'Location')}>
<TextInput
accessibilityLabel={t('calendar.form.location', 'Location')}
value={location}
onChangeText={setLocation}
placeholder={t('calendar.form.location_placeholder', 'Place or address')}
@@ -844,6 +847,7 @@ export function EventModal({
<Section icon={<Video size={16} color={c.textMuted} />} label={t('calendar.form.meeting_link', 'Meeting link')}>
<TextInput
accessibilityLabel={t('calendar.form.meeting_link', 'Meeting link')}
value={videoUrl}
onChangeText={setVideoUrl}
placeholder="https://meet.example.com/…"
@@ -860,6 +864,7 @@ export function EventModal({
label={t('calendar.form.description', 'Description')}
>
<TextInput
accessibilityLabel={t('calendar.form.description', 'Description')}
value={description}
onChangeText={setDescription}
placeholder={t('calendar.form.description_placeholder', 'Notes, agenda…')}
@@ -94,6 +94,7 @@ export function ParticipantInput({ attendees, onAdd, onRemove }: ParticipantInpu
)}
<TextInput
accessibilityLabel={t('calendar.event_modal.participant_placeholder', 'Add participant by email')}
// No full-screen keyboard in landscape: it would hide the suggestions
// and the text around the field.
disableFullscreenUI
+2
View File
@@ -282,6 +282,7 @@ export function TasksSheet({
<View style={styles.addRow}>
<TextInput
accessibilityLabel={t('calendar.tasks.title_placeholder', 'Task title')}
value={editor.title}
onChangeText={(v) => setEditor((e) => ({ ...e, title: v }))}
placeholder={editor.id ? t('calendar.tasks.title_placeholder', 'Task title') : t('calendar.tasks.add_placeholder', 'Add a task…')}
@@ -305,6 +306,7 @@ export function TasksSheet({
{expanded && (
<View style={styles.editor}>
<TextInput
accessibilityLabel={t('calendar.tasks.description_placeholder', 'Notes')}
value={editor.description}
onChangeText={(v) => setEditor((e) => ({ ...e, description: v }))}
placeholder={t('calendar.tasks.description_placeholder', 'Notes')}
@@ -128,6 +128,7 @@ export default function AddressBookPickerSheet({
{creating ? (
<View style={styles.createRow}>
<TextInput
accessibilityLabel={t('contacts.address_books.name_label', 'Address book name')}
style={styles.createInput}
placeholder={t('contacts.address_books.name_label', 'Address book name')}
placeholderTextColor={c.textMuted}
@@ -140,6 +140,7 @@ export default function ContactPickerSheet({
<View style={styles.searchBar}>
<Search size={16} color={c.textMuted} />
<TextInput
accessibilityLabel={t('contacts.search_placeholder', 'Search contacts...')}
style={styles.searchInput}
placeholder={t('contacts.search_placeholder', 'Search contacts...')}
placeholderTextColor={c.textMuted}
@@ -282,6 +282,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
<View key={kw.keyword} style={styles.renameRow}>
<Tag size={16} color={c.textSecondary} />
<TextInput
accessibilityLabel={t('contacts.rename_category', 'Rename category')}
style={styles.renameInput}
value={renameValue}
onChangeText={setRenameValue}
@@ -94,6 +94,7 @@ export default function TagAssignSheet({
<View style={styles.inputRow}>
<TextInput
accessibilityLabel={t('contacts.tag_name_placeholder', 'New tag name')}
style={styles.input}
placeholder={t('contacts.tag_name_placeholder', 'New tag name')}
placeholderTextColor={c.textMuted}
+1
View File
@@ -274,6 +274,7 @@ export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps
<View style={styles.section}>
<Text style={styles.sectionLabel}>{t('files.share_add_people', 'Add people')}</Text>
<TextInput
accessibilityLabel={t('sharing.add_person', 'Add person or group')}
value={search}
onChangeText={setSearch}
placeholder={t('files.share_search_placeholder', 'Search by name or email')}
@@ -331,6 +331,7 @@ export function VacationSettings() {
description={t('settings.vacation.date_range.start_description', 'Leave empty for no start limit')}
>
<TextInput
accessibilityLabel={t('settings.vacation.date_range.start', 'Start Date')}
value={fromDate}
onChangeText={setFromDate}
placeholder="YYYY-MM-DD HH:MM"
@@ -345,6 +346,7 @@ export function VacationSettings() {
description={t('settings.vacation.date_range.end_description', 'Leave empty for no end limit')}
>
<TextInput
accessibilityLabel={t('settings.vacation.date_range.end', 'End Date')}
value={toDate}
onChangeText={setToDate}
placeholder="YYYY-MM-DD HH:MM"
@@ -379,6 +381,7 @@ export function VacationSettings() {
<Text style={styles.bodyLabel}>{t('settings.vacation.message.body_label', 'Message Body')}</Text>
<Text style={styles.bodyDesc}>{t('settings.vacation.message.body_description', 'Plain text message content')}</Text>
<TextInput
accessibilityLabel={t('settings.vacation.message.body_label', 'Message Body')}
value={body}
onChangeText={setBody}
placeholder={t('settings.vacation.message.body_placeholder', 'Thank you for your email. I am currently out of the office and will respond when I return.')}
@@ -236,6 +236,7 @@ export function ShareCollectionSheet<R extends object>({
<View style={styles.section}>
<Text style={styles.sectionLabel}>{t('calendar.share.add_people', 'Add people')}</Text>
<TextInput
accessibilityLabel={t('sharing.add_person', 'Add person or group')}
value={search}
onChangeText={setSearch}
placeholder={t('calendar.share.search_placeholder', 'Search by name or email')}
+31
View File
@@ -538,6 +538,7 @@ export default function ContactFormScreen() {
onRemove={() => updateForm('notes', form.notes.filter((_, idx) => idx !== i))}
>
<TextInput
accessibilityLabel={t('contacts.form.note', 'Notes')}
style={[styles.input, styles.multiline]}
placeholder={t('contacts.form.note_placeholder', 'Add a note...')}
placeholderTextColor={c.textMuted}
@@ -580,6 +581,7 @@ export default function ContactFormScreen() {
</View>
)}
<TextInput
accessibilityLabel={t('contacts.form.categories', 'Categories')}
style={styles.input}
placeholder={t('contacts.form.categories_placeholder_mobile', 'Add tag and press Enter')}
placeholderTextColor={c.textMuted}
@@ -703,6 +705,7 @@ export default function ContactFormScreen() {
<Section icon={<Users size={16} color={c.textMuted} />} label={t('contacts.group', 'Group')}>
<Field label={t('contacts.groups.name_label', 'Group Name')}>
<TextInput
accessibilityLabel={t('contacts.groups.name_label', 'Group Name')}
style={styles.input}
placeholder={t('contacts.groups.name_placeholder', 'e.g., Team, Family')}
placeholderTextColor={c.textMuted}
@@ -815,6 +818,7 @@ export default function ContactFormScreen() {
{form.isOrg ? (
<Field label={t('contacts.form.organization', 'Organization')}>
<TextInput
accessibilityLabel={t('contacts.form.organization', 'Organization')}
style={styles.input}
placeholder={t('contacts.form.organization_placeholder', 'Company name')}
ref={orgRef}
@@ -835,6 +839,7 @@ export default function ContactFormScreen() {
<View style={styles.row2}>
<Field label={t('contacts.form.prefix', 'Prefix')}>
<TextInput
accessibilityLabel={t('contacts.form.prefix', 'Prefix')}
style={styles.input}
placeholder={t('contacts.form.prefix_placeholder', 'Dr., Mr., Mrs.')}
ref={prefixRef}
@@ -848,6 +853,7 @@ export default function ContactFormScreen() {
</Field>
<Field label={t('contacts.form.suffix', 'Suffix')}>
<TextInput
accessibilityLabel={t('contacts.form.suffix', 'Suffix')}
style={styles.input}
placeholder={t('contacts.form.suffix_placeholder', 'Jr., Sr., III')}
ref={suffixRef}
@@ -862,6 +868,7 @@ export default function ContactFormScreen() {
</View>
<Field label={t('contacts.form.given_name', 'First name')}>
<TextInput
accessibilityLabel={t('contacts.form.given_name', 'First name')}
style={styles.input}
placeholder={t('contacts.form.given_name', 'First name')}
ref={givenRef}
@@ -876,6 +883,7 @@ export default function ContactFormScreen() {
</Field>
<Field label={t('contacts.form.middle_name', 'Middle name')}>
<TextInput
accessibilityLabel={t('contacts.form.middle_name', 'Middle name')}
style={styles.input}
placeholder={t('contacts.form.middle_name', 'Middle name')}
ref={middleRef}
@@ -889,6 +897,7 @@ export default function ContactFormScreen() {
</Field>
<Field label={t('contacts.form.surname', 'Last name')}>
<TextInput
accessibilityLabel={t('contacts.form.surname', 'Last name')}
style={styles.input}
placeholder={t('contacts.form.surname', 'Last name')}
ref={surnameRef}
@@ -905,6 +914,7 @@ export default function ContactFormScreen() {
<Field key={i} label={i === 0 ? t('contacts.form.nickname', 'Nickname') : undefined}>
<View style={styles.dateInputRow}>
<TextInput
accessibilityLabel={t('contacts.form.nickname', 'Nickname')}
style={[styles.input, { flex: 1 }]}
placeholder={t('contacts.form.nickname_placeholder', 'Nickname')}
placeholderTextColor={c.textMuted}
@@ -936,6 +946,7 @@ export default function ContactFormScreen() {
)}
<Field label={t('contacts.form.display_name_optional', 'Display name (optional)')}>
<TextInput
accessibilityLabel={t('contacts.form.display_name_optional', 'Display name (optional)')}
style={styles.input}
placeholder={t('contacts.form.display_name_placeholder', 'Custom display name')}
ref={fullRef}
@@ -957,6 +968,7 @@ export default function ContactFormScreen() {
onRemove={() => updateForm('emails', form.emails.filter((_, idx) => idx !== i))}
>
<TextInput
accessibilityLabel={t('contacts.form.email', 'Email')}
style={styles.input}
placeholder={t('contacts.form.email_placeholder', 'email@example.com')}
ref={i === 0 ? firstEmailRef : undefined}
@@ -996,6 +1008,7 @@ export default function ContactFormScreen() {
onRemove={() => updateForm('phones', form.phones.filter((_, idx) => idx !== i))}
>
<TextInput
accessibilityLabel={t('contacts.form.phone', 'Phone')}
style={styles.input}
placeholder={t('contacts.form.phone_placeholder', '+1 234 567 890')}
placeholderTextColor={c.textMuted}
@@ -1048,6 +1061,7 @@ export default function ContactFormScreen() {
{!(form.isOrg && i === 0) && (
<Field label={t('contacts.form.organization', 'Organization')}>
<TextInput
accessibilityLabel={t('contacts.form.organization', 'Organization')}
style={styles.input}
placeholder={t('contacts.form.organization_placeholder', 'Company name')}
placeholderTextColor={c.textMuted}
@@ -1062,6 +1076,7 @@ export default function ContactFormScreen() {
)}
<Field label={t('contacts.form.department', 'Department')}>
<TextInput
accessibilityLabel={t('contacts.form.department', 'Department')}
style={styles.input}
placeholder={t('contacts.form.department_placeholder', 'Department')}
placeholderTextColor={c.textMuted}
@@ -1075,6 +1090,7 @@ export default function ContactFormScreen() {
</Field>
<Field label={t('contacts.form.job_title', 'Job title')}>
<TextInput
accessibilityLabel={t('contacts.form.job_title', 'Job title')}
style={styles.input}
placeholder={t('contacts.form.job_title_placeholder', 'e.g., Software Engineer')}
placeholderTextColor={c.textMuted}
@@ -1088,6 +1104,7 @@ export default function ContactFormScreen() {
</Field>
<Field label={t('contacts.form.role', 'Role')}>
<TextInput
accessibilityLabel={t('contacts.form.role', 'Role')}
style={styles.input}
placeholder={t('contacts.form.role_placeholder', 'e.g., Team Lead')}
placeholderTextColor={c.textMuted}
@@ -1120,6 +1137,7 @@ export default function ContactFormScreen() {
onRemove={() => updateForm('addresses', form.addresses.filter((_, idx) => idx !== i))}
>
<TextInput
accessibilityLabel={t('contacts.form.street', 'Street')}
style={styles.input}
placeholder={t('contacts.form.street', 'Street')}
placeholderTextColor={c.textMuted}
@@ -1133,6 +1151,7 @@ export default function ContactFormScreen() {
<View style={styles.row2}>
<Field>
<TextInput
accessibilityLabel={t('contacts.form.city', 'City')}
style={styles.input}
placeholder={t('contacts.form.city', 'City')}
placeholderTextColor={c.textMuted}
@@ -1146,6 +1165,7 @@ export default function ContactFormScreen() {
</Field>
<Field>
<TextInput
accessibilityLabel={t('contacts.form.region', 'State / Region')}
style={styles.input}
placeholder={t('contacts.form.region', 'State / Region')}
placeholderTextColor={c.textMuted}
@@ -1161,6 +1181,7 @@ export default function ContactFormScreen() {
<View style={styles.row2}>
<Field>
<TextInput
accessibilityLabel={t('contacts.form.postcode', 'Postal code')}
style={styles.input}
placeholder={t('contacts.form.postcode', 'Postal code')}
placeholderTextColor={c.textMuted}
@@ -1174,6 +1195,7 @@ export default function ContactFormScreen() {
</Field>
<Field>
<TextInput
accessibilityLabel={t('contacts.form.country', 'Country')}
style={styles.input}
placeholder={t('contacts.form.country', 'Country')}
placeholderTextColor={c.textMuted}
@@ -1221,6 +1243,7 @@ export default function ContactFormScreen() {
>
<Field label={t('contacts.form.url', 'URL')}>
<TextInput
accessibilityLabel={t('contacts.form.url', 'URL')}
style={styles.input}
placeholder={t('contacts.form.url_placeholder', 'https://...')}
placeholderTextColor={c.textMuted}
@@ -1236,6 +1259,7 @@ export default function ContactFormScreen() {
<View style={styles.row2}>
<Field label={t('contacts.form.service_placeholder', 'Service')}>
<TextInput
accessibilityLabel={t('contacts.form.service_placeholder', 'Service')}
style={styles.input}
placeholder="LinkedIn, Mastodon, …"
placeholderTextColor={c.textMuted}
@@ -1249,6 +1273,7 @@ export default function ContactFormScreen() {
</Field>
<Field label={t('contacts.form.label', 'Label')}>
<TextInput
accessibilityLabel={t('contacts.form.label', 'Label')}
style={styles.input}
placeholder={t('contacts.form.label_placeholder', 'Work, Personal, …')}
placeholderTextColor={c.textMuted}
@@ -1283,6 +1308,7 @@ export default function ContactFormScreen() {
>
<View style={styles.dateInputRow}>
<TextInput
accessibilityLabel={t('contacts.form.anniversary_date', 'Date')}
style={[
styles.input,
{ flex: 1 },
@@ -1337,6 +1363,7 @@ export default function ContactFormScreen() {
onRemove={() => updateForm('personalInfo', form.personalInfo.filter((_, idx) => idx !== i))}
>
<TextInput
accessibilityLabel={t('contacts.form.personal_info', 'Personal info')}
style={styles.input}
placeholder={t('contacts.form.personal_info_placeholder', 'e.g., Photography')}
placeholderTextColor={c.textMuted}
@@ -1397,6 +1424,7 @@ export default function ContactFormScreen() {
</Field>
<Field label={t('contacts.form.pronouns', 'Pronouns')}>
<TextInput
accessibilityLabel={t('contacts.form.pronouns', 'Pronouns')}
style={styles.input}
placeholder={t('contacts.form.pronouns_placeholder', 'they/them')}
placeholderTextColor={c.textMuted}
@@ -1416,6 +1444,7 @@ export default function ContactFormScreen() {
>
<Field label={t('contacts.form.calendar_uri', 'Calendar URL')}>
<TextInput
accessibilityLabel={t('contacts.form.calendar_uri', 'Calendar URL')}
style={styles.input}
placeholder={t('contacts.form.url_placeholder', 'https://...')}
placeholderTextColor={c.textMuted}
@@ -1426,6 +1455,7 @@ export default function ContactFormScreen() {
</Field>
<Field label={t('contacts.form.scheduling_uri', 'Scheduling URL')}>
<TextInput
accessibilityLabel={t('contacts.form.scheduling_uri', 'Scheduling URL')}
style={styles.input}
placeholder={t('contacts.form.url_placeholder', 'https://...')}
placeholderTextColor={c.textMuted}
@@ -1436,6 +1466,7 @@ export default function ContactFormScreen() {
</Field>
<Field label={t('contacts.form.freebusy_uri', 'Free/Busy URL')}>
<TextInput
accessibilityLabel={t('contacts.form.freebusy_uri', 'Free/Busy URL')}
style={styles.input}
placeholder={t('contacts.form.url_placeholder', 'https://...')}
placeholderTextColor={c.textMuted}
+1
View File
@@ -530,6 +530,7 @@ export default function ContactsScreen({ onOpenContact, activeContactId = null }
<View style={styles.searchBar}>
<Search size={16} color={c.textMuted} />
<TextInput
accessibilityLabel={t('contacts.search_placeholder', 'Search contacts...')}
style={styles.searchInput}
placeholder={t('contacts.search_placeholder', 'Search contacts...')}
placeholderTextColor={c.textMuted}
+2
View File
@@ -1092,6 +1092,7 @@ export default function FilesScreen() {
<View style={styles.toolbarRow}>
{searchOpen ? (
<TextInput
accessibilityLabel={t('files.search_placeholder', 'Search files...')}
value={searchQuery}
onChangeText={setSearchQuery}
placeholder={t('files.search_placeholder', 'Search files...')}
@@ -1748,6 +1749,7 @@ function PromptModal(props: PromptModalProps) {
<View style={styles.dialog}>
<Text style={styles.title}>{props.title}</Text>
<TextInput
accessibilityLabel={props.placeholder ?? props.title}
value={props.value}
onChangeText={props.onChange}
placeholder={props.placeholder}