From beb361fbabe0b4b5b22411f8160ebfae07db8edd Mon Sep 17 00:00:00 2001 From: likevy Date: Thu, 3 Sep 2026 12:42:51 +0200 Subject: [PATCH 01/15] 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 b0ca4b4bd7..eca65c1b64 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 cc26a47a83cb7b85e10cc7ba6fa0c4016b6e8560 Mon Sep 17 00:00:00 2001 From: likevy Date: Thu, 3 Sep 2026 12:43:00 +0200 Subject: [PATCH 02/15] 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 eca65c1b64..5bd044f0c9 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 f3cc7c63f9ba99a54ea6bba04f71b8c9b2d2255e Mon Sep 17 00:00:00 2001 From: likevy Date: Thu, 3 Sep 2026 12:43:10 +0200 Subject: [PATCH 03/15] 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 5bd044f0c9..40add5f540 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 393f34ef65fa73d3184f92c913180ddf052e5ea5 Mon Sep 17 00:00:00 2001 From: likevy Date: Thu, 3 Sep 2026 12:43:19 +0200 Subject: [PATCH 04/15] 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 40add5f540..547ce68ed2 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 67d220f7de8d4be8ec15b88ff69b76d31d4af364 Mon Sep 17 00:00:00 2001 From: likevy Date: Thu, 3 Sep 2026 12:43:28 +0200 Subject: [PATCH 05/15] feat: require switches to declare how they can be operated BREAKING CHANGE: `Switch` now requires `onValueChange`, `readOnly`, or `disabled`, and reserves the 48dp minimum touch target (40dp before). See the 6.x migration guide. --- 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 a4d7123a09..f9a587b842 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -279,3 +279,39 @@ const theme = { style={{ fontSize: 16, color: '#1C1B1F' }} /> ``` + +### 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 94afa3136c..00489864cc 100644 --- a/example/src/DrawerItems.tsx +++ b/example/src/DrawerItems.tsx @@ -178,7 +178,7 @@ function DrawerItems() { Use Dynamic Theme - + @@ -187,7 +187,7 @@ function DrawerItems() { Dark Theme - + @@ -196,7 +196,7 @@ function DrawerItems() { RTL - + @@ -205,7 +205,7 @@ function DrawerItems() { Collapsed drawer * - + @@ -214,7 +214,7 @@ function DrawerItems() { Custom font * - + @@ -225,7 +225,7 @@ function DrawerItems() { {isIOS ? '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 547ce68ed2..be8726b194 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 '../../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 8863e2fa20..8571269cb7 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -127,7 +127,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 1eb7db71cd906cd3fa9b94a2fa3ad455feca5017 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 4 Sep 2026 11:54:11 +0200 Subject: [PATCH 06/15] 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 be8726b194..993af821f1 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 1644985170d82d4fddddc44eb17baab5c5874ed4 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 4 Sep 2026 11:54:35 +0200 Subject: [PATCH 07/15] 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 993af821f1..450b9d1ca0 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 45e4b9876afa95fec0668657ce0abcbb8b61a7f1 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 4 Sep 2026 11:55:18 +0200 Subject: [PATCH 08/15] 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 3fb785f26e5b147a6844bc305f3d8334adb49800 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 4 Sep 2026 12:49:37 +0200 Subject: [PATCH 09/15] 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 450b9d1ca0..45e5f8af7a 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 f63bbd180c0e1f0160a3c9b79f1a5b934a6ec4f5 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 4 Sep 2026 12:50:02 +0200 Subject: [PATCH 10/15] 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 ae1a012bace9f122ca523be49ff6c17c30c3e1e5 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 4 Sep 2026 12:50:21 +0200 Subject: [PATCH 11/15] 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 45e5f8af7a..97d6feabd5 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 d49a164f9dc6eb2b5059808705f4d94c1f59409e Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 4 Sep 2026 13:05:55 +0200 Subject: [PATCH 12/15] 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 f9a587b842..ac56958eae 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -312,6 +312,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 d326a20884b6bddf5bcdc442bbd64592c839f833 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 4 Sep 2026 13:06:15 +0200 Subject: [PATCH 13/15] 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 fe0bd3078637da093cd6e6a3b9df845608d028e2 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 4 Sep 2026 13:06:36 +0200 Subject: [PATCH 14/15] 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 553266852a2f132bbda45ed4aea02084d1009bb6 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 4 Sep 2026 13:11:57 +0200 Subject: [PATCH 15/15] 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 () => {