Skip to content

Commit ec53e24

Browse files
committed
fix(switch): address accessibility review feedback
1 parent 9a3a84b commit ec53e24

4 files changed

Lines changed: 858 additions & 142 deletions

File tree

‎docs/6.x/docs/guides/migration.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,7 @@ mutually exclusive options, use `SegmentedButtons`.
364364

365365
A `Switch` now has to declare how it can be operated. Previously a switch with no `onValueChange` still rendered as an enabled, focusable control that did nothing when activated, and screen readers announced it as operable.
366366

367-
Pass `onValueChange` to make it interactive, or mark it `readOnly` or `disabled` to render it as a state indicator. A read-only switch keeps its enabled appearance and is still announced with its on/off state, but it is neither focusable nor pressable — it is not reported as disabled.
367+
Pass `onValueChange` to make it interactive, or mark it `readOnly` or `disabled` to render it as a state indicator. A read-only switch keeps its enabled appearance and exposes its on/off state to screen readers, but it cannot be focused with a keyboard or pressed. On web it exposes `aria-readonly`. On iOS and Android it exposes a disabled accessibility state because React Native has no read-only state for switches. Use `disabled` when you also want the disabled appearance.
368368

369369
```tsx
370370
// Before (v5) — an enabled switch that does nothing

‎src/components/Switch/Switch.tsx‎

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,8 @@ export type Props = SwitchBaseProps & {
6464
onValueChange?: (value: boolean) => void;
6565
/**
6666
* Reports state the user cannot change here. The switch keeps its enabled
67-
* appearance and is still announced by a screen reader, but it is neither
68-
* focusable nor pressable.
67+
* appearance but cannot be focused with a keyboard or pressed. Screen readers
68+
* receive a read-only state on web and a disabled state on native platforms.
6969
*/
7070
readOnly?: boolean;
7171
/**
@@ -191,9 +191,8 @@ const Switch: (props: OperableProps) => React.JSX.Element = ({
191191
}
192192

193193
const isInteractive = !isDisabled && !isReadOnly && !isMissingOperability;
194-
// Non-operable but not disabled: an explicit `readOnly`, or the fallback for
195-
// a missing handler. Both render as state indicators, so both are announced
196-
// that way; `aria-disabled` carries the disabled case on its own.
194+
// Native accessibility has no read-only switch state. Disable the Pressable
195+
// there to expose non-operability, while keeping colors tied to `disabled`.
197196
const isAnnouncedReadOnly = !isDisabled && !isInteractive;
198197
const iconSource = checked ? checkedIcon : uncheckedIcon;
199198
const hasIcon = iconSource !== undefined;
@@ -202,6 +201,7 @@ const Switch: (props: OperableProps) => React.JSX.Element = ({
202201
Platform.OS === 'web' && direction === 'rtl' ? { right: 0 } : { left: 0 };
203202

204203
const pressedSV = useSharedValue(0);
204+
const spacePressedRef = React.useRef(false);
205205
const hoveredSV = useSharedValue(0);
206206
const focusedSV = useSharedValue(0);
207207
const checkedSV = useSharedValue(checked ? 1 : 0);
@@ -221,6 +221,7 @@ const Switch: (props: OperableProps) => React.JSX.Element = ({
221221
if (isInteractive) return;
222222

223223
pressedSV.value = 0;
224+
spacePressedRef.current = false;
224225
hoveredSV.value = 0;
225226
focusedSV.value = 0;
226227
}, [isInteractive, pressedSV, hoveredSV, focusedSV]);
@@ -425,20 +426,46 @@ const Switch: (props: OperableProps) => React.JSX.Element = ({
425426
},
426427
onBlur: () => {
427428
focusedSV.value = 0;
429+
pressedSV.value = 0;
430+
spacePressedRef.current = false;
428431
},
432+
// React Native Web only handles Space for button roles.
433+
...(Platform.OS === 'web'
434+
? {
435+
onKeyDown: (event: React.KeyboardEvent<HTMLElement>) => {
436+
const { key, repeat } = event.nativeEvent;
437+
if (key !== ' ' && key !== 'Spacebar') return;
438+
event.preventDefault();
439+
if (!repeat) {
440+
spacePressedRef.current = true;
441+
pressedSV.value = 1;
442+
}
443+
},
444+
onKeyUp: (event: React.KeyboardEvent<HTMLElement>) => {
445+
const { key } = event.nativeEvent;
446+
if (key !== ' ' && key !== 'Spacebar') return;
447+
event.preventDefault();
448+
if (spacePressedRef.current) {
449+
spacePressedRef.current = false;
450+
pressedSV.value = 0;
451+
onValueChange?.(!checked);
452+
}
453+
},
454+
}
455+
: undefined),
429456
}
430457
: null;
431458

432459
return (
433460
<View style={[styles.wrapper, style]}>
434461
<Pressable
435-
disabled={disabled}
462+
disabled={Platform.OS === 'web' ? isDisabled : !isInteractive}
436463
focusable={isInteractive}
437-
aria-readonly={isAnnouncedReadOnly}
464+
tabIndex={isInteractive ? 0 : -1}
465+
aria-readonly={Platform.OS === 'web' ? isAnnouncedReadOnly : undefined}
438466
{...interactionProps}
439467
android_ripple={{ color: 'transparent' }}
440468
role="switch"
441-
aria-disabled={isDisabled}
442469
aria-checked={checked}
443470
aria-label={ariaLabel}
444471
testID={testID}
@@ -460,7 +487,6 @@ const Switch: (props: OperableProps) => React.JSX.Element = ({
460487
</Pressable>
461488

462489
<Animated.View
463-
testID={testID ? `${testID}-state-layer` : undefined}
464490
style={[
465491
styles.stateLayer,
466492
anchorStyle,
@@ -469,10 +495,7 @@ const Switch: (props: OperableProps) => React.JSX.Element = ({
469495
]}
470496
/>
471497

472-
<Animated.View
473-
testID={testID ? `${testID}-handle` : undefined}
474-
style={[styles.handle, anchorStyle, handleAnimatedStyle]}
475-
>
498+
<Animated.View style={[styles.handle, anchorStyle, handleAnimatedStyle]}>
476499
{/* Disabled-only: opaque `surface` backdrop. The tinted fill above
477500
composites over it, reproducing the native math avoiding the PlatformColor alpha limitation. */}
478501
{isDisabled ? (
@@ -484,7 +507,6 @@ const Switch: (props: OperableProps) => React.JSX.Element = ({
484507
/>
485508
) : null}
486509
<Animated.View
487-
testID={testID ? `${testID}-handle-fill` : undefined}
488510
style={[
489511
styles.handleFill,
490512
{ opacity: handleOpacity },
@@ -520,7 +542,6 @@ const Switch: (props: OperableProps) => React.JSX.Element = ({
520542
) : null}
521543

522544
<Animated.View
523-
testID={testID ? `${testID}-focus-ring` : undefined}
524545
style={[
525546
styles.focusRing,
526547
{

0 commit comments

Comments
 (0)