diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx index 69087a861e..be89bc16c7 100644 --- a/src/components/Checkbox/Checkbox.tsx +++ b/src/components/Checkbox/Checkbox.tsx @@ -1,19 +1,35 @@ import * as React from 'react'; import { - Animated, ColorValue, GestureResponderEvent, + NativeSyntheticEvent, + Platform, + Pressable, + StyleProp, StyleSheet, + TargetedEvent, View, + ViewStyle, } from 'react-native'; -import { getSelectionControlColor } from './utils'; +import Animated, { + Easing, + ReduceMotion, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; + +import { CheckboxTokens } from './tokens'; +import { getSelectionVisualState } from './utils'; +import { useLocale } from '../../core/locale'; import { useInternalTheme } from '../../core/theming'; -import type { $RemoveChildren, ThemeProp } from '../../types'; -import MaterialCommunityIcon from '../MaterialCommunityIcon'; -import TouchableRipple from '../TouchableRipple/TouchableRipple'; +import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; +import { tokens } from '../../theme/tokens'; +import type { ThemeProp } from '../../types'; +import { isKeyboardFocusEvent } from '../../utils/isKeyboardFocusEvent'; -export type Props = $RemoveChildren & { +export type Props = { /** * Status of checkbox. */ @@ -36,9 +52,9 @@ export type Props = $RemoveChildren & { color?: ColorValue; /** * Whether the checkbox is in an error state. When true, the outline - * (unchecked) and container (checked / indeterminate) use - * `theme.colors.error`. `disabled` and explicit `color`/`uncheckedColor` - * overrides take precedence. + * (unchecked) and container (selected) use `theme.colors.error`. + * `disabled` and explicit `color`/`uncheckedColor` overrides take + * precedence. */ error?: boolean; /** @@ -49,9 +65,25 @@ export type Props = $RemoveChildren & { * testID to be used on tests. */ testID?: string; + /** + * Custom style to override the default tap target. Passed through to + * the underlying `Pressable`. + */ + style?: StyleProp; }; -const ANIMATION_DURATION = 100; +// Spec dimensions (https://m3.material.io/components/checkbox/specs). +const { + containerSize: CONTAINER_SIZE, + containerRadius: CONTAINER_RADIUS, + outlineWidth: OUTLINE_WIDTH, + stateLayerSize: STATE_LAYER_SIZE, +} = CheckboxTokens; + +const FOCUS_THICKNESS = tokens.md.sys.state.focusIndicator.thickness; +const FOCUS_OFFSET = tokens.md.sys.state.focusIndicator.outerOffset; +const FOCUS_RING_SIZE = CONTAINER_SIZE + 2 * (FOCUS_OFFSET + FOCUS_THICKNESS); +const FOCUS_RING_RADIUS = CONTAINER_RADIUS + FOCUS_OFFSET + FOCUS_THICKNESS; /** * Checkboxes allow the selection of multiple options from a set. @@ -84,121 +116,327 @@ const Checkbox = ({ onPress, testID, error, - ...rest + color, + uncheckedColor, + style, }: Props) => { const theme = useInternalTheme(themeOverrides); - const { current: scaleAnim } = React.useRef( - new Animated.Value(1) + const reduceMotion = useReduceMotion(); + const { direction } = useLocale(); + // On native, `I18nManager` flips layout so the default `left: 0` mask + // anchor already mirrors automatically. On web (react-native-web), the + // layout system doesn't flip, so we anchor explicitly when the locale + // direction is RTL. + const flipMaskForWebRTL = Platform.OS === 'web' && direction === 'rtl'; + const [hovered, setHovered] = React.useState(false); + const [pressed, setPressed] = React.useState(false); + const [focused, setFocused] = React.useState(false); + + const selected = status === 'checked' || status === 'indeterminate'; + + const reanimatedReduceMotion = reduceMotion + ? ReduceMotion.Always + : ReduceMotion.Never; + + const fillTimingConfig = React.useMemo( + () => ({ + duration: theme.motion.duration.short2, + easing: Easing.bezier(...theme.motion.easing.standard), + reduceMotion: reanimatedReduceMotion, + }), + [ + theme.motion.duration.short2, + theme.motion.easing.standard, + reanimatedReduceMotion, + ] + ); + const checkTimingConfig = React.useMemo( + () => ({ + duration: theme.motion.duration.short3, + easing: Easing.bezier(...theme.motion.easing.standard), + reduceMotion: reanimatedReduceMotion, + }), + [ + theme.motion.duration.short3, + theme.motion.easing.standard, + reanimatedReduceMotion, + ] ); - const isFirstRendering = React.useRef(true); - const { - animation: { scale }, - } = theme; + // 0 = unselected (outline only), 1 = selected (filled + drawn icon). + const fillProgress = useSharedValue(selected ? 1 : 0); + const checkProgress = useSharedValue(selected ? 1 : 0); + const firstRender = React.useRef(true); React.useEffect(() => { - // Do not run animation on very first rendering - if (isFirstRendering.current) { - isFirstRendering.current = false; + if (firstRender.current) { + firstRender.current = false; return; } + const target = selected ? 1 : 0; + fillProgress.value = withTiming(target, fillTimingConfig); + checkProgress.value = withTiming(target, checkTimingConfig); + }, [ + selected, + fillProgress, + checkProgress, + fillTimingConfig, + checkTimingConfig, + ]); - const checked = status === 'checked'; - - Animated.sequence([ - Animated.timing(scaleAnim, { - toValue: 0.85, - duration: checked ? ANIMATION_DURATION * scale : 0, - useNativeDriver: false, - }), - Animated.timing(scaleAnim, { - toValue: 1, - duration: checked - ? ANIMATION_DURATION * scale - : ANIMATION_DURATION * scale * 1.75, - useNativeDriver: false, - }), - ]).start(); - }, [status, scaleAnim, scale]); - - const checked = status === 'checked'; - const indeterminate = status === 'indeterminate'; - - const { selectionControlColor, selectionControlOpacity } = - getSelectionControlColor({ - theme, - disabled, - checked, - customColor: rest.color, - customUncheckedColor: rest.uncheckedColor, - error, - }); - - const borderWidth = scaleAnim.interpolate({ - inputRange: [0.8, 1], - outputRange: [7, 0], + const visual = getSelectionVisualState({ + theme, + selected, + disabled, + hovered, + pressed, + error, + customColor: color, + customUncheckedColor: uncheckedColor, }); - const icon = indeterminate - ? 'minus-box' - : checked - ? 'checkbox-marked' - : 'checkbox-blank-outline'; + // Outline fades out as fill fades in (and vice versa). + const outlineAnimatedStyle = useAnimatedStyle(() => ({ + opacity: 1 - fillProgress.value, + })); + + const fillAnimatedStyle = useAnimatedStyle(() => ({ + opacity: fillProgress.value, + })); + + const maskAnimatedStyle = useAnimatedStyle(() => ({ + width: checkProgress.value * CONTAINER_SIZE, + opacity: checkProgress.value, + })); + + // Remember which glyph to render so the reveal-mask can still collapse + // when transitioning back to 'unchecked' (selected becomes false, but + // we keep showing the previous glyph until checkProgress hits 0). + const lastGlyph = React.useRef<'check' | 'indeterminate'>('check'); + if (status === 'checked') lastGlyph.current = 'check'; + else if (status === 'indeterminate') lastGlyph.current = 'indeterminate'; + const showIndeterminate = lastGlyph.current === 'indeterminate'; + + const handleFocus = React.useCallback( + (e: NativeSyntheticEvent) => { + if (disabled) return; + if (!isKeyboardFocusEvent(e)) return; + setFocused(true); + }, + [disabled] + ); + + const handleBlur = React.useCallback(() => { + setFocused(false); + }, []); return ( - setHovered(true)} + onHoverOut={() => setHovered(false)} + onPressIn={() => setPressed(true)} + onPressOut={() => setPressed(false)} + onFocus={handleFocus} + onBlur={handleBlur} disabled={disabled} accessibilityRole="checkbox" - accessibilityState={{ disabled, checked }} + accessibilityState={{ + disabled: !!disabled, + checked: status === 'indeterminate' ? 'mixed' : status === 'checked', + }} accessibilityLiveRegion="polite" - style={styles.container} testID={testID} - theme={theme} + style={[ + styles.tapTarget, + Platform.OS === 'web' ? webNoOutline : undefined, + style, + ]} > - - + - + {focused && !disabled ? ( + + ) : null} + + + {showIndeterminate ? ( + + + + + + ) : ( + + )} - - + + ); }; +/** + * Reveal-mask checkmark: a static L-shape (borderLeftWidth + + * borderBottomWidth rotated -45deg) inside a directional-anchored View + * whose width animates 0 -> CONTAINER_SIZE. The checkmark "draws in" + * along the writing direction, approximating Compose Material3's + * stroke-fraction animation without an SVG dependency. + * + * The glyph itself is not mirrored in RTL (Compose M3 keeps the + * checkmark orientation consistent across locales) — only the reveal + * anchor flips so the stroke appears to draw from the leading edge. + * On native, `I18nManager` flips the layout so `left: 0` already does + * the right thing; we only need to explicitly switch sides on web. + */ +const Checkmark = ({ + color, + maskAnimatedStyle, + flipMaskForWebRTL, +}: { + color: ColorValue; + maskAnimatedStyle: ReturnType; + flipMaskForWebRTL: boolean; +}) => { + return ( + + + + + + ); +}; + +// Web-only style; not in StyleSheet because `outline` is outside ViewStyle. +const webNoOutline = { outline: 'none' } as unknown as ViewStyle; + const styles = StyleSheet.create({ - container: { - borderRadius: 18, - width: 36, - height: 36, - padding: 6, + tapTarget: { + width: STATE_LAYER_SIZE, + height: STATE_LAYER_SIZE, + alignItems: 'center', + justifyContent: 'center', + }, + tapTargetInner: { + width: STATE_LAYER_SIZE, + height: STATE_LAYER_SIZE, + alignItems: 'center', + justifyContent: 'center', }, - fillContainer: { + stateLayer: { + position: 'absolute', + top: 0, + left: 0, + width: STATE_LAYER_SIZE, + height: STATE_LAYER_SIZE, + borderRadius: STATE_LAYER_SIZE / 2, + }, + focusRing: { + position: 'absolute', + width: FOCUS_RING_SIZE, + height: FOCUS_RING_SIZE, + borderRadius: FOCUS_RING_RADIUS, + borderWidth: FOCUS_THICKNESS, + }, + container: { + width: CONTAINER_SIZE, + height: CONTAINER_SIZE, + borderRadius: CONTAINER_RADIUS, alignItems: 'center', justifyContent: 'center', + overflow: 'hidden', }, fill: { - height: 14, - width: 14, + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + borderRadius: CONTAINER_RADIUS, + }, + outline: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + borderWidth: OUTLINE_WIDTH, + borderRadius: CONTAINER_RADIUS, + }, + dash: { + width: 10, + height: 2, + borderRadius: 1, + }, + checkmarkMask: { + position: 'absolute', + left: 0, + top: 0, + height: CONTAINER_SIZE, + overflow: 'hidden', + }, + checkmarkMaskWebRTL: { + position: 'absolute', + right: 0, + top: 0, + height: CONTAINER_SIZE, + overflow: 'hidden', + }, + checkmarkContent: { + width: CONTAINER_SIZE, + height: CONTAINER_SIZE, + alignItems: 'center', + justifyContent: 'center', + }, + checkmarkGlyph: { + width: 11, + height: 6, + borderLeftWidth: 2, + borderBottomWidth: 2, + transform: [{ rotate: '-45deg' }, { translateY: -1 }, { translateX: 1 }], }, }); diff --git a/src/components/Checkbox/tokens.ts b/src/components/Checkbox/tokens.ts new file mode 100644 index 0000000000..475ccaacdf --- /dev/null +++ b/src/components/Checkbox/tokens.ts @@ -0,0 +1,33 @@ +import type { ColorRole } from '../../theme/types'; + +/** + * MD3 Checkbox spec dimensions and color-role tokens. + * @see https://m3.material.io/components/checkbox/specs + * + * Mirrors the `SwitchTokens` pattern from `src/components/Switch/tokens.ts` + * so other selection-control modernizations (e.g. RadioButton) can adopt + * the same shape. + */ +const sizes = { + containerSize: 18, + containerRadius: 2, + outlineWidth: 2, + stateLayerSize: 40, +} as const; + +const colors = { + containerColor: 'primary', + disabledContainerColor: 'onSurface', + errorContainerColor: 'error', + outlineColor: 'onSurfaceVariant', + disabledOutlineColor: 'onSurface', + errorOutlineColor: 'error', + iconColor: 'onPrimary', + disabledIconColor: 'surface', + errorIconColor: 'onError', + selectedStateLayerColor: 'primary', + unselectedStateLayerColor: 'onSurface', + errorStateLayerColor: 'error', +} as const satisfies Record; + +export const CheckboxTokens = { ...sizes, ...colors }; diff --git a/src/components/Checkbox/utils.ts b/src/components/Checkbox/utils.ts index 10f6d4eb1e..5c39a0c266 100644 --- a/src/components/Checkbox/utils.ts +++ b/src/components/Checkbox/utils.ts @@ -1,106 +1,169 @@ import type { ColorValue } from 'react-native'; +import { CheckboxTokens } from './tokens'; import { tokens } from '../../theme/tokens'; +import type { ColorRole } from '../../theme/types'; +import { getStateLayer } from '../../theme/utils/state'; import type { InternalTheme } from '../../types'; +// MD3 Checkbox spec: https://m3.material.io/components/checkbox/specs + const stateOpacity = tokens.md.sys.state.opacity; -const getCheckedColor = ({ - theme, - customColor, - error, -}: { +type StaticState = { theme: InternalTheme; - customColor?: ColorValue; + selected: boolean; + disabled?: boolean; error?: boolean; -}) => { + customColor?: ColorValue; + customUncheckedColor?: ColorValue; +}; + +type SelectionState = StaticState & { + hovered?: boolean; + pressed?: boolean; +}; + +type SelectionVisualState = { + containerColor: ColorValue; + outlineColor: ColorValue; + containerOpacity: number; + iconColor: ColorValue; + stateLayerColor: ColorValue; + stateLayerOpacity: number; +}; + +const getContainerColor = ({ + theme, + disabled, + error, + customColor, +}: StaticState): ColorValue => { + if (disabled) { + return theme.colors[CheckboxTokens.disabledContainerColor]; + } if (customColor) { return customColor; } - if (error) { - return theme.colors.error; + return theme.colors[CheckboxTokens.errorContainerColor]; } - - return theme.colors.primary; + return theme.colors[CheckboxTokens.containerColor]; }; -const getUncheckedColor = ({ +const getOutlineColor = ({ theme, - customUncheckedColor, + disabled, error, -}: { - theme: InternalTheme; - customUncheckedColor?: ColorValue; - error?: boolean; -}) => { + customUncheckedColor, +}: StaticState): ColorValue => { + if (disabled) { + return theme.colors[CheckboxTokens.disabledOutlineColor]; + } if (customUncheckedColor) { return customUncheckedColor; } - if (error) { - return theme.colors.error; + return theme.colors[CheckboxTokens.errorOutlineColor]; } - - return theme.colors.onSurfaceVariant; + return theme.colors[CheckboxTokens.outlineColor]; }; -const getControlColor = ({ +const getIconColor = ({ theme, - checked, + selected, disabled, - checkedColor, - uncheckedColor, -}: { - theme: InternalTheme; - checked: boolean; - checkedColor: ColorValue; - uncheckedColor: ColorValue; - disabled?: boolean; -}) => { + error, +}: StaticState): ColorValue => { + if (!selected) { + return 'transparent'; + } if (disabled) { - return theme.colors.onSurface; + return theme.colors[CheckboxTokens.disabledIconColor]; } - - if (checked) { - return checkedColor; + if (error) { + return theme.colors[CheckboxTokens.errorIconColor]; } - return uncheckedColor; + return theme.colors[CheckboxTokens.iconColor]; +}; + +// The MD3 spec renders the focused state as an outline ring only (no +// state-layer fill), so `focused` is intentionally not handled here. +const resolveStateLayer = ({ + theme, + selected, + hovered, + pressed, + error, +}: Omit): { + color: ColorValue; + opacity: number; +} => { + const state = pressed ? 'pressed' : hovered ? 'hovered' : null; + if (!state) return { color: 'transparent', opacity: 0 }; + + // Pressed flips selected/unselected colors per the MD3 spec. + const role: ColorRole = error + ? CheckboxTokens.errorStateLayerColor + : (pressed ? !selected : selected) + ? CheckboxTokens.selectedStateLayerColor + : CheckboxTokens.unselectedStateLayerColor; + return getStateLayer(theme, role, state); }; -export const getSelectionControlColor = ({ +/** + * Resolve the full color + opacity picture for the Checkbox renderer. + * + * Returns flat values so the renderer can pass them straight to its + * `Animated.View` styles without re-deriving anything. + */ +export const getSelectionVisualState = ({ theme, + selected, disabled, - checked, + hovered, + pressed, + error, customColor, customUncheckedColor, - error, -}: { - theme: InternalTheme; - checked: boolean; - disabled?: boolean; - customColor?: ColorValue; - customUncheckedColor?: ColorValue; - error?: boolean; -}) => { - const checkedColor = getCheckedColor({ theme, customColor, error }); - const uncheckedColor = getUncheckedColor({ +}: SelectionState): SelectionVisualState => { + const containerColor = getContainerColor({ theme, + selected, + disabled, + error, + customColor, customUncheckedColor, + }); + const outlineColor = getOutlineColor({ + theme, + selected, + disabled, + error, + customColor, + customUncheckedColor, + }); + const iconColor = getIconColor({ + theme, + selected, + disabled, + error, + customColor, + customUncheckedColor, + }); + const stateLayer = resolveStateLayer({ + theme, + selected, + hovered: hovered && !disabled, + pressed: pressed && !disabled, error, }); - const selectionControlOpacity = disabled - ? stateOpacity.disabled - : stateOpacity.enabled; - return { - selectionControlColor: getControlColor({ - theme, - disabled, - checked, - checkedColor, - uncheckedColor, - }), - selectionControlOpacity, + containerColor, + outlineColor, + containerOpacity: disabled ? stateOpacity.disabled : stateOpacity.enabled, + iconColor, + stateLayerColor: stateLayer.color, + stateLayerOpacity: stateLayer.opacity, }; }; diff --git a/src/components/RadioButton/RadioButtonAndroid.tsx b/src/components/RadioButton/RadioButtonAndroid.tsx index cb1757726b..eeb42c218e 100644 --- a/src/components/RadioButton/RadioButtonAndroid.tsx +++ b/src/components/RadioButton/RadioButtonAndroid.tsx @@ -2,10 +2,9 @@ import * as React from 'react'; import { Animated, StyleSheet, View } from 'react-native'; import { RadioButtonContext, RadioButtonContextType } from './RadioButtonGroup'; -import { handlePress, isChecked } from './utils'; +import { getSelectionControlColor, handlePress, isChecked } from './utils'; import { useInternalTheme } from '../../core/theming'; import type { $RemoveChildren, ThemeProp } from '../../types'; -import { getSelectionControlColor } from '../Checkbox/utils'; import TouchableRipple from '../TouchableRipple/TouchableRipple'; export type Props = $RemoveChildren & { diff --git a/src/components/RadioButton/utils.ts b/src/components/RadioButton/utils.ts index 96adafaf47..1f2a289e29 100644 --- a/src/components/RadioButton/utils.ts +++ b/src/components/RadioButton/utils.ts @@ -93,3 +93,44 @@ export const getSelectionControlIOSColor = ({ checkedColorOpacity, }; }; + +/** + * Color resolver for `RadioButtonAndroid`. + * + * Previously shared with the pre-MD3 Checkbox (and lived in + * `Checkbox/utils.ts`); moved here when Checkbox modernized to its own + * `getSelectionVisualState` helper. + */ +export const getSelectionControlColor = ({ + theme, + disabled, + checked, + customColor, + customUncheckedColor, + error, +}: { + theme: InternalTheme; + checked: boolean; + disabled?: boolean; + customColor?: ColorValue; + customUncheckedColor?: ColorValue; + error?: boolean; +}): { selectionControlColor: ColorValue; selectionControlOpacity: number } => { + const opacity = disabled ? stateOpacity.disabled : stateOpacity.enabled; + const checkedColor = customColor + ? customColor + : error + ? theme.colors.error + : theme.colors.primary; + const uncheckedColor = customUncheckedColor + ? customUncheckedColor + : error + ? theme.colors.error + : theme.colors.onSurfaceVariant; + const color = disabled + ? theme.colors.onSurface + : checked + ? checkedColor + : uncheckedColor; + return { selectionControlColor: color, selectionControlOpacity: opacity }; +}; diff --git a/src/components/__tests__/Checkbox/CheckboxItem.test.tsx b/src/components/__tests__/Checkbox/CheckboxItem.test.tsx index 7018d01c38..fb161dbbe9 100644 --- a/src/components/__tests__/Checkbox/CheckboxItem.test.tsx +++ b/src/components/__tests__/Checkbox/CheckboxItem.test.tsx @@ -42,13 +42,16 @@ it('should have `accessibilityState={ checked: false }` when `status="unchecked" expect(elements).toHaveLength(2); }); -it('should have `accessibilityState={ checked: false }` when `status="indeterminate"', () => { +it('should have `accessibilityState={ checked: "mixed" }` when `status="indeterminate"`', () => { const { getAllByA11yState } = render( ); - const elements = getAllByA11yState({ checked: false }); - expect(elements).toHaveLength(2); + // The inner Checkbox exposes `checked: "mixed"` (per W3C ARIA spec for + // tri-state controls), while the outer row Pressable exposes + // `checked: false`. + expect(getAllByA11yState({ checked: 'mixed' })).toHaveLength(1); + expect(getAllByA11yState({ checked: false })).toHaveLength(1); }); it('disables the row when the prop disabled is true', () => { diff --git a/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap b/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap index f1a7834ac6..5a4e49168d 100644 --- a/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap +++ b/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap @@ -8,7 +8,7 @@ exports[`renders Checkbox with custom testID 1`] = ` { "busy": undefined, "checked": true, - "disabled": true, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -36,89 +36,161 @@ exports[`renders Checkbox with custom testID 1`] = ` style={ [ { - "overflow": "hidden", - }, - { - "borderRadius": 18, - "height": 36, - "padding": 6, - "width": 36, + "alignItems": "center", + "height": 40, + "justifyContent": "center", + "width": 40, }, + undefined, + undefined, ] } testID="custom:testID" > - - checkbox-marked - + /> + + + + + + @@ -132,7 +204,7 @@ exports[`renders checked Checkbox with color 1`] = ` { "busy": undefined, "checked": true, - "disabled": true, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -147,7 +219,6 @@ exports[`renders checked Checkbox with color 1`] = ` } accessible={true} collapsable={false} - color="red" focusable={true} onBlur={[Function]} onClick={[Function]} @@ -161,88 +232,160 @@ exports[`renders checked Checkbox with color 1`] = ` style={ [ { - "overflow": "hidden", - }, - { - "borderRadius": 18, - "height": 36, - "padding": 6, - "width": 36, + "alignItems": "center", + "height": 40, + "justifyContent": "center", + "width": 40, }, + undefined, + undefined, ] } > - - checkbox-marked - + /> + + + + + + @@ -284,88 +427,160 @@ exports[`renders checked Checkbox with onPress 1`] = ` style={ [ { - "overflow": "hidden", - }, - { - "borderRadius": 18, - "height": 36, - "padding": 6, - "width": 36, + "alignItems": "center", + "height": 40, + "justifyContent": "center", + "width": 40, }, + undefined, + undefined, ] } > - - checkbox-marked - + /> + + + + + + @@ -378,7 +593,7 @@ exports[`renders indeterminate Checkbox 1`] = ` accessibilityState={ { "busy": undefined, - "checked": false, + "checked": "mixed", "disabled": false, "expanded": undefined, "selected": undefined, @@ -407,88 +622,148 @@ exports[`renders indeterminate Checkbox 1`] = ` style={ [ { - "overflow": "hidden", - }, - { - "borderRadius": 18, - "height": 36, - "padding": 6, - "width": 36, + "alignItems": "center", + "height": 40, + "justifyContent": "center", + "width": 40, }, + undefined, + undefined, ] } > - - minus-box - + /> + + + + + + @@ -501,8 +776,8 @@ exports[`renders indeterminate Checkbox with color 1`] = ` accessibilityState={ { "busy": undefined, - "checked": false, - "disabled": true, + "checked": "mixed", + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -517,7 +792,6 @@ exports[`renders indeterminate Checkbox with color 1`] = ` } accessible={true} collapsable={false} - color="red" focusable={true} onBlur={[Function]} onClick={[Function]} @@ -531,88 +805,148 @@ exports[`renders indeterminate Checkbox with color 1`] = ` style={ [ { - "overflow": "hidden", - }, - { - "borderRadius": 18, - "height": 36, - "padding": 6, - "width": 36, + "alignItems": "center", + "height": 40, + "justifyContent": "center", + "width": 40, }, + undefined, + undefined, ] } > - - minus-box - + /> + + + + + + @@ -626,7 +960,7 @@ exports[`renders unchecked Checkbox with color 1`] = ` { "busy": undefined, "checked": true, - "disabled": true, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -641,7 +975,6 @@ exports[`renders unchecked Checkbox with color 1`] = ` } accessible={true} collapsable={false} - color="red" focusable={true} onBlur={[Function]} onClick={[Function]} @@ -655,88 +988,160 @@ exports[`renders unchecked Checkbox with color 1`] = ` style={ [ { - "overflow": "hidden", - }, - { - "borderRadius": 18, - "height": 36, - "padding": 6, - "width": 36, + "alignItems": "center", + "height": 40, + "justifyContent": "center", + "width": 40, }, + undefined, + undefined, ] } > - - checkbox-marked - + /> + + + + + + @@ -778,88 +1183,160 @@ exports[`renders unchecked Checkbox with onPress 1`] = ` style={ [ { - "overflow": "hidden", - }, - { - "borderRadius": 18, - "height": 36, - "padding": 6, - "width": 36, + "alignItems": "center", + "height": 40, + "justifyContent": "center", + "width": 40, }, + undefined, + undefined, ] } > - - checkbox-blank-outline - + /> + + + + + + diff --git a/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap b/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap index 8f8faef29b..d6a3b40b12 100644 --- a/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap +++ b/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap @@ -63,7 +63,7 @@ exports[`can render leading checkbox control 1`] = ` { "busy": undefined, "checked": false, - "disabled": true, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -91,88 +91,160 @@ exports[`can render leading checkbox control 1`] = ` style={ [ { - "overflow": "hidden", - }, - { - "borderRadius": 18, - "height": 36, - "padding": 6, - "width": 36, + "alignItems": "center", + "height": 40, + "justifyContent": "center", + "width": 40, }, + undefined, + undefined, ] } > - - checkbox-blank-outline - + /> + + + + + + @@ -319,7 +391,7 @@ exports[`renders unchecked 1`] = ` { "busy": undefined, "checked": false, - "disabled": true, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -347,88 +419,160 @@ exports[`renders unchecked 1`] = ` style={ [ { - "overflow": "hidden", - }, - { - "borderRadius": 18, - "height": 36, - "padding": 6, - "width": 36, + "alignItems": "center", + "height": 40, + "justifyContent": "center", + "width": 40, }, + undefined, + undefined, ] } > - - checkbox-blank-outline - + /> + + + + + + diff --git a/src/components/__tests__/Checkbox/utils.test.tsx b/src/components/__tests__/Checkbox/utils.test.tsx deleted file mode 100644 index 78af32e8ea..0000000000 --- a/src/components/__tests__/Checkbox/utils.test.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import { getTheme } from '../../../core/theming'; -import { tokens } from '../../../theme/tokens'; -import { getSelectionControlColor } from '../../Checkbox/utils'; -const stateOpacity = tokens.md.sys.state.opacity; - -describe('getSelectionControlColor - checkbox color', () => { - it('should return correct disabled color, for theme version 3', () => { - expect( - getSelectionControlColor({ - theme: getTheme(), - disabled: true, - checked: false, - }) - ).toMatchObject({ - selectionControlColor: getTheme().colors.onSurface, - selectionControlOpacity: stateOpacity.disabled, - }); - }); - - it('should return custom color, checked', () => { - expect( - getSelectionControlColor({ - theme: getTheme(), - checked: true, - customColor: 'purple', - }) - ).toMatchObject({ - selectionControlColor: 'purple', - }); - }); - - it('should return theme color, for theme version 3, checked', () => { - expect( - getSelectionControlColor({ - theme: getTheme(), - checked: true, - }) - ).toMatchObject({ - selectionControlColor: getTheme().colors.primary, - }); - }); - - it('should return custom color, unchecked', () => { - expect( - getSelectionControlColor({ - theme: getTheme(), - checked: false, - customUncheckedColor: 'purple', - }) - ).toMatchObject({ - selectionControlColor: 'purple', - }); - }); - - it('should return theme color, for theme version 3, unchecked', () => { - expect( - getSelectionControlColor({ - theme: getTheme(), - checked: false, - }) - ).toMatchObject({ - selectionControlColor: getTheme().colors.onSurfaceVariant, - }); - }); - - it('should return theme color, unchecked, dark mode', () => { - expect( - getSelectionControlColor({ - theme: getTheme(true), - checked: false, - }) - ).toMatchObject({ - selectionControlColor: getTheme(true).colors.onSurfaceVariant, - }); - }); - - it('should return theme color, unchecked, light mode', () => { - expect( - getSelectionControlColor({ - theme: getTheme(false), - checked: false, - }) - ).toMatchObject({ - selectionControlColor: getTheme(false).colors.onSurfaceVariant, - }); - }); - - it('should return error color, checked, when error is true', () => { - expect( - getSelectionControlColor({ - theme: getTheme(), - checked: true, - error: true, - }) - ).toMatchObject({ - selectionControlColor: getTheme().colors.error, - }); - }); - - it('should return error color, unchecked, when error is true', () => { - expect( - getSelectionControlColor({ - theme: getTheme(), - checked: false, - error: true, - }) - ).toMatchObject({ - selectionControlColor: getTheme().colors.error, - }); - }); - - it('should return error color, checked, dark mode, when error is true', () => { - expect( - getSelectionControlColor({ - theme: getTheme(true), - checked: true, - error: true, - }) - ).toMatchObject({ - selectionControlColor: getTheme(true).colors.error, - }); - }); - - it('should return disabled color when both disabled and error are true (disabled wins)', () => { - expect( - getSelectionControlColor({ - theme: getTheme(), - disabled: true, - checked: true, - error: true, - }) - ).toMatchObject({ - selectionControlColor: getTheme().colors.onSurface, - selectionControlOpacity: stateOpacity.disabled, - }); - }); - - it('should return custom color when both customColor and error are true, checked (customColor wins)', () => { - expect( - getSelectionControlColor({ - theme: getTheme(), - checked: true, - customColor: 'purple', - error: true, - }) - ).toMatchObject({ - selectionControlColor: 'purple', - }); - }); - - it('should return custom unchecked color when both customUncheckedColor and error are true, unchecked (customUncheckedColor wins)', () => { - expect( - getSelectionControlColor({ - theme: getTheme(), - checked: false, - customUncheckedColor: 'purple', - error: true, - }) - ).toMatchObject({ - selectionControlColor: 'purple', - }); - }); -}); diff --git a/src/components/__tests__/RadioButton/utils.test.tsx b/src/components/__tests__/RadioButton/utils.test.tsx index 12486a9c3b..2d72895f91 100644 --- a/src/components/__tests__/RadioButton/utils.test.tsx +++ b/src/components/__tests__/RadioButton/utils.test.tsx @@ -1,6 +1,9 @@ import { getTheme } from '../../../core/theming'; import { tokens } from '../../../theme/tokens'; -import { getSelectionControlIOSColor } from '../../RadioButton/utils'; +import { + getSelectionControlColor, + getSelectionControlIOSColor, +} from '../../RadioButton/utils'; const stateOpacity = tokens.md.sys.state.opacity; @@ -85,3 +88,162 @@ describe('getSelectionControlIOSColor - checked color', () => { }); }); }); + +describe('getSelectionControlColor - radio color (legacy, shared with pre-MD3 Checkbox)', () => { + it('should return correct disabled color, for theme version 3', () => { + expect( + getSelectionControlColor({ + theme: getTheme(), + disabled: true, + checked: false, + }) + ).toMatchObject({ + selectionControlColor: getTheme().colors.onSurface, + selectionControlOpacity: stateOpacity.disabled, + }); + }); + + it('should return custom color, checked', () => { + expect( + getSelectionControlColor({ + theme: getTheme(), + checked: true, + customColor: 'purple', + }) + ).toMatchObject({ + selectionControlColor: 'purple', + }); + }); + + it('should return theme color, for theme version 3, checked', () => { + expect( + getSelectionControlColor({ + theme: getTheme(), + checked: true, + }) + ).toMatchObject({ + selectionControlColor: getTheme().colors.primary, + }); + }); + + it('should return custom color, unchecked', () => { + expect( + getSelectionControlColor({ + theme: getTheme(), + checked: false, + customUncheckedColor: 'purple', + }) + ).toMatchObject({ + selectionControlColor: 'purple', + }); + }); + + it('should return theme color, for theme version 3, unchecked', () => { + expect( + getSelectionControlColor({ + theme: getTheme(), + checked: false, + }) + ).toMatchObject({ + selectionControlColor: getTheme().colors.onSurfaceVariant, + }); + }); + + it('should return theme color, unchecked, dark mode', () => { + expect( + getSelectionControlColor({ + theme: getTheme(true), + checked: false, + }) + ).toMatchObject({ + selectionControlColor: getTheme(true).colors.onSurfaceVariant, + }); + }); + + it('should return theme color, unchecked, light mode', () => { + expect( + getSelectionControlColor({ + theme: getTheme(false), + checked: false, + }) + ).toMatchObject({ + selectionControlColor: getTheme(false).colors.onSurfaceVariant, + }); + }); + + it('should return error color, checked, when error is true', () => { + expect( + getSelectionControlColor({ + theme: getTheme(), + checked: true, + error: true, + }) + ).toMatchObject({ + selectionControlColor: getTheme().colors.error, + }); + }); + + it('should return error color, unchecked, when error is true', () => { + expect( + getSelectionControlColor({ + theme: getTheme(), + checked: false, + error: true, + }) + ).toMatchObject({ + selectionControlColor: getTheme().colors.error, + }); + }); + + it('should return error color, checked, dark mode, when error is true', () => { + expect( + getSelectionControlColor({ + theme: getTheme(true), + checked: true, + error: true, + }) + ).toMatchObject({ + selectionControlColor: getTheme(true).colors.error, + }); + }); + + it('should return disabled color when both disabled and error are true (disabled wins)', () => { + expect( + getSelectionControlColor({ + theme: getTheme(), + disabled: true, + checked: true, + error: true, + }) + ).toMatchObject({ + selectionControlColor: getTheme().colors.onSurface, + selectionControlOpacity: stateOpacity.disabled, + }); + }); + + it('should return custom color when both customColor and error are true, checked (customColor wins)', () => { + expect( + getSelectionControlColor({ + theme: getTheme(), + checked: true, + customColor: 'purple', + error: true, + }) + ).toMatchObject({ + selectionControlColor: 'purple', + }); + }); + + it('should return custom unchecked color when both customUncheckedColor and error are true, unchecked (customUncheckedColor wins)', () => { + expect( + getSelectionControlColor({ + theme: getTheme(), + checked: false, + customUncheckedColor: 'purple', + error: true, + }) + ).toMatchObject({ + selectionControlColor: 'purple', + }); + }); +});