From b205fc5c4b8edbe2a35a3353586103ded6218c95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20M=C3=B3rawski?= Date: Wed, 26 Aug 2026 15:47:40 +0200 Subject: [PATCH 1/2] fix: expand interactive targets to the 48dp minimum --- src/components/Checkbox/Checkbox.tsx | 7 +- src/components/Chip/Chip.tsx | 43 +++- src/components/IconButton/IconButton.tsx | 26 ++- .../TouchableRipple.native.tsx | 119 ++++++++++ .../TouchableRipple/TouchableRipple.tsx | 106 ++++++++- .../Appbar/__snapshots__/Appbar.test.tsx.snap | 64 +++--- .../__snapshots__/Checkbox.test.tsx.snap | 7 + .../__snapshots__/CheckboxItem.test.tsx.snap | 4 + src/components/__tests__/Chip.test.tsx | 38 ++++ .../__snapshots__/RadioButton.test.tsx.snap | 4 + .../RadioButtonGroup.test.tsx.snap | 1 + .../RadioButtonItem.test.tsx.snap | 8 + .../__tests__/TouchableRipple.test.tsx | 212 +++++++++++++++++- .../__tests__/TouchableRippleWeb.test.tsx | 134 +++++++++++ .../__snapshots__/Banner.test.tsx.snap | 4 + .../__snapshots__/Button.test.tsx.snap | 13 ++ .../__snapshots__/Chip.test.tsx.snap | 34 ++- .../__snapshots__/DataTable.test.tsx.snap | 195 +++++++--------- .../__snapshots__/DrawerItem.test.tsx.snap | 3 + .../__tests__/__snapshots__/FAB.test.tsx.snap | 13 ++ .../__snapshots__/FABExtended.test.tsx.snap | 6 + .../__snapshots__/FABMenu.test.tsx.snap | 25 +++ .../__snapshots__/IconButton.test.tsx.snap | 80 +++---- .../__snapshots__/ListAccordion.test.tsx.snap | 6 + .../__snapshots__/ListItem.test.tsx.snap | 8 + .../__snapshots__/ListSection.test.tsx.snap | 6 + .../__snapshots__/Menu.test.tsx.snap | 7 + .../__snapshots__/MenuItem.test.tsx.snap | 5 + .../__snapshots__/Searchbar.test.tsx.snap | 80 +++---- .../SegmentedButton.test.tsx.snap | 2 + .../__snapshots__/Snackbar.test.tsx.snap | 1 + .../__snapshots__/TextInput.test.tsx.snap | 128 +++++------ .../__snapshots__/ToggleButton.test.tsx.snap | 48 ++-- src/theme/tokens/sys/state.ts | 7 + 34 files changed, 1081 insertions(+), 363 deletions(-) create mode 100644 src/components/__tests__/TouchableRippleWeb.test.tsx diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx index f2527383d7..165c54c3b7 100644 --- a/src/components/Checkbox/Checkbox.tsx +++ b/src/components/Checkbox/Checkbox.tsx @@ -80,9 +80,10 @@ const { 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. +// We don't apply `focusIndicator.outerOffset`, so the ring stays inside the 40dp +// circle. `TouchableRipple borderless` used to crop anything outside it; on web +// it no longer does, since the touchable cannot clip without clipping the touch +// target. Native still clips. Check both when revisiting the offset. const FOCUS_RING_SIZE = STATE_LAYER_SIZE; const FOCUS_RING_RADIUS = STATE_LAYER_SIZE / 2; diff --git a/src/components/Chip/Chip.tsx b/src/components/Chip/Chip.tsx index b6482e9209..a651077bc8 100644 --- a/src/components/Chip/Chip.tsx +++ b/src/components/Chip/Chip.tsx @@ -169,6 +169,25 @@ export type Props = $Omit, '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, @@ -273,7 +292,7 @@ const Chip = ({ : 8 * multiplier, }; const contentSpacings = { - paddingRight: onClose ? 34 : 0, + paddingRight: onClose ? CLOSE_AFFORDANCE_WIDTH : 0, }; const labelTextStyle = { color: textColor, @@ -399,8 +418,12 @@ const Chip = ({ disabled={disabled} role="button" aria-label={closeIconAccessibilityLabel} + style={styles.closeButton} > - + {closeIcon ? ( ) : ( @@ -451,6 +474,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 +508,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/IconButton/IconButton.tsx b/src/components/IconButton/IconButton.tsx index 270c9289ac..5fa9b635fc 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, @@ -181,7 +181,7 @@ const IconButton = ({ pointerEvents="none" style={[ StyleSheet.absoluteFill, - { backgroundColor, opacity: backgroundOpacity }, + { backgroundColor, opacity: backgroundOpacity, borderRadius }, ]} /> )} @@ -190,15 +190,18 @@ const IconButton = ({ centered onPress={onPress} aria-label={ariaLabel} - style={[styles.touchable, contentStyle]} + style={[ + styles.touchable, + { borderRadius }, + // 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 +219,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 +230,9 @@ const styles = StyleSheet.create({ justifyContent: 'center', alignItems: 'center', }, + clipToShape: { + overflow: 'hidden', + }, }); export default IconButton; diff --git a/src/components/TouchableRipple/TouchableRipple.native.tsx b/src/components/TouchableRipple/TouchableRipple.native.tsx index 513355afa5..55468f7096 100644 --- a/src/components/TouchableRipple/TouchableRipple.native.tsx +++ b/src/components/TouchableRipple/TouchableRipple.native.tsx @@ -6,6 +6,8 @@ import type { ViewStyle, GestureResponderEvent, ColorValue, + Insets, + LayoutChangeEvent, } from 'react-native'; import type { PressableProps } from './Pressable'; @@ -14,12 +16,81 @@ 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'; 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; @@ -46,6 +117,8 @@ const TouchableRipple = ({ underlayColor, children, theme: themeOverrides, + hitSlop, + onLayout, ref, ...rest }: Props) => { @@ -63,6 +136,47 @@ const TouchableRipple = ({ const disabled = disabledProp || !hasPassedTouchHandler; + const [expansion, setExpansion] = React.useState( + undefined + ); + + // A caller hitSlop wins, so there is nothing to measure for. `null` counts as + // supplied, it means "no slop". + const shouldMeasure = hitSlop === 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 + // gets no event once it is enabled and would stay small. + const shouldExpand = shouldMeasure && !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,6 +206,8 @@ const TouchableRipple = ({ {...rest} ref={ref} disabled={disabled} + hitSlop={shouldExpand ? expansion : hitSlop} + onLayout={shouldMeasure ? handleLayout : onLayout} style={[useForeground && styles.overflowHidden, style]} android_ripple={androidRipple} > @@ -105,6 +221,8 @@ const TouchableRipple = ({ {...rest} ref={ref} disabled={disabled} + hitSlop={shouldExpand ? expansion : hitSlop} + onLayout={shouldMeasure ? handleLayout : onLayout} style={[borderless && styles.overflowHidden, style]} > {({ pressed }) => ( @@ -114,6 +232,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..6432dfffcc 100644 --- a/src/components/TouchableRipple/TouchableRipple.tsx +++ b/src/components/TouchableRipple/TouchableRipple.tsx @@ -15,12 +15,58 @@ 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'; +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; /** @@ -105,12 +151,14 @@ 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, ref, ...rest }: Props) => { @@ -178,7 +226,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 @@ -282,7 +339,6 @@ const TouchableRipple = ({ disabled={disabled} style={(state) => [ styles.touchable, - borderless && styles.borderless, // focused state is not ready yet: https://github.com/necolas/react-native-web/issues/1849 // state.focused && { backgroundColor: ___ }, state.hovered && { backgroundColor: hoverColor }, @@ -290,11 +346,26 @@ const TouchableRipple = ({ typeof style === 'function' ? style(state) : style, ]} > - {(state) => - React.Children.only( - typeof children === 'function' ? children(state) : children - ) - } + {(state) => ( + <> + {/* Before the children, not after. It hit-tests, so as the last + sibling it covers anything interactive inside the touchable and + takes its presses, e.g. a pressable List.Item with a control in + `right`. Ahead of them it still covers the area outside the + touchable, where there is nothing else to hit. + Nothing that cannot be pressed gets a target, same as native. */} + {!disabled && ( + + )} + {React.Children.only( + typeof children === 'function' ? children(state) : children + )} + + )} ); }; @@ -317,8 +388,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..cc3dc7f938 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]} @@ -213,6 +214,7 @@ exports[`renders checked Checkbox with color 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -398,6 +400,7 @@ exports[`renders checked Checkbox with onPress 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -583,6 +586,7 @@ exports[`renders indeterminate Checkbox 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -755,6 +759,7 @@ exports[`renders indeterminate Checkbox with color 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -927,6 +932,7 @@ exports[`renders unchecked Checkbox with color 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1112,6 +1118,7 @@ exports[`renders unchecked Checkbox with onPress 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} diff --git a/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap b/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap index 0f7f92073b..8846a0c2fc 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]} @@ -304,6 +306,7 @@ exports[`renders unchecked 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -397,6 +400,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__/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__/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..a96c63cd10 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,214 @@ 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('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']); + }); + }); }); }); 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..d87ad50074 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,13 @@ exports[`renders chip with close button 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} role="button" + style={ + { + "height": "100%", + "justifyContent": "center", + "width": "100%", + } + } > @@ -542,6 +555,13 @@ exports[`renders chip with custom close button 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} role="button" + style={ + { + "height": "100%", + "justifyContent": "center", + "width": "100%", + } + } > Date: Thu, 27 Aug 2026 16:04:28 +0200 Subject: [PATCH 2/2] fix: hitslop and styling --- src/components/IconButton/IconButton.tsx | 28 +++++++++++++------ .../TouchableRipple.native.tsx | 16 +++++------ src/components/__tests__/IconButton.test.tsx | 21 ++++++++++++++ .../__tests__/TouchableRipple.test.tsx | 22 +++++++++++++++ 4 files changed, 70 insertions(+), 17 deletions(-) diff --git a/src/components/IconButton/IconButton.tsx b/src/components/IconButton/IconButton.tsx index 5fa9b635fc..bd5b3bdfd1 100644 --- a/src/components/IconButton/IconButton.tsx +++ b/src/components/IconButton/IconButton.tsx @@ -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 ( @@ -181,7 +192,8 @@ const IconButton = ({ pointerEvents="none" style={[ StyleSheet.absoluteFill, - { backgroundColor, opacity: backgroundOpacity, borderRadius }, + { backgroundColor, opacity: backgroundOpacity }, + shapeStyles, ]} /> )} @@ -192,7 +204,7 @@ const IconButton = ({ aria-label={ariaLabel} style={[ styles.touchable, - { borderRadius }, + 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. diff --git a/src/components/TouchableRipple/TouchableRipple.native.tsx b/src/components/TouchableRipple/TouchableRipple.native.tsx index 55468f7096..4e6ec11bb2 100644 --- a/src/components/TouchableRipple/TouchableRipple.native.tsx +++ b/src/components/TouchableRipple/TouchableRipple.native.tsx @@ -140,14 +140,12 @@ const TouchableRipple = ({ undefined ); - // A caller hitSlop wins, so there is nothing to measure for. `null` counts as - // supplied, it means "no slop". - const shouldMeasure = hitSlop === 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 - // gets no event once it is enabled and would stay small. - const shouldExpand = shouldMeasure && !disabled; + // 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) => { @@ -207,7 +205,7 @@ const TouchableRipple = ({ ref={ref} disabled={disabled} hitSlop={shouldExpand ? expansion : hitSlop} - onLayout={shouldMeasure ? handleLayout : onLayout} + onLayout={handleLayout} style={[useForeground && styles.overflowHidden, style]} android_ripple={androidRipple} > @@ -222,7 +220,7 @@ const TouchableRipple = ({ ref={ref} disabled={disabled} hitSlop={shouldExpand ? expansion : hitSlop} - onLayout={shouldMeasure ? handleLayout : onLayout} + onLayout={handleLayout} style={[borderless && styles.overflowHidden, style]} > {({ pressed }) => ( 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__/TouchableRipple.test.tsx b/src/components/__tests__/TouchableRipple.test.tsx index a96c63cd10..b0ae9ea71b 100644 --- a/src/components/__tests__/TouchableRipple.test.tsx +++ b/src/components/__tests__/TouchableRipple.test.tsx @@ -221,6 +221,28 @@ describe('TouchableRipple', () => { 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 });