diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md
index 72aee6d00f..6d42c9b5bc 100644
--- a/docs/6.x/docs/guides/migration.md
+++ b/docs/6.x/docs/guides/migration.md
@@ -573,3 +573,43 @@ mutually exclusive options, use `SegmentedButtons`.
/>
>
```
+
+### Checkbox
+
+#### Interaction states
+
+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.
+
+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.
+
+#### Touch target size
+
+`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.
+
+#### Style
+
+`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
+-
++
+```
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={() => }
/>
diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx
index 3bccccf33b..77d18ee425 100644
--- a/src/components/Checkbox/Checkbox.tsx
+++ b/src/components/Checkbox/Checkbox.tsx
@@ -3,22 +3,37 @@ import { Platform, StyleSheet, View } from 'react-native';
import type {
ColorValue,
GestureResponderEvent,
+ MouseEvent,
NativeSyntheticEvent,
StyleProp,
TargetedEvent,
ViewStyle,
} from 'react-native';
-import Animated, { cubicBezier, type CSSStyle } from 'react-native-reanimated';
+import Animated, {
+ cubicBezier,
+ Easing,
+ ReduceMotion,
+ useAnimatedReaction,
+ useAnimatedStyle,
+ useSharedValue,
+ withDelay,
+ withTiming,
+ type CSSStyle,
+} from 'react-native-reanimated';
import { CheckboxTokens } from './tokens';
-import { getSelectionVisualState } from './utils';
+import { getCheckboxStateLayer, getSelectionVisualState } from './utils';
+import type { CheckboxInteraction } from './utils';
import { useLocale } from '../../core/locale';
+import { SettingsContext } from '../../core/settings';
import { useInternalTheme } from '../../core/theming';
import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext';
import { tokens } from '../../theme/tokens';
import type { 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';
@@ -39,11 +54,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;
/**
@@ -62,10 +79,18 @@ 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. `width` and `height` here
+ * 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;
+ /**
+ * 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).
@@ -74,15 +99,20 @@ const {
containerRadius: CONTAINER_RADIUS,
outlineWidth: OUTLINE_WIDTH,
stateLayerSize: STATE_LAYER_SIZE,
+ touchTargetSize: TOUCH_TARGET_SIZE,
} = CheckboxTokens;
-const FOCUS_THICKNESS = tokens.md.sys.state.focusIndicator.thickness;
-// Focus indicator is a circular ring at the 40dp state-layer boundary.
-// We don't apply `focusIndicator.outerOffset` here because the surrounding
-// `TouchableRipple borderless` clips overflow to the tap-target shape,
-// so a ring drawn outside the 40dp circle would be cropped.
-const FOCUS_RING_SIZE = STATE_LAYER_SIZE;
-const FOCUS_RING_RADIUS = STATE_LAYER_SIZE / 2;
+const { thickness: FOCUS_THICKNESS, outerOffset: FOCUS_OUTER_OFFSET } =
+ tokens.md.sys.state.focusIndicator;
+// The border is drawn inside the ring's own box, so the box spans the state
+// layer plus the offset and the border on each side.
+const FOCUS_RING_SIZE =
+ STATE_LAYER_SIZE + 2 * (FOCUS_OUTER_OFFSET + FOCUS_THICKNESS);
+const FOCUS_RING_RADIUS = FOCUS_RING_SIZE / 2;
+
+// Compose's `RippleAnimation` starts the ripple at 30% of the target layer's
+// size, i.e. 0.6 of the radius.
+const RIPPLE_START_SCALE = 0.6;
/**
* Checkboxes allow the selection of multiple options from a set.
@@ -101,12 +131,19 @@ const FOCUS_RING_RADIUS = STATE_LAYER_SIZE / 2;
* onPress={() => {
* setChecked(!checked);
* }}
+ * aria-label="Remember me"
* />
* );
* };
*
* 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,
@@ -123,27 +160,94 @@ const Checkbox = ({
const theme = useInternalTheme(themeOverrides);
const reduceMotion = useReduceMotion();
+ const { rippleEffectEnabled } = React.useContext(SettingsContext);
const { direction } = useLocale();
// Web (react-native-web) doesn't auto-mirror layout, so flip the mask
// anchor manually for RTL. Native handles it via `I18nManager`.
const flipMaskForWebRTL = Platform.OS === 'web' && direction === 'rtl';
const [focused, setFocused] = React.useState(false);
+ const [hovered, setHovered] = React.useState(false);
const selected = status === 'checked' || status === 'indeterminate';
- // Visual state (colors + opacity) for the static layers. `hovered` /
- // `pressed` aren't tracked here — `TouchableRipple` owns the press ripple
- // and hover overlay.
- const visual = getSelectionVisualState({
+ // Shared by the box and the state layer, so a custom color reaches both.
+ const selectionColors = {
theme,
selected,
- disabled,
error,
customColor: color,
customUncheckedColor: uncheckedColor,
+ };
+
+ const visual = getSelectionVisualState({ ...selectionColors, disabled });
+
+ // `TouchableRipple` disables itself when nothing can handle a press, so
+ // attaching press handlers unconditionally would make a handler-less
+ // checkbox enabled and focusable while doing nothing.
+ const isInteractive =
+ !disabled &&
+ hasTouchHandler({
+ onPress,
+ onLongPress: rest.onLongPress,
+ onPressIn: rest.onPressIn,
+ onPressOut: rest.onPressOut,
+ });
+
+ const interaction: CheckboxInteraction | null = !isInteractive
+ ? null
+ : focused
+ ? 'focused'
+ : hovered
+ ? 'hovered'
+ : null;
+
+ // Fade by opacity alone: under a dynamic theme the role is a `PlatformColor`,
+ // which Reanimated cannot interpolate, so the color stays put while idle
+ // instead of dropping to transparent.
+ const stateLayer = getCheckboxStateLayer({ ...selectionColors, interaction });
+ const stateLayerColor = getCheckboxStateLayer({
+ ...selectionColors,
+ interaction: interaction ?? 'hovered',
+ }).color;
+ const pressRipple = getCheckboxStateLayer({
+ ...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 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) ||
+ (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
);
@@ -176,6 +280,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,
@@ -184,6 +296,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)
@@ -202,6 +381,23 @@ const Checkbox = ({
}
const showIndeterminate = nextGlyph === 'indeterminate';
+ 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: handlePressIn,
+ onPressOut: handlePressOut,
+ });
+
const handleFocus = React.useCallback(
(e: NativeSyntheticEvent) => {
if (disabled) return;
@@ -211,9 +407,74 @@ const Checkbox = ({
[disabled]
);
- const handleBlur = React.useCallback(() => {
- setFocused(false);
- }, []);
+ const handleBlur = React.useCallback(
+ (e: NativeSyntheticEvent) => {
+ setFocused(false);
+ handleSpaceKeyBlur(e);
+ },
+ [handleSpaceKeyBlur]
+ );
+
+ const interactionHandlers = isInteractive
+ ? {
+ onHoverIn: (e: MouseEvent) => {
+ setHovered(true);
+ rest.onHoverIn?.(e);
+ },
+ onHoverOut: (e: MouseEvent) => {
+ setHovered(false);
+ rest.onHoverOut?.(e);
+ },
+ onPressIn: handlePressIn,
+ onPressOut: handlePressOut,
+ }
+ : null;
+
+ // Losing the handlers means the matching hover-out or press-out never
+ // arrives, so the state would otherwise linger.
+ React.useEffect(() => {
+ if (isInteractive) return;
+
+ setHovered(false);
+ pressedSV.value = 0;
+ rippleHoldSV.value = 0;
+ rippleAlpha.value = 0;
+ rippleScale.value = RIPPLE_START_SCALE;
+ }, [isInteractive, pressedSV, rippleHoldSV, rippleAlpha, rippleScale]);
+
+ // A hidden checkbox needs no name. `Checkbox.Item` names the row and passes
+ // `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 ??
+ (Platform.OS !== 'ios'
+ ? (rest['aria-labelledby'] ?? rest.accessibilityLabelledBy)
+ : undefined)
+ );
+
+ React.useEffect(() => {
+ 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.'
+ );
+ }, [isHidden, hasAccessibleName]);
const checked: boolean | 'mixed' =
status === 'indeterminate' ? 'mixed' : status === 'checked';
@@ -232,72 +493,100 @@ const Checkbox = ({
'aria-live': 'polite' as const,
};
+ const focusRing =
+ focused && !disabled ? (
+
+ ) : null;
+
return (
-
-
- {focused && !disabled ? (
-
- ) : null}
-
+ // The ring is a sibling of the pressable, not a child: a foreground ripple
+ // forces `overflow: hidden` on it regardless of `borderless`. 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.
+
+
+
-
-
+ ) : null}
+
- {showIndeterminate ? (
-
-
-
- ) : (
-
-
-
- )}
-
+
+
+
+ {showIndeterminate ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
-
-
+
+ {focusRing}
+
);
};
@@ -306,10 +595,18 @@ 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',
+ },
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',
},
@@ -319,6 +616,12 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
},
+ stateLayer: {
+ position: 'absolute',
+ width: STATE_LAYER_SIZE,
+ height: STATE_LAYER_SIZE,
+ borderRadius: STATE_LAYER_SIZE / 2,
+ },
focusRing: {
position: 'absolute',
width: FOCUS_RING_SIZE,
diff --git a/src/components/Checkbox/CheckboxItem.tsx b/src/components/Checkbox/CheckboxItem.tsx
index 7d0f8ee64a..b720ce179b 100644
--- a/src/components/Checkbox/CheckboxItem.tsx
+++ b/src/components/Checkbox/CheckboxItem.tsx
@@ -11,6 +11,7 @@ import Checkbox from './Checkbox';
import { useInternalTheme } from '../../core/theming';
import type { ThemeProp, TypescaleKey } from '../../theme/types';
import { getStateLayer } from '../../theme/utils/state';
+import { useSpaceKeyPress } from '../../utils/useSpaceKeyPress';
import TouchableRipple from '../TouchableRipple/TouchableRipple';
import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple';
import Text from '../Typography/Text';
@@ -147,6 +148,10 @@ const CheckboxItem = ({
const textAlign = isLeading ? 'right' : 'left';
+ // The row is the focusable checkbox, so it takes the Space key itself.
+ const { keyHandlers: spaceKeyHandlers, onBlur: handleSpaceKeyBlur } =
+ useSpaceKeyPress({ onPress: disabled ? undefined : onPress });
+
const computedStyle: TextStyle = {
...getStateLayer(theme, 'onSurface', disabled ? 'disabled' : 'enabled'),
textAlign,
@@ -160,6 +165,8 @@ const CheckboxItem = ({
aria-disabled={disabled}
onPress={onPress}
onLongPress={onLongPress}
+ onBlur={handleSpaceKeyBlur}
+ {...spaceKeyHandlers}
testID={testID}
disabled={disabled}
theme={theme}
diff --git a/src/components/Checkbox/tokens.ts b/src/components/Checkbox/tokens.ts
index 6406f25c47..3a0606b090 100644
--- a/src/components/Checkbox/tokens.ts
+++ b/src/components/Checkbox/tokens.ts
@@ -9,6 +9,8 @@ const sizes = {
containerRadius: 2,
outlineWidth: 2,
stateLayerSize: 40,
+ /** Minimum interactive area; larger than the 40dp state layer. */
+ touchTargetSize: 48,
} as const;
const colors = {
@@ -21,8 +23,13 @@ const colors = {
iconColor: 'onPrimary',
disabledIconColor: 'surface',
errorIconColor: 'onError',
- selectedStateLayerColor: 'primary',
- unselectedStateLayerColor: 'onSurface',
+ // Hover and focus tint by selection; pressing inverts it.
+ selectedHoverStateLayerColor: 'primary',
+ selectedFocusStateLayerColor: 'primary',
+ selectedPressedStateLayerColor: 'onSurface',
+ unselectedHoverStateLayerColor: 'onSurface',
+ unselectedFocusStateLayerColor: 'onSurface',
+ unselectedPressedStateLayerColor: 'primary',
errorStateLayerColor: 'error',
} as const satisfies Record;
diff --git a/src/components/Checkbox/utils.ts b/src/components/Checkbox/utils.ts
index 80e7c31750..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 } 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,8 +81,8 @@ 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 `getCheckboxStateLayer`
+ * instead.
*/
export const getSelectionVisualState = ({
theme,
@@ -115,3 +120,70 @@ export const getSelectionVisualState = ({
}),
};
};
+
+/** Interaction the state layer is painting, or `null` when it is idle. */
+export type CheckboxInteraction = Extract<
+ StateOpacityKey,
+ 'hovered' | 'focused' | 'pressed'
+>;
+
+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 getCheckboxStateLayer = ({
+ theme,
+ selected,
+ error,
+ interaction,
+ customColor,
+ customUncheckedColor,
+}: StateLayerState & {
+ interaction: CheckboxInteraction | null;
+}): StateLayer => {
+ if (interaction === null) {
+ return { color: 'transparent', opacity: 0 };
+ }
+
+ // 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: stateOpacity[interaction] };
+ }
+
+ if (error) {
+ return getStateLayer(
+ theme,
+ CheckboxTokens.errorStateLayerColor,
+ interaction
+ );
+ }
+
+ const role =
+ interaction === 'pressed'
+ ? selected
+ ? CheckboxTokens.selectedPressedStateLayerColor
+ : CheckboxTokens.unselectedPressedStateLayerColor
+ : interaction === 'focused'
+ ? selected
+ ? CheckboxTokens.selectedFocusStateLayerColor
+ : CheckboxTokens.unselectedFocusStateLayerColor
+ : selected
+ ? CheckboxTokens.selectedHoverStateLayerColor
+ : CheckboxTokens.unselectedHoverStateLayerColor;
+
+ return getStateLayer(theme, role, interaction);
+};
diff --git a/src/components/__tests__/Checkbox/Checkbox.test.tsx b/src/components/__tests__/Checkbox/Checkbox.test.tsx
index a72200bb4e..d73d673959 100644
--- a/src/components/__tests__/Checkbox/Checkbox.test.tsx
+++ b/src/components/__tests__/Checkbox/Checkbox.test.tsx
@@ -1,11 +1,19 @@
-import { expect, it } from '@jest/globals';
+import { type GestureResponderEvent, Platform } from 'react-native';
-import { render } from '../../../test-utils';
+import { afterEach, describe, expect, it, jest } from '@jest/globals';
+
+import { fireEvent, render, screen, userEvent } from '../../../test-utils';
import Checkbox from '../../Checkbox';
+afterEach(() => {
+ jest.restoreAllMocks();
+});
+
it('renders checked Checkbox with onPress', async () => {
const tree = (
- await render( {}} />)
+ await render(
+ {}} aria-label="Notify me" />
+ )
).toJSON();
expect(tree).toMatchSnapshot();
@@ -13,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();
@@ -21,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();
@@ -29,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();
@@ -37,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();
@@ -45,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();
@@ -53,8 +75,441 @@ 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', () => {
+ 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(
+ screen.getByRole('checkbox', { name: 'Notify me', checked })
+ ).toBeOnTheScreen();
+ }
+ );
+});
+
+describe('Checkbox press', () => {
+ it('calls onPress when pressed', async () => {
+ const onPress = jest.fn<(e: GestureResponderEvent) => void>();
+ await render(
+
+ );
+
+ await userEvent.press(screen.getByRole('checkbox'));
+
+ expect(onPress).toHaveBeenCalledTimes(1);
+ });
+
+ it('is disabled and ignores presses when disabled', async () => {
+ const onPress = jest.fn<(e: GestureResponderEvent) => void>();
+ await render(
+
+ );
+
+ await userEvent.press(screen.getByRole('checkbox'));
+
+ expect(screen.getByRole('checkbox')).toBeDisabled();
+ expect(onPress).not.toHaveBeenCalled();
+ });
+
+ it('is reported as disabled rather than an enabled no-op without a press handler', async () => {
+ await render();
+
+ expect(screen.getByRole('checkbox')).toBeDisabled();
+ });
+});
+
+describe('Checkbox Space key', () => {
+ const key = (name: string, repeat = false) => ({
+ nativeEvent: { key: name, repeat },
+ preventDefault: jest.fn(),
+ });
+
+ const renderCheckbox = (onPress: (e: GestureResponderEvent) => void) =>
+ 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('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>();
+ 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('presses out without toggling when focus leaves before the release', 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 fireEvent(screen.getByRole('checkbox'), 'blur');
+
+ expect(onPressOut).toHaveBeenCalledTimes(1);
+
+ await fireEvent(screen.getByRole('checkbox'), 'keyUp', key(' '));
+
+ expect(onPressOut).toHaveBeenCalledTimes(1);
+ 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>();
+ 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(
+ {}} aria-label="Notify me" />
+ );
+
+ expect(screen.getByRole('checkbox')).toHaveStyle({
+ width: 48,
+ height: 48,
+ });
+ });
+
+ it('takes the custom style on the outer container, not the pressable', async () => {
+ await render(
+ {}}
+ aria-label="Notify me"
+ style={{ marginTop: 12 }}
+ />
+ );
+
+ // On the pressable the style would miss the focus ring beside it, and
+ // could shrink the touch target back below 48dp.
+ expect(screen.root).toHaveStyle({ marginTop: 12 });
+ 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(
+ {}}
+ aria-label="Notify me"
+ style={{ margin: -4 }}
+ />
+ );
+
+ expect(screen.root).toHaveStyle({ margin: -4 });
+ expect(screen.getByRole('checkbox')).toHaveStyle({
+ width: 48,
+ height: 48,
+ });
+ });
+});
+
+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('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(() => {});
+
+ 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.each([
+ { 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 on $os for a checkbox hidden with $hidden',
+ async ({ os, hidden }) => {
+ jest.replaceProperty(Platform, 'OS', os);
+ jest.spyOn(console, 'warn').mockImplementation(() => {});
+
+ await render(
+ {}} {...hidden} />
+ );
+
+ expect(console.warn).not.toHaveBeenCalled();
+ }
+ );
+
+ // 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(() => {});
+
+ await render(
+ {}} {...hidden} />
+ );
+
+ 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();
+ }
+ );
+});
diff --git a/src/components/__tests__/Checkbox/CheckboxItem.test.tsx b/src/components/__tests__/Checkbox/CheckboxItem.test.tsx
index ee52c1b3e4..acd9c352f4 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,50 @@ 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 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(
+
+ );
+ await screen.rerender(
+
+ );
+ await fireEvent(screen.getByRole('checkbox'), 'keyUp', space);
+
+ expect(onPress).not.toHaveBeenCalled();
+});
diff --git a/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap b/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap
index 202a95467a..358f0d3c78 100644
--- a/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap
+++ b/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap
@@ -2,84 +2,80 @@
exports[`renders Checkbox with custom testID 1`] = `
-
+
-
+
+
+ >
+
+
@@ -187,83 +219,79 @@ exports[`renders Checkbox with custom testID 1`] = `
exports[`renders checked Checkbox with color 1`] = `
-
+
-
+
+
+ >
+
+
@@ -371,83 +435,79 @@ exports[`renders checked Checkbox with color 1`] = `
exports[`renders checked Checkbox with onPress 1`] = `
-
+
+
+
+ >
+
+
@@ -555,83 +676,79 @@ exports[`renders checked Checkbox with onPress 1`] = `
exports[`renders indeterminate Checkbox 1`] = `
-
+
+
+
+ >
+
+
@@ -726,83 +904,79 @@ exports[`renders indeterminate Checkbox 1`] = `
exports[`renders indeterminate Checkbox with color 1`] = `
-
+
-
+
+
+ >
+
+
@@ -897,83 +1107,79 @@ exports[`renders indeterminate Checkbox with color 1`] = `
exports[`renders unchecked Checkbox with color 1`] = `
-
+
-
+
+
+ >
+
+
@@ -1081,83 +1323,79 @@ 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 e2d338f791..ddfaac46f1 100644
--- a/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap
+++ b/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap
@@ -57,81 +57,76 @@ exports[`can render leading checkbox control 1`] = `
}
>
-
+
-
+
+
+ >
+
+
@@ -370,81 +401,76 @@ exports[`renders unchecked 1`] = `
Unchecked Button
-
+
-
+
+
+ >
+
+
@@ -645,81 +707,76 @@ 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 6184bdf329..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 } from '../../Checkbox/utils';
+import {
+ getCheckboxStateLayer,
+ getSelectionVisualState,
+} from '../../Checkbox/utils';
const stateOpacity = tokens.md.sys.state.opacity;
@@ -153,3 +156,167 @@ describe('getSelectionVisualState', () => {
});
});
});
+
+describe('getCheckboxStateLayer', () => {
+ const { colors } = LightTheme;
+ const { hovered, focused, pressed } = tokens.md.sys.state.opacity;
+
+ it('is fully transparent when idle', () => {
+ expect(
+ getCheckboxStateLayer({
+ theme: LightTheme,
+ 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(
+ getCheckboxStateLayer({ theme: LightTheme, 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(
+ getCheckboxStateLayer({ theme: LightTheme, selected: false, interaction })
+ ).toEqual({
+ color: colors.onSurface,
+ opacity,
+ });
+ });
+
+ it('inverts to onSurface when a selected checkbox is pressed', () => {
+ expect(
+ getCheckboxStateLayer({
+ theme: LightTheme,
+ selected: true,
+ interaction: 'pressed',
+ })
+ ).toEqual({ color: colors.onSurface, opacity: pressed });
+ });
+
+ it('inverts to primary when an unselected checkbox is pressed', () => {
+ expect(
+ getCheckboxStateLayer({
+ theme: LightTheme,
+ 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(
+ getCheckboxStateLayer({
+ theme: LightTheme,
+ selected: true,
+ error: true,
+ interaction,
+ })
+ ).toEqual({ color: colors.error, opacity: expect.any(Number) });
+ expect(
+ getCheckboxStateLayer({
+ theme: LightTheme,
+ 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(
+ getCheckboxStateLayer({
+ theme: LightTheme,
+ 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(
+ getCheckboxStateLayer({
+ theme: LightTheme,
+ selected: false,
+ interaction,
+ ...custom,
+ })
+ ).toEqual({ color: 'teal', opacity });
+ }
+ );
+
+ it('takes the unchecked color when a selected checkbox is pressed', () => {
+ expect(
+ getCheckboxStateLayer({
+ theme: LightTheme,
+ selected: true,
+ interaction: 'pressed',
+ ...custom,
+ })
+ ).toEqual({ color: 'teal', opacity: pressed });
+ });
+
+ it('takes the checked color when an unselected checkbox is pressed', () => {
+ expect(
+ getCheckboxStateLayer({
+ theme: LightTheme,
+ selected: false,
+ interaction: 'pressed',
+ ...custom,
+ })
+ ).toEqual({ color: 'rebeccapurple', opacity: pressed });
+ });
+
+ it('overrides error, matching the box', () => {
+ expect(
+ getCheckboxStateLayer({
+ theme: LightTheme,
+ selected: true,
+ error: true,
+ interaction: 'hovered',
+ ...custom,
+ })
+ ).toEqual({ color: 'rebeccapurple', opacity: hovered });
+ });
+
+ it('leaves the other side on its token role', () => {
+ expect(
+ getCheckboxStateLayer({
+ theme: LightTheme,
+ selected: true,
+ interaction: 'hovered',
+ customUncheckedColor: 'teal',
+ })
+ ).toEqual({ color: colors.primary, opacity: hovered });
+ });
+ });
+});
diff --git a/src/components/__tests__/DataTable.test.tsx b/src/components/__tests__/DataTable.test.tsx
index 7a29a72800..5cbbff1f48 100644
--- a/src/components/__tests__/DataTable.test.tsx
+++ b/src/components/__tests__/DataTable.test.tsx
@@ -81,7 +81,11 @@ describe('DataTable.Cell', () => {
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 9084841e9b..435361d17b 100644
--- a/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap
+++ b/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap
@@ -119,84 +119,80 @@ exports[`DataTable.Cell renders data table cell children without wrapping text c
testID="table-cell"
>
-
+
-
+
+
+ >
+
+
diff --git a/src/utils/useSpaceKeyPress.ts b/src/utils/useSpaceKeyPress.ts
new file mode 100644
index 0000000000..6f9a9e0bb1
--- /dev/null
+++ b/src/utils/useSpaceKeyPress.ts
@@ -0,0 +1,98 @@
+import * as React from 'react';
+import {
+ type GestureResponderEvent,
+ type NativeSyntheticEvent,
+ Platform,
+ type TargetedEvent,
+} 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?: (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 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,
+ onPressIn,
+ onPressOut,
+}: Options) => {
+ // 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;
+
+ /** 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(() => {
+ 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) => {
+ endPress(event);
+ }
+ );
+
+ const keyHandlers = enabled
+ ? {
+ onKeyDown: (event: KeyboardEvent) => {
+ if (!isSpaceKey(event)) return;
+
+ event.preventDefault();
+ if (event.nativeEvent.repeat) return;
+
+ pressInEventRef.current = event;
+ onPressIn?.(asPressEvent(event));
+ },
+ onKeyUp: (event: KeyboardEvent) => {
+ if (!isSpaceKey(event)) return;
+
+ event.preventDefault();
+ if (endPress(event)) onPress(asPressEvent(event));
+ },
+ }
+ : undefined;
+
+ return { keyHandlers, onBlur };
+};