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;
onValueChange: (val: boolean) => void;
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
* - 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 styles = React.useMemo(() => makeStyles(c), [c]);
return (
@@ -28,6 +30,7 @@ export default function ToggleSwitch({ value, onValueChange, disabled = false }:
]}
onPress={() => !disabled && onValueChange(!value)}
accessibilityRole="switch"
accessibilityLabel={accessibilityLabel}
accessibilityState={{ checked: value, disabled }}
>
<View
+11 -2
View File
@@ -400,6 +400,7 @@ export function FilterRuleModal({ visible, rule, mailboxes, onSave, onClose }: F
<ToggleSwitch
checked={!!action.keepCopy}
onChange={(v) => updateAction(index, { keepCopy: v || undefined })}
accessibilityLabel={t('settings.filters.forward_keep_copy', 'Keep a copy')}
/>
</View>
</>
@@ -446,7 +447,11 @@ export function FilterRuleModal({ visible, rule, mailboxes, onSave, onClose }: F
<Text style={styles.stopLabel}>
{t('settings.filters.stop_processing', 'Stop processing subsequent rules')}
</Text>
<ToggleSwitch checked={stopProcessing} onChange={setStopProcessing} />
<ToggleSwitch
checked={stopProcessing}
onChange={setStopProcessing}
accessibilityLabel={t('settings.filters.stop_processing', 'Stop processing subsequent rules')}
/>
</View>
{/* 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}>
{t('settings.filters.include_spam', 'Also move messages marked as spam')}
</Text>
<ToggleSwitch checked={includeSpam} onChange={setIncludeSpam} />
<ToggleSwitch
checked={includeSpam}
onChange={setIncludeSpam}
accessibilityLabel={t('settings.filters.include_spam', 'Also move messages marked as spam')}
/>
</View>
)}
</ScrollView>
+6 -2
View File
@@ -385,7 +385,7 @@ export function FilterSettings({ onOpenVacation }: FilterSettingsProps = {}) {
return (
<View key={rule.id} style={[styles.ruleRow, !rule.enabled && styles.ruleDisabled]}>
<View style={{ paddingTop: 2 }}>
<ToggleSwitch checked={rule.enabled} onChange={() => handleToggle(rule.id)} />
<ToggleSwitch checked={rule.enabled} onChange={() => handleToggle(rule.id)} accessibilityLabel={rule.name} />
</View>
<Pressable
@@ -458,7 +458,11 @@ export function FilterSettings({ onOpenVacation }: FilterSettingsProps = {}) {
{!isOpaque && rules.length > 0 && (
<View style={styles.expandedToggle}>
<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>
@@ -188,7 +188,11 @@ function AppForm({ initial, onSave, onCancel }: AppFormProps) {
<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>
<ToggleSwitch checked={showOnMobile} onChange={setShowOnMobile} />
<ToggleSwitch
checked={showOnMobile}
onChange={setShowOnMobile}
accessibilityLabel={t('settings.sidebar_apps.form.show_on_mobile', "Show on mobile")}
/>
</View>
<View style={{ flexDirection: 'row', gap: spacing.sm, justifyContent: 'flex-end' }}>
+10 -2
View File
@@ -246,11 +246,19 @@ export function TemplateSettings() {
</Text>
<View style={styles.toggleRow}>
<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 style={styles.toggleRow}>
<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>
</ScrollView>
<View style={styles.modalActions}>
+15 -3
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useContext, useState } from 'react';
import {
View,
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 {
label: string;
description?: string;
@@ -96,7 +100,11 @@ export function SettingItem({
</View>
{description && <Text style={styles.settingDescription}>{description}</Text>}
</View>
{children && <View style={styles.settingRight}>{children}</View>}
{children && (
<SettingLabelContext.Provider value={label}>
<View style={styles.settingRight}>{children}</View>
</SettingLabelContext.Provider>
)}
</View>
);
}
@@ -105,14 +113,18 @@ interface ToggleSwitchProps {
checked: boolean;
onChange: (v: boolean) => void;
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 (
<ToggleSwitchComponent
value={checked}
onValueChange={onChange}
disabled={disabled}
accessibilityLabel={accessibilityLabel ?? settingLabel}
/>
);
}