diff --git a/src/components/Card/Card.tsx b/src/components/Card/Card.tsx index f0fdc89407..7b489746a9 100644 --- a/src/components/Card/Card.tsx +++ b/src/components/Card/Card.tsx @@ -13,6 +13,7 @@ import { useInternalTheme } from '../../core/theming'; import type { $Omit, ThemeProp } from '../../types'; import hasTouchHandler from '../../utils/hasTouchHandler'; import { splitStyles } from '../../utils/splitStyles'; +import { getFocusRingStyle, useFocusRing } from '../../utils/useFocusRing'; import Surface from '../Surface'; type OutlinedCardProps = { @@ -141,6 +142,7 @@ const Card = ({ ...rest }: (OutlinedCardProps | ElevatedCardProps | ContainedCardProps) & Props) => { const theme = useInternalTheme(themeOverrides); + const focusRing = useFocusRing(disabled); const isMode = React.useCallback( (modeToCompare: Mode) => { return cardMode === modeToCompare; @@ -267,6 +269,12 @@ const Card = ({ onPress={onPress} onPressIn={handlePressIn} onPressOut={handlePressOut} + onFocus={focusRing.onFocus} + onBlur={focusRing.onBlur} + style={[ + borderRadiusCombinedStyles, + getFocusRingStyle(focusRing.focused, theme.colors.secondary), + ]} > {content} diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx index f2527383d7..8840b6db89 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'; @@ -22,9 +20,7 @@ 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 { $RemoveChildren, ThemeProp } from '../../types'; -import { isKeyboardFocusEvent } from '../../utils/isKeyboardFocusEvent'; import TouchableRipple from '../TouchableRipple/TouchableRipple'; export type Props = $RemoveChildren & { @@ -78,14 +74,6 @@ 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; - /** * Checkboxes allow the selection of multiple options from a set. * @@ -128,7 +116,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'; @@ -226,19 +213,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'; @@ -262,24 +236,12 @@ const Checkbox = ({ borderless centered onPress={onPress} - onFocus={handleFocus} - onBlur={handleBlur} disabled={disabled} {...accessibilityProps} testID={testID} - style={[ - styles.tapTarget, - Platform.OS === 'web' ? webNoOutline : undefined, - style, - ]} + style={[styles.tapTarget, style]} > - {focused && !disabled ? ( - - ) : null} , 'mode'> & { * export default MyComponent; * ``` */ +/** + * 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; + const Chip = ({ mode = 'flat', children, @@ -201,6 +225,7 @@ const Chip = ({ ...rest }: Props) => { const theme = useInternalTheme(themeOverrides); + const closeFocusRing = useFocusRing(disabled); const isWeb = Platform.OS === 'web'; const { current: elevation } = React.useRef( @@ -273,7 +298,7 @@ const Chip = ({ : 8 * multiplier, }; const contentSpacings = { - paddingRight: onClose ? 34 : 0, + paddingRight: onClose ? CLOSE_AFFORDANCE_WIDTH : 0, }; const labelTextStyle = { color: textColor, @@ -299,6 +324,7 @@ const Chip = ({ > - + {closeIcon ? ( ) : ( @@ -451,6 +493,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', @@ -481,9 +527,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/FAB/Menu.tsx b/src/components/FAB/Menu.tsx index 58a75291cf..7e057729ca 100644 --- a/src/components/FAB/Menu.tsx +++ b/src/components/FAB/Menu.tsx @@ -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,12 @@ import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; import { toRawSpring } from '../../theme/tokens/sys/motion'; import { resolveCornerRadius } from '../../theme/utils/shape'; import type { InternalTheme, ThemeProp } from '../../types'; +import { + getFocusRingStyle, + toStyleList, + useFocusRing, + webNoOutline, +} from '../../utils/useFocusRing'; import Icon from '../Icon'; import type { IconSource } from '../Icon'; import TouchableRipple from '../TouchableRipple/TouchableRipple'; @@ -249,10 +248,7 @@ const MenuItem = ({ MenuTokens.listItem; const borderRadius = resolveCornerRadius(theme, shape); - const { focusedSV, onFocus, onBlur } = useFocusRing(); - const focusRingStyle = useAnimatedStyle(() => ({ - opacity: focusedSV.value ? 1 : 0, - })); + const { focused, onFocus, onBlur } = useFocusRing(); return ( @@ -260,10 +256,12 @@ const MenuItem = ({ style={[ styles.menuItem, { height, borderRadius, backgroundColor: colors.container }, + ...toStyleList(getFocusRingStyle(focused, theme.colors.secondary)), ]} > - ); }; @@ -687,15 +675,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 2d2d60e4cd..e90402d663 100644 --- a/src/components/FAB/Shell.tsx +++ b/src/components/FAB/Shell.tsx @@ -16,19 +16,19 @@ 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 { useVisibility } from './useVisibility'; import { getDimensions, resolveColors } from './utils'; import { useInternalTheme } from '../../core/theming'; import type { ShapeToken } from '../../theme/utils/shape'; import type { Elevation, ThemeProp } from '../../types'; +import { + getFocusRingStyle, + toStyleList, + useFocusRing, + webNoOutline, +} from '../../utils/useFocusRing'; import type { IconSource } from '../Icon'; import TouchableRipple from '../TouchableRipple/TouchableRipple'; @@ -276,14 +276,7 @@ const Shell = ({ [borderRadius, containerBg] ); - const { focusedSV, onFocus, onBlur } = useFocusRing(); - const focusRingStyle = useAnimatedStyle( - () => ({ - opacity: focusedSV.value ? 1 : 0, - borderRadius: borderRadius.value + FOCUS_RING_INSET, - }), - [borderRadius] - ); + const { focused, onFocus, onBlur } = useFocusRing(); return ( - + {overlay} - ); }; @@ -366,15 +359,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 270c9289ac..bd5b3bdfd1 100644 --- a/src/components/IconButton/IconButton.tsx +++ b/src/components/IconButton/IconButton.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Animated, StyleSheet, View } from 'react-native'; +import { Animated, Platform, StyleSheet, View } from 'react-native'; import type { ColorValue, GestureResponderEvent, @@ -10,6 +10,7 @@ import type { import { getIconButtonColor } from './utils'; import { useInternalTheme } from '../../core/theming'; import type { $RemoveChildren, ThemeProp } from '../../types'; +import { splitStyles } from '../../utils/splitStyles'; import ActivityIndicator from '../ActivityIndicator'; import CrossFadeIcon from '../CrossFadeIcon'; import Icon from '../Icon'; @@ -147,16 +148,26 @@ const IconButton = ({ const buttonSize = size + 2 * PADDING; - const { - borderWidth = mode === 'outlined' && !selected ? 1 : 0, - borderRadius = buttonSize / 2, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - } = (StyleSheet.flatten(style) || {}) as ViewStyle; + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const flattenedStyle = (StyleSheet.flatten(style) || {}) as ViewStyle; + + const { borderWidth = mode === 'outlined' && !selected ? 1 : 0 } = + flattenedStyle; + + const [, borderRadiusStyles] = splitStyles( + flattenedStyle, + (style) => style.startsWith('border') && style.endsWith('Radius') + ); + + const shapeStyles = { + borderRadius: buttonSize / 2, + ...borderRadiusStyles, + }; const borderStyles = { borderWidth, - borderRadius, borderColor, + ...shapeStyles, }; return ( @@ -182,6 +193,7 @@ const IconButton = ({ style={[ StyleSheet.absoluteFill, { backgroundColor, opacity: backgroundOpacity }, + shapeStyles, ]} /> )} @@ -190,15 +202,18 @@ 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} {...rest} > @@ -216,7 +231,9 @@ const IconButton = ({ const styles = StyleSheet.create({ container: { - overflow: 'hidden', + // 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, elevation: 0, }, @@ -225,6 +242,9 @@ const styles = StyleSheet.create({ 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 a6f0181f02..d9c09849d8 100644 --- a/src/components/List/ListItem.tsx +++ b/src/components/List/ListItem.tsx @@ -228,6 +228,7 @@ const ListItem = ({ handlePress({ onPress: onPress, diff --git a/src/components/SegmentedButtons/SegmentedButtonItem.tsx b/src/components/SegmentedButtons/SegmentedButtonItem.tsx index 4f701f0b48..4cd9beae41 100644 --- a/src/components/SegmentedButtons/SegmentedButtonItem.tsx +++ b/src/components/SegmentedButtons/SegmentedButtonItem.tsx @@ -194,6 +194,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); @@ -323,10 +332,6 @@ const Switch = ({ ], })); - const focusRingAnimatedStyle = useAnimatedStyle(() => ({ - opacity: focusedSV.value, - })); - const paint = resolveSwitchPaint(colors, isEnabled, checked); const stateLayerColor = checked ? colors.checkedStateLayerColor @@ -363,10 +368,11 @@ const Switch = ({ hoveredSV.value = 0; }} onFocus={(e) => { - if (!isKeyboardFocusEvent(e)) return; - focusedSV.value = 1; + focusRing.onFocus(e); + if (!isDisabled && isKeyboardFocusEvent(e)) focusedSV.value = 1; }} onBlur={() => { + focusRing.onBlur(); focusedSV.value = 0; }} android_ripple={{ color: 'transparent' }} @@ -384,6 +390,9 @@ const Switch = ({ style={[ styles.track, { backgroundColor: paint.track, opacity: trackOpacityValue }, + ...toStyleList( + getFocusRingStyle(focusRing.focused, colors.focusIndicatorColor) + ), ]} > {showOutline ? ( @@ -446,22 +455,6 @@ const Switch = ({ ) : null} - - ); }; @@ -525,10 +518,6 @@ const styles = StyleSheet.create({ height: SELECTED_ICON, pointerEvents: 'none', }, - focusRing: { - position: 'absolute', - pointerEvents: 'none', - }, absoluteFill: { position: 'absolute', top: 0, @@ -538,8 +527,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 513355afa5..a97e97d386 100644 --- a/src/components/TouchableRipple/TouchableRipple.native.tsx +++ b/src/components/TouchableRipple/TouchableRipple.native.tsx @@ -6,6 +6,10 @@ import type { ViewStyle, GestureResponderEvent, ColorValue, + Insets, + LayoutChangeEvent, + NativeSyntheticEvent, + TargetedEvent, } from 'react-native'; import type { PressableProps } from './Pressable'; @@ -14,17 +18,104 @@ import { getTouchableRippleColors } from './utils'; import { SettingsContext } from '../../core/settings'; import type { Settings } from '../../core/settings'; import { useInternalTheme } from '../../core/theming'; +import { tokens } from '../../theme/tokens'; import type { ThemeProp } from '../../types'; import hasTouchHandler from '../../utils/hasTouchHandler'; +import type { FocusRingPlacement } from '../../utils/useFocusRing'; +import { + getFocusRingStyle, + toStyleList, + useFocusRing, +} from '../../utils/useFocusRing'; const ANDROID_VERSION_LOLLIPOP = 21; const ANDROID_VERSION_PIE = 28; +const { minInteractiveSize } = tokens.md.sys.state; + +/** + * 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 for the expansion to work. + */ +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, + }; +}; + +/** + * Slop needed to bring a rendered size up to `minInteractiveSize`. Expands + * outside the bounds rather than resizing, so a 40dp state layer keeps its 40dp + * and gains 4dp per side. Returns undefined when the size is already enough, so + * that case does not re-render. + * @see https://developer.android.com/develop/ui/compose/accessibility/api-defaults + */ +const getExpansion = (width: number, height: number): Insets | undefined => { + // A collapsed touchable would otherwise claim 24dp of slop around a point + // where nothing is drawn. + if (width === 0 || height === 0) { + return undefined; + } + + const horizontal = Math.max(0, (minInteractiveSize - width) / 2); + const vertical = Math.max(0, (minInteractiveSize - height) / 2); + + if (horizontal === 0 && vertical === 0) { + return undefined; + } + + return { + top: vertical, + bottom: vertical, + left: horizontal, + right: horizontal, + }; +}; + 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, which does not dispatch focus events for a + * `Pressable`. + */ + focusRing?: FocusRingPlacement; onPress?: (e: GestureResponderEvent) => void | null; onLongPress?: (e: GestureResponderEvent) => void; onPressIn?: (e: GestureResponderEvent) => void; @@ -46,6 +137,11 @@ const TouchableRipple = ({ underlayColor, children, theme: themeOverrides, + hitSlop, + onLayout, + focusRing = 'outward', + onFocus, + onBlur, ref, ...rest }: Props) => { @@ -63,6 +159,58 @@ const TouchableRipple = ({ const disabled = disabledProp || !hasPassedTouchHandler; + const ring = useFocusRing(disabled || focusRing === 'none'); + const handleFocus = (e: NativeSyntheticEvent) => { + onFocus?.(e); + ring.onFocus(e); + }; + const handleBlur = (e: NativeSyntheticEvent) => { + onBlur?.(e); + ring.onBlur(); + }; + const ringStyles = toStyleList( + getFocusRingStyle(ring.focused, theme.colors.secondary, focusRing) + ); + + const [expansion, setExpansion] = React.useState( + undefined + ); + + // Gates whether the measurement is applied, not whether it happens. RN emits + // onLayout on mount and on layout change, so a touchable that mounts disabled, + // or with a caller hitSlop, gets no event once that goes away and would stay + // small. A caller hitSlop wins while it is set; `null` counts as set, it means + // "no slop". + const shouldExpand = hitSlop === undefined && !disabled; + + const handleLayout = React.useCallback( + (event: LayoutChangeEvent) => { + onLayout?.(event); + + const { width, height } = event.nativeEvent.layout; + const next = getExpansion(width, height); + + setExpansion((current) => { + // Nothing changed, so a big enough touchable does not re-render. + if (current === next) { + return current; + } + if ( + current && + next && + current.top === next.top && + current.bottom === next.bottom && + current.left === next.left && + current.right === next.right + ) { + return current; + } + return next; + }); + }, + [onLayout] + ); + const { calculatedRippleColor, calculatedUnderlayColor } = getTouchableRippleColors({ theme, @@ -92,7 +240,11 @@ const TouchableRipple = ({ {...rest} ref={ref} disabled={disabled} - style={[useForeground && styles.overflowHidden, style]} + hitSlop={shouldExpand ? expansion : hitSlop} + onLayout={handleLayout} + onFocus={handleFocus} + onBlur={handleBlur} + style={[useForeground && styles.overflowHidden, style, ...ringStyles]} android_ripple={androidRipple} > {React.Children.only(children)} @@ -105,7 +257,11 @@ const TouchableRipple = ({ {...rest} ref={ref} disabled={disabled} - style={[borderless && styles.overflowHidden, style]} + hitSlop={shouldExpand ? expansion : hitSlop} + onLayout={handleLayout} + onFocus={handleFocus} + onBlur={handleBlur} + style={[borderless && styles.overflowHidden, style, ...ringStyles]} > {({ pressed }) => ( <> @@ -114,6 +270,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 d913b1ee3c..1f978f37b9 100644 --- a/src/components/TouchableRipple/TouchableRipple.tsx +++ b/src/components/TouchableRipple/TouchableRipple.tsx @@ -3,7 +3,9 @@ import { Platform, StyleSheet, View } from 'react-native'; import type { ColorValue, GestureResponderEvent, + NativeSyntheticEvent, StyleProp, + TargetedEvent, ViewStyle, } from 'react-native'; @@ -15,12 +17,64 @@ import { getTouchableRippleColors } from './utils'; import { SettingsContext } from '../../core/settings'; import type { Settings } from '../../core/settings'; import { useInternalTheme } from '../../core/theming'; +import { tokens } from '../../theme/tokens'; import type { ThemeProp } from '../../types'; import hasTouchHandler from '../../utils/hasTouchHandler'; +import type { FocusRingPlacement } from '../../utils/useFocusRing'; +import { + getFocusRingStyle, + toStyleList, + useFocusRing, +} from '../../utils/useFocusRing'; + +const { minInteractiveSize } = tokens.md.sys.state; + +/** + * react-native-web removed `hitSlop` in 0.13.0, so web needs a real element the + * browser can hit-test instead. An absolutely positioned box at least the + * minimum target size, which is what material-web does, and it costs no layout. + * @see https://github.com/necolas/react-native-web/releases/tag/0.13.0 + * @see https://github.com/material-components/material-web/blob/main/iconbutton/internal/_shared.scss + */ +const getTouchTargetStyle = (hitSlop: PressableProps['hitSlop']): ViewStyle => { + // `undefined` means the caller said nothing, so the minimum applies. `null` + // means "no slop", same as native. + if (hitSlop === undefined) { + return styles.touchTarget; + } + if (hitSlop === null) { + return styles.noTouchTarget; + } + + // A caller hitSlop wins here too, so web matches native instead of ignoring + // the prop. + 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 +90,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, which does not dispatch focus events for a + * `Pressable`. + */ + focusRing?: FocusRingPlacement; /** * Function to execute on press. If not set, will cause the touchable to be disabled. */ @@ -105,12 +171,17 @@ 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', + onFocus, + onBlur, ref, ...rest }: Props) => { @@ -178,7 +249,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,28 +353,57 @@ const TouchableRipple = ({ const disabled = disabledProp || !hasPassedTouchHandler; + const ring = useFocusRing(disabled || focusRing === 'none'); + const handleFocus = (e: NativeSyntheticEvent) => { + onFocus?.(e); + ring.onFocus(e); + }; + const handleBlur = (e: NativeSyntheticEvent) => { + onBlur?.(e); + ring.onBlur(); + }; + 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, so the ring is + // driven by onFocus instead: https://github.com/necolas/react-native-web/issues/1849 state.hovered && { backgroundColor: hoverColor }, disabled && styles.disabled, typeof style === 'function' ? style(state) : style, + ...toStyleList( + getFocusRingStyle(ring.focused, theme.colors.secondary, focusRing) + ), ]} > - {(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 +426,23 @@ const styles = StyleSheet.create({ cursor: 'auto', }), }, - borderless: { - overflow: 'hidden', + noTouchTarget: { + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + right: 0, + }, + touchTarget: { + position: 'absolute', + top: '50%', + left: '50%', + // max(minInteractiveSize, 100%), same as MD3 web's .touch + width: '100%', + height: '100%', + minWidth: minInteractiveSize, + minHeight: minInteractiveSize, + transform: [{ translateX: '-50%' }, { translateY: '-50%' }], }, }); diff --git a/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap b/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap index a5d9d95766..9327719388 100644 --- a/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap +++ b/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap @@ -109,7 +109,6 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` "borderWidth": 0, "elevation": 0, "flex": 1, - "overflow": "hidden", "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { "height": 0, @@ -144,17 +143,10 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` centered={true} collapsable={false} focusable={true} - hitSlop={ - { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, - } - } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -173,6 +165,12 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderRadius": 20, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -299,7 +297,6 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` "borderWidth": 0, "elevation": 0, "flex": 1, - "overflow": "hidden", "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { "height": 0, @@ -334,17 +331,10 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` centered={true} collapsable={false} focusable={true} - hitSlop={ - { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, - } - } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -363,6 +353,12 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderRadius": 20, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -488,7 +484,6 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A "borderWidth": 0, "elevation": 0, "flex": 1, - "overflow": "hidden", "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { "height": 0, @@ -523,17 +518,10 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A centered={true} collapsable={false} focusable={true} - hitSlop={ - { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, - } - } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -552,6 +540,12 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A "flexGrow": 1, "justifyContent": "center", }, + { + "borderRadius": 20, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -732,7 +726,6 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A "borderWidth": 0, "elevation": 0, "flex": 1, - "overflow": "hidden", "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { "height": 0, @@ -766,17 +759,10 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A centered={true} collapsable={false} focusable={true} - hitSlop={ - { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, - } - } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -795,6 +781,12 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A "flexGrow": 1, "justifyContent": "center", }, + { + "borderRadius": 20, + }, + { + "overflow": "hidden", + }, undefined, ], ] diff --git a/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap b/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap index 54f2e4f7a4..7cc0010786 100644 --- a/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap +++ b/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap @@ -27,6 +27,7 @@ exports[`renders Checkbox with custom testID 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -48,7 +49,6 @@ exports[`renders Checkbox with custom testID 1`] = ` "width": 40, }, undefined, - undefined, ], ] } @@ -213,6 +213,7 @@ exports[`renders checked Checkbox with color 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -234,7 +235,6 @@ exports[`renders checked Checkbox with color 1`] = ` "width": 40, }, undefined, - undefined, ], ] } @@ -398,6 +398,7 @@ exports[`renders checked Checkbox with onPress 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -419,7 +420,6 @@ exports[`renders checked Checkbox with onPress 1`] = ` "width": 40, }, undefined, - undefined, ], ] } @@ -583,6 +583,7 @@ exports[`renders indeterminate Checkbox 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -604,7 +605,6 @@ exports[`renders indeterminate Checkbox 1`] = ` "width": 40, }, undefined, - undefined, ], ] } @@ -755,6 +755,7 @@ exports[`renders indeterminate Checkbox with color 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -776,7 +777,6 @@ exports[`renders indeterminate Checkbox with color 1`] = ` "width": 40, }, undefined, - undefined, ], ] } @@ -927,6 +927,7 @@ exports[`renders unchecked Checkbox with color 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -948,7 +949,6 @@ exports[`renders unchecked Checkbox with color 1`] = ` "width": 40, }, undefined, - undefined, ], ] } @@ -1112,6 +1112,7 @@ exports[`renders unchecked Checkbox with onPress 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1133,7 +1134,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 0f7f92073b..c8fad1923c 100644 --- a/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap +++ b/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap @@ -26,6 +26,7 @@ exports[`can render leading checkbox control 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -81,6 +82,7 @@ exports[`can render leading checkbox control 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -101,7 +103,6 @@ exports[`can render leading checkbox control 1`] = ` "width": 40, }, undefined, - undefined, ], ] } @@ -304,6 +305,7 @@ exports[`renders unchecked 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -397,6 +399,7 @@ exports[`renders unchecked 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -417,7 +420,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 644906ae33..c676da734a 100644 --- a/src/components/__tests__/Chip.test.tsx +++ b/src/components/__tests__/Chip.test.tsx @@ -402,3 +402,41 @@ it('animated value changes correctly', async () => { transform: [{ scale: 1.5 }], }); }); + +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 b28456c5ce..89284b99b6 100644 --- a/src/components/__tests__/IconButton.test.tsx +++ b/src/components/__tests__/IconButton.test.tsx @@ -19,6 +19,9 @@ const styles = StyleSheet.create({ slightlyRounded: { borderRadius: 4, }, + cutCorner: { + borderTopLeftRadius: 0, + }, }); it('renders icon button by default', async () => { @@ -85,6 +88,24 @@ it('renders icon button with small border radius', async () => { }); }); +it('clips to a custom corner radius', async () => { + await render( + {}} + style={styles.cutCorner} + /> + ); + + // 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/__snapshots__/RadioButton.test.tsx.snap b/src/components/__tests__/RadioButton/__snapshots__/RadioButton.test.tsx.snap index c20910f20e..31dffb6970 100644 --- a/src/components/__tests__/RadioButton/__snapshots__/RadioButton.test.tsx.snap +++ b/src/components/__tests__/RadioButton/__snapshots__/RadioButton.test.tsx.snap @@ -26,6 +26,7 @@ exports[`RadioButton RadioButton with custom testID renders properly 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -113,6 +114,7 @@ exports[`RadioButton on default platform renders properly 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -199,6 +201,7 @@ exports[`RadioButton on ios platform renders properly 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -285,6 +288,7 @@ exports[`RadioButton when RadioButton is wrapped by RadioButtonContext.Provider onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} diff --git a/src/components/__tests__/RadioButton/__snapshots__/RadioButtonGroup.test.tsx.snap b/src/components/__tests__/RadioButton/__snapshots__/RadioButtonGroup.test.tsx.snap index 1ae7f560be..54a237c099 100644 --- a/src/components/__tests__/RadioButton/__snapshots__/RadioButtonGroup.test.tsx.snap +++ b/src/components/__tests__/RadioButton/__snapshots__/RadioButtonGroup.test.tsx.snap @@ -29,6 +29,7 @@ exports[`RadioButtonGroup renders properly 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} diff --git a/src/components/__tests__/RadioButton/__snapshots__/RadioButtonItem.test.tsx.snap b/src/components/__tests__/RadioButton/__snapshots__/RadioButtonItem.test.tsx.snap index 5867b1408c..ef32fb657b 100644 --- a/src/components/__tests__/RadioButton/__snapshots__/RadioButtonItem.test.tsx.snap +++ b/src/components/__tests__/RadioButton/__snapshots__/RadioButtonItem.test.tsx.snap @@ -26,6 +26,7 @@ exports[`can render leading radio button control 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -80,6 +81,7 @@ exports[`can render leading radio button control 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -204,6 +206,7 @@ exports[`can render the Android radio button on different platforms 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -294,6 +297,7 @@ exports[`can render the Android radio button on different platforms 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -356,6 +360,7 @@ exports[`can render the iOS radio button on different platforms 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -446,6 +451,7 @@ exports[`can render the iOS radio button on different platforms 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -534,6 +540,7 @@ exports[`renders unchecked 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -624,6 +631,7 @@ exports[`renders unchecked 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} diff --git a/src/components/__tests__/TouchableRipple.test.tsx b/src/components/__tests__/TouchableRipple.test.tsx index f3c4cb1168..37ecb86837 100644 --- a/src/components/__tests__/TouchableRipple.test.tsx +++ b/src/components/__tests__/TouchableRipple.test.tsx @@ -1,8 +1,9 @@ +import * as React from 'react'; 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 +69,304 @@ 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('minimum interactive size', () => { + const layout = (width: number, height: number) => ({ + nativeEvent: { layout: { width, height, x: 0, y: 0 } }, + }); + + // hitSlop has no user-visible effect here, the renderer does not lay views + // out or hit-test them. Real behaviour is checked on device; this only stops + // the props being dropped. + /* eslint-disable no-restricted-syntax */ + const hitSlopOf = () => screen.getByTestId('touchable').props.hitSlop; + const onLayoutOf = () => screen.getByTestId('touchable').props.onLayout; + /* eslint-enable no-restricted-syntax */ + + const renderTouchable = async (props = {}) => { + await render( + {}} {...props}> + Button + + ); + return screen.getByTestId('touchable'); + }; + + const fireLayout = async (width: number, height: number) => { + await act(async () => { + await fireEvent( + screen.getByTestId('touchable'), + 'layout', + layout(width, height) + ); + }); + }; + + it('expands a small target out to the minimum interactive size', async () => { + await renderTouchable(); + expect(hitSlopOf()).toBeUndefined(); + + await fireLayout(32, 32); + + // (48 - 32) / 2 on every side + expect(hitSlopOf()).toEqual({ top: 8, bottom: 8, left: 8, right: 8 }); + }); + + it('expands each axis independently', async () => { + await renderTouchable(); + + await fireLayout(40, 100); + + expect(hitSlopOf()).toEqual({ top: 0, bottom: 0, left: 4, right: 4 }); + }); + + it('leaves a target that is already big enough alone', async () => { + await renderTouchable(); + + await fireLayout(48, 48); + + expect(hitSlopOf()).toBeUndefined(); + }); + + it('lets a caller-supplied hitSlop win', async () => { + await renderTouchable({ hitSlop: 2 }); + + await fireLayout(32, 32); + + expect(hitSlopOf()).toBe(2); + }); + + it('does not expand a touchable with no touch handlers', async () => { + await render( + + Not a control + + ); + + expect(hitSlopOf()).toBeUndefined(); + }); + + // Measuring and applying are separate. RN emits onLayout on mount and on + // layout change, so measuring only once interactive would mean no event ever + // arrives and the target stays small. + it('measures even while it cannot be pressed', async () => { + await renderTouchable({ disabled: true }); + + expect(onLayoutOf()).toEqual(expect.any(Function)); + expect(hitSlopOf()).toBeUndefined(); + }); + + it('does not expand a disabled touchable', async () => { + await renderTouchable({ disabled: true }); + + await fireLayout(32, 32); + + expect(hitSlopOf()).toBeUndefined(); + }); + + it('keeps the measurement across losing and regaining interactivity', async () => { + const Harness = ({ disabled }: { disabled: boolean }) => ( + {}} + > + Button + + ); + const view = await render(); + const expanded = { top: 8, bottom: 8, left: 8, right: 8 }; + + await fireLayout(32, 32); + expect(hitSlopOf()).toEqual(expanded); + + await act(async () => { + await view.rerender(); + }); + expect(hitSlopOf()).toBeUndefined(); + + // back again, with no second layout event to rely on + await act(async () => { + await view.rerender(); + }); + expect(hitSlopOf()).toEqual(expanded); + }); + + it('expands once a caller-supplied hitSlop is taken away', async () => { + const Harness = ({ hitSlop }: { hitSlop?: number }) => ( + {}} + > + Button + + ); + const view = await render(); + + await fireLayout(32, 32); + expect(hitSlopOf()).toBe(2); + + // back to the default, with no second layout event to rely on + await act(async () => { + await view.rerender(); + }); + expect(hitSlopOf()).toEqual({ top: 8, bottom: 8, left: 8, right: 8 }); + }); + + it('still calls a caller-supplied onLayout', async () => { + const onLayout = jest.fn(); + await renderTouchable({ onLayout }); + + await fireLayout(32, 32); + + expect(onLayout).toHaveBeenCalledTimes(1); + }); + + describe('render cost', () => { + // TouchableRipple renders everywhere, so the cost of measuring is worth + // pinning down. + const withProfiler = async () => { + const commits: string[] = []; + await render( + commits.push(phase)} + > + {}}> + Button + + + ); + return commits; + }; + + it('costs no extra render when the target is already big enough', async () => { + const commits = await withProfiler(); + expect(commits).toEqual(['mount']); + + await fireLayout(56, 56); + + // the updater returned the identical value, so React bails out + expect(commits).toEqual(['mount']); + }); + + it('costs one extra render when the target is too small', async () => { + const commits = await withProfiler(); + + await fireLayout(32, 32); + + expect(commits).toEqual(['mount', 'update']); + }); + + it('settles after a repeated layout at the same size', async () => { + const commits = await withProfiler(); + + await fireLayout(32, 32); + await fireLayout(32, 32); + await fireLayout(32, 32); + + // React renders once more before it can bail out on an unchanged value, + // then stops. Three more layout events, one more render. + expect(commits).toEqual(['mount', 'update', 'update']); + }); + }); + }); +}); + +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..7b34a946ff --- /dev/null +++ b/src/components/__tests__/TouchableRippleFocusWeb.test.tsx @@ -0,0 +1,95 @@ +import { Platform, Text } 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'; +// 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'; + +const { thickness, outerOffset } = tokens.md.sys.state.focusIndicator; + +// react-native-web hands `onFocus` a real DOM node, and `isKeyboardFocusEvent` +// asks it whether it matches `:focus-visible`. +const keyboardFocus = { currentTarget: { matches: () => true } }; +const pointerFocus = { currentTarget: { matches: () => false } }; + +const focus = async (data: unknown) => { + await act(async () => { + await fireEvent(screen.getByTestId('ripple'), 'focus', data); + }); +}; + +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('rings on keyboard focus, in the theme secondary colour', async () => { + await renderRipple(); + + await focus(keyboardFocus); + + // colour matters: a ring the same colour as its surface is invisible + expect(screen.getByTestId('ripple')).toHaveStyle({ + outlineWidth: thickness, + outlineOffset: outerOffset, + outlineStyle: 'solid', + outlineColor: 'rgba(98, 91, 113, 1)', + }); + }); + + it('does not ring on a pointer focus', async () => { + await renderRipple(); + + await focus(pointerFocus); + + expect(screen.getByTestId('ripple')).not.toHaveStyle({ + outlineWidth: thickness, + }); + }); + + it('draws inward when asked', async () => { + await renderRipple({ focusRing: 'inward' }); + + await focus(keyboardFocus); + + expect(screen.getByTestId('ripple')).toHaveStyle({ + outlineOffset: -thickness, + }); + }); + + it('forwards onFocus and onBlur to the caller', async () => { + const onFocus = jest.fn(); + const onBlur = jest.fn(); + await renderRipple({ onFocus, onBlur }); + + await focus(keyboardFocus); + 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..ccb07f18eb --- /dev/null +++ b/src/components/__tests__/TouchableRippleWeb.test.tsx @@ -0,0 +1,134 @@ +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 minimum sized touch target for an interactive touchable', async () => { + await render( + {}}> + Button + + ); + + expect(screen.getByTestId(TARGET, HIDDEN)).toBeOnTheScreen(); + expect(styleOf(TARGET)).toMatchObject({ + position: 'absolute', + minWidth: 48, + minHeight: 48, + width: '100%', + height: '100%', + }); + }); + + 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__/Banner.test.tsx.snap b/src/components/__tests__/__snapshots__/Banner.test.tsx.snap index 4e75db8cc7..867ceb8c76 100644 --- a/src/components/__tests__/__snapshots__/Banner.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Banner.test.tsx.snap @@ -181,6 +181,7 @@ exports[`render visible banner, with custom theme 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -614,6 +615,7 @@ exports[`renders visible banner, with action buttons and with image 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -890,6 +892,7 @@ exports[`renders visible banner, with action buttons and without image 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1042,6 +1045,7 @@ exports[`renders visible banner, with action buttons and without image 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} diff --git a/src/components/__tests__/__snapshots__/Button.test.tsx.snap b/src/components/__tests__/__snapshots__/Button.test.tsx.snap index bbc1fff1be..ded51c64a4 100644 --- a/src/components/__tests__/__snapshots__/Button.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Button.test.tsx.snap @@ -65,6 +65,7 @@ exports[`renders button with an accessibility hint 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -218,6 +219,7 @@ exports[`renders button with an accessibility label 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -370,6 +372,7 @@ exports[`renders button with button color 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -522,6 +525,7 @@ exports[`renders button with color 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -674,6 +678,7 @@ exports[`renders button with custom testID 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -826,6 +831,7 @@ exports[`renders button with icon 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1027,6 +1033,7 @@ exports[`renders button with icon in reverse order 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1230,6 +1237,7 @@ exports[`renders contained contained with mode 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1383,6 +1391,7 @@ exports[`renders disabled button 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1535,6 +1544,7 @@ exports[`renders loading button 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1891,6 +1901,7 @@ exports[`renders outlined button with mode 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2044,6 +2055,7 @@ exports[`renders text button by default 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2196,6 +2208,7 @@ exports[`renders text button with mode 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} diff --git a/src/components/__tests__/__snapshots__/Chip.test.tsx.snap b/src/components/__tests__/__snapshots__/Chip.test.tsx.snap index 7bf18dde0e..8a59b9dd4a 100644 --- a/src/components/__tests__/__snapshots__/Chip.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Chip.test.tsx.snap @@ -64,6 +64,7 @@ exports[`renders chip with close button 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -204,11 +205,12 @@ exports[`renders chip with close button 1`] = ` @@ -244,6 +246,18 @@ exports[`renders chip with close button 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} role="button" + style={ + [ + { + "height": "100%", + "justifyContent": "center", + "width": "100%", + }, + { + "borderRadius": 8, + }, + ] + } > @@ -542,6 +560,18 @@ exports[`renders chip with custom close button 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} role="button" + style={ + [ + { + "height": "100%", + "justifyContent": "center", + "width": "100%", + }, + { + "borderRadius": 8, + }, + ] + } > - `; @@ -247,6 +226,7 @@ exports[`renders FAB medium size 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -319,28 +299,6 @@ exports[`renders FAB medium size 1`] = ` - `; @@ -419,6 +377,7 @@ exports[`renders FAB transitioning to not visible 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -491,28 +450,6 @@ exports[`renders FAB transitioning to not visible 1`] = ` - `; @@ -591,6 +528,7 @@ exports[`renders FAB transitioning to visible 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -663,28 +601,6 @@ exports[`renders FAB transitioning to visible 1`] = ` - `; @@ -764,6 +680,7 @@ exports[`renders FAB with aria-label 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -836,28 +753,6 @@ exports[`renders FAB with aria-label 1`] = ` - `; @@ -936,6 +831,7 @@ exports[`renders FAB with containerColor and contentColor overrides 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1008,28 +904,6 @@ exports[`renders FAB with containerColor and contentColor overrides 1`] = ` - `; @@ -1108,6 +982,7 @@ exports[`renders FAB with containerColor override 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1180,28 +1055,6 @@ exports[`renders FAB with containerColor override 1`] = ` - `; @@ -1280,6 +1133,7 @@ exports[`renders FAB with default props 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1352,28 +1206,6 @@ exports[`renders FAB with default props 1`] = ` - `; @@ -1452,6 +1284,7 @@ exports[`renders FAB with primary variant 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1524,28 +1357,6 @@ exports[`renders FAB with primary variant 1`] = ` - `; @@ -1624,6 +1435,7 @@ exports[`renders FAB with secondary variant 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1696,28 +1508,6 @@ exports[`renders FAB with secondary variant 1`] = ` - `; @@ -1796,6 +1586,7 @@ exports[`renders FAB with tertiary variant 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1868,28 +1659,6 @@ exports[`renders FAB with tertiary variant 1`] = ` - `; @@ -1968,6 +1737,7 @@ exports[`renders FAB with tonalSecondary variant 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2040,28 +1810,6 @@ exports[`renders FAB with tonalSecondary variant 1`] = ` - `; @@ -2140,6 +1888,7 @@ exports[`renders FAB with tonalTertiary variant 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2212,27 +1961,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 d159f82912..cfbc27b02e 100644 --- a/src/components/__tests__/__snapshots__/FABExtended.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/FABExtended.test.tsx.snap @@ -77,6 +77,7 @@ exports[`renders extended FAB collapsed 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -182,28 +183,6 @@ exports[`renders extended FAB collapsed 1`] = ` - - - - - - - - @@ -516,6 +474,7 @@ exports[`renders FAB.Menu closed 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -657,28 +616,6 @@ exports[`renders FAB.Menu closed 1`] = ` - @@ -808,6 +745,7 @@ exports[`renders FAB.Menu not expanded when trigger is not visible 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -880,28 +818,6 @@ exports[`renders FAB.Menu not expanded when trigger is not visible 1`] = ` - - @@ -1201,6 +1096,7 @@ exports[`renders FAB.Menu not expanded when trigger is not visible 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1342,28 +1238,6 @@ exports[`renders FAB.Menu not expanded when trigger is not visible 1`] = ` - @@ -1493,6 +1367,7 @@ exports[`renders FAB.Menu open 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1565,28 +1440,6 @@ exports[`renders FAB.Menu open 1`] = ` - - @@ -1886,6 +1718,7 @@ exports[`renders FAB.Menu open 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2027,28 +1860,6 @@ exports[`renders FAB.Menu open 1`] = ` - @@ -2178,6 +1989,7 @@ exports[`renders FAB.Menu with 6 items 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2250,28 +2062,6 @@ exports[`renders FAB.Menu with 6 items 1`] = ` - - - - - - @@ -3231,6 +2916,7 @@ exports[`renders FAB.Menu with 6 items 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -3372,28 +3058,6 @@ exports[`renders FAB.Menu with 6 items 1`] = ` - @@ -3524,6 +3188,7 @@ exports[`renders FAB.Menu with center alignment 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -3596,28 +3261,6 @@ exports[`renders FAB.Menu with center alignment 1`] = ` - - @@ -3917,6 +3539,7 @@ exports[`renders FAB.Menu with center alignment 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -4058,28 +3681,6 @@ exports[`renders FAB.Menu with center alignment 1`] = ` - @@ -4209,6 +3810,7 @@ exports[`renders FAB.Menu with items having icons 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -4310,28 +3912,6 @@ exports[`renders FAB.Menu with items having icons 1`] = ` - - @@ -4660,6 +4219,7 @@ exports[`renders FAB.Menu with items having icons 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -4801,28 +4361,6 @@ exports[`renders FAB.Menu with items having icons 1`] = ` - @@ -4952,6 +4490,7 @@ exports[`renders FAB.Menu with start alignment 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -5024,28 +4563,6 @@ exports[`renders FAB.Menu with start alignment 1`] = ` - - @@ -5345,6 +4841,7 @@ exports[`renders FAB.Menu with start alignment 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -5486,28 +4983,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 731830ff73..917101fc5f 100644 --- a/src/components/__tests__/__snapshots__/IconButton.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/IconButton.test.tsx.snap @@ -31,7 +31,6 @@ exports[`renders disabled icon button 1`] = ` "borderWidth": 0, "elevation": 0, "flex": 1, - "overflow": "hidden", "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { "height": 0, @@ -65,17 +64,10 @@ 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]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -94,6 +86,12 @@ exports[`renders disabled icon button 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderRadius": 20, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -173,7 +171,6 @@ exports[`renders icon button by default 1`] = ` "borderWidth": 0, "elevation": 0, "flex": 1, - "overflow": "hidden", "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { "height": 0, @@ -207,17 +204,10 @@ exports[`renders icon button by default 1`] = ` centered={true} collapsable={false} focusable={true} - hitSlop={ - { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, - } - } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -236,6 +226,12 @@ exports[`renders icon button by default 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderRadius": 20, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -315,7 +311,6 @@ exports[`renders icon button with color 1`] = ` "borderWidth": 0, "elevation": 0, "flex": 1, - "overflow": "hidden", "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { "height": 0, @@ -349,17 +344,10 @@ exports[`renders icon button with color 1`] = ` centered={true} collapsable={false} focusable={true} - hitSlop={ - { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, - } - } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -378,6 +366,12 @@ exports[`renders icon button with color 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderRadius": 20, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -457,7 +451,6 @@ exports[`renders icon button with size 1`] = ` "borderWidth": 0, "elevation": 0, "flex": 1, - "overflow": "hidden", "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { "height": 0, @@ -491,17 +484,10 @@ exports[`renders icon button with size 1`] = ` centered={true} collapsable={false} focusable={true} - hitSlop={ - { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, - } - } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -520,6 +506,12 @@ exports[`renders icon button with size 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderRadius": 23, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -599,7 +591,6 @@ exports[`renders icon change animated 1`] = ` "borderWidth": 0, "elevation": 0, "flex": 1, - "overflow": "hidden", "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { "height": 0, @@ -633,17 +624,10 @@ exports[`renders icon change animated 1`] = ` centered={true} collapsable={false} focusable={true} - hitSlop={ - { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, - } - } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -662,6 +646,12 @@ exports[`renders icon change animated 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderRadius": 20, + }, + { + "overflow": "hidden", + }, undefined, ], ] diff --git a/src/components/__tests__/__snapshots__/ListAccordion.test.tsx.snap b/src/components/__tests__/__snapshots__/ListAccordion.test.tsx.snap index 66fa6d11e8..8b293d3b43 100644 --- a/src/components/__tests__/__snapshots__/ListAccordion.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/ListAccordion.test.tsx.snap @@ -33,6 +33,7 @@ exports[`renders expanded accordion 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -181,6 +182,7 @@ exports[`renders expanded accordion 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -298,6 +300,7 @@ exports[`renders list accordion with children 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -505,6 +508,7 @@ exports[`renders list accordion with custom title and description styles 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -705,6 +709,7 @@ exports[`renders list accordion with left items 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -912,6 +917,7 @@ exports[`renders multiline list accordion 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} diff --git a/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap b/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap index f06b87045f..11f09645a3 100644 --- a/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap @@ -25,6 +25,7 @@ exports[`renders list item with custom description 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -180,6 +181,7 @@ exports[`renders list item with custom description 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -351,6 +353,7 @@ exports[`renders list item with custom title and description styles 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -496,6 +499,7 @@ exports[`renders list item with left and right items 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -686,6 +690,7 @@ exports[`renders list item with left item 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -842,6 +847,7 @@ exports[`renders list item with right item 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -954,6 +960,7 @@ exports[`renders list item with title and description 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1095,6 +1102,7 @@ exports[`renders with a description with typeof number 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} diff --git a/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap b/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap index 7e7dcc158c..01e5e17a33 100644 --- a/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap @@ -477,6 +477,7 @@ exports[`renders list section with custom title style 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -630,6 +631,7 @@ exports[`renders list section with custom title style 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1237,6 +1239,7 @@ exports[`renders list section with subheader 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1390,6 +1393,7 @@ exports[`renders list section with subheader 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1957,6 +1961,7 @@ exports[`renders list section without subheader 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2110,6 +2115,7 @@ exports[`renders list section without subheader 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} diff --git a/src/components/__tests__/__snapshots__/Menu.test.tsx.snap b/src/components/__tests__/__snapshots__/Menu.test.tsx.snap index 3e027106ae..6d6ae3bd34 100644 --- a/src/components/__tests__/__snapshots__/Menu.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Menu.test.tsx.snap @@ -77,6 +77,7 @@ exports[`renders menu with content styles 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -332,6 +333,7 @@ exports[`renders menu with content styles 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -456,6 +458,7 @@ exports[`renders menu with content styles 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -640,6 +643,7 @@ exports[`renders not visible menu 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -808,6 +812,7 @@ exports[`renders visible menu 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1059,6 +1064,7 @@ exports[`renders visible menu 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1183,6 +1189,7 @@ exports[`renders visible menu 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} diff --git a/src/components/__tests__/__snapshots__/MenuItem.test.tsx.snap b/src/components/__tests__/__snapshots__/MenuItem.test.tsx.snap index 6977abf932..cd81d90644 100644 --- a/src/components/__tests__/__snapshots__/MenuItem.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/MenuItem.test.tsx.snap @@ -26,6 +26,7 @@ exports[`Menu Item renders menu item 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -190,6 +191,7 @@ exports[`Menu Item renders menu item 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -354,6 +356,7 @@ exports[`Menu Item renders menu item 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -518,6 +521,7 @@ exports[`Menu Item renders menu item 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -682,6 +686,7 @@ exports[`Menu Item renders menu item 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} diff --git a/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap b/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap index 29b660cbd9..f55968c94d 100644 --- a/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap @@ -68,7 +68,6 @@ exports[`activity indicator snapshot test 1`] = ` "borderWidth": 0, "elevation": 0, "flex": 1, - "overflow": "hidden", "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { "height": 0, @@ -103,17 +102,10 @@ exports[`activity indicator snapshot test 1`] = ` centered={true} collapsable={false} focusable={true} - hitSlop={ - { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, - } - } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -132,6 +124,12 @@ exports[`activity indicator snapshot test 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderRadius": 20, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -480,7 +478,6 @@ exports[`renders with placeholder 1`] = ` "borderWidth": 0, "elevation": 0, "flex": 1, - "overflow": "hidden", "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { "height": 0, @@ -515,17 +512,10 @@ exports[`renders with placeholder 1`] = ` centered={true} collapsable={false} focusable={true} - hitSlop={ - { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, - } - } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -544,6 +534,12 @@ exports[`renders with placeholder 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderRadius": 20, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -670,7 +666,6 @@ exports[`renders with placeholder 1`] = ` "borderWidth": 0, "elevation": 0, "flex": 1, - "overflow": "hidden", "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { "height": 0, @@ -705,17 +700,10 @@ exports[`renders with placeholder 1`] = ` centered={true} collapsable={false} focusable={true} - hitSlop={ - { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, - } - } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -734,6 +722,12 @@ exports[`renders with placeholder 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderRadius": 20, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -853,7 +847,6 @@ exports[`renders with text 1`] = ` "borderWidth": 0, "elevation": 0, "flex": 1, - "overflow": "hidden", "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { "height": 0, @@ -888,17 +881,10 @@ exports[`renders with text 1`] = ` centered={true} collapsable={false} focusable={true} - hitSlop={ - { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, - } - } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -917,6 +903,12 @@ exports[`renders with text 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderRadius": 20, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -1039,7 +1031,6 @@ exports[`renders with text 1`] = ` "borderWidth": 0, "elevation": 0, "flex": 1, - "overflow": "hidden", "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { "height": 0, @@ -1074,17 +1065,10 @@ exports[`renders with text 1`] = ` centered={true} collapsable={false} focusable={true} - hitSlop={ - { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, - } - } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1103,6 +1087,12 @@ exports[`renders with text 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderRadius": 20, + }, + { + "overflow": "hidden", + }, undefined, ], ] diff --git a/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap b/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap index 4de6f40b6f..7173875d64 100644 --- a/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap @@ -59,6 +59,7 @@ exports[`renders segmented button 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -186,6 +187,7 @@ exports[`renders segmented button 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} diff --git a/src/components/__tests__/__snapshots__/Snackbar.test.tsx.snap b/src/components/__tests__/__snapshots__/Snackbar.test.tsx.snap index 76d4857c89..3f6003f5d7 100644 --- a/src/components/__tests__/__snapshots__/Snackbar.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Snackbar.test.tsx.snap @@ -383,6 +383,7 @@ exports[`renders snackbar with action button 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} diff --git a/src/components/__tests__/__snapshots__/Switch.test.tsx.snap b/src/components/__tests__/__snapshots__/Switch.test.tsx.snap index 3589a26ee9..67e804891e 100644 --- a/src/components/__tests__/__snapshots__/Switch.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Switch.test.tsx.snap @@ -186,28 +186,6 @@ exports[`Switch render renders disabled off 1`] = ` } /> - `; @@ -377,28 +355,6 @@ exports[`Switch render renders disabled on 1`] = ` } /> - `; @@ -571,28 +527,6 @@ exports[`Switch render renders off 1`] = ` } /> - `; @@ -745,28 +679,6 @@ exports[`Switch render renders on 1`] = ` } /> - `; @@ -989,28 +901,6 @@ exports[`Switch render renders with checked icon 1`] = ` - `; @@ -1233,27 +1123,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 c955c943e2..e8f4671a95 100644 --- a/src/components/__tests__/__snapshots__/TextInput.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/TextInput.test.tsx.snap @@ -175,7 +175,6 @@ exports[`renders filled TextInput with TextInput.Icon accessories 1`] = ` "elevation": 0, "flex": 1, "justifyContent": "center", - "overflow": "hidden", "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { "height": 0, @@ -209,18 +208,11 @@ exports[`renders filled TextInput with TextInput.Icon accessories 1`] = ` centered={true} collapsable={false} focusable={true} - hitSlop={ - { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, - } - } multiline={false} onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -239,6 +231,12 @@ exports[`renders filled TextInput with TextInput.Icon accessories 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderRadius": 20, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -380,7 +378,6 @@ exports[`renders filled TextInput with TextInput.Icon accessories 1`] = ` "elevation": 0, "flex": 1, "justifyContent": "center", - "overflow": "hidden", "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { "height": 0, @@ -414,18 +411,11 @@ exports[`renders filled TextInput with TextInput.Icon accessories 1`] = ` centered={true} collapsable={false} focusable={true} - hitSlop={ - { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, - } - } multiline={false} onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -444,6 +434,12 @@ exports[`renders filled TextInput with TextInput.Icon accessories 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderRadius": 20, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -677,7 +673,6 @@ exports[`renders filled TextInput with TextInput.Icon accessories when error is "elevation": 0, "flex": 1, "justifyContent": "center", - "overflow": "hidden", "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { "height": 0, @@ -711,18 +706,11 @@ exports[`renders filled TextInput with TextInput.Icon accessories when error is centered={true} collapsable={false} focusable={true} - hitSlop={ - { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, - } - } multiline={false} onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -741,6 +729,12 @@ exports[`renders filled TextInput with TextInput.Icon accessories when error is "flexGrow": 1, "justifyContent": "center", }, + { + "borderRadius": 20, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -882,7 +876,6 @@ exports[`renders filled TextInput with TextInput.Icon accessories when error is "elevation": 0, "flex": 1, "justifyContent": "center", - "overflow": "hidden", "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { "height": 0, @@ -916,18 +909,11 @@ exports[`renders filled TextInput with TextInput.Icon accessories when error is centered={true} collapsable={false} focusable={true} - hitSlop={ - { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, - } - } multiline={false} onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -946,6 +932,12 @@ exports[`renders filled TextInput with TextInput.Icon accessories when error is "flexGrow": 1, "justifyContent": "center", }, + { + "borderRadius": 20, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -1356,7 +1348,6 @@ exports[`renders outlined TextInput with TextInput.Icon accessories 1`] = ` "elevation": 0, "flex": 1, "justifyContent": "center", - "overflow": "hidden", "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { "height": 0, @@ -1390,18 +1381,11 @@ exports[`renders outlined TextInput with TextInput.Icon accessories 1`] = ` centered={true} collapsable={false} focusable={true} - hitSlop={ - { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, - } - } multiline={false} onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1420,6 +1404,12 @@ exports[`renders outlined TextInput with TextInput.Icon accessories 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderRadius": 20, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -1561,7 +1551,6 @@ exports[`renders outlined TextInput with TextInput.Icon accessories 1`] = ` "elevation": 0, "flex": 1, "justifyContent": "center", - "overflow": "hidden", "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { "height": 0, @@ -1595,18 +1584,11 @@ exports[`renders outlined TextInput with TextInput.Icon accessories 1`] = ` centered={true} collapsable={false} focusable={true} - hitSlop={ - { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, - } - } multiline={false} onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1625,6 +1607,12 @@ exports[`renders outlined TextInput with TextInput.Icon accessories 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderRadius": 20, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -1839,7 +1827,6 @@ exports[`renders outlined TextInput with TextInput.Icon accessories when error i "elevation": 0, "flex": 1, "justifyContent": "center", - "overflow": "hidden", "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { "height": 0, @@ -1873,18 +1860,11 @@ exports[`renders outlined TextInput with TextInput.Icon accessories when error i centered={true} collapsable={false} focusable={true} - hitSlop={ - { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, - } - } multiline={false} onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1903,6 +1883,12 @@ exports[`renders outlined TextInput with TextInput.Icon accessories when error i "flexGrow": 1, "justifyContent": "center", }, + { + "borderRadius": 20, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -2044,7 +2030,6 @@ exports[`renders outlined TextInput with TextInput.Icon accessories when error i "elevation": 0, "flex": 1, "justifyContent": "center", - "overflow": "hidden", "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { "height": 0, @@ -2078,18 +2063,11 @@ exports[`renders outlined TextInput with TextInput.Icon accessories when error i centered={true} collapsable={false} focusable={true} - hitSlop={ - { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, - } - } multiline={false} onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2108,6 +2086,12 @@ exports[`renders outlined TextInput with TextInput.Icon accessories when error i "flexGrow": 1, "justifyContent": "center", }, + { + "borderRadius": 20, + }, + { + "overflow": "hidden", + }, undefined, ], ] diff --git a/src/components/__tests__/__snapshots__/ToggleButton.test.tsx.snap b/src/components/__tests__/__snapshots__/ToggleButton.test.tsx.snap index 63257435ed..5003bbdf35 100644 --- a/src/components/__tests__/__snapshots__/ToggleButton.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/ToggleButton.test.tsx.snap @@ -31,7 +31,6 @@ exports[`renders disabled toggle button 1`] = ` "borderWidth": 0, "elevation": 0, "flex": 1, - "overflow": "hidden", "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { "height": 0, @@ -65,17 +64,10 @@ 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]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -92,6 +84,12 @@ exports[`renders disabled toggle button 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderRadius": 4, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -171,7 +169,6 @@ exports[`renders toggle button 1`] = ` "borderWidth": 0, "elevation": 0, "flex": 1, - "overflow": "hidden", "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { "height": 0, @@ -205,17 +202,10 @@ exports[`renders toggle button 1`] = ` centered={true} collapsable={false} focusable={true} - hitSlop={ - { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, - } - } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -232,6 +222,12 @@ exports[`renders toggle button 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderRadius": 4, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -311,7 +307,6 @@ exports[`renders unchecked toggle button 1`] = ` "borderWidth": 0, "elevation": 0, "flex": 1, - "overflow": "hidden", "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { "height": 0, @@ -345,17 +340,10 @@ 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]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -372,6 +360,12 @@ exports[`renders unchecked toggle button 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderRadius": 4, + }, + { + "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..f7a400edd3 --- /dev/null +++ b/src/utils/__tests__/useFocusRing.test.tsx @@ -0,0 +1,136 @@ +import { Platform, Pressable, Text } from 'react-native'; + +import { 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 { getFocusRingStyle, 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 { focused, onFocus, onBlur } = useFocusRing(disabled); + onRender?.(); + return ( + {}} + onFocus={onFocus} + onBlur={onBlur} + style={getFocusRingStyle(focused, 'rebeccapurple')} + > + probe + + ); +}; + +describe('getFocusRingStyle', () => { + it('returns nothing when not focused', () => { + expect(getFocusRingStyle(false, 'rebeccapurple')).toBeNull(); + }); + + // 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 offset from the focusIndicator tokens', () => { + const { thickness, outerOffset } = tokens.md.sys.state.focusIndicator; + + expect(getFocusRingStyle(true, 'rebeccapurple')).toEqual({ + outlineWidth: thickness, + outlineColor: 'rebeccapurple', + outlineStyle: 'solid', + outlineOffset: outerOffset, + }); + expect(getFocusRingStyle(true, 'rebeccapurple', 'inward')).toEqual({ + outlineWidth: thickness, + outlineColor: 'rebeccapurple', + outlineStyle: 'solid', + outlineOffset: -thickness, + }); + }); +}); + +describe('useFocusRing', () => { + it('applies the outline on focus and removes it on blur', async () => { + await render(); + expect(screen.getByTestId('probe')).not.toHaveStyle({ outlineWidth: 3 }); + + await focus(); + expect(screen.getByTestId('probe')).toHaveStyle({ + outlineWidth: 3, + outlineColor: 'rebeccapurple', + outlineOffset: 2, + }); + + await blur(); + expect(screen.getByTestId('probe')).not.toHaveStyle({ outlineWidth: 3 }); + }); + + // `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: 3 }); + }); + + it('ignores a non-keyboard focus event on web, so a mouse click does not ring', async () => { + const original = Platform.OS; + Platform.OS = 'web'; + try { + await render(); + + await focus({ currentTarget: { matches: () => false } }); + + expect(screen.getByTestId('probe')).not.toHaveStyle({ outlineWidth: 3 }); + } finally { + Platform.OS = original; + } + }); + + // 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: 3 }); + + 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: 3 }); + }); +}); diff --git a/src/utils/useFocusRing.ts b/src/utils/useFocusRing.ts new file mode 100644 index 0000000000..776776f642 --- /dev/null +++ b/src/utils/useFocusRing.ts @@ -0,0 +1,91 @@ +import * as React from 'react'; +import type { + ColorValue, + NativeSyntheticEvent, + TargetedEvent, + 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'; + +export type FocusRingState = { + focused: boolean; + onFocus: (e: NativeSyntheticEvent) => void; + onBlur: () => void; +}; + +/** + * Tracks keyboard focus for an MD3 focus indicator. + * + * Fires on web and Android only. iOS never dispatches `onFocus` for a View or + * Pressable unless the `enableImperativeFocus` flag is on, and it defaults off. + */ +export function useFocusRing(disabled?: boolean): FocusRingState { + const [focused, setFocused] = React.useState(false); + + const onFocus = React.useCallback( + (e: NativeSyntheticEvent) => { + // Symmetric, and skipped entirely when there is nothing to show, so a + // suppressed ring costs no render. + if (!disabled) { + setFocused(isKeyboardFocusEvent(e)); + } + }, + [disabled] + ); + + 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 (disabled) { + setFocused(false); + } + }, [disabled]); + + return { focused: focused && !disabled, onFocus, onBlur }; +} + +/** + * MD3 focus indicator, drawn with the platform's own `outline`. Costs no + * layout, takes its radius from the view it sits on, and `borderless` does not + * clip it. + * + * Outward by default, per MD3 (hence the `outerOffset` token): the ring lands + * on the page background, where it has a predictable contrast ratio. Pass + * `inward` only where an outward ring is measurably clipped or would land on a + * neighbour - list rows, flush segments, chips in a scrolling row. + * + * Never pair this with `outline: 'none'`. Ours overrides the browser's, so if + * it never runs the user still gets the browser ring instead of nothing. + */ +export const getFocusRingStyle = ( + focused: boolean, + color: ColorValue, + placement: FocusRingPlacement = 'outward' +): ViewStyle | null => + focused && placement !== 'none' + ? { + outlineWidth: thickness, + outlineColor: color, + outlineStyle: 'solid', + outlineOffset: placement === 'inward' ? -thickness : outerOffset, + } + : null; + +/** Spread into a style array so an absent ring adds no entry. */ +export const toStyleList = (s: ViewStyle | null): ViewStyle[] => (s ? [s] : []); + +/** + * Suppresses the browser's own focus ring. Only for controls that draw the MD3 + * ring on a different element, where the browser's would land on the wrong box + * or be clipped. Anywhere else, leaving it alone is the safer default. + */ +// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion +export const webNoOutline = { outline: 'none' } as unknown as ViewStyle;