From 4734da786456d7cbb79bfbf2e693ef7c1c427338 Mon Sep 17 00:00:00 2001 From: likevy Date: Mon, 7 Sep 2026 12:24:41 +0200 Subject: [PATCH 1/6] fix: paint the MD3 state layer on checkbox The state-layer tokens were declared but never read, so hover and focus had no tint of their own. The layer now follows the spec -- `primary` when selected, `onSurface` when not, `error` throughout an error checkbox, `color` and `uncheckedColor` standing in for the role they override on the box -- and fades by opacity alone, so a dynamic theme's `PlatformColor` roles are never interpolated. Press handlers are only attached when something can handle a press, since TouchableRipple keys its own disabled state off that. --- docs/6.x/docs/guides/migration.md | 6 + src/components/Checkbox/Checkbox.tsx | 99 ++++++++++-- src/components/Checkbox/tokens.ts | 9 +- src/components/Checkbox/utils.ts | 78 ++++++++- .../__tests__/Checkbox/Checkbox.test.tsx | 148 +++++++++++++++++- .../__snapshots__/Checkbox.test.tsx.snap | 127 +++++++++++++++ .../__snapshots__/CheckboxItem.test.tsx.snap | 36 +++++ .../__tests__/Checkbox/utils.test.tsx | 130 ++++++++++++++- 8 files changed, 616 insertions(+), 17 deletions(-) diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index a4d7123a09..0b55aea1fc 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -279,3 +279,9 @@ const theme = { style={{ fontSize: 16, color: '#1C1B1F' }} /> ``` + +### Checkbox + +#### Interaction state colors + +Hover and focus are now painted as a Material Design 3 state layer, so the tint follows the spec: a flat 40dp layer with `primary` when selected and `onSurface` when not, and `error` throughout an error checkbox. `color` and `uncheckedColor` replace the role they already override on the box, so a custom checkbox no longer picks up a `primary` halo. diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx index 3bccccf33b..955dbc74ec 100644 --- a/src/components/Checkbox/Checkbox.tsx +++ b/src/components/Checkbox/Checkbox.tsx @@ -3,6 +3,7 @@ import { Platform, StyleSheet, View } from 'react-native'; import type { ColorValue, GestureResponderEvent, + MouseEvent, NativeSyntheticEvent, StyleProp, TargetedEvent, @@ -12,12 +13,14 @@ import type { import Animated, { cubicBezier, 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 { useInternalTheme } from '../../core/theming'; import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; import { tokens } from '../../theme/tokens'; import type { ThemeProp } from '../../theme/types'; +import hasTouchHandler from '../../utils/hasTouchHandler'; import { isKeyboardFocusEvent } from '../../utils/isKeyboardFocusEvent'; import TouchableRipple from '../TouchableRipple/TouchableRipple'; import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple'; @@ -39,11 +42,13 @@ export type Props = Omit< */ 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; /** @@ -129,20 +134,49 @@ const Checkbox = ({ // 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 fillTransitionTimingFunction = cubicBezier( ...theme.motion.easing.standard @@ -176,6 +210,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, @@ -215,6 +257,33 @@ 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) => { + rest.onPressIn?.(e); + }, + onPressOut: (e: GestureResponderEvent) => { + rest.onPressOut?.(e); + }, + } + : null; + + // Losing the handlers means the matching hover-out never arrives, so the + // state would otherwise linger. + React.useEffect(() => { + if (isInteractive) return; + + setHovered(false); + }, [isInteractive]); + const checked: boolean | 'mixed' = status === 'indeterminate' ? 'mixed' : status === 'checked'; @@ -240,6 +309,7 @@ const Checkbox = ({ onPress={onPress} onFocus={handleFocus} onBlur={handleBlur} + {...interactionHandlers} disabled={disabled} {...accessibilityProps} testID={testID} @@ -250,6 +320,11 @@ const Checkbox = ({ ]} > + {focused && !disabled ? ( ; diff --git a/src/components/Checkbox/utils.ts b/src/components/Checkbox/utils.ts index 80e7c31750..edcf0794d4 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 '../../theme/types'; +import type { InternalTheme, StateOpacityKey } from '../../theme/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..f41d70b63d 100644 --- a/src/components/__tests__/Checkbox/Checkbox.test.tsx +++ b/src/components/__tests__/Checkbox/Checkbox.test.tsx @@ -1,7 +1,13 @@ -import { expect, it } from '@jest/globals'; +import { PlatformColor } from 'react-native'; -import { render } from '../../../test-utils'; +import { describe, expect, it } from '@jest/globals'; +import { getAnimatedStyle } from 'react-native-reanimated'; + +import { defaultThemes } from '../../../core/theming'; +import { fireEvent, render, screen } from '../../../test-utils'; +import { tokens } from '../../../theme/tokens'; import Checkbox from '../../Checkbox'; +import type { Props as CheckboxProps } from '../../Checkbox/Checkbox'; it('renders checked Checkbox with onPress', async () => { const tree = ( @@ -58,3 +64,141 @@ 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 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, + }); + }); +}); diff --git a/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap b/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap index 202a95467a..a77ba5ae4f 100644 --- a/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap +++ b/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap @@ -65,6 +65,25 @@ exports[`renders Checkbox with custom testID 1`] = ` } } > + + + + + + + + + { }); }); }); + +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 }); + }); + }); +}); From 177dad0571ee43f0746bcf1c1e8ab8ef3401137b Mon Sep 17 00:00:00 2001 From: likevy Date: Mon, 7 Sep 2026 12:27:01 +0200 Subject: [PATCH 2/6] fix: ripple the checkbox press inside the state layer MD3 paints the press as a ripple bounded to the 40dp state layer in the inverted role -- `onSurface` when selected, `primary` when not. The platform press could not deliver that: Android's ripple rejects the `PlatformColor` a dynamic theme resolves roles to, iOS and Android only offered the neutral default, and web's hover overlay doubled up with the layer. The checkbox now draws the ripple itself, holds it for a minimum press so a quick tap still reads, and resolves its color once per press so a controlled `status` flip mid-hold cannot recolor it. A caller gets the platform press back only through a prop that platform honors: `rippleColor` anywhere, `background` on Android, `underlayColor` on iOS. `rippleEffectEnabled: false` disables it as everywhere else. --- docs/6.x/docs/guides/migration.md | 2 +- src/components/Checkbox/Checkbox.tsx | 149 +++++++- .../__tests__/Checkbox/Checkbox.test.tsx | 318 +++++++++++++++++- .../__snapshots__/Checkbox.test.tsx.snap | 75 +++++ 4 files changed, 536 insertions(+), 8 deletions(-) diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index 0b55aea1fc..eace208816 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -284,4 +284,4 @@ const theme = { #### Interaction state colors -Hover and focus are now painted as a Material Design 3 state layer, so the tint follows the spec: a flat 40dp layer with `primary` when selected and `onSurface` when not, and `error` throughout an error checkbox. `color` and `uncheckedColor` replace the role they already override on the box, so a custom checkbox no longer picks up a `primary` halo. +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 `rippleColor` on any platform, `background` on Android or `underlayColor` on iOS turns it back on and disables the built-in press instead. Setting `rippleEffectEnabled: false` on the `settings` prop of `PaperProvider` still suppresses both. diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx index 955dbc74ec..07033cc0b7 100644 --- a/src/components/Checkbox/Checkbox.tsx +++ b/src/components/Checkbox/Checkbox.tsx @@ -10,12 +10,23 @@ import type { 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, 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'; @@ -89,6 +100,10 @@ const FOCUS_THICKNESS = tokens.md.sys.state.focusIndicator.thickness; const FOCUS_RING_SIZE = STATE_LAYER_SIZE; const FOCUS_RING_RADIUS = STATE_LAYER_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. * @@ -128,6 +143,7 @@ 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 @@ -177,6 +193,42 @@ const Checkbox = ({ ...selectionColors, interaction: interaction ?? 'hovered', }).color; + const pressRipple = getStateLayer({ + ...selectionColors, + interaction: 'pressed', + }); + + // A controlled checkbox flips `status` from `onPress`, while the ripple is + // still held up. The inverted color previews the state being moved to, so + // recomputing it there would both flicker and start previewing the way back. + // Only the ripple is frozen: the flat layer above reports the current state, + // which the flip genuinely changed, so under a web hover it is expected to + // briefly converge on the hue the ripple is holding. + const [pressColor, setPressColor] = React.useState( + pressRipple.color + ); + + // The platform press paints the wrong thing: it 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. A caller can still ask for that platform press back, but only + // through a prop this platform honours: `rippleColor` everywhere, + // `background` for the Android ripple, `underlayColor` for the iOS + // highlight. Counting one the platform drops would cost the MD3 ripple and + // hand back the neutral default instead. + const platformOwnsPress = + rest.rippleColor != null || + (Platform.OS === 'android' && rest.background != null) || + (Platform.OS === 'ios' && 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 @@ -226,6 +278,73 @@ 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; + + setPressColor(pressRipple.color); + 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) @@ -268,21 +387,27 @@ const Checkbox = ({ 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 never arrives, so the - // state would otherwise linger. + // 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); - }, [isInteractive]); + pressedSV.value = 0; + rippleHoldSV.value = 0; + rippleAlpha.value = 0; + rippleScale.value = RIPPLE_START_SCALE; + }, [isInteractive, pressedSV, rippleHoldSV, rippleAlpha, rippleScale]); const checked: boolean | 'mixed' = status === 'indeterminate' ? 'mixed' : status === 'checked'; @@ -304,12 +429,15 @@ const Checkbox = ({ return ( + {ownsPress ? ( + + ) : null} {focused && !disabled ? ( { + jest.restoreAllMocks(); +}); + it('renders checked Checkbox with onPress', async () => { const tree = ( await render( {}} />) @@ -185,6 +191,314 @@ describe('Checkbox state layer', () => { }); }); +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 }); + }); + + // Whether the platform press was suppressed or handed back only shows up on + // `TouchableRipple`'s own press underlay, so assert there: the absence of our + // ripple says nothing about what the platform paints in its place. + const underlay = () => screen.getByTestId('touchable-ripple-underlay'); + + it('suppresses the platform press by default', async () => { + await renderCheckbox({ testOnly_pressed: true }); + + expect(underlay()).toHaveStyle({ backgroundColor: 'transparent' }); + }); + + it('hands the press back to the platform when a rippleColor is given', async () => { + await renderCheckbox({ rippleColor: 'teal', testOnly_pressed: true }); + + expect(screen.queryByTestId('checkbox-ripple')).toBeNull(); + expect(underlay()).toHaveStyle({ backgroundColor: 'teal' }); + }); + + it('hands the press back to the platform when an underlayColor is given on ios', async () => { + jest.replaceProperty(Platform, 'OS', 'ios'); + await renderCheckbox({ underlayColor: 'teal', testOnly_pressed: true }); + + expect(screen.queryByTestId('checkbox-ripple')).toBeNull(); + expect(underlay()).toHaveStyle({ backgroundColor: 'teal' }); + }); + + it('hands the press back to the platform when a background is given on android', async () => { + jest.replaceProperty(Platform, 'OS', 'android'); + await renderCheckbox({ + background: { color: 'teal' }, + testOnly_pressed: true, + }); + + expect(screen.queryByTestId('checkbox-ripple')).toBeNull(); + expect(underlay()).toHaveStyle({ + backgroundColor: colors.stateLayerPressed, + }); + }); + + // Android discards `underlayColor`, so taking it for a hand-back there would + // drop our ripple for the platform's neutral default. + it('keeps painting the press despite an underlayColor on android', async () => { + jest.replaceProperty(Platform, 'OS', 'android'); + await renderCheckbox({ underlayColor: 'teal' }); + + await pressIn(); + jest.advanceTimersByTime(GROW); + + expect(ripple()).toMatchObject({ + backgroundColor: colors.primary, + opacity: pressed, + }); + }); + + // Only the android ripple reads `background`; every other platform discards + // it, so taking it for a hand-back would trade our ripple for the platform's + // neutral default. + it('keeps painting the press despite a background off android', async () => { + jest.replaceProperty(Platform, 'OS', 'ios'); + await renderCheckbox({ background: { color: 'teal' } }); + + await pressIn(); + jest.advanceTimersByTime(GROW); + + expect(ripple()).toMatchObject({ + backgroundColor: colors.primary, + opacity: pressed, + }); + }); + + it('keeps its press-start color when the status flips mid-press', async () => { + await renderCheckbox(); + + await pressIn(); + jest.advanceTimersByTime(GROW); + expect(ripple()).toMatchObject({ backgroundColor: colors.primary }); + + // What a controlled parent does from `onPress`, while the ripple is still + // on screen. + await screen.rerender( + {}} testID="checkbox" /> + ); + expect(ripple()).toMatchObject({ backgroundColor: colors.primary }); + + await pressOut(); + await jest.runAllTimersAsync(); + + await pressIn(); + jest.advanceTimersByTime(GROW); + expect(ripple()).toMatchObject({ backgroundColor: colors.onSurface }); + }); + + 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(); diff --git a/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap b/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap index a77ba5ae4f..51aa4896de 100644 --- a/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap +++ b/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap @@ -488,6 +488,31 @@ exports[`renders checked Checkbox with onPress 1`] = ` ] } /> + + + Date: Mon, 7 Sep 2026 12:28:35 +0200 Subject: [PATCH 3/6] fix: meet the 48dp touch target on checkbox The pressable was sized to the 40dp state layer, 8dp short of the minimum interactive area, with no hitSlop making up the difference. Only the pressable grows; the 40dp layers it centres stay where they were. --- docs/6.x/docs/guides/migration.md | 4 ++ src/components/Checkbox/Checkbox.tsx | 24 ++++++----- src/components/Checkbox/tokens.ts | 2 + .../__tests__/Checkbox/Checkbox.test.tsx | 16 +++++++ .../__snapshots__/Checkbox.test.tsx.snap | 42 +++++++++---------- .../__snapshots__/CheckboxItem.test.tsx.snap | 12 +++--- 6 files changed, 62 insertions(+), 38 deletions(-) diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index eace208816..6093884dad 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -285,3 +285,7 @@ const theme = { #### 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 `rippleColor` on any platform, `background` on Android or `underlayColor` on iOS turns it back on 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 07033cc0b7..5a27de43df 100644 --- a/src/components/Checkbox/Checkbox.tsx +++ b/src/components/Checkbox/Checkbox.tsx @@ -90,6 +90,7 @@ 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; @@ -208,14 +209,15 @@ const Checkbox = ({ pressRipple.color ); - // The platform press paints the wrong thing: it 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. A caller can still ask for that platform press back, but only - // through a prop this platform honours: `rippleColor` everywhere, - // `background` for the Android ripple, `underlayColor` for the iOS - // highlight. Counting one the platform drops would cost the MD3 ripple and - // hand back the neutral default instead. + // 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. + // A caller can still ask for that platform press back, but only through a + // prop this platform honours: `rippleColor` everywhere, `background` for the + // Android ripple, `underlayColor` for the iOS highlight. Counting one the + // platform drops would cost the MD3 ripple and hand back the neutral default + // instead. const platformOwnsPress = rest.rippleColor != null || (Platform.OS === 'android' && rest.background != null) || @@ -521,9 +523,9 @@ const webNoOutline = { outline: 'none' } as unknown as ViewStyle; const styles = StyleSheet.create({ 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', }, diff --git a/src/components/Checkbox/tokens.ts b/src/components/Checkbox/tokens.ts index 7a565a2521..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 = { diff --git a/src/components/__tests__/Checkbox/Checkbox.test.tsx b/src/components/__tests__/Checkbox/Checkbox.test.tsx index c083e8f7cf..7fbf45109e 100644 --- a/src/components/__tests__/Checkbox/Checkbox.test.tsx +++ b/src/components/__tests__/Checkbox/Checkbox.test.tsx @@ -516,3 +516,19 @@ describe('Checkbox without a press handler', () => { }); }); }); +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, + }); + }); +}); diff --git a/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap b/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap index 51aa4896de..c32e8c5bdc 100644 --- a/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap +++ b/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap @@ -42,10 +42,10 @@ exports[`renders Checkbox with custom testID 1`] = ` [ { "alignItems": "center", - "borderRadius": 20, - "height": 40, + "borderRadius": 24, + "height": 48, "justifyContent": "center", - "width": 40, + "width": 48, }, undefined, undefined, @@ -246,10 +246,10 @@ exports[`renders checked Checkbox with color 1`] = ` [ { "alignItems": "center", - "borderRadius": 20, - "height": 40, + "borderRadius": 24, + "height": 48, "justifyContent": "center", - "width": 40, + "width": 48, }, undefined, undefined, @@ -448,10 +448,10 @@ exports[`renders checked Checkbox with onPress 1`] = ` [ { "alignItems": "center", - "borderRadius": 20, - "height": 40, + "borderRadius": 24, + "height": 48, "justifyContent": "center", - "width": 40, + "width": 48, }, undefined, undefined, @@ -675,10 +675,10 @@ exports[`renders indeterminate Checkbox 1`] = ` [ { "alignItems": "center", - "borderRadius": 20, - "height": 40, + "borderRadius": 24, + "height": 48, "justifyContent": "center", - "width": 40, + "width": 48, }, undefined, undefined, @@ -889,10 +889,10 @@ exports[`renders indeterminate Checkbox with color 1`] = ` [ { "alignItems": "center", - "borderRadius": 20, - "height": 40, + "borderRadius": 24, + "height": 48, "justifyContent": "center", - "width": 40, + "width": 48, }, undefined, undefined, @@ -1078,10 +1078,10 @@ exports[`renders unchecked Checkbox with color 1`] = ` [ { "alignItems": "center", - "borderRadius": 20, - "height": 40, + "borderRadius": 24, + "height": 48, "justifyContent": "center", - "width": 40, + "width": 48, }, undefined, undefined, @@ -1280,10 +1280,10 @@ exports[`renders unchecked Checkbox with onPress 1`] = ` [ { "alignItems": "center", - "borderRadius": 20, - "height": 40, + "borderRadius": 24, + "height": 48, "justifyContent": "center", - "width": 40, + "width": 48, }, undefined, undefined, diff --git a/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap b/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap index 45a317308e..cf6d93e658 100644 --- a/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap +++ b/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap @@ -95,10 +95,10 @@ exports[`can render leading checkbox control 1`] = ` [ { "alignItems": "center", - "borderRadius": 20, - "height": 40, + "borderRadius": 24, + "height": 48, "justifyContent": "center", - "width": 40, + "width": 48, }, undefined, undefined, @@ -428,10 +428,10 @@ exports[`renders unchecked 1`] = ` [ { "alignItems": "center", - "borderRadius": 20, - "height": 40, + "borderRadius": 24, + "height": 48, "justifyContent": "center", - "width": 40, + "width": 48, }, undefined, undefined, From 331690c0bb1579ee6e5ec6df8e3e65ef614600d6 Mon Sep 17 00:00:00 2001 From: likevy Date: Mon, 7 Sep 2026 12:30:52 +0200 Subject: [PATCH 4/6] fix: offset the checkbox focus ring by 2dp The ring sat on the state-layer boundary because the pressable clips overflow to the tap-target shape and would crop the spec's outer offset. Rendered as a sibling of the pressable it takes the spec geometry, which also covers Android P+, where a foreground ripple forces the clip regardless of `borderless`. That wrapper is the component's outer box, so the public `style` moves onto it -- on the pressable it had stopped positioning or transforming the checkbox as a whole. --- docs/6.x/docs/guides/migration.md | 4 + src/components/Checkbox/Checkbox.tsx | 189 +- .../__tests__/Checkbox/Checkbox.test.tsx | 90 + .../__snapshots__/Checkbox.test.tsx.snap | 2053 +++++++++-------- .../__snapshots__/CheckboxItem.test.tsx.snap | 578 ++--- 5 files changed, 1560 insertions(+), 1354 deletions(-) diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index 6093884dad..643c62847e 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -289,3 +289,7 @@ Interaction states are now painted as a Material Design 3 state layer on every p #### 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. + +#### Custom style target + +The `style` prop now applies to the checkbox's outer container rather than the pressable inside it, so layout styles such as `margin`, `position` and `transform` move the whole component, focus ring included. `width` and `height` no longer resize the 48dp tap target, and paint styles land on a different shape: a `backgroundColor` used to fill the circular pressable and now fills the square container around it. diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx index 5a27de43df..e9e2f86385 100644 --- a/src/components/Checkbox/Checkbox.tsx +++ b/src/components/Checkbox/Checkbox.tsx @@ -78,8 +78,8 @@ export type Props = Omit< */ testID?: string; /** - * Custom style to override the default tap target. Passed through to - * the underlying `TouchableRipple`. + * Custom style for the checkbox's outer container. The 48dp tap target is + * fixed, so `width` and `height` here do not resize it. */ style?: StyleProp; }; @@ -93,13 +93,13 @@ const { 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. @@ -428,92 +428,101 @@ const Checkbox = ({ 'aria-live': 'polite' as const, }; + const focusRing = + focused && !disabled ? ( + + ) : null; + return ( - - - - {ownsPress ? ( + // The ring is a sibling of the pressable, not a child: a foreground ripple + // forces `overflow: hidden` on it regardless of `borderless`. + + + - ) : null} - {focused && !disabled ? ( + {ownsPress ? ( + + ) : null} - ) : null} - - - - - {showIndeterminate ? ( - - - - ) : ( - - - - )} - + + + + {showIndeterminate ? ( + + + + ) : ( + + + + )} + + - - + + {focusRing} + ); }; @@ -522,6 +531,10 @@ const Checkbox = ({ const webNoOutline = { outline: 'none' } as unknown as ViewStyle; const styles = StyleSheet.create({ + root: { + alignItems: 'center', + justifyContent: 'center', + }, tapTarget: { width: TOUCH_TARGET_SIZE, height: TOUCH_TARGET_SIZE, diff --git a/src/components/__tests__/Checkbox/Checkbox.test.tsx b/src/components/__tests__/Checkbox/Checkbox.test.tsx index 7fbf45109e..5ef3a552be 100644 --- a/src/components/__tests__/Checkbox/Checkbox.test.tsx +++ b/src/components/__tests__/Checkbox/Checkbox.test.tsx @@ -531,4 +531,94 @@ describe('Checkbox touch target', () => { height: 40, }); }); + + it('takes the custom style on the outer container, not the pressable', async () => { + await render( + {}} + testID="checkbox" + style={{ marginTop: 12 }} + /> + ); + + // On the pressable the style would miss the focus ring beside it, and + // could shrink the tap target back below 48dp. + expect(screen.root).toHaveStyle({ marginTop: 12 }); + expect(screen.getByRole('checkbox')).not.toHaveStyle({ marginTop: 12 }); + }); +}); +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, + }); + }); +}); + +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 c32e8c5bdc..3f34623321 100644 --- a/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap +++ b/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap @@ -2,103 +2,77 @@ exports[`renders Checkbox with custom testID 1`] = ` - - + - + + + > + + @@ -206,101 +217,76 @@ exports[`renders Checkbox with custom testID 1`] = ` exports[`renders checked Checkbox with color 1`] = ` - - + - + + + > + + @@ -408,126 +430,76 @@ exports[`renders checked Checkbox with color 1`] = ` exports[`renders checked Checkbox with onPress 1`] = ` - - - + + + + > + + @@ -635,126 +668,76 @@ exports[`renders checked Checkbox with onPress 1`] = ` exports[`renders indeterminate Checkbox 1`] = ` - - - + + + + > + + @@ -849,101 +893,76 @@ exports[`renders indeterminate Checkbox 1`] = ` exports[`renders indeterminate Checkbox with color 1`] = ` - - + - + + + > + + @@ -1038,101 +1093,76 @@ exports[`renders indeterminate Checkbox with color 1`] = ` exports[`renders unchecked Checkbox with color 1`] = ` - - + - + + + > + + @@ -1240,126 +1306,76 @@ 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 cf6d93e658..0ed386a1d2 100644 --- a/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap +++ b/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap @@ -57,99 +57,74 @@ exports[`can render leading checkbox control 1`] = ` } > - - + - + + + > + + @@ -390,99 +401,74 @@ exports[`renders unchecked 1`] = ` Unchecked Button - - + - + + + > + + From 51651d8ae5ea6cb0fa69d5afe64f8290e863df0a Mon Sep 17 00:00:00 2001 From: likevy Date: Mon, 7 Sep 2026 12:32:33 +0200 Subject: [PATCH 5/6] feat: add tapTargetStyle to the checkbox Moving `style` onto the outer container took away the only way to reach the pressable, which that prop used to document. `tapTargetStyle` reaches it again, named for what it styles rather than `contentStyle`, which elsewhere in the library means a non-interactive content container. Sizing is what it is for. Margin, padding and transform belong in `style`: here they shift the pressable out from under the focus ring. --- docs/6.x/docs/guides/migration.md | 2 +- src/components/Checkbox/Checkbox.tsx | 13 ++++- .../__tests__/Checkbox/Checkbox.test.tsx | 49 +++++++++++++++++++ .../__snapshots__/Checkbox.test.tsx.snap | 7 +++ .../__snapshots__/CheckboxItem.test.tsx.snap | 2 + 5 files changed, 70 insertions(+), 3 deletions(-) diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index 643c62847e..091db2b983 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -292,4 +292,4 @@ Interaction states are now painted as a Material Design 3 state layer on every p #### Custom style target -The `style` prop now applies to the checkbox's outer container rather than the pressable inside it, so layout styles such as `margin`, `position` and `transform` move the whole component, focus ring included. `width` and `height` no longer resize the 48dp tap target, and paint styles land on a different shape: a `backgroundColor` used to fill the circular pressable and now fills the square container around it. +The `style` prop now applies to the checkbox's outer container rather than the pressable inside it, so layout styles such as `margin`, `position` and `transform` move the whole component, focus ring included. `width` and `height` no longer resize the 48dp tap target, and paint styles land on a different shape: a `backgroundColor` used to fill the circular pressable and now fills the square container around it. Resizing the tap target now lives on the new `tapTargetStyle` prop, which reaches the pressable itself. diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx index e9e2f86385..6793fc057d 100644 --- a/src/components/Checkbox/Checkbox.tsx +++ b/src/components/Checkbox/Checkbox.tsx @@ -78,10 +78,17 @@ export type Props = Omit< */ testID?: string; /** - * Custom style for the checkbox's outer container. The 48dp tap target is - * fixed, so `width` and `height` here do not resize it. + * Custom style for the checkbox's outer container. `width` and `height` here + * do not resize the tap target; `tapTargetStyle` does. */ style?: StyleProp; + /** + * Custom style for the pressable that carries the 48dp tap target. Sizing it + * keeps the 24dp corner radius, and shrinking it clips the 40dp state layer. + * Margin, padding and transform belong in `style`: here they shift the + * pressable out from under the focus ring. + */ + tapTargetStyle?: StyleProp; }; // Spec dimensions (https://m3.material.io/components/checkbox/specs). @@ -139,6 +146,7 @@ const Checkbox = ({ color, uncheckedColor, style, + tapTargetStyle, ...rest }: Props) => { const theme = useInternalTheme(themeOverrides); @@ -458,6 +466,7 @@ const Checkbox = ({ style={[ styles.tapTarget, Platform.OS === 'web' ? webNoOutline : undefined, + tapTargetStyle, ]} > diff --git a/src/components/__tests__/Checkbox/Checkbox.test.tsx b/src/components/__tests__/Checkbox/Checkbox.test.tsx index 5ef3a552be..ccd5a92f20 100644 --- a/src/components/__tests__/Checkbox/Checkbox.test.tsx +++ b/src/components/__tests__/Checkbox/Checkbox.test.tsx @@ -547,6 +547,55 @@ describe('Checkbox touch target', () => { expect(screen.root).toHaveStyle({ marginTop: 12 }); expect(screen.getByRole('checkbox')).not.toHaveStyle({ marginTop: 12 }); }); + + it('puts the tap target style on the pressable, over the built-in size', async () => { + await render( + {}} + testID="checkbox" + tapTargetStyle={{ width: 60, height: 60 }} + /> + ); + + expect(screen.getByRole('checkbox')).toHaveStyle({ + width: 60, + height: 60, + }); + expect(screen.root).not.toHaveStyle({ width: 60 }); + }); + + it('keeps the tap target style without a press handler', async () => { + await render( + + ); + + expect(screen.getByRole('checkbox')).toHaveStyle({ + width: 60, + height: 60, + }); + }); + + it('keeps the tap target style on a disabled checkbox', async () => { + await render( + {}} + disabled + testID="checkbox" + tapTargetStyle={{ width: 60, height: 60 }} + /> + ); + + expect(screen.getByRole('checkbox')).toHaveStyle({ + width: 60, + height: 60, + }); + }); }); describe('Checkbox focus ring', () => { const renderFocused = async () => { diff --git a/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap b/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap index 3f34623321..f950d771f7 100644 --- a/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap +++ b/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap @@ -59,6 +59,7 @@ exports[`renders Checkbox with custom testID 1`] = ` "width": 48, }, undefined, + undefined, ], ] } @@ -274,6 +275,7 @@ exports[`renders checked Checkbox with color 1`] = ` "width": 48, }, undefined, + undefined, ], ] } @@ -487,6 +489,7 @@ exports[`renders checked Checkbox with onPress 1`] = ` "width": 48, }, undefined, + undefined, ], ] } @@ -725,6 +728,7 @@ exports[`renders indeterminate Checkbox 1`] = ` "width": 48, }, undefined, + undefined, ], ] } @@ -950,6 +954,7 @@ exports[`renders indeterminate Checkbox with color 1`] = ` "width": 48, }, undefined, + undefined, ], ] } @@ -1150,6 +1155,7 @@ exports[`renders unchecked Checkbox with color 1`] = ` "width": 48, }, undefined, + undefined, ], ] } @@ -1363,6 +1369,7 @@ exports[`renders unchecked Checkbox with onPress 1`] = ` "width": 48, }, undefined, + undefined, ], ] } diff --git a/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap b/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap index 0ed386a1d2..1093e09cff 100644 --- a/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap +++ b/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap @@ -112,6 +112,7 @@ exports[`can render leading checkbox control 1`] = ` "width": 48, }, undefined, + undefined, ], ] } @@ -456,6 +457,7 @@ exports[`renders unchecked 1`] = ` "width": 48, }, undefined, + undefined, ], ] } From 55f6971ca64fab219732dac14a8967645c8b3760 Mon Sep 17 00:00:00 2001 From: likevy Date: Mon, 7 Sep 2026 12:33:33 +0200 Subject: [PATCH 6/6] feat: require an accessible name on a standalone checkbox A standalone checkbox renders no visible label, and `aria-label` reached it only by inheritance, so it appeared in no prop table and nothing flagged one that shipped unnamed. `Checkbox.Item` names the row and is exempt. --- src/components/Checkbox/Checkbox.tsx | 30 ++++++++++++++++++ .../__tests__/Checkbox/Checkbox.test.tsx | 31 +++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx index 6793fc057d..3123905630 100644 --- a/src/components/Checkbox/Checkbox.tsx +++ b/src/components/Checkbox/Checkbox.tsx @@ -89,6 +89,13 @@ export type Props = Omit< * pressable out from under the focus ring. */ tapTargetStyle?: 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). @@ -135,6 +142,12 @@ const RIPPLE_START_SCALE = 0.6; * * 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, @@ -419,6 +432,23 @@ const Checkbox = ({ 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'; diff --git a/src/components/__tests__/Checkbox/Checkbox.test.tsx b/src/components/__tests__/Checkbox/Checkbox.test.tsx index ccd5a92f20..de91708187 100644 --- a/src/components/__tests__/Checkbox/Checkbox.test.tsx +++ b/src/components/__tests__/Checkbox/Checkbox.test.tsx @@ -651,6 +651,37 @@ describe('Checkbox focus ring', () => { }); }); +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(