fix: name the settings switches for screen readers

A settings switch had a role and a checked state but no label, so
TalkBack only announced "switch, off". A switch inside a SettingItem now
takes that row's label, and the switches outside one (filter rules,
expanded view, template and sidebar app editors, filter rule options)
get their visible label passed explicitly.
This commit is contained in:
Linus Rath
2026-09-24 20:49:55 +02:00
parent 6e6fd95e5e
commit cb5c152cb3
6 changed files with 51 additions and 11 deletions
+4 -1
View File
@@ -7,6 +7,8 @@ interface ToggleSwitchProps {
value: boolean; value: boolean;
onValueChange: (val: boolean) => void; onValueChange: (val: boolean) => void;
disabled?: boolean; disabled?: boolean;
/** What the switch turns on or off, read out by screen readers. */
accessibilityLabel?: string;
} }
/** /**
@@ -16,7 +18,7 @@ interface ToggleSwitchProps {
* - thumb: h-4 w-4 rounded-full bg-background * - thumb: h-4 w-4 rounded-full bg-background
* - translate-x-6 (checked) / translate-x-1 (unchecked) * - translate-x-6 (checked) / translate-x-1 (unchecked)
*/ */
export default function ToggleSwitch({ value, onValueChange, disabled = false }: ToggleSwitchProps) { export default function ToggleSwitch({ value, onValueChange, disabled = false, accessibilityLabel }: ToggleSwitchProps) {
const c = useColors(); const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]); const styles = React.useMemo(() => makeStyles(c), [c]);
return ( return (
@@ -28,6 +30,7 @@ export default function ToggleSwitch({ value, onValueChange, disabled = false }:
]} ]}
onPress={() => !disabled && onValueChange(!value)} onPress={() => !disabled && onValueChange(!value)}
accessibilityRole="switch" accessibilityRole="switch"
accessibilityLabel={accessibilityLabel}
accessibilityState={{ checked: value, disabled }} accessibilityState={{ checked: value, disabled }}
> >
<View <View
+11 -2
View File
@@ -400,6 +400,7 @@ export function FilterRuleModal({ visible, rule, mailboxes, onSave, onClose }: F
<ToggleSwitch <ToggleSwitch
checked={!!action.keepCopy} checked={!!action.keepCopy}
onChange={(v) => updateAction(index, { keepCopy: v || undefined })} onChange={(v) => updateAction(index, { keepCopy: v || undefined })}
accessibilityLabel={t('settings.filters.forward_keep_copy', 'Keep a copy')}
/> />
</View> </View>
</> </>
@@ -446,7 +447,11 @@ export function FilterRuleModal({ visible, rule, mailboxes, onSave, onClose }: F
<Text style={styles.stopLabel}> <Text style={styles.stopLabel}>
{t('settings.filters.stop_processing', 'Stop processing subsequent rules')} {t('settings.filters.stop_processing', 'Stop processing subsequent rules')}
</Text> </Text>
<ToggleSwitch checked={stopProcessing} onChange={setStopProcessing} /> <ToggleSwitch
checked={stopProcessing}
onChange={setStopProcessing}
accessibilityLabel={t('settings.filters.stop_processing', 'Stop processing subsequent rules')}
/>
</View> </View>
{/* Folder rules skip spam unless the rule opts in (webmail 6241ed61). */} {/* Folder rules skip spam unless the rule opts in (webmail 6241ed61). */}
@@ -455,7 +460,11 @@ export function FilterRuleModal({ visible, rule, mailboxes, onSave, onClose }: F
<Text style={styles.stopLabel}> <Text style={styles.stopLabel}>
{t('settings.filters.include_spam', 'Also move messages marked as spam')} {t('settings.filters.include_spam', 'Also move messages marked as spam')}
</Text> </Text>
<ToggleSwitch checked={includeSpam} onChange={setIncludeSpam} /> <ToggleSwitch
checked={includeSpam}
onChange={setIncludeSpam}
accessibilityLabel={t('settings.filters.include_spam', 'Also move messages marked as spam')}
/>
</View> </View>
)} )}
</ScrollView> </ScrollView>
+6 -2
View File
@@ -385,7 +385,7 @@ export function FilterSettings({ onOpenVacation }: FilterSettingsProps = {}) {
return ( return (
<View key={rule.id} style={[styles.ruleRow, !rule.enabled && styles.ruleDisabled]}> <View key={rule.id} style={[styles.ruleRow, !rule.enabled && styles.ruleDisabled]}>
<View style={{ paddingTop: 2 }}> <View style={{ paddingTop: 2 }}>
<ToggleSwitch checked={rule.enabled} onChange={() => handleToggle(rule.id)} /> <ToggleSwitch checked={rule.enabled} onChange={() => handleToggle(rule.id)} accessibilityLabel={rule.name} />
</View> </View>
<Pressable <Pressable
@@ -458,7 +458,11 @@ export function FilterSettings({ onOpenVacation }: FilterSettingsProps = {}) {
{!isOpaque && rules.length > 0 && ( {!isOpaque && rules.length > 0 && (
<View style={styles.expandedToggle}> <View style={styles.expandedToggle}>
<Text style={styles.expandedLabel}>{t('settings.filters.expanded_view', 'Expanded view')}</Text> <Text style={styles.expandedLabel}>{t('settings.filters.expanded_view', 'Expanded view')}</Text>
<ToggleSwitch checked={expandedView} onChange={(v) => updateSetting('filtersExpandedView', v)} /> <ToggleSwitch
checked={expandedView}
onChange={(v) => updateSetting('filtersExpandedView', v)}
accessibilityLabel={t('settings.filters.expanded_view', 'Expanded view')}
/>
</View> </View>
)} )}
</View> </View>
@@ -188,7 +188,11 @@ function AppForm({ initial, onSave, onCancel }: AppFormProps) {
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}> <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={formStyles.label}>{t('settings.sidebar_apps.form.show_on_mobile', "Show on mobile")}</Text> <Text style={formStyles.label}>{t('settings.sidebar_apps.form.show_on_mobile', "Show on mobile")}</Text>
<ToggleSwitch checked={showOnMobile} onChange={setShowOnMobile} /> <ToggleSwitch
checked={showOnMobile}
onChange={setShowOnMobile}
accessibilityLabel={t('settings.sidebar_apps.form.show_on_mobile', "Show on mobile")}
/>
</View> </View>
<View style={{ flexDirection: 'row', gap: spacing.sm, justifyContent: 'flex-end' }}> <View style={{ flexDirection: 'row', gap: spacing.sm, justifyContent: 'flex-end' }}>
+10 -2
View File
@@ -246,11 +246,19 @@ export function TemplateSettings() {
</Text> </Text>
<View style={styles.toggleRow}> <View style={styles.toggleRow}>
<Text style={styles.toggleLabel}>{tr('settings.templates.favorite', 'Favorite')}</Text> <Text style={styles.toggleLabel}>{tr('settings.templates.favorite', 'Favorite')}</Text>
<ToggleSwitch checked={draftFavorite} onChange={setDraftFavorite} /> <ToggleSwitch
checked={draftFavorite}
onChange={setDraftFavorite}
accessibilityLabel={tr('settings.templates.favorite', 'Favorite')}
/>
</View> </View>
<View style={styles.toggleRow}> <View style={styles.toggleRow}>
<Text style={styles.toggleLabel}>{tr('settings.templates.is_html', 'Body is HTML')}</Text> <Text style={styles.toggleLabel}>{tr('settings.templates.is_html', 'Body is HTML')}</Text>
<ToggleSwitch checked={draftIsHtml} onChange={setDraftIsHtml} /> <ToggleSwitch
checked={draftIsHtml}
onChange={setDraftIsHtml}
accessibilityLabel={tr('settings.templates.is_html', 'Body is HTML')}
/>
</View> </View>
</ScrollView> </ScrollView>
<View style={styles.modalActions}> <View style={styles.modalActions}>
+15 -3
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useContext, useState } from 'react';
import { import {
View, View,
Text, Text,
@@ -65,6 +65,10 @@ export function SettingsSection({
); );
} }
// The label of the SettingItem a control sits in. A switch has no text of
// its own, so without it a screen reader only announces "switch, off".
const SettingLabelContext = React.createContext<string | undefined>(undefined);
interface SettingItemProps { interface SettingItemProps {
label: string; label: string;
description?: string; description?: string;
@@ -96,7 +100,11 @@ export function SettingItem({
</View> </View>
{description && <Text style={styles.settingDescription}>{description}</Text>} {description && <Text style={styles.settingDescription}>{description}</Text>}
</View> </View>
{children && <View style={styles.settingRight}>{children}</View>} {children && (
<SettingLabelContext.Provider value={label}>
<View style={styles.settingRight}>{children}</View>
</SettingLabelContext.Provider>
)}
</View> </View>
); );
} }
@@ -105,14 +113,18 @@ interface ToggleSwitchProps {
checked: boolean; checked: boolean;
onChange: (v: boolean) => void; onChange: (v: boolean) => void;
disabled?: boolean; disabled?: boolean;
/** Defaults to the label of the enclosing SettingItem. */
accessibilityLabel?: string;
} }
export function ToggleSwitch({ checked, onChange, disabled }: ToggleSwitchProps) { export function ToggleSwitch({ checked, onChange, disabled, accessibilityLabel }: ToggleSwitchProps) {
const settingLabel = useContext(SettingLabelContext);
return ( return (
<ToggleSwitchComponent <ToggleSwitchComponent
value={checked} value={checked}
onValueChange={onChange} onValueChange={onChange}
disabled={disabled} disabled={disabled}
accessibilityLabel={accessibilityLabel ?? settingLabel}
/> />
); );
} }