From aae3f40148718ad87f98a36b1098ef72fc522e8f Mon Sep 17 00:00:00 2001 From: likevy Date: Thu, 3 Sep 2026 12:42:51 +0200 Subject: [PATCH 01/16] 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 cd3d19824b5de37b3cbd64411382dd805e97af32 Mon Sep 17 00:00:00 2001 From: likevy Date: Thu, 3 Sep 2026 12:43:00 +0200 Subject: [PATCH 02/16] 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 025f12202d18fc62173b3b4c06964e11725d0996 Mon Sep 17 00:00:00 2001 From: likevy Date: Thu, 3 Sep 2026 12:43:10 +0200 Subject: [PATCH 03/16] 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 85f9b9c40d8d2211c9cfed7ce291f5792dcbe2cc Mon Sep 17 00:00:00 2001 From: likevy Date: Thu, 3 Sep 2026 12:43:19 +0200 Subject: [PATCH 04/16] 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 618096c6a4be8bd8c3c9c0b33ef734980ba25f2f Mon Sep 17 00:00:00 2001 From: likevy Date: Thu, 3 Sep 2026 12:43:28 +0200 Subject: [PATCH 05/16] 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 e51de006aa..102a6f6848 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -357,3 +357,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 49bdd5f99d..6ceb1c9a36 100644 --- a/example/src/DrawerItems.tsx +++ b/example/src/DrawerItems.tsx @@ -177,7 +177,7 @@ function DrawerItems() { Use Dynamic Theme - + @@ -186,7 +186,7 @@ function DrawerItems() { Dark Theme - + @@ -195,7 +195,7 @@ function DrawerItems() { RTL - + @@ -204,7 +204,7 @@ function DrawerItems() { Collapsed drawer * - + @@ -213,7 +213,7 @@ function DrawerItems() { Custom font * - + @@ -224,7 +224,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 0e038ea38dfb7817bc217d661c14aaba189316d0 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 4 Sep 2026 11:54:11 +0200 Subject: [PATCH 06/16] 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 24a3bf508eb8d3b555a2b8b032847bc8c7ef5166 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 4 Sep 2026 11:54:35 +0200 Subject: [PATCH 07/16] 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 549bf85da045403db3b5a7752f7b2f123c1b69c9 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 4 Sep 2026 11:55:18 +0200 Subject: [PATCH 08/16] 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 811c2a9821b28e51eca7770b9cb961de865eefa2 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 4 Sep 2026 12:49:37 +0200 Subject: [PATCH 09/16] 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 b1dc4b9cdef34968d399858acbd0eea73b1d6b67 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 4 Sep 2026 12:50:02 +0200 Subject: [PATCH 10/16] 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 47ad755d2ecfffeec5790e88fdc5d76ccccf7a6b Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 4 Sep 2026 12:50:21 +0200 Subject: [PATCH 11/16] 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 b9857001e49e6be56d29a71211879d3eb2031bf6 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 4 Sep 2026 13:05:55 +0200 Subject: [PATCH 12/16] 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 102a6f6848..79705ae61f 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -390,6 +390,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 44847abe0a529aa00b23003ac1763e6b069220fc Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 4 Sep 2026 13:06:15 +0200 Subject: [PATCH 13/16] 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 d7d2dd0c34f77849e2d737c7149897cebc1b33d4 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 4 Sep 2026 13:06:36 +0200 Subject: [PATCH 14/16] 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 9a3a84b7f1eb52660becb38d8c8c58a2a884ad23 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 4 Sep 2026 13:11:57 +0200 Subject: [PATCH 15/16] 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 ec53e2493783d87e73fc46e0a35e04b66a2e5370 Mon Sep 17 00:00:00 2001 From: likevy Date: Wed, 23 Sep 2026 11:50:05 +0200 Subject: [PATCH 16/16] 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 79705ae61f..fb82a83a29 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -364,7 +364,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`] = `