diff --git a/src/components/Card/Card.tsx b/src/components/Card/Card.tsx index c1cf7ee19c..6b9d164469 100644 --- a/src/components/Card/Card.tsx +++ b/src/components/Card/Card.tsx @@ -17,6 +17,7 @@ import { getCardColors } from './utils'; import { useInternalTheme } from '../../core/theming'; import type { Elevation, ThemeProp } from '../../theme/types'; import hasTouchHandler from '../../utils/hasTouchHandler'; +import { useFocusRing } from '../../utils/useFocusRing'; import Surface from '../Surface'; import type { SurfaceStyle } from '../Surface'; @@ -148,7 +149,10 @@ const Card = ({ ...rest }: (OutlinedCardProps | ElevatedCardProps | ContainedCardProps) & Props) => { const theme = useInternalTheme(themeOverrides); - + const { target: focusTarget, ring: focusRing } = useFocusRing( + disabled, + theme.colors.secondary + ); const isMode = React.useCallback( (modeToCompare: Mode) => { return cardMode === modeToCompare; @@ -252,6 +256,10 @@ const Card = ({ onPress={onPress} onPressIn={handlePressIn} onPressOut={handlePressOut} + onFocus={focusTarget.onFocus} + onBlur={focusTarget.onBlur} + {...focusRing.dataSetProps} + style={[{ borderRadius }, ...focusRing.style]} > {content} diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx index 3bccccf33b..a404ca8f5f 100644 --- a/src/components/Checkbox/Checkbox.tsx +++ b/src/components/Checkbox/Checkbox.tsx @@ -3,9 +3,7 @@ import { Platform, StyleSheet, View } from 'react-native'; import type { ColorValue, GestureResponderEvent, - NativeSyntheticEvent, StyleProp, - TargetedEvent, ViewStyle, } from 'react-native'; @@ -16,9 +14,8 @@ import { getSelectionVisualState } from './utils'; import { useLocale } from '../../core/locale'; import { useInternalTheme } from '../../core/theming'; import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; -import { tokens } from '../../theme/tokens'; import type { ThemeProp } from '../../theme/types'; -import { isKeyboardFocusEvent } from '../../utils/isKeyboardFocusEvent'; +import getMinInteractiveSizeHitSlop from '../../utils/getMinInteractiveSizeHitSlop'; import TouchableRipple from '../TouchableRipple/TouchableRipple'; import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple'; @@ -76,13 +73,12 @@ const { stateLayerSize: STATE_LAYER_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; +// The state layer is fixed, so the slop to reach the 48dp minimum +// interactive target is a constant rather than something to measure. +const CHECKBOX_HIT_SLOP = getMinInteractiveSizeHitSlop({ + width: STATE_LAYER_SIZE, + height: STATE_LAYER_SIZE, +}); /** * Checkboxes allow the selection of multiple options from a set. @@ -128,7 +124,6 @@ const Checkbox = ({ // 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 selected = status === 'checked' || status === 'indeterminate'; @@ -202,19 +197,6 @@ const Checkbox = ({ } const showIndeterminate = nextGlyph === 'indeterminate'; - const handleFocus = React.useCallback( - (e: NativeSyntheticEvent) => { - if (disabled) return; - if (!isKeyboardFocusEvent(e)) return; - setFocused(true); - }, - [disabled] - ); - - const handleBlur = React.useCallback(() => { - setFocused(false); - }, []); - const checked: boolean | 'mixed' = status === 'indeterminate' ? 'mixed' : status === 'checked'; @@ -238,24 +220,13 @@ const Checkbox = ({ borderless centered onPress={onPress} - onFocus={handleFocus} - onBlur={handleBlur} disabled={disabled} {...accessibilityProps} testID={testID} - style={[ - styles.tapTarget, - Platform.OS === 'web' ? webNoOutline : undefined, - style, - ]} + hitSlop={rest.hitSlop ?? (disabled ? undefined : CHECKBOX_HIT_SLOP)} + style={[styles.tapTarget, style]} > - {focused && !disabled ? ( - - ) : null} & { ref?: React.Ref; }; +/** + * Room the chip reserves on its right for the close button, which fills all of + * it, so the body stops here and the two divide the chip. + * + * MD3 splits the same way and does not give a chip's trailing action 48dp; in + * material-web it is 24x24 with no expansion. This column is wider than that and + * gets no vertical expansion, so the strips above and below belong to the body + * and a near miss activates the chip rather than deleting it. + * @see https://github.com/material-components/material-web/blob/main/chips/internal/_trailing-icon.scss + */ +const CLOSE_AFFORDANCE_WIDTH = 34; + +/** + * Floor for the clamp below. The glyph is 18dp and sits 8dp from the right, so + * under this it hangs over the chip body, and part of the visible icon would + * activate the chip instead of removing it. + */ +const CLOSE_AFFORDANCE_MIN_WIDTH = 26; + +/** + * The container height is fixed by spec, so the slop to reach the 48dp minimum + * is a constant rather than something to measure. Width grows with the label + * and the whole pill is already the target, so only the vertical axis needs it. + */ +const { containerHeight: CHIP_BODY_HEIGHT } = ChipTokens; +const CHIP_BODY_HIT_SLOP = getMinInteractiveSizeHitSlop({ + height: CHIP_BODY_HEIGHT, +}); + /** * Chips are compact elements that can represent inputs, attributes, or actions. * They can have an icon or avatar on the left, and a close button icon on the right. @@ -207,6 +239,14 @@ const Chip = ({ ...rest }: Props) => { const theme = useInternalTheme(themeOverrides); + // The close affordance is a plain `Pressable`, not a `TouchableRipple` + // (see below), so it calls `useFocusRing` directly instead of going + // through `TouchableRipple`'s `focusRing` prop like the body does. + const { target: closeFocusTarget, ring: closeFocusRing } = useFocusRing( + disabled, + theme.colors.secondary, + 'inward' + ); const [pressed, setPressed] = React.useState(false); const elevation = elevated ? (pressed ? 2 : 1) : 0; @@ -265,7 +305,7 @@ const Chip = ({ }; const contentSpacings = { - paddingRight: onClose ? 34 : 0, + paddingRight: onClose ? CLOSE_AFFORDANCE_WIDTH : 0, }; const labelTextStyle = { @@ -286,6 +326,7 @@ const Chip = ({ > - + {closeIcon ? ( ) : ( @@ -423,6 +475,7 @@ const styles = StyleSheet.create({ }, md3Content: { paddingLeft: 0, + minHeight: CHIP_BODY_HEIGHT, }, icon: { padding: 4, @@ -438,6 +491,10 @@ const styles = StyleSheet.create({ md3CloseIcon: { marginRight: 8, padding: 0, + // `styles.icon` sets `alignSelf: 'center'`, which beats `alignItems` on the + // parent. Without this the glyph centres in the wider column and moves 4dp + // left. + alignSelf: 'flex-end', }, md3LabelText: { textAlignVertical: 'center', @@ -468,9 +525,19 @@ const styles = StyleSheet.create({ closeButtonStyle: { position: 'absolute', right: 0, + width: CLOSE_AFFORDANCE_WIDTH, + // A chip narrower than this column would hand the whole thing to the close + // button. Never more than half, never less than the glyph needs; minWidth + // wins over maxWidth. + minWidth: CLOSE_AFFORDANCE_MIN_WIDTH, + maxWidth: '50%', height: '100%', + }, + closeButton: { + width: '100%', + height: '100%', + // Vertical only. The glyph pins itself horizontally with `alignSelf`. justifyContent: 'center', - alignItems: 'center', }, touchable: { width: '100%', diff --git a/src/components/Chip/tokens.ts b/src/components/Chip/tokens.ts new file mode 100644 index 0000000000..772ef9d0e0 --- /dev/null +++ b/src/components/Chip/tokens.ts @@ -0,0 +1,7 @@ +/** + * MD3 Chip spec dimensions. + * @see https://m3.material.io/components/chips/specs + */ +export const ChipTokens = { + containerHeight: 32, +} as const; diff --git a/src/components/FAB/Menu.tsx b/src/components/FAB/Menu.tsx index 438d7c2f1a..2daf828809 100644 --- a/src/components/FAB/Menu.tsx +++ b/src/components/FAB/Menu.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Platform, StyleSheet, View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; import type { ColorValue, GestureResponderEvent } from 'react-native'; import Animated, { @@ -14,15 +14,8 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import Content from './Content'; import Shell from './Shell'; -import { - MenuTokens, - Tokens, - FOCUS_RING_INSET, - FOCUS_RING_THICKNESS, - webNoOutline, -} from './tokens'; +import { MenuTokens, Tokens } from './tokens'; import type { Size, Variant } from './tokens'; -import { useFocusRing } from './useFocusRing'; import { resolveColors } from './utils'; import { useLocale } from '../../core/locale'; import { useInternalTheme } from '../../core/theming'; @@ -30,6 +23,7 @@ import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; import { toRawSpring } from '../../theme/tokens/sys/motion'; import type { InternalTheme, ThemeProp } from '../../theme/types'; import { resolveCornerRadius } from '../../theme/utils/shape'; +import { useFocusRing } from '../../utils/useFocusRing'; import Icon from '../Icon'; import type { IconSource } from '../Icon'; import TouchableRipple from '../TouchableRipple/TouchableRipple'; @@ -249,10 +243,17 @@ const MenuItem = ({ MenuTokens.listItem; const borderRadius = resolveCornerRadius(theme, shape); - const { focusedSV, onFocus, onBlur } = useFocusRing(); - const focusRingStyle = useAnimatedStyle(() => ({ - opacity: focusedSV.value ? 1 : 0, - })); + // `scope: 'within'`: the ring belongs on the pill below, not the inner + // `TouchableRipple` that actually receives focus. + // + // `undefined` disabled: menu items have no `disabled` prop today. Wire the + // real value through here if that ever changes. + const { target: focusTarget, ring: focusRing } = useFocusRing( + undefined, + theme.colors.secondary, + 'outward', + 'within' + ); return ( @@ -260,19 +261,19 @@ const MenuItem = ({ style={[ styles.menuItem, { height, borderRadius, backgroundColor: colors.container }, + ...focusRing.style, ]} + {...focusRing.dataSetProps} > - ); }; @@ -687,15 +678,6 @@ const styles = StyleSheet.create({ menuItem: { overflow: 'hidden', }, - menuItemFocusRing: { - position: 'absolute', - top: -FOCUS_RING_INSET, - left: -FOCUS_RING_INSET, - right: -FOCUS_RING_INSET, - bottom: -FOCUS_RING_INSET, - borderWidth: FOCUS_RING_THICKNESS, - pointerEvents: 'none', - }, triggerSlot: { justifyContent: 'flex-start', }, diff --git a/src/components/FAB/Shell.tsx b/src/components/FAB/Shell.tsx index 6a07babb47..d6beb1e2bb 100644 --- a/src/components/FAB/Shell.tsx +++ b/src/components/FAB/Shell.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Platform, StyleSheet, View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; import type { ColorValue, GestureResponderEvent, @@ -17,20 +17,15 @@ import type { SharedValue } from 'react-native-reanimated'; import type { AnimatedStyle } from 'react-native-reanimated'; import Content from './Content'; -import { - Tokens, - FOCUS_RING_INSET, - FOCUS_RING_THICKNESS, - webNoOutline, -} from './tokens'; +import { Tokens } from './tokens'; import type { Size, Variant } from './tokens'; -import { useFocusRing } from './useFocusRing'; import { getDimensions, resolveColors } from './utils'; import { useInternalTheme } from '../../core/theming'; import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; import { toRawSpring } from '../../theme/tokens/sys/motion'; import type { Elevation, ThemeProp } from '../../theme/types'; import type { ShapeToken } from '../../theme/utils/shape'; +import { useFocusRing } from '../../utils/useFocusRing'; import type { IconSource } from '../Icon'; import Surface from '../Surface'; import TouchableRipple from '../TouchableRipple/TouchableRipple'; @@ -297,14 +292,18 @@ const Shell = ({ [borderRadius, containerBg] ); - const { focusedSV, onFocus, onBlur } = useFocusRing(); - - const focusRingStyle = useAnimatedStyle( - () => ({ - opacity: focusedSV.value ? 1 : 0, - borderRadius: borderRadius.value + FOCUS_RING_INSET, - }), - [borderRadius] + // `scope: 'within'`: the ring is drawn on the clip view below, not the + // inner `TouchableRipple` that actually receives focus - `target.style` + // suppresses the browser's own outline there on web, so only the clip + // view's ring shows. + // + // `undefined` disabled: FAB has no `disabled` prop today. Wire the real + // value through here if that ever changes. + const { target: focusTarget, ring: focusRing } = useFocusRing( + undefined, + theme.colors.secondary, + 'outward', + 'within' ); return ( @@ -322,14 +321,18 @@ const Shell = ({ testID={testID ? `${testID}-container` : undefined} theme={theme} > - + {overlay} {children ?? ( - ); }; @@ -391,15 +384,6 @@ const styles = StyleSheet.create({ pointerEventsNone: { pointerEvents: 'none', }, - focusRing: { - position: 'absolute', - top: -FOCUS_RING_INSET, - left: -FOCUS_RING_INSET, - right: -FOCUS_RING_INSET, - bottom: -FOCUS_RING_INSET, - borderWidth: FOCUS_RING_THICKNESS, - pointerEvents: 'none', - }, }); export default Shell; diff --git a/src/components/FAB/tokens.ts b/src/components/FAB/tokens.ts index 0fb79d1d9c..4188bc340a 100644 --- a/src/components/FAB/tokens.ts +++ b/src/components/FAB/tokens.ts @@ -1,6 +1,3 @@ -import type { ViewStyle } from 'react-native'; - -import { tokens } from '../../theme/tokens'; import type { ColorRole, Elevation, @@ -119,11 +116,3 @@ export const MenuTokens = { listItem, spacing, }; - -const focusIndicator = tokens.md.sys.state.focusIndicator; -export const FOCUS_RING_THICKNESS = focusIndicator.thickness; -export const FOCUS_RING_OUTER_OFFSET = focusIndicator.outerOffset; -export const FOCUS_RING_INSET = FOCUS_RING_OUTER_OFFSET + FOCUS_RING_THICKNESS; - -// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -export const webNoOutline = { outline: 'none' } as unknown as ViewStyle; diff --git a/src/components/FAB/useFocusRing.ts b/src/components/FAB/useFocusRing.ts deleted file mode 100644 index bb056a10c8..0000000000 --- a/src/components/FAB/useFocusRing.ts +++ /dev/null @@ -1,41 +0,0 @@ -import * as React from 'react'; -import { Platform } from 'react-native'; - -import { useSharedValue, type SharedValue } from 'react-native-reanimated'; - -export type FocusRingState = { - /** - * `true` when the surface is keyboard-focused. Drive the focus ring's - * `opacity` from this in a `useAnimatedStyle`. - */ - focusedSV: SharedValue; - /** Wire to the `Pressable`/`TouchableRipple`'s `onFocus`. */ - onFocus: () => void; - /** Wire to the `Pressable`/`TouchableRipple`'s `onBlur`. */ - onBlur: () => void; -}; - -/** - * Drives an MD3 focus indicator for FAB-flavored surfaces. On web, focus is - * gated by `:focus-visible` so a mouse click does not light the ring; on - * native, every focus event is honored. - */ -export function useFocusRing(): FocusRingState { - const focusedSV = useSharedValue(false); - - const onFocus = React.useCallback(() => { - if ( - Platform.OS === 'web' && - !document.activeElement?.matches(':focus-visible') - ) { - return; - } - focusedSV.value = true; - }, [focusedSV]); - - const onBlur = React.useCallback(() => { - focusedSV.value = false; - }, [focusedSV]); - - return { focusedSV, onFocus, onBlur }; -} diff --git a/src/components/IconButton/IconButton.tsx b/src/components/IconButton/IconButton.tsx index a55c75e6c6..b42463434c 100644 --- a/src/components/IconButton/IconButton.tsx +++ b/src/components/IconButton/IconButton.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { StyleSheet, View } from 'react-native'; +import { Platform, StyleSheet, View } from 'react-native'; import type { ColorValue, GestureResponderEvent, @@ -12,6 +12,7 @@ import Animated, { type AnimatedStyle } from 'react-native-reanimated'; import { getIconButtonColor } from './utils'; import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../theme/types'; +import getMinInteractiveSizeHitSlop from '../../utils/getMinInteractiveSizeHitSlop'; import ActivityIndicator from '../ActivityIndicator'; import CrossFadeIcon from '../CrossFadeIcon'; import Icon from '../Icon'; @@ -74,6 +75,21 @@ export type Props = Omit< * Function to execute on press. */ onPress?: (e: GestureResponderEvent) => void; + /** + * Radius of every corner of the button. Defaults to a circle (half of the + * button's size). Read as a plain prop rather than out of `style`, since + * `style` may be an animated value on the UI thread that a synchronous + * `StyleSheet.flatten` cannot see. + */ + borderRadius?: number; + borderTopLeftRadius?: number; + borderTopRightRadius?: number; + borderBottomLeftRadius?: number; + borderBottomRightRadius?: number; + borderTopStartRadius?: number; + borderTopEndRadius?: number; + borderBottomStartRadius?: number; + borderBottomEndRadius?: number; style?: StyleProp>; ref?: React.Ref; /** @@ -128,6 +144,15 @@ const IconButton = ({ testID, loading = false, contentStyle, + borderRadius, + borderTopLeftRadius, + borderTopRightRadius, + borderBottomLeftRadius, + borderBottomRightRadius, + borderTopStartRadius, + borderTopEndRadius, + borderBottomStartRadius, + borderBottomEndRadius, ref, ...rest }: Props) => { @@ -151,13 +176,34 @@ const IconButton = ({ }); const buttonSize = size + 2 * PADDING; + const borderWidth = mode === 'outlined' && !selected ? 1 : 0; + + const shapeStyles = { + borderRadius: borderRadius ?? buttonSize / 2, + borderTopLeftRadius, + borderTopRightRadius, + borderBottomLeftRadius, + borderBottomRightRadius, + borderTopStartRadius, + borderTopEndRadius, + borderBottomStartRadius, + borderBottomEndRadius, + }; const borderStyles = { - borderWidth: mode === 'outlined' && !selected ? 1 : 0, - borderRadius: buttonSize / 2, + borderWidth, borderColor, + ...shapeStyles, }; + // Computed straight from `size`, a plain prop known at render time, rather + // than measured. `buttonSize` never changes after mount without `size` also + // changing, so there is nothing to react to. A disabled button gets no + // slop of its own, only what a caller's own `hitSlop` in `rest` supplies. + const hitSlop = disabled + ? undefined + : getMinInteractiveSizeHitSlop({ width: buttonSize, height: buttonSize }); + return ( )} @@ -187,16 +234,20 @@ const IconButton = ({ centered onPress={onPress} aria-label={ariaLabel} - style={[styles.touchable, contentStyle]} + style={[ + styles.touchable, + shapeStyles, + // The Surface used to clip the ripple, so the touchable does it now. + // Native only: its own overflow does not clip its hitSlop, but on web + // it would clip the touch target, where the container already clips. + Platform.OS !== 'web' && styles.clipToShape, + contentStyle, + ]} role="button" aria-disabled={disabled} disabled={disabled} - hitSlop={ - TouchableRipple.supported - ? { top: 10, left: 10, bottom: 10, right: 10 } - : { top: 6, left: 6, bottom: 6, right: 6 } - } testID={testID} + hitSlop={hitSlop} {...rest} > @@ -218,14 +269,19 @@ const IconButton = ({ const styles = StyleSheet.create({ container: { + // No `overflow: 'hidden'`. An ancestor that clips also clips the touch + // target, which is why the hitSlop this component used to pass never + // applied. The overlay and the touchable clip themselves instead. margin: 6, - overflow: 'hidden', }, touchable: { flexGrow: 1, justifyContent: 'center', alignItems: 'center', }, + clipToShape: { + overflow: 'hidden', + }, }); export default IconButton; diff --git a/src/components/List/ListItem.tsx b/src/components/List/ListItem.tsx index 740b3df5d2..0c35ee84b3 100644 --- a/src/components/List/ListItem.tsx +++ b/src/components/List/ListItem.tsx @@ -233,6 +233,7 @@ const ListItem = ({ , 'children' @@ -146,6 +157,9 @@ const RadioButtonAndroid = ({ style={styles.container} testID={testID} theme={theme} + hitSlop={ + rest.hitSlop ?? (disabled ? undefined : RADIO_BUTTON_HIT_SLOP) + } > , 'children' @@ -104,12 +116,15 @@ const RadioButtonIOS = ({ style={styles.container} testID={testID} theme={theme} + hitSlop={ + rest.hitSlop ?? (disabled ? undefined : RADIO_BUTTON_HIT_SLOP) + } > @@ -125,8 +140,9 @@ RadioButtonIOS.displayName = 'RadioButton.IOS'; const styles = StyleSheet.create({ container: { - borderRadius: 18, - padding: 6, + borderRadius: STATE_LAYER_SIZE / 2, + // Centres the 24dp checkmark within the 40dp state layer. + padding: (STATE_LAYER_SIZE - CHECKMARK_SIZE) / 2, }, }); diff --git a/src/components/RadioButton/RadioButtonItem.tsx b/src/components/RadioButton/RadioButtonItem.tsx index 71f7aaec23..d13e68efa2 100644 --- a/src/components/RadioButton/RadioButtonItem.tsx +++ b/src/components/RadioButton/RadioButtonItem.tsx @@ -195,6 +195,7 @@ const RadioButtonItem = ({ }) === 'checked'; return ( handlePress({ onPress: onPress, diff --git a/src/components/RadioButton/tokens.ts b/src/components/RadioButton/tokens.ts new file mode 100644 index 0000000000..a3abdfe3e0 --- /dev/null +++ b/src/components/RadioButton/tokens.ts @@ -0,0 +1,7 @@ +/** + * MD3 Radio button spec dimensions. + * @see https://m3.material.io/components/radio-button/specs + */ +export const RadioButtonTokens = { + stateLayerSize: 40, +} as const; diff --git a/src/components/SegmentedButtons/SegmentedButtonItem.tsx b/src/components/SegmentedButtons/SegmentedButtonItem.tsx index 4b851de75a..d4c4d3b836 100644 --- a/src/components/SegmentedButtons/SegmentedButtonItem.tsx +++ b/src/components/SegmentedButtons/SegmentedButtonItem.tsx @@ -23,6 +23,7 @@ import { import { useInternalTheme } from '../../core/theming'; import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; import type { ThemeProp } from '../../theme/types'; +import getMinInteractiveSizeHitSlop from '../../utils/getMinInteractiveSizeHitSlop'; import type { IconSource } from '../Icon'; import Icon from '../Icon'; import TouchableRipple from '../TouchableRipple/TouchableRipple'; @@ -193,6 +194,14 @@ const SegmentedButtonItem = ({ const paddingVertical = getSegmentedButtonDensityPadding({ density }); + // Height is `2 * paddingVertical + content`, and content is never shorter + // than the 18dp icon (the label's own line height is taller), so that is + // the safe floor to compute slop from without needing to measure. + const contentHeight = 2 * paddingVertical + iconSize; + const defaultHitSlop = disabled + ? undefined + : getMinInteractiveSizeHitSlop({ height: contentHeight }); + const rippleStyle: ViewStyle = { borderRadius, ...segmentBorderRadius, @@ -207,6 +216,7 @@ const SegmentedButtonItem = ({ { + if (isDisabled) { + focusedSV.value = 0; + } + }, [isDisabled, focusedSV]); const checkedSV = useSharedValue(checked ? 1 : 0); const hasIconSV = useSharedValue(hasIcon ? 1 : 0); const isDisabledSV = useSharedValue(isDisabled ? 1 : 0); @@ -166,6 +177,16 @@ const Switch = ({ const colors = React.useMemo(() => getDefaultSwitchColors(theme), [theme]); + // `scope: 'within'`: the ring is drawn on the track below, not this + // Pressable - it also suppresses the browser's own outline here on web, so + // only the track's ring shows. + const { target: focusTarget, ring: focusRing } = useFocusRing( + isDisabled, + colors.focusIndicatorColor, + 'outward', + 'within' + ); + const reanimatedReduceMotion = reduceMotion ? ReduceMotion.Always : ReduceMotion.Never; @@ -323,10 +344,6 @@ const Switch = ({ ], })); - const focusRingAnimatedStyle = useAnimatedStyle(() => ({ - opacity: focusedSV.value, - })); - const paint = resolveSwitchPaint(colors, isEnabled, checked); const stateLayerColor = checked ? colors.checkedStateLayerColor @@ -363,10 +380,14 @@ const Switch = ({ hoveredSV.value = 0; }} onFocus={(e) => { - if (!isKeyboardFocusEvent(e)) return; - focusedSV.value = 1; + // Not the ring - it's real CSS on web now. This drives the + // handle's own separate focused-visual animation, native and web + // alike, so it keeps its own keyboard-vs-pointer check. + focusTarget.onFocus?.(e); + if (!isDisabled && isKeyboardFocusEvent(e)) focusedSV.value = 1; }} onBlur={() => { + focusTarget.onBlur?.(); focusedSV.value = 0; }} android_ripple={{ color: 'transparent' }} @@ -375,16 +396,26 @@ const Switch = ({ aria-checked={checked} aria-label={ariaLabel} testID={testID} - style={[ - styles.touchable, - Platform.OS === 'web' ? webNoOutline : undefined, - ]} + hitSlop={isDisabled ? undefined : SWITCH_HIT_SLOP} + style={[styles.touchable, ...focusTarget.style]} > + {/* react-native-web removed `hitSlop` in 0.13.0 (same as + TouchableRipple), so web needs a real element the browser can + hit-test instead of a native responder inset. */} + {Platform.OS === 'web' && !isDisabled && ( + + )} {showOutline ? ( @@ -446,22 +477,6 @@ const Switch = ({ ) : null} - - ); }; @@ -479,6 +494,14 @@ const styles = StyleSheet.create({ height: STATE_LAYER_SIZE, alignItems: 'center', justifyContent: 'center', + ...(Platform.OS === 'web' && { position: 'relative' }), + }, + webTouchTarget: { + position: 'absolute', + top: -SWITCH_HIT_SLOP_INSET, + bottom: -SWITCH_HIT_SLOP_INSET, + left: 0, + right: 0, }, track: { width: TRACK_WIDTH, @@ -525,10 +548,6 @@ const styles = StyleSheet.create({ height: SELECTED_ICON, pointerEvents: 'none', }, - focusRing: { - position: 'absolute', - pointerEvents: 'none', - }, absoluteFill: { position: 'absolute', top: 0, @@ -538,8 +557,4 @@ const styles = StyleSheet.create({ }, }); -// Web-only style; not in StyleSheet because `outline` is outside ViewStyle. -// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -const webNoOutline = { outline: 'none' } as unknown as ViewStyle; - export default Switch; diff --git a/src/components/TouchableRipple/TouchableRipple.native.tsx b/src/components/TouchableRipple/TouchableRipple.native.tsx index e86ad49c23..376098d9a5 100644 --- a/src/components/TouchableRipple/TouchableRipple.native.tsx +++ b/src/components/TouchableRipple/TouchableRipple.native.tsx @@ -6,6 +6,8 @@ import type { ViewStyle, GestureResponderEvent, ColorValue, + NativeSyntheticEvent, + TargetedEvent, } from 'react-native'; import type { PressableProps } from './Pressable'; @@ -16,15 +18,66 @@ import type { Settings } from '../../core/settings'; import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../theme/types'; import hasTouchHandler from '../../utils/hasTouchHandler'; +import type { FocusRingPlacement } from '../../utils/useFocusRing'; +import { useFocusRing } from '../../utils/useFocusRing'; const ANDROID_VERSION_LOLLIPOP = 21; const ANDROID_VERSION_PIE = 28; +/** + * The underlay fills the touchable absolutely and has no radius of its own, so + * it paints square corners over a rounded one. A clipping ancestor used to hide + * that, and those ancestors have to stop clipping to reach into the `hitSlop`. + */ +const getUnderlayShape = (style: StyleProp): ViewStyle => { + const flat = StyleSheet.flatten(style); + + if (!flat) { + return {}; + } + + const { + borderRadius, + borderTopLeftRadius, + borderTopRightRadius, + borderBottomLeftRadius, + borderBottomRightRadius, + borderTopStartRadius, + borderTopEndRadius, + borderBottomStartRadius, + borderBottomEndRadius, + } = flat; + + return { + borderRadius, + borderTopLeftRadius, + borderTopRightRadius, + borderBottomLeftRadius, + borderBottomRightRadius, + borderTopStartRadius, + borderTopEndRadius, + borderBottomStartRadius, + borderBottomEndRadius, + }; +}; + export type Props = PressableProps & { borderless?: boolean; background?: PressableAndroidRippleConfig; centered?: boolean; disabled?: boolean; + /** + * Where to draw the MD3 keyboard focus indicator. + * + * - `outward` - just outside the bounds. The MD3 default. + * - `inward` - just inside, for controls a clipping ancestor would trim or + * that sit flush against a neighbour. + * - `none` - no indicator. Only for a control that draws its own. + * + * Has no effect on iOS today - see `useFocusRing`'s doc comment for why + * (`enableImperativeFocus`, off by default). + */ + focusRing?: FocusRingPlacement; onPress?: (e: GestureResponderEvent) => void | null; onLongPress?: (e: GestureResponderEvent) => void; onPressIn?: (e: GestureResponderEvent) => void; @@ -46,6 +99,10 @@ const TouchableRipple = ({ underlayColor, children, theme: themeOverrides, + hitSlop, + focusRing = 'outward', + onFocus, + onBlur, ref, ...rest }: Props) => { @@ -63,6 +120,23 @@ const TouchableRipple = ({ const disabled = disabledProp || !hasPassedTouchHandler; + // Keyed off `disabledProp`, not `disabled`: the latter also folds in + // "no press handler passed", which is a non-interactivity signal, not a + // disabled one - the ring should only react to real disablement. + const { target, ring } = useFocusRing( + disabledProp, + theme.colors.secondary, + focusRing + ); + const handleFocus = (e: NativeSyntheticEvent) => { + onFocus?.(e); + target.onFocus?.(e); + }; + const handleBlur = (e: NativeSyntheticEvent) => { + onBlur?.(e); + target.onBlur?.(); + }; + const { calculatedRippleColor, calculatedUnderlayColor } = getTouchableRippleColors({ theme, @@ -92,7 +166,10 @@ const TouchableRipple = ({ {...rest} ref={ref} disabled={disabled} - style={[useForeground && styles.overflowHidden, style]} + hitSlop={hitSlop} + onFocus={handleFocus} + onBlur={handleBlur} + style={[useForeground && styles.overflowHidden, style, ...ring.style]} android_ripple={androidRipple} > {React.Children.only(children)} @@ -105,7 +182,10 @@ const TouchableRipple = ({ {...rest} ref={ref} disabled={disabled} - style={[borderless && styles.overflowHidden, style]} + hitSlop={hitSlop} + onFocus={handleFocus} + onBlur={handleBlur} + style={[borderless && styles.overflowHidden, style, ...ring.style]} > {({ pressed }) => ( <> @@ -114,6 +194,7 @@ const TouchableRipple = ({ testID="touchable-ripple-underlay" style={[ styles.underlay, + getUnderlayShape(style), { backgroundColor: calculatedUnderlayColor }, ]} /> diff --git a/src/components/TouchableRipple/TouchableRipple.tsx b/src/components/TouchableRipple/TouchableRipple.tsx index 128e2c017e..59a26b3836 100644 --- a/src/components/TouchableRipple/TouchableRipple.tsx +++ b/src/components/TouchableRipple/TouchableRipple.tsx @@ -17,10 +17,48 @@ import type { Settings } from '../../core/settings'; import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../theme/types'; import hasTouchHandler from '../../utils/hasTouchHandler'; +import type { FocusRingPlacement } from '../../utils/useFocusRing'; +import { useFocusRing } from '../../utils/useFocusRing'; + +/** + * react-native-web removed `hitSlop` in 0.13.0, so web needs a real element the + * browser can hit-test instead of a native responder inset. + * @see https://github.com/necolas/react-native-web/releases/tag/0.13.0 + */ +const getTouchTargetStyle = (hitSlop: PressableProps['hitSlop']): ViewStyle => { + // `undefined` or `null` both mean no slop: nothing for the caller to opt + // into, so the target matches the touchable's own bounds. + if (hitSlop === undefined || hitSlop === null) { + return styles.noTouchTarget; + } + + const inset = (value: number | undefined) => -(value ?? 0); + + return typeof hitSlop === 'number' + ? { + position: 'absolute', + top: inset(hitSlop), + bottom: inset(hitSlop), + left: inset(hitSlop), + right: inset(hitSlop), + } + : { + position: 'absolute', + top: inset(hitSlop.top), + bottom: inset(hitSlop.bottom), + left: inset(hitSlop.left), + right: inset(hitSlop.right), + }; +}; export type Props = PressableProps & { /** * Whether to render the ripple outside the view bounds. + * + * On web the ripple is bounded by its own container, so this no longer clips + * the touchable's content. The touchable cannot clip without clipping the + * touch target, so children needing a rounded shape carry the radius + * themselves. */ borderless?: boolean; /** @@ -36,6 +74,18 @@ export type Props = PressableProps & { * Whether to prevent interaction with the touchable. */ disabled?: boolean; + /** + * Where to draw the MD3 keyboard focus indicator. + * + * - `outward` - just outside the bounds. The MD3 default. + * - `inward` - just inside, for controls a clipping ancestor would trim or + * that sit flush against a neighbour. + * - `none` - no indicator. Only for a control that draws its own. + * + * Has no effect on iOS today - see `useFocusRing`'s doc comment for why + * (`enableImperativeFocus`, off by default). + */ + focusRing?: FocusRingPlacement; /** * Function to execute on press. If not set, will cause the touchable to be disabled. */ @@ -105,12 +155,15 @@ export type Props = PressableProps & { const TouchableRipple = ({ style, background: _background, - borderless = false, + // consumed so it does not reach the DOM; the ripple container clips regardless + borderless: _borderless = false, disabled: disabledProp, rippleColor, underlayColor: _underlayColor, children, theme: themeOverrides, + hitSlop, + focusRing = 'outward', ref, ...rest }: Props) => { @@ -178,7 +231,16 @@ const TouchableRipple = ({ borderTopRightRadius: style.borderTopRightRadius, borderBottomRightRadius: style.borderBottomRightRadius, borderBottomLeftRadius: style.borderBottomLeftRadius, - overflow: centered ? 'visible' : 'hidden', + // The touchable cannot clip, it would clip the touch target too, so + // the ripple is contained here. This container is inset to the + // touchable and copies its radii, so it clips to the same shape. + // + // Always, not `centered ? 'visible' : 'hidden'` as before. A ripple + // that escaped used to be caught by whichever ancestor clipped, and + // those ancestors have to stop. ToggleButton hit this: it passes + // `borderless={false}` to IconButton, which spreads it over its own, + // so the Surface was holding the ripple in. + overflow: 'hidden', }); // Create span to show the ripple effect @@ -273,6 +335,20 @@ const TouchableRipple = ({ const disabled = disabledProp || !hasPassedTouchHandler; + // No JS focus tracking here: the ring is real CSS, driven by the browser's + // own `:focus-visible`, keyed off the `data-focus-ring` attribute spread + // below. `onFocus`/`onBlur` reach the caller unmodified via `rest`, nothing + // to intercept. + // + // Keyed off `disabledProp`, not `disabled`: the latter also folds in + // "no press handler passed", which is a non-interactivity signal, not a + // disabled one - the ring should only react to real disablement. + const { ring } = useFocusRing( + disabledProp, + theme.colors.secondary, + focusRing + ); + return ( [ styles.touchable, - borderless && styles.borderless, - // focused state is not ready yet: https://github.com/necolas/react-native-web/issues/1849 - // state.focused && { backgroundColor: ___ }, + // RNW's own `state.focused` fires for mouse clicks too, which is + // exactly the distinction `:focus-visible` exists to make. + // https://github.com/necolas/react-native-web/issues/1849 state.hovered && { backgroundColor: hoverColor }, disabled && styles.disabled, typeof style === 'function' ? style(state) : style, + ...ring.style, ]} > - {(state) => - React.Children.only( - typeof children === 'function' ? children(state) : children - ) - } + {(state) => ( + <> + {/* Before the children, not after. It hit-tests, so as the last + sibling it covers anything interactive inside the touchable and + takes its presses, e.g. a pressable List.Item with a control in + `right`. Ahead of them it still covers the area outside the + touchable, where there is nothing else to hit. + Nothing that cannot be pressed gets a target, same as native. */} + {!disabled && ( + + )} + {React.Children.only( + typeof children === 'function' ? children(state) : children + )} + + )} ); }; @@ -317,8 +410,12 @@ const styles = StyleSheet.create({ cursor: 'auto', }), }, - borderless: { - overflow: 'hidden', + noTouchTarget: { + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + right: 0, }, }); diff --git a/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap b/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap index ad5805e1e6..e17dbda7e7 100644 --- a/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap +++ b/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap @@ -194,7 +194,6 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -202,8 +201,16 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -235,10 +242,10 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } onBlur={[Function]} @@ -262,6 +269,20 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -360,7 +381,6 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -368,8 +388,16 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -401,10 +429,10 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } onBlur={[Function]} @@ -428,6 +456,20 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -582,7 +624,6 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -590,8 +631,16 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -623,10 +672,10 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } onBlur={[Function]} @@ -650,6 +699,20 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A "flexGrow": 1, "justifyContent": "center", }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -805,7 +868,6 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -813,8 +875,16 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -845,10 +915,10 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } onBlur={[Function]} @@ -872,6 +942,20 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A "flexGrow": 1, "justifyContent": "center", }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "overflow": "hidden", + }, undefined, ], ] diff --git a/src/components/__tests__/Checkbox/Checkbox.test.tsx b/src/components/__tests__/Checkbox/Checkbox.test.tsx index a72200bb4e..fa9c520ebb 100644 --- a/src/components/__tests__/Checkbox/Checkbox.test.tsx +++ b/src/components/__tests__/Checkbox/Checkbox.test.tsx @@ -1,6 +1,6 @@ import { expect, it } from '@jest/globals'; -import { render } from '../../../test-utils'; +import { render, screen } from '../../../test-utils'; import Checkbox from '../../Checkbox'; it('renders checked Checkbox with onPress', async () => { @@ -58,3 +58,23 @@ it('renders Checkbox with custom testID', async () => { expect(tree).toMatchSnapshot(); }); + +it('expands hitSlop up to the 48dp minimum when enabled', async () => { + await render(); + + // (48 - 40) / 2 on every side + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('checkbox').props.hitSlop).toEqual({ + top: 4, + bottom: 4, + left: 4, + right: 4, + }); +}); + +it('gives a disabled Checkbox no hitSlop of its own', async () => { + await render(); + + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('checkbox').props.hitSlop).toBeUndefined(); +}); diff --git a/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap b/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap index 202a95467a..b4f716826c 100644 --- a/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap +++ b/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap @@ -24,6 +24,14 @@ exports[`renders Checkbox with custom testID 1`] = ` centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -48,7 +56,6 @@ exports[`renders Checkbox with custom testID 1`] = ` "width": 40, }, undefined, - undefined, ], ] } @@ -209,6 +216,14 @@ exports[`renders checked Checkbox with color 1`] = ` centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -233,7 +248,6 @@ exports[`renders checked Checkbox with color 1`] = ` "width": 40, }, undefined, - undefined, ], ] } @@ -393,6 +407,14 @@ exports[`renders checked Checkbox with onPress 1`] = ` centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -417,7 +439,6 @@ exports[`renders checked Checkbox with onPress 1`] = ` "width": 40, }, undefined, - undefined, ], ] } @@ -577,6 +598,14 @@ exports[`renders indeterminate Checkbox 1`] = ` centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -601,7 +630,6 @@ exports[`renders indeterminate Checkbox 1`] = ` "width": 40, }, undefined, - undefined, ], ] } @@ -748,6 +776,14 @@ exports[`renders indeterminate Checkbox with color 1`] = ` centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -772,7 +808,6 @@ exports[`renders indeterminate Checkbox with color 1`] = ` "width": 40, }, undefined, - undefined, ], ] } @@ -919,6 +954,14 @@ exports[`renders unchecked Checkbox with color 1`] = ` centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -943,7 +986,6 @@ exports[`renders unchecked Checkbox with color 1`] = ` "width": 40, }, undefined, - undefined, ], ] } @@ -1103,6 +1145,14 @@ exports[`renders unchecked Checkbox with onPress 1`] = ` centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -1127,7 +1177,6 @@ exports[`renders unchecked Checkbox with onPress 1`] = ` "width": 40, }, undefined, - undefined, ], ] } diff --git a/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap b/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap index bc5b884910..770e4ef105 100644 --- a/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap +++ b/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap @@ -78,6 +78,14 @@ exports[`can render leading checkbox control 1`] = ` centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -101,7 +109,6 @@ exports[`can render leading checkbox control 1`] = ` "width": 40, }, undefined, - undefined, ], ] } @@ -391,6 +398,14 @@ exports[`renders unchecked 1`] = ` centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -414,7 +429,6 @@ exports[`renders unchecked 1`] = ` "width": 40, }, undefined, - undefined, ], ] } diff --git a/src/components/__tests__/Chip.test.tsx b/src/components/__tests__/Chip.test.tsx index 87f54dba4e..bc7249120e 100644 --- a/src/components/__tests__/Chip.test.tsx +++ b/src/components/__tests__/Chip.test.tsx @@ -95,6 +95,35 @@ it('renders chip with zero border radius', async () => { }); }); +it('expands hitSlop up to the 48dp minimum when enabled', async () => { + await render( + {}}> + Active chip + + ); + + // (48 - 32) / 2 top/bottom, none horizontally: the pill's width already + // covers it + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('active-chip').props.hitSlop).toEqual({ + top: 8, + bottom: 8, + left: 0, + right: 0, + }); +}); + +it('gives a disabled Chip no hitSlop of its own', async () => { + await render( + {}}> + Disabled chip + + ); + + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('disabled-chip').props.hitSlop).toBeUndefined(); +}); + describe('getChipColors - text color', () => { it('should return correct disabled color, for theme version 3', () => { expect( @@ -309,3 +338,41 @@ describe('getChipColor - border color', () => { }); }); }); + +describe('close affordance', () => { + // The chip already reserved room on its right, but only the icon was tappable, + // so the body owned the rest of that column. MD3 has the primary action stop + // where the trailing one starts. + it('fills the column the chip reserves for it', async () => { + await render( + {}} onClose={() => {}}> + Example + + ); + + expect(screen.getByLabelText('Close')).toHaveStyle({ + width: '100%', + height: '100%', + }); + }); + + it('keeps the close glyph pinned right so it does not drift', async () => { + await render( + {}} onClose={() => {}}> + Example + + ); + + // `styles.icon` sets alignSelf center, which would otherwise win and move + // the glyph 4dp left + expect(screen.getByTestId('chip-close-icon')).toHaveStyle({ + alignSelf: 'flex-end', + }); + }); + + it('is not rendered without onClose', async () => { + await render( {}}>Example); + + expect(screen.queryByLabelText('Close')).not.toBeOnTheScreen(); + }); +}); diff --git a/src/components/__tests__/IconButton.test.tsx b/src/components/__tests__/IconButton.test.tsx index a8bc540aa9..e14f844bfb 100644 --- a/src/components/__tests__/IconButton.test.tsx +++ b/src/components/__tests__/IconButton.test.tsx @@ -46,6 +46,35 @@ it('renders disabled icon button', async () => { expect(tree).toMatchSnapshot(); }); +it('expands hitSlop up to the 48dp minimum for a button smaller than that', async () => { + await render(); + + // (48 - 40) / 2 on every side, for the default 24dp icon plus 8dp padding + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('icon-button').props.hitSlop).toEqual({ + top: 4, + bottom: 4, + left: 4, + right: 4, + }); +}); + +it('gives a disabled button no hitSlop of its own', async () => { + await render(); + + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('icon-button').props.hitSlop).toBeUndefined(); +}); + +it('lets a caller-supplied hitSlop win even while disabled', async () => { + await render( + + ); + + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('icon-button').props.hitSlop).toBe(2); +}); + it('renders icon change animated', async () => { const tree = (await render()).toJSON(); @@ -84,6 +113,24 @@ it('renders icon button with small border radius', async () => { }); }); +it('clips to a custom corner radius', async () => { + await render( + {}} + borderTopLeftRadius={0} + /> + ); + + // The container stopped clipping so the touch target can escape it, so the + // touchable has to take the shape itself, corners included. + expect(screen.getByTestId('icon-button')).toHaveStyle({ + borderTopLeftRadius: 0, + }); +}); + describe('getIconButtonColor - icon color', () => { it('should return custom icon color', () => { expect( diff --git a/src/components/__tests__/RadioButton/RadioButton.test.tsx b/src/components/__tests__/RadioButton/RadioButton.test.tsx index da8a04375d..90e76d68f6 100644 --- a/src/components/__tests__/RadioButton/RadioButton.test.tsx +++ b/src/components/__tests__/RadioButton/RadioButton.test.tsx @@ -6,7 +6,7 @@ import { jest as mockJest, } from '@jest/globals'; -import { render } from '../../../test-utils'; +import { render, screen } from '../../../test-utils'; import RadioButton from '../../RadioButton'; import { RadioButtonContext } from '../../RadioButton/RadioButtonGroup'; @@ -82,4 +82,26 @@ describe('RadioButton', () => { expect(tree).toMatchSnapshot(); }); }); + + describe('hitSlop', () => { + it('expands up to the 48dp minimum when enabled', async () => { + await render(); + + // (48 - 40) / 2 on every side + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('radio').props.hitSlop).toEqual({ + top: 4, + bottom: 4, + left: 4, + right: 4, + }); + }); + + it('gives a disabled RadioButton no hitSlop of its own', async () => { + await render(); + + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('radio').props.hitSlop).toBeUndefined(); + }); + }); }); diff --git a/src/components/__tests__/RadioButton/__snapshots__/RadioButton.test.tsx.snap b/src/components/__tests__/RadioButton/__snapshots__/RadioButton.test.tsx.snap index c20910f20e..a6e37f97d4 100644 --- a/src/components/__tests__/RadioButton/__snapshots__/RadioButton.test.tsx.snap +++ b/src/components/__tests__/RadioButton/__snapshots__/RadioButton.test.tsx.snap @@ -23,6 +23,14 @@ exports[`RadioButton RadioButton with custom testID renders properly 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -39,8 +47,8 @@ exports[`RadioButton RadioButton with custom testID renders properly 1`] = ` "overflow": "hidden", }, { - "borderRadius": 18, - "padding": 6, + "borderRadius": 20, + "padding": 8, }, ] } @@ -110,6 +118,14 @@ exports[`RadioButton on default platform renders properly 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -126,8 +142,8 @@ exports[`RadioButton on default platform renders properly 1`] = ` "overflow": "hidden", }, { - "borderRadius": 18, - "padding": 6, + "borderRadius": 20, + "padding": 8, }, ] } @@ -196,6 +212,14 @@ exports[`RadioButton on ios platform renders properly 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -212,8 +236,8 @@ exports[`RadioButton on ios platform renders properly 1`] = ` "overflow": "hidden", }, { - "borderRadius": 18, - "padding": 6, + "borderRadius": 20, + "padding": 8, }, ] } @@ -282,6 +306,14 @@ exports[`RadioButton when RadioButton is wrapped by RadioButtonContext.Provider accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -298,8 +330,8 @@ exports[`RadioButton when RadioButton is wrapped by RadioButtonContext.Provider "overflow": "hidden", }, { - "borderRadius": 18, - "padding": 6, + "borderRadius": 20, + "padding": 8, }, ] } diff --git a/src/components/__tests__/RadioButton/__snapshots__/RadioButtonGroup.test.tsx.snap b/src/components/__tests__/RadioButton/__snapshots__/RadioButtonGroup.test.tsx.snap index 1ae7f560be..82f06ebf6c 100644 --- a/src/components/__tests__/RadioButton/__snapshots__/RadioButtonGroup.test.tsx.snap +++ b/src/components/__tests__/RadioButton/__snapshots__/RadioButtonGroup.test.tsx.snap @@ -26,6 +26,14 @@ exports[`RadioButtonGroup renders properly 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -42,8 +50,8 @@ exports[`RadioButtonGroup renders properly 1`] = ` "overflow": "hidden", }, { - "borderRadius": 18, - "padding": 6, + "borderRadius": 20, + "padding": 8, }, ] } diff --git a/src/components/__tests__/RadioButton/__snapshots__/RadioButtonItem.test.tsx.snap b/src/components/__tests__/RadioButton/__snapshots__/RadioButtonItem.test.tsx.snap index 5867b1408c..1d8934aa6f 100644 --- a/src/components/__tests__/RadioButton/__snapshots__/RadioButtonItem.test.tsx.snap +++ b/src/components/__tests__/RadioButton/__snapshots__/RadioButtonItem.test.tsx.snap @@ -77,6 +77,14 @@ exports[`can render leading radio button control 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -93,8 +101,8 @@ exports[`can render leading radio button control 1`] = ` "overflow": "hidden", }, { - "borderRadius": 18, - "padding": 6, + "borderRadius": 20, + "padding": 8, }, ] } @@ -291,6 +299,14 @@ exports[`can render the Android radio button on different platforms 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -307,7 +323,7 @@ exports[`can render the Android radio button on different platforms 1`] = ` "overflow": "hidden", }, { - "borderRadius": 18, + "borderRadius": 20, }, ] } @@ -320,7 +336,7 @@ exports[`can render the Android radio button on different platforms 1`] = ` "borderRadius": 10, "borderWidth": 2, "height": 20, - "margin": 8, + "margin": 10, "width": 20, } } @@ -443,6 +459,14 @@ exports[`can render the iOS radio button on different platforms 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -459,8 +483,8 @@ exports[`can render the iOS radio button on different platforms 1`] = ` "overflow": "hidden", }, { - "borderRadius": 18, - "padding": 6, + "borderRadius": 20, + "padding": 8, }, ] } @@ -621,6 +645,14 @@ exports[`renders unchecked 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -637,8 +669,8 @@ exports[`renders unchecked 1`] = ` "overflow": "hidden", }, { - "borderRadius": 18, - "padding": 6, + "borderRadius": 20, + "padding": 8, }, ] } diff --git a/src/components/__tests__/SegmentedButton.test.tsx b/src/components/__tests__/SegmentedButton.test.tsx index ce950f671f..1855bb67e7 100644 --- a/src/components/__tests__/SegmentedButton.test.tsx +++ b/src/components/__tests__/SegmentedButton.test.tsx @@ -512,3 +512,45 @@ describe('labelStyle is handled', () => { }); }); }); + +describe('hitSlop', () => { + it('expands up to the 48dp minimum when enabled', async () => { + await render( + {}} + /> + ); + + // (48 - (2 * 9dp default padding + 18dp icon)) / 2 top/bottom, none + // horizontally + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('walking-button').props.hitSlop).toEqual({ + top: 6, + bottom: 6, + left: 0, + right: 0, + }); + }); + + it('gives a disabled button no hitSlop of its own', async () => { + await render( + {}} + /> + ); + + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('walking-button').props.hitSlop).toBeUndefined(); + }); +}); diff --git a/src/components/__tests__/Switch.test.tsx b/src/components/__tests__/Switch.test.tsx index ceff77a0e6..170ce15dbe 100644 --- a/src/components/__tests__/Switch.test.tsx +++ b/src/components/__tests__/Switch.test.tsx @@ -24,6 +24,26 @@ describe('Switch render', () => { ).toMatchSnapshot(); }); + it('expands hitSlop up to the 48dp minimum when enabled', async () => { + await render(); + + // (48 - 40) / 2 top/bottom, none horizontally: the track is already wider + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('switch').props.hitSlop).toEqual({ + top: 4, + bottom: 4, + left: 0, + right: 0, + }); + }); + + it('gives a disabled switch no hitSlop of its own', async () => { + await render(); + + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('switch').props.hitSlop).toBeUndefined(); + }); + it('renders with checked icon', async () => { expect( (await render()).toJSON() diff --git a/src/components/__tests__/TouchableRipple.test.tsx b/src/components/__tests__/TouchableRipple.test.tsx index f3c4cb1168..1feb4c55c9 100644 --- a/src/components/__tests__/TouchableRipple.test.tsx +++ b/src/components/__tests__/TouchableRipple.test.tsx @@ -2,7 +2,7 @@ import { Platform, Text } from 'react-native'; import type { GestureResponderEvent } from 'react-native'; import { describe, expect, it, jest } from '@jest/globals'; -import { userEvent } from '@testing-library/react-native'; +import { act, fireEvent, userEvent } from '@testing-library/react-native'; import { render, screen } from '../../test-utils'; import TouchableRipple from '../TouchableRipple/TouchableRipple.native'; @@ -68,5 +68,154 @@ describe('TouchableRipple', () => { const underlay = screen.getByTestId('touchable-ripple-underlay'); expect(underlay).toHaveStyle({ backgroundColor: 'purple' }); }); + + it('takes the shape of the touchable so it does not square off the corners', async () => { + await render( + + Press me! + + ); + + expect(screen.getByTestId('touchable-ripple-underlay')).toHaveStyle({ + borderRadius: 4, + }); + }); + + it('takes per-corner radii too', async () => { + await render( + + Press me! + + ); + + expect(screen.getByTestId('touchable-ripple-underlay')).toHaveStyle({ + borderTopLeftRadius: 8, + borderBottomRightRadius: 2, + }); + }); + }); + + describe('hitSlop', () => { + // hitSlop has no user-visible effect here, the renderer does not lay views + // out or hit-test them. This only stops the prop being dropped. + /* eslint-disable no-restricted-syntax */ + const hitSlopOf = () => screen.getByTestId('touchable').props.hitSlop; + /* eslint-enable no-restricted-syntax */ + + it('is not enforced or defaulted: the primitive does not measure', async () => { + await render( + {}}> + Button + + ); + + expect(hitSlopOf()).toBeUndefined(); + }); + + it('passes a caller-supplied hitSlop straight through', async () => { + await render( + {}} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + > + Button + + ); + + expect(hitSlopOf()).toEqual({ top: 8, bottom: 8, left: 8, right: 8 }); + }); + + it('still calls a caller-supplied onLayout', async () => { + const onLayout = jest.fn(); + await render( + {}} + onLayout={onLayout} + > + Button + + ); + + await act(async () => { + await fireEvent(screen.getByTestId('touchable'), 'layout', { + nativeEvent: { layout: { width: 32, height: 32, x: 0, y: 0 } }, + }); + }); + + expect(onLayout).toHaveBeenCalledTimes(1); + }); + }); +}); + +describe('TouchableRipple focus ring', () => { + const focus = async () => { + await act(async () => { + await fireEvent(screen.getByTestId('ripple'), 'focus'); + }); + }; + + it('rings on keyboard focus and clears on blur', async () => { + await render( + {}}> + Button + + ); + + await focus(); + expect(screen.getByTestId('ripple')).toHaveStyle({ + outlineWidth: 3, + outlineOffset: 2, + }); + + await act(async () => { + await fireEvent(screen.getByTestId('ripple'), 'blur'); + }); + expect(screen.getByTestId('ripple')).not.toHaveStyle({ outlineWidth: 3 }); + }); + + // Inward is opt-in, for controls a clipping ancestor would trim. + it('draws the ring inward only when asked', async () => { + await render( + {}} focusRing="inward"> + Button + + ); + + await focus(); + expect(screen.getByTestId('ripple')).toHaveStyle({ + outlineWidth: 3, + outlineOffset: -3, + }); + }); + + // The non-interactive case is covered in useFocusRing's own tests. It cannot + // be asserted here: RNTL will not dispatch to a disabled element, so a + // touchable with no press handler passes for free. + it('does not ring when the ring is turned off', async () => { + await render( + {}} focusRing="none"> + Button + + ); + + await focus(); + expect(screen.getByTestId('ripple')).not.toHaveStyle({ outlineWidth: 3 }); + }); + + it('still calls a caller onFocus', async () => { + const onFocus = jest.fn(); + await render( + {}} onFocus={onFocus}> + Button + + ); + + await focus(); + expect(onFocus).toHaveBeenCalled(); }); }); diff --git a/src/components/__tests__/TouchableRippleFocusWeb.test.tsx b/src/components/__tests__/TouchableRippleFocusWeb.test.tsx new file mode 100644 index 0000000000..99c5a7d107 --- /dev/null +++ b/src/components/__tests__/TouchableRippleFocusWeb.test.tsx @@ -0,0 +1,97 @@ +import { Platform, Text } from 'react-native'; +import type { ViewStyle } from 'react-native'; + +import { + afterEach, + beforeEach, + describe, + expect, + it, + jest, +} from '@jest/globals'; +import { act, fireEvent } from '@testing-library/react-native'; + +import { render, screen } from '../../test-utils'; +// By extension: a bare import resolves to `.native` under the jest preset, so +// the web implementation would never be exercised. +import TouchableRipple from '../TouchableRipple/TouchableRipple.tsx'; + +// There is no DOM in this repo's Jest, so there is no real `:focus-visible` to +// fire - the mechanism is now the browser's, not this library's. These prove +// the wiring instead: the right `data-focus-ring[-within]` attribute and CSS +// colour variable land on the rendered element for a given `focusRing` prop. +// Real focus behaviour is a manual, browser-only check (see the PR +// description). +const renderRipple = (props = {}) => + render( + {}} {...props}> + Button + + ); + +describe('TouchableRipple focus ring (web implementation)', () => { + const original = Platform.OS; + beforeEach(() => { + Platform.OS = 'web'; + }); + afterEach(() => { + Platform.OS = original; + }); + + it('emits data-focus-ring="outward" and the secondary colour by default', async () => { + await renderRipple(); + + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('ripple').props.dataSet).toEqual({ + focusRing: 'outward', + }); + expect(screen.getByTestId('ripple')).toHaveStyle( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + { + ['--rnp-focus-ring-color']: 'rgba(98, 91, 113, 1)', + } as unknown as ViewStyle + ); + }); + + it('emits data-focus-ring="inward" when asked', async () => { + await renderRipple({ focusRing: 'inward' }); + + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('ripple').props.dataSet).toEqual({ + focusRing: 'inward', + }); + }); + + it('emits no attribute when the ring is turned off', async () => { + await renderRipple({ focusRing: 'none' }); + + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('ripple').props.dataSet).toBeUndefined(); + }); + + it('emits no attribute when disabled', async () => { + await renderRipple({ disabled: true }); + + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('ripple').props.dataSet).toBeUndefined(); + }); + + // Not reference equality: RN's own `Pressable` wraps the handler it is + // given internally, on every platform, ring or no ring. What matters here + // is that this library stops doing its own extra wrapping around it. + it('still calls a caller onFocus and onBlur', async () => { + const onFocus = jest.fn(); + const onBlur = jest.fn(); + await renderRipple({ onFocus, onBlur }); + + await act(async () => { + await fireEvent(screen.getByTestId('ripple'), 'focus'); + }); + await act(async () => { + await fireEvent(screen.getByTestId('ripple'), 'blur'); + }); + + expect(onFocus).toHaveBeenCalled(); + expect(onBlur).toHaveBeenCalled(); + }); +}); diff --git a/src/components/__tests__/TouchableRippleWeb.test.tsx b/src/components/__tests__/TouchableRippleWeb.test.tsx new file mode 100644 index 0000000000..a3e811a390 --- /dev/null +++ b/src/components/__tests__/TouchableRippleWeb.test.tsx @@ -0,0 +1,136 @@ +import { Text } from 'react-native'; + +import { describe, expect, it } from '@jest/globals'; + +import { render, screen } from '../../test-utils'; +import type TouchableRippleType from '../TouchableRipple/TouchableRipple'; + +// The web variant, required with its extension on purpose. A bare specifier +// resolves to `TouchableRipple.native.tsx` under the jest preset, so importing +// it the normal way silently tests the native file and none of this runs. +// +// The preset sets `Platform.OS` to 'ios' and there is no DOM, so this renders the +// web source on the native renderer. It pins props and element order, nothing +// more. Hit testing, stacking order, computed styles and clipping ancestors have +// to be checked in a browser. Pressing here would throw, `handlePressIn` reaches +// for `window`. +const TouchableRipple: typeof TouchableRippleType = + require('../TouchableRipple/TouchableRipple.tsx').default; + +const TARGET = 'touchable-ripple-touch-target'; + +// The target is `aria-hidden`, the button already carries the semantics. Testing +// library skips hidden elements, so queries have to opt in or they find nothing +// and the negative cases pass for free. +const HIDDEN = { includeHiddenElements: true } as const; + +describe('TouchableRipple (web)', () => { + // The target is invisible by design, so there is no user-visible assertion to + // make about it. Its style is the behaviour. + const styleOf = (testID: string) => { + // eslint-disable-next-line no-restricted-syntax + const { style } = screen.getByTestId(testID, HIDDEN).props; + return Array.isArray(style) ? Object.assign({}, ...style.flat()) : style; + }; + + it("renders a touch target matching the touchable's own bounds by default", async () => { + // No minimum is enforced here: the primitive does not measure, so with no + // caller-supplied `hitSlop` the target is exactly the touchable's bounds. + await render( + {}}> + Button + + ); + + expect(screen.getByTestId(TARGET, HIDDEN)).toBeOnTheScreen(); + expect(styleOf(TARGET)).toEqual({ + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + right: 0, + }); + }); + + it('renders the touch target before the children so it cannot cover them', async () => { + // It hit-tests, so as the last sibling it covers anything interactive inside + // the touchable, e.g. a pressable List.Item with a control in `right`. + await render( + {}}> + child-marker + + ); + + const tree = JSON.stringify(screen.toJSON()); + + expect(tree.indexOf(TARGET)).toBeGreaterThan(-1); + expect(tree.indexOf(TARGET)).toBeLessThan(tree.indexOf('child-marker')); + }); + + it('does not render a touch target when there are no touch handlers', async () => { + await render( + + Not a control + + ); + + expect(screen.queryByTestId(TARGET, HIDDEN)).not.toBeOnTheScreen(); + }); + + it('does not render a touch target when disabled', async () => { + await render( + {}}> + Button + + ); + + expect(screen.queryByTestId(TARGET, HIDDEN)).not.toBeOnTheScreen(); + }); + + it('lets a caller-supplied hitSlop size the target instead', async () => { + await render( + {}}> + Button + + ); + + expect(styleOf(TARGET)).toEqual({ + position: 'absolute', + top: -6, + bottom: -6, + left: -6, + right: -6, + }); + }); + + it('accepts a per-edge hitSlop', async () => { + await render( + {}}> + Button + + ); + + expect(styleOf(TARGET)).toEqual({ + position: 'absolute', + top: -4, + bottom: -0, + left: -8, + right: -0, + }); + }); + + it('no longer clips the touchable itself, which would clip the target', async () => { + await render( + {}} testID="touchable"> + Button + + ); + + const style = styleOf('touchable'); + + // check we have the touchable's own style first, or the absence below passes + // against any empty object + expect(style).toMatchObject({ position: 'relative' }); + expect(style.overflow).toBeUndefined(); + }); +}); diff --git a/src/components/__tests__/__snapshots__/Chip.test.tsx.snap b/src/components/__tests__/__snapshots__/Chip.test.tsx.snap index 04687e301f..e9d20a714d 100644 --- a/src/components/__tests__/__snapshots__/Chip.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Chip.test.tsx.snap @@ -118,6 +118,14 @@ exports[`renders chip with close button 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 8, + "left": 0, + "right": 0, + "top": 8, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -154,6 +162,7 @@ exports[`renders chip with close button 1`] = ` "position": "relative", }, { + "minHeight": 32, "paddingLeft": 0, }, { @@ -260,11 +269,12 @@ exports[`renders chip with close button 1`] = ` @@ -300,6 +310,18 @@ exports[`renders chip with close button 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} role="button" + style={ + [ + { + "height": "100%", + "justifyContent": "center", + "width": "100%", + }, + { + "borderRadius": 8, + }, + ] + } > @@ -653,6 +686,18 @@ exports[`renders chip with custom close button 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} role="button" + style={ + [ + { + "height": "100%", + "justifyContent": "center", + "width": "100%", + }, + { + "borderRadius": 8, + }, + ] + } > - `; @@ -395,7 +371,6 @@ exports[`renders FAB medium size 1`] = ` }, [ null, - null, ], ] } @@ -452,29 +427,6 @@ exports[`renders FAB medium size 1`] = ` - `; @@ -634,7 +586,6 @@ exports[`renders FAB transitioning to not visible 1`] = ` }, [ null, - null, ], ] } @@ -691,29 +642,6 @@ exports[`renders FAB transitioning to not visible 1`] = ` - `; @@ -873,7 +801,6 @@ exports[`renders FAB transitioning to visible 1`] = ` }, [ null, - null, ], ] } @@ -930,29 +857,6 @@ exports[`renders FAB transitioning to visible 1`] = ` - `; @@ -1113,7 +1017,6 @@ exports[`renders FAB with aria-label 1`] = ` }, [ null, - null, ], ] } @@ -1170,29 +1073,6 @@ exports[`renders FAB with aria-label 1`] = ` - `; @@ -1352,7 +1232,6 @@ exports[`renders FAB with containerColor and contentColor overrides 1`] = ` }, [ null, - null, ], ] } @@ -1409,29 +1288,6 @@ exports[`renders FAB with containerColor and contentColor overrides 1`] = ` - `; @@ -1591,7 +1447,6 @@ exports[`renders FAB with containerColor override 1`] = ` }, [ null, - null, ], ] } @@ -1648,29 +1503,6 @@ exports[`renders FAB with containerColor override 1`] = ` - `; @@ -1830,7 +1662,6 @@ exports[`renders FAB with default props 1`] = ` }, [ null, - null, ], ] } @@ -1887,29 +1718,6 @@ exports[`renders FAB with default props 1`] = ` - `; @@ -2069,7 +1877,6 @@ exports[`renders FAB with primary variant 1`] = ` }, [ null, - null, ], ] } @@ -2126,29 +1933,6 @@ exports[`renders FAB with primary variant 1`] = ` - `; @@ -2308,7 +2092,6 @@ exports[`renders FAB with secondary variant 1`] = ` }, [ null, - null, ], ] } @@ -2365,29 +2148,6 @@ exports[`renders FAB with secondary variant 1`] = ` - `; @@ -2547,7 +2307,6 @@ exports[`renders FAB with tertiary variant 1`] = ` }, [ null, - null, ], ] } @@ -2604,29 +2363,6 @@ exports[`renders FAB with tertiary variant 1`] = ` - `; @@ -2786,7 +2522,6 @@ exports[`renders FAB with tonalSecondary variant 1`] = ` }, [ null, - null, ], ] } @@ -2843,29 +2578,6 @@ exports[`renders FAB with tonalSecondary variant 1`] = ` - `; @@ -3025,7 +2737,6 @@ exports[`renders FAB with tonalTertiary variant 1`] = ` }, [ null, - null, ], ] } @@ -3082,28 +2793,5 @@ exports[`renders FAB with tonalTertiary variant 1`] = ` - `; diff --git a/src/components/__tests__/__snapshots__/FABExtended.test.tsx.snap b/src/components/__tests__/__snapshots__/FABExtended.test.tsx.snap index 644a640c5a..8cf2e644d1 100644 --- a/src/components/__tests__/__snapshots__/FABExtended.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/FABExtended.test.tsx.snap @@ -158,7 +158,6 @@ exports[`renders extended FAB collapsed 1`] = ` }, [ null, - null, ], ] } @@ -257,29 +256,6 @@ exports[`renders extended FAB collapsed 1`] = ` - - - - - - - - @@ -624,7 +576,6 @@ exports[`renders FAB.Menu closed 1`] = ` { "flex": 1, }, - null, ], ] } @@ -750,29 +701,6 @@ exports[`renders FAB.Menu closed 1`] = ` - @@ -918,7 +846,6 @@ exports[`renders FAB.Menu not expanded when trigger is not visible 1`] = ` { "borderRadius": 9999, }, - null, ], ] } @@ -984,29 +911,6 @@ exports[`renders FAB.Menu not expanded when trigger is not visible 1`] = ` - - @@ -1403,7 +1283,6 @@ exports[`renders FAB.Menu not expanded when trigger is not visible 1`] = ` { "flex": 1, }, - null, ], ] } @@ -1529,29 +1408,6 @@ exports[`renders FAB.Menu not expanded when trigger is not visible 1`] = ` - @@ -1697,7 +1553,6 @@ exports[`renders FAB.Menu open 1`] = ` { "borderRadius": 9999, }, - null, ], ] } @@ -1763,29 +1618,6 @@ exports[`renders FAB.Menu open 1`] = ` - - @@ -2182,7 +1990,6 @@ exports[`renders FAB.Menu open 1`] = ` { "flex": 1, }, - null, ], ] } @@ -2308,29 +2115,6 @@ exports[`renders FAB.Menu open 1`] = ` - @@ -2476,7 +2260,6 @@ exports[`renders FAB.Menu with 6 items 1`] = ` { "borderRadius": 9999, }, - null, ], ] } @@ -2542,29 +2325,6 @@ exports[`renders FAB.Menu with 6 items 1`] = ` - - - - - - @@ -3669,7 +3309,6 @@ exports[`renders FAB.Menu with 6 items 1`] = ` { "flex": 1, }, - null, ], ] } @@ -3795,29 +3434,6 @@ exports[`renders FAB.Menu with 6 items 1`] = ` - @@ -3964,7 +3580,6 @@ exports[`renders FAB.Menu with center alignment 1`] = ` { "borderRadius": 9999, }, - null, ], ] } @@ -4030,29 +3645,6 @@ exports[`renders FAB.Menu with center alignment 1`] = ` - - @@ -4449,7 +4017,6 @@ exports[`renders FAB.Menu with center alignment 1`] = ` { "flex": 1, }, - null, ], ] } @@ -4575,29 +4142,6 @@ exports[`renders FAB.Menu with center alignment 1`] = ` - @@ -4743,7 +4287,6 @@ exports[`renders FAB.Menu with items having icons 1`] = ` { "borderRadius": 9999, }, - null, ], ] } @@ -4838,29 +4381,6 @@ exports[`renders FAB.Menu with items having icons 1`] = ` - - @@ -5286,7 +4782,6 @@ exports[`renders FAB.Menu with items having icons 1`] = ` { "flex": 1, }, - null, ], ] } @@ -5412,29 +4907,6 @@ exports[`renders FAB.Menu with items having icons 1`] = ` - @@ -5580,7 +5052,6 @@ exports[`renders FAB.Menu with start alignment 1`] = ` { "borderRadius": 9999, }, - null, ], ] } @@ -5646,29 +5117,6 @@ exports[`renders FAB.Menu with start alignment 1`] = ` - - @@ -6065,7 +5489,6 @@ exports[`renders FAB.Menu with start alignment 1`] = ` { "flex": 1, }, - null, ], ] } @@ -6191,29 +5614,6 @@ exports[`renders FAB.Menu with start alignment 1`] = ` - diff --git a/src/components/__tests__/__snapshots__/IconButton.test.tsx.snap b/src/components/__tests__/__snapshots__/IconButton.test.tsx.snap index c1e777d963..e8042d7973 100644 --- a/src/components/__tests__/__snapshots__/IconButton.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/IconButton.test.tsx.snap @@ -7,7 +7,6 @@ exports[`renders disabled icon button 1`] = ` [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -15,8 +14,16 @@ exports[`renders disabled icon button 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -45,14 +52,6 @@ exports[`renders disabled icon button 1`] = ` centered={true} collapsable={false} focusable={true} - hitSlop={ - { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, - } - } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -74,6 +73,20 @@ exports[`renders disabled icon button 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -127,7 +140,6 @@ exports[`renders icon button by default 1`] = ` [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -135,8 +147,16 @@ exports[`renders icon button by default 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -167,10 +187,10 @@ exports[`renders icon button by default 1`] = ` focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } onBlur={[Function]} @@ -194,6 +214,20 @@ exports[`renders icon button by default 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -247,7 +281,6 @@ exports[`renders icon button with color 1`] = ` [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -255,8 +288,16 @@ exports[`renders icon button with color 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -287,10 +328,10 @@ exports[`renders icon button with color 1`] = ` focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } onBlur={[Function]} @@ -314,6 +355,20 @@ exports[`renders icon button with color 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -367,7 +422,6 @@ exports[`renders icon button with size 1`] = ` [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -375,8 +429,16 @@ exports[`renders icon button with size 1`] = ` "width": 46, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 23, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -407,10 +469,10 @@ exports[`renders icon button with size 1`] = ` focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 1, + "left": 1, + "right": 1, + "top": 1, } } onBlur={[Function]} @@ -434,6 +496,20 @@ exports[`renders icon button with size 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 23, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -487,7 +563,6 @@ exports[`renders icon change animated 1`] = ` [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -495,8 +570,16 @@ exports[`renders icon change animated 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -527,10 +610,10 @@ exports[`renders icon change animated 1`] = ` focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } onBlur={[Function]} @@ -554,6 +637,20 @@ exports[`renders icon change animated 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "overflow": "hidden", + }, undefined, ], ] diff --git a/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap b/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap index c4d5a5d847..a0f3b3452d 100644 --- a/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap @@ -234,6 +234,14 @@ exports[`renders list item with custom description 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 8, + "left": 0, + "right": 0, + "top": 8, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -270,6 +278,7 @@ exports[`renders list item with custom description 1`] = ` "position": "relative", }, { + "minHeight": 32, "paddingLeft": 0, }, { diff --git a/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap b/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap index 27436d6599..02ea78d217 100644 --- a/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap @@ -96,7 +96,6 @@ exports[`activity indicator snapshot test 1`] = ` [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -104,8 +103,16 @@ exports[`activity indicator snapshot test 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -137,10 +144,10 @@ exports[`activity indicator snapshot test 1`] = ` focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } onBlur={[Function]} @@ -164,6 +171,20 @@ exports[`activity indicator snapshot test 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -536,7 +557,6 @@ exports[`renders with placeholder 1`] = ` [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -544,8 +564,16 @@ exports[`renders with placeholder 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -577,10 +605,10 @@ exports[`renders with placeholder 1`] = ` focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } onBlur={[Function]} @@ -604,6 +632,20 @@ exports[`renders with placeholder 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -702,7 +744,6 @@ exports[`renders with placeholder 1`] = ` [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -710,8 +751,16 @@ exports[`renders with placeholder 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -743,10 +792,10 @@ exports[`renders with placeholder 1`] = ` focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } onBlur={[Function]} @@ -770,6 +819,20 @@ exports[`renders with placeholder 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -914,7 +977,6 @@ exports[`renders with text 1`] = ` [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -922,8 +984,16 @@ exports[`renders with text 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -955,10 +1025,10 @@ exports[`renders with text 1`] = ` focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } onBlur={[Function]} @@ -982,6 +1052,20 @@ exports[`renders with text 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -1076,7 +1160,6 @@ exports[`renders with text 1`] = ` [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -1084,8 +1167,16 @@ exports[`renders with text 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -1117,10 +1208,10 @@ exports[`renders with text 1`] = ` focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } onBlur={[Function]} @@ -1144,6 +1235,20 @@ exports[`renders with text 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "overflow": "hidden", + }, undefined, ], ] diff --git a/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap b/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap index 4153d05f96..84fefa5c19 100644 --- a/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap @@ -56,6 +56,14 @@ exports[`renders segmented button 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 6, + "left": 0, + "right": 0, + "top": 6, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -182,6 +190,14 @@ exports[`renders segmented button 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 6, + "left": 0, + "right": 0, + "top": 6, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} diff --git a/src/components/__tests__/__snapshots__/Switch.test.tsx.snap b/src/components/__tests__/__snapshots__/Switch.test.tsx.snap index e38a5145e1..aa7825ecc1 100644 --- a/src/components/__tests__/__snapshots__/Switch.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Switch.test.tsx.snap @@ -54,7 +54,6 @@ exports[`Switch render renders disabled off 1`] = ` "justifyContent": "center", "width": 52, }, - undefined, ] } > @@ -189,29 +188,6 @@ exports[`Switch render renders disabled off 1`] = ` } /> - `; @@ -269,7 +245,6 @@ exports[`Switch render renders disabled on 1`] = ` "justifyContent": "center", "width": 52, }, - undefined, ] } > @@ -384,29 +359,6 @@ exports[`Switch render renders disabled on 1`] = ` } /> - `; @@ -446,6 +398,14 @@ exports[`Switch render renders off 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 0, + "right": 0, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -464,7 +424,6 @@ exports[`Switch render renders off 1`] = ` "justifyContent": "center", "width": 52, }, - undefined, ] } > @@ -582,29 +541,6 @@ exports[`Switch render renders off 1`] = ` } /> - `; @@ -644,6 +580,14 @@ exports[`Switch render renders on 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 0, + "right": 0, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -662,7 +606,6 @@ exports[`Switch render renders on 1`] = ` "justifyContent": "center", "width": 52, }, - undefined, ] } > @@ -760,29 +703,6 @@ exports[`Switch render renders on 1`] = ` } /> - `; @@ -822,6 +742,14 @@ exports[`Switch render renders with checked icon 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 0, + "right": 0, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -840,7 +768,6 @@ exports[`Switch render renders with checked icon 1`] = ` "justifyContent": "center", "width": 52, }, - undefined, ] } > @@ -1009,29 +936,6 @@ exports[`Switch render renders with checked icon 1`] = ` - `; @@ -1071,6 +975,14 @@ exports[`Switch render renders with per-state icons 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 0, + "right": 0, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -1089,7 +1001,6 @@ exports[`Switch render renders with per-state icons 1`] = ` "justifyContent": "center", "width": 52, }, - undefined, ] } > @@ -1258,28 +1169,5 @@ exports[`Switch render renders with per-state icons 1`] = ` - `; diff --git a/src/components/__tests__/__snapshots__/TextInput.test.tsx.snap b/src/components/__tests__/__snapshots__/TextInput.test.tsx.snap index 6efc467066..25bbdc381c 100644 --- a/src/components/__tests__/__snapshots__/TextInput.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/TextInput.test.tsx.snap @@ -150,7 +150,6 @@ exports[`renders filled TextInput with TextInput.Icon accessories 1`] = ` [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -158,8 +157,16 @@ exports[`renders filled TextInput with TextInput.Icon accessories 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, { @@ -201,10 +208,10 @@ exports[`renders filled TextInput with TextInput.Icon accessories 1`] = ` focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } multiline={false} @@ -229,6 +236,20 @@ exports[`renders filled TextInput with TextInput.Icon accessories 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -339,7 +360,6 @@ exports[`renders filled TextInput with TextInput.Icon accessories 1`] = ` [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -347,8 +367,16 @@ exports[`renders filled TextInput with TextInput.Icon accessories 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, { @@ -390,10 +418,10 @@ exports[`renders filled TextInput with TextInput.Icon accessories 1`] = ` focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } multiline={false} @@ -418,6 +446,20 @@ exports[`renders filled TextInput with TextInput.Icon accessories 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -624,7 +666,6 @@ exports[`renders filled TextInput with TextInput.Icon accessories when error is [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -632,8 +673,16 @@ exports[`renders filled TextInput with TextInput.Icon accessories when error is "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, { @@ -675,10 +724,10 @@ exports[`renders filled TextInput with TextInput.Icon accessories when error is focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } multiline={false} @@ -703,6 +752,20 @@ exports[`renders filled TextInput with TextInput.Icon accessories when error is "flexGrow": 1, "justifyContent": "center", }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -813,7 +876,6 @@ exports[`renders filled TextInput with TextInput.Icon accessories when error is [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -821,8 +883,16 @@ exports[`renders filled TextInput with TextInput.Icon accessories when error is "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, { @@ -864,10 +934,10 @@ exports[`renders filled TextInput with TextInput.Icon accessories when error is focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } multiline={false} @@ -892,6 +962,20 @@ exports[`renders filled TextInput with TextInput.Icon accessories when error is "flexGrow": 1, "justifyContent": "center", }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -1276,7 +1360,6 @@ exports[`renders outlined TextInput with TextInput.Icon accessories 1`] = ` [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -1284,8 +1367,16 @@ exports[`renders outlined TextInput with TextInput.Icon accessories 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, { @@ -1327,10 +1418,10 @@ exports[`renders outlined TextInput with TextInput.Icon accessories 1`] = ` focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } multiline={false} @@ -1355,6 +1446,20 @@ exports[`renders outlined TextInput with TextInput.Icon accessories 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -1465,7 +1570,6 @@ exports[`renders outlined TextInput with TextInput.Icon accessories 1`] = ` [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -1473,8 +1577,16 @@ exports[`renders outlined TextInput with TextInput.Icon accessories 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, { @@ -1516,10 +1628,10 @@ exports[`renders outlined TextInput with TextInput.Icon accessories 1`] = ` focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } multiline={false} @@ -1544,6 +1656,20 @@ exports[`renders outlined TextInput with TextInput.Icon accessories 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -1730,7 +1856,6 @@ exports[`renders outlined TextInput with TextInput.Icon accessories when error i [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -1738,8 +1863,16 @@ exports[`renders outlined TextInput with TextInput.Icon accessories when error i "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, { @@ -1781,10 +1914,10 @@ exports[`renders outlined TextInput with TextInput.Icon accessories when error i focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } multiline={false} @@ -1809,6 +1942,20 @@ exports[`renders outlined TextInput with TextInput.Icon accessories when error i "flexGrow": 1, "justifyContent": "center", }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -1919,7 +2066,6 @@ exports[`renders outlined TextInput with TextInput.Icon accessories when error i [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -1927,8 +2073,16 @@ exports[`renders outlined TextInput with TextInput.Icon accessories when error i "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, { @@ -1970,10 +2124,10 @@ exports[`renders outlined TextInput with TextInput.Icon accessories when error i focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } multiline={false} @@ -1998,6 +2152,20 @@ exports[`renders outlined TextInput with TextInput.Icon accessories when error i "flexGrow": 1, "justifyContent": "center", }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "overflow": "hidden", + }, undefined, ], ] diff --git a/src/components/__tests__/__snapshots__/ToggleButton.test.tsx.snap b/src/components/__tests__/__snapshots__/ToggleButton.test.tsx.snap index 62fedc3a85..018760b28d 100644 --- a/src/components/__tests__/__snapshots__/ToggleButton.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/ToggleButton.test.tsx.snap @@ -7,7 +7,6 @@ exports[`renders disabled toggle button 1`] = ` [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -15,8 +14,16 @@ exports[`renders disabled toggle button 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, { @@ -55,14 +62,6 @@ exports[`renders disabled toggle button 1`] = ` centered={true} collapsable={false} focusable={true} - hitSlop={ - { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, - } - } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -82,6 +81,20 @@ exports[`renders disabled toggle button 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -135,7 +148,6 @@ exports[`renders toggle button 1`] = ` [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -143,8 +155,16 @@ exports[`renders toggle button 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, { @@ -185,10 +205,10 @@ exports[`renders toggle button 1`] = ` focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } onBlur={[Function]} @@ -210,6 +230,20 @@ exports[`renders toggle button 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -263,7 +297,6 @@ exports[`renders unchecked toggle button 1`] = ` [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -271,8 +304,16 @@ exports[`renders unchecked toggle button 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, { @@ -311,14 +352,6 @@ exports[`renders unchecked toggle button 1`] = ` centered={true} collapsable={false} focusable={true} - hitSlop={ - { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, - } - } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -338,6 +371,20 @@ exports[`renders unchecked toggle button 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "overflow": "hidden", + }, undefined, ], ] diff --git a/src/components/__tests__/focusRingWiring.test.tsx b/src/components/__tests__/focusRingWiring.test.tsx new file mode 100644 index 0000000000..97b416b560 --- /dev/null +++ b/src/components/__tests__/focusRingWiring.test.tsx @@ -0,0 +1,103 @@ +/* eslint-disable testing-library/no-node-access, @typescript-eslint/no-unsafe-type-assertion, no-restricted-syntax -- + The node carrying the ring is an unnamed internal view (Card's Pressable, + Switch's track, FAB's clip view). There is no testID to query it by, and + which node it is IS the thing under test, so the tree has to be walked. */ +import { StyleSheet, Text } from 'react-native'; +import type { ViewStyle } from 'react-native'; + +import { describe, expect, it } from '@jest/globals'; +import { act, fireEvent } from '@testing-library/react-native'; + +import { render, screen } from '../../test-utils'; +import { tokens } from '../../theme/tokens'; +import Card from '../Card/Card'; +import Chip from '../Chip/Chip'; +import FAB from '../FAB/FAB'; +import ListItem from '../List/ListItem'; +import Switch from '../Switch/Switch'; + +const { thickness, outerOffset } = tokens.md.sys.state.focusIndicator; +const OUTWARD = outerOffset; +const INWARD = -thickness; + +type Node = { props?: Record; children?: unknown[] }; + +const walk = (node: Node | undefined, hit: (n: Node) => boolean): Node[] => { + if (!node || typeof node !== 'object') return []; + const here = hit(node) ? [node] : []; + const kids = (node.children ?? []).flatMap((c) => walk(c as Node, hit)); + return [...here, ...kids]; +}; + +const style = (n: Node) => + StyleSheet.flatten(n.props?.style as ViewStyle) ?? {}; + +/** The node carrying the ring is often not the one that took focus. */ +const ringOffset = () => { + const root = (screen as unknown as { root: Node }).root; + const ringed = walk(root, (n) => style(n).outlineStyle === 'solid'); + return ringed.length ? style(ringed[0]).outlineOffset : undefined; +}; + +/** Focus whichever node actually has the handler wired. */ +const focusFirstFocusable = async () => { + const root = (screen as unknown as { root: Node }).root; + const target = walk(root, (n) => typeof n.props?.onFocus === 'function')[0]; + expect(target).toBeDefined(); + await act(async () => { + await fireEvent(target as never, 'focus'); + }); +}; + +/** + * Each component decides where its ring goes, and getting it backwards is + * invisible to a snapshot because the ring only exists while focused. Pin the + * placement per component so a wiring change cannot pass silently. + */ +describe('focus ring wiring', () => { + it('List.Item rings inward, clear of the rows above and below', async () => { + await render( {}} />); + + await focusFirstFocusable(); + + expect(ringOffset()).toBe(INWARD); + }); + + it('Chip rings inward, so a scrolling chip row cannot trim it', async () => { + await render( {}}>chip); + + await focusFirstFocusable(); + + expect(ringOffset()).toBe(INWARD); + }); + + it('Card rings outward', async () => { + await render( + {}}> + card + + ); + + await focusFirstFocusable(); + + expect(ringOffset()).toBe(OUTWARD); + }); + + it('FAB rings outward on its clip view', async () => { + await render( {}} />); + + await focusFirstFocusable(); + + expect(ringOffset()).toBe(OUTWARD); + }); + + // Inward here lands on the filled track, where `secondary` is ~1:1 against + // `primary` and effectively invisible. + it('Switch rings outward on its track, not inside it', async () => { + await render( {}} />); + + await focusFirstFocusable(); + + expect(ringOffset()).toBe(OUTWARD); + }); +}); diff --git a/src/theme/tokens/sys/state.ts b/src/theme/tokens/sys/state.ts index d0742351bf..ab3aa94f68 100644 --- a/src/theme/tokens/sys/state.ts +++ b/src/theme/tokens/sys/state.ts @@ -15,4 +15,11 @@ export const state = { thickness: 3, outerOffset: 2, }, + /** + * Minimum size of an interactive target. Applied by expanding outside the + * component's bounds, so it is separate from the 40dp state layer that + * Checkbox and Switch render. + * @see https://m3.material.io/foundations/designing/structure + */ + minInteractiveSize: 48, } as const; diff --git a/src/utils/__tests__/focusRingContrast.test.ts b/src/utils/__tests__/focusRingContrast.test.ts new file mode 100644 index 0000000000..5ff9e9cf6f --- /dev/null +++ b/src/utils/__tests__/focusRingContrast.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from '@jest/globals'; + +import { DarkTheme, LightTheme } from '../../theme/schemes'; + +/** + * The MD3 tonal palette is luminance-matched by tone, so a `secondary` ring on + * any other role at the same tone is ~1:1 and vanishes in greyscale. The ring + * is drawn outward onto the page background for exactly this reason; these + * tests pin the surfaces it is allowed to land on. + * + * WCAG 1.4.11 Non-text Contrast wants 3:1. + */ +const MIN_RATIO = 3; + +const luminance = (rgb: string) => { + const [r, g, b] = (rgb.match(/\d+/g) ?? []).slice(0, 3).map(Number); + const channel = (c: number) => { + const s = c / 255; + return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); +}; + +const contrastRatio = (a: string, b: string) => { + const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x); + return (hi + 0.05) / (lo + 0.05); +}; + +describe.each([ + ['light', LightTheme], + ['dark', DarkTheme], +])('focus ring contrast (%s)', (_name, theme) => { + const ring = String(theme.colors.secondary); + + // Surfaces an outward ring actually lands on. + const landsOn: (keyof typeof theme.colors)[] = [ + 'background', + 'surface', + 'surfaceVariant', + 'secondaryContainer', + ]; + it.each(landsOn)('has 3:1 against %s', (role) => { + expect( + contrastRatio(ring, String(theme.colors[role])) + ).toBeGreaterThanOrEqual(MIN_RATIO); + }); + + // Guards the reason the ring is outward rather than inward: these are the + // fills it would sit on top of, and it is invisible against them. + const wouldVanishOn: (keyof typeof theme.colors)[] = [ + 'primary', + 'tertiary', + 'error', + ]; + it.each(wouldVanishOn)( + 'is documented as unusable inward on the %s fill', + (role) => { + expect(contrastRatio(ring, String(theme.colors[role]))).toBeLessThan( + MIN_RATIO + ); + } + ); +}); diff --git a/src/utils/__tests__/useFocusRing.test.tsx b/src/utils/__tests__/useFocusRing.test.tsx new file mode 100644 index 0000000000..4a4f7e6d93 --- /dev/null +++ b/src/utils/__tests__/useFocusRing.test.tsx @@ -0,0 +1,226 @@ +import { Platform, Pressable, Text, View } from 'react-native'; +import type { ViewStyle } from 'react-native'; + +import { + afterEach, + beforeEach, + describe, + expect, + it, + jest, +} from '@jest/globals'; +import { act, fireEvent } from '@testing-library/react-native'; + +import { render, screen } from '../../test-utils'; +import { tokens } from '../../theme/tokens'; +import type { FocusRingPlacement, FocusRingScope } from '../useFocusRing'; +import { buildFocusRingStylesheet, useFocusRing } from '../useFocusRing'; + +const focus = async (data?: unknown) => { + await act(async () => { + await fireEvent(screen.getByTestId('probe'), 'focus', data); + }); +}; + +const blur = async () => { + await act(async () => { + await fireEvent(screen.getByTestId('probe'), 'blur'); + }); +}; + +const Probe = ({ + disabled, + onRender, +}: { + disabled?: boolean; + onRender?: () => void; +}) => { + const { target, ring } = useFocusRing(disabled, 'rebeccapurple'); + onRender?.(); + return ( + {}} + onFocus={target.onFocus} + onBlur={target.onBlur} + style={ring.style} + > + probe + + ); +}; + +describe('buildFocusRingStylesheet', () => { + const css = buildFocusRingStylesheet(); + + it('rings `self` via :focus-visible and `within` via :has(:focus-visible), for both placements', () => { + expect(css).toContain('[data-focus-ring="outward"]:focus-visible'); + expect(css).toContain('[data-focus-ring="inward"]:focus-visible'); + expect(css).toContain( + '[data-focus-ring-within="outward"]:has(:focus-visible)' + ); + expect(css).toContain( + '[data-focus-ring-within="inward"]:has(:focus-visible)' + ); + }); + + // Values must come from the design tokens, not be hardcoded here, or the + // token is decorative. Derive the expectation from the token itself. + it('takes its thickness and offsets from the focusIndicator tokens', () => { + const { thickness, outerOffset } = tokens.md.sys.state.focusIndicator; + + expect(css).toContain(`outline: ${thickness}px solid`); + expect(css).toContain(`outline-offset: ${outerOffset}px;`); + expect(css).toContain(`outline-offset: ${-thickness}px;`); + }); +}); + +describe('useFocusRing (native)', () => { + // Derived from the token, not hardcoded - see the note on the stylesheet + // test above. + const { thickness, outerOffset } = tokens.md.sys.state.focusIndicator; + + it('applies the outline on focus and removes it on blur', async () => { + await render(); + expect(screen.getByTestId('probe')).not.toHaveStyle({ + outlineWidth: thickness, + }); + + await focus(); + expect(screen.getByTestId('probe')).toHaveStyle({ + outlineWidth: thickness, + outlineColor: 'rebeccapurple', + outlineOffset: outerOffset, + }); + + await blur(); + expect(screen.getByTestId('probe')).not.toHaveStyle({ + outlineWidth: thickness, + }); + }); + + // `disabled` goes to the hook only, never to the Pressable: RNTL will not + // dispatch to a disabled element, so that would pass for free. + it('never rings a disabled control, even if a focus event arrives', async () => { + await render(); + + await focus(); + + expect(screen.getByTestId('probe')).not.toHaveStyle({ + outlineWidth: thickness, + }); + }); + + // The gate has to skip the state update, not just mask the result, or a + // suppressed ring still costs a render on the library's hottest primitive. + it('costs no re-render when the ring is suppressed', async () => { + const onRender = jest.fn(); + await render(); + const before = onRender.mock.calls.length; + + await focus(); + + expect(onRender.mock.calls.length).toBe(before); + }); + + it('does not restore the ring when a control is re-enabled', async () => { + const { rerender } = await render(); + await focus(); + expect(screen.getByTestId('probe')).toHaveStyle({ + outlineWidth: thickness, + }); + + await act(async () => { + await rerender(); + }); + await act(async () => { + await rerender(); + }); + + // no focus event happened in between, so nothing should be ringed + expect(screen.getByTestId('probe')).not.toHaveStyle({ + outlineWidth: thickness, + }); + }); +}); + +// There is no DOM in this repo's Jest, so these can only prove the wiring - +// the right data attribute and CSS variable land on the right element. Real +// `:focus-visible`/`:has()` behaviour is a manual, browser-only check (see +// the PR description). +describe('useFocusRing (web)', () => { + const original = Platform.OS; + beforeEach(() => { + Platform.OS = 'web'; + }); + afterEach(() => { + Platform.OS = original; + }); + + const WebProbe = ({ + disabled, + placement, + scope, + }: { + disabled?: boolean; + placement?: FocusRingPlacement; + scope?: FocusRingScope; + }) => { + const { target, ring } = useFocusRing( + disabled, + 'rebeccapurple', + placement, + scope + ); + return ( + + + + ); + }; + + it('emits data-focus-ring and the colour variable for scope "self"', async () => { + await render(); + + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('probe').props.dataSet).toEqual({ + focusRing: 'outward', + }); + expect(screen.getByTestId('probe')).toHaveStyle( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + { '--rnp-focus-ring-color': 'rebeccapurple' } as unknown as ViewStyle + ); + }); + + it('emits data-focus-ring-within for scope "within", and suppresses the target element\'s own outline', async () => { + await render(); + + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('probe').props.dataSet).toEqual({ + focusRingWithin: 'inward', + }); + expect(screen.getByTestId('target')).toHaveStyle( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + { outline: 'none' } as unknown as ViewStyle + ); + }); + + it('does not suppress the target element\'s outline for scope "self"', async () => { + await render(); + + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('target').props.style).toEqual([]); + }); + + it('emits nothing when disabled or when the ring is turned off', async () => { + await render(); + + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('probe').props.dataSet).toBeUndefined(); + + await render(); + + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('probe').props.dataSet).toBeUndefined(); + }); +}); diff --git a/src/utils/getMinInteractiveSizeHitSlop.ts b/src/utils/getMinInteractiveSizeHitSlop.ts new file mode 100644 index 0000000000..3317a79070 --- /dev/null +++ b/src/utils/getMinInteractiveSizeHitSlop.ts @@ -0,0 +1,40 @@ +import type { Insets } from 'react-native'; + +import { tokens } from '../theme/tokens'; + +const { minInteractiveSize } = tokens.md.sys.state; + +/** + * Slop needed to bring a fixed-size element up to the 48dp minimum + * interactive target, expanding outward rather than resizing. Pass the + * element's own rendered width and/or height; omit an axis that is already + * big enough on its own (e.g. a pill that grows with its label) to opt it out + * of slop entirely. Returns `undefined` when there is nothing to add, so that + * case does not create a new object on every call. + * @see https://developer.android.com/develop/ui/compose/accessibility/api-defaults + */ +const getMinInteractiveSizeHitSlop = ({ + width, + height, +}: { + width?: number; + height?: number; +}): Insets | undefined => { + const horizontal = + width === undefined ? 0 : Math.max(0, (minInteractiveSize - width) / 2); + const vertical = + height === undefined ? 0 : Math.max(0, (minInteractiveSize - height) / 2); + + if (horizontal === 0 && vertical === 0) { + return undefined; + } + + return { + top: vertical, + bottom: vertical, + left: horizontal, + right: horizontal, + }; +}; + +export default getMinInteractiveSizeHitSlop; diff --git a/src/utils/useFocusRing.ts b/src/utils/useFocusRing.ts new file mode 100644 index 0000000000..a13fb75b06 --- /dev/null +++ b/src/utils/useFocusRing.ts @@ -0,0 +1,203 @@ +import * as React from 'react'; +import { + Platform, + type ColorValue, + type NativeSyntheticEvent, + type TargetedEvent, + type ViewStyle, +} from 'react-native'; + +import { isKeyboardFocusEvent } from './isKeyboardFocusEvent'; +import { tokens } from '../theme/tokens'; + +const { thickness, outerOffset } = tokens.md.sys.state.focusIndicator; + +export type FocusRingPlacement = 'outward' | 'inward' | 'none'; + +/** + * `'self'` - the ring is drawn on the element that receives focus. + * `'within'` - the ring is drawn on an ancestor (a track/clip view), because + * the focusable element's own box is the wrong shape or size for it. + */ +export type FocusRingScope = 'self' | 'within'; + +export type FocusRingResult = { + /** Spread onto the element that receives focus. */ + target: { + onFocus?: (e: NativeSyntheticEvent) => void; + onBlur?: () => void; + style: ViewStyle[]; + }; + /** Spread onto the element that draws the ring. */ + ring: { + style: ViewStyle[]; + /** + * Spread onto the ring element, e.g. ``. + * `dataSet` isn't in RN's core view prop types, though react-native-web + * renders it as real `data-*` attributes - the mechanism the shared + * stylesheet below keys off. Typed as `object` so it spreads onto any + * host component without a prop-type mismatch; empty (a no-op spread) + * on native and when the ring is suppressed. + */ + dataSetProps: object; + }; +}; + +const toDataSetProps = (dataSet: Record | undefined): object => + dataSet ? { dataSet } : {}; + +const EMPTY: FocusRingResult = { + target: { style: [] }, + ring: { style: [], dataSetProps: {} }, +}; + +/** Suppresses the browser's own focus ring, for `scope: 'within'` on web. */ +// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion +const webNoOutlineStyle = { outline: 'none' } as unknown as ViewStyle; + +/** + * MD3 keyboard focus indicator, from one hook shared by every component built + * on it. + * + * On native there is no CSS, so this tracks focus in state and computes the + * ring as an `outline*` style, live. On web the ring is real CSS: a shared + * stylesheet keyed off a `data-focus-ring[-within]` attribute and + * `:focus-visible`/`:has(:focus-visible)`, with only the (theme-dependent) + * color passed through as a CSS custom property. Nothing here tracks focus in + * JS on web - the browser drives it. + * + * `scope: 'within'` is for a control whose ring belongs on an ancestor of the + * element that actually receives focus (Switch's track, FAB's clip view): + * `target` also suppresses the browser's default outline on the focused + * element itself on web, so only the ring shows. + * + * iOS: `onFocus`/`onBlur` never reach here today. Fabric's view does call + * `-becomeFirstResponder`/`-resignFirstResponder` for hardware-keyboard/Full + * Keyboard Access navigation, but only emits the JS event when the + * `enableImperativeFocus` feature flag is on, and it defaults off (old + * architecture has no equivalent path at all). Pre-existing, not something + * this hook introduces - the library's older FAB, Checkbox, and Switch rings + * were equally inert on iOS. + */ +export function useFocusRing( + disabled: boolean | undefined, + color: ColorValue, + placement: FocusRingPlacement = 'outward', + scope: FocusRingScope = 'self' +): FocusRingResult { + const suppressed = disabled || placement === 'none'; + + // Rules of hooks: called unconditionally regardless of platform. Cheap - + // native's is a no-op until focus/blur actually fires, web's is stateless. + const [focused, setFocused] = React.useState(false); + + const onFocus = React.useCallback( + (e: NativeSyntheticEvent) => { + if (!suppressed) { + setFocused(isKeyboardFocusEvent(e)); + } + }, + [suppressed] + ); + + const onBlur = React.useCallback(() => setFocused(false), []); + + // The focusable node can unmount while this hook stays mounted, and neither + // the DOM nor React fires blur for that, so clear rather than only masking. + React.useEffect(() => { + if (suppressed) { + setFocused(false); + } + }, [suppressed]); + + const isNativeFocused = focused && !suppressed; + + return React.useMemo(() => { + if (suppressed) { + return EMPTY; + } + + if (Platform.OS === 'web') { + const dataKey = scope === 'within' ? 'focusRingWithin' : 'focusRing'; + return { + target: { style: scope === 'within' ? [webNoOutlineStyle] : [] }, + ring: { + style: [ + // A style key starting with `--` becomes a real CSS custom + // property on web (react-native-web's setValueForStyles), read + // by the shared stylesheet below. Not reactive to focus - the + // browser's own `:focus-visible`/`:has()` shows the ring. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + { ['--rnp-focus-ring-color']: color } as unknown as ViewStyle, + ], + dataSetProps: toDataSetProps({ [dataKey]: placement }), + }, + }; + } + + return { + target: { onFocus, onBlur, style: [] }, + ring: { + style: isNativeFocused + ? [ + { + outlineWidth: thickness, + outlineColor: color, + outlineStyle: 'solid' as const, + outlineOffset: + placement === 'inward' ? -thickness : outerOffset, + }, + ] + : [], + dataSetProps: {}, + }, + }; + }, [suppressed, scope, color, placement, isNativeFocused, onFocus, onBlur]); +} + +const STYLE_ELEMENT_ATTR = 'data-rnp-focus-ring-styles'; + +const focusRingRule = ( + selector: (placement: 'outward' | 'inward') => string +) => ` +${selector('outward')} { + outline: ${thickness}px solid var(--rnp-focus-ring-color); + outline-offset: ${outerOffset}px; +} +${selector('inward')} { + outline: ${thickness}px solid var(--rnp-focus-ring-color); + outline-offset: ${-thickness}px; +}`; + +/** + * The shared web stylesheet text, as a pure function of the design tokens - + * exported so its contents can be asserted without a DOM. + */ +export const buildFocusRingStylesheet = (): string => + [ + focusRingRule((p) => `[data-focus-ring="${p}"]:focus-visible`), + focusRingRule((p) => `[data-focus-ring-within="${p}"]:has(:focus-visible)`), + ].join('\n'); + +let injected = false; + +/** Idempotent: safe to call from every module that needs the ring on web. */ +export const injectFocusRingStylesheet = (): void => { + if (injected || Platform.OS !== 'web' || typeof document === 'undefined') { + return; + } + if (document.querySelector(`style[${STYLE_ELEMENT_ATTR}]`)) { + injected = true; + return; + } + + const style = document.createElement('style'); + style.setAttribute(STYLE_ELEMENT_ATTR, ''); + style.textContent = buildFocusRingStylesheet(); + document.head.appendChild(style); + injected = true; +}; + +if (Platform.OS === 'web') { + injectFocusRingStylesheet(); +}