From 562f94758557604194242f01086a687e607597f5 Mon Sep 17 00:00:00 2001 From: Adrian Cotfas Date: Tue, 19 May 2026 14:25:12 +0300 Subject: [PATCH] feat: modernize Switch to MD3 --- example/src/Examples/SwitchExample.tsx | 157 +- jest/testSetup.js | 4 + src/components/Switch/Switch.tsx | 534 ++++++- src/components/Switch/tokens.ts | 55 + src/components/Switch/utils.ts | 240 +-- src/components/__tests__/Switch.test.tsx | 269 ++-- .../__snapshots__/Switch.test.tsx.snap | 1291 ++++++++++++++++- src/index.tsx | 5 +- 8 files changed, 2090 insertions(+), 465 deletions(-) create mode 100644 src/components/Switch/tokens.ts diff --git a/example/src/Examples/SwitchExample.tsx b/example/src/Examples/SwitchExample.tsx index da959eda47..fc7d491355 100644 --- a/example/src/Examples/SwitchExample.tsx +++ b/example/src/Examples/SwitchExample.tsx @@ -1,73 +1,106 @@ import * as React from 'react'; -import { Platform, StyleSheet, View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; -import { Palette, Switch, Text, TouchableRipple } from 'react-native-paper'; +import { Switch, Text, useTheme } from 'react-native-paper'; +import type { SwitchColors } from 'react-native-paper'; import ScreenWrapper from '../ScreenWrapper'; +const Row = ({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) => ( + + {label} + {children} + +); + const SwitchExample = () => { - const [valueNormal, setNormalValue] = React.useState(true); - const [valueCustom, setCustomValue] = React.useState(true); + const theme = useTheme(); + const [defaultOn, setDefaultOn] = React.useState(true); + const [defaultCheckedIconOn, setDefaultCheckedIconOn] = React.useState(true); + const [defaultIconOn, setDefaultIconOn] = React.useState(true); + const [customOn, setCustomOn] = React.useState(true); + const [customIconOn, setCustomIconOn] = React.useState(true); + const [disableAll, setDisableAll] = React.useState(false); - const switchValueNormalLabel = `switch ${ - valueNormal === true ? 'on' : 'off' - }`; - const switchValueCustomlLabel = `switch ${ - valueCustom === true ? 'on' : 'off' - }`; + const tertiaryColors: Partial = React.useMemo( + () => ({ + checkedTrackColor: theme.colors.tertiary, + checkedHandleColor: theme.colors.onTertiary, + checkedHoverHandleColor: theme.colors.tertiaryContainer, + checkedFocusHandleColor: theme.colors.tertiaryContainer, + checkedPressedHandleColor: theme.colors.tertiaryContainer, + checkedIconColor: theme.colors.tertiary, + checkedStateLayerColor: theme.colors.tertiary, + focusIndicatorColor: theme.colors.tertiary, + }), + [theme] + ); - return Platform.OS === 'android' ? ( + return ( - setNormalValue(!valueNormal)}> - - Normal {switchValueNormalLabel} - - - - - - setCustomValue(!valueCustom)}> - - Custom {switchValueCustomlLabel} - - - - - - - Switch on (disabled) - - - - Switch off (disabled) - - - - ) : ( - - - Normal {switchValueNormalLabel} + + + + + + + + + setNormalValue(!valueNormal)} + value={defaultIconOn} + onValueChange={setDefaultIconOn} + checkedIcon="check" + uncheckedIcon="close" + disabled={disableAll} /> - - - Custom {switchValueCustomlLabel} + + + setCustomValue(!valueCustom)} - color={Palette.tertiary50} + value={customOn} + onValueChange={setCustomOn} + colors={tertiaryColors} + disabled={disableAll} /> - - - Switch on (disabled) - - - - Switch off (disabled) - - + + + + + + + + + + + ); }; @@ -85,6 +118,16 @@ const styles = StyleSheet.create({ paddingVertical: 8, paddingHorizontal: 16, }, + right: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + separator: { + height: 1, + marginHorizontal: 16, + marginVertical: 16, + }, }); export default SwitchExample; diff --git a/jest/testSetup.js b/jest/testSetup.js index 5088ab5585..5110c85551 100644 --- a/jest/testSetup.js +++ b/jest/testSetup.js @@ -8,6 +8,10 @@ jest.mock('react-native-worklets', () => require('react-native-worklets/lib/module/mock') ); +jest.mock('react-native-reanimated', () => + require('react-native-reanimated/mock') +); + jest.mock('@react-native-vector-icons/material-design-icons', () => { const React = require('react'); const { Text } = require('react-native'); diff --git a/src/components/Switch/Switch.tsx b/src/components/Switch/Switch.tsx index 04b0fef0c6..05113032ec 100644 --- a/src/components/Switch/Switch.tsx +++ b/src/components/Switch/Switch.tsx @@ -1,108 +1,492 @@ import * as React from 'react'; +import { StyleSheet, type StyleProp, View, type ViewStyle } from 'react-native'; + +import Animated, { + Easing, + ReduceMotion, + useAnimatedStyle, + useDerivedValue, + useSharedValue, + withDelay, + withSequence, + withSpring, + withTiming, +} from 'react-native-reanimated'; + +import { SwitchTokens } from './tokens'; import { - NativeModules, - Platform, - StyleProp, - Switch as NativeSwitch, - ViewStyle, -} from 'react-native'; - -import { getSwitchColor } from './utils'; + resolveSwitchColors, + resolveSwitchPaint, + type SwitchColors, +} from './utils'; +import { useLocale } from '../../core/locale'; import { useInternalTheme } from '../../core/theming'; -import type { ThemeProp } from '../../types'; +import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; +import { tokens } from '../../theme/tokens'; +import { toRawSpring } from '../../theme/tokens/sys/motion'; +import { cornerFull } from '../../theme/tokens/sys/shape'; +import type { StateOpacityKey, ThemeProp } from '../../types'; +import { useFocusVisible } from '../../utils/useFocusVisible'; +import Icon, { type IconSource } from '../Icon'; +import TouchableRipple from '../TouchableRipple/TouchableRipple'; -const version = NativeModules.PlatformConstants - ? NativeModules.PlatformConstants.reactNativeVersion - : undefined; +export type { SwitchColors } from './utils'; -export type Props = React.ComponentPropsWithRef & { - /** - * Disable toggling the switch. - */ - disabled?: boolean; - /** - * Value of the switch, true means 'on', false means 'off'. - */ +export type Props = { + /** Whether the switch is on. */ value?: boolean; - /** - * Custom color for switch. - */ - color?: string; - /** - * Callback called with the new value when it changes. - */ - onValueChange?: Function; + /** 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 */ + checkedIcon?: IconSource; + /** Icon shown inside the handle when unchecked. */ + uncheckedIcon?: IconSource; + /** Per-slot color overrides. Unspecified keys fall back to theme defaults. */ + colors?: Partial; style?: StyleProp; - /** - * @optional - */ + testID?: string; theme?: ThemeProp; + accessibilityLabel?: string; }; +const { + trackWidth: TRACK_WIDTH, + trackHeight: TRACK_HEIGHT, + trackOutlineWidth: TRACK_OUTLINE_WIDTH, + stateLayerSize: STATE_LAYER_SIZE, + selectedHandleSize: SELECTED_HANDLE, + unselectedHandleSize: UNSELECTED_HANDLE, + iconHandleSize: ICON_HANDLE, + pressedHandleSize: PRESSED_HANDLE, + selectedIconSize: SELECTED_ICON, + unselectedIconSize: UNSELECTED_ICON, + disabledSelectedHandleOpacity: DISABLED_SELECTED_HANDLE_OPACITY, + disabledUnselectedHandleOpacity: DISABLED_UNSELECTED_HANDLE_OPACITY, + disabledSelectedIconOpacity: DISABLED_SELECTED_ICON_OPACITY, + disabledUnselectedIconOpacity: DISABLED_UNSELECTED_ICON_OPACITY, + disabledTrackOpacity: DISABLED_TRACK_OPACITY, +} = SwitchTokens; + +const { state: stateTokens } = tokens.md.sys; +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; + +// 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; +} + +function offsetForHandle( + checked: boolean, + handleSize: number, + pressed: boolean +): number { + const minBound = (TRACK_HEIGHT - handleSize) / 2; + const handlePadding = (TRACK_HEIGHT - SELECTED_HANDLE) / 2; + const maxBound = TRACK_WIDTH - SELECTED_HANDLE - handlePadding; + if (pressed) { + return checked ? maxBound - TRACK_OUTLINE_WIDTH : TRACK_OUTLINE_WIDTH; + } + return checked ? maxBound : minBound; +} + +function stateLayerOpacityFor(interaction: StateOpacityKey | null): number { + switch (interaction) { + case 'pressed': + return stateOpacity.pressed; + case 'focused': + return 0; + case 'hovered': + return stateOpacity.hovered; + default: + return 0; + } +} + /** - * Switch is a visual toggle between two mutually exclusive states — on and off. + * Material 3 toggle between two mutually exclusive states (on / off). * * ## Usage * ```js * import * as React from 'react'; * import { Switch } from 'react-native-paper'; * - * const MyComponent = () => { - * const [isSwitchOn, setIsSwitchOn] = React.useState(false); - * - * const onToggleSwitch = () => setIsSwitchOn(!isSwitchOn); - * - * return ; + * const Example = () => { + * const [on, setOn] = React.useState(false); + * return ; * }; - * - * export default MyComponent; * ``` */ const Switch = ({ value, disabled, onValueChange, - color, + checkedIcon, + uncheckedIcon, + colors: colorOverrides, + style, + testID, theme: themeOverrides, - ...rest + accessibilityLabel, }: Props) => { const theme = useInternalTheme(themeOverrides); - const { checkedColor, onTintColor, thumbTintColor } = getSwitchColor({ - theme, - disabled, - value, - color, - }); - - const props = - version && version.major === 0 && version.minor <= 56 - ? { - onTintColor, - thumbTintColor, - } - : Platform.OS === 'web' - ? { - activeTrackColor: onTintColor, - thumbColor: thumbTintColor, - activeThumbColor: checkedColor, - } - : { - thumbColor: thumbTintColor, - trackColor: { - true: onTintColor, - false: onTintColor, - }, - }; + const reduceMotion = useReduceMotion(); + const { direction } = useLocale(); + const xSign = direction === 'ltr' ? 1 : -1; + + const checked = !!value; + const isDisabled = !!disabled; + const isEnabled = !isDisabled; + const iconSource = checked ? checkedIcon : uncheckedIcon; + const hasIcon = iconSource !== undefined; + + const [hovered, setHovered] = React.useState(false); + const [pressed, setPressed] = React.useState(false); + const { + focusVisible, + onFocus: handleFocus, + onBlur: handleBlur, + } = useFocusVisible(); + + const colors = React.useMemo( + () => resolveSwitchColors(theme, colorOverrides), + [theme, colorOverrides] + ); + + const reanimatedReduceMotion = reduceMotion + ? ReduceMotion.Always + : ReduceMotion.Never; + + const springConfig = React.useMemo( + () => ({ + ...toRawSpring(theme.motion.spring.fast.spatial), + reduceMotion: reanimatedReduceMotion, + }), + [theme.motion.spring.fast.spatial, reanimatedReduceMotion] + ); + const stateLayerRiseConfig = React.useMemo( + () => ({ + duration: theme.motion.duration.medium3, + easing: Easing.bezier(...theme.motion.easing.standard), + reduceMotion: reanimatedReduceMotion, + }), + [ + theme.motion.duration.medium3, + theme.motion.easing.standard, + reanimatedReduceMotion, + ] + ); + const stateLayerPulseFallConfig = React.useMemo( + () => ({ + duration: theme.motion.duration.medium3, + easing: Easing.bezier(...theme.motion.easing.standardAccelerate), + reduceMotion: reanimatedReduceMotion, + }), + [ + theme.motion.duration.medium3, + theme.motion.easing.standardAccelerate, + reanimatedReduceMotion, + ] + ); + + const interaction: StateOpacityKey | null = pressed + ? 'pressed' + : focusVisible + ? 'focused' + : hovered + ? 'hovered' + : null; + + const handleSize = useSharedValue(restingHandleSize(checked, hasIcon)); + const handleOffset = useSharedValue( + offsetForHandle(checked, restingHandleSize(checked, hasIcon), false) + ); + const stateLayerAlpha = useSharedValue(0); + const prevInteractionRef = React.useRef(null); + + React.useEffect(() => { + const targetSize = pressed + ? PRESSED_HANDLE + : restingHandleSize(checked, hasIcon); + const targetOffset = offsetForHandle(checked, targetSize, pressed); + if (pressed) { + handleSize.value = withDelay( + PRESS_GROW_DELAY, + withTiming(targetSize, { duration: 0 }) + ); + handleOffset.value = withDelay( + PRESS_GROW_DELAY, + withTiming(targetOffset, { duration: 0 }) + ); + } else { + handleSize.value = withSpring(targetSize, springConfig); + handleOffset.value = withSpring(targetOffset, springConfig); + } + }, [checked, hasIcon, pressed, handleSize, handleOffset, springConfig]); + + React.useEffect(() => { + const wasPressed = prevInteractionRef.current === 'pressed'; + const target = stateLayerOpacityFor(interaction); + + if (wasPressed && interaction !== 'pressed') { + // On release: rise to peak, then fall to the next state (0/hover/focus). + stateLayerAlpha.value = withSequence( + withTiming(stateOpacity.pressed, stateLayerRiseConfig), + withTiming(target, stateLayerPulseFallConfig) + ); + } else if (interaction === null) { + // Hover/focus exit without prior press: snap off. + stateLayerAlpha.value = 0; + } else { + stateLayerAlpha.value = withTiming(target, stateLayerRiseConfig); + } + + prevInteractionRef.current = interaction; + }, [ + interaction, + stateLayerAlpha, + stateLayerRiseConfig, + stateLayerPulseFallConfig, + ]); + + const handleCenter = useDerivedValue( + () => handleOffset.value + handleSize.value / 2 + ); + + const handleAnimatedStyle = useAnimatedStyle(() => ({ + width: handleSize.value, + height: handleSize.value, + top: (STATE_LAYER_SIZE - handleSize.value) / 2, + transform: [{ translateX: xSign * handleOffset.value }], + })); + + const stateLayerAnimatedStyle = useAnimatedStyle(() => ({ + opacity: stateLayerAlpha.value, + transform: [ + { translateX: xSign * (handleCenter.value - STATE_LAYER_SIZE / 2) }, + ], + })); + + // Icon translateX follows the handle's center. + // Vertical position is fixed so the icon never moves vertically + // as the spring oscillates around its target. + const iconAnimatedStyle = useAnimatedStyle(() => ({ + transform: [ + { translateX: xSign * (handleCenter.value - SELECTED_ICON / 2) }, + ], + })); + + const paint = resolveSwitchPaint(colors, isEnabled, checked, interaction); + const stateLayerColor = checked + ? colors.checkedStateLayerColor + : colors.uncheckedStateLayerColor; + const showOutline = paint.border !== 'transparent'; + const handleOpacity = isDisabled + ? checked + ? DISABLED_SELECTED_HANDLE_OPACITY + : DISABLED_UNSELECTED_HANDLE_OPACITY + : 1; + const iconOpacity = isDisabled + ? checked + ? DISABLED_SELECTED_ICON_OPACITY + : DISABLED_UNSELECTED_ICON_OPACITY + : 1; + const trackOpacityValue = isDisabled ? DISABLED_TRACK_OPACITY : 1; + const iconSize = checked ? SELECTED_ICON : UNSELECTED_ICON; return ( - + + onValueChange?.(!checked)} + onPressIn={() => setPressed(true)} + onPressOut={() => setPressed(false)} + onHoverIn={() => setHovered(true)} + onHoverOut={() => setHovered(false)} + onFocus={handleFocus} + onBlur={handleBlur} + rippleColor="transparent" + accessibilityRole="switch" + accessibilityState={{ disabled: isDisabled, checked }} + accessibilityLiveRegion="polite" + accessibilityLabel={accessibilityLabel} + testID={testID} + style={styles.touchable} + theme={theme} + > + + {showOutline ? ( + + ) : null} + + + + + + + {/* Disabled-only: opaque `surface` backdrop. The tinted fill above + composites over it, reproducing the native math avoiding the PlatformColor alpha limitation. */} + {isDisabled ? ( + + ) : null} + + + {iconSource ? ( + + {/* The same icon twice; one tinted fill on top of the other, + reproducing the native math avoiding the PlatformColor alpha limitation. */} + {isDisabled ? ( + + + + ) : null} + + + + + ) : null} + + {focusVisible ? ( + + ) : null} + ); }; +const styles = StyleSheet.create({ + wrapper: { + width: TRACK_WIDTH, + height: STATE_LAYER_SIZE, + alignItems: 'center', + justifyContent: 'center', + overflow: 'visible', + }, + touchable: { + width: TRACK_WIDTH, + height: STATE_LAYER_SIZE, + alignItems: 'center', + justifyContent: 'center', + }, + track: { + width: TRACK_WIDTH, + height: TRACK_HEIGHT, + borderRadius: cornerFull, + overflow: 'visible', + }, + outline: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + borderWidth: TRACK_OUTLINE_WIDTH, + borderRadius: cornerFull, + }, + stateLayer: { + position: 'absolute', + top: 0, + left: 0, + width: STATE_LAYER_SIZE, + height: STATE_LAYER_SIZE, + borderRadius: cornerFull, + }, + handle: { + position: 'absolute', + left: 0, + alignItems: 'center', + justifyContent: 'center', + }, + handleFill: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + borderRadius: cornerFull, + }, + iconWrap: { + position: 'absolute', + left: 0, + top: (STATE_LAYER_SIZE - SELECTED_ICON) / 2, + width: SELECTED_ICON, + height: SELECTED_ICON, + }, + focusRing: { + position: 'absolute', + }, + absoluteFill: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + }, +}); + export default Switch; diff --git a/src/components/Switch/tokens.ts b/src/components/Switch/tokens.ts new file mode 100644 index 0000000000..53422c7bad --- /dev/null +++ b/src/components/Switch/tokens.ts @@ -0,0 +1,55 @@ +import type { ColorRole } from '../../theme/types'; + +const sizes = { + trackWidth: 52, + trackHeight: 32, + trackOutlineWidth: 2, + stateLayerSize: 40, + + selectedHandleSize: 24, + unselectedHandleSize: 16, + iconHandleSize: 24, + pressedHandleSize: 28, + selectedIconSize: 16, + unselectedIconSize: 16, + + disabledSelectedHandleOpacity: 1.0, + disabledUnselectedHandleOpacity: 0.38, + disabledSelectedIconOpacity: 0.38, + disabledUnselectedIconOpacity: 0.38, + disabledTrackOpacity: 0.12, +} as const; + +const colors = { + selectedHandleColor: 'onPrimary', + selectedHoverHandleColor: 'primaryContainer', + selectedFocusHandleColor: 'primaryContainer', + selectedPressedHandleColor: 'primaryContainer', + + unselectedHandleColor: 'outline', + unselectedHoverHandleColor: 'onSurfaceVariant', + unselectedFocusHandleColor: 'onSurfaceVariant', + unselectedPressedHandleColor: 'onSurfaceVariant', + + selectedIconColor: 'primary', + unselectedIconColor: 'surfaceContainerHighest', + + selectedTrackColor: 'primary', + unselectedTrackColor: 'surfaceContainerHighest', + unselectedTrackOutlineColor: 'outline', + + disabledSelectedHandleColor: 'surface', + disabledSelectedIconColor: 'onSurface', + disabledSelectedTrackColor: 'onSurface', + disabledUnselectedHandleColor: 'onSurface', + disabledUnselectedIconColor: 'surfaceContainerHighest', + disabledUnselectedTrackColor: 'surfaceContainerHighest', + disabledUnselectedTrackOutlineColor: 'onSurface', + + selectedStateLayerColor: 'primary', + unselectedStateLayerColor: 'onSurface', + + focusIndicatorColor: 'secondary', +} as const satisfies Record; + +export const SwitchTokens = { ...sizes, ...colors }; diff --git a/src/components/Switch/utils.ts b/src/components/Switch/utils.ts index cebf7264f2..e80e0f5690 100644 --- a/src/components/Switch/utils.ts +++ b/src/components/Switch/utils.ts @@ -1,106 +1,154 @@ -import { Platform, type ColorValue } from 'react-native'; - -import setColor from 'color'; - -import { - grey400, - grey800, - grey50, - grey700, - white, - black, -} from '../../theme/colors'; -import type { InternalTheme } from '../../types'; - -type BaseProps = { - theme: InternalTheme; - disabled?: boolean; - value?: boolean; +import type { ColorValue } from 'react-native'; + +import { SwitchTokens } from './tokens'; +import type { InternalTheme, StateOpacityKey } from '../../types'; + +export type SwitchColors = { + // Enabled, base + checkedHandleColor: ColorValue; + checkedTrackColor: ColorValue; + checkedBorderColor: ColorValue; + checkedIconColor: ColorValue; + uncheckedHandleColor: ColorValue; + uncheckedTrackColor: ColorValue; + uncheckedBorderColor: ColorValue; + uncheckedIconColor: ColorValue; + + // Enabled, interacted handle (track stays constant per spec) + checkedHoverHandleColor: ColorValue; + checkedFocusHandleColor: ColorValue; + checkedPressedHandleColor: ColorValue; + uncheckedHoverHandleColor: ColorValue; + uncheckedFocusHandleColor: ColorValue; + uncheckedPressedHandleColor: ColorValue; + + // State layer tints + checkedStateLayerColor: ColorValue; + uncheckedStateLayerColor: ColorValue; + + // Focus indicator ring + focusIndicatorColor: ColorValue; + + // Disabled + disabledCheckedHandleColor: ColorValue; + disabledCheckedTrackColor: ColorValue; + disabledCheckedBorderColor: ColorValue; + disabledCheckedIconColor: ColorValue; + disabledUncheckedHandleColor: ColorValue; + disabledUncheckedTrackColor: ColorValue; + disabledUncheckedBorderColor: ColorValue; + disabledUncheckedIconColor: ColorValue; }; -const getCheckedColor = ({ - theme, - color, -}: { - theme: InternalTheme; - color?: ColorValue; -}) => { - if (color) { - return color; - } - - return theme.colors.primary; -}; - -const getThumbTintColor = ({ - theme, - disabled, - value, - checkedColor, -}: BaseProps & { checkedColor: ColorValue }) => { - const isIOS = Platform.OS === 'ios'; - - if (isIOS) { - return undefined; - } - - if (disabled) { - if (theme.dark) { - return grey800; - } - return grey400; - } - - if (value) { - return checkedColor; - } - - if (theme.dark) { - return grey400; - } - return grey50; +export function getDefaultSwitchColors(theme: InternalTheme): SwitchColors { + const t = SwitchTokens; + const c = theme.colors; + return { + checkedHandleColor: c[t.selectedHandleColor], + checkedTrackColor: c[t.selectedTrackColor], + checkedBorderColor: 'transparent', + checkedIconColor: c[t.selectedIconColor], + + uncheckedHandleColor: c[t.unselectedHandleColor], + uncheckedTrackColor: c[t.unselectedTrackColor], + uncheckedBorderColor: c[t.unselectedTrackOutlineColor], + uncheckedIconColor: c[t.unselectedIconColor], + + checkedHoverHandleColor: c[t.selectedHoverHandleColor], + checkedFocusHandleColor: c[t.selectedFocusHandleColor], + checkedPressedHandleColor: c[t.selectedPressedHandleColor], + uncheckedHoverHandleColor: c[t.unselectedHoverHandleColor], + uncheckedFocusHandleColor: c[t.unselectedFocusHandleColor], + uncheckedPressedHandleColor: c[t.unselectedPressedHandleColor], + + checkedStateLayerColor: c[t.selectedStateLayerColor], + uncheckedStateLayerColor: c[t.unselectedStateLayerColor], + + focusIndicatorColor: c[t.focusIndicatorColor], + + disabledCheckedHandleColor: c[t.disabledSelectedHandleColor], + disabledCheckedTrackColor: c[t.disabledSelectedTrackColor], + disabledCheckedBorderColor: 'transparent', + disabledCheckedIconColor: c[t.disabledSelectedIconColor], + disabledUncheckedHandleColor: c[t.disabledUnselectedHandleColor], + disabledUncheckedTrackColor: c[t.disabledUnselectedTrackColor], + disabledUncheckedBorderColor: c[t.disabledUnselectedTrackOutlineColor], + disabledUncheckedIconColor: c[t.disabledUnselectedIconColor], + }; +} + +export function resolveSwitchColors( + theme: InternalTheme, + overrides: Partial | undefined +): SwitchColors { + return { ...getDefaultSwitchColors(theme), ...overrides }; +} + +export type SwitchPaint = { + handle: ColorValue; + track: ColorValue; + border: ColorValue; + icon: ColorValue; }; -const getOnTintColor = ({ - theme, - disabled, - value, - checkedColor, -}: BaseProps & { checkedColor: ColorValue }) => { - const isIOS = Platform.OS === 'ios'; - - if (isIOS) { - return checkedColor; - } - - if (disabled) { - if (theme.dark) { - return setColor(white).alpha(0.06).rgb().string(); - } - return setColor(black).alpha(0.12).rgb().string(); +/** + * Resolve every paint slot for the current state in one pass. The handle + * color further branches on `interaction` (hover/focus/pressed swap to the + * primary-container family); track / border / icon are interaction-agnostic + * per the MD3 spec. + */ +export function resolveSwitchPaint( + c: SwitchColors, + isEnabled: boolean, + checked: boolean, + interaction: StateOpacityKey | null +): SwitchPaint { + if (!isEnabled) { + return checked + ? { + handle: c.disabledCheckedHandleColor, + track: c.disabledCheckedTrackColor, + border: c.disabledCheckedBorderColor, + icon: c.disabledCheckedIconColor, + } + : { + handle: c.disabledUncheckedHandleColor, + track: c.disabledUncheckedTrackColor, + border: c.disabledUncheckedBorderColor, + icon: c.disabledUncheckedIconColor, + }; } - if (value) { - return theme.colors.surfaceContainerHighest; + let handle: ColorValue; + if (checked) { + handle = + interaction === 'pressed' + ? c.checkedPressedHandleColor + : interaction === 'focused' + ? c.checkedFocusHandleColor + : interaction === 'hovered' + ? c.checkedHoverHandleColor + : c.checkedHandleColor; + return { + handle, + track: c.checkedTrackColor, + border: c.checkedBorderColor, + icon: c.checkedIconColor, + }; } - if (theme.dark) { - return grey700; - } - return 'rgb(178, 175, 177)'; -}; - -export const getSwitchColor = ({ - theme, - disabled, - value, - color, -}: BaseProps & { color?: ColorValue }) => { - const checkedColor = getCheckedColor({ theme, color }); - + handle = + interaction === 'pressed' + ? c.uncheckedPressedHandleColor + : interaction === 'focused' + ? c.uncheckedFocusHandleColor + : interaction === 'hovered' + ? c.uncheckedHoverHandleColor + : c.uncheckedHandleColor; return { - onTintColor: getOnTintColor({ theme, disabled, value, checkedColor }), - thumbTintColor: getThumbTintColor({ theme, disabled, value, checkedColor }), - checkedColor, + handle, + track: c.uncheckedTrackColor, + border: c.uncheckedBorderColor, + icon: c.uncheckedIconColor, }; -}; +} diff --git a/src/components/__tests__/Switch.test.tsx b/src/components/__tests__/Switch.test.tsx index 27637c085b..3417d65831 100644 --- a/src/components/__tests__/Switch.test.tsx +++ b/src/components/__tests__/Switch.test.tsx @@ -1,224 +1,121 @@ import * as React from 'react'; -import { Platform } from 'react-native'; -import color from 'color'; - -import { getTheme } from '../../core/theming'; -import { render } from '../../test-utils'; -import { - white, - black, - grey400, - grey50, - grey800, - pink500, - grey700, -} from '../../theme/colors'; +import { fireEvent, render } from '../../test-utils'; +import { LightTheme, DarkTheme } from '../../theme/schemes'; import Switch from '../Switch/Switch'; -import { getSwitchColor } from '../Switch/utils'; - -it('renders on switch', () => { - const tree = render().toJSON(); - - expect(tree).toMatchSnapshot(); -}); - -it('renders off switch', () => { - const tree = render().toJSON(); - - expect(tree).toMatchSnapshot(); -}); - -it('renders disabled switch', () => { - const tree = render().toJSON(); - - expect(tree).toMatchSnapshot(); -}); - -it('renders switch with color', () => { - const tree = render().toJSON(); - - expect(tree).toMatchSnapshot(); -}); +import { getDefaultSwitchColors } from '../Switch/utils'; -describe('getSwitchColor - checked color', () => { - it('should return custom color', () => { - expect( - getSwitchColor({ - theme: getTheme(), - color: 'purple', - }) - ).toMatchObject({ - checkedColor: 'purple', - }); +describe('Switch render', () => { + it('renders on', () => { + expect(render().toJSON()).toMatchSnapshot(); }); - it('should return theme color, for theme version 3', () => { - expect( - getSwitchColor({ - theme: getTheme(), - }) - ).toMatchObject({ - checkedColor: getTheme().colors.primary, - }); + it('renders off', () => { + expect(render().toJSON()).toMatchSnapshot(); }); -}); - -describe('getSwitchColor - thumb tint color', () => { - it('should return undefined for iOS platform', () => { - Platform.OS = 'ios'; - expect( - getSwitchColor({ - theme: getTheme(true), - }) - ).toMatchObject({ - thumbTintColor: undefined, - }); + it('renders disabled on', () => { + expect(render().toJSON()).toMatchSnapshot(); }); - it('should return correct disabled color, dark mode', () => { - Platform.OS = 'android'; - + it('renders disabled off', () => { expect( - getSwitchColor({ - theme: getTheme(true), - disabled: true, - }) - ).toMatchObject({ - thumbTintColor: grey800, - }); + render().toJSON() + ).toMatchSnapshot(); }); - it('should return correct disabled color, light mode', () => { + it('renders with checked icon', () => { expect( - getSwitchColor({ - theme: getTheme(), - disabled: true, - }) - ).toMatchObject({ - thumbTintColor: grey400, - }); + render().toJSON() + ).toMatchSnapshot(); }); - it('should return correct checked color if there is value', () => { - Platform.OS = 'android'; - + it('renders with per-state icons', () => { expect( - getSwitchColor({ - theme: getTheme(), - value: true, - color: 'purple', - }) - ).toMatchObject({ - thumbTintColor: 'purple', - }); + render( + + ).toJSON() + ).toMatchSnapshot(); }); - it('should return theme checked color, dark mode', () => { - Platform.OS = 'android'; - + it('renders with colors override', () => { expect( - getSwitchColor({ - theme: getTheme(true), - }) - ).toMatchObject({ - thumbTintColor: grey400, - }); - }); - - it('should return theme checked color, light mode', () => { - Platform.OS = 'android'; - - expect( - getSwitchColor({ - theme: getTheme(), - }) - ).toMatchObject({ - thumbTintColor: grey50, - }); + render( + + ).toJSON() + ).toMatchSnapshot(); }); }); -describe('getSwitchColor - on tint color', () => { - it('should return checked color for iOS platform, for theme version 3', () => { - Platform.OS = 'ios'; - - expect( - getSwitchColor({ - theme: getTheme(), - }) - ).toMatchObject({ - onTintColor: getTheme().colors.primary, - }); +describe('Switch interaction', () => { + it('toggles to true when off and pressed', () => { + const onValueChange = jest.fn(); + const { getByRole } = render( + + ); + fireEvent.press(getByRole('switch')); + expect(onValueChange).toHaveBeenCalledWith(true); }); - it('should return custom color for iOS platform', () => { - Platform.OS = 'ios'; - - expect( - getSwitchColor({ - theme: getTheme(), - color: 'purple', - }) - ).toMatchObject({ - onTintColor: 'purple', - }); + it('toggles to false when on and pressed', () => { + const onValueChange = jest.fn(); + const { getByRole } = render( + + ); + fireEvent.press(getByRole('switch')); + expect(onValueChange).toHaveBeenCalledWith(false); }); - it('should return correct disabled color, for theme version 3, dark mode', () => { - Platform.OS = 'android'; - - expect( - getSwitchColor({ - theme: getTheme(true), - disabled: true, - }) - ).toMatchObject({ - onTintColor: color(white).alpha(0.06).rgb().string(), - }); + it('does not fire onValueChange when disabled', () => { + const onValueChange = jest.fn(); + const { getByRole } = render( + + ); + fireEvent.press(getByRole('switch')); + expect(onValueChange).not.toHaveBeenCalled(); }); +}); - it('should return correct disabled color, light mode', () => { - expect( - getSwitchColor({ - theme: getTheme(), - disabled: true, - }) - ).toMatchObject({ - onTintColor: color(black).alpha(0.12).rgb().string(), +describe('Switch accessibility', () => { + it('uses role="switch" and reports checked/disabled', () => { + const { getByRole } = render(); + const node = getByRole('switch'); + expect(node.props.accessibilityState).toEqual({ + disabled: true, + checked: true, }); }); - it('should return correct checked color if there is value', () => { - expect( - getSwitchColor({ - theme: getTheme(), - value: true, - color: 'purple', - }) - ).toMatchObject({ - checkedColor: 'purple', - }); + it('forwards accessibilityLabel', () => { + const { getByLabelText } = render( + + ); + expect(getByLabelText('My switch')).toBeTruthy(); }); +}); - it('should return theme checked color, dark mode', () => { - expect( - getSwitchColor({ - theme: getTheme(true), - }) - ).toMatchObject({ - onTintColor: grey700, - }); +describe('getDefaultSwitchColors', () => { + it('resolves all color roles against the light theme palette', () => { + const colors = getDefaultSwitchColors(LightTheme); + expect(colors.checkedTrackColor).toBe(LightTheme.colors.primary); + expect(colors.checkedHandleColor).toBe(LightTheme.colors.onPrimary); + expect(colors.uncheckedTrackColor).toBe( + LightTheme.colors.surfaceContainerHighest + ); + expect(colors.uncheckedHandleColor).toBe(LightTheme.colors.outline); + expect(colors.focusIndicatorColor).toBe(LightTheme.colors.secondary); + expect(colors.checkedBorderColor).toBe('transparent'); }); - it('should return theme checked color, light mode', () => { - expect( - getSwitchColor({ - theme: getTheme(), - }) - ).toMatchObject({ - onTintColor: 'rgb(178, 175, 177)', - }); + it('resolves all color roles against the dark theme palette', () => { + const colors = getDefaultSwitchColors(DarkTheme); + expect(colors.checkedTrackColor).toBe(DarkTheme.colors.primary); + expect(colors.checkedHandleColor).toBe(DarkTheme.colors.onPrimary); + expect(colors.uncheckedTrackColor).toBe( + DarkTheme.colors.surfaceContainerHighest + ); }); }); diff --git a/src/components/__tests__/__snapshots__/Switch.test.tsx.snap b/src/components/__tests__/__snapshots__/Switch.test.tsx.snap index f7839d9be4..ac302fa4a5 100644 --- a/src/components/__tests__/__snapshots__/Switch.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Switch.test.tsx.snap @@ -1,70 +1,1261 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`renders disabled switch 1`] = ` - +> + + + + + + + + + + + `; -exports[`renders off switch 1`] = ` - + + + + + + + + + +`; + +exports[`Switch render renders off 1`] = ` + +> + + + + + + + + + + `; -exports[`renders on switch 1`] = ` - + + + + + + + + +`; + +exports[`Switch render renders with checked icon 1`] = ` + +> + + + + + + + + + + + check + + + + `; -exports[`renders switch with color 1`] = ` - + + + + + + + + +`; + +exports[`Switch render renders with per-state icons 1`] = ` + +> + + + + + + + + + + + check + + + + `; diff --git a/src/index.tsx b/src/index.tsx index 1430e797c4..2e56e3fa70 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -126,7 +126,10 @@ 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 { + Props as SwitchProps, + SwitchColors, +} from './components/Switch/Switch'; export type { Props as TextInputProps } from './components/TextInput/TextInput'; export type { Props as TextInputAffixProps } from './components/TextInput/Adornment/TextInputAffix'; export type { Props as TextInputIconProps } from './components/TextInput/Adornment/TextInputIcon';