From 735c58ebe83e745334395776fc0f92b98b9dd94a Mon Sep 17 00:00:00 2001 From: likevy Date: Thu, 3 Sep 2026 12:42:51 +0200 Subject: [PATCH 01/29] fix: use onPrimaryContainer for the selected switch icon --- src/components/Switch/Switch.tsx | 3 ++- src/components/Switch/tokens.ts | 2 +- src/components/__tests__/__snapshots__/Switch.test.tsx.snap | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/components/Switch/Switch.tsx b/src/components/Switch/Switch.tsx index 2c86f8d963..a098fd0ed5 100644 --- a/src/components/Switch/Switch.tsx +++ b/src/components/Switch/Switch.tsx @@ -117,7 +117,8 @@ const CHECKED_CENTER = TRACK_WIDTH - HANDLE_PADDING - SELECTED_HANDLE / 2; * * ## Theming * Customize by overriding these `theme.colors` roles: - * - `primary` / `onPrimary`: selected track + icon / selected handle + * - `primary` / `onPrimary`: selected track / selected handle + * - `onPrimaryContainer`: selected icon * - `primaryContainer`: selected handle on hover, press * - `surfaceContainerHighest`: unselected track + icon * - `outline`: unselected resting handle, unselected track outline diff --git a/src/components/Switch/tokens.ts b/src/components/Switch/tokens.ts index ccde1a9a3f..ab5b2c8bb0 100644 --- a/src/components/Switch/tokens.ts +++ b/src/components/Switch/tokens.ts @@ -29,7 +29,7 @@ const colors = { unselectedHoverHandleColor: 'onSurfaceVariant', unselectedPressedHandleColor: 'onSurfaceVariant', - selectedIconColor: 'primary', + selectedIconColor: 'onPrimaryContainer', unselectedIconColor: 'surfaceContainerHighest', selectedTrackColor: 'primary', diff --git a/src/components/__tests__/__snapshots__/Switch.test.tsx.snap b/src/components/__tests__/__snapshots__/Switch.test.tsx.snap index e38a5145e1..967661acca 100644 --- a/src/components/__tests__/__snapshots__/Switch.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Switch.test.tsx.snap @@ -986,7 +986,7 @@ exports[`Switch render renders with checked icon 1`] = ` style={ [ { - "color": "rgba(103, 80, 164, 1)", + "color": "rgba(33, 0, 93, 1)", "fontSize": 16, }, [ @@ -1235,7 +1235,7 @@ exports[`Switch render renders with per-state icons 1`] = ` style={ [ { - "color": "rgba(103, 80, 164, 1)", + "color": "rgba(33, 0, 93, 1)", "fontSize": 16, }, [ From 2d7a0b3c85c3ffff6b466723418a07caf163a2e3 Mon Sep 17 00:00:00 2001 From: likevy Date: Thu, 3 Sep 2026 12:43:00 +0200 Subject: [PATCH 02/29] fix: apply focus handle color and focus state layer to switch --- src/components/Switch/Switch.tsx | 19 ++++---- src/components/Switch/tokens.ts | 2 + src/components/Switch/utils.ts | 4 ++ src/components/__tests__/Switch.test.tsx | 58 +++++++++++++++++++++++- 4 files changed, 74 insertions(+), 9 deletions(-) diff --git a/src/components/Switch/Switch.tsx b/src/components/Switch/Switch.tsx index a098fd0ed5..4220539658 100644 --- a/src/components/Switch/Switch.tsx +++ b/src/components/Switch/Switch.tsx @@ -119,10 +119,10 @@ const CHECKED_CENTER = TRACK_WIDTH - HANDLE_PADDING - SELECTED_HANDLE / 2; * Customize by overriding these `theme.colors` roles: * - `primary` / `onPrimary`: selected track / selected handle * - `onPrimaryContainer`: selected icon - * - `primaryContainer`: selected handle on hover, press + * - `primaryContainer`: selected handle on hover, focus, press * - `surfaceContainerHighest`: unselected track + icon * - `outline`: unselected resting handle, unselected track outline - * - `onSurfaceVariant`: unselected handle on hover, press + * - `onSurfaceVariant`: unselected handle on hover, focus, press * - `onSurface`: disabled track, handle, and icon fills * - `surface`: disabled selected handle * - `secondary`: focus indicator @@ -252,12 +252,7 @@ const Switch = ({ (current, prev) => { if (current === prev) return; const wasPressed = prev === 'pressed'; - const target = - current === 'pressed' - ? stateOpacity.pressed - : current === 'hovered' - ? stateOpacity.hovered - : 0; + const target = current ? stateOpacity[current] : 0; if (wasPressed && current !== 'pressed') { // On release: rise to peak, then fall to the next state. stateLayerAlpha.value = withSequence( @@ -285,6 +280,11 @@ const Switch = ({ ? colors.checkedPressedHandleColor : colors.uncheckedPressedHandleColor; } + if (focusedSV.value === 1) { + return isCheckedNow + ? colors.checkedFocusHandleColor + : colors.uncheckedFocusHandleColor; + } if (hoveredSV.value === 1) { return isCheckedNow ? colors.checkedHoverHandleColor @@ -394,6 +394,7 @@ const Switch = ({ ) : null} { @@ -47,6 +52,57 @@ describe('Switch accessibility', () => { }); }); +describe('Switch focus state', () => { + const renderAndFocus = async (element: React.ReactElement) => { + await render(element); + + await fireEvent(screen.getByTestId('switch'), 'focus'); + await jest.runAllTimersAsync(); + }; + + const animatedStyle = (testID: string) => + Reanimated.getAnimatedStyle(screen.getByTestId(testID)); + + it('shows the focus indicator on keyboard focus', async () => { + await renderAndFocus( + + ); + + expect(animatedStyle('switch-focus-ring')).toMatchObject({ opacity: 1 }); + }); + + it('hides the focus indicator again on blur', async () => { + await renderAndFocus( + + ); + + await fireEvent(screen.getByTestId('switch'), 'blur'); + await jest.runAllTimersAsync(); + + expect(animatedStyle('switch-focus-ring')).toMatchObject({ opacity: 0 }); + }); + + it('raises the state layer to the focused opacity', async () => { + await renderAndFocus( + + ); + + expect(animatedStyle('switch-state-layer')).toMatchObject({ + opacity: tokens.md.sys.state.opacity.focused, + }); + }); + + it('paints the focus handle color when selected and focused', async () => { + await renderAndFocus( + + ); + + expect(animatedStyle('switch-handle')).toMatchObject({ + backgroundColor: defaultThemes.light.colors.primaryContainer, + }); + }); +}); + describe('Switch interaction', () => { it('toggles to true when off and pressed', async () => { const user = userEvent.setup(); From ec1540a5029cb74bc5d184c75ddd837e0f36098c Mon Sep 17 00:00:00 2001 From: likevy Date: Thu, 3 Sep 2026 12:43:10 +0200 Subject: [PATCH 03/29] fix: meet the 48dp minimum touch target on switch --- src/components/Switch/Switch.tsx | 20 +++-- src/components/Switch/tokens.ts | 5 ++ src/components/__tests__/Switch.test.tsx | 6 ++ .../__snapshots__/Switch.test.tsx.snap | 76 +++++++++---------- 4 files changed, 61 insertions(+), 46 deletions(-) diff --git a/src/components/Switch/Switch.tsx b/src/components/Switch/Switch.tsx index 4220539658..5db255da20 100644 --- a/src/components/Switch/Switch.tsx +++ b/src/components/Switch/Switch.tsx @@ -68,6 +68,7 @@ const { trackHeight: TRACK_HEIGHT, trackOutlineWidth: TRACK_OUTLINE_WIDTH, stateLayerSize: STATE_LAYER_SIZE, + touchTargetSize: TOUCH_TARGET_SIZE, selectedHandleSize: SELECTED_HANDLE, unselectedHandleSize: UNSELECTED_HANDLE, iconHandleSize: ICON_HANDLE, @@ -86,7 +87,10 @@ const stateOpacity = stateTokens.opacity; const { thickness: FOCUS_THICKNESS, outerOffset: FOCUS_OUTER_OFFSET } = stateTokens.focusIndicator; const FOCUS_RING_INSET = -(FOCUS_OUTER_OFFSET + FOCUS_THICKNESS); -const OVERLAY_TOP = (STATE_LAYER_SIZE - TRACK_HEIGHT) / 2; +// Every painted layer is smaller than the touch target and absolutely +// positioned, so each one is centred against it. +const centreY = (size: number) => (TOUCH_TARGET_SIZE - size) / 2; +const TRACK_TOP = centreY(TRACK_HEIGHT); // Hold-then-grow: a brief delay before snapping to PRESSED_HANDLE so a quick // tap doesn't flash the press-grow visual. @@ -298,7 +302,7 @@ const Switch = ({ const handleAnimatedStyle = useAnimatedStyle(() => ({ width: handleSize.value, height: handleSize.value, - top: (STATE_LAYER_SIZE - handleSize.value) / 2, + top: (TOUCH_TARGET_SIZE - handleSize.value) / 2, transform: [ { translateX: xSign * (handleCenter.value - handleSize.value / 2) }, ], @@ -457,10 +461,10 @@ const Switch = ({ { borderColor: colors.focusIndicatorColor, borderWidth: FOCUS_THICKNESS, - top: OVERLAY_TOP + FOCUS_RING_INSET, + top: TRACK_TOP + FOCUS_RING_INSET, left: FOCUS_RING_INSET, right: FOCUS_RING_INSET, - bottom: OVERLAY_TOP + FOCUS_RING_INSET, + bottom: TRACK_TOP + FOCUS_RING_INSET, borderRadius: cornerFull, }, focusRingAnimatedStyle, @@ -473,14 +477,14 @@ const Switch = ({ const styles = StyleSheet.create({ wrapper: { width: TRACK_WIDTH, - height: STATE_LAYER_SIZE, + height: TOUCH_TARGET_SIZE, alignItems: 'center', justifyContent: 'center', overflow: 'visible', }, touchable: { width: TRACK_WIDTH, - height: STATE_LAYER_SIZE, + height: TOUCH_TARGET_SIZE, alignItems: 'center', justifyContent: 'center', }, @@ -502,7 +506,7 @@ const styles = StyleSheet.create({ }, stateLayer: { position: 'absolute', - top: 0, + top: centreY(STATE_LAYER_SIZE), width: STATE_LAYER_SIZE, height: STATE_LAYER_SIZE, borderRadius: cornerFull, @@ -524,7 +528,7 @@ const styles = StyleSheet.create({ }, iconWrap: { position: 'absolute', - top: (STATE_LAYER_SIZE - SELECTED_ICON) / 2, + top: centreY(SELECTED_ICON), width: SELECTED_ICON, height: SELECTED_ICON, pointerEvents: 'none', diff --git a/src/components/Switch/tokens.ts b/src/components/Switch/tokens.ts index fe314b8867..83998b5033 100644 --- a/src/components/Switch/tokens.ts +++ b/src/components/Switch/tokens.ts @@ -5,6 +5,11 @@ const sizes = { trackHeight: 32, trackOutlineWidth: 2, stateLayerSize: 40, + /** + * Minimum interactive area. Larger than the 40dp state layer, so it sets the + * component's layout height rather than any painted surface. + */ + touchTargetSize: 48, selectedHandleSize: 24, unselectedHandleSize: 16, diff --git a/src/components/__tests__/Switch.test.tsx b/src/components/__tests__/Switch.test.tsx index d3c4d98a44..e298c6b4ba 100644 --- a/src/components/__tests__/Switch.test.tsx +++ b/src/components/__tests__/Switch.test.tsx @@ -50,6 +50,12 @@ describe('Switch accessibility', () => { expect(screen.getByRole('switch')).toBeOnTheScreen(); }); + + it('exposes a touch target meeting the 48dp minimum', async () => { + await render(); + + expect(screen.getByRole('switch')).toHaveStyle({ width: 52, height: 48 }); + }); }); describe('Switch focus state', () => { diff --git a/src/components/__tests__/__snapshots__/Switch.test.tsx.snap b/src/components/__tests__/__snapshots__/Switch.test.tsx.snap index 967661acca..00f6a476ba 100644 --- a/src/components/__tests__/__snapshots__/Switch.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Switch.test.tsx.snap @@ -6,7 +6,7 @@ exports[`Switch render renders disabled off 1`] = ` [ { "alignItems": "center", - "height": 40, + "height": 48, "justifyContent": "center", "overflow": "visible", "width": 52, @@ -50,7 +50,7 @@ exports[`Switch render renders disabled off 1`] = ` [ { "alignItems": "center", - "height": 40, + "height": 48, "justifyContent": "center", "width": 52, }, @@ -104,7 +104,7 @@ exports[`Switch render renders disabled off 1`] = ` "height": 40, "pointerEvents": "none", "position": "absolute", - "top": 0, + "top": 4, "width": 40, }, { @@ -139,7 +139,7 @@ exports[`Switch render renders disabled off 1`] = ` }, { "height": 16, - "top": 12, + "top": 16, "transform": [ { "translateX": 8, @@ -201,10 +201,10 @@ exports[`Switch render renders disabled off 1`] = ` "borderColor": "rgba(98, 91, 113, 1)", "borderRadius": 9999, "borderWidth": 3, - "bottom": -1, + "bottom": 3, "left": -5, "right": -5, - "top": -1, + "top": 3, }, { "opacity": 0, @@ -221,7 +221,7 @@ exports[`Switch render renders disabled on 1`] = ` [ { "alignItems": "center", - "height": 40, + "height": 48, "justifyContent": "center", "overflow": "visible", "width": 52, @@ -265,7 +265,7 @@ exports[`Switch render renders disabled on 1`] = ` [ { "alignItems": "center", - "height": 40, + "height": 48, "justifyContent": "center", "width": 52, }, @@ -299,7 +299,7 @@ exports[`Switch render renders disabled on 1`] = ` "height": 40, "pointerEvents": "none", "position": "absolute", - "top": 0, + "top": 4, "width": 40, }, { @@ -334,7 +334,7 @@ exports[`Switch render renders disabled on 1`] = ` }, { "height": 24, - "top": 8, + "top": 12, "transform": [ { "translateX": 24, @@ -396,10 +396,10 @@ exports[`Switch render renders disabled on 1`] = ` "borderColor": "rgba(98, 91, 113, 1)", "borderRadius": 9999, "borderWidth": 3, - "bottom": -1, + "bottom": 3, "left": -5, "right": -5, - "top": -1, + "top": 3, }, { "opacity": 0, @@ -416,7 +416,7 @@ exports[`Switch render renders off 1`] = ` [ { "alignItems": "center", - "height": 40, + "height": 48, "justifyContent": "center", "overflow": "visible", "width": 52, @@ -460,7 +460,7 @@ exports[`Switch render renders off 1`] = ` [ { "alignItems": "center", - "height": 40, + "height": 48, "justifyContent": "center", "width": 52, }, @@ -514,7 +514,7 @@ exports[`Switch render renders off 1`] = ` "height": 40, "pointerEvents": "none", "position": "absolute", - "top": 0, + "top": 4, "width": 40, }, { @@ -549,7 +549,7 @@ exports[`Switch render renders off 1`] = ` }, { "height": 16, - "top": 12, + "top": 16, "transform": [ { "translateX": 8, @@ -594,10 +594,10 @@ exports[`Switch render renders off 1`] = ` "borderColor": "rgba(98, 91, 113, 1)", "borderRadius": 9999, "borderWidth": 3, - "bottom": -1, + "bottom": 3, "left": -5, "right": -5, - "top": -1, + "top": 3, }, { "opacity": 0, @@ -614,7 +614,7 @@ exports[`Switch render renders on 1`] = ` [ { "alignItems": "center", - "height": 40, + "height": 48, "justifyContent": "center", "overflow": "visible", "width": 52, @@ -658,7 +658,7 @@ exports[`Switch render renders on 1`] = ` [ { "alignItems": "center", - "height": 40, + "height": 48, "justifyContent": "center", "width": 52, }, @@ -692,7 +692,7 @@ exports[`Switch render renders on 1`] = ` "height": 40, "pointerEvents": "none", "position": "absolute", - "top": 0, + "top": 4, "width": 40, }, { @@ -727,7 +727,7 @@ exports[`Switch render renders on 1`] = ` }, { "height": 24, - "top": 8, + "top": 12, "transform": [ { "translateX": 24, @@ -772,10 +772,10 @@ exports[`Switch render renders on 1`] = ` "borderColor": "rgba(98, 91, 113, 1)", "borderRadius": 9999, "borderWidth": 3, - "bottom": -1, + "bottom": 3, "left": -5, "right": -5, - "top": -1, + "top": 3, }, { "opacity": 0, @@ -792,7 +792,7 @@ exports[`Switch render renders with checked icon 1`] = ` [ { "alignItems": "center", - "height": 40, + "height": 48, "justifyContent": "center", "overflow": "visible", "width": 52, @@ -836,7 +836,7 @@ exports[`Switch render renders with checked icon 1`] = ` [ { "alignItems": "center", - "height": 40, + "height": 48, "justifyContent": "center", "width": 52, }, @@ -870,7 +870,7 @@ exports[`Switch render renders with checked icon 1`] = ` "height": 40, "pointerEvents": "none", "position": "absolute", - "top": 0, + "top": 4, "width": 40, }, { @@ -905,7 +905,7 @@ exports[`Switch render renders with checked icon 1`] = ` }, { "height": 24, - "top": 8, + "top": 12, "transform": [ { "translateX": 24, @@ -946,7 +946,7 @@ exports[`Switch render renders with checked icon 1`] = ` "height": 16, "pointerEvents": "none", "position": "absolute", - "top": 12, + "top": 16, "width": 16, }, { @@ -1021,10 +1021,10 @@ exports[`Switch render renders with checked icon 1`] = ` "borderColor": "rgba(98, 91, 113, 1)", "borderRadius": 9999, "borderWidth": 3, - "bottom": -1, + "bottom": 3, "left": -5, "right": -5, - "top": -1, + "top": 3, }, { "opacity": 0, @@ -1041,7 +1041,7 @@ exports[`Switch render renders with per-state icons 1`] = ` [ { "alignItems": "center", - "height": 40, + "height": 48, "justifyContent": "center", "overflow": "visible", "width": 52, @@ -1085,7 +1085,7 @@ exports[`Switch render renders with per-state icons 1`] = ` [ { "alignItems": "center", - "height": 40, + "height": 48, "justifyContent": "center", "width": 52, }, @@ -1119,7 +1119,7 @@ exports[`Switch render renders with per-state icons 1`] = ` "height": 40, "pointerEvents": "none", "position": "absolute", - "top": 0, + "top": 4, "width": 40, }, { @@ -1154,7 +1154,7 @@ exports[`Switch render renders with per-state icons 1`] = ` }, { "height": 24, - "top": 8, + "top": 12, "transform": [ { "translateX": 24, @@ -1195,7 +1195,7 @@ exports[`Switch render renders with per-state icons 1`] = ` "height": 16, "pointerEvents": "none", "position": "absolute", - "top": 12, + "top": 16, "width": 16, }, { @@ -1270,10 +1270,10 @@ exports[`Switch render renders with per-state icons 1`] = ` "borderColor": "rgba(98, 91, 113, 1)", "borderRadius": 9999, "borderWidth": 3, - "bottom": -1, + "bottom": 3, "left": -5, "right": -5, - "top": -1, + "top": 3, }, { "opacity": 0, From b6eaefe4c3656a2cef1e8593c77138a53d08914d Mon Sep 17 00:00:00 2001 From: likevy Date: Thu, 3 Sep 2026 12:43:19 +0200 Subject: [PATCH 04/29] fix: grow the switch handle immediately on press --- src/components/Switch/Switch.tsx | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/src/components/Switch/Switch.tsx b/src/components/Switch/Switch.tsx index 5db255da20..60c04b2c21 100644 --- a/src/components/Switch/Switch.tsx +++ b/src/components/Switch/Switch.tsx @@ -15,7 +15,6 @@ import Animated, { useAnimatedStyle, useDerivedValue, useSharedValue, - withDelay, withSequence, withSpring, withTiming, @@ -92,10 +91,6 @@ const FOCUS_RING_INSET = -(FOCUS_OUTER_OFFSET + FOCUS_THICKNESS); const centreY = (size: number) => (TOUCH_TARGET_SIZE - size) / 2; const TRACK_TOP = centreY(TRACK_HEIGHT); -// Hold-then-grow: a brief delay before snapping to PRESSED_HANDLE so a quick -// tap doesn't flash the press-grow visual. -const PRESS_GROW_DELAY = 100; - function restingHandleSize(checked: boolean, hasIcon: boolean): number { if (hasIcon) return ICON_HANDLE; return checked ? SELECTED_HANDLE : UNSELECTED_HANDLE; @@ -234,14 +229,10 @@ const Switch = ({ ({ p, c, hi }) => { const restingSize = hi === 1 ? ICON_HANDLE : c === 1 ? SELECTED_HANDLE : UNSELECTED_HANDLE; - if (p === 1) { - handleSize.value = withDelay( - PRESS_GROW_DELAY, - withTiming(PRESSED_HANDLE, { duration: 0 }) - ); - } else { - handleSize.value = withSpring(restingSize, springConfig); - } + handleSize.value = withSpring( + p === 1 ? PRESSED_HANDLE : restingSize, + springConfig + ); }, [springConfig] ); From e7799a364e05df3ca39f651b538a3374888fcae7 Mon Sep 17 00:00:00 2001 From: likevy Date: Thu, 3 Sep 2026 12:43:28 +0200 Subject: [PATCH 05/29] feat(switch): render a switch without onValueChange as read-only BREAKING CHANGE: a `Switch` with no `onValueChange` that is neither `readOnly` nor `disabled` now renders read-only and logs a warning. `Switch` reserves a 48dp tall touch target. --- docs/6.x/docs/guides/migration.md | 36 ++++++ example/src/DrawerItems.tsx | 12 +- example/src/Examples/FABExample.tsx | 2 +- example/src/Examples/SwitchExample.tsx | 4 + example/src/Examples/TextInputExample.tsx | 2 +- src/components/Switch/Switch.tsx | 110 +++++++++++++----- src/components/__tests__/Switch.test.tsx | 70 ++++++++++- .../__snapshots__/Switch.test.tsx.snap | 10 +- src/index.tsx | 2 +- 9 files changed, 205 insertions(+), 43 deletions(-) diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index 72aee6d00f..8996654c3e 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -573,3 +573,39 @@ mutually exclusive options, use `SegmentedButtons`. /> ``` + +### Switch + +#### Explicit operability + +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. + +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. + +```tsx +// Before (v5) — an enabled switch that does nothing + + +// After (v6) — say which it is + + + +``` + +This most often shows up where the switch sits inside a row that owns the press: + +```tsx +// After (v6) + + + Dark Theme + + + + + +``` + +#### Touch target height + +`Switch` now reserves the 48dp minimum touch target Material Design requires, so it occupies 48dp of height instead of 40dp. Nothing painted changed size — the track is still 52×32 — but rows containing a switch may become slightly taller. diff --git a/example/src/DrawerItems.tsx b/example/src/DrawerItems.tsx index 7e39596f9b..1ec070eb5a 100644 --- a/example/src/DrawerItems.tsx +++ b/example/src/DrawerItems.tsx @@ -176,7 +176,7 @@ function DrawerItems() { Use Dynamic Theme - + @@ -185,7 +185,7 @@ function DrawerItems() { Dark Theme - + @@ -194,7 +194,7 @@ function DrawerItems() { RTL - + @@ -203,7 +203,7 @@ function DrawerItems() { Collapsed drawer * - + @@ -212,7 +212,7 @@ function DrawerItems() { Custom font * - + @@ -223,7 +223,7 @@ function DrawerItems() { {Platform.OS === 'ios' ? 'Highlight' : 'Ripple'} effect * - + diff --git a/example/src/Examples/FABExample.tsx b/example/src/Examples/FABExample.tsx index 09ce39aa14..1b8c3d61f9 100644 --- a/example/src/Examples/FABExample.tsx +++ b/example/src/Examples/FABExample.tsx @@ -154,7 +154,7 @@ const FABExample = () => { title="Show FAB" right={() => ( - + )} onPress={() => setShowFab((v) => !v)} diff --git a/example/src/Examples/SwitchExample.tsx b/example/src/Examples/SwitchExample.tsx index 545dbf8dd3..981013d44c 100644 --- a/example/src/Examples/SwitchExample.tsx +++ b/example/src/Examples/SwitchExample.tsx @@ -88,6 +88,10 @@ const SwitchExample = () => { /> + + + + { {label} - + diff --git a/src/components/Switch/Switch.tsx b/src/components/Switch/Switch.tsx index 60c04b2c21..32fddfecd1 100644 --- a/src/components/Switch/Switch.tsx +++ b/src/components/Switch/Switch.tsx @@ -3,7 +3,9 @@ import { Platform, Pressable, StyleSheet, + type NativeSyntheticEvent, type StyleProp, + type TargetedEvent, View, type ViewStyle, } from 'react-native'; @@ -32,19 +34,11 @@ import type { StateOpacityKey, ThemeProp } from '../../theme/types'; import { isKeyboardFocusEvent } from '../../utils/isKeyboardFocusEvent'; import Icon, { type IconSource } from '../Icon'; -export type Props = { +type SwitchBaseProps = { /** * Whether the switch is on. */ value?: boolean; - /** - * Called with the new value when the user toggles the switch. - */ - onValueChange?: (value: boolean) => void; - /** - * Disables interaction and renders the disabled visual state. - */ - disabled?: boolean; /** * Icon shown inside the handle when checked */ @@ -62,6 +56,45 @@ export type Props = { 'aria-label'?: string; }; +export type Props = SwitchBaseProps & { + /** + * Called with the new value when the user toggles the switch. Required + * unless the switch is `readOnly` or `disabled`. + */ + onValueChange?: (value: boolean) => void; + /** + * Reports state the user cannot change here. The switch keeps its enabled + * appearance and is still announced by a screen reader, but it is neither + * focusable nor pressable. + */ + readOnly?: boolean; + /** + * Disables interaction and renders the disabled visual state. + */ + disabled?: boolean; +}; + +/** + * `Props`, narrowed so a switch always declares how it can be operated and can + * never render as an enabled control that does nothing when activated. + * + * This is applied to the component's declared type rather than folded into + * `Props`, because the docs generator reads props off the parameter annotation + * and silently drops a union (a top-level one drops the whole page). So the + * parameter below stays annotated as the flat `Props`, and callers still get + * the narrowed type through this one. + */ +export type OperableProps = SwitchBaseProps & + ( + | { + onValueChange: (value: boolean) => void; + readOnly?: false; + disabled?: boolean; + } + | { onValueChange?: never; readOnly: true; disabled?: boolean } + | { onValueChange?: never; readOnly?: false; disabled: true } + ); + const { trackWidth: TRACK_WIDTH, trackHeight: TRACK_HEIGHT, @@ -126,9 +159,10 @@ const CHECKED_CENTER = TRACK_WIDTH - HANDLE_PADDING - SELECTED_HANDLE / 2; * - `surface`: disabled selected handle * - `secondary`: focus indicator */ -const Switch = ({ +const Switch: (props: OperableProps) => React.JSX.Element = ({ value, disabled, + readOnly, onValueChange, checkedIcon, uncheckedIcon, @@ -145,6 +179,18 @@ const Switch = ({ const checked = !!value; const isDisabled = !!disabled; const isEnabled = !isDisabled; + const isReadOnly = !!readOnly; + // `OperableProps` already requires one of these, but untyped callers can slip + // past it, so guard here too rather than rendering an enabled no-op. + const isMissingOperability = !onValueChange && !isReadOnly && !isDisabled; + + if (isMissingOperability) { + console.warn( + 'Switch: pass `onValueChange` to make the switch operable, or set `readOnly` or `disabled` to render it as a state indicator.' + ); + } + + const isInteractive = !isDisabled && !isReadOnly && !isMissingOperability; const iconSource = checked ? checkedIcon : uncheckedIcon; const hasIcon = iconSource !== undefined; @@ -341,30 +387,40 @@ const Switch = ({ const trackOpacityValue = isDisabled ? DISABLED_TRACK_OPACITY : 1; const iconSize = checked ? SELECTED_ICON : UNSELECTED_ICON; - return ( - - onValueChange?.(!checked)} - onPressIn={() => { + // A non-interactive switch gets no press, hover, or focus affordances at all. + // It stays in the accessibility tree, so its state is still announced. + const interactionProps = isInteractive + ? { + onPress: () => onValueChange?.(!checked), + onPressIn: () => { pressedSV.value = 1; - }} - onPressOut={() => { + }, + onPressOut: () => { pressedSV.value = 0; - }} - onHoverIn={() => { + }, + onHoverIn: () => { hoveredSV.value = 1; - }} - onHoverOut={() => { + }, + onHoverOut: () => { hoveredSV.value = 0; - }} - onFocus={(e) => { + }, + onFocus: (e: NativeSyntheticEvent) => { if (!isKeyboardFocusEvent(e)) return; focusedSV.value = 1; - }} - onBlur={() => { + }, + onBlur: () => { focusedSV.value = 0; - }} + }, + } + : null; + + return ( + + { it('renders on', async () => { - expect((await render()).toJSON()).toMatchSnapshot(); + expect( + (await render()).toJSON() + ).toMatchSnapshot(); }); it('renders off', async () => { - expect((await render()).toJSON()).toMatchSnapshot(); + expect( + ( + await render() + ).toJSON() + ).toMatchSnapshot(); }); it('renders disabled on', async () => { @@ -31,14 +37,25 @@ describe('Switch render', () => { it('renders with checked icon', async () => { expect( - (await render()).toJSON() + ( + await render( + + ) + ).toJSON() ).toMatchSnapshot(); }); it('renders with per-state icons', async () => { expect( ( - await render() + await render( + + ) ).toJSON() ).toMatchSnapshot(); }); @@ -46,7 +63,7 @@ describe('Switch render', () => { describe('Switch accessibility', () => { it('has switch role', async () => { - await render(); + await render(); expect(screen.getByRole('switch')).toBeOnTheScreen(); }); @@ -109,6 +126,49 @@ describe('Switch focus state', () => { }); }); +describe('Switch operability', () => { + it('warns when it is given no way to be operated', async () => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + + // @ts-expect-error -- the props type requires a handler, `readOnly`, or + // `disabled`; this covers untyped callers that get past it. + await render(); + + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('onValueChange') + ); + + jest.restoreAllMocks(); + }); + + it('is not focusable when it has no way to be operated', async () => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + + // @ts-expect-error -- see above. + await render(); + + expect(screen.getByRole('switch')).toHaveProp('focusable', false); + + jest.restoreAllMocks(); + }); + + it('announces a read-only switch without making it focusable', async () => { + await render(); + + const control = screen.getByRole('switch'); + + expect(control).toHaveProp('focusable', false); + expect(control).toBeChecked(); + expect(control).toBeEnabled(); + }); + + it('keeps an interactive switch focusable', async () => { + await render(); + + expect(screen.getByRole('switch')).toHaveProp('focusable', true); + }); +}); + describe('Switch interaction', () => { it('toggles to true when off and pressed', async () => { const user = userEvent.setup(); diff --git a/src/components/__tests__/__snapshots__/Switch.test.tsx.snap b/src/components/__tests__/__snapshots__/Switch.test.tsx.snap index 00f6a476ba..45aed5023f 100644 --- a/src/components/__tests__/__snapshots__/Switch.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Switch.test.tsx.snap @@ -34,8 +34,9 @@ exports[`Switch render renders disabled off 1`] = ` } } accessible={true} + aria-readonly={false} collapsable={false} - focusable={true} + focusable={false} onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -249,8 +250,9 @@ exports[`Switch render renders disabled on 1`] = ` } } accessible={true} + aria-readonly={false} collapsable={false} - focusable={true} + focusable={false} onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -444,6 +446,7 @@ exports[`Switch render renders off 1`] = ` } } accessible={true} + aria-readonly={false} collapsable={false} focusable={true} onBlur={[Function]} @@ -642,6 +645,7 @@ exports[`Switch render renders on 1`] = ` } } accessible={true} + aria-readonly={false} collapsable={false} focusable={true} onBlur={[Function]} @@ -820,6 +824,7 @@ exports[`Switch render renders with checked icon 1`] = ` } } accessible={true} + aria-readonly={false} collapsable={false} focusable={true} onBlur={[Function]} @@ -1069,6 +1074,7 @@ exports[`Switch render renders with per-state icons 1`] = ` } } accessible={true} + aria-readonly={false} collapsable={false} focusable={true} onBlur={[Function]} diff --git a/src/index.tsx b/src/index.tsx index 1807d2f249..b8bacf9f47 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -126,7 +126,7 @@ export type { Props as RadioButtonItemProps } from './components/RadioButton/Rad export type { Props as SearchbarProps } from './components/Searchbar'; export type { Props as SnackbarProps } from './components/Snackbar'; export type { Props as SurfaceProps } from './components/Surface'; -export type { Props as SwitchProps } from './components/Switch/Switch'; +export type { OperableProps as SwitchProps } from './components/Switch/Switch'; export type { TextInputProps, TextInputRenderProps, From 827fea89fe1e392856b55ee39202e81a9052b2ce Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 4 Sep 2026 11:54:11 +0200 Subject: [PATCH 06/29] fix: allow readOnly to be set dynamically on a switch The handler branch of the union only accepted `readOnly={false}`, so a switch with a stable handler could not become read-only from state, even though the runtime already gives `readOnly` precedence and `disabled` allowed exactly that pattern. The branch that requires `disabled` was equally strict. A handler still guarantees operability whenever read-only is false, and a switch with neither a handler nor a statically-true `readOnly`/`disabled` is still rejected. --- src/components/Switch/Switch.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Switch/Switch.tsx b/src/components/Switch/Switch.tsx index 32fddfecd1..4b9d232d83 100644 --- a/src/components/Switch/Switch.tsx +++ b/src/components/Switch/Switch.tsx @@ -88,11 +88,11 @@ export type OperableProps = SwitchBaseProps & ( | { onValueChange: (value: boolean) => void; - readOnly?: false; + readOnly?: boolean; disabled?: boolean; } | { onValueChange?: never; readOnly: true; disabled?: boolean } - | { onValueChange?: never; readOnly?: false; disabled: true } + | { onValueChange?: never; readOnly?: boolean; disabled: true } ); const { From 9bc307689176063cf9ad4507b6097fd99d07f4a4 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 4 Sep 2026 11:54:35 +0200 Subject: [PATCH 07/29] fix: clear switch interaction state when it becomes non-interactive Interaction handlers are withheld while a switch is disabled, read-only, or missing a handler, so one that was hovered, pressed, or focused at the moment it flipped never received the matching hover-out, press-out, or blur. The shared value stayed at 1 and the switch kept painting that state -- a disabled switch could show a focus ring, and the stale value was still there if it became interactive again. --- src/components/Switch/Switch.tsx | 11 +++++++++++ src/components/__tests__/Switch.test.tsx | 15 +++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/components/Switch/Switch.tsx b/src/components/Switch/Switch.tsx index 4b9d232d83..63c8890570 100644 --- a/src/components/Switch/Switch.tsx +++ b/src/components/Switch/Switch.tsx @@ -210,6 +210,17 @@ const Switch: (props: OperableProps) => React.JSX.Element = ({ isDisabledSV.value = isDisabled ? 1 : 0; }, [checked, hasIcon, isDisabled, checkedSV, hasIconSV, isDisabledSV]); + // A switch that stops being interactive loses its handlers, so one that was + // hovered, pressed, or focused at that moment would never get the matching + // hover-out, press-out, or blur and would keep painting that state. + React.useEffect(() => { + if (isInteractive) return; + + pressedSV.value = 0; + hoveredSV.value = 0; + focusedSV.value = 0; + }, [isInteractive, pressedSV, hoveredSV, focusedSV]); + const colors = React.useMemo(() => getDefaultSwitchColors(theme), [theme]); const reanimatedReduceMotion = reduceMotion diff --git a/src/components/__tests__/Switch.test.tsx b/src/components/__tests__/Switch.test.tsx index 36ce7bafcd..073620c4d2 100644 --- a/src/components/__tests__/Switch.test.tsx +++ b/src/components/__tests__/Switch.test.tsx @@ -115,6 +115,21 @@ describe('Switch focus state', () => { }); }); + it('clears the focus state when the switch stops being interactive', async () => { + const view = await render( + + ); + + await fireEvent(screen.getByTestId('switch'), 'focus'); + await jest.runAllTimersAsync(); + expect(animatedStyle('switch-focus-ring')).toMatchObject({ opacity: 1 }); + + await view.rerender(); + await jest.runAllTimersAsync(); + + expect(animatedStyle('switch-focus-ring')).toMatchObject({ opacity: 0 }); + }); + it('paints the focus handle color when selected and focused', async () => { await renderAndFocus( From ae2bad11b3cee9a12e760b0ba4c07adacb89f60f Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 4 Sep 2026 11:55:18 +0200 Subject: [PATCH 08/29] test: assert aria-readonly on a read-only switch The read-only test covered focusability and checked state but never the attribute the read-only contract exists for on web, so dropping it left the test green. --- src/components/__tests__/Switch.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/__tests__/Switch.test.tsx b/src/components/__tests__/Switch.test.tsx index 073620c4d2..0cf36795b1 100644 --- a/src/components/__tests__/Switch.test.tsx +++ b/src/components/__tests__/Switch.test.tsx @@ -173,6 +173,7 @@ describe('Switch operability', () => { const control = screen.getByRole('switch'); expect(control).toHaveProp('focusable', false); + expect(control).toHaveProp('aria-readonly', true); expect(control).toBeChecked(); expect(control).toBeEnabled(); }); From e331223a7a8eff7b062d0ea43d96d7a6b917a0cb Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 4 Sep 2026 12:49:37 +0200 Subject: [PATCH 09/29] fix: announce a switch with no way to be operated as read-only The runtime fallback for an untyped `` withheld the interaction handlers but still emitted `aria-readonly={false}` alongside `aria-disabled={false}`, so on web it was still exposed as an enabled switch rather than the state indicator the guard renders. Read-only is now derived from the rendered state -- non-operable but not disabled -- which covers both an explicit `readOnly` and the fallback, while `aria-disabled` keeps carrying the disabled case on its own. --- src/components/Switch/Switch.tsx | 6 +++++- src/components/__tests__/Switch.test.tsx | 23 +++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/components/Switch/Switch.tsx b/src/components/Switch/Switch.tsx index 63c8890570..f67f032b55 100644 --- a/src/components/Switch/Switch.tsx +++ b/src/components/Switch/Switch.tsx @@ -191,6 +191,10 @@ const Switch: (props: OperableProps) => React.JSX.Element = ({ } const isInteractive = !isDisabled && !isReadOnly && !isMissingOperability; + // Non-operable but not disabled: an explicit `readOnly`, or the fallback for + // a missing handler. Both render as state indicators, so both are announced + // that way; `aria-disabled` carries the disabled case on its own. + const isAnnouncedReadOnly = !isDisabled && !isInteractive; const iconSource = checked ? checkedIcon : uncheckedIcon; const hasIcon = iconSource !== undefined; @@ -430,7 +434,7 @@ const Switch: (props: OperableProps) => React.JSX.Element = ({ { expect(control).toBeEnabled(); }); + it('announces a switch with no way to be operated as read-only', async () => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + + // @ts-expect-error -- the fallback exists for untyped callers. + await render(); + + const control = screen.getByRole('switch'); + + expect(control).toHaveProp('aria-readonly', true); + expect(control).toBeEnabled(); + + jest.restoreAllMocks(); + }); + + it('does not announce a disabled switch as read-only', async () => { + await render(); + + const control = screen.getByRole('switch'); + + expect(control).toHaveProp('aria-readonly', false); + expect(control).toBeDisabled(); + }); + it('keeps an interactive switch focusable', async () => { await render(); From c91a84a2d48995e39f24e73cf450e2deb8b03638 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 4 Sep 2026 12:50:02 +0200 Subject: [PATCH 10/29] test: cover pointer focus on web leaving the indicator hidden The focus tests only fired a generic focus event on the default native platform, where `isKeyboardFocusEvent` always returns true, so nothing covered the `:focus-visible` gating that keeps a mouse click from lighting the ring. Adds both web paths, driving `currentTarget.matches` directly. --- src/components/__tests__/Switch.test.tsx | 39 ++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/src/components/__tests__/Switch.test.tsx b/src/components/__tests__/Switch.test.tsx index 8652608c5b..b87ea6cfc3 100644 --- a/src/components/__tests__/Switch.test.tsx +++ b/src/components/__tests__/Switch.test.tsx @@ -1,4 +1,5 @@ import type * as React from 'react'; +import { Platform } from 'react-native'; import { describe, expect, it, jest } from '@jest/globals'; import * as Reanimated from 'react-native-reanimated'; @@ -8,6 +9,9 @@ import { fireEvent, render, screen, userEvent } from '../../test-utils'; import { tokens } from '../../theme/tokens'; import Switch from '../Switch/Switch'; +const animatedStyle = (testID: string) => + Reanimated.getAnimatedStyle(screen.getByTestId(testID)); + describe('Switch render', () => { it('renders on', async () => { expect( @@ -83,9 +87,6 @@ describe('Switch focus state', () => { await jest.runAllTimersAsync(); }; - const animatedStyle = (testID: string) => - Reanimated.getAnimatedStyle(screen.getByTestId(testID)); - it('shows the focus indicator on keyboard focus', async () => { await renderAndFocus( @@ -115,6 +116,38 @@ describe('Switch focus state', () => { }); }); + it('leaves the indicator hidden for pointer focus on web', async () => { + jest.replaceProperty(Platform, 'OS', 'web'); + + await render( + + ); + await fireEvent(screen.getByTestId('switch'), 'focus', { + currentTarget: { matches: () => false }, + }); + await jest.runAllTimersAsync(); + + expect(animatedStyle('switch-focus-ring')).toMatchObject({ opacity: 0 }); + + jest.restoreAllMocks(); + }); + + it('shows the indicator for keyboard focus on web', async () => { + jest.replaceProperty(Platform, 'OS', 'web'); + + await render( + + ); + await fireEvent(screen.getByTestId('switch'), 'focus', { + currentTarget: { matches: () => true }, + }); + await jest.runAllTimersAsync(); + + expect(animatedStyle('switch-focus-ring')).toMatchObject({ opacity: 1 }); + + jest.restoreAllMocks(); + }); + it('clears the focus state when the switch stops being interactive', async () => { const view = await render( From bda2160b846e85b69e4d8e8d454b6bb4d0140174 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 4 Sep 2026 12:50:21 +0200 Subject: [PATCH 11/29] test: cover the switch handle growing on press Removing the 100ms delay was a user-visible fix with no test behind it, so the delay could have come back unnoticed. Asserting the size needs a testID on the view that carries it: `-handle` now names the view holding the handle's size and position, and the tinted fill inside it becomes `-handle-fill`. --- src/components/Switch/Switch.tsx | 7 +++++-- src/components/__tests__/Switch.test.tsx | 26 +++++++++++++++++++++++- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/components/Switch/Switch.tsx b/src/components/Switch/Switch.tsx index f67f032b55..7b8694dbab 100644 --- a/src/components/Switch/Switch.tsx +++ b/src/components/Switch/Switch.tsx @@ -469,7 +469,10 @@ const Switch: (props: OperableProps) => React.JSX.Element = ({ ]} /> - + {/* Disabled-only: opaque `surface` backdrop. The tinted fill above composites over it, reproducing the native math avoiding the PlatformColor alpha limitation. */} {isDisabled ? ( @@ -481,7 +484,7 @@ const Switch: (props: OperableProps) => React.JSX.Element = ({ /> ) : null} { ); - expect(animatedStyle('switch-handle')).toMatchObject({ + expect(animatedStyle('switch-handle-fill')).toMatchObject({ backgroundColor: defaultThemes.light.colors.primaryContainer, }); }); @@ -241,6 +241,30 @@ describe('Switch operability', () => { }); }); +describe('Switch press feedback', () => { + it('grows the handle to the pressed size and back on release', async () => { + await render(); + + await fireEvent(screen.getByTestId('switch'), 'pressIn'); + await jest.runAllTimersAsync(); + + // MD3 grows the handle to 28dp while pressed, from a 24dp selected resting + // size. This has to be immediate -- a delay swallows short taps. + expect(animatedStyle('switch-handle')).toMatchObject({ + width: 28, + height: 28, + }); + + await fireEvent(screen.getByTestId('switch'), 'pressOut'); + await jest.runAllTimersAsync(); + + expect(animatedStyle('switch-handle')).toMatchObject({ + width: 24, + height: 24, + }); + }); +}); + describe('Switch interaction', () => { it('toggles to true when off and pressed', async () => { const user = userEvent.setup(); From 4d57cf81177167ebdd58a63695c0d361319f2eb6 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 4 Sep 2026 13:05:55 +0200 Subject: [PATCH 12/29] fix: override onPrimaryContainer in the tertiary switch example Moving the selected icon to `onPrimaryContainer` left the tertiary-theme example with a mixed palette: it overrides `primary`, `onPrimary` and `primaryContainer`, and a partial theme falls back to the app theme for omitted roles, so the icon stayed in the default palette. Documents the role change in the migration guide too, since any partial `Switch` theme in consumer code has the same gap. --- docs/6.x/docs/guides/migration.md | 15 +++++++++++++++ example/src/Examples/SwitchExample.tsx | 1 + 2 files changed, 16 insertions(+) diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index 8996654c3e..2f5ad1f8e7 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -606,6 +606,21 @@ This most often shows up where the switch sits inside a row that owns the press: ``` +#### Selected icon color + +The icon inside a selected switch now uses `onPrimaryContainer` instead of `primary`, matching the Material Design 3 spec and improving its contrast against the handle. If you theme `Switch` with a partial `theme.colors` override, add `onPrimaryContainer` alongside the roles you already override — omitted roles fall back to the app theme, which would leave the icon in the default palette. + +```diff + const switchTheme = { + colors: { + primary: theme.colors.tertiary, + onPrimary: theme.colors.onTertiary, + primaryContainer: theme.colors.tertiaryContainer, ++ onPrimaryContainer: theme.colors.onTertiaryContainer, + }, + }; +``` + #### Touch target height `Switch` now reserves the 48dp minimum touch target Material Design requires, so it occupies 48dp of height instead of 40dp. Nothing painted changed size — the track is still 52×32 — but rows containing a switch may become slightly taller. diff --git a/example/src/Examples/SwitchExample.tsx b/example/src/Examples/SwitchExample.tsx index 981013d44c..09a3c7ec92 100644 --- a/example/src/Examples/SwitchExample.tsx +++ b/example/src/Examples/SwitchExample.tsx @@ -33,6 +33,7 @@ const SwitchExample = () => { primary: theme.colors.tertiary, onPrimary: theme.colors.onTertiary, primaryContainer: theme.colors.tertiaryContainer, + onPrimaryContainer: theme.colors.onTertiaryContainer, secondary: theme.colors.tertiary, }, }), From 97c8e11c186da92a8cc6a1d095ba847df0447e76 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 4 Sep 2026 13:06:15 +0200 Subject: [PATCH 13/29] test: assert a read-only switch does not toggle The read-only tests checked the exposed metadata but never the guarantee it stands for. The props type deliberately permits `readOnly` alongside `onValueChange`, so read-only has to win at runtime -- and nothing would have caught the handler being attached anyway. --- src/components/__tests__/Switch.test.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/components/__tests__/Switch.test.tsx b/src/components/__tests__/Switch.test.tsx index b9bf0fafd2..1d0aa73432 100644 --- a/src/components/__tests__/Switch.test.tsx +++ b/src/components/__tests__/Switch.test.tsx @@ -282,6 +282,19 @@ describe('Switch interaction', () => { expect(onValueChange).toHaveBeenCalledWith(false); }); + it('does not toggle a read-only switch that still has a handler', async () => { + const user = userEvent.setup(); + const onValueChange = jest.fn(); + // The props type permits this pairing, so read-only has to win at runtime. + await render( + + ); + + await user.press(screen.getByRole('switch')); + + expect(onValueChange).not.toHaveBeenCalled(); + }); + it('does not fire onValueChange when disabled', async () => { const user = userEvent.setup(); const onValueChange = jest.fn(); From 5ba4126aa741112794a9e4cf0ffd0a0cd4facb84 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 4 Sep 2026 13:06:36 +0200 Subject: [PATCH 14/29] test: catch a reintroduced delay in the switch press growth The press test flushed all timers before asserting, so restoring the old `withDelay(100, ...)` snap still reached the pressed size and the test passed. It verified the end state, not the immediacy it was written for. Now asserts the handle has already started growing two frames in, well inside the delay the old implementation waited out, and keeps a separate case for the settled sizes on press and release. --- src/components/__tests__/Switch.test.tsx | 33 ++++++++++++++---------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/src/components/__tests__/Switch.test.tsx b/src/components/__tests__/Switch.test.tsx index 1d0aa73432..9b75c9668f 100644 --- a/src/components/__tests__/Switch.test.tsx +++ b/src/components/__tests__/Switch.test.tsx @@ -242,26 +242,33 @@ describe('Switch operability', () => { }); describe('Switch press feedback', () => { - it('grows the handle to the pressed size and back on release', async () => { + // MD3 resting (selected) and pressed handle sizes. + const RESTING = 24; + const PRESSED = 28; + const handleWidth = () => Number(animatedStyle('switch-handle').width); + + it('starts growing the handle immediately on press', async () => { await render(); + expect(handleWidth()).toBe(RESTING); await fireEvent(screen.getByTestId('switch'), 'pressIn'); - await jest.runAllTimersAsync(); + // Two frames in -- well inside the 100ms delay the old implementation + // waited out before snapping, so a reintroduced delay still reads RESTING. + jest.advanceTimersByTime(32); - // MD3 grows the handle to 28dp while pressed, from a 24dp selected resting - // size. This has to be immediate -- a delay swallows short taps. - expect(animatedStyle('switch-handle')).toMatchObject({ - width: 28, - height: 28, - }); + expect(handleWidth()).toBeGreaterThan(RESTING); + }); - await fireEvent(screen.getByTestId('switch'), 'pressOut'); + it('settles at the pressed size and returns on release', async () => { + await render(); + + await fireEvent(screen.getByTestId('switch'), 'pressIn'); await jest.runAllTimersAsync(); + expect(handleWidth()).toBe(PRESSED); - expect(animatedStyle('switch-handle')).toMatchObject({ - width: 24, - height: 24, - }); + await fireEvent(screen.getByTestId('switch'), 'pressOut'); + await jest.runAllTimersAsync(); + expect(handleWidth()).toBe(RESTING); }); }); From 8fd107cf9bf9764ca2dc175f92609f75b9258de9 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 4 Sep 2026 13:11:57 +0200 Subject: [PATCH 15/29] test: restore mocks in afterEach so a failure cannot leak Each test that overrode `Platform.OS` or stubbed `console.warn` restored it on the last line, which never runs when an assertion throws. A single failing test left every later test in the file running as web, turning one real failure into a cascade of misleading ones. Matches the teardown FABExtended and Surface already use. --- src/components/__tests__/Switch.test.tsx | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src/components/__tests__/Switch.test.tsx b/src/components/__tests__/Switch.test.tsx index 9b75c9668f..51cf09e13a 100644 --- a/src/components/__tests__/Switch.test.tsx +++ b/src/components/__tests__/Switch.test.tsx @@ -1,7 +1,7 @@ import type * as React from 'react'; import { Platform } from 'react-native'; -import { describe, expect, it, jest } from '@jest/globals'; +import { afterEach, describe, expect, it, jest } from '@jest/globals'; import * as Reanimated from 'react-native-reanimated'; import { defaultThemes } from '../../core/theming'; @@ -12,6 +12,10 @@ import Switch from '../Switch/Switch'; const animatedStyle = (testID: string) => Reanimated.getAnimatedStyle(screen.getByTestId(testID)); +afterEach(() => { + jest.restoreAllMocks(); +}); + describe('Switch render', () => { it('renders on', async () => { expect( @@ -128,8 +132,6 @@ describe('Switch focus state', () => { await jest.runAllTimersAsync(); expect(animatedStyle('switch-focus-ring')).toMatchObject({ opacity: 0 }); - - jest.restoreAllMocks(); }); it('shows the indicator for keyboard focus on web', async () => { @@ -144,8 +146,6 @@ describe('Switch focus state', () => { await jest.runAllTimersAsync(); expect(animatedStyle('switch-focus-ring')).toMatchObject({ opacity: 1 }); - - jest.restoreAllMocks(); }); it('clears the focus state when the switch stops being interactive', async () => { @@ -185,8 +185,6 @@ describe('Switch operability', () => { expect(console.warn).toHaveBeenCalledWith( expect.stringContaining('onValueChange') ); - - jest.restoreAllMocks(); }); it('is not focusable when it has no way to be operated', async () => { @@ -196,8 +194,6 @@ describe('Switch operability', () => { await render(); expect(screen.getByRole('switch')).toHaveProp('focusable', false); - - jest.restoreAllMocks(); }); it('announces a read-only switch without making it focusable', async () => { @@ -221,8 +217,6 @@ describe('Switch operability', () => { expect(control).toHaveProp('aria-readonly', true); expect(control).toBeEnabled(); - - jest.restoreAllMocks(); }); it('does not announce a disabled switch as read-only', async () => { From bbe9b8778622dce752f29ec2b5e6a644b383d5fa Mon Sep 17 00:00:00 2001 From: likevy Date: Wed, 23 Sep 2026 11:50:05 +0200 Subject: [PATCH 16/29] fix(switch): address accessibility review feedback --- docs/6.x/docs/guides/migration.md | 2 +- src/components/Switch/Switch.tsx | 51 +- src/components/__tests__/Switch.test.tsx | 347 ++++++---- .../__snapshots__/Switch.test.tsx.snap | 600 +++++++++++++++++- 4 files changed, 858 insertions(+), 142 deletions(-) diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index 2f5ad1f8e7..66b7ad8e62 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -580,7 +580,7 @@ mutually exclusive options, use `SegmentedButtons`. 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. -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. +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. ```tsx // Before (v5) — an enabled switch that does nothing diff --git a/src/components/Switch/Switch.tsx b/src/components/Switch/Switch.tsx index 7b8694dbab..eb46631f33 100644 --- a/src/components/Switch/Switch.tsx +++ b/src/components/Switch/Switch.tsx @@ -64,8 +64,8 @@ export type Props = SwitchBaseProps & { onValueChange?: (value: boolean) => void; /** * Reports state the user cannot change here. The switch keeps its enabled - * appearance and is still announced by a screen reader, but it is neither - * focusable nor pressable. + * appearance but cannot be focused with a keyboard or pressed. Screen readers + * receive a read-only state on web and a disabled state on native platforms. */ readOnly?: boolean; /** @@ -191,9 +191,8 @@ const Switch: (props: OperableProps) => React.JSX.Element = ({ } const isInteractive = !isDisabled && !isReadOnly && !isMissingOperability; - // Non-operable but not disabled: an explicit `readOnly`, or the fallback for - // a missing handler. Both render as state indicators, so both are announced - // that way; `aria-disabled` carries the disabled case on its own. + // Native accessibility has no read-only switch state. Disable the Pressable + // there to expose non-operability, while keeping colors tied to `disabled`. const isAnnouncedReadOnly = !isDisabled && !isInteractive; const iconSource = checked ? checkedIcon : uncheckedIcon; const hasIcon = iconSource !== undefined; @@ -202,6 +201,7 @@ const Switch: (props: OperableProps) => React.JSX.Element = ({ Platform.OS === 'web' && direction === 'rtl' ? { right: 0 } : { left: 0 }; const pressedSV = useSharedValue(0); + const spacePressedRef = React.useRef(false); const hoveredSV = useSharedValue(0); const focusedSV = useSharedValue(0); const checkedSV = useSharedValue(checked ? 1 : 0); @@ -221,6 +221,7 @@ const Switch: (props: OperableProps) => React.JSX.Element = ({ if (isInteractive) return; pressedSV.value = 0; + spacePressedRef.current = false; hoveredSV.value = 0; focusedSV.value = 0; }, [isInteractive, pressedSV, hoveredSV, focusedSV]); @@ -425,20 +426,46 @@ const Switch: (props: OperableProps) => React.JSX.Element = ({ }, onBlur: () => { focusedSV.value = 0; + pressedSV.value = 0; + spacePressedRef.current = false; }, + // React Native Web only handles Space for button roles. + ...(Platform.OS === 'web' + ? { + onKeyDown: (event: React.KeyboardEvent) => { + const { key, repeat } = event.nativeEvent; + if (key !== ' ' && key !== 'Spacebar') return; + event.preventDefault(); + if (!repeat) { + spacePressedRef.current = true; + pressedSV.value = 1; + } + }, + onKeyUp: (event: React.KeyboardEvent) => { + const { key } = event.nativeEvent; + if (key !== ' ' && key !== 'Spacebar') return; + event.preventDefault(); + if (spacePressedRef.current) { + spacePressedRef.current = false; + pressedSV.value = 0; + onValueChange?.(!checked); + } + }, + } + : undefined), } : null; return ( React.JSX.Element = ({ React.JSX.Element = ({ ]} /> - + {/* Disabled-only: opaque `surface` backdrop. The tinted fill above composites over it, reproducing the native math avoiding the PlatformColor alpha limitation. */} {isDisabled ? ( @@ -484,7 +507,6 @@ const Switch: (props: OperableProps) => React.JSX.Element = ({ /> ) : null} React.JSX.Element = ({ ) : null} - Reanimated.getAnimatedStyle(screen.getByTestId(testID)); +// Snapshot rendered output with current animation values instead of the +// initial styles retained by the test renderer. +const renderedStyles = ( + tree: JsonNode | JsonNode[] | null = screen.toJSON() +): ViewStyle[] => { + if (tree === null || typeof tree === 'string') return []; + if (Array.isArray(tree)) return tree.flatMap(renderedStyles); + + const { props, children } = tree; + return [ + { ...StyleSheet.flatten(props.style), ...getAnimatedStyle(tree) }, + ...children.flatMap(renderedStyles), + ]; +}; afterEach(() => { jest.restoreAllMocks(); @@ -19,7 +29,9 @@ afterEach(() => { describe('Switch render', () => { it('renders on', async () => { expect( - (await render()).toJSON() + ( + await render() + ).toJSON() ).toMatchSnapshot(); }); @@ -84,94 +96,67 @@ describe('Switch accessibility', () => { }); describe('Switch focus state', () => { - const renderAndFocus = async (element: React.ReactElement) => { - await render(element); - - await fireEvent(screen.getByTestId('switch'), 'focus'); - await jest.runAllTimersAsync(); - }; - - it('shows the focus indicator on keyboard focus', async () => { - await renderAndFocus( - - ); - - expect(animatedStyle('switch-focus-ring')).toMatchObject({ opacity: 1 }); - }); - - it('hides the focus indicator again on blur', async () => { - await renderAndFocus( - - ); + it.each([false, true])( + 'renders keyboard focus feedback when value is %s and clears it on blur', + async (value) => { + await render(); + const resting = renderedStyles(); - await fireEvent(screen.getByTestId('switch'), 'blur'); - await jest.runAllTimersAsync(); + await fireEvent(screen.getByRole('switch'), 'focus'); + await jest.runAllTimersAsync(); - expect(animatedStyle('switch-focus-ring')).toMatchObject({ opacity: 0 }); - }); + expect(renderedStyles()).toMatchSnapshot('focused'); + expect(renderedStyles()).not.toEqual(resting); - it('raises the state layer to the focused opacity', async () => { - await renderAndFocus( - - ); + await fireEvent(screen.getByRole('switch'), 'blur'); + await jest.runAllTimersAsync(); - expect(animatedStyle('switch-state-layer')).toMatchObject({ - opacity: tokens.md.sys.state.opacity.focused, - }); - }); + expect(renderedStyles()).toEqual(resting); + } + ); - it('leaves the indicator hidden for pointer focus on web', async () => { + it('only shows focus feedback for keyboard focus on web', async () => { jest.replaceProperty(Platform, 'OS', 'web'); + await render(); + const resting = renderedStyles(); - await render( - - ); - await fireEvent(screen.getByTestId('switch'), 'focus', { + await fireEvent(screen.getByRole('switch'), 'focus', { currentTarget: { matches: () => false }, }); await jest.runAllTimersAsync(); + expect(renderedStyles()).toEqual(resting); - expect(animatedStyle('switch-focus-ring')).toMatchObject({ opacity: 0 }); - }); - - it('shows the indicator for keyboard focus on web', async () => { - jest.replaceProperty(Platform, 'OS', 'web'); - - await render( - - ); - await fireEvent(screen.getByTestId('switch'), 'focus', { + await fireEvent(screen.getByRole('switch'), 'focus', { currentTarget: { matches: () => true }, }); await jest.runAllTimersAsync(); - - expect(animatedStyle('switch-focus-ring')).toMatchObject({ opacity: 1 }); - }); - - it('clears the focus state when the switch stops being interactive', async () => { - const view = await render( - - ); - - await fireEvent(screen.getByTestId('switch'), 'focus'); - await jest.runAllTimersAsync(); - expect(animatedStyle('switch-focus-ring')).toMatchObject({ opacity: 1 }); - - await view.rerender(); - await jest.runAllTimersAsync(); - - expect(animatedStyle('switch-focus-ring')).toMatchObject({ opacity: 0 }); + expect(renderedStyles()).toMatchSnapshot('keyboard focus'); + expect(renderedStyles()).not.toEqual(resting); }); - it('paints the focus handle color when selected and focused', async () => { - await renderAndFocus( - - ); - - expect(animatedStyle('switch-handle-fill')).toMatchObject({ - backgroundColor: defaultThemes.light.colors.primaryContainer, - }); - }); + it.each(['disabled', 'readOnly'] as const)( + 'clears interaction feedback when the switch becomes %s', + async (prop) => { + const onValueChange = jest.fn(); + const view = await render(); + const resting = renderedStyles(); + + await fireEvent(screen.getByRole('switch'), 'focus'); + await fireEvent(screen.getByRole('switch'), 'hoverIn'); + await fireEvent(screen.getByRole('switch'), 'pressIn'); + await jest.runAllTimersAsync(); + expect(renderedStyles()).not.toEqual(resting); + + await view.rerender( + + ); + await jest.runAllTimersAsync(); + await view.rerender(); + await jest.runAllTimersAsync(); + + expect(renderedStyles()).toEqual(resting); + } + ); }); describe('Switch operability', () => { @@ -196,36 +181,96 @@ describe('Switch operability', () => { expect(screen.getByRole('switch')).toHaveProp('focusable', false); }); - it('announces a read-only switch without making it focusable', async () => { - await render(); + describe.each(['ios', 'android', 'web'] as const)('%s', (platform) => { + it.each([false, true])( + 'exposes a read-only switch with value %s as non-operable', + async (value) => { + jest.replaceProperty(Platform, 'OS', platform); + const view = await render( + + ); + const enabledAppearance = renderedStyles(); + await view.rerender( + + ); + + const control = screen.getByRole('switch', { + name: 'Dark theme', + checked: value, + disabled: platform !== 'web', + }); + expect(control).toHaveProp('focusable', false); + expect(control).toHaveProp('tabIndex', -1); + expect(renderedStyles()).toEqual(enabledAppearance); + expect(control).toBeOnTheScreen(); + } + ); + + it('marks a missing-handler fallback as non-operable', async () => { + jest.replaceProperty(Platform, 'OS', platform); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + + // @ts-expect-error -- the fallback exists for untyped callers. + await render(); + const control = screen.getByRole('switch', { + checked: true, + disabled: platform !== 'web', + }); + + expect(control).toHaveProp('tabIndex', -1); + expect(control).toBeOnTheScreen(); + }); + + it('keeps disabled semantics when read-only is also set', async () => { + jest.replaceProperty(Platform, 'OS', platform); + await render(); - const control = screen.getByRole('switch'); + expect(screen.getByRole('switch')).toBeDisabled(); + expect(screen.getByRole('switch')).not.toHaveProp('aria-readonly', true); + }); + }); + + it('exposes the read-only state on web', async () => { + jest.replaceProperty(Platform, 'OS', 'web'); + await render(); - expect(control).toHaveProp('focusable', false); - expect(control).toHaveProp('aria-readonly', true); - expect(control).toBeChecked(); - expect(control).toBeEnabled(); + expect(screen.getByRole('switch')).toHaveProp('aria-readonly', true); + expect(screen.getByRole('switch')).toBeEnabled(); }); - it('announces a switch with no way to be operated as read-only', async () => { + it('exposes a missing-handler fallback as read-only on web', async () => { + jest.replaceProperty(Platform, 'OS', 'web'); jest.spyOn(console, 'warn').mockImplementation(() => {}); // @ts-expect-error -- the fallback exists for untyped callers. await render(); - const control = screen.getByRole('switch'); - - expect(control).toHaveProp('aria-readonly', true); - expect(control).toBeEnabled(); + expect(screen.getByRole('switch')).toHaveProp('aria-readonly', true); }); - it('does not announce a disabled switch as read-only', async () => { - await render(); + it('allows activation after read-only is turned off', async () => { + const user = userEvent.setup(); + const onValueChange = jest.fn(); + const view = await render( + + ); + + await user.press(screen.getByRole('switch')); + expect(onValueChange).not.toHaveBeenCalled(); - const control = screen.getByRole('switch'); + await view.rerender( + + ); - expect(control).toHaveProp('aria-readonly', false); - expect(control).toBeDisabled(); + expect(screen.getByRole('switch')).toBeEnabled(); + expect(screen.getByRole('switch')).toHaveProp('tabIndex', 0); + await user.press(screen.getByRole('switch')); + expect(onValueChange).toHaveBeenCalledWith(false); }); it('keeps an interactive switch focusable', async () => { @@ -236,34 +281,96 @@ describe('Switch operability', () => { }); describe('Switch press feedback', () => { - // MD3 resting (selected) and pressed handle sizes. - const RESTING = 24; - const PRESSED = 28; - const handleWidth = () => Number(animatedStyle('switch-handle').width); + it.each([false, true])( + 'grows immediately when value is %s and returns to rest on release', + async (value) => { + await render(); + const resting = renderedStyles(); + + await fireEvent(screen.getByRole('switch'), 'pressIn'); + // Two frames, before the previous 100ms growth delay would elapse. + jest.advanceTimersByTime(32); + expect(renderedStyles()).toMatchSnapshot('early press'); + + await jest.runAllTimersAsync(); + expect(renderedStyles()).toMatchSnapshot('held press'); + + await fireEvent(screen.getByRole('switch'), 'pressOut'); + await jest.runAllTimersAsync(); + expect(renderedStyles()).toEqual(resting); + } + ); +}); - it('starts growing the handle immediately on press', async () => { - await render(); - expect(handleWidth()).toBe(RESTING); +describe('Switch keyboard interaction on web', () => { + it.each([false, true])( + 'toggles once on Space release when value is %s', + async (value) => { + jest.replaceProperty(Platform, 'OS', 'web'); + const onValueChange = jest.fn(); + const preventDefault = jest.fn(); + await render(); + const control = screen.getByRole('switch'); + + await fireEvent(control, 'keyDown', { + nativeEvent: { key: ' ' }, + preventDefault, + }); + await fireEvent(control, 'keyDown', { + nativeEvent: { key: ' ', repeat: true }, + preventDefault, + }); + expect(onValueChange).not.toHaveBeenCalled(); + expect(preventDefault).toHaveBeenCalledTimes(2); + + await fireEvent(control, 'keyUp', { + nativeEvent: { key: ' ' }, + preventDefault, + }); + await fireEvent(control, 'keyUp', { + nativeEvent: { key: ' ' }, + preventDefault, + }); + expect(onValueChange).toHaveBeenCalledTimes(1); + expect(onValueChange).toHaveBeenCalledWith(!value); + } + ); + + it('cancels Space activation when focus leaves the switch', async () => { + jest.replaceProperty(Platform, 'OS', 'web'); + const onValueChange = jest.fn(); + const event = { nativeEvent: { key: ' ' }, preventDefault: jest.fn() }; + await render(); - await fireEvent(screen.getByTestId('switch'), 'pressIn'); - // Two frames in -- well inside the 100ms delay the old implementation - // waited out before snapping, so a reintroduced delay still reads RESTING. - jest.advanceTimersByTime(32); + await fireEvent(screen.getByRole('switch'), 'keyDown', event); + await fireEvent(screen.getByRole('switch'), 'blur'); + await fireEvent(screen.getByRole('switch'), 'keyUp', event); - expect(handleWidth()).toBeGreaterThan(RESTING); + expect(onValueChange).not.toHaveBeenCalled(); }); - it('settles at the pressed size and returns on release', async () => { - await render(); - - await fireEvent(screen.getByTestId('switch'), 'pressIn'); - await jest.runAllTimersAsync(); - expect(handleWidth()).toBe(PRESSED); - - await fireEvent(screen.getByTestId('switch'), 'pressOut'); - await jest.runAllTimersAsync(); - expect(handleWidth()).toBe(RESTING); - }); + it.each(['readOnly', 'disabled'] as const)( + 'cancels Space activation when the switch becomes %s', + async (prop) => { + jest.replaceProperty(Platform, 'OS', 'web'); + const onValueChange = jest.fn(); + const event = { nativeEvent: { key: ' ' }, preventDefault: jest.fn() }; + const view = await render(); + + await fireEvent(screen.getByRole('switch'), 'keyDown', event); + await view.rerender( + + ); + await fireEvent(screen.getByRole('switch'), 'keyUp', event); + await fireEvent(screen.getByRole('switch'), 'keyDown', event); + await fireEvent(screen.getByRole('switch'), 'keyUp', event); + expect(onValueChange).not.toHaveBeenCalled(); + + await view.rerender(); + await fireEvent(screen.getByRole('switch'), 'keyUp', event); + expect(onValueChange).not.toHaveBeenCalled(); + } + ); }); describe('Switch interaction', () => { diff --git a/src/components/__tests__/__snapshots__/Switch.test.tsx.snap b/src/components/__tests__/__snapshots__/Switch.test.tsx.snap index 45aed5023f..33ed7a065b 100644 --- a/src/components/__tests__/__snapshots__/Switch.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Switch.test.tsx.snap @@ -1,5 +1,592 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`Switch focus state only shows focus feedback for keyboard focus on web: keyboard focus 1`] = ` +[ + { + "alignItems": "center", + "height": 48, + "justifyContent": "center", + "overflow": "visible", + "width": 52, + }, + { + "alignItems": "center", + "height": 48, + "justifyContent": "center", + "outline": "none", + "width": 52, + }, + { + "backgroundColor": "rgba(103, 80, 164, 1)", + "borderRadius": 9999, + "height": 32, + "opacity": 1, + "overflow": "visible", + "width": 52, + }, + { + "backgroundColor": "rgba(103, 80, 164, 1)", + "borderRadius": 9999, + "height": 40, + "left": 0, + "opacity": 0.1, + "pointerEvents": "none", + "position": "absolute", + "top": 4, + "transform": [ + { + "translateX": 16, + }, + ], + "width": 40, + }, + { + "alignItems": "center", + "height": 24, + "justifyContent": "center", + "left": 0, + "pointerEvents": "none", + "position": "absolute", + "top": 12, + "transform": [ + { + "translateX": 24, + }, + ], + "width": 24, + }, + { + "backgroundColor": "rgba(234, 221, 255, 1)", + "borderRadius": 9999, + "bottom": 0, + "left": 0, + "opacity": 1, + "position": "absolute", + "right": 0, + "top": 0, + }, + { + "borderColor": "rgba(98, 91, 113, 1)", + "borderRadius": 9999, + "borderWidth": 3, + "bottom": 3, + "left": -5, + "opacity": 1, + "pointerEvents": "none", + "position": "absolute", + "right": -5, + "top": 3, + }, +] +`; + +exports[`Switch focus state renders keyboard focus feedback when value is false and clears it on blur: focused 1`] = ` +[ + { + "alignItems": "center", + "height": 48, + "justifyContent": "center", + "overflow": "visible", + "width": 52, + }, + { + "alignItems": "center", + "height": 48, + "justifyContent": "center", + "width": 52, + }, + { + "backgroundColor": "rgba(230, 224, 233, 1)", + "borderRadius": 9999, + "height": 32, + "opacity": 1, + "overflow": "visible", + "width": 52, + }, + { + "borderColor": "rgba(121, 116, 126, 1)", + "borderRadius": 9999, + "borderWidth": 2, + "bottom": 0, + "left": 0, + "pointerEvents": "none", + "position": "absolute", + "right": 0, + "top": 0, + }, + { + "backgroundColor": "rgba(29, 27, 32, 1)", + "borderRadius": 9999, + "height": 40, + "left": 0, + "opacity": 0.1, + "pointerEvents": "none", + "position": "absolute", + "top": 4, + "transform": [ + { + "translateX": -4, + }, + ], + "width": 40, + }, + { + "alignItems": "center", + "height": 16, + "justifyContent": "center", + "left": 0, + "pointerEvents": "none", + "position": "absolute", + "top": 16, + "transform": [ + { + "translateX": 8, + }, + ], + "width": 16, + }, + { + "backgroundColor": "rgba(73, 69, 79, 1)", + "borderRadius": 9999, + "bottom": 0, + "left": 0, + "opacity": 1, + "position": "absolute", + "right": 0, + "top": 0, + }, + { + "borderColor": "rgba(98, 91, 113, 1)", + "borderRadius": 9999, + "borderWidth": 3, + "bottom": 3, + "left": -5, + "opacity": 1, + "pointerEvents": "none", + "position": "absolute", + "right": -5, + "top": 3, + }, +] +`; + +exports[`Switch focus state renders keyboard focus feedback when value is true and clears it on blur: focused 1`] = ` +[ + { + "alignItems": "center", + "height": 48, + "justifyContent": "center", + "overflow": "visible", + "width": 52, + }, + { + "alignItems": "center", + "height": 48, + "justifyContent": "center", + "width": 52, + }, + { + "backgroundColor": "rgba(103, 80, 164, 1)", + "borderRadius": 9999, + "height": 32, + "opacity": 1, + "overflow": "visible", + "width": 52, + }, + { + "backgroundColor": "rgba(103, 80, 164, 1)", + "borderRadius": 9999, + "height": 40, + "left": 0, + "opacity": 0.1, + "pointerEvents": "none", + "position": "absolute", + "top": 4, + "transform": [ + { + "translateX": 16, + }, + ], + "width": 40, + }, + { + "alignItems": "center", + "height": 24, + "justifyContent": "center", + "left": 0, + "pointerEvents": "none", + "position": "absolute", + "top": 12, + "transform": [ + { + "translateX": 24, + }, + ], + "width": 24, + }, + { + "backgroundColor": "rgba(234, 221, 255, 1)", + "borderRadius": 9999, + "bottom": 0, + "left": 0, + "opacity": 1, + "position": "absolute", + "right": 0, + "top": 0, + }, + { + "borderColor": "rgba(98, 91, 113, 1)", + "borderRadius": 9999, + "borderWidth": 3, + "bottom": 3, + "left": -5, + "opacity": 1, + "pointerEvents": "none", + "position": "absolute", + "right": -5, + "top": 3, + }, +] +`; + +exports[`Switch press feedback grows immediately when value is false and returns to rest on release: early press 1`] = ` +[ + { + "alignItems": "center", + "height": 48, + "justifyContent": "center", + "overflow": "visible", + "width": 52, + }, + { + "alignItems": "center", + "height": 48, + "justifyContent": "center", + "width": 52, + }, + { + "backgroundColor": "rgba(230, 224, 233, 1)", + "borderRadius": 9999, + "height": 32, + "opacity": 1, + "overflow": "visible", + "width": 52, + }, + { + "borderColor": "rgba(121, 116, 126, 1)", + "borderRadius": 9999, + "borderWidth": 2, + "bottom": 0, + "left": 0, + "pointerEvents": "none", + "position": "absolute", + "right": 0, + "top": 0, + }, + { + "backgroundColor": "rgba(29, 27, 32, 1)", + "borderRadius": 9999, + "height": 40, + "left": 0, + "opacity": 0.012550532338511944, + "pointerEvents": "none", + "position": "absolute", + "top": 4, + "transform": [ + { + "translateX": -4, + }, + ], + "width": 40, + }, + { + "alignItems": "center", + "height": 19.31374049870675, + "justifyContent": "center", + "left": 0, + "pointerEvents": "none", + "position": "absolute", + "top": 14.343129750646625, + "transform": [ + { + "translateX": 6.343129750646625, + }, + ], + "width": 19.31374049870675, + }, + { + "backgroundColor": "rgba(73, 69, 79, 1)", + "borderRadius": 9999, + "bottom": 0, + "left": 0, + "opacity": 1, + "position": "absolute", + "right": 0, + "top": 0, + }, + { + "borderColor": "rgba(98, 91, 113, 1)", + "borderRadius": 9999, + "borderWidth": 3, + "bottom": 3, + "left": -5, + "opacity": 0, + "pointerEvents": "none", + "position": "absolute", + "right": -5, + "top": 3, + }, +] +`; + +exports[`Switch press feedback grows immediately when value is false and returns to rest on release: held press 1`] = ` +[ + { + "alignItems": "center", + "height": 48, + "justifyContent": "center", + "overflow": "visible", + "width": 52, + }, + { + "alignItems": "center", + "height": 48, + "justifyContent": "center", + "width": 52, + }, + { + "backgroundColor": "rgba(230, 224, 233, 1)", + "borderRadius": 9999, + "height": 32, + "opacity": 1, + "overflow": "visible", + "width": 52, + }, + { + "borderColor": "rgba(121, 116, 126, 1)", + "borderRadius": 9999, + "borderWidth": 2, + "bottom": 0, + "left": 0, + "pointerEvents": "none", + "position": "absolute", + "right": 0, + "top": 0, + }, + { + "backgroundColor": "rgba(29, 27, 32, 1)", + "borderRadius": 9999, + "height": 40, + "left": 0, + "opacity": 0.1, + "pointerEvents": "none", + "position": "absolute", + "top": 4, + "transform": [ + { + "translateX": -4, + }, + ], + "width": 40, + }, + { + "alignItems": "center", + "height": 28, + "justifyContent": "center", + "left": 0, + "pointerEvents": "none", + "position": "absolute", + "top": 10, + "transform": [ + { + "translateX": 2, + }, + ], + "width": 28, + }, + { + "backgroundColor": "rgba(73, 69, 79, 1)", + "borderRadius": 9999, + "bottom": 0, + "left": 0, + "opacity": 1, + "position": "absolute", + "right": 0, + "top": 0, + }, + { + "borderColor": "rgba(98, 91, 113, 1)", + "borderRadius": 9999, + "borderWidth": 3, + "bottom": 3, + "left": -5, + "opacity": 0, + "pointerEvents": "none", + "position": "absolute", + "right": -5, + "top": 3, + }, +] +`; + +exports[`Switch press feedback grows immediately when value is true and returns to rest on release: early press 1`] = ` +[ + { + "alignItems": "center", + "height": 48, + "justifyContent": "center", + "overflow": "visible", + "width": 52, + }, + { + "alignItems": "center", + "height": 48, + "justifyContent": "center", + "width": 52, + }, + { + "backgroundColor": "rgba(103, 80, 164, 1)", + "borderRadius": 9999, + "height": 32, + "opacity": 1, + "overflow": "visible", + "width": 52, + }, + { + "backgroundColor": "rgba(103, 80, 164, 1)", + "borderRadius": 9999, + "height": 40, + "left": 0, + "opacity": 0.012550532338511944, + "pointerEvents": "none", + "position": "absolute", + "top": 4, + "transform": [ + { + "translateX": 16, + }, + ], + "width": 40, + }, + { + "alignItems": "center", + "height": 25.104580166235582, + "justifyContent": "center", + "left": 0, + "pointerEvents": "none", + "position": "absolute", + "top": 11.447709916882209, + "transform": [ + { + "translateX": 23.44770991688221, + }, + ], + "width": 25.104580166235582, + }, + { + "backgroundColor": "rgba(234, 221, 255, 1)", + "borderRadius": 9999, + "bottom": 0, + "left": 0, + "opacity": 1, + "position": "absolute", + "right": 0, + "top": 0, + }, + { + "borderColor": "rgba(98, 91, 113, 1)", + "borderRadius": 9999, + "borderWidth": 3, + "bottom": 3, + "left": -5, + "opacity": 0, + "pointerEvents": "none", + "position": "absolute", + "right": -5, + "top": 3, + }, +] +`; + +exports[`Switch press feedback grows immediately when value is true and returns to rest on release: held press 1`] = ` +[ + { + "alignItems": "center", + "height": 48, + "justifyContent": "center", + "overflow": "visible", + "width": 52, + }, + { + "alignItems": "center", + "height": 48, + "justifyContent": "center", + "width": 52, + }, + { + "backgroundColor": "rgba(103, 80, 164, 1)", + "borderRadius": 9999, + "height": 32, + "opacity": 1, + "overflow": "visible", + "width": 52, + }, + { + "backgroundColor": "rgba(103, 80, 164, 1)", + "borderRadius": 9999, + "height": 40, + "left": 0, + "opacity": 0.1, + "pointerEvents": "none", + "position": "absolute", + "top": 4, + "transform": [ + { + "translateX": 16, + }, + ], + "width": 40, + }, + { + "alignItems": "center", + "height": 28, + "justifyContent": "center", + "left": 0, + "pointerEvents": "none", + "position": "absolute", + "top": 10, + "transform": [ + { + "translateX": 22, + }, + ], + "width": 28, + }, + { + "backgroundColor": "rgba(234, 221, 255, 1)", + "borderRadius": 9999, + "bottom": 0, + "left": 0, + "opacity": 1, + "position": "absolute", + "right": 0, + "top": 0, + }, + { + "borderColor": "rgba(98, 91, 113, 1)", + "borderRadius": 9999, + "borderWidth": 3, + "bottom": 3, + "left": -5, + "opacity": 0, + "pointerEvents": "none", + "position": "absolute", + "right": -5, + "top": 3, + }, +] +`; + exports[`Switch render renders disabled off 1`] = ` Date: Fri, 30 Oct 2026 13:02:40 +0100 Subject: [PATCH 17/29] fix(switch): keep the flat props type and rely on the runtime warning The operability union rejected props that are valid at runtime, such as a conditional handler paired with `disabled`, and it collapsed in wrappers built with `Omit`. `SwitchProps` is the flat `Props` again, like `Checkbox` and `RadioButton`. A switch with no `onValueChange` that is neither `readOnly` nor `disabled` still renders as read-only and logs a warning. --- docs/6.x/docs/guides/migration.md | 12 ++--- src/components/Switch/Switch.tsx | 63 ++++++++---------------- src/components/__tests__/Switch.test.tsx | 5 -- src/index.tsx | 2 +- 4 files changed, 27 insertions(+), 55 deletions(-) diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index 66b7ad8e62..d0d84f0537 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -576,22 +576,22 @@ mutually exclusive options, use `SegmentedButtons`. ### Switch -#### Explicit operability +#### Switches without `onValueChange` -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. - -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. +A `Switch` with no `onValueChange` used to render as an enabled, focusable control that did nothing when activated, and screen readers announced it as operable. It now renders as read-only and logs a warning. Pass `onValueChange` to make it interactive, or set `readOnly` or `disabled` to render it as a state indicator: ```tsx -// Before (v5) — an enabled switch that does nothing +// Before (v5) -// After (v6) — say which it is +// After (v6) ``` +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. + This most often shows up where the switch sits inside a row that owns the press: ```tsx diff --git a/src/components/Switch/Switch.tsx b/src/components/Switch/Switch.tsx index eb46631f33..be7e093425 100644 --- a/src/components/Switch/Switch.tsx +++ b/src/components/Switch/Switch.tsx @@ -34,11 +34,27 @@ import type { StateOpacityKey, ThemeProp } from '../../theme/types'; import { isKeyboardFocusEvent } from '../../utils/isKeyboardFocusEvent'; import Icon, { type IconSource } from '../Icon'; -type SwitchBaseProps = { +export type Props = { /** * Whether the switch is on. */ value?: boolean; + /** + * Called with the new value when the user toggles the switch. A switch + * without it that is neither `readOnly` nor `disabled` renders as read-only + * and logs a warning. + */ + onValueChange?: (value: boolean) => void; + /** + * Reports state the user cannot change here. The switch keeps its enabled + * appearance but cannot be focused with a keyboard or pressed. Screen readers + * receive a read-only state on web and a disabled state on native platforms. + */ + readOnly?: boolean; + /** + * Disables interaction and renders the disabled visual state. + */ + disabled?: boolean; /** * Icon shown inside the handle when checked */ @@ -56,45 +72,6 @@ type SwitchBaseProps = { 'aria-label'?: string; }; -export type Props = SwitchBaseProps & { - /** - * Called with the new value when the user toggles the switch. Required - * unless the switch is `readOnly` or `disabled`. - */ - onValueChange?: (value: boolean) => void; - /** - * Reports state the user cannot change here. The switch keeps its enabled - * appearance but cannot be focused with a keyboard or pressed. Screen readers - * receive a read-only state on web and a disabled state on native platforms. - */ - readOnly?: boolean; - /** - * Disables interaction and renders the disabled visual state. - */ - disabled?: boolean; -}; - -/** - * `Props`, narrowed so a switch always declares how it can be operated and can - * never render as an enabled control that does nothing when activated. - * - * This is applied to the component's declared type rather than folded into - * `Props`, because the docs generator reads props off the parameter annotation - * and silently drops a union (a top-level one drops the whole page). So the - * parameter below stays annotated as the flat `Props`, and callers still get - * the narrowed type through this one. - */ -export type OperableProps = SwitchBaseProps & - ( - | { - onValueChange: (value: boolean) => void; - readOnly?: boolean; - disabled?: boolean; - } - | { onValueChange?: never; readOnly: true; disabled?: boolean } - | { onValueChange?: never; readOnly?: boolean; disabled: true } - ); - const { trackWidth: TRACK_WIDTH, trackHeight: TRACK_HEIGHT, @@ -159,7 +136,7 @@ const CHECKED_CENTER = TRACK_WIDTH - HANDLE_PADDING - SELECTED_HANDLE / 2; * - `surface`: disabled selected handle * - `secondary`: focus indicator */ -const Switch: (props: OperableProps) => React.JSX.Element = ({ +const Switch = ({ value, disabled, readOnly, @@ -180,8 +157,8 @@ const Switch: (props: OperableProps) => React.JSX.Element = ({ const isDisabled = !!disabled; const isEnabled = !isDisabled; const isReadOnly = !!readOnly; - // `OperableProps` already requires one of these, but untyped callers can slip - // past it, so guard here too rather than rendering an enabled no-op. + // Without a handler, an enabled switch would be a control that does nothing + // when activated, so render it as read-only instead. const isMissingOperability = !onValueChange && !isReadOnly && !isDisabled; if (isMissingOperability) { diff --git a/src/components/__tests__/Switch.test.tsx b/src/components/__tests__/Switch.test.tsx index 3bb6790ca5..8d192f3f34 100644 --- a/src/components/__tests__/Switch.test.tsx +++ b/src/components/__tests__/Switch.test.tsx @@ -163,8 +163,6 @@ describe('Switch operability', () => { it('warns when it is given no way to be operated', async () => { jest.spyOn(console, 'warn').mockImplementation(() => {}); - // @ts-expect-error -- the props type requires a handler, `readOnly`, or - // `disabled`; this covers untyped callers that get past it. await render(); expect(console.warn).toHaveBeenCalledWith( @@ -175,7 +173,6 @@ describe('Switch operability', () => { it('is not focusable when it has no way to be operated', async () => { jest.spyOn(console, 'warn').mockImplementation(() => {}); - // @ts-expect-error -- see above. await render(); expect(screen.getByRole('switch')).toHaveProp('focusable', false); @@ -215,7 +212,6 @@ describe('Switch operability', () => { jest.replaceProperty(Platform, 'OS', platform); jest.spyOn(console, 'warn').mockImplementation(() => {}); - // @ts-expect-error -- the fallback exists for untyped callers. await render(); const control = screen.getByRole('switch', { checked: true, @@ -247,7 +243,6 @@ describe('Switch operability', () => { jest.replaceProperty(Platform, 'OS', 'web'); jest.spyOn(console, 'warn').mockImplementation(() => {}); - // @ts-expect-error -- the fallback exists for untyped callers. await render(); expect(screen.getByRole('switch')).toHaveProp('aria-readonly', true); diff --git a/src/index.tsx b/src/index.tsx index b8bacf9f47..1807d2f249 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -126,7 +126,7 @@ export type { Props as RadioButtonItemProps } from './components/RadioButton/Rad export type { Props as SearchbarProps } from './components/Searchbar'; export type { Props as SnackbarProps } from './components/Snackbar'; export type { Props as SurfaceProps } from './components/Surface'; -export type { OperableProps as SwitchProps } from './components/Switch/Switch'; +export type { Props as SwitchProps } from './components/Switch/Switch'; export type { TextInputProps, TextInputRenderProps, From 55a324e16e60ea9d5b75fb9c63ee1285f76380ce Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 13:08:16 +0100 Subject: [PATCH 18/29] test(switch): assert the values each interaction test covers The focus, press and read-only tests flattened every style in the tree and compared or snapshotted the whole dump, so a change to any internal layer broke them and the snapshots did not show which value mattered. They now read the animated style of the one layer that changes: the focus ring opacity, the state layer opacity, the handle size and the handle color. --- src/components/__tests__/Switch.test.tsx | 125 ++-- .../__snapshots__/Switch.test.tsx.snap | 587 ------------------ 2 files changed, 84 insertions(+), 628 deletions(-) diff --git a/src/components/__tests__/Switch.test.tsx b/src/components/__tests__/Switch.test.tsx index 8d192f3f34..dc66969d15 100644 --- a/src/components/__tests__/Switch.test.tsx +++ b/src/components/__tests__/Switch.test.tsx @@ -1,25 +1,37 @@ -import { Platform, StyleSheet, type ViewStyle } from 'react-native'; +import { Platform } from 'react-native'; import { afterEach, describe, expect, it, jest } from '@jest/globals'; import { getAnimatedStyle } from 'react-native-reanimated'; -import type { JsonNode } from 'test-renderer'; +import type { JsonElement, JsonNode } from 'test-renderer'; import { fireEvent, render, screen, userEvent } from '../../test-utils'; +import { LightTheme } from '../../theme/schemes'; import Switch from '../Switch/Switch'; -// Snapshot rendered output with current animation values instead of the -// initial styles retained by the test renderer. -const renderedStyles = ( - tree: JsonNode | JsonNode[] | null = screen.toJSON() -): ViewStyle[] => { - if (tree === null || typeof tree === 'string') return []; - if (Array.isArray(tree)) return tree.flatMap(renderedStyles); - - const { props, children } = tree; - return [ - { ...StyleSheet.flatten(props.style), ...getAnimatedStyle(tree) }, - ...children.flatMap(renderedStyles), - ]; +const { colors } = LightTheme; + +const element = (node: JsonNode | null | undefined): JsonElement => { + if (typeof node !== 'object' || node === null) { + throw new Error('Expected a rendered view'); + } + + return node; +}; + +// The state layer, handle and focus ring are decorative views with no role or +// test ID. They render after the control in this order, and the handle fill +// is the last view inside the handle. +const getLayers = () => { + const { children } = element(screen.toJSON()); + const handle = element(children[2]); + const { children: handleChildren } = handle; + + return { + stateLayer: getAnimatedStyle(element(children[1])), + handle: getAnimatedStyle(handle), + handleFill: getAnimatedStyle(element(handleChildren.at(-1))), + focusRing: getAnimatedStyle(element(children.at(-1))), + }; }; afterEach(() => { @@ -96,42 +108,57 @@ describe('Switch accessibility', () => { }); describe('Switch focus state', () => { - it.each([false, true])( - 'renders keyboard focus feedback when value is %s and clears it on blur', - async (value) => { + it.each([ + { + value: false, + focusedHandle: colors.onSurfaceVariant, + restingHandle: colors.outline, + }, + { + value: true, + focusedHandle: colors.primaryContainer, + restingHandle: colors.onPrimary, + }, + ])( + 'renders keyboard focus feedback when value is $value and clears it on blur', + async ({ value, focusedHandle, restingHandle }) => { await render(); - const resting = renderedStyles(); await fireEvent(screen.getByRole('switch'), 'focus'); await jest.runAllTimersAsync(); - expect(renderedStyles()).toMatchSnapshot('focused'); - expect(renderedStyles()).not.toEqual(resting); + expect(getLayers().focusRing).toMatchObject({ opacity: 1 }); + expect(getLayers().stateLayer).toMatchObject({ opacity: 0.1 }); + expect(getLayers().handleFill).toMatchObject({ + backgroundColor: focusedHandle, + }); await fireEvent(screen.getByRole('switch'), 'blur'); await jest.runAllTimersAsync(); - expect(renderedStyles()).toEqual(resting); + expect(getLayers().focusRing).toMatchObject({ opacity: 0 }); + expect(getLayers().stateLayer).toMatchObject({ opacity: 0 }); + expect(getLayers().handleFill).toMatchObject({ + backgroundColor: restingHandle, + }); } ); it('only shows focus feedback for keyboard focus on web', async () => { jest.replaceProperty(Platform, 'OS', 'web'); await render(); - const resting = renderedStyles(); await fireEvent(screen.getByRole('switch'), 'focus', { currentTarget: { matches: () => false }, }); await jest.runAllTimersAsync(); - expect(renderedStyles()).toEqual(resting); + expect(getLayers().focusRing).toMatchObject({ opacity: 0 }); await fireEvent(screen.getByRole('switch'), 'focus', { currentTarget: { matches: () => true }, }); await jest.runAllTimersAsync(); - expect(renderedStyles()).toMatchSnapshot('keyboard focus'); - expect(renderedStyles()).not.toEqual(resting); + expect(getLayers().focusRing).toMatchObject({ opacity: 1 }); }); it.each(['disabled', 'readOnly'] as const)( @@ -139,13 +166,13 @@ describe('Switch focus state', () => { async (prop) => { const onValueChange = jest.fn(); const view = await render(); - const resting = renderedStyles(); await fireEvent(screen.getByRole('switch'), 'focus'); await fireEvent(screen.getByRole('switch'), 'hoverIn'); await fireEvent(screen.getByRole('switch'), 'pressIn'); await jest.runAllTimersAsync(); - expect(renderedStyles()).not.toEqual(resting); + expect(getLayers().focusRing).toMatchObject({ opacity: 1 }); + expect(getLayers().handle).toMatchObject({ width: 28, height: 28 }); await view.rerender( @@ -154,7 +181,12 @@ describe('Switch focus state', () => { await view.rerender(); await jest.runAllTimersAsync(); - expect(renderedStyles()).toEqual(resting); + expect(getLayers().focusRing).toMatchObject({ opacity: 0 }); + expect(getLayers().stateLayer).toMatchObject({ opacity: 0 }); + expect(getLayers().handle).toMatchObject({ width: 24, height: 24 }); + expect(getLayers().handleFill).toMatchObject({ + backgroundColor: colors.onPrimary, + }); } ); }); @@ -179,14 +211,16 @@ describe('Switch operability', () => { }); describe.each(['ios', 'android', 'web'] as const)('%s', (platform) => { - it.each([false, true])( - 'exposes a read-only switch with value %s as non-operable', - async (value) => { + it.each([ + { value: false, restingHandle: colors.outline }, + { value: true, restingHandle: colors.onPrimary }, + ])( + 'exposes a read-only switch with value $value as non-operable', + async ({ value, restingHandle }) => { jest.replaceProperty(Platform, 'OS', platform); const view = await render( ); - const enabledAppearance = renderedStyles(); await view.rerender( { aria-label="Dark theme" /> ); + await jest.runAllTimersAsync(); const control = screen.getByRole('switch', { name: 'Dark theme', @@ -203,7 +238,10 @@ describe('Switch operability', () => { }); expect(control).toHaveProp('focusable', false); expect(control).toHaveProp('tabIndex', -1); - expect(renderedStyles()).toEqual(enabledAppearance); + expect(getLayers().handleFill).toMatchObject({ + backgroundColor: restingHandle, + opacity: 1, + }); expect(control).toBeOnTheScreen(); } ); @@ -276,23 +314,28 @@ describe('Switch operability', () => { }); describe('Switch press feedback', () => { - it.each([false, true])( - 'grows immediately when value is %s and returns to rest on release', - async (value) => { + it.each([ + { value: false, restingSize: 16 }, + { value: true, restingSize: 24 }, + ])( + 'grows immediately when value is $value and returns to rest on release', + async ({ value, restingSize }) => { await render(); - const resting = renderedStyles(); await fireEvent(screen.getByRole('switch'), 'pressIn'); // Two frames, before the previous 100ms growth delay would elapse. jest.advanceTimersByTime(32); - expect(renderedStyles()).toMatchSnapshot('early press'); + expect(getLayers().handle.width).toBeGreaterThan(restingSize); await jest.runAllTimersAsync(); - expect(renderedStyles()).toMatchSnapshot('held press'); + expect(getLayers().handle).toMatchObject({ width: 28, height: 28 }); await fireEvent(screen.getByRole('switch'), 'pressOut'); await jest.runAllTimersAsync(); - expect(renderedStyles()).toEqual(resting); + expect(getLayers().handle).toMatchObject({ + width: restingSize, + height: restingSize, + }); } ); }); diff --git a/src/components/__tests__/__snapshots__/Switch.test.tsx.snap b/src/components/__tests__/__snapshots__/Switch.test.tsx.snap index 33ed7a065b..a8aedd0c21 100644 --- a/src/components/__tests__/__snapshots__/Switch.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Switch.test.tsx.snap @@ -1,592 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Switch focus state only shows focus feedback for keyboard focus on web: keyboard focus 1`] = ` -[ - { - "alignItems": "center", - "height": 48, - "justifyContent": "center", - "overflow": "visible", - "width": 52, - }, - { - "alignItems": "center", - "height": 48, - "justifyContent": "center", - "outline": "none", - "width": 52, - }, - { - "backgroundColor": "rgba(103, 80, 164, 1)", - "borderRadius": 9999, - "height": 32, - "opacity": 1, - "overflow": "visible", - "width": 52, - }, - { - "backgroundColor": "rgba(103, 80, 164, 1)", - "borderRadius": 9999, - "height": 40, - "left": 0, - "opacity": 0.1, - "pointerEvents": "none", - "position": "absolute", - "top": 4, - "transform": [ - { - "translateX": 16, - }, - ], - "width": 40, - }, - { - "alignItems": "center", - "height": 24, - "justifyContent": "center", - "left": 0, - "pointerEvents": "none", - "position": "absolute", - "top": 12, - "transform": [ - { - "translateX": 24, - }, - ], - "width": 24, - }, - { - "backgroundColor": "rgba(234, 221, 255, 1)", - "borderRadius": 9999, - "bottom": 0, - "left": 0, - "opacity": 1, - "position": "absolute", - "right": 0, - "top": 0, - }, - { - "borderColor": "rgba(98, 91, 113, 1)", - "borderRadius": 9999, - "borderWidth": 3, - "bottom": 3, - "left": -5, - "opacity": 1, - "pointerEvents": "none", - "position": "absolute", - "right": -5, - "top": 3, - }, -] -`; - -exports[`Switch focus state renders keyboard focus feedback when value is false and clears it on blur: focused 1`] = ` -[ - { - "alignItems": "center", - "height": 48, - "justifyContent": "center", - "overflow": "visible", - "width": 52, - }, - { - "alignItems": "center", - "height": 48, - "justifyContent": "center", - "width": 52, - }, - { - "backgroundColor": "rgba(230, 224, 233, 1)", - "borderRadius": 9999, - "height": 32, - "opacity": 1, - "overflow": "visible", - "width": 52, - }, - { - "borderColor": "rgba(121, 116, 126, 1)", - "borderRadius": 9999, - "borderWidth": 2, - "bottom": 0, - "left": 0, - "pointerEvents": "none", - "position": "absolute", - "right": 0, - "top": 0, - }, - { - "backgroundColor": "rgba(29, 27, 32, 1)", - "borderRadius": 9999, - "height": 40, - "left": 0, - "opacity": 0.1, - "pointerEvents": "none", - "position": "absolute", - "top": 4, - "transform": [ - { - "translateX": -4, - }, - ], - "width": 40, - }, - { - "alignItems": "center", - "height": 16, - "justifyContent": "center", - "left": 0, - "pointerEvents": "none", - "position": "absolute", - "top": 16, - "transform": [ - { - "translateX": 8, - }, - ], - "width": 16, - }, - { - "backgroundColor": "rgba(73, 69, 79, 1)", - "borderRadius": 9999, - "bottom": 0, - "left": 0, - "opacity": 1, - "position": "absolute", - "right": 0, - "top": 0, - }, - { - "borderColor": "rgba(98, 91, 113, 1)", - "borderRadius": 9999, - "borderWidth": 3, - "bottom": 3, - "left": -5, - "opacity": 1, - "pointerEvents": "none", - "position": "absolute", - "right": -5, - "top": 3, - }, -] -`; - -exports[`Switch focus state renders keyboard focus feedback when value is true and clears it on blur: focused 1`] = ` -[ - { - "alignItems": "center", - "height": 48, - "justifyContent": "center", - "overflow": "visible", - "width": 52, - }, - { - "alignItems": "center", - "height": 48, - "justifyContent": "center", - "width": 52, - }, - { - "backgroundColor": "rgba(103, 80, 164, 1)", - "borderRadius": 9999, - "height": 32, - "opacity": 1, - "overflow": "visible", - "width": 52, - }, - { - "backgroundColor": "rgba(103, 80, 164, 1)", - "borderRadius": 9999, - "height": 40, - "left": 0, - "opacity": 0.1, - "pointerEvents": "none", - "position": "absolute", - "top": 4, - "transform": [ - { - "translateX": 16, - }, - ], - "width": 40, - }, - { - "alignItems": "center", - "height": 24, - "justifyContent": "center", - "left": 0, - "pointerEvents": "none", - "position": "absolute", - "top": 12, - "transform": [ - { - "translateX": 24, - }, - ], - "width": 24, - }, - { - "backgroundColor": "rgba(234, 221, 255, 1)", - "borderRadius": 9999, - "bottom": 0, - "left": 0, - "opacity": 1, - "position": "absolute", - "right": 0, - "top": 0, - }, - { - "borderColor": "rgba(98, 91, 113, 1)", - "borderRadius": 9999, - "borderWidth": 3, - "bottom": 3, - "left": -5, - "opacity": 1, - "pointerEvents": "none", - "position": "absolute", - "right": -5, - "top": 3, - }, -] -`; - -exports[`Switch press feedback grows immediately when value is false and returns to rest on release: early press 1`] = ` -[ - { - "alignItems": "center", - "height": 48, - "justifyContent": "center", - "overflow": "visible", - "width": 52, - }, - { - "alignItems": "center", - "height": 48, - "justifyContent": "center", - "width": 52, - }, - { - "backgroundColor": "rgba(230, 224, 233, 1)", - "borderRadius": 9999, - "height": 32, - "opacity": 1, - "overflow": "visible", - "width": 52, - }, - { - "borderColor": "rgba(121, 116, 126, 1)", - "borderRadius": 9999, - "borderWidth": 2, - "bottom": 0, - "left": 0, - "pointerEvents": "none", - "position": "absolute", - "right": 0, - "top": 0, - }, - { - "backgroundColor": "rgba(29, 27, 32, 1)", - "borderRadius": 9999, - "height": 40, - "left": 0, - "opacity": 0.012550532338511944, - "pointerEvents": "none", - "position": "absolute", - "top": 4, - "transform": [ - { - "translateX": -4, - }, - ], - "width": 40, - }, - { - "alignItems": "center", - "height": 19.31374049870675, - "justifyContent": "center", - "left": 0, - "pointerEvents": "none", - "position": "absolute", - "top": 14.343129750646625, - "transform": [ - { - "translateX": 6.343129750646625, - }, - ], - "width": 19.31374049870675, - }, - { - "backgroundColor": "rgba(73, 69, 79, 1)", - "borderRadius": 9999, - "bottom": 0, - "left": 0, - "opacity": 1, - "position": "absolute", - "right": 0, - "top": 0, - }, - { - "borderColor": "rgba(98, 91, 113, 1)", - "borderRadius": 9999, - "borderWidth": 3, - "bottom": 3, - "left": -5, - "opacity": 0, - "pointerEvents": "none", - "position": "absolute", - "right": -5, - "top": 3, - }, -] -`; - -exports[`Switch press feedback grows immediately when value is false and returns to rest on release: held press 1`] = ` -[ - { - "alignItems": "center", - "height": 48, - "justifyContent": "center", - "overflow": "visible", - "width": 52, - }, - { - "alignItems": "center", - "height": 48, - "justifyContent": "center", - "width": 52, - }, - { - "backgroundColor": "rgba(230, 224, 233, 1)", - "borderRadius": 9999, - "height": 32, - "opacity": 1, - "overflow": "visible", - "width": 52, - }, - { - "borderColor": "rgba(121, 116, 126, 1)", - "borderRadius": 9999, - "borderWidth": 2, - "bottom": 0, - "left": 0, - "pointerEvents": "none", - "position": "absolute", - "right": 0, - "top": 0, - }, - { - "backgroundColor": "rgba(29, 27, 32, 1)", - "borderRadius": 9999, - "height": 40, - "left": 0, - "opacity": 0.1, - "pointerEvents": "none", - "position": "absolute", - "top": 4, - "transform": [ - { - "translateX": -4, - }, - ], - "width": 40, - }, - { - "alignItems": "center", - "height": 28, - "justifyContent": "center", - "left": 0, - "pointerEvents": "none", - "position": "absolute", - "top": 10, - "transform": [ - { - "translateX": 2, - }, - ], - "width": 28, - }, - { - "backgroundColor": "rgba(73, 69, 79, 1)", - "borderRadius": 9999, - "bottom": 0, - "left": 0, - "opacity": 1, - "position": "absolute", - "right": 0, - "top": 0, - }, - { - "borderColor": "rgba(98, 91, 113, 1)", - "borderRadius": 9999, - "borderWidth": 3, - "bottom": 3, - "left": -5, - "opacity": 0, - "pointerEvents": "none", - "position": "absolute", - "right": -5, - "top": 3, - }, -] -`; - -exports[`Switch press feedback grows immediately when value is true and returns to rest on release: early press 1`] = ` -[ - { - "alignItems": "center", - "height": 48, - "justifyContent": "center", - "overflow": "visible", - "width": 52, - }, - { - "alignItems": "center", - "height": 48, - "justifyContent": "center", - "width": 52, - }, - { - "backgroundColor": "rgba(103, 80, 164, 1)", - "borderRadius": 9999, - "height": 32, - "opacity": 1, - "overflow": "visible", - "width": 52, - }, - { - "backgroundColor": "rgba(103, 80, 164, 1)", - "borderRadius": 9999, - "height": 40, - "left": 0, - "opacity": 0.012550532338511944, - "pointerEvents": "none", - "position": "absolute", - "top": 4, - "transform": [ - { - "translateX": 16, - }, - ], - "width": 40, - }, - { - "alignItems": "center", - "height": 25.104580166235582, - "justifyContent": "center", - "left": 0, - "pointerEvents": "none", - "position": "absolute", - "top": 11.447709916882209, - "transform": [ - { - "translateX": 23.44770991688221, - }, - ], - "width": 25.104580166235582, - }, - { - "backgroundColor": "rgba(234, 221, 255, 1)", - "borderRadius": 9999, - "bottom": 0, - "left": 0, - "opacity": 1, - "position": "absolute", - "right": 0, - "top": 0, - }, - { - "borderColor": "rgba(98, 91, 113, 1)", - "borderRadius": 9999, - "borderWidth": 3, - "bottom": 3, - "left": -5, - "opacity": 0, - "pointerEvents": "none", - "position": "absolute", - "right": -5, - "top": 3, - }, -] -`; - -exports[`Switch press feedback grows immediately when value is true and returns to rest on release: held press 1`] = ` -[ - { - "alignItems": "center", - "height": 48, - "justifyContent": "center", - "overflow": "visible", - "width": 52, - }, - { - "alignItems": "center", - "height": 48, - "justifyContent": "center", - "width": 52, - }, - { - "backgroundColor": "rgba(103, 80, 164, 1)", - "borderRadius": 9999, - "height": 32, - "opacity": 1, - "overflow": "visible", - "width": 52, - }, - { - "backgroundColor": "rgba(103, 80, 164, 1)", - "borderRadius": 9999, - "height": 40, - "left": 0, - "opacity": 0.1, - "pointerEvents": "none", - "position": "absolute", - "top": 4, - "transform": [ - { - "translateX": 16, - }, - ], - "width": 40, - }, - { - "alignItems": "center", - "height": 28, - "justifyContent": "center", - "left": 0, - "pointerEvents": "none", - "position": "absolute", - "top": 10, - "transform": [ - { - "translateX": 22, - }, - ], - "width": 28, - }, - { - "backgroundColor": "rgba(234, 221, 255, 1)", - "borderRadius": 9999, - "bottom": 0, - "left": 0, - "opacity": 1, - "position": "absolute", - "right": 0, - "top": 0, - }, - { - "borderColor": "rgba(98, 91, 113, 1)", - "borderRadius": 9999, - "borderWidth": 3, - "bottom": 3, - "left": -5, - "opacity": 0, - "pointerEvents": "none", - "position": "absolute", - "right": -5, - "top": 3, - }, -] -`; - exports[`Switch render renders disabled off 1`] = ` Date: Fri, 30 Oct 2026 13:03:19 +0100 Subject: [PATCH 19/29] fix(switch): drop the pointer cursor from a read-only switch on web React Native Web's Pressable shows a pointer cursor whenever it is not `disabled`, and a read-only switch keeps `disabled` false on web so it can expose `aria-readonly`. The pointer suggested a click that does nothing, so a switch that cannot be operated now uses the default cursor. --- src/components/Switch/Switch.tsx | 8 ++++++++ src/components/__tests__/Switch.test.tsx | 11 +++++++++++ .../__tests__/__snapshots__/Switch.test.tsx.snap | 6 ++++++ 3 files changed, 25 insertions(+) diff --git a/src/components/Switch/Switch.tsx b/src/components/Switch/Switch.tsx index be7e093425..1abd12ea80 100644 --- a/src/components/Switch/Switch.tsx +++ b/src/components/Switch/Switch.tsx @@ -449,6 +449,11 @@ const Switch = ({ style={[ styles.touchable, Platform.OS === 'web' ? webNoOutline : undefined, + // React Native Web shows a pointer cursor on any Pressable that is + // not `disabled`, and a read-only switch stays enabled there. + Platform.OS === 'web' && !isInteractive + ? styles.nonInteractiveTouchable + : undefined, ]} > { expect(screen.getByRole('switch')).toBeEnabled(); }); + it('drops the pointer cursor on web when the switch becomes read-only', async () => { + jest.replaceProperty(Platform, 'OS', 'web'); + const view = await render(); + + expect(screen.getByRole('switch')).not.toHaveStyle({ cursor: 'auto' }); + + await view.rerender(); + + expect(screen.getByRole('switch')).toHaveStyle({ cursor: 'auto' }); + }); + it('exposes a missing-handler fallback as read-only on web', async () => { jest.replaceProperty(Platform, 'OS', 'web'); jest.spyOn(console, 'warn').mockImplementation(() => {}); diff --git a/src/components/__tests__/__snapshots__/Switch.test.tsx.snap b/src/components/__tests__/__snapshots__/Switch.test.tsx.snap index a8aedd0c21..4c81708152 100644 --- a/src/components/__tests__/__snapshots__/Switch.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Switch.test.tsx.snap @@ -55,6 +55,7 @@ exports[`Switch render renders disabled off 1`] = ` "width": 52, }, undefined, + undefined, ] } tabIndex={-1} @@ -271,6 +272,7 @@ exports[`Switch render renders disabled on 1`] = ` "width": 52, }, undefined, + undefined, ] } tabIndex={-1} @@ -467,6 +469,7 @@ exports[`Switch render renders off 1`] = ` "width": 52, }, undefined, + undefined, ] } tabIndex={0} @@ -666,6 +669,7 @@ exports[`Switch render renders on 1`] = ` "width": 52, }, undefined, + undefined, ] } tabIndex={0} @@ -846,6 +850,7 @@ exports[`Switch render renders with checked icon 1`] = ` "width": 52, }, undefined, + undefined, ] } tabIndex={0} @@ -1096,6 +1101,7 @@ exports[`Switch render renders with per-state icons 1`] = ` "width": 52, }, undefined, + undefined, ] } tabIndex={0} From ed5301a54fae248d36a4aa3f15bfec55fe752808 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 13:09:09 +0100 Subject: [PATCH 20/29] fix(switch): set keyboard focusability with tabIndex only `focusable` and `tabIndex` set the same thing. React Native's View treats `tabIndex` as an alias of `focusable` on Android, and React Native Web uses `tabIndex` when it is set and ignores the deprecated `focusable`. The tests check `tabIndex` instead. The disabled render snapshots now show Pressable's default `focusable={true}` next to `tabIndex={-1}`, because the jest View mock skips the aliasing that the real View applies. --- src/components/Switch/Switch.tsx | 1 - src/components/__tests__/Switch.test.tsx | 5 ++--- src/components/__tests__/__snapshots__/Switch.test.tsx.snap | 4 ++-- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/components/Switch/Switch.tsx b/src/components/Switch/Switch.tsx index 1abd12ea80..c725e56511 100644 --- a/src/components/Switch/Switch.tsx +++ b/src/components/Switch/Switch.tsx @@ -437,7 +437,6 @@ const Switch = ({ { await render(); - expect(screen.getByRole('switch')).toHaveProp('focusable', false); + expect(screen.getByRole('switch')).toHaveProp('tabIndex', -1); }); describe.each(['ios', 'android', 'web'] as const)('%s', (platform) => { @@ -236,7 +236,6 @@ describe('Switch operability', () => { checked: value, disabled: platform !== 'web', }); - expect(control).toHaveProp('focusable', false); expect(control).toHaveProp('tabIndex', -1); expect(getLayers().handleFill).toMatchObject({ backgroundColor: restingHandle, @@ -320,7 +319,7 @@ describe('Switch operability', () => { it('keeps an interactive switch focusable', async () => { await render(); - expect(screen.getByRole('switch')).toHaveProp('focusable', true); + expect(screen.getByRole('switch')).toHaveProp('tabIndex', 0); }); }); diff --git a/src/components/__tests__/__snapshots__/Switch.test.tsx.snap b/src/components/__tests__/__snapshots__/Switch.test.tsx.snap index 4c81708152..bb34d6ba84 100644 --- a/src/components/__tests__/__snapshots__/Switch.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Switch.test.tsx.snap @@ -35,7 +35,7 @@ exports[`Switch render renders disabled off 1`] = ` } accessible={true} collapsable={false} - focusable={false} + focusable={true} onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -252,7 +252,7 @@ exports[`Switch render renders disabled on 1`] = ` } accessible={true} collapsable={false} - focusable={false} + focusable={true} onBlur={[Function]} onClick={[Function]} onFocus={[Function]} From ed2ba445801ea8636088a71484e6e5e943cf553d Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 13:10:26 +0100 Subject: [PATCH 21/29] docs(switch): expose a row that owns the press as one switch In the pattern where a row owns the press and the switch only shows the state, screen readers got the on/off state from neither element: iOS merged the inner switch into the row, Android exposed a clickable row next to a separate disabled switch, and on web the focusable row had no role or `aria-checked`. The migration guide and the drawer, FAB and TextInput example rows now give the row the switch role and state and hide the inner switch from assistive technology. Also drop the em dashes from the Switch migration notes. --- docs/6.x/docs/guides/migration.md | 34 ++++++++++------ example/src/DrawerItems.tsx | 48 +++++++++++++++++------ example/src/Examples/FABExample.tsx | 4 +- example/src/Examples/TextInputExample.tsx | 9 ++++- 4 files changed, 67 insertions(+), 28 deletions(-) diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index d0d84f0537..f24b5a810f 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -592,23 +592,31 @@ A `Switch` with no `onValueChange` used to render as an enabled, focusable contr 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. -This most often shows up where the switch sits inside a row that owns the press: +This most often shows up where the switch sits inside a row that owns the press. Give the row the switch role and state, and hide the inner switch from assistive technology, so screen readers announce the row as one switch with its on/off state: -```tsx -// After (v6) - - - Dark Theme - - - - - +```diff +- ++ + + Dark Theme +- +- ++ ++ + + + ``` +On web, such a row toggles with Enter but not with Space, because React Native Web only handles Space for elements with the `button` role. + #### Selected icon color -The icon inside a selected switch now uses `onPrimaryContainer` instead of `primary`, matching the Material Design 3 spec and improving its contrast against the handle. If you theme `Switch` with a partial `theme.colors` override, add `onPrimaryContainer` alongside the roles you already override — omitted roles fall back to the app theme, which would leave the icon in the default palette. +The icon inside a selected switch now uses `onPrimaryContainer` instead of `primary`, matching the Material Design 3 spec and improving its contrast against the handle. If you theme `Switch` with a partial `theme.colors` override, add `onPrimaryContainer` alongside the roles you already override. Omitted roles fall back to the app theme, which would leave the icon in the default palette. ```diff const switchTheme = { @@ -623,4 +631,4 @@ The icon inside a selected switch now uses `onPrimaryContainer` instead of `prim #### Touch target height -`Switch` now reserves the 48dp minimum touch target Material Design requires, so it occupies 48dp of height instead of 40dp. Nothing painted changed size — the track is still 52×32 — but rows containing a switch may become slightly taller. +`Switch` now reserves the 48dp minimum touch target Material Design requires, so it occupies 48dp of height instead of 40dp. The painted track is still 52×32, but rows containing a switch may become slightly taller. diff --git a/example/src/DrawerItems.tsx b/example/src/DrawerItems.tsx index 1ec070eb5a..3c66f7d68e 100644 --- a/example/src/DrawerItems.tsx +++ b/example/src/DrawerItems.tsx @@ -172,57 +172,81 @@ function DrawerItems() { {dynamicThemeSupported ? ( - + Use Dynamic Theme - + ) : null} - + Dark Theme - + - + RTL - + - + Collapsed drawer * - + - + Custom font * - + - + {Platform.OS === 'ios' ? 'Highlight' : 'Ripple'} effect * - + diff --git a/example/src/Examples/FABExample.tsx b/example/src/Examples/FABExample.tsx index 1b8c3d61f9..0dd95cb10f 100644 --- a/example/src/Examples/FABExample.tsx +++ b/example/src/Examples/FABExample.tsx @@ -152,8 +152,10 @@ const FABExample = () => { /> ( - + )} diff --git a/example/src/Examples/TextInputExample.tsx b/example/src/Examples/TextInputExample.tsx index 831f45a4c7..95dfdb1f7f 100644 --- a/example/src/Examples/TextInputExample.tsx +++ b/example/src/Examples/TextInputExample.tsx @@ -143,10 +143,15 @@ const TextInputDemo = ({ variant }: TextInputDemoProps) => { {/* Controls */} Controls {SWITCH_CONTROLS.map(({ label, key }) => ( - toggleControl(key)}> + toggleControl(key)} + role="switch" + aria-checked={controls[key]} + > {label} - + From 07ceeeeee7b0fbf0ae3c2d03ffc228acb72eab68 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 14:09:27 +0100 Subject: [PATCH 22/29] refactor(switch): spell the centering helper the American way --- src/components/Switch/Switch.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/Switch/Switch.tsx b/src/components/Switch/Switch.tsx index c725e56511..1729a19534 100644 --- a/src/components/Switch/Switch.tsx +++ b/src/components/Switch/Switch.tsx @@ -97,9 +97,9 @@ const { thickness: FOCUS_THICKNESS, outerOffset: FOCUS_OUTER_OFFSET } = stateTokens.focusIndicator; const FOCUS_RING_INSET = -(FOCUS_OUTER_OFFSET + FOCUS_THICKNESS); // Every painted layer is smaller than the touch target and absolutely -// positioned, so each one is centred against it. -const centreY = (size: number) => (TOUCH_TARGET_SIZE - size) / 2; -const TRACK_TOP = centreY(TRACK_HEIGHT); +// positioned, so each one is centered against it. +const centerY = (size: number) => (TOUCH_TARGET_SIZE - size) / 2; +const TRACK_TOP = centerY(TRACK_HEIGHT); function restingHandleSize(checked: boolean, hasIcon: boolean): number { if (hasIcon) return ICON_HANDLE; @@ -576,7 +576,7 @@ const styles = StyleSheet.create({ }, stateLayer: { position: 'absolute', - top: centreY(STATE_LAYER_SIZE), + top: centerY(STATE_LAYER_SIZE), width: STATE_LAYER_SIZE, height: STATE_LAYER_SIZE, borderRadius: cornerFull, @@ -598,7 +598,7 @@ const styles = StyleSheet.create({ }, iconWrap: { position: 'absolute', - top: centreY(SELECTED_ICON), + top: centerY(SELECTED_ICON), width: SELECTED_ICON, height: SELECTED_ICON, pointerEvents: 'none', From b5618709c3c6240c0c8c3d12c6438e295b854aa8 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 14:10:09 +0100 Subject: [PATCH 23/29] fix(switch): let a press on a read-only switch reach its row on web A read-only switch keeps `disabled` false on web so it can expose `aria-readonly`, but React Native Web's Pressable then still takes the responder and stops the click, even without `onPress`. Inside a row that owns the press, such as `List.Item` with the switch in `right`, clicking the switch did nothing on web, while iOS and Android pass the press to the row. A switch that cannot be operated now has `pointerEvents: 'none'` on web instead of the `cursor: 'auto'` override, so the click and the cursor come from what is underneath. --- src/components/Switch/Switch.tsx | 7 +++--- src/components/__tests__/Switch.test.tsx | 28 ++++++++++++++---------- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/src/components/Switch/Switch.tsx b/src/components/Switch/Switch.tsx index 1729a19534..e969588fe5 100644 --- a/src/components/Switch/Switch.tsx +++ b/src/components/Switch/Switch.tsx @@ -448,8 +448,9 @@ const Switch = ({ style={[ styles.touchable, Platform.OS === 'web' ? webNoOutline : undefined, - // React Native Web shows a pointer cursor on any Pressable that is - // not `disabled`, and a read-only switch stays enabled there. + // A read-only switch stays enabled on web, where React Native Web's + // Pressable would take the press and stop the click from reaching a + // row that owns it. Native platforms disable the Pressable instead. Platform.OS === 'web' && !isInteractive ? styles.nonInteractiveTouchable : undefined, @@ -556,7 +557,7 @@ const styles = StyleSheet.create({ justifyContent: 'center', }, nonInteractiveTouchable: { - cursor: 'auto', + pointerEvents: 'none', }, track: { width: TRACK_WIDTH, diff --git a/src/components/__tests__/Switch.test.tsx b/src/components/__tests__/Switch.test.tsx index c2aae66d98..b8e6acffe7 100644 --- a/src/components/__tests__/Switch.test.tsx +++ b/src/components/__tests__/Switch.test.tsx @@ -1,4 +1,4 @@ -import { Platform } from 'react-native'; +import { Platform, Pressable } from 'react-native'; import { afterEach, describe, expect, it, jest } from '@jest/globals'; import { getAnimatedStyle } from 'react-native-reanimated'; @@ -259,6 +259,21 @@ describe('Switch operability', () => { expect(control).toBeOnTheScreen(); }); + it('passes a press on a read-only switch to the row that owns it', async () => { + jest.replaceProperty(Platform, 'OS', platform); + const user = userEvent.setup(); + const onRowPress = jest.fn(); + await render( + + + + ); + + await user.press(screen.getByRole('switch')); + + expect(onRowPress).toHaveBeenCalledTimes(1); + }); + it('keeps disabled semantics when read-only is also set', async () => { jest.replaceProperty(Platform, 'OS', platform); await render(); @@ -276,17 +291,6 @@ describe('Switch operability', () => { expect(screen.getByRole('switch')).toBeEnabled(); }); - it('drops the pointer cursor on web when the switch becomes read-only', async () => { - jest.replaceProperty(Platform, 'OS', 'web'); - const view = await render(); - - expect(screen.getByRole('switch')).not.toHaveStyle({ cursor: 'auto' }); - - await view.rerender(); - - expect(screen.getByRole('switch')).toHaveStyle({ cursor: 'auto' }); - }); - it('exposes a missing-handler fallback as read-only on web', async () => { jest.replaceProperty(Platform, 'OS', 'web'); jest.spyOn(console, 'warn').mockImplementation(() => {}); From b67220b5047d16135939b312dbbc3f43c5c4892c Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 14:13:53 +0100 Subject: [PATCH 24/29] fix(switch): show focus again when a focused switch becomes operable On web a focused switch that becomes disabled or read-only keeps DOM focus, because React Native Web only changes its `tabIndex` and ARIA state. The focus handlers were detached and the focus state cleared meanwhile, so once the switch was operable again it was still focused but showed no ring or state layer. Focus and blur are now tracked regardless of interactivity, and the focus ring, state layer and focus handle color are shown only while the switch can be operated. --- src/components/Switch/Switch.tsx | 61 +++++++++++++++--------- src/components/__tests__/Switch.test.tsx | 60 +++++++++++++++++++++-- 2 files changed, 95 insertions(+), 26 deletions(-) diff --git a/src/components/Switch/Switch.tsx b/src/components/Switch/Switch.tsx index e969588fe5..0ab76026b9 100644 --- a/src/components/Switch/Switch.tsx +++ b/src/components/Switch/Switch.tsx @@ -3,9 +3,7 @@ import { Platform, Pressable, StyleSheet, - type NativeSyntheticEvent, type StyleProp, - type TargetedEvent, View, type ViewStyle, } from 'react-native'; @@ -184,24 +182,41 @@ const Switch = ({ const checkedSV = useSharedValue(checked ? 1 : 0); const hasIconSV = useSharedValue(hasIcon ? 1 : 0); const isDisabledSV = useSharedValue(isDisabled ? 1 : 0); + const isInteractiveSV = useSharedValue(isInteractive ? 1 : 0); React.useEffect(() => { checkedSV.value = checked ? 1 : 0; hasIconSV.value = hasIcon ? 1 : 0; isDisabledSV.value = isDisabled ? 1 : 0; - }, [checked, hasIcon, isDisabled, checkedSV, hasIconSV, isDisabledSV]); - - // A switch that stops being interactive loses its handlers, so one that was - // hovered, pressed, or focused at that moment would never get the matching - // hover-out, press-out, or blur and would keep painting that state. + isInteractiveSV.value = isInteractive ? 1 : 0; + }, [ + checked, + hasIcon, + isDisabled, + isInteractive, + checkedSV, + hasIconSV, + isDisabledSV, + isInteractiveSV, + ]); + + // A switch that stops being interactive loses its press and hover handlers, + // so one that was pressed or hovered at that moment would never get the + // matching press-out or hover-out and would keep painting that state. React.useEffect(() => { if (isInteractive) return; pressedSV.value = 0; spacePressedRef.current = false; hoveredSV.value = 0; - focusedSV.value = 0; - }, [isInteractive, pressedSV, hoveredSV, focusedSV]); + }, [isInteractive, pressedSV, hoveredSV]); + + // Focus is tracked even while the switch is not interactive, because it can + // keep focus meanwhile (web only changes `tabIndex`). It is shown only while + // the switch can be operated, so it returns once the switch is operable again. + const isFocusShownSV = useDerivedValue(() => + focusedSV.value === 1 && isInteractiveSV.value === 1 ? 1 : 0 + ); const colors = React.useMemo(() => getDefaultSwitchColors(theme), [theme]); @@ -279,7 +294,7 @@ const Switch = ({ useAnimatedReaction( (): StateOpacityKey | null => { if (pressedSV.value === 1) return 'pressed'; - if (focusedSV.value === 1) return 'focused'; + if (isFocusShownSV.value === 1) return 'focused'; if (hoveredSV.value === 1) return 'hovered'; return null; }, @@ -314,7 +329,7 @@ const Switch = ({ ? colors.checkedPressedHandleColor : colors.uncheckedPressedHandleColor; } - if (focusedSV.value === 1) { + if (isFocusShownSV.value === 1) { return isCheckedNow ? colors.checkedFocusHandleColor : colors.uncheckedFocusHandleColor; @@ -359,7 +374,7 @@ const Switch = ({ })); const focusRingAnimatedStyle = useAnimatedStyle(() => ({ - opacity: focusedSV.value, + opacity: isFocusShownSV.value, })); const paint = resolveSwitchPaint(colors, isEnabled, checked); @@ -380,8 +395,8 @@ const Switch = ({ const trackOpacityValue = isDisabled ? DISABLED_TRACK_OPACITY : 1; const iconSize = checked ? SELECTED_ICON : UNSELECTED_ICON; - // A non-interactive switch gets no press, hover, or focus affordances at all. - // It stays in the accessibility tree, so its state is still announced. + // A non-interactive switch gets no press or hover handlers. It stays in the + // accessibility tree, so its state is still announced. const interactionProps = isInteractive ? { onPress: () => onValueChange?.(!checked), @@ -397,15 +412,6 @@ const Switch = ({ onHoverOut: () => { hoveredSV.value = 0; }, - onFocus: (e: NativeSyntheticEvent) => { - if (!isKeyboardFocusEvent(e)) return; - focusedSV.value = 1; - }, - onBlur: () => { - focusedSV.value = 0; - pressedSV.value = 0; - spacePressedRef.current = false; - }, // React Native Web only handles Space for button roles. ...(Platform.OS === 'web' ? { @@ -439,6 +445,15 @@ const Switch = ({ disabled={Platform.OS === 'web' ? isDisabled : !isInteractive} tabIndex={isInteractive ? 0 : -1} aria-readonly={Platform.OS === 'web' ? isAnnouncedReadOnly : undefined} + onFocus={(e) => { + if (!isKeyboardFocusEvent(e)) return; + focusedSV.value = 1; + }} + onBlur={() => { + focusedSV.value = 0; + pressedSV.value = 0; + spacePressedRef.current = false; + }} {...interactionProps} android_ripple={{ color: 'transparent' }} role="switch" diff --git a/src/components/__tests__/Switch.test.tsx b/src/components/__tests__/Switch.test.tsx index b8e6acffe7..41fa607029 100644 --- a/src/components/__tests__/Switch.test.tsx +++ b/src/components/__tests__/Switch.test.tsx @@ -161,17 +161,72 @@ describe('Switch focus state', () => { expect(getLayers().focusRing).toMatchObject({ opacity: 1 }); }); + it('paints no focus feedback when a read-only switch receives focus on web', async () => { + jest.replaceProperty(Platform, 'OS', 'web'); + await render(); + + await fireEvent(screen.getByRole('switch'), 'focus', { + currentTarget: { matches: () => true }, + }); + await jest.runAllTimersAsync(); + + expect(getLayers().focusRing).toMatchObject({ opacity: 0 }); + expect(getLayers().stateLayer).toMatchObject({ opacity: 0 }); + }); + + // On web the switch keeps DOM focus while it is not interactive, because + // React Native Web only changes its `tabIndex` and ARIA state. it.each(['disabled', 'readOnly'] as const)( - 'clears interaction feedback when the switch becomes %s', + 'shows focus feedback again on web when a focused switch stops being %s', async (prop) => { + jest.replaceProperty(Platform, 'OS', 'web'); const onValueChange = jest.fn(); const view = await render(); await fireEvent(screen.getByRole('switch'), 'focus'); + await view.rerender( + + ); + await jest.runAllTimersAsync(); + expect(getLayers().focusRing).toMatchObject({ opacity: 0 }); + expect(getLayers().stateLayer).toMatchObject({ opacity: 0 }); + + await view.rerender(); + await jest.runAllTimersAsync(); + + expect(getLayers().focusRing).toMatchObject({ opacity: 1 }); + expect(getLayers().stateLayer).toMatchObject({ opacity: 0.1 }); + expect(getLayers().handleFill).toMatchObject({ + backgroundColor: colors.primaryContainer, + }); + } + ); + + it('keeps focus feedback hidden on web when the switch lost focus while read-only', async () => { + jest.replaceProperty(Platform, 'OS', 'web'); + const onValueChange = jest.fn(); + const view = await render(); + + await fireEvent(screen.getByRole('switch'), 'focus'); + await view.rerender( + + ); + await fireEvent(screen.getByRole('switch'), 'blur'); + await view.rerender(); + await jest.runAllTimersAsync(); + + expect(getLayers().focusRing).toMatchObject({ opacity: 0 }); + }); + + it.each(['disabled', 'readOnly'] as const)( + 'clears hover and press feedback when the switch becomes %s', + async (prop) => { + const onValueChange = jest.fn(); + const view = await render(); + await fireEvent(screen.getByRole('switch'), 'hoverIn'); await fireEvent(screen.getByRole('switch'), 'pressIn'); await jest.runAllTimersAsync(); - expect(getLayers().focusRing).toMatchObject({ opacity: 1 }); expect(getLayers().handle).toMatchObject({ width: 28, height: 28 }); await view.rerender( @@ -181,7 +236,6 @@ describe('Switch focus state', () => { await view.rerender(); await jest.runAllTimersAsync(); - expect(getLayers().focusRing).toMatchObject({ opacity: 0 }); expect(getLayers().stateLayer).toMatchObject({ opacity: 0 }); expect(getLayers().handle).toMatchObject({ width: 24, height: 24 }); expect(getLayers().handleFill).toMatchObject({ From eea2d54e607dc1981484611dc635416fa4be008c Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 14:14:16 +0100 Subject: [PATCH 25/29] test(switch): cover read-only and disabled switches not warning --- src/components/__tests__/Switch.test.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/components/__tests__/Switch.test.tsx b/src/components/__tests__/Switch.test.tsx index 41fa607029..9ae66457e4 100644 --- a/src/components/__tests__/Switch.test.tsx +++ b/src/components/__tests__/Switch.test.tsx @@ -256,6 +256,17 @@ describe('Switch operability', () => { ); }); + it.each(['readOnly', 'disabled'] as const)( + 'does not warn when %s is set without onValueChange', + async (prop) => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + + await render(); + + expect(console.warn).not.toHaveBeenCalled(); + } + ); + it('is not focusable when it has no way to be operated', async () => { jest.spyOn(console, 'warn').mockImplementation(() => {}); From 9f91fe66447f210bde455d7b35aa6c1fdc25fcd0 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 14:15:24 +0100 Subject: [PATCH 26/29] docs(switch): describe the switch icon color and size for 5.x readers The guide covers migrating from 5.x, where `Switch` was the platform switch with no icon and no 40dp box, so notes saying the icon color changed from `primary` and the height grew from 40dp only made sense against a 6.0 prerelease. Both notes now describe the 6.x switch: the selected icon uses `onPrimaryContainer`, and the switch is a 52x32 track inside a 48dp tall touch target. --- docs/6.x/docs/guides/migration.md | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index f24b5a810f..328ac4a638 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -614,21 +614,21 @@ This most often shows up where the switch sits inside a row that owns the press. On web, such a row toggles with Enter but not with Space, because React Native Web only handles Space for elements with the `button` role. -#### Selected icon color +#### Icon color -The icon inside a selected switch now uses `onPrimaryContainer` instead of `primary`, matching the Material Design 3 spec and improving its contrast against the handle. If you theme `Switch` with a partial `theme.colors` override, add `onPrimaryContainer` alongside the roles you already override. Omitted roles fall back to the app theme, which would leave the icon in the default palette. +When selected, the icon inside the handle uses `onPrimaryContainer`. If you pass `Switch` a `theme` with a partial `theme.colors` override, include `onPrimaryContainer` with the roles you override, because omitted roles fall back to the app theme: -```diff - const switchTheme = { - colors: { - primary: theme.colors.tertiary, - onPrimary: theme.colors.onTertiary, - primaryContainer: theme.colors.tertiaryContainer, -+ onPrimaryContainer: theme.colors.onTertiaryContainer, - }, - }; +```tsx +const switchTheme = { + colors: { + primary: theme.colors.tertiary, + onPrimary: theme.colors.onTertiary, + primaryContainer: theme.colors.tertiaryContainer, + onPrimaryContainer: theme.colors.onTertiaryContainer, + }, +}; ``` -#### Touch target height +#### Size -`Switch` now reserves the 48dp minimum touch target Material Design requires, so it occupies 48dp of height instead of 40dp. The painted track is still 52×32, but rows containing a switch may become slightly taller. +`Switch` is drawn by Paper instead of the platform switch: a 52×32 track inside a 48dp tall touch target, so rows containing a switch may change height. From 8c18f6f015b816de2c4bc97235b4d4847023aaac Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 14:55:15 +0100 Subject: [PATCH 27/29] test(switch): cover pressing an operable switch inside a row on web --- src/components/__tests__/Switch.test.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/components/__tests__/Switch.test.tsx b/src/components/__tests__/Switch.test.tsx index 9ae66457e4..35a0baafb5 100644 --- a/src/components/__tests__/Switch.test.tsx +++ b/src/components/__tests__/Switch.test.tsx @@ -348,6 +348,23 @@ describe('Switch operability', () => { }); }); + it('toggles an operable switch inside a pressable row on web without pressing the row', async () => { + jest.replaceProperty(Platform, 'OS', 'web'); + const user = userEvent.setup(); + const onValueChange = jest.fn(); + const onRowPress = jest.fn(); + await render( + + + + ); + + await user.press(screen.getByRole('switch')); + + expect(onValueChange).toHaveBeenCalledWith(false); + expect(onRowPress).not.toHaveBeenCalled(); + }); + it('exposes the read-only state on web', async () => { jest.replaceProperty(Platform, 'OS', 'web'); await render(); From 48f0b3093b03cb017efb5b3766b05d119679afc7 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 14:55:56 +0100 Subject: [PATCH 28/29] test(switch): cover focus handling on a read-only switch --- src/components/__tests__/Switch.test.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/components/__tests__/Switch.test.tsx b/src/components/__tests__/Switch.test.tsx index 35a0baafb5..2274d3ca00 100644 --- a/src/components/__tests__/Switch.test.tsx +++ b/src/components/__tests__/Switch.test.tsx @@ -172,6 +172,9 @@ describe('Switch focus state', () => { expect(getLayers().focusRing).toMatchObject({ opacity: 0 }); expect(getLayers().stateLayer).toMatchObject({ opacity: 0 }); + expect(getLayers().handleFill).toMatchObject({ + backgroundColor: colors.onPrimary, + }); }); // On web the switch keeps DOM focus while it is not interactive, because @@ -218,6 +221,22 @@ describe('Switch focus state', () => { expect(getLayers().focusRing).toMatchObject({ opacity: 0 }); }); + it('shows focus feedback on web once a switch focused while read-only becomes operable', async () => { + jest.replaceProperty(Platform, 'OS', 'web'); + const onValueChange = jest.fn(); + const view = await render( + + ); + + await fireEvent(screen.getByRole('switch'), 'focus', { + currentTarget: { matches: () => true }, + }); + await view.rerender(); + await jest.runAllTimersAsync(); + + expect(getLayers().focusRing).toMatchObject({ opacity: 1 }); + }); + it.each(['disabled', 'readOnly'] as const)( 'clears hover and press feedback when the switch becomes %s', async (prop) => { From 5f3d727caccdf2b6580cbd4a91269c9a40a61659 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 14:56:40 +0100 Subject: [PATCH 29/29] docs(switch): tie the icon color note to checkedIcon --- docs/6.x/docs/guides/migration.md | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index 328ac4a638..40668d3293 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -616,16 +616,30 @@ On web, such a row toggles with Enter but not with Space, because React Native W #### Icon color -When selected, the icon inside the handle uses `onPrimaryContainer`. If you pass `Switch` a `theme` with a partial `theme.colors` override, include `onPrimaryContainer` with the roles you override, because omitted roles fall back to the app theme: +When selected, an icon set with `checkedIcon` uses `onPrimaryContainer`. If you set `checkedIcon` and pass a `theme` with a partial `theme.colors` override, include `onPrimaryContainer`, because omitted roles fall back to the app theme: ```tsx -const switchTheme = { - colors: { - primary: theme.colors.tertiary, - onPrimary: theme.colors.onTertiary, - primaryContainer: theme.colors.tertiaryContainer, - onPrimaryContainer: theme.colors.onTertiaryContainer, - }, +import { Switch, useTheme } from 'react-native-paper'; + +const TertiarySwitch = ({ value, onValueChange }) => { + const theme = useTheme(); + const switchTheme = { + colors: { + primary: theme.colors.tertiary, + onPrimary: theme.colors.onTertiary, + primaryContainer: theme.colors.tertiaryContainer, + onPrimaryContainer: theme.colors.onTertiaryContainer, + }, + }; + + return ( + + ); }; ```