From 0a4170889b7c62f89df9223de5e5e458795f7b98 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 4 Sep 2026 16:38:05 +0200 Subject: [PATCH 1/5] 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 02df81b0b4..ed4e845c70 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 { $RemoveChildren, ThemeProp } from '../../types'; +import hasTouchHandler from '../../utils/hasTouchHandler'; import { isKeyboardFocusEvent } from '../../utils/isKeyboardFocusEvent'; import TouchableRipple from '../TouchableRipple/TouchableRipple'; @@ -35,11 +38,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; /** @@ -125,20 +130,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 @@ -172,6 +206,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, @@ -211,6 +253,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'; @@ -236,6 +305,7 @@ const Checkbox = ({ onPress={onPress} onFocus={handleFocus} onBlur={handleBlur} + {...interactionHandlers} disabled={disabled} {...accessibilityProps} testID={testID} @@ -246,6 +316,11 @@ const Checkbox = ({ ]} > + {focused && !disabled ? ( ; 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..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 b32d19c97f8930c5761eae511c36a0b103eb7dbe Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 4 Sep 2026 16:40:55 +0200 Subject: [PATCH 2/5] 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 and holds it for a minimum press so a quick tap still reads. `rippleColor` or `underlayColor` hands the press back to the platform; `rippleEffectEnabled: false` disables it as everywhere else. --- docs/6.x/docs/guides/migration.md | 2 +- src/components/Checkbox/Checkbox.tsx | 133 +++++++++- .../__tests__/Checkbox/Checkbox.test.tsx | 238 +++++++++++++++++- .../__snapshots__/Checkbox.test.tsx.snap | 75 ++++++ 4 files changed, 440 insertions(+), 8 deletions(-) diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index 0b55aea1fc..c3641e455c 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 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. diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx index ed4e845c70..a5d54acf81 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'; @@ -85,6 +96,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. * @@ -124,6 +139,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 @@ -173,6 +189,27 @@ const Checkbox = ({ ...selectionColors, interaction: interaction ?? 'hovered', }).color; + const pressRipple = getStateLayer({ + ...selectionColors, + interaction: 'pressed', + }); + + // 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. 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 @@ -222,6 +259,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) @@ -264,21 +367,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'; @@ -300,12 +409,15 @@ const Checkbox = ({ return ( + {ownsPress ? ( + + ) : null} {focused && !disabled ? ( { + jest.restoreAllMocks(); +}); + it('renders checked Checkbox with onPress', async () => { const tree = ( await render( {}} />) @@ -185,6 +191,234 @@ 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 }); + }); + + 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(); 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: Fri, 4 Sep 2026 16:42:17 +0200 Subject: [PATCH 3/5] 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 | 18 ++++---- 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, 59 insertions(+), 35 deletions(-) diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index c3641e455c..793f6af73f 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 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 a5d54acf81..39a79174dd 100644 --- a/src/components/Checkbox/Checkbox.tsx +++ b/src/components/Checkbox/Checkbox.tsx @@ -86,6 +86,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; @@ -194,11 +195,12 @@ const Checkbox = ({ interaction: 'pressed', }); - // 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. An explicit `rippleColor` or `underlayColor` asks for that - // platform press instead, so leave it alone. + // 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; @@ -501,9 +503,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 827d481080..391d3cbad4 100644 --- a/src/components/__tests__/Checkbox/Checkbox.test.tsx +++ b/src/components/__tests__/Checkbox/Checkbox.test.tsx @@ -436,3 +436,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 9c1fb3ee44f3a0109dde5be84c5da50958cf75e3 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 4 Sep 2026 16:44:00 +0200 Subject: [PATCH 4/5] 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`. --- src/components/Checkbox/Checkbox.tsx | 186 +- .../__tests__/Checkbox/Checkbox.test.tsx | 74 + .../__snapshots__/Checkbox.test.tsx.snap | 2081 +++++++++-------- .../__snapshots__/CheckboxItem.test.tsx.snap | 586 ++--- 4 files changed, 1548 insertions(+), 1379 deletions(-) diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx index 39a79174dd..60158ae4a6 100644 --- a/src/components/Checkbox/Checkbox.tsx +++ b/src/components/Checkbox/Checkbox.tsx @@ -89,13 +89,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. @@ -408,92 +408,102 @@ 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} + ); }; @@ -502,6 +512,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 391d3cbad4..45f23ee595 100644 --- a/src/components/__tests__/Checkbox/Checkbox.test.tsx +++ b/src/components/__tests__/Checkbox/Checkbox.test.tsx @@ -452,3 +452,77 @@ describe('Checkbox touch target', () => { }); }); }); +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..db4751c61b 100644 --- a/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap +++ b/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap @@ -2,103 +2,75 @@ exports[`renders Checkbox with custom testID 1`] = ` - - + - + + + > + + @@ -206,101 +215,74 @@ exports[`renders Checkbox with custom testID 1`] = ` exports[`renders checked Checkbox with color 1`] = ` - - + - + + + > + + @@ -408,126 +426,74 @@ exports[`renders checked Checkbox with color 1`] = ` exports[`renders checked Checkbox with onPress 1`] = ` - - - + + + + > + + @@ -635,126 +662,74 @@ exports[`renders checked Checkbox with onPress 1`] = ` exports[`renders indeterminate Checkbox 1`] = ` - - - + + + + > + + @@ -849,101 +885,74 @@ exports[`renders indeterminate Checkbox 1`] = ` exports[`renders indeterminate Checkbox with color 1`] = ` - - + - + + + > + + @@ -1038,101 +1083,74 @@ exports[`renders indeterminate Checkbox with color 1`] = ` exports[`renders unchecked Checkbox with color 1`] = ` - - + - + + + > + + @@ -1240,126 +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 cf6d93e658..be4f7381cd 100644 --- a/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap +++ b/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap @@ -57,99 +57,72 @@ exports[`can render leading checkbox control 1`] = ` } > - - + - + + + > + + @@ -390,99 +399,72 @@ exports[`renders unchecked 1`] = ` Unchecked Button - - + - + + + > + + From bfb7a1b6be789dd674b720b4852ae4a620e9a203 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 4 Sep 2026 16:44:59 +0200 Subject: [PATCH 5/5] 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 60158ae4a6..044cfab6f0 100644 --- a/src/components/Checkbox/Checkbox.tsx +++ b/src/components/Checkbox/Checkbox.tsx @@ -78,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). @@ -124,6 +131,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, @@ -391,6 +404,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 45f23ee595..0ee021f19c 100644 --- a/src/components/__tests__/Checkbox/Checkbox.test.tsx +++ b/src/components/__tests__/Checkbox/Checkbox.test.tsx @@ -506,6 +506,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(