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 }; +};