fix: toggle a settings switch by tapping anywhere on its row

This commit is contained in:
Linus Rath
2026-10-31 20:29:26 +01:00
parent 1286e6cb82
commit 3165b40127
2 changed files with 72 additions and 4 deletions
@@ -0,0 +1,40 @@
import { describe, expect, it, vi } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
vi.mock('../../ToggleSwitch', () => ({ default: () => null }));
vi.mock('../../icons', () => ({}));
vi.mock('../../../stores/locale-store', () => ({ useLocaleStore: () => undefined }));
vi.mock('../../../theme/colors', () => ({ useColors: () => ({}) }));
vi.mock('../search-highlight', () => ({ useSearchHighlight: () => ({}) }));
vi.mock('../../../lib/modal-orientations', () => ({ MODAL_ORIENTATIONS: [] }));
vi.mock('react-native', () => ({ StyleSheet: { create: (s: unknown) => s } }));
import { switchRowPress } from '../settings-section';
// Tapping the "Colour-blind-safe colours" label did nothing: only the switch
// itself flipped the setting.
describe('a settings row with a switch', () => {
it('flips the switch when the row is tapped', () => {
const onChange = vi.fn();
switchRowPress(false, onChange)!();
expect(onChange).toHaveBeenLastCalledWith(true);
switchRowPress(true, onChange)!();
expect(onChange).toHaveBeenLastCalledWith(false);
});
it('leaves a disabled switch alone', () => {
expect(switchRowPress(false, vi.fn(), true)).toBeNull();
});
it('hands the switch to its row, which presses it unless the setting is locked', () => {
const src = fs.readFileSync(path.resolve(__dirname, '..', 'settings-section.tsx'), 'utf8');
expect(src).toMatch(/rowToggle\.current = switchRowPress\(checked, onChange, disabled\);/);
expect(src).toMatch(/onPress=\{\(\) => \{ if \(!locked\) rowToggle\.current\?\.\(\); \}\}/);
expect(src).toMatch(/<SettingToggleContext\.Provider value=\{rowToggle\}>/);
// The colour-blind row is such a row.
const a11y = fs.readFileSync(path.resolve(__dirname, '..', 'AccessibilitySettings.tsx'), 'utf8');
expect(a11y).toMatch(/label=\{t\('settings\.accessibility\.cvd_palette\.label'[\s\S]{0,300}<ToggleSwitch\s+checked=\{colorBlindPalette\}/);
});
});
+32 -4
View File
@@ -76,6 +76,16 @@ const STACK_BELOW = 140;
// its own, so without it a screen reader only announces "switch, off".
const SettingLabelContext = React.createContext<string | undefined>(undefined);
// What a tap on the row does when its control is a switch: flip it, so the
// label and the description are as good a target as the 44 x 24 switch
// (tapping "Colour-blind-safe colours" did nothing).
const SettingToggleContext = React.createContext<React.MutableRefObject<(() => void) | null> | null>(null);
/** The row tap for a switch: flips it; null while it is disabled. */
export function switchRowPress(checked: boolean, onChange: (v: boolean) => void, disabled?: boolean): (() => void) | null {
return disabled ? null : () => onChange(!checked);
}
interface SettingItemProps {
label: string;
description?: string;
@@ -100,6 +110,8 @@ export function SettingItem({
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const highlight = useSearchHighlight(label);
// Set by a ToggleSwitch in the row (switchRowPress).
const rowToggle = React.useRef<(() => void) | null>(null);
// The control sits on the right. When that leaves the label column narrower
// than STACK_BELOW (a long translation, two buttons), the control moves
// under the label, as the webmail stacks it on a phone. A new row width
@@ -130,8 +142,10 @@ export function SettingItem({
</View>
{children && (
<SettingLabelContext.Provider value={label}>
{/* A locked setting shows its value but can't be changed. */}
<View style={styles.settingRight} pointerEvents={locked ? 'none' : 'auto'}>{children}</View>
<SettingToggleContext.Provider value={rowToggle}>
{/* A locked setting shows its value but can't be changed. */}
<View style={styles.settingRight} pointerEvents={locked ? 'none' : 'auto'}>{children}</View>
</SettingToggleContext.Provider>
</SettingLabelContext.Provider>
)}
{onPress && (link === 'external'
@@ -152,10 +166,18 @@ export function SettingItem({
</Pressable>
);
}
// A tap anywhere on the row flips its switch, if it has one. Not a
// control of its own for a screen reader: the switch carries the label.
return (
<View ref={highlight.ref} onLayout={onRowLayout} style={rowStyle}>
<Pressable
ref={highlight.ref}
onLayout={onRowLayout}
onPress={() => { if (!locked) rowToggle.current?.(); }}
accessible={false}
style={rowStyle}
>
{body}
</View>
</Pressable>
);
}
@@ -219,6 +241,12 @@ interface ToggleSwitchProps {
export function ToggleSwitch({ checked, onChange, disabled, accessibilityLabel }: ToggleSwitchProps) {
const settingLabel = useContext(SettingLabelContext);
const rowToggle = useContext(SettingToggleContext);
React.useEffect(() => {
if (!rowToggle) return undefined;
rowToggle.current = switchRowPress(checked, onChange, disabled);
return () => { rowToggle.current = null; };
}, [rowToggle, checked, onChange, disabled]);
return (
<ToggleSwitchComponent
value={checked}