diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index a4d7123a09..793f6af73f 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -279,3 +279,13 @@ const theme = { style={{ fontSize: 16, color: '#1C1B1F' }} /> ``` + +### Checkbox + +#### Interaction state colors + +Interaction states are now painted as a Material Design 3 state layer on every platform, so the tint follows the spec: hover and focus fill a flat 40dp layer with `primary` when selected and `onSurface` when not, a press ripples inside that same 40dp layer in the inverted color, and an error checkbox stays on `error` throughout. `color` and `uncheckedColor` replace the role they already override on the box, so a custom checkbox no longer picks up a `primary` halo. The platform ripple is turned off to make room for it; passing an explicit `rippleColor` or `underlayColor` turns it back on in that color and disables the built-in press instead. Setting `rippleEffectEnabled: false` on the `settings` prop of `PaperProvider` still suppresses both. + +#### Touch target height + +`Checkbox` now reserves the 48dp minimum touch target, so it occupies 48dp instead of 40dp. Nothing painted changed size, but rows containing a checkbox may become slightly taller. diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx index 02df81b0b4..044cfab6f0 100644 --- a/src/components/Checkbox/Checkbox.tsx +++ b/src/components/Checkbox/Checkbox.tsx @@ -3,21 +3,35 @@ import { Platform, StyleSheet, View } from 'react-native'; import type { ColorValue, GestureResponderEvent, + MouseEvent, NativeSyntheticEvent, StyleProp, TargetedEvent, ViewStyle, } from 'react-native'; -import Animated, { cubicBezier, type CSSStyle } from 'react-native-reanimated'; +import Animated, { + cubicBezier, + Easing, + ReduceMotion, + useAnimatedReaction, + useAnimatedStyle, + useSharedValue, + withDelay, + withTiming, + type CSSStyle, +} from 'react-native-reanimated'; import { CheckboxTokens } from './tokens'; -import { getSelectionVisualState } from './utils'; +import { getSelectionVisualState, getStateLayer } from './utils'; +import type { CheckboxInteraction } from './utils'; import { useLocale } from '../../core/locale'; +import { SettingsContext } from '../../core/settings'; import { useInternalTheme } from '../../core/theming'; import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; import { tokens } from '../../theme/tokens'; import type { $RemoveChildren, ThemeProp } from '../../types'; +import hasTouchHandler from '../../utils/hasTouchHandler'; import { isKeyboardFocusEvent } from '../../utils/isKeyboardFocusEvent'; import TouchableRipple from '../TouchableRipple/TouchableRipple'; @@ -35,11 +49,13 @@ export type Props = $RemoveChildren & { */ onPress?: (e: GestureResponderEvent) => void; /** - * Custom color for unchecked checkbox. + * Custom color for unchecked checkbox. Replaces `onSurface` in the state + * layer as well as the outline. */ uncheckedColor?: ColorValue; /** - * Custom color for checkbox. + * Custom color for checkbox. Replaces `primary` in the state layer as well + * as the container. */ color?: ColorValue; /** @@ -62,6 +78,13 @@ export type Props = $RemoveChildren & { * the underlying `TouchableRipple`. */ style?: StyleProp; + /** + * Accessibility label for the checkbox, read by a screen reader in place of + * a visible label. A standalone `Checkbox` has no label of its own, so it + * needs one here. `Checkbox.Item` names the whole row instead and does not + * require it. + */ + 'aria-label'?: string; }; // Spec dimensions (https://m3.material.io/components/checkbox/specs). @@ -70,15 +93,20 @@ const { containerRadius: CONTAINER_RADIUS, outlineWidth: OUTLINE_WIDTH, stateLayerSize: STATE_LAYER_SIZE, + touchTargetSize: TOUCH_TARGET_SIZE, } = CheckboxTokens; -const FOCUS_THICKNESS = tokens.md.sys.state.focusIndicator.thickness; -// Focus indicator is a circular ring at the 40dp state-layer boundary. -// We don't apply `focusIndicator.outerOffset` here because the surrounding -// `TouchableRipple borderless` clips overflow to the tap-target shape, -// so a ring drawn outside the 40dp circle would be cropped. -const FOCUS_RING_SIZE = STATE_LAYER_SIZE; -const FOCUS_RING_RADIUS = STATE_LAYER_SIZE / 2; +const { thickness: FOCUS_THICKNESS, outerOffset: FOCUS_OUTER_OFFSET } = + tokens.md.sys.state.focusIndicator; +// The border is drawn inside the ring's own box, so the box spans the state +// layer plus the offset and the border on each side. +const FOCUS_RING_SIZE = + STATE_LAYER_SIZE + 2 * (FOCUS_OUTER_OFFSET + FOCUS_THICKNESS); +const FOCUS_RING_RADIUS = FOCUS_RING_SIZE / 2; + +// Compose's `RippleAnimation` starts the ripple at 30% of the target layer's +// size, i.e. 0.6 of the radius. +const RIPPLE_START_SCALE = 0.6; /** * Checkboxes allow the selection of multiple options from a set. @@ -103,6 +131,12 @@ const FOCUS_RING_RADIUS = STATE_LAYER_SIZE / 2; * * export default MyComponent; * ``` + * + * ## Accessibility + * A standalone `Checkbox` renders no visible label, so give it an `aria-label` + * to name it for assistive tech. Use `Checkbox.Item` when you want a labelled + * row: it owns the accessible name and keeps the inner checkbox out of the + * accessibility tree so the state is announced once. */ const Checkbox = ({ status, @@ -119,27 +153,79 @@ const Checkbox = ({ const theme = useInternalTheme(themeOverrides); const reduceMotion = useReduceMotion(); + const { rippleEffectEnabled } = React.useContext(SettingsContext); const { direction } = useLocale(); // Web (react-native-web) doesn't auto-mirror layout, so flip the mask // anchor manually for RTL. Native handles it via `I18nManager`. const flipMaskForWebRTL = Platform.OS === 'web' && direction === 'rtl'; const [focused, setFocused] = React.useState(false); + const [hovered, setHovered] = React.useState(false); const selected = status === 'checked' || status === 'indeterminate'; - // Visual state (colors + opacity) for the static layers. `hovered` / - // `pressed` aren't tracked here — `TouchableRipple` owns the press ripple - // and hover overlay. - const visual = getSelectionVisualState({ + // Shared by the box and the state layer, so a custom color reaches both. + const selectionColors = { theme, selected, - disabled, error, customColor: color, customUncheckedColor: uncheckedColor, + }; + + const visual = getSelectionVisualState({ ...selectionColors, disabled }); + + // `TouchableRipple` disables itself when nothing can handle a press, so + // attaching press handlers unconditionally would make a handler-less + // checkbox enabled and focusable while doing nothing. + const isInteractive = + !disabled && + hasTouchHandler({ + onPress, + onLongPress: rest.onLongPress, + onPressIn: rest.onPressIn, + onPressOut: rest.onPressOut, + }); + + const interaction: CheckboxInteraction | null = !isInteractive + ? null + : focused + ? 'focused' + : hovered + ? 'hovered' + : null; + + // Fade by opacity alone: under a dynamic theme the role is a `PlatformColor`, + // which Reanimated cannot interpolate, so the color stays put while idle + // instead of dropping to transparent. + const stateLayer = getStateLayer({ ...selectionColors, interaction }); + const stateLayerColor = getStateLayer({ + ...selectionColors, + interaction: interaction ?? 'hovered', + }).color; + const pressRipple = getStateLayer({ + ...selectionColors, + interaction: 'pressed', }); + // The platform press paints the wrong thing: it covers the whole 48dp target + // instead of the 40dp state layer, tints with a neutral role, and on web its + // hover overlay doubles up with the layer. Android also refuses a + // `PlatformColor`, which is what the dynamic theme resolves the roles to. + // An explicit `rippleColor` or `underlayColor` asks for that platform press + // instead, so leave it alone. + const platformOwnsPress = + rest.rippleColor != null || rest.underlayColor != null; + + const platformPressOverride = platformOwnsPress + ? null + : ({ rippleColor: 'transparent' } as const); + + // A disabled ripple effect asks for no press at all, on top of the platform + // press being spoken for above -- either way, nothing left for us to paint. + const ownsPress = + isInteractive && !platformOwnsPress && Boolean(rippleEffectEnabled); + const fillTransitionTimingFunction = cubicBezier( ...theme.motion.easing.standard ); @@ -172,6 +258,14 @@ const Checkbox = ({ transitionTimingFunction: fillTransitionTimingFunction, }; + const stateLayerStyle: CSSStyle = { + backgroundColor: stateLayerColor, + opacity: stateLayer.opacity, + transitionDuration: fillTransitionDuration, + transitionProperty: ['opacity'], + transitionTimingFunction: fillTransitionTimingFunction, + }; + const maskStyle: CSSStyle = { width: selected ? CONTAINER_SIZE : 0, opacity: selected ? 1 : 0, @@ -180,6 +274,72 @@ const Checkbox = ({ transitionTimingFunction: checkTransitionTimingFunction, }; + const rippleAlpha = useSharedValue(0); + const rippleScale = useSharedValue(RIPPLE_START_SCALE); + const pressedSV = useSharedValue(0); + // Held for the length of the grow so a tap that releases mid-grow still + // shows the ripple instead of flashing sub-frame. + const rippleHoldSV = useSharedValue(0); + + // Reanimated defaults an unset `reduceMotion` to the OS setting, which + // would fight ``. Mirror the provider's + // already-resolved preference explicitly instead, as `Switch` does. + const reanimatedReduceMotion = reduceMotion + ? ReduceMotion.Always + : ReduceMotion.Never; + + // Durations follow Compose's `RippleAnimation` (fade in 75ms, grow 225ms, + // fade out 150ms) and material-web's `MINIMUM_PRESS_MS` (225), snapped to + // the motion tokens. + const rippleGrowDuration = reduceMotion ? 0 : theme.motion.duration.short4; + const rippleAlphaInDuration = reduceMotion ? 0 : theme.motion.duration.short2; + const rippleFadeOutDuration = reduceMotion ? 0 : theme.motion.duration.short3; + const rippleHoldDuration = theme.motion.duration.short4; + + const startPressRipple = () => { + if (!ownsPress) return; + + pressedSV.value = 1; + rippleScale.value = RIPPLE_START_SCALE; + rippleScale.value = withTiming(1, { + duration: rippleGrowDuration, + easing: Easing.bezier(...theme.motion.easing.standard), + reduceMotion: reanimatedReduceMotion, + }); + rippleAlpha.value = withTiming(pressRipple.opacity, { + duration: rippleAlphaInDuration, + reduceMotion: reanimatedReduceMotion, + }); + rippleHoldSV.value = 1; + rippleHoldSV.value = withDelay( + rippleHoldDuration, + withTiming(0, { duration: 0 }), + // The hold gates visibility rather than movement, so it outlives both + // our own reduced-motion durations and the device setting. + ReduceMotion.Never + ); + }; + + useAnimatedReaction( + () => ({ pressed: pressedSV.value, holding: rippleHoldSV.value }), + ({ pressed, holding }) => { + // Also runs on registration, with everything already at rest -- there's + // nothing to fade then. + if (pressed === 1 || holding === 1 || rippleAlpha.value === 0) return; + + rippleAlpha.value = withTiming(0, { + duration: rippleFadeOutDuration, + reduceMotion: reanimatedReduceMotion, + }); + }, + [rippleFadeOutDuration, reanimatedReduceMotion] + ); + + const rippleStyle = useAnimatedStyle(() => ({ + opacity: rippleAlpha.value, + transform: [{ scale: rippleScale.value }], + })); + // Remember the last drawn glyph so the reveal-mask can finish collapsing // when `selected` flips back to false. Computed via the "derive state // during render" pattern (https://react.dev/reference/react/useState#storing-information-from-previous-renders) @@ -211,6 +371,56 @@ const Checkbox = ({ setFocused(false); }, []); + const interactionHandlers = isInteractive + ? { + onHoverIn: (e: MouseEvent) => { + setHovered(true); + rest.onHoverIn?.(e); + }, + onHoverOut: (e: MouseEvent) => { + setHovered(false); + rest.onHoverOut?.(e); + }, + onPressIn: (e: GestureResponderEvent) => { + startPressRipple(); + rest.onPressIn?.(e); + }, + onPressOut: (e: GestureResponderEvent) => { + pressedSV.value = 0; + rest.onPressOut?.(e); + }, + } + : null; + + // Losing the handlers means the matching hover-out or press-out never + // arrives, so the state would otherwise linger. + React.useEffect(() => { + if (isInteractive) return; + + setHovered(false); + pressedSV.value = 0; + rippleHoldSV.value = 0; + rippleAlpha.value = 0; + rippleScale.value = RIPPLE_START_SCALE; + }, [isInteractive, pressedSV, rippleHoldSV, rippleAlpha, rippleScale]); + + // `Checkbox.Item` names the row and passes `accessible={false}` here. + const isInAccessibilityTree = rest.accessible !== false; + const hasAccessibleName = Boolean( + rest['aria-label'] ?? + rest.accessibilityLabel ?? + rest['aria-labelledby'] ?? + rest.accessibilityLabelledBy + ); + + React.useEffect(() => { + if (!isInAccessibilityTree || hasAccessibleName) return; + + console.warn( + 'Checkbox: pass `aria-label` to name the checkbox for assistive tech, or use `Checkbox.Item` for a labelled row.' + ); + }, [isInAccessibilityTree, hasAccessibleName]); + const checked: boolean | 'mixed' = status === 'indeterminate' ? 'mixed' : status === 'checked'; @@ -228,72 +438,102 @@ const Checkbox = ({ 'aria-live': 'polite' as const, }; + const focusRing = + focused && !disabled ? ( + + ) : null; + return ( - - - {focused && !disabled ? ( - - ) : null} - - + // The ring is a sibling of the pressable, not a child: a foreground ripple + // forces `overflow: hidden` on it regardless of `borderless`. + + + - + ) : null} + - {showIndeterminate ? ( - - - - ) : ( - - - - )} - + + + + {showIndeterminate ? ( + + + + ) : ( + + + + )} + + - - + + {focusRing} + ); }; @@ -302,10 +542,14 @@ const Checkbox = ({ const webNoOutline = { outline: 'none' } as unknown as ViewStyle; const styles = StyleSheet.create({ + root: { + alignItems: 'center', + justifyContent: 'center', + }, tapTarget: { - width: STATE_LAYER_SIZE, - height: STATE_LAYER_SIZE, - borderRadius: STATE_LAYER_SIZE / 2, + width: TOUCH_TARGET_SIZE, + height: TOUCH_TARGET_SIZE, + borderRadius: TOUCH_TARGET_SIZE / 2, alignItems: 'center', justifyContent: 'center', }, @@ -315,6 +559,12 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', }, + stateLayer: { + position: 'absolute', + width: STATE_LAYER_SIZE, + height: STATE_LAYER_SIZE, + borderRadius: STATE_LAYER_SIZE / 2, + }, focusRing: { position: 'absolute', width: FOCUS_RING_SIZE, diff --git a/src/components/Checkbox/tokens.ts b/src/components/Checkbox/tokens.ts index 6406f25c47..3a0606b090 100644 --- a/src/components/Checkbox/tokens.ts +++ b/src/components/Checkbox/tokens.ts @@ -9,6 +9,8 @@ const sizes = { containerRadius: 2, outlineWidth: 2, stateLayerSize: 40, + /** Minimum interactive area; larger than the 40dp state layer. */ + touchTargetSize: 48, } as const; const colors = { @@ -21,8 +23,13 @@ const colors = { iconColor: 'onPrimary', disabledIconColor: 'surface', errorIconColor: 'onError', - selectedStateLayerColor: 'primary', - unselectedStateLayerColor: 'onSurface', + // Hover and focus tint by selection; pressing inverts it. + selectedHoverStateLayerColor: 'primary', + selectedFocusStateLayerColor: 'primary', + selectedPressedStateLayerColor: 'onSurface', + unselectedHoverStateLayerColor: 'onSurface', + unselectedFocusStateLayerColor: 'onSurface', + unselectedPressedStateLayerColor: 'primary', errorStateLayerColor: 'error', } as const satisfies Record; diff --git a/src/components/Checkbox/utils.ts b/src/components/Checkbox/utils.ts index 75b7398a77..3a0521eda9 100644 --- a/src/components/Checkbox/utils.ts +++ b/src/components/Checkbox/utils.ts @@ -2,7 +2,7 @@ import type { ColorValue } from 'react-native'; import { CheckboxTokens } from './tokens'; import { tokens } from '../../theme/tokens'; -import type { InternalTheme } from '../../types'; +import type { InternalTheme, StateOpacityKey } from '../../types'; // MD3 Checkbox spec: https://m3.material.io/components/checkbox/specs @@ -76,8 +76,7 @@ const getIconColor = ({ /** * Resolve the static (non-interactive) colors + opacity for the Checkbox - * renderer. Hover / pressed / focused visuals are owned by `TouchableRipple` - * and the focus-ring outline, so they don't appear here. + * renderer. The interaction states are resolved by `getStateLayer` instead. */ export const getSelectionVisualState = ({ theme, @@ -115,3 +114,76 @@ export const getSelectionVisualState = ({ }), }; }; + +/** Interaction the state layer is painting, or `null` when it is idle. */ +export type CheckboxInteraction = Extract< + StateOpacityKey, + 'hovered' | 'focused' | 'pressed' +>; + +export type CheckboxStateLayer = { + color: ColorValue; + opacity: number; +}; + +type StateLayerState = { + theme: InternalTheme; + selected: boolean; + error?: boolean; + customColor?: ColorValue; + customUncheckedColor?: ColorValue; +}; + +/** + * Resolve the MD3 state layer for the current interaction. Hover and focus use + * `primary` when selected and `onSurface` when not; pressing swaps them. An + * error checkbox stays on `error` throughout, and `color`/`uncheckedColor` + * override the role they already override on the box itself. + */ +export const getStateLayer = ({ + theme, + selected, + error, + interaction, + customColor, + customUncheckedColor, +}: StateLayerState & { + interaction: CheckboxInteraction | null; +}): CheckboxStateLayer => { + if (interaction === null) { + return { color: 'transparent', opacity: 0 }; + } + + const opacity = stateOpacity[interaction]; + + // A press previews the state being moved to, so the layer takes the opposite + // selection's color -- the same inversion the tokens below encode. + const followsSelected = interaction === 'pressed' ? !selected : selected; + const custom = followsSelected ? customColor : customUncheckedColor; + + if (custom) { + return { color: custom, opacity }; + } + + if (error) { + return { + color: theme.colors[CheckboxTokens.errorStateLayerColor], + opacity, + }; + } + + const role = + interaction === 'pressed' + ? selected + ? CheckboxTokens.selectedPressedStateLayerColor + : CheckboxTokens.unselectedPressedStateLayerColor + : interaction === 'focused' + ? selected + ? CheckboxTokens.selectedFocusStateLayerColor + : CheckboxTokens.unselectedFocusStateLayerColor + : selected + ? CheckboxTokens.selectedHoverStateLayerColor + : CheckboxTokens.unselectedHoverStateLayerColor; + + return { color: theme.colors[role], opacity }; +}; diff --git a/src/components/__tests__/Checkbox/Checkbox.test.tsx b/src/components/__tests__/Checkbox/Checkbox.test.tsx index a72200bb4e..0ee021f19c 100644 --- a/src/components/__tests__/Checkbox/Checkbox.test.tsx +++ b/src/components/__tests__/Checkbox/Checkbox.test.tsx @@ -1,7 +1,19 @@ -import { expect, it } from '@jest/globals'; +import { Platform, PlatformColor } from 'react-native'; -import { render } from '../../../test-utils'; +import { afterEach, describe, expect, it, jest } from '@jest/globals'; +import { getAnimatedStyle } from 'react-native-reanimated'; + +import { Provider as SettingsProvider } from '../../../core/settings'; +import { defaultThemes } from '../../../core/theming'; +import { fireEvent, render, screen } from '../../../test-utils'; +import { ReduceMotionContext } from '../../../theme/accessibility/ReduceMotionContext'; +import { tokens } from '../../../theme/tokens'; import Checkbox from '../../Checkbox'; +import type { Props as CheckboxProps } from '../../Checkbox/Checkbox'; + +afterEach(() => { + jest.restoreAllMocks(); +}); it('renders checked Checkbox with onPress', async () => { const tree = ( @@ -58,3 +70,490 @@ it('renders Checkbox with custom testID', async () => { expect(tree).toMatchSnapshot(); }); + +describe('Checkbox state layer', () => { + const { colors } = defaultThemes.light; + const { hovered, focused } = tokens.md.sys.state.opacity; + + const stateLayer = () => screen.getByTestId('checkbox-state-layer'); + + const renderCheckbox = (props: Partial = {}) => + render( + {}} + testID="checkbox" + {...props} + /> + ); + + it('is idle until the checkbox is interacted with', async () => { + await renderCheckbox(); + + expect(stateLayer()).toHaveStyle({ opacity: 0 }); + }); + + it('tints hover with onSurface when unselected', async () => { + await renderCheckbox(); + + await fireEvent(screen.getByRole('checkbox'), 'hoverIn'); + + expect(stateLayer()).toHaveStyle({ + backgroundColor: colors.onSurface, + opacity: hovered, + }); + }); + + it('tints hover with primary when selected', async () => { + await renderCheckbox({ status: 'checked' }); + + await fireEvent(screen.getByRole('checkbox'), 'hoverIn'); + + expect(stateLayer()).toHaveStyle({ + backgroundColor: colors.primary, + opacity: hovered, + }); + }); + + it('tints focus the same way as hover', async () => { + await renderCheckbox({ status: 'checked' }); + + await fireEvent(screen.getByRole('checkbox'), 'focus'); + + expect(stateLayer()).toHaveStyle({ + backgroundColor: colors.primary, + opacity: focused, + }); + }); + + it('stays on error for every interaction', async () => { + await renderCheckbox({ status: 'checked', error: true }); + + await fireEvent(screen.getByRole('checkbox'), 'hoverIn'); + expect(stateLayer()).toHaveStyle({ backgroundColor: colors.error }); + + await fireEvent(screen.getByRole('checkbox'), 'focus'); + expect(stateLayer()).toHaveStyle({ backgroundColor: colors.error }); + }); + + it('tints hover with a custom color instead of the token role', async () => { + await renderCheckbox({ status: 'checked', color: 'teal' }); + + await fireEvent(screen.getByRole('checkbox'), 'hoverIn'); + + expect(stateLayer()).toHaveStyle({ + backgroundColor: 'teal', + opacity: hovered, + }); + }); + + it('stays idle on a disabled checkbox', async () => { + await renderCheckbox({ disabled: true }); + + await fireEvent(screen.getByRole('checkbox'), 'hoverIn'); + + expect(stateLayer()).toHaveStyle({ opacity: 0 }); + }); + + it('fades out by opacity and keeps its color', async () => { + await renderCheckbox(); + + await fireEvent(screen.getByRole('checkbox'), 'hoverIn'); + await fireEvent(screen.getByRole('checkbox'), 'hoverOut'); + + expect(stateLayer()).toHaveStyle({ + backgroundColor: colors.onSurface, + opacity: 0, + }); + }); + + it('transitions opacity only', async () => { + await renderCheckbox(); + + // `toHaveStyle` reads the props Reanimated leaves on the host node, which + // drops CSS-transition-only keys -- `getAnimatedStyle` mirrors how + // Surface.test.tsx asserts `transitionProperty` for the same reason. + expect(getAnimatedStyle(stateLayer())).toMatchObject({ + transitionProperty: ['opacity'], + }); + }); + + it('tints with a PlatformColor role as-is on hover', async () => { + const onSurface = PlatformColor('?attr/colorOnSurface'); + await renderCheckbox({ theme: { colors: { onSurface } } }); + + await fireEvent(screen.getByRole('checkbox'), 'hoverIn'); + + expect(stateLayer()).toHaveStyle({ + backgroundColor: onSurface, + opacity: hovered, + }); + }); +}); + +describe('Checkbox press ripple', () => { + const { colors, motion } = defaultThemes.light; + const { hovered, pressed } = tokens.md.sys.state.opacity; + const GROW = motion.duration.short4; + const FADE_OUT = motion.duration.short3; + // Mirrors `RIPPLE_START_SCALE` in Checkbox.tsx. + const RIPPLE_START_SCALE = 0.6; + const platforms = ['ios', 'android', 'web'] as const; + + const ripple = () => getAnimatedStyle(screen.getByTestId('checkbox-ripple')); + + const renderCheckbox = (props: Partial = {}) => + render( + {}} + testID="checkbox" + {...props} + /> + ); + + const pressIn = () => fireEvent(screen.getByRole('checkbox'), 'pressIn'); + const pressOut = () => fireEvent(screen.getByRole('checkbox'), 'pressOut'); + + // The ripple has no `Platform` branch; these two guard against one + // creeping back in (the old implementation split on it). + it.each(platforms)('grows to fill the state layer on %s', async (os) => { + jest.replaceProperty(Platform, 'OS', os); + await renderCheckbox(); + + await pressIn(); + jest.advanceTimersByTime(GROW); + + expect(ripple()).toMatchObject({ + backgroundColor: colors.primary, + opacity: pressed, + transform: [{ scale: 1 }], + }); + }); + + it.each(platforms)('inverts to onSurface when selected on %s', async (os) => { + jest.replaceProperty(Platform, 'OS', os); + await renderCheckbox({ status: 'checked' }); + + await pressIn(); + jest.advanceTimersByTime(GROW); + + expect(ripple()).toMatchObject({ + backgroundColor: colors.onSurface, + opacity: pressed, + transform: [{ scale: 1 }], + }); + }); + + it('resets the scale on a second press', async () => { + await renderCheckbox(); + + await pressIn(); + await pressOut(); + await jest.runAllTimersAsync(); + + await pressIn(); + jest.advanceTimersByTime(0); + expect(ripple().transform).toEqual([{ scale: RIPPLE_START_SCALE }]); + + jest.advanceTimersByTime(GROW); + expect(ripple().transform).toEqual([{ scale: 1 }]); + }); + + it('stays on error while pressed', async () => { + await renderCheckbox({ status: 'checked', error: true }); + + await pressIn(); + jest.advanceTimersByTime(GROW); + + expect(ripple()).toMatchObject({ backgroundColor: colors.error }); + }); + + it('inverts a selected checkbox into its custom unchecked color', async () => { + await renderCheckbox({ status: 'checked', uncheckedColor: 'teal' }); + + await pressIn(); + jest.advanceTimersByTime(GROW); + + expect(ripple()).toMatchObject({ backgroundColor: 'teal' }); + }); + + it('inverts an unselected checkbox into its custom color', async () => { + await renderCheckbox({ color: 'teal' }); + + await pressIn(); + jest.advanceTimersByTime(GROW); + + expect(ripple()).toMatchObject({ backgroundColor: 'teal' }); + }); + + it('stays up for a minimum press when the finger lifts immediately', async () => { + await renderCheckbox(); + + await pressIn(); + await pressOut(); + + jest.advanceTimersByTime(100); + expect(ripple()).toMatchObject({ opacity: pressed }); + + jest.advanceTimersByTime(GROW + FADE_OUT); + expect(ripple()).toMatchObject({ opacity: 0 }); + }); + + it('takes the fade-out duration to reach zero', async () => { + await renderCheckbox(); + + await pressIn(); + await pressOut(); + jest.advanceTimersByTime(GROW + FADE_OUT / 2); + + const { opacity } = ripple(); + expect(opacity).toBeGreaterThan(0); + expect(opacity).toBeLessThan(pressed); + + jest.advanceTimersByTime(FADE_OUT / 2); + expect(ripple()).toMatchObject({ opacity: 0 }); + }); + + it('is not left behind by a rapid double tap', async () => { + await renderCheckbox(); + + await pressIn(); + await pressOut(); + jest.advanceTimersByTime(30); + await pressIn(); + await pressOut(); + + // The first press's hold (armed at t=0) would have expired here if the + // second press (t=30) hadn't re-armed it -- the ripple must still be up. + jest.advanceTimersByTime(GROW - 30 + 1); + expect(ripple()).toMatchObject({ opacity: pressed }); + + await jest.runAllTimersAsync(); + expect(ripple()).toMatchObject({ opacity: 0 }); + }); + + it('keeps the minimum-press hold under reduce motion', async () => { + await render( + + {}} testID="checkbox" /> + + ); + + await pressIn(); + jest.advanceTimersByTime(0); + expect(ripple()).toMatchObject({ + opacity: pressed, + transform: [{ scale: 1 }], + }); + + await pressOut(); + jest.advanceTimersByTime(GROW - 1); + expect(ripple()).toMatchObject({ opacity: pressed }); + + // Hold expires; the fade duration is 0 under reduce motion so it lands on + // 0 within this same tick. + jest.advanceTimersByTime(1); + expect(ripple()).toMatchObject({ opacity: 0 }); + }); + + it('leaves the flat state layer on hover while it paints the press', async () => { + await renderCheckbox(); + + await fireEvent(screen.getByRole('checkbox'), 'hoverIn'); + await pressIn(); + jest.advanceTimersByTime(GROW); + + expect(screen.getByTestId('checkbox-state-layer')).toHaveStyle({ + backgroundColor: colors.onSurface, + opacity: hovered, + }); + expect(ripple()).toMatchObject({ + backgroundColor: colors.primary, + opacity: pressed, + }); + }); + + it('tints with a PlatformColor role as-is', async () => { + const primary = PlatformColor('?attr/colorPrimary'); + await renderCheckbox({ theme: { colors: { primary } } }); + + await pressIn(); + jest.advanceTimersByTime(GROW); + + expect(ripple()).toMatchObject({ backgroundColor: primary }); + }); + + it('hands the press back to the platform when a rippleColor is given', async () => { + await renderCheckbox({ rippleColor: 'teal' }); + + expect(screen.queryByTestId('checkbox-ripple')).toBeNull(); + }); + + it('hands the press back to the platform when an underlayColor is given', async () => { + await renderCheckbox({ underlayColor: 'teal' }); + + expect(screen.queryByTestId('checkbox-ripple')).toBeNull(); + }); + + it('paints nothing when the ripple effect is turned off', async () => { + await render( + + {}} testID="checkbox" /> + + ); + + expect(screen.queryByTestId('checkbox-ripple')).toBeNull(); + }); + + it('is not rendered on a disabled checkbox', async () => { + await renderCheckbox({ disabled: true }); + + expect(screen.queryByTestId('checkbox-ripple')).toBeNull(); + }); + + it('is not rendered without a press handler', async () => { + await render(); + + expect(screen.queryByTestId('checkbox-ripple')).toBeNull(); + }); +}); + +describe('Checkbox without a press handler', () => { + it('is reported as disabled rather than an enabled no-op', async () => { + await render(); + + expect(screen.getByRole('checkbox')).toBeDisabled(); + }); + + it('does not paint a state layer on hover', async () => { + await render(); + + await fireEvent(screen.getByRole('checkbox'), 'hoverIn'); + + expect(screen.getByTestId('checkbox-state-layer')).toHaveStyle({ + opacity: 0, + }); + }); +}); +describe('Checkbox touch target', () => { + it('meets the 48dp minimum without resizing the state layer', async () => { + await render( + {}} testID="checkbox" /> + ); + + expect(screen.getByRole('checkbox')).toHaveStyle({ + width: 48, + height: 48, + }); + expect(screen.getByTestId('checkbox-state-layer')).toHaveStyle({ + width: 40, + height: 40, + }); + }); +}); +describe('Checkbox focus ring', () => { + const renderFocused = async () => { + await render( + {}} testID="checkbox" /> + ); + await fireEvent(screen.getByRole('checkbox'), 'focus'); + }; + + it('is not rendered until the checkbox is focused', async () => { + await render( + {}} testID="checkbox" /> + ); + + expect(screen.queryByTestId('checkbox-focus-ring')).toBeNull(); + }); + + it('stays hidden for pointer focus on web', async () => { + jest.replaceProperty(Platform, 'OS', 'web'); + + await render( + {}} testID="checkbox" /> + ); + await fireEvent(screen.getByRole('checkbox'), 'focus', { + currentTarget: { matches: () => false }, + }); + + expect(screen.queryByTestId('checkbox-focus-ring')).toBeNull(); + }); + + it('is shown for keyboard focus on web', async () => { + jest.replaceProperty(Platform, 'OS', 'web'); + + await render( + {}} testID="checkbox" /> + ); + await fireEvent(screen.getByRole('checkbox'), 'focus', { + currentTarget: { matches: () => true }, + }); + + expect(screen.getByTestId('checkbox-focus-ring')).toBeOnTheScreen(); + }); + + it('clears the 40dp state layer by the 2dp outer offset', async () => { + await renderFocused(); + + // 40dp state layer + 2dp offset + 3dp border on each side. + expect(screen.getByTestId('checkbox-focus-ring')).toHaveStyle({ + width: 50, + height: 50, + borderWidth: 3, + }); + }); +}); + +describe('Checkbox accessible name', () => { + it('warns when a standalone checkbox has no accessible name', async () => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + + await render( {}} />); + + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('aria-label') + ); + }); + + it('is named by aria-label', async () => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + + await render( + {}} aria-label="Notify me" /> + ); + + expect(screen.getByLabelText('Notify me')).toBeOnTheScreen(); + expect(console.warn).not.toHaveBeenCalled(); + }); + + it('does not warn for the checkbox inside a labelled Checkbox.Item', async () => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + + await render(); + + expect(console.warn).not.toHaveBeenCalled(); + }); +}); + +it('renders the focus ring outside the pressable so clipping cannot crop it', async () => { + await render( + {}} + aria-label="Notify me" + testID="checkbox" + /> + ); + await fireEvent(screen.getByRole('checkbox'), 'focus'); + + // Android P+ forces `overflow: hidden` on the pressable for the foreground + // ripple, so a ring nested inside it would be cropped. + const pressable = screen.getByRole('checkbox'); + let node = screen.getByTestId('checkbox-focus-ring').parent; + while (node) { + expect(node).not.toBe(pressable); + node = node.parent; + } +}); diff --git a/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap b/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap index 202a95467a..db4751c61b 100644 --- a/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap +++ b/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap @@ -2,84 +2,75 @@ exports[`renders Checkbox with custom testID 1`] = ` - + - + + + > + + @@ -187,83 +215,74 @@ exports[`renders Checkbox with custom testID 1`] = ` exports[`renders checked Checkbox with color 1`] = ` - + - + + + > + + @@ -371,83 +426,74 @@ exports[`renders checked Checkbox with color 1`] = ` exports[`renders checked Checkbox with onPress 1`] = ` - + + + + > + + @@ -555,83 +662,74 @@ exports[`renders checked Checkbox with onPress 1`] = ` exports[`renders indeterminate Checkbox 1`] = ` - + + + + > + + @@ -726,83 +885,74 @@ exports[`renders indeterminate Checkbox 1`] = ` exports[`renders indeterminate Checkbox with color 1`] = ` - + - + + + > + + @@ -897,83 +1083,74 @@ exports[`renders indeterminate Checkbox with color 1`] = ` exports[`renders unchecked Checkbox with color 1`] = ` - + - + + + > + + @@ -1081,83 +1294,74 @@ exports[`renders unchecked Checkbox with color 1`] = ` exports[`renders unchecked Checkbox with onPress 1`] = ` - + + + + > + + diff --git a/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap b/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap index 9617ebefbd..be4f7381cd 100644 --- a/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap +++ b/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap @@ -57,81 +57,72 @@ exports[`can render leading checkbox control 1`] = ` } > - + - + + + > + + @@ -372,81 +399,72 @@ exports[`renders unchecked 1`] = ` Unchecked Button - + - + + + > + + diff --git a/src/components/__tests__/Checkbox/utils.test.tsx b/src/components/__tests__/Checkbox/utils.test.tsx index ee0b09f159..b974350cf4 100644 --- a/src/components/__tests__/Checkbox/utils.test.tsx +++ b/src/components/__tests__/Checkbox/utils.test.tsx @@ -2,7 +2,7 @@ import { describe, expect, it } from '@jest/globals'; import { getTheme } from '../../../core/theming'; import { tokens } from '../../../theme/tokens'; -import { getSelectionVisualState } from '../../Checkbox/utils'; +import { getSelectionVisualState, getStateLayer } from '../../Checkbox/utils'; const stateOpacity = tokens.md.sys.state.opacity; const theme = getTheme(); @@ -129,3 +129,131 @@ describe('getSelectionVisualState', () => { }); }); }); + +describe('getStateLayer', () => { + const { colors } = theme; + const { hovered, focused, pressed } = tokens.md.sys.state.opacity; + + it('is fully transparent when idle', () => { + expect( + getStateLayer({ theme, selected: false, interaction: null }) + ).toEqual({ color: 'transparent', opacity: 0 }); + }); + + it.each([ + ['hovered' as const, hovered], + ['focused' as const, focused], + ])('tints %s with primary when selected', (interaction, opacity) => { + expect(getStateLayer({ theme, selected: true, interaction })).toEqual({ + color: colors.primary, + opacity, + }); + }); + + it.each([ + ['hovered' as const, hovered], + ['focused' as const, focused], + ])('tints %s with onSurface when unselected', (interaction, opacity) => { + expect(getStateLayer({ theme, selected: false, interaction })).toEqual({ + color: colors.onSurface, + opacity, + }); + }); + + it('inverts to onSurface when a selected checkbox is pressed', () => { + expect( + getStateLayer({ theme, selected: true, interaction: 'pressed' }) + ).toEqual({ color: colors.onSurface, opacity: pressed }); + }); + + it('inverts to primary when an unselected checkbox is pressed', () => { + expect( + getStateLayer({ theme, selected: false, interaction: 'pressed' }) + ).toEqual({ color: colors.primary, opacity: pressed }); + }); + + it.each(['hovered' as const, 'focused' as const, 'pressed' as const])( + 'stays on error for %s regardless of selection', + (interaction) => { + expect( + getStateLayer({ theme, selected: true, error: true, interaction }) + ).toEqual({ color: colors.error, opacity: expect.any(Number) }); + expect( + getStateLayer({ theme, selected: false, error: true, interaction }) + ).toEqual({ color: colors.error, opacity: expect.any(Number) }); + } + ); + + describe('custom colors', () => { + const custom = { + customColor: 'rebeccapurple', + customUncheckedColor: 'teal', + }; + + it.each([ + ['hovered' as const, hovered], + ['focused' as const, focused], + ])('uses customColor for %s when selected', (interaction, opacity) => { + expect( + getStateLayer({ theme, selected: true, interaction, ...custom }) + ).toEqual({ color: 'rebeccapurple', opacity }); + }); + + it.each([ + ['hovered' as const, hovered], + ['focused' as const, focused], + ])( + 'uses customUncheckedColor for %s when unselected', + (interaction, opacity) => { + expect( + getStateLayer({ theme, selected: false, interaction, ...custom }) + ).toEqual({ color: 'teal', opacity }); + } + ); + + it('takes the unchecked color when a selected checkbox is pressed', () => { + expect( + getStateLayer({ + theme, + selected: true, + interaction: 'pressed', + ...custom, + }) + ).toEqual({ color: 'teal', opacity: pressed }); + }); + + it('takes the checked color when an unselected checkbox is pressed', () => { + expect( + getStateLayer({ + theme, + selected: false, + interaction: 'pressed', + ...custom, + }) + ).toEqual({ color: 'rebeccapurple', opacity: pressed }); + }); + + it('overrides error, matching the box', () => { + expect( + getStateLayer({ + theme, + selected: true, + error: true, + interaction: 'hovered', + ...custom, + }) + ).toEqual({ color: 'rebeccapurple', opacity: hovered }); + }); + + it('leaves the other side on its token role', () => { + expect( + getStateLayer({ + theme, + selected: true, + interaction: 'hovered', + customUncheckedColor: 'teal', + }) + ).toEqual({ color: colors.primary, opacity: hovered }); + }); + }); +});