From 1a774eb0f56575db87a43acc4fe19a774e33b0da Mon Sep 17 00:00:00 2001 From: likevy Date: Mon, 7 Sep 2026 12:24:41 +0200 Subject: [PATCH 01/22] 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 72aee6d00f..d062ed04d7 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -573,3 +573,9 @@ mutually exclusive options, use `SegmentedButtons`. /> ``` + +### 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 1ddbd8799f745dea7669c268ca3f9753cdea17a8 Mon Sep 17 00:00:00 2001 From: likevy Date: Mon, 7 Sep 2026 12:27:01 +0200 Subject: [PATCH 02/22] 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 d062ed04d7..d4c06bf554 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -578,4 +578,4 @@ mutually exclusive options, use `SegmentedButtons`. #### 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 03/22] 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 d4c06bf554..6f338c66b0 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -579,3 +579,7 @@ mutually exclusive options, use `SegmentedButtons`. #### 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 68f22c4b48..a947294932 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, @@ -426,10 +426,10 @@ exports[`renders unchecked 1`] = ` [ { "alignItems": "center", - "borderRadius": 20, - "height": 40, + "borderRadius": 24, + "height": 48, "justifyContent": "center", - "width": 40, + "width": 48, }, undefined, undefined, From 1f0e89c90051c1063734e4bb243264af0a623979 Mon Sep 17 00:00:00 2001 From: likevy Date: Mon, 7 Sep 2026 12:30:52 +0200 Subject: [PATCH 04/22] 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 6f338c66b0..bb981eb793 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -583,3 +583,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 a947294932..d5f219266d 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`] = ` } > - - + - + + + > + + @@ -388,99 +399,74 @@ exports[`renders unchecked 1`] = ` Unchecked Button - - + - + + + > + + From 37ab78a24216ec56049fcbe78227c2c8910c2ae4 Mon Sep 17 00:00:00 2001 From: likevy Date: Mon, 7 Sep 2026 12:32:33 +0200 Subject: [PATCH 05/22] 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 bb981eb793..eaf9b55938 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -586,4 +586,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 d5f219266d..6e0c86b672 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, ], ] } @@ -454,6 +455,7 @@ exports[`renders unchecked 1`] = ` "width": 48, }, undefined, + undefined, ], ] } From d7205a32640f6ff7973e320c1bb8f1059bc02bcb Mon Sep 17 00:00:00 2001 From: likevy Date: Mon, 7 Sep 2026 12:33:33 +0200 Subject: [PATCH 06/22] 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( Date: Tue, 22 Sep 2026 16:10:22 +0200 Subject: [PATCH 07/22] test(checkbox): adapt tests after rebase onto main --- .../__tests__/Checkbox/Checkbox.test.tsx | 8 +- .../__snapshots__/CheckboxItem.test.tsx.snap | 274 +++++++++-------- .../__tests__/Checkbox/utils.test.tsx | 60 +++- .../__snapshots__/DataTable.test.tsx.snap | 277 ++++++++++-------- 4 files changed, 356 insertions(+), 263 deletions(-) diff --git a/src/components/__tests__/Checkbox/Checkbox.test.tsx b/src/components/__tests__/Checkbox/Checkbox.test.tsx index de91708187..69ff56c821 100644 --- a/src/components/__tests__/Checkbox/Checkbox.test.tsx +++ b/src/components/__tests__/Checkbox/Checkbox.test.tsx @@ -4,9 +4,9 @@ import { afterEach, describe, expect, it, jest } from '@jest/globals'; import { getAnimatedStyle } from 'react-native-reanimated'; import { Provider as SettingsProvider } from '../../../core/settings'; -import { defaultThemes } from '../../../core/theming'; import { fireEvent, render, screen } from '../../../test-utils'; import { ReduceMotionContext } from '../../../theme/accessibility/ReduceMotionContext'; +import { LightTheme } from '../../../theme/schemes'; import { tokens } from '../../../theme/tokens'; import Checkbox from '../../Checkbox'; import type { Props as CheckboxProps } from '../../Checkbox/Checkbox'; @@ -72,7 +72,7 @@ it('renders Checkbox with custom testID', async () => { }); describe('Checkbox state layer', () => { - const { colors } = defaultThemes.light; + const { colors } = LightTheme; const { hovered, focused } = tokens.md.sys.state.opacity; const stateLayer = () => screen.getByTestId('checkbox-state-layer'); @@ -192,7 +192,7 @@ describe('Checkbox state layer', () => { }); describe('Checkbox press ripple', () => { - const { colors, motion } = defaultThemes.light; + const { colors, motion } = LightTheme; const { hovered, pressed } = tokens.md.sys.state.opacity; const GROW = motion.duration.short4; const FADE_OUT = motion.duration.short3; @@ -387,7 +387,7 @@ describe('Checkbox press ripple', () => { // 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'); + const underlay = () => screen.getByRole('checkbox').children[0]; it('suppresses the platform press by default', async () => { await renderCheckbox({ testOnly_pressed: true }); diff --git a/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap b/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap index 6e0c86b672..b0722bd625 100644 --- a/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap +++ b/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap @@ -705,81 +705,75 @@ exports[`should have maxFontSizeMultiplier set to 1.5 by default 1`] = ` } /> - + - + + + > + + diff --git a/src/components/__tests__/Checkbox/utils.test.tsx b/src/components/__tests__/Checkbox/utils.test.tsx index 6f8a080a0d..db4f85e796 100644 --- a/src/components/__tests__/Checkbox/utils.test.tsx +++ b/src/components/__tests__/Checkbox/utils.test.tsx @@ -155,12 +155,12 @@ describe('getSelectionVisualState', () => { }); describe('getStateLayer', () => { - const { colors } = theme; + const { colors } = LightTheme; const { hovered, focused, pressed } = tokens.md.sys.state.opacity; it('is fully transparent when idle', () => { expect( - getStateLayer({ theme, selected: false, interaction: null }) + getStateLayer({ theme: LightTheme, selected: false, interaction: null }) ).toEqual({ color: 'transparent', opacity: 0 }); }); @@ -168,7 +168,9 @@ describe('getStateLayer', () => { ['hovered' as const, hovered], ['focused' as const, focused], ])('tints %s with primary when selected', (interaction, opacity) => { - expect(getStateLayer({ theme, selected: true, interaction })).toEqual({ + expect( + getStateLayer({ theme: LightTheme, selected: true, interaction }) + ).toEqual({ color: colors.primary, opacity, }); @@ -178,7 +180,9 @@ describe('getStateLayer', () => { ['hovered' as const, hovered], ['focused' as const, focused], ])('tints %s with onSurface when unselected', (interaction, opacity) => { - expect(getStateLayer({ theme, selected: false, interaction })).toEqual({ + expect( + getStateLayer({ theme: LightTheme, selected: false, interaction }) + ).toEqual({ color: colors.onSurface, opacity, }); @@ -186,13 +190,21 @@ describe('getStateLayer', () => { it('inverts to onSurface when a selected checkbox is pressed', () => { expect( - getStateLayer({ theme, selected: true, interaction: 'pressed' }) + getStateLayer({ + theme: LightTheme, + 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' }) + getStateLayer({ + theme: LightTheme, + selected: false, + interaction: 'pressed', + }) ).toEqual({ color: colors.primary, opacity: pressed }); }); @@ -200,10 +212,20 @@ describe('getStateLayer', () => { 'stays on error for %s regardless of selection', (interaction) => { expect( - getStateLayer({ theme, selected: true, error: true, interaction }) + getStateLayer({ + theme: LightTheme, + selected: true, + error: true, + interaction, + }) ).toEqual({ color: colors.error, opacity: expect.any(Number) }); expect( - getStateLayer({ theme, selected: false, error: true, interaction }) + getStateLayer({ + theme: LightTheme, + selected: false, + error: true, + interaction, + }) ).toEqual({ color: colors.error, opacity: expect.any(Number) }); } ); @@ -219,7 +241,12 @@ describe('getStateLayer', () => { ['focused' as const, focused], ])('uses customColor for %s when selected', (interaction, opacity) => { expect( - getStateLayer({ theme, selected: true, interaction, ...custom }) + getStateLayer({ + theme: LightTheme, + selected: true, + interaction, + ...custom, + }) ).toEqual({ color: 'rebeccapurple', opacity }); }); @@ -230,7 +257,12 @@ describe('getStateLayer', () => { 'uses customUncheckedColor for %s when unselected', (interaction, opacity) => { expect( - getStateLayer({ theme, selected: false, interaction, ...custom }) + getStateLayer({ + theme: LightTheme, + selected: false, + interaction, + ...custom, + }) ).toEqual({ color: 'teal', opacity }); } ); @@ -238,7 +270,7 @@ describe('getStateLayer', () => { it('takes the unchecked color when a selected checkbox is pressed', () => { expect( getStateLayer({ - theme, + theme: LightTheme, selected: true, interaction: 'pressed', ...custom, @@ -249,7 +281,7 @@ describe('getStateLayer', () => { it('takes the checked color when an unselected checkbox is pressed', () => { expect( getStateLayer({ - theme, + theme: LightTheme, selected: false, interaction: 'pressed', ...custom, @@ -260,7 +292,7 @@ describe('getStateLayer', () => { it('overrides error, matching the box', () => { expect( getStateLayer({ - theme, + theme: LightTheme, selected: true, error: true, interaction: 'hovered', @@ -272,7 +304,7 @@ describe('getStateLayer', () => { it('leaves the other side on its token role', () => { expect( getStateLayer({ - theme, + theme: LightTheme, selected: true, interaction: 'hovered', customUncheckedColor: 'teal', diff --git a/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap b/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap index 9084841e9b..9d7b60dac4 100644 --- a/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap @@ -119,84 +119,78 @@ exports[`DataTable.Cell renders data table cell children without wrapping text c testID="table-cell" > - + - + + + > + + From c060f2e3985a90ad09c535b32e668007bc9b9558 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 13:03:57 +0100 Subject: [PATCH 08/22] fix(checkbox): skip the accessible name warning for hidden checkboxes --- src/components/Checkbox/Checkbox.tsx | 9 +++++++-- .../__tests__/Checkbox/Checkbox.test.tsx | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx index 3123905630..59665e471e 100644 --- a/src/components/Checkbox/Checkbox.tsx +++ b/src/components/Checkbox/Checkbox.tsx @@ -432,8 +432,13 @@ 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; + // A hidden checkbox needs no name. `Checkbox.Item` names the row and passes + // `accessible={false}` here. + const isInAccessibilityTree = + rest.accessible !== false && + rest['aria-hidden'] !== true && + rest.importantForAccessibility !== 'no' && + rest.importantForAccessibility !== 'no-hide-descendants'; const hasAccessibleName = Boolean( rest['aria-label'] ?? rest.accessibilityLabel ?? diff --git a/src/components/__tests__/Checkbox/Checkbox.test.tsx b/src/components/__tests__/Checkbox/Checkbox.test.tsx index 69ff56c821..c50e38779d 100644 --- a/src/components/__tests__/Checkbox/Checkbox.test.tsx +++ b/src/components/__tests__/Checkbox/Checkbox.test.tsx @@ -680,6 +680,23 @@ describe('Checkbox accessible name', () => { expect(console.warn).not.toHaveBeenCalled(); }); + + it.each([ + { 'aria-hidden': true }, + { importantForAccessibility: 'no' }, + { importantForAccessibility: 'no-hide-descendants' }, + ] as const)( + 'does not warn for a checkbox hidden from assistive tech with %o', + async (hidden) => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + + await render( + {}} {...hidden} /> + ); + + expect(console.warn).not.toHaveBeenCalled(); + } + ); }); it('renders the focus ring outside the pressable so clipping cannot crop it', async () => { From a5c403ea06cca3fc6685e00ad261b89941222126 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 13:05:12 +0100 Subject: [PATCH 09/22] refactor(checkbox): drop the tapTargetStyle prop A negative margin on `style` already gives a denser layout and keeps the 48dp touch target, so the migration guide shows that instead. --- docs/6.x/docs/guides/migration.md | 12 +++++- src/components/Checkbox/Checkbox.tsx | 12 +----- .../__tests__/Checkbox/Checkbox.test.tsx | 42 +++---------------- .../__snapshots__/Checkbox.test.tsx.snap | 7 ---- .../__snapshots__/CheckboxItem.test.tsx.snap | 3 -- .../__snapshots__/DataTable.test.tsx.snap | 1 - 6 files changed, 18 insertions(+), 59 deletions(-) diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index eaf9b55938..f026ad8ac6 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -586,4 +586,14 @@ 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. Resizing the tap target now lives on the new `tapTargetStyle` prop, which reaches the pressable itself. +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 touch 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. + +To keep a layout as dense as before, give the extra space back with a negative margin instead of shrinking the checkbox. It then takes up 40dp in the layout again, while its touch target stays 48dp: + +```tsx +// Before (v5) + + +// After (v6) + +``` diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx index 59665e471e..b3278aef0f 100644 --- a/src/components/Checkbox/Checkbox.tsx +++ b/src/components/Checkbox/Checkbox.tsx @@ -79,16 +79,10 @@ export type Props = Omit< testID?: string; /** * Custom style for the checkbox's outer container. `width` and `height` here - * do not resize the tap target; `tapTargetStyle` does. + * do not resize the 48dp touch target. For a denser layout, use a negative + * margin, which reclaims layout space and keeps the touch target. */ 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; /** * 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 @@ -159,7 +153,6 @@ const Checkbox = ({ color, uncheckedColor, style, - tapTargetStyle, ...rest }: Props) => { const theme = useInternalTheme(themeOverrides); @@ -501,7 +494,6 @@ 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 c50e38779d..b7aec07011 100644 --- a/src/components/__tests__/Checkbox/Checkbox.test.tsx +++ b/src/components/__tests__/Checkbox/Checkbox.test.tsx @@ -548,52 +548,20 @@ describe('Checkbox touch target', () => { expect(screen.getByRole('checkbox')).not.toHaveStyle({ marginTop: 12 }); }); - it('puts the tap target style on the pressable, over the built-in size', async () => { + it('keeps the 48dp touch target under a negative margin', async () => { await render( {}} testID="checkbox" - tapTargetStyle={{ width: 60, height: 60 }} + style={{ margin: -4 }} /> ); + expect(screen.root).toHaveStyle({ margin: -4 }); 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, + width: 48, + height: 48, }); }); }); diff --git a/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap b/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap index f950d771f7..3f34623321 100644 --- a/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap +++ b/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap @@ -59,7 +59,6 @@ exports[`renders Checkbox with custom testID 1`] = ` "width": 48, }, undefined, - undefined, ], ] } @@ -275,7 +274,6 @@ exports[`renders checked Checkbox with color 1`] = ` "width": 48, }, undefined, - undefined, ], ] } @@ -489,7 +487,6 @@ exports[`renders checked Checkbox with onPress 1`] = ` "width": 48, }, undefined, - undefined, ], ] } @@ -728,7 +725,6 @@ exports[`renders indeterminate Checkbox 1`] = ` "width": 48, }, undefined, - undefined, ], ] } @@ -954,7 +950,6 @@ exports[`renders indeterminate Checkbox with color 1`] = ` "width": 48, }, undefined, - undefined, ], ] } @@ -1155,7 +1150,6 @@ exports[`renders unchecked Checkbox with color 1`] = ` "width": 48, }, undefined, - undefined, ], ] } @@ -1369,7 +1363,6 @@ 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 b0722bd625..5a7b140edb 100644 --- a/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap +++ b/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap @@ -112,7 +112,6 @@ exports[`can render leading checkbox control 1`] = ` "width": 48, }, undefined, - undefined, ], ] } @@ -455,7 +454,6 @@ exports[`renders unchecked 1`] = ` "width": 48, }, undefined, - undefined, ], ] } @@ -760,7 +758,6 @@ exports[`should have maxFontSizeMultiplier set to 1.5 by default 1`] = ` "width": 48, }, undefined, - undefined, ], ] } diff --git a/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap b/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap index 9d7b60dac4..1951b49d33 100644 --- a/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap @@ -176,7 +176,6 @@ exports[`DataTable.Cell renders data table cell children without wrapping text c "width": 48, }, undefined, - undefined, ], ] } From 8fb45fe7f48f284badcb6fe19b11c891f18bf593 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 13:06:59 +0100 Subject: [PATCH 10/22] refactor(checkbox): remove internal test IDs 6.0 stopped deriving internal test IDs from `testID`, so the state layer, ripple and focus ring no longer get one. The tests now cover public behavior only: role and state, press callbacks, disabled handling and the accessible name warning. They also name every checkbox, so the warning only prints where a test asserts it. --- src/components/Checkbox/Checkbox.tsx | 3 - .../__tests__/Checkbox/Checkbox.test.tsx | 596 +++--------------- .../__snapshots__/Checkbox.test.tsx.snap | 8 +- src/components/__tests__/DataTable.test.tsx | 6 +- .../__snapshots__/DataTable.test.tsx.snap | 2 +- 5 files changed, 85 insertions(+), 530 deletions(-) diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx index b3278aef0f..571843255b 100644 --- a/src/components/Checkbox/Checkbox.tsx +++ b/src/components/Checkbox/Checkbox.tsx @@ -468,7 +468,6 @@ const Checkbox = ({ focused && !disabled ? ( ) : null; @@ -499,13 +498,11 @@ const Checkbox = ({ {ownsPress ? ( { jest.restoreAllMocks(); @@ -17,7 +11,9 @@ afterEach(() => { it('renders checked Checkbox with onPress', async () => { const tree = ( - await render( {}} />) + await render( + {}} aria-label="Notify me" /> + ) ).toJSON(); expect(tree).toMatchSnapshot(); @@ -25,7 +21,9 @@ it('renders checked Checkbox with onPress', async () => { it('renders unchecked Checkbox with onPress', async () => { const tree = ( - await render( {}} />) + await render( + {}} aria-label="Notify me" /> + ) ).toJSON(); expect(tree).toMatchSnapshot(); @@ -33,7 +31,13 @@ it('renders unchecked Checkbox with onPress', async () => { it('renders indeterminate Checkbox', async () => { const tree = ( - await render( {}} />) + await render( + {}} + aria-label="Notify me" + /> + ) ).toJSON(); expect(tree).toMatchSnapshot(); @@ -41,7 +45,9 @@ it('renders indeterminate Checkbox', async () => { it('renders checked Checkbox with color', async () => { const tree = ( - await render() + await render( + + ) ).toJSON(); expect(tree).toMatchSnapshot(); @@ -49,7 +55,9 @@ it('renders checked Checkbox with color', async () => { it('renders unchecked Checkbox with color', async () => { const tree = ( - await render() + await render( + + ) ).toJSON(); expect(tree).toMatchSnapshot(); @@ -57,7 +65,9 @@ it('renders unchecked Checkbox with color', async () => { it('renders indeterminate Checkbox with color', async () => { const tree = ( - await render() + await render( + + ) ).toJSON(); expect(tree).toMatchSnapshot(); @@ -65,471 +75,83 @@ it('renders indeterminate Checkbox with color', async () => { it('renders Checkbox with custom testID', async () => { const tree = ( - await render() + await render( + + ) ).toJSON(); expect(tree).toMatchSnapshot(); }); -describe('Checkbox state layer', () => { - const { colors } = LightTheme; - 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'); +describe('Checkbox state', () => { + it.each([ + { status: 'checked', checked: true }, + { status: 'unchecked', checked: false }, + { status: 'indeterminate', checked: 'mixed' }, + ] as const)( + 'reports $status as checked: $checked', + async ({ status, checked }) => { + await render( + {}} aria-label="Notify me" /> + ); - expect(stateLayer()).toHaveStyle({ - backgroundColor: onSurface, - opacity: hovered, - }); - }); + expect( + screen.getByRole('checkbox', { name: 'Notify me', checked }) + ).toBeOnTheScreen(); + } + ); }); -describe('Checkbox press ripple', () => { - const { colors, motion } = LightTheme; - 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 () => { +describe('Checkbox press', () => { + it('calls onPress when pressed', async () => { + const onPress = jest.fn<(e: GestureResponderEvent) => void>(); 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); + await userEvent.press(screen.getByRole('checkbox')); - expect(ripple()).toMatchObject({ backgroundColor: primary }); + expect(onPress).toHaveBeenCalledTimes(1); }); - // 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.getByRole('checkbox').children[0]; - - 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 () => { + it('is disabled and ignores presses when disabled', async () => { + const onPress = jest.fn<(e: GestureResponderEvent) => void>(); 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(); + await userEvent.press(screen.getByRole('checkbox')); expect(screen.getByRole('checkbox')).toBeDisabled(); + expect(onPress).not.toHaveBeenCalled(); }); - it('does not paint a state layer on hover', async () => { - await render(); - - await fireEvent(screen.getByRole('checkbox'), 'hoverIn'); + it('is reported as disabled rather than an enabled no-op without a press handler', async () => { + await render(); - expect(screen.getByTestId('checkbox-state-layer')).toHaveStyle({ - opacity: 0, - }); + expect(screen.getByRole('checkbox')).toBeDisabled(); }); }); + describe('Checkbox touch target', () => { - it('meets the 48dp minimum without resizing the state layer', async () => { + it('meets the 48dp minimum', async () => { await render( - {}} testID="checkbox" /> + {}} aria-label="Notify me" /> ); expect(screen.getByRole('checkbox')).toHaveStyle({ width: 48, height: 48, }); - expect(screen.getByTestId('checkbox-state-layer')).toHaveStyle({ - width: 40, - height: 40, - }); }); it('takes the custom style on the outer container, not the pressable', async () => { @@ -537,13 +159,13 @@ describe('Checkbox touch target', () => { {}} - testID="checkbox" + aria-label="Notify me" style={{ marginTop: 12 }} /> ); // On the pressable the style would miss the focus ring beside it, and - // could shrink the tap target back below 48dp. + // could shrink the touch target back below 48dp. expect(screen.root).toHaveStyle({ marginTop: 12 }); expect(screen.getByRole('checkbox')).not.toHaveStyle({ marginTop: 12 }); }); @@ -553,7 +175,7 @@ describe('Checkbox touch target', () => { {}} - testID="checkbox" + aria-label="Notify me" style={{ margin: -4 }} /> ); @@ -565,59 +187,6 @@ 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, - }); - }); -}); describe('Checkbox accessible name', () => { it('warns when a standalone checkbox has no accessible name', async () => { @@ -666,24 +235,3 @@ describe('Checkbox accessible name', () => { } ); }); - -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 3f34623321..27e778a606 100644 --- a/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap +++ b/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap @@ -13,6 +13,7 @@ exports[`renders Checkbox with custom testID 1`] = ` } > { const tree = ( await render( - + ) ).toJSON(); diff --git a/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap b/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap index 1951b49d33..607459c0a9 100644 --- a/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap @@ -130,6 +130,7 @@ exports[`DataTable.Cell renders data table cell children without wrapping text c } > Date: Fri, 30 Oct 2026 13:08:20 +0100 Subject: [PATCH 11/22] chore(example): name the example checkboxes A standalone checkbox now warns without an accessible name, so the Checkbox and List examples label theirs. The List rows are not pressable, so the checkbox is the only element that carries the checked state and is named after the item it belongs to rather than hidden. --- example/src/Examples/CheckboxExample.tsx | 40 +++++++++++++++++++----- example/src/Examples/ListItemExample.tsx | 12 +++---- 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/example/src/Examples/CheckboxExample.tsx b/example/src/Examples/CheckboxExample.tsx index 6722350704..b2f9e53bab 100644 --- a/example/src/Examples/CheckboxExample.tsx +++ b/example/src/Examples/CheckboxExample.tsx @@ -11,29 +11,43 @@ const STATUSES: Status[] = ['unchecked', 'checked', 'indeterminate']; type Row = { label: string; - render: (status: Status) => React.ReactNode; + render: (status: Status, label: string) => React.ReactNode; }; const ROWS: Row[] = [ { label: 'Default', - render: (status) => {}} />, + render: (status, label) => ( + {}} aria-label={label} /> + ), }, { label: 'Disabled', - render: (status) => , + render: (status, label) => ( + + ), }, { label: 'Custom color (tertiary)', - render: (status) => , + render: (status, label) => ( + + ), }, { label: 'Error', - render: (status) => {}} />, + render: (status, label) => ( + {}} aria-label={label} /> + ), }, ]; -const CustomColorCheckbox = ({ status }: { status: Status }) => { +const CustomColorCheckbox = ({ + status, + label, +}: { + status: Status; + label: string; +}) => { const theme = useTheme(); return ( { color={theme.colors.tertiary} uncheckedColor={theme.colors.tertiary} onPress={() => {}} + aria-label={label} /> ); }; @@ -59,6 +74,7 @@ const Interactive = () => { setAChecked((v) => !v)} + aria-label="Unchecked or checked" /> { setBIndeterminate((v) => !v)} + aria-label="Unchecked or indeterminate" /> { Parent / children (indeterminate) - + @@ -125,6 +146,7 @@ const IndeterminateParent = () => { setChild1((v) => !v)} + aria-label="Apples" /> { setChild2((v) => !v)} + aria-label="Bananas" /> { setChild3((v) => !v)} + aria-label="Cherries" /> { {STATUSES.map((status) => ( - {row.render(status)} + {row.render(status, row.label)} ))} diff --git a/example/src/Examples/ListItemExample.tsx b/example/src/Examples/ListItemExample.tsx index 4d5a69b400..f91eb04081 100644 --- a/example/src/Examples/ListItemExample.tsx +++ b/example/src/Examples/ListItemExample.tsx @@ -6,7 +6,7 @@ import ScreenWrapper from '../ScreenWrapper'; const CenteredCheckbox = () => ( - + ); @@ -30,7 +30,7 @@ const ListItemExample = () => { } + right={() => } /> @@ -66,7 +66,7 @@ const ListItemExample = () => { title="Headline" description="Supporting text that is long enough to fill up multiple lines in the item" left={(props) => } - right={() => } + right={() => } /> @@ -114,7 +114,7 @@ const ListItemExample = () => { left={(props) => ( )} - right={() => } + right={() => } /> @@ -180,7 +180,7 @@ const ListItemExample = () => { source={require('../../../example/assets/images/strawberries.jpg')} /> )} - right={() => } + right={() => } /> @@ -252,7 +252,7 @@ const ListItemExample = () => { source={require('../../../example/assets/images/strawberries.jpg')} /> )} - right={() => } + right={() => } /> From d9ba88de9a388311159074623372fa20524929eb Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 13:12:27 +0100 Subject: [PATCH 12/22] fix(checkbox): toggle on space key on web React Native Web presses on Space only for button roles, so Space on a focused checkbox scrolled the page instead of toggling it. Space now toggles a checkbox and a Checkbox.Item row on release, as a native checkbox does. Native platforms and Enter, which React Native Web already handles, are unchanged. --- src/components/Checkbox/Checkbox.tsx | 19 ++- src/components/Checkbox/CheckboxItem.tsx | 7 ++ .../__tests__/Checkbox/Checkbox.test.tsx | 114 +++++++++++++++++- .../__tests__/Checkbox/CheckboxItem.test.tsx | 52 +++++++- src/utils/useSpaceKeyPress.ts | 78 ++++++++++++ 5 files changed, 263 insertions(+), 7 deletions(-) create mode 100644 src/utils/useSpaceKeyPress.ts diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx index 571843255b..998f552990 100644 --- a/src/components/Checkbox/Checkbox.tsx +++ b/src/components/Checkbox/Checkbox.tsx @@ -33,6 +33,7 @@ import { tokens } from '../../theme/tokens'; import type { ThemeProp } from '../../theme/types'; import hasTouchHandler from '../../utils/hasTouchHandler'; import { isKeyboardFocusEvent } from '../../utils/isKeyboardFocusEvent'; +import { useSpaceKeyPress } from '../../utils/useSpaceKeyPress'; import TouchableRipple from '../TouchableRipple/TouchableRipple'; import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple'; @@ -379,6 +380,15 @@ const Checkbox = ({ } const showIndeterminate = nextGlyph === 'indeterminate'; + const { keyHandlers: spaceKeyHandlers, cancel: cancelSpaceKeyPress } = + useSpaceKeyPress({ + onPress: isInteractive ? onPress : undefined, + onPressIn: startPressRipple, + onPressOut: () => { + pressedSV.value = 0; + }, + }); + const handleFocus = React.useCallback( (e: NativeSyntheticEvent) => { if (disabled) return; @@ -390,7 +400,8 @@ const Checkbox = ({ const handleBlur = React.useCallback(() => { setFocused(false); - }, []); + cancelSpaceKeyPress(); + }, [cancelSpaceKeyPress]); const interactionHandlers = isInteractive ? { @@ -474,8 +485,10 @@ const Checkbox = ({ return ( // The ring is a sibling of the pressable, not a child: a foreground ripple - // forces `overflow: hidden` on it regardless of `borderless`. - + // forces `overflow: hidden` on it regardless of `borderless`. Space is + // handled here as its key events bubble up from the pressable, which + // leaves a caller's own key handlers on the pressable in place. + { @@ -142,6 +142,116 @@ describe('Checkbox press', () => { }); }); +describe('Checkbox Space key', () => { + const key = (name: string, repeat = false) => ({ + nativeEvent: { key: name, repeat }, + preventDefault: jest.fn(), + }); + + const renderCheckbox = ( + onPress: (e: GestureResponderEvent) => void, + disabled = false + ) => + render( + + ); + + it('toggles once on release on web, without scrolling the page', async () => { + jest.replaceProperty(Platform, 'OS', 'web'); + const onPress = jest.fn<(e: GestureResponderEvent) => void>(); + await renderCheckbox(onPress); + const down = key(' '); + const held = key(' ', true); + + await fireEvent(screen.getByRole('checkbox'), 'keyDown', down); + await fireEvent(screen.getByRole('checkbox'), 'keyDown', held); + + expect(down.preventDefault).toHaveBeenCalled(); + expect(held.preventDefault).toHaveBeenCalled(); + expect(onPress).not.toHaveBeenCalled(); + + await fireEvent(screen.getByRole('checkbox'), 'keyUp', key(' ')); + await fireEvent(screen.getByRole('checkbox'), 'keyUp', key(' ')); + + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('leaves Enter to React Native Web on web', async () => { + jest.replaceProperty(Platform, 'OS', 'web'); + const onPress = jest.fn<(e: GestureResponderEvent) => void>(); + await renderCheckbox(onPress); + const enter = key('Enter'); + + await fireEvent(screen.getByRole('checkbox'), 'keyDown', enter); + await fireEvent(screen.getByRole('checkbox'), 'keyUp', enter); + + expect(enter.preventDefault).not.toHaveBeenCalled(); + expect(onPress).not.toHaveBeenCalled(); + }); + + it('does not toggle when focus leaves before the release', async () => { + jest.replaceProperty(Platform, 'OS', 'web'); + const onPress = jest.fn<(e: GestureResponderEvent) => void>(); + await renderCheckbox(onPress); + + await fireEvent(screen.getByRole('checkbox'), 'keyDown', key(' ')); + await fireEvent(screen.getByRole('checkbox'), 'blur'); + await fireEvent(screen.getByRole('checkbox'), 'keyUp', key(' ')); + + expect(onPress).not.toHaveBeenCalled(); + }); + + it('does not toggle once the checkbox becomes disabled', async () => { + jest.replaceProperty(Platform, 'OS', 'web'); + const onPress = jest.fn<(e: GestureResponderEvent) => void>(); + await renderCheckbox(onPress); + + await fireEvent(screen.getByRole('checkbox'), 'keyDown', key(' ')); + await screen.rerender( + + ); + await fireEvent(screen.getByRole('checkbox'), 'keyUp', key(' ')); + await fireEvent(screen.getByRole('checkbox'), 'keyDown', key(' ')); + await fireEvent(screen.getByRole('checkbox'), 'keyUp', key(' ')); + + expect(onPress).not.toHaveBeenCalled(); + + // The press started before it was disabled must not survive it either. + await screen.rerender( + + ); + await fireEvent(screen.getByRole('checkbox'), 'keyUp', key(' ')); + + expect(onPress).not.toHaveBeenCalled(); + }); + + it.each(['ios', 'android'] as const)( + 'leaves the key alone on %s', + async (os) => { + jest.replaceProperty(Platform, 'OS', os); + const onPress = jest.fn<(e: GestureResponderEvent) => void>(); + await renderCheckbox(onPress); + const space = key(' '); + + await fireEvent(screen.getByRole('checkbox'), 'keyDown', space); + await fireEvent(screen.getByRole('checkbox'), 'keyUp', space); + + expect(space.preventDefault).not.toHaveBeenCalled(); + expect(onPress).not.toHaveBeenCalled(); + } + ); +}); + describe('Checkbox touch target', () => { it('meets the 48dp minimum', async () => { await render( diff --git a/src/components/__tests__/Checkbox/CheckboxItem.test.tsx b/src/components/__tests__/Checkbox/CheckboxItem.test.tsx index ee52c1b3e4..d4d491cb22 100644 --- a/src/components/__tests__/Checkbox/CheckboxItem.test.tsx +++ b/src/components/__tests__/Checkbox/CheckboxItem.test.tsx @@ -1,9 +1,15 @@ -import { expect, it, jest } from '@jest/globals'; +import { type GestureResponderEvent, Platform } from 'react-native'; + +import { afterEach, expect, it, jest } from '@jest/globals'; import { userEvent } from '@testing-library/react-native'; -import { render, screen } from '../../../test-utils'; +import { fireEvent, render, screen } from '../../../test-utils'; import Checkbox from '../../Checkbox'; +afterEach(() => { + jest.restoreAllMocks(); +}); + it('renders unchecked', async () => { const tree = ( await render() @@ -84,3 +90,45 @@ it('should execute onLongPress', async () => { expect(onLongPress).toHaveBeenCalledTimes(1); }); + +it('toggles the row once on Space release on web', async () => { + jest.replaceProperty(Platform, 'OS', 'web'); + const onPress = jest.fn<(e: GestureResponderEvent) => void>(); + const preventDefault = jest.fn(); + await render( + + ); + const row = screen.getByRole('checkbox', { name: 'Notify me' }); + + await fireEvent(row, 'keyDown', { + nativeEvent: { key: ' ' }, + preventDefault, + }); + + expect(preventDefault).toHaveBeenCalled(); + expect(onPress).not.toHaveBeenCalled(); + + await fireEvent(row, 'keyUp', { nativeEvent: { key: ' ' }, preventDefault }); + + expect(onPress).toHaveBeenCalledTimes(1); +}); + +it('does not toggle a disabled row on Space', async () => { + jest.replaceProperty(Platform, 'OS', 'web'); + const onPress = jest.fn<(e: GestureResponderEvent) => void>(); + await render( + + ); + const row = screen.getByRole('checkbox', { name: 'Notify me' }); + const space = { nativeEvent: { key: ' ' }, preventDefault: jest.fn() }; + + await fireEvent(row, 'keyDown', space); + await fireEvent(row, 'keyUp', space); + + expect(onPress).not.toHaveBeenCalled(); +}); diff --git a/src/utils/useSpaceKeyPress.ts b/src/utils/useSpaceKeyPress.ts new file mode 100644 index 0000000000..1baaa6bf1e --- /dev/null +++ b/src/utils/useSpaceKeyPress.ts @@ -0,0 +1,78 @@ +import * as React from 'react'; +import { type GestureResponderEvent, Platform } from 'react-native'; + +import useLatestCallback from 'use-latest-callback'; + +type KeyboardEvent = React.KeyboardEvent; + +type Options = { + /** + * Called when Space is released. Pass `undefined` while the element cannot + * be pressed. + */ + onPress: ((e: GestureResponderEvent) => void) | undefined; + onPressIn?: () => void; + onPressOut?: () => void; +}; + +const isSpaceKey = ({ nativeEvent: { key } }: KeyboardEvent) => + key === ' ' || key === 'Spacebar'; + +/** + * React Native Web presses on Space only for `role="button"`, so on a focused + * element with another role, such as `checkbox`, Space scrolls the page + * instead. The returned key handlers keep the page in place and press once + * Space is released, as a native checkbox does. Enter needs nothing here: + * React Native Web presses on it for every role. Native platforms get no + * handlers. + */ +export const useSpaceKeyPress = ({ + onPress, + onPressIn, + onPressOut, +}: Options) => { + const pressedRef = React.useRef(false); + const enabled = Platform.OS === 'web' && onPress !== undefined; + + /** Drops a pending Space press, e.g. when focus leaves before the release. */ + const cancel = useLatestCallback(() => { + if (!pressedRef.current) return; + + pressedRef.current = false; + onPressOut?.(); + }); + + // Without `onPress` the release is never handled, so a press pending at + // that moment would otherwise fire on a later, unrelated release. + React.useEffect(() => { + if (!enabled) cancel(); + }, [enabled, cancel]); + + const keyHandlers = enabled + ? { + onKeyDown: (event: KeyboardEvent) => { + if (!isSpaceKey(event)) return; + + event.preventDefault(); + if (event.nativeEvent.repeat) return; + + pressedRef.current = true; + onPressIn?.(); + }, + onKeyUp: (event: KeyboardEvent) => { + if (!isSpaceKey(event)) return; + + event.preventDefault(); + if (!pressedRef.current) return; + + pressedRef.current = false; + onPressOut?.(); + // React Native Web hands the key event to `onPress` for Enter too. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + onPress(event as unknown as GestureResponderEvent); + }, + } + : undefined; + + return { keyHandlers, cancel }; +}; From 64590ecab39eb4f655934346b3d6c6e6d0a83d7d Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 14:14:06 +0100 Subject: [PATCH 13/22] fix(checkbox): size the outer container to the touch target The container had no size of its own, so a parent's default `alignItems: 'stretch'` widened it in a column, which centered the checkbox, and made it as tall as a row. A definite 48dp size keeps the checkbox where it is laid out. A consumer `style` still overrides it. --- src/components/Checkbox/Checkbox.tsx | 4 ++++ .../__tests__/Checkbox/Checkbox.test.tsx | 10 ++++++++++ .../Checkbox/__snapshots__/Checkbox.test.tsx.snap | 14 ++++++++++++++ .../__snapshots__/CheckboxItem.test.tsx.snap | 6 ++++++ .../__snapshots__/DataTable.test.tsx.snap | 2 ++ 5 files changed, 36 insertions(+) diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx index 998f552990..f7cda24a89 100644 --- a/src/components/Checkbox/Checkbox.tsx +++ b/src/components/Checkbox/Checkbox.tsx @@ -577,7 +577,11 @@ const Checkbox = ({ const webNoOutline = { outline: 'none' } as unknown as ViewStyle; const styles = StyleSheet.create({ + // A definite size keeps a stretching parent from widening the container and + // moving the checkbox away from where it is laid out. root: { + width: TOUCH_TARGET_SIZE, + height: TOUCH_TARGET_SIZE, alignItems: 'center', justifyContent: 'center', }, diff --git a/src/components/__tests__/Checkbox/Checkbox.test.tsx b/src/components/__tests__/Checkbox/Checkbox.test.tsx index 5495f0706d..454de881bb 100644 --- a/src/components/__tests__/Checkbox/Checkbox.test.tsx +++ b/src/components/__tests__/Checkbox/Checkbox.test.tsx @@ -280,6 +280,16 @@ describe('Checkbox touch target', () => { expect(screen.getByRole('checkbox')).not.toHaveStyle({ marginTop: 12 }); }); + it('sizes its outer container to the touch target', async () => { + await render( + {}} aria-label="Notify me" /> + ); + + // Without a definite size, a column's default `alignItems: 'stretch'` + // widens the container and centers the checkbox in the column. + expect(screen.root).toHaveStyle({ width: 48, height: 48 }); + }); + it('keeps the 48dp touch target under a negative margin', async () => { await render( Date: Fri, 30 Oct 2026 14:15:14 +0100 Subject: [PATCH 14/22] fix(checkbox): judge the accessible name by what the platform supports `importantForAccessibility` hides a view only on Android and `accessibilityElementsHidden` only on iOS, and iOS does not name an element after its `aria-labelledby` target. The warning now counts each of these only on the platform that honors it, so an iOS checkbox that stays exposed or unnamed still warns. --- src/components/Checkbox/Checkbox.tsx | 26 +++++--- .../__tests__/Checkbox/Checkbox.test.tsx | 61 +++++++++++++++++-- 2 files changed, 72 insertions(+), 15 deletions(-) diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx index f7cda24a89..4bcc47b80a 100644 --- a/src/components/Checkbox/Checkbox.tsx +++ b/src/components/Checkbox/Checkbox.tsx @@ -437,26 +437,32 @@ const Checkbox = ({ }, [isInteractive, pressedSV, rippleHoldSV, rippleAlpha, rippleScale]); // A hidden checkbox needs no name. `Checkbox.Item` names the row and passes - // `accessible={false}` here. - const isInAccessibilityTree = - rest.accessible !== false && - rest['aria-hidden'] !== true && - rest.importantForAccessibility !== 'no' && - rest.importantForAccessibility !== 'no-hide-descendants'; + // `accessible={false}` here. Only count props the platform acts on: + // `importantForAccessibility` hides on Android alone, + // `accessibilityElementsHidden` on iOS alone, and iOS does not name an + // element after the one `aria-labelledby` points to. + const isHidden = + rest.accessible === false || + rest['aria-hidden'] === true || + (Platform.OS === 'ios' && rest.accessibilityElementsHidden === true) || + (Platform.OS === 'android' && + (rest.importantForAccessibility === 'no' || + rest.importantForAccessibility === 'no-hide-descendants')); const hasAccessibleName = Boolean( rest['aria-label'] ?? rest.accessibilityLabel ?? - rest['aria-labelledby'] ?? - rest.accessibilityLabelledBy + (Platform.OS !== 'ios' + ? (rest['aria-labelledby'] ?? rest.accessibilityLabelledBy) + : undefined) ); React.useEffect(() => { - if (!isInAccessibilityTree || hasAccessibleName) return; + if (isHidden || 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]); + }, [isHidden, 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 454de881bb..78c4c17338 100644 --- a/src/components/__tests__/Checkbox/Checkbox.test.tsx +++ b/src/components/__tests__/Checkbox/Checkbox.test.tsx @@ -339,12 +339,19 @@ describe('Checkbox accessible name', () => { }); it.each([ - { 'aria-hidden': true }, - { importantForAccessibility: 'no' }, - { importantForAccessibility: 'no-hide-descendants' }, + { os: 'ios', hidden: { 'aria-hidden': true } }, + { os: 'android', hidden: { 'aria-hidden': true } }, + { os: 'web', hidden: { 'aria-hidden': true } }, + { os: 'ios', hidden: { accessibilityElementsHidden: true } }, + { os: 'android', hidden: { importantForAccessibility: 'no' } }, + { + os: 'android', + hidden: { importantForAccessibility: 'no-hide-descendants' }, + }, ] as const)( - 'does not warn for a checkbox hidden from assistive tech with %o', - async (hidden) => { + 'does not warn on $os for a checkbox hidden with $hidden', + async ({ os, hidden }) => { + jest.replaceProperty(Platform, 'OS', os); jest.spyOn(console, 'warn').mockImplementation(() => {}); await render( @@ -354,4 +361,48 @@ describe('Checkbox accessible name', () => { expect(console.warn).not.toHaveBeenCalled(); } ); + + it('still warns on iOS when only importantForAccessibility hides it', async () => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + + // iOS ignores `importantForAccessibility`, so the checkbox stays exposed. + await render( + {}} + importantForAccessibility="no-hide-descendants" + /> + ); + + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('aria-label') + ); + }); + + it('warns on iOS when only aria-labelledby names it', async () => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + + // iOS does not name an element after the one it is labelled by. + await render( + {}} aria-labelledby="label" /> + ); + + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('aria-label') + ); + }); + + it.each(['android', 'web'] as const)( + 'is named by aria-labelledby on %s', + async (os) => { + jest.replaceProperty(Platform, 'OS', os); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + + await render( + {}} aria-labelledby="label" /> + ); + + expect(console.warn).not.toHaveBeenCalled(); + } + ); }); From ec21e7451e33634e3a84a79721ae6f00623ac493 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 14:17:10 +0100 Subject: [PATCH 15/22] fix(checkbox): press in and out on space like enter On web, Enter runs press in on key down and press out before the press on release, so it shows the ripple and calls the caller's `onPressIn` and `onPressOut`. Space now does the same, and a blur before the release presses out without pressing. Checkbox.Item rows still only toggle on Space, since the row ripple runs inside TouchableRipple's own press handlers. --- src/components/Checkbox/Checkbox.tsx | 39 ++++++------ src/components/Checkbox/CheckboxItem.tsx | 4 +- .../__tests__/Checkbox/Checkbox.test.tsx | 56 ++++++++++++++---- src/utils/useSpaceKeyPress.ts | 59 +++++++++++-------- 4 files changed, 104 insertions(+), 54 deletions(-) diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx index 4bcc47b80a..6d05296377 100644 --- a/src/components/Checkbox/Checkbox.tsx +++ b/src/components/Checkbox/Checkbox.tsx @@ -380,13 +380,21 @@ const Checkbox = ({ } const showIndeterminate = nextGlyph === 'indeterminate'; - const { keyHandlers: spaceKeyHandlers, cancel: cancelSpaceKeyPress } = + const handlePressIn = (e: GestureResponderEvent) => { + startPressRipple(); + rest.onPressIn?.(e); + }; + + const handlePressOut = (e: GestureResponderEvent) => { + pressedSV.value = 0; + rest.onPressOut?.(e); + }; + + const { keyHandlers: spaceKeyHandlers, onBlur: handleSpaceKeyBlur } = useSpaceKeyPress({ onPress: isInteractive ? onPress : undefined, - onPressIn: startPressRipple, - onPressOut: () => { - pressedSV.value = 0; - }, + onPressIn: handlePressIn, + onPressOut: handlePressOut, }); const handleFocus = React.useCallback( @@ -398,10 +406,13 @@ const Checkbox = ({ [disabled] ); - const handleBlur = React.useCallback(() => { - setFocused(false); - cancelSpaceKeyPress(); - }, [cancelSpaceKeyPress]); + const handleBlur = React.useCallback( + (e: NativeSyntheticEvent) => { + setFocused(false); + handleSpaceKeyBlur(e); + }, + [handleSpaceKeyBlur] + ); const interactionHandlers = isInteractive ? { @@ -413,14 +424,8 @@ const Checkbox = ({ setHovered(false); rest.onHoverOut?.(e); }, - onPressIn: (e: GestureResponderEvent) => { - startPressRipple(); - rest.onPressIn?.(e); - }, - onPressOut: (e: GestureResponderEvent) => { - pressedSV.value = 0; - rest.onPressOut?.(e); - }, + onPressIn: handlePressIn, + onPressOut: handlePressOut, } : null; diff --git a/src/components/Checkbox/CheckboxItem.tsx b/src/components/Checkbox/CheckboxItem.tsx index d3f42442b2..b720ce179b 100644 --- a/src/components/Checkbox/CheckboxItem.tsx +++ b/src/components/Checkbox/CheckboxItem.tsx @@ -149,7 +149,7 @@ const CheckboxItem = ({ const textAlign = isLeading ? 'right' : 'left'; // The row is the focusable checkbox, so it takes the Space key itself. - const { keyHandlers: spaceKeyHandlers, cancel: cancelSpaceKeyPress } = + const { keyHandlers: spaceKeyHandlers, onBlur: handleSpaceKeyBlur } = useSpaceKeyPress({ onPress: disabled ? undefined : onPress }); const computedStyle: TextStyle = { @@ -165,7 +165,7 @@ const CheckboxItem = ({ aria-disabled={disabled} onPress={onPress} onLongPress={onLongPress} - onBlur={cancelSpaceKeyPress} + onBlur={handleSpaceKeyBlur} {...spaceKeyHandlers} testID={testID} disabled={disabled} diff --git a/src/components/__tests__/Checkbox/Checkbox.test.tsx b/src/components/__tests__/Checkbox/Checkbox.test.tsx index 78c4c17338..249bb381b2 100644 --- a/src/components/__tests__/Checkbox/Checkbox.test.tsx +++ b/src/components/__tests__/Checkbox/Checkbox.test.tsx @@ -148,17 +148,9 @@ describe('Checkbox Space key', () => { preventDefault: jest.fn(), }); - const renderCheckbox = ( - onPress: (e: GestureResponderEvent) => void, - disabled = false - ) => + const renderCheckbox = (onPress: (e: GestureResponderEvent) => void) => render( - + ); it('toggles once on release on web, without scrolling the page', async () => { @@ -181,6 +173,34 @@ describe('Checkbox Space key', () => { expect(onPress).toHaveBeenCalledTimes(1); }); + it('presses in on key down and presses out before toggling, like Enter', async () => { + jest.replaceProperty(Platform, 'OS', 'web'); + const calls: string[] = []; + await render( + { + calls.push('pressIn'); + }} + onPressOut={() => { + calls.push('pressOut'); + }} + onPress={() => { + calls.push('press'); + }} + aria-label="Notify me" + /> + ); + + await fireEvent(screen.getByRole('checkbox'), 'keyDown', key(' ')); + + expect(calls).toEqual(['pressIn']); + + await fireEvent(screen.getByRole('checkbox'), 'keyUp', key(' ')); + + expect(calls).toEqual(['pressIn', 'pressOut', 'press']); + }); + it('leaves Enter to React Native Web on web', async () => { jest.replaceProperty(Platform, 'OS', 'web'); const onPress = jest.fn<(e: GestureResponderEvent) => void>(); @@ -194,15 +214,27 @@ describe('Checkbox Space key', () => { expect(onPress).not.toHaveBeenCalled(); }); - it('does not toggle when focus leaves before the release', async () => { + it('presses out without toggling when focus leaves before the release', async () => { jest.replaceProperty(Platform, 'OS', 'web'); const onPress = jest.fn<(e: GestureResponderEvent) => void>(); - await renderCheckbox(onPress); + const onPressOut = jest.fn<(e: GestureResponderEvent) => void>(); + await render( + + ); await fireEvent(screen.getByRole('checkbox'), 'keyDown', key(' ')); await fireEvent(screen.getByRole('checkbox'), 'blur'); + + expect(onPressOut).toHaveBeenCalledTimes(1); + await fireEvent(screen.getByRole('checkbox'), 'keyUp', key(' ')); + expect(onPressOut).toHaveBeenCalledTimes(1); expect(onPress).not.toHaveBeenCalled(); }); diff --git a/src/utils/useSpaceKeyPress.ts b/src/utils/useSpaceKeyPress.ts index 1baaa6bf1e..a0e0e98dc9 100644 --- a/src/utils/useSpaceKeyPress.ts +++ b/src/utils/useSpaceKeyPress.ts @@ -1,5 +1,10 @@ import * as React from 'react'; -import { type GestureResponderEvent, Platform } from 'react-native'; +import { + type GestureResponderEvent, + type NativeSyntheticEvent, + Platform, + type TargetedEvent, +} from 'react-native'; import useLatestCallback from 'use-latest-callback'; @@ -11,20 +16,28 @@ type Options = { * be pressed. */ onPress: ((e: GestureResponderEvent) => void) | undefined; - onPressIn?: () => void; - onPressOut?: () => void; + onPressIn?: (e: GestureResponderEvent) => void; + onPressOut?: (e: GestureResponderEvent) => void; }; const isSpaceKey = ({ nativeEvent: { key } }: KeyboardEvent) => key === ' ' || key === 'Spacebar'; +// React Native Web hands Enter's key events to the press callbacks as they +// are, so Space passes its own events the same way. +const asPressEvent = ( + event: KeyboardEvent | NativeSyntheticEvent +) => + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + event as unknown as GestureResponderEvent; + /** * React Native Web presses on Space only for `role="button"`, so on a focused * element with another role, such as `checkbox`, Space scrolls the page - * instead. The returned key handlers keep the page in place and press once - * Space is released, as a native checkbox does. Enter needs nothing here: - * React Native Web presses on it for every role. Native platforms get no - * handlers. + * instead. The returned key handlers keep the page in place and press the way + * Enter does: press in on key down, then press out and press on release. + * Enter needs nothing here, since React Native Web handles it for every role. + * Native platforms get no handlers. */ export const useSpaceKeyPress = ({ onPress, @@ -34,19 +47,21 @@ export const useSpaceKeyPress = ({ const pressedRef = React.useRef(false); const enabled = Platform.OS === 'web' && onPress !== undefined; - /** Drops a pending Space press, e.g. when focus leaves before the release. */ - const cancel = useLatestCallback(() => { - if (!pressedRef.current) return; - - pressedRef.current = false; - onPressOut?.(); - }); - // Without `onPress` the release is never handled, so a press pending at // that moment would otherwise fire on a later, unrelated release. React.useEffect(() => { - if (!enabled) cancel(); - }, [enabled, cancel]); + if (!enabled) pressedRef.current = false; + }, [enabled]); + + /** Ends a pending press when focus leaves before Space is released. */ + const onBlur = useLatestCallback( + (event: NativeSyntheticEvent) => { + if (!pressedRef.current) return; + + pressedRef.current = false; + onPressOut?.(asPressEvent(event)); + } + ); const keyHandlers = enabled ? { @@ -57,7 +72,7 @@ export const useSpaceKeyPress = ({ if (event.nativeEvent.repeat) return; pressedRef.current = true; - onPressIn?.(); + onPressIn?.(asPressEvent(event)); }, onKeyUp: (event: KeyboardEvent) => { if (!isSpaceKey(event)) return; @@ -66,13 +81,11 @@ export const useSpaceKeyPress = ({ if (!pressedRef.current) return; pressedRef.current = false; - onPressOut?.(); - // React Native Web hands the key event to `onPress` for Enter too. - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - onPress(event as unknown as GestureResponderEvent); + onPressOut?.(asPressEvent(event)); + onPress(asPressEvent(event)); }, } : undefined; - return { keyHandlers, cancel }; + return { keyHandlers, onBlur }; }; From df3fe383002236e38961e6dc8792f548fb59fde0 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 14:17:55 +0100 Subject: [PATCH 16/22] test(checkbox): cover a row disabled in the middle of a space press Testing Library never dispatches events to a disabled pressable, so the old test passed whether or not the row ignored Space while disabled. The new one disables and re-enables the row between key down and key up, and fails without the guard or the reset. --- .../__tests__/Checkbox/CheckboxItem.test.tsx | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/components/__tests__/Checkbox/CheckboxItem.test.tsx b/src/components/__tests__/Checkbox/CheckboxItem.test.tsx index d4d491cb22..acd9c352f4 100644 --- a/src/components/__tests__/Checkbox/CheckboxItem.test.tsx +++ b/src/components/__tests__/Checkbox/CheckboxItem.test.tsx @@ -113,10 +113,16 @@ it('toggles the row once on Space release on web', async () => { expect(onPress).toHaveBeenCalledTimes(1); }); -it('does not toggle a disabled row on Space', async () => { +it('does not toggle on a release after the row was disabled mid press', async () => { jest.replaceProperty(Platform, 'OS', 'web'); const onPress = jest.fn<(e: GestureResponderEvent) => void>(); + const space = { nativeEvent: { key: ' ' }, preventDefault: jest.fn() }; await render( + + ); + + await fireEvent(screen.getByRole('checkbox'), 'keyDown', space); + await screen.rerender( { disabled /> ); - const row = screen.getByRole('checkbox', { name: 'Notify me' }); - const space = { nativeEvent: { key: ' ' }, preventDefault: jest.fn() }; - - await fireEvent(row, 'keyDown', space); - await fireEvent(row, 'keyUp', space); + await screen.rerender( + + ); + await fireEvent(screen.getByRole('checkbox'), 'keyUp', space); expect(onPress).not.toHaveBeenCalled(); }); From cfa49afdb9a7d68d4525c5a6dac7f7e382d763b4 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 14:18:29 +0100 Subject: [PATCH 17/22] refactor(checkbox): rename the checkbox state layer resolver The resolver shared its name with the theme's `getStateLayer(theme, role, state)` but took different arguments, and `CheckboxStateLayer` repeated the shared `StateLayer` type. It is now `getCheckboxStateLayer`, returns `StateLayer` and resolves its token roles through the shared helper. --- src/components/Checkbox/Checkbox.tsx | 8 ++-- src/components/Checkbox/utils.ts | 34 ++++++++--------- .../__tests__/Checkbox/utils.test.tsx | 37 +++++++++++-------- 3 files changed, 43 insertions(+), 36 deletions(-) diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx index 6d05296377..c4f336a162 100644 --- a/src/components/Checkbox/Checkbox.tsx +++ b/src/components/Checkbox/Checkbox.tsx @@ -23,7 +23,7 @@ import Animated, { } from 'react-native-reanimated'; import { CheckboxTokens } from './tokens'; -import { getSelectionVisualState, getStateLayer } from './utils'; +import { getCheckboxStateLayer, getSelectionVisualState } from './utils'; import type { CheckboxInteraction } from './utils'; import { useLocale } from '../../core/locale'; import { SettingsContext } from '../../core/settings'; @@ -204,12 +204,12 @@ const Checkbox = ({ // 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({ + const stateLayer = getCheckboxStateLayer({ ...selectionColors, interaction }); + const stateLayerColor = getCheckboxStateLayer({ ...selectionColors, interaction: interaction ?? 'hovered', }).color; - const pressRipple = getStateLayer({ + const pressRipple = getCheckboxStateLayer({ ...selectionColors, interaction: 'pressed', }); diff --git a/src/components/Checkbox/utils.ts b/src/components/Checkbox/utils.ts index edcf0794d4..0af089ddb7 100644 --- a/src/components/Checkbox/utils.ts +++ b/src/components/Checkbox/utils.ts @@ -2,7 +2,12 @@ import type { ColorValue } from 'react-native'; import { CheckboxTokens } from './tokens'; import { tokens } from '../../theme/tokens'; -import type { InternalTheme, StateOpacityKey } from '../../theme/types'; +import type { + InternalTheme, + StateLayer, + StateOpacityKey, +} from '../../theme/types'; +import { getStateLayer } from '../../theme/utils/state'; // MD3 Checkbox spec: https://m3.material.io/components/checkbox/specs @@ -76,7 +81,8 @@ const getIconColor = ({ /** * Resolve the static (non-interactive) colors + opacity for the Checkbox - * renderer. The interaction states are resolved by `getStateLayer` instead. + * renderer. The interaction states are resolved by `getCheckboxStateLayer` + * instead. */ export const getSelectionVisualState = ({ theme, @@ -121,11 +127,6 @@ export type CheckboxInteraction = Extract< 'hovered' | 'focused' | 'pressed' >; -export type CheckboxStateLayer = { - color: ColorValue; - opacity: number; -}; - type StateLayerState = { theme: InternalTheme; selected: boolean; @@ -140,7 +141,7 @@ type StateLayerState = { * error checkbox stays on `error` throughout, and `color`/`uncheckedColor` * override the role they already override on the box itself. */ -export const getStateLayer = ({ +export const getCheckboxStateLayer = ({ theme, selected, error, @@ -149,27 +150,26 @@ export const getStateLayer = ({ customUncheckedColor, }: StateLayerState & { interaction: CheckboxInteraction | null; -}): CheckboxStateLayer => { +}): StateLayer => { 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 }; + return { color: custom, opacity: stateOpacity[interaction] }; } if (error) { - return { - color: theme.colors[CheckboxTokens.errorStateLayerColor], - opacity, - }; + return getStateLayer( + theme, + CheckboxTokens.errorStateLayerColor, + interaction + ); } const role = @@ -185,5 +185,5 @@ export const getStateLayer = ({ ? CheckboxTokens.selectedHoverStateLayerColor : CheckboxTokens.unselectedHoverStateLayerColor; - return { color: theme.colors[role], opacity }; + return getStateLayer(theme, role, interaction); }; diff --git a/src/components/__tests__/Checkbox/utils.test.tsx b/src/components/__tests__/Checkbox/utils.test.tsx index db4f85e796..92b9048dd0 100644 --- a/src/components/__tests__/Checkbox/utils.test.tsx +++ b/src/components/__tests__/Checkbox/utils.test.tsx @@ -2,7 +2,10 @@ import { describe, expect, it } from '@jest/globals'; import { DarkTheme, LightTheme } from '../../../theme/schemes'; import { tokens } from '../../../theme/tokens'; -import { getSelectionVisualState, getStateLayer } from '../../Checkbox/utils'; +import { + getCheckboxStateLayer, + getSelectionVisualState, +} from '../../Checkbox/utils'; const stateOpacity = tokens.md.sys.state.opacity; @@ -154,13 +157,17 @@ describe('getSelectionVisualState', () => { }); }); -describe('getStateLayer', () => { +describe('getCheckboxStateLayer', () => { const { colors } = LightTheme; const { hovered, focused, pressed } = tokens.md.sys.state.opacity; it('is fully transparent when idle', () => { expect( - getStateLayer({ theme: LightTheme, selected: false, interaction: null }) + getCheckboxStateLayer({ + theme: LightTheme, + selected: false, + interaction: null, + }) ).toEqual({ color: 'transparent', opacity: 0 }); }); @@ -169,7 +176,7 @@ describe('getStateLayer', () => { ['focused' as const, focused], ])('tints %s with primary when selected', (interaction, opacity) => { expect( - getStateLayer({ theme: LightTheme, selected: true, interaction }) + getCheckboxStateLayer({ theme: LightTheme, selected: true, interaction }) ).toEqual({ color: colors.primary, opacity, @@ -181,7 +188,7 @@ describe('getStateLayer', () => { ['focused' as const, focused], ])('tints %s with onSurface when unselected', (interaction, opacity) => { expect( - getStateLayer({ theme: LightTheme, selected: false, interaction }) + getCheckboxStateLayer({ theme: LightTheme, selected: false, interaction }) ).toEqual({ color: colors.onSurface, opacity, @@ -190,7 +197,7 @@ describe('getStateLayer', () => { it('inverts to onSurface when a selected checkbox is pressed', () => { expect( - getStateLayer({ + getCheckboxStateLayer({ theme: LightTheme, selected: true, interaction: 'pressed', @@ -200,7 +207,7 @@ describe('getStateLayer', () => { it('inverts to primary when an unselected checkbox is pressed', () => { expect( - getStateLayer({ + getCheckboxStateLayer({ theme: LightTheme, selected: false, interaction: 'pressed', @@ -212,7 +219,7 @@ describe('getStateLayer', () => { 'stays on error for %s regardless of selection', (interaction) => { expect( - getStateLayer({ + getCheckboxStateLayer({ theme: LightTheme, selected: true, error: true, @@ -220,7 +227,7 @@ describe('getStateLayer', () => { }) ).toEqual({ color: colors.error, opacity: expect.any(Number) }); expect( - getStateLayer({ + getCheckboxStateLayer({ theme: LightTheme, selected: false, error: true, @@ -241,7 +248,7 @@ describe('getStateLayer', () => { ['focused' as const, focused], ])('uses customColor for %s when selected', (interaction, opacity) => { expect( - getStateLayer({ + getCheckboxStateLayer({ theme: LightTheme, selected: true, interaction, @@ -257,7 +264,7 @@ describe('getStateLayer', () => { 'uses customUncheckedColor for %s when unselected', (interaction, opacity) => { expect( - getStateLayer({ + getCheckboxStateLayer({ theme: LightTheme, selected: false, interaction, @@ -269,7 +276,7 @@ describe('getStateLayer', () => { it('takes the unchecked color when a selected checkbox is pressed', () => { expect( - getStateLayer({ + getCheckboxStateLayer({ theme: LightTheme, selected: true, interaction: 'pressed', @@ -280,7 +287,7 @@ describe('getStateLayer', () => { it('takes the checked color when an unselected checkbox is pressed', () => { expect( - getStateLayer({ + getCheckboxStateLayer({ theme: LightTheme, selected: false, interaction: 'pressed', @@ -291,7 +298,7 @@ describe('getStateLayer', () => { it('overrides error, matching the box', () => { expect( - getStateLayer({ + getCheckboxStateLayer({ theme: LightTheme, selected: true, error: true, @@ -303,7 +310,7 @@ describe('getStateLayer', () => { it('leaves the other side on its token role', () => { expect( - getStateLayer({ + getCheckboxStateLayer({ theme: LightTheme, selected: true, interaction: 'hovered', From afb4c403cfbb138b978daf1cdfb492f4e1be4a0b Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 14:19:52 +0100 Subject: [PATCH 18/22] docs(checkbox): describe the checkbox changes against 5.x The guide covers migrating from 5.x, where the checkbox took 36dp and ignored `style`, so the section now compares against that instead of earlier 6.0 prereleases. It describes the end state of the interaction states, shows the 36dp footprint kept with a negative margin, and adds an accessible name subsection for the new warning. --- docs/6.x/docs/guides/migration.md | 34 +++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index f026ad8ac6..6d42c9b5bc 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -576,24 +576,40 @@ mutually exclusive options, use `SegmentedButtons`. ### Checkbox -#### Interaction state colors +#### Interaction states -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. +Hover, focus and press now paint a Material Design 3 state layer, a 40dp circle around the box, on every platform. Hover and focus use `primary` when the checkbox is selected and `onSurface` when it is not, a press ripples in the opposite color, and an error checkbox uses `error` throughout. `color` and `uncheckedColor` also set the matching state layer color. Keyboard focus draws a 50dp ring with a 2dp gap around the state layer. -#### Touch target height +To use the platform ripple instead of the state layer press, pass `rippleColor` on any platform, `background` on Android or `underlayColor` on iOS. Setting `rippleEffectEnabled: false` on the `settings` prop of `PaperProvider` turns off the press ripple either way. -`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. +#### Touch target size -#### Custom style target +`Checkbox` now takes up 48dp instead of 36dp to meet the minimum touch target, so rows with a checkbox can grow up to 12dp taller. -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 touch 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. +#### Style -To keep a layout as dense as before, give the extra space back with a negative margin instead of shrinking the checkbox. It then takes up 40dp in the layout again, while its touch target stays 48dp: +`Checkbox` now accepts `style` and applies it to its outer container, so layout styles such as `margin`, `position` and `transform` move the whole checkbox, focus ring included. `width` and `height` do not resize the 48dp touch target. + +To keep the 36dp footprint of 5.x, use a negative margin. The touch target stays 48dp and reaches 6dp into the space around the checkbox: ```tsx // Before (v5) - + // After (v6) - + +``` + +#### Accessible name + +A standalone `Checkbox` has no visible label, so it now warns in development when it has no accessible name. Pass `aria-label` or `accessibilityLabel`, or use `Checkbox.Item`, which names the whole row. On Android and web, `aria-labelledby` names it too. A checkbox hidden from assistive technology, for example with `aria-hidden`, does not warn. + +```diff +- ++ ``` From 71c7a4dc2ecf3790a2ca83c833139a8009ba5e43 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 14:19:53 +0100 Subject: [PATCH 19/22] docs(checkbox): name the checkbox in the usage example The usage example is published on the docs page, and without a name it triggers the accessible name warning. --- src/components/Checkbox/Checkbox.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx index c4f336a162..3790015303 100644 --- a/src/components/Checkbox/Checkbox.tsx +++ b/src/components/Checkbox/Checkbox.tsx @@ -131,6 +131,7 @@ const RIPPLE_START_SCALE = 0.6; * onPress={() => { * setChecked(!checked); * }} + * aria-label="Remember me" * /> * ); * }; From b6de5319736b4b6ccd06d1ea985021ecfc81bcf4 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 15:03:29 +0100 Subject: [PATCH 20/22] fix(checkbox): press out when a space press loses onPress When a pending Space press lost `onPress`, by disabling the checkbox or removing the handler, the press was dropped without pressing out. The caller's `onPressIn` went unmatched and, while the checkbox stayed interactive, the ripple stayed painted. Every way a Space press ends now presses out through one helper, and only a release presses. --- .../__tests__/Checkbox/Checkbox.test.tsx | 60 +++++++++++++++++++ src/utils/useSpaceKeyPress.ts | 37 +++++++----- 2 files changed, 82 insertions(+), 15 deletions(-) diff --git a/src/components/__tests__/Checkbox/Checkbox.test.tsx b/src/components/__tests__/Checkbox/Checkbox.test.tsx index 249bb381b2..caa9b48418 100644 --- a/src/components/__tests__/Checkbox/Checkbox.test.tsx +++ b/src/components/__tests__/Checkbox/Checkbox.test.tsx @@ -238,6 +238,66 @@ describe('Checkbox Space key', () => { expect(onPress).not.toHaveBeenCalled(); }); + it('presses out without toggling when disabled mid press', async () => { + jest.replaceProperty(Platform, 'OS', 'web'); + const onPress = jest.fn<(e: GestureResponderEvent) => void>(); + const onPressOut = jest.fn<(e: GestureResponderEvent) => void>(); + await render( + + ); + + await fireEvent(screen.getByRole('checkbox'), 'keyDown', key(' ')); + await screen.rerender( + + ); + await fireEvent(screen.getByRole('checkbox'), 'keyUp', key(' ')); + + expect(onPressOut).toHaveBeenCalledTimes(1); + expect(onPress).not.toHaveBeenCalled(); + }); + + it('presses out when onPress goes away mid press', async () => { + jest.replaceProperty(Platform, 'OS', 'web'); + const onPress = jest.fn<(e: GestureResponderEvent) => void>(); + const onLongPress = jest.fn<(e: GestureResponderEvent) => void>(); + const onPressOut = jest.fn<(e: GestureResponderEvent) => void>(); + await render( + + ); + + await fireEvent(screen.getByRole('checkbox'), 'keyDown', key(' ')); + // Still interactive through `onLongPress`, but Space has nothing to press. + await screen.rerender( + + ); + await fireEvent(screen.getByRole('checkbox'), 'keyUp', key(' ')); + + expect(onPressOut).toHaveBeenCalledTimes(1); + expect(onPress).not.toHaveBeenCalled(); + }); + it('does not toggle once the checkbox becomes disabled', async () => { jest.replaceProperty(Platform, 'OS', 'web'); const onPress = jest.fn<(e: GestureResponderEvent) => void>(); diff --git a/src/utils/useSpaceKeyPress.ts b/src/utils/useSpaceKeyPress.ts index a0e0e98dc9..6f9a9e0bb1 100644 --- a/src/utils/useSpaceKeyPress.ts +++ b/src/utils/useSpaceKeyPress.ts @@ -44,22 +44,33 @@ export const useSpaceKeyPress = ({ onPressIn, onPressOut, }: Options) => { - const pressedRef = React.useRef(false); + // The key down event of the pending press, or `null` when there is none. + const pressInEventRef = React.useRef(null); const enabled = Platform.OS === 'web' && onPress !== undefined; - // Without `onPress` the release is never handled, so a press pending at - // that moment would otherwise fire on a later, unrelated release. + /** Presses out the pending press, if any, and returns whether there was one. */ + const endPress = useLatestCallback( + (event: KeyboardEvent | NativeSyntheticEvent) => { + if (pressInEventRef.current === null) return false; + + pressInEventRef.current = null; + onPressOut?.(asPressEvent(event)); + return true; + } + ); + + // Without `onPress` the release is never handled, so a pending press ends + // here instead, without pressing. Otherwise it would press on a later, + // unrelated release. React.useEffect(() => { - if (!enabled) pressedRef.current = false; - }, [enabled]); + const pressInEvent = pressInEventRef.current; + if (!enabled && pressInEvent !== null) endPress(pressInEvent); + }, [enabled, endPress]); /** Ends a pending press when focus leaves before Space is released. */ const onBlur = useLatestCallback( (event: NativeSyntheticEvent) => { - if (!pressedRef.current) return; - - pressedRef.current = false; - onPressOut?.(asPressEvent(event)); + endPress(event); } ); @@ -71,18 +82,14 @@ export const useSpaceKeyPress = ({ event.preventDefault(); if (event.nativeEvent.repeat) return; - pressedRef.current = true; + pressInEventRef.current = event; onPressIn?.(asPressEvent(event)); }, onKeyUp: (event: KeyboardEvent) => { if (!isSpaceKey(event)) return; event.preventDefault(); - if (!pressedRef.current) return; - - pressedRef.current = false; - onPressOut?.(asPressEvent(event)); - onPress(asPressEvent(event)); + if (endPress(event)) onPress(asPressEvent(event)); }, } : undefined; From 3797c68c0cba22939c98f0e8b77ebc1e921f26ad Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 15:04:17 +0100 Subject: [PATCH 21/22] test(checkbox): cover the hiding props on the platforms that ignore them Android ignores `accessibilityElementsHidden`, and React Native Web drops it along with `importantForAccessibility`, so a checkbox hidden only by one of them still warns there. The tests fail if a platform check is removed or widened. --- .../__tests__/Checkbox/Checkbox.test.tsx | 34 +++++++++++-------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/src/components/__tests__/Checkbox/Checkbox.test.tsx b/src/components/__tests__/Checkbox/Checkbox.test.tsx index caa9b48418..a97d9ff18a 100644 --- a/src/components/__tests__/Checkbox/Checkbox.test.tsx +++ b/src/components/__tests__/Checkbox/Checkbox.test.tsx @@ -454,22 +454,28 @@ describe('Checkbox accessible name', () => { } ); - it('still warns on iOS when only importantForAccessibility hides it', async () => { - jest.spyOn(console, 'warn').mockImplementation(() => {}); + // Each of these props hides a view on one platform only, so elsewhere the + // checkbox stays exposed and still needs a name. + it.each([ + { os: 'ios', hidden: { importantForAccessibility: 'no-hide-descendants' } }, + { os: 'web', hidden: { importantForAccessibility: 'no-hide-descendants' } }, + { os: 'android', hidden: { accessibilityElementsHidden: true } }, + { os: 'web', hidden: { accessibilityElementsHidden: true } }, + ] as const)( + 'still warns on $os when only $hidden hides it', + async ({ os, hidden }) => { + jest.replaceProperty(Platform, 'OS', os); + jest.spyOn(console, 'warn').mockImplementation(() => {}); - // iOS ignores `importantForAccessibility`, so the checkbox stays exposed. - await render( - {}} - importantForAccessibility="no-hide-descendants" - /> - ); + await render( + {}} {...hidden} /> + ); - expect(console.warn).toHaveBeenCalledWith( - expect.stringContaining('aria-label') - ); - }); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('aria-label') + ); + } + ); it('warns on iOS when only aria-labelledby names it', async () => { jest.spyOn(console, 'warn').mockImplementation(() => {}); From c79fe2387be66c7ccebef70cf9393a12daf7fc96 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 15:04:52 +0100 Subject: [PATCH 22/22] fix(checkbox): warn about a missing accessible name only in development The migration guide describes a development warning, but production builds logged it too, including in the browser console on web. It now uses the same `process.env.NODE_ENV` check as the other development-only messages. --- src/components/Checkbox/Checkbox.tsx | 8 +++++++- src/components/__tests__/Checkbox/Checkbox.test.tsx | 9 +++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx index 3790015303..77d18ee425 100644 --- a/src/components/Checkbox/Checkbox.tsx +++ b/src/components/Checkbox/Checkbox.tsx @@ -463,7 +463,13 @@ const Checkbox = ({ ); React.useEffect(() => { - if (isHidden || hasAccessibleName) return; + if ( + process.env.NODE_ENV === 'production' || + isHidden || + hasAccessibleName + ) { + return; + } console.warn( 'Checkbox: pass `aria-label` to name the checkbox for assistive tech, or use `Checkbox.Item` for a labelled row.' diff --git a/src/components/__tests__/Checkbox/Checkbox.test.tsx b/src/components/__tests__/Checkbox/Checkbox.test.tsx index a97d9ff18a..d73d673959 100644 --- a/src/components/__tests__/Checkbox/Checkbox.test.tsx +++ b/src/components/__tests__/Checkbox/Checkbox.test.tsx @@ -411,6 +411,15 @@ describe('Checkbox accessible name', () => { ); }); + it('does not warn in production', async () => { + jest.replaceProperty(process.env, 'NODE_ENV', 'production'); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + + await render( {}} />); + + expect(console.warn).not.toHaveBeenCalled(); + }); + it('is named by aria-label', async () => { jest.spyOn(console, 'warn').mockImplementation(() => {});