From 27ee27198f15022c0179913743f52c7d5d5e8e28 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 01/15] 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 ++++++++- .../__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__/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__/ListAccordion.test.tsx.snap | 6 + .../__snapshots__/ListSection.test.tsx.snap | 6 + .../__snapshots__/MenuItem.test.tsx.snap | 5 + .../SegmentedButton.test.tsx.snap | 2 + src/theme/tokens/sys/state.ts | 7 + 22 files changed, 756 insertions(+), 26 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 3bccccf33b..fe73752eb7 100644 --- a/src/components/Checkbox/Checkbox.tsx +++ b/src/components/Checkbox/Checkbox.tsx @@ -78,9 +78,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 4507561a48..af5fb93522 100644 --- a/src/components/Chip/Chip.tsx +++ b/src/components/Chip/Chip.tsx @@ -179,6 +179,25 @@ export type Props = Omit & { * 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, @@ -270,7 +289,7 @@ const Chip = ({ }; const contentSpacings = { - paddingRight: onClose ? 34 : 0, + paddingRight: onClose ? CLOSE_AFFORDANCE_WIDTH : 0, }; const labelTextStyle = { @@ -391,8 +410,12 @@ const Chip = ({ role="button" aria-label={closeIconAccessibilityLabel} testID={closeIconTestID} + style={styles.closeButton} > - + {closeIcon ? ( ) : ( @@ -443,6 +466,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', @@ -473,9 +500,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 1d673a3d27..0e3b0bb3c2 100644 --- a/src/components/IconButton/IconButton.tsx +++ b/src/components/IconButton/IconButton.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { StyleSheet, View } from 'react-native'; +import { Platform, StyleSheet, View } from 'react-native'; import type { ColorValue, GestureResponderEvent, @@ -177,7 +177,7 @@ const IconButton = ({ pointerEvents="none" style={[ StyleSheet.absoluteFill, - { backgroundColor, opacity: backgroundOpacity }, + { backgroundColor, opacity: backgroundOpacity, borderRadius }, ]} /> )} @@ -186,15 +186,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} > @@ -212,14 +215,19 @@ const IconButton = ({ const styles = StyleSheet.create({ container: { + // No `overflow: 'hidden'`. An ancestor that clips also clips the touch + // target, which is why the hitSlop this component used to pass never + // applied. The overlay and the touchable clip themselves instead. margin: 6, - overflow: 'hidden', }, touchable: { flexGrow: 1, justifyContent: 'center', alignItems: 'center', }, + clipToShape: { + overflow: 'hidden', + }, }); export default IconButton; diff --git a/src/components/TouchableRipple/TouchableRipple.native.tsx b/src/components/TouchableRipple/TouchableRipple.native.tsx index ec7b13dd91..c1a214e201 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 '../../theme/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 }) => ( @@ -113,6 +231,7 @@ const TouchableRipple = ({ diff --git a/src/components/TouchableRipple/TouchableRipple.tsx b/src/components/TouchableRipple/TouchableRipple.tsx index 3b98d252c7..97da8e3f01 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 '../../theme/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__/Checkbox/__snapshots__/Checkbox.test.tsx.snap b/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap index 202a95467a..0cd9bcc43f 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]} @@ -212,6 +213,7 @@ exports[`renders checked Checkbox with color 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -396,6 +398,7 @@ exports[`renders checked Checkbox with onPress 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -580,6 +583,7 @@ exports[`renders indeterminate Checkbox 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -751,6 +755,7 @@ exports[`renders indeterminate Checkbox with color 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -922,6 +927,7 @@ exports[`renders unchecked Checkbox with color 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1106,6 +1112,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 e2d338f791..769d9df19a 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]} @@ -302,6 +304,7 @@ exports[`renders unchecked 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -394,6 +397,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 a7d522e4f0..0774ad6109 100644 --- a/src/components/__tests__/Chip.test.tsx +++ b/src/components/__tests__/Chip.test.tsx @@ -309,3 +309,41 @@ describe('getChipColor - border color', () => { }); }); }); + +describe('close affordance', () => { + // The chip already reserved room on its right, but only the icon was tappable, + // so the body owned the rest of that column. MD3 has the primary action stop + // where the trailing one starts. + it('fills the column the chip reserves for it', async () => { + await render( + {}} onClose={() => {}}> + Example + + ); + + expect(screen.getByLabelText('Close')).toHaveStyle({ + width: '100%', + height: '100%', + }); + }); + + it('keeps the close glyph pinned right so it does not drift', async () => { + await render( + {}} onClose={() => {}}> + Example + + ); + + // `styles.icon` sets alignSelf center, which would otherwise win and move + // the glyph 4dp left + expect(screen.getByTestId('chip-close-icon')).toHaveStyle({ + alignSelf: 'flex-end', + }); + }); + + it('is not rendered without onClose', async () => { + await render( {}}>Example); + + expect(screen.queryByLabelText('Close')).not.toBeOnTheScreen(); + }); +}); diff --git a/src/components/__tests__/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 663d540bec..d70418a5f8 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'; @@ -66,5 +67,214 @@ describe('TouchableRipple', () => { expect(toJSON()).toMatchSnapshot(); }); + + 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__/DrawerItem.test.tsx.snap b/src/components/__tests__/__snapshots__/DrawerItem.test.tsx.snap index 4823f954c1..980dd7897b 100644 --- a/src/components/__tests__/__snapshots__/DrawerItem.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/DrawerItem.test.tsx.snap @@ -26,6 +26,7 @@ exports[`renders DrawerItem with icon 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -185,6 +186,7 @@ exports[`renders active DrawerItem 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -344,6 +346,7 @@ exports[`renders basic DrawerItem 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} diff --git a/src/components/__tests__/__snapshots__/FAB.test.tsx.snap b/src/components/__tests__/__snapshots__/FAB.test.tsx.snap index 4b06302358..a3cee84a46 100644 --- a/src/components/__tests__/__snapshots__/FAB.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/FAB.test.tsx.snap @@ -142,6 +142,7 @@ exports[`renders FAB large size 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -381,6 +382,7 @@ exports[`renders FAB medium size 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -620,6 +622,7 @@ exports[`renders FAB transitioning to not visible 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -859,6 +862,7 @@ exports[`renders FAB transitioning to visible 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1099,6 +1103,7 @@ exports[`renders FAB with aria-label 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1338,6 +1343,7 @@ exports[`renders FAB with containerColor and contentColor overrides 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1577,6 +1583,7 @@ exports[`renders FAB with containerColor override 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1816,6 +1823,7 @@ exports[`renders FAB with default props 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2055,6 +2063,7 @@ exports[`renders FAB with primary variant 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2294,6 +2303,7 @@ exports[`renders FAB with secondary variant 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2533,6 +2543,7 @@ exports[`renders FAB with tertiary variant 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2772,6 +2783,7 @@ exports[`renders FAB with tonalSecondary variant 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -3011,6 +3023,7 @@ exports[`renders FAB with tonalTertiary variant 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} diff --git a/src/components/__tests__/__snapshots__/FABExtended.test.tsx.snap b/src/components/__tests__/__snapshots__/FABExtended.test.tsx.snap index 644a640c5a..f871c16538 100644 --- a/src/components/__tests__/__snapshots__/FABExtended.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/FABExtended.test.tsx.snap @@ -144,6 +144,7 @@ exports[`renders extended FAB collapsed 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -470,6 +471,7 @@ exports[`renders extended FAB expanded 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -796,6 +798,7 @@ exports[`renders extended FAB large size 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1122,6 +1125,7 @@ exports[`renders extended FAB medium size 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1448,6 +1452,7 @@ exports[`renders extended FAB not visible 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1774,6 +1779,7 @@ exports[`renders extended FAB transitioning to collapsed 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} diff --git a/src/components/__tests__/__snapshots__/FABMenu.test.tsx.snap b/src/components/__tests__/__snapshots__/FABMenu.test.tsx.snap index 8b66657112..e9d87ba84b 100644 --- a/src/components/__tests__/__snapshots__/FABMenu.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/FABMenu.test.tsx.snap @@ -123,6 +123,7 @@ exports[`renders FAB.Menu closed 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -299,6 +300,7 @@ exports[`renders FAB.Menu closed 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -606,6 +608,7 @@ exports[`renders FAB.Menu closed 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -900,6 +903,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]} @@ -1076,6 +1080,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]} @@ -1383,6 +1388,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]} @@ -1677,6 +1683,7 @@ exports[`renders FAB.Menu open 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1853,6 +1860,7 @@ exports[`renders FAB.Menu open 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2160,6 +2168,7 @@ exports[`renders FAB.Menu open 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2454,6 +2463,7 @@ exports[`renders FAB.Menu with 6 items 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2630,6 +2640,7 @@ exports[`renders FAB.Menu with 6 items 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2806,6 +2817,7 @@ exports[`renders FAB.Menu with 6 items 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2982,6 +2994,7 @@ exports[`renders FAB.Menu with 6 items 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -3158,6 +3171,7 @@ exports[`renders FAB.Menu with 6 items 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -3334,6 +3348,7 @@ exports[`renders FAB.Menu with 6 items 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -3641,6 +3656,7 @@ exports[`renders FAB.Menu with 6 items 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -3936,6 +3952,7 @@ exports[`renders FAB.Menu with center alignment 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -4112,6 +4129,7 @@ exports[`renders FAB.Menu with center alignment 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -4419,6 +4437,7 @@ exports[`renders FAB.Menu with center alignment 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -4713,6 +4732,7 @@ exports[`renders FAB.Menu with items having icons 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -4919,6 +4939,7 @@ exports[`renders FAB.Menu with items having icons 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -5256,6 +5277,7 @@ exports[`renders FAB.Menu with items having icons 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -5550,6 +5572,7 @@ exports[`renders FAB.Menu with start alignment 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -5726,6 +5749,7 @@ exports[`renders FAB.Menu with start alignment 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -6033,6 +6057,7 @@ exports[`renders FAB.Menu with start alignment 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} diff --git a/src/components/__tests__/__snapshots__/ListAccordion.test.tsx.snap b/src/components/__tests__/__snapshots__/ListAccordion.test.tsx.snap index c01fcf856a..9a67eff123 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]} @@ -297,6 +299,7 @@ exports[`renders list accordion with children 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -504,6 +507,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]} @@ -704,6 +708,7 @@ exports[`renders list accordion with left items 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -911,6 +916,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__/ListSection.test.tsx.snap b/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap index a64f2e3662..94e51ef9f9 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]} @@ -629,6 +630,7 @@ exports[`renders list section with custom title style 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1235,6 +1237,7 @@ exports[`renders list section with subheader 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1387,6 +1390,7 @@ exports[`renders list section with subheader 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1953,6 +1957,7 @@ exports[`renders list section without subheader 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2105,6 +2110,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__/MenuItem.test.tsx.snap b/src/components/__tests__/__snapshots__/MenuItem.test.tsx.snap index c316a38599..b3083b43d2 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]} @@ -188,6 +189,7 @@ exports[`Menu Item renders menu item 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -350,6 +352,7 @@ exports[`Menu Item renders menu item 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -512,6 +515,7 @@ exports[`Menu Item renders menu item 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -674,6 +678,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__/SegmentedButton.test.tsx.snap b/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap index 76988d9bc2..5fd65d1b31 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]} @@ -185,6 +186,7 @@ exports[`renders segmented button 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} 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; From 1c70fdef722aba8568c5b1c7b8d8fcab0ccbb961 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20M=C3=B3rawski?= Date: Thu, 27 Aug 2026 16:04:28 +0200 Subject: [PATCH 02/15] fix: hitslop and styling --- src/components/IconButton/IconButton.tsx | 26 ++++++++++++++++--- .../TouchableRipple.native.tsx | 16 +++++------- src/components/__tests__/IconButton.test.tsx | 21 +++++++++++++++ .../__tests__/TouchableRipple.test.tsx | 22 ++++++++++++++++ 4 files changed, 72 insertions(+), 13 deletions(-) diff --git a/src/components/IconButton/IconButton.tsx b/src/components/IconButton/IconButton.tsx index 0e3b0bb3c2..fa80fcc15f 100644 --- a/src/components/IconButton/IconButton.tsx +++ b/src/components/IconButton/IconButton.tsx @@ -12,6 +12,7 @@ import Animated, { type AnimatedStyle } from 'react-native-reanimated'; import { getIconButtonColor } from './utils'; import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../theme/types'; +import { splitStyles } from '../../utils/splitStyles'; import ActivityIndicator from '../ActivityIndicator'; import CrossFadeIcon from '../CrossFadeIcon'; import Icon from '../Icon'; @@ -152,10 +153,26 @@ const IconButton = ({ const buttonSize = size + 2 * PADDING; - const borderStyles = { - borderWidth: mode === 'outlined' && !selected ? 1 : 0, + // 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, borderColor, + ...shapeStyles, }; return ( @@ -177,7 +194,8 @@ const IconButton = ({ pointerEvents="none" style={[ StyleSheet.absoluteFill, - { backgroundColor, opacity: backgroundOpacity, borderRadius }, + { backgroundColor, opacity: backgroundOpacity }, + shapeStyles, ]} /> )} @@ -188,7 +206,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 c1a214e201..a30cc9d35f 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 914f1e2c55..2e373ed3dd 100644 --- a/src/components/__tests__/IconButton.test.tsx +++ b/src/components/__tests__/IconButton.test.tsx @@ -18,6 +18,9 @@ const styles = StyleSheet.create({ slightlyRounded: { borderRadius: 4, }, + cutCorner: { + borderTopLeftRadius: 0, + }, }); it('renders icon button by default', async () => { @@ -80,6 +83,24 @@ it('renders icon button with small border radius', async () => { expect(toJSON()).toMatchSnapshot(); }); +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 d70418a5f8..fea4c58136 100644 --- a/src/components/__tests__/TouchableRipple.test.tsx +++ b/src/components/__tests__/TouchableRipple.test.tsx @@ -219,6 +219,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 }); From aab7ec76121027232d139d709a7aedc6b037dfb1 Mon Sep 17 00:00:00 2001 From: Konrad Rydygier Date: Mon, 7 Sep 2026 14:55:36 +0200 Subject: [PATCH 03/15] test: update snapshots after rebasing onto main --- .../Appbar/__snapshots__/Appbar.test.tsx.snap | 64 +++--- .../__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__/IconButton.test.tsx.snap | 80 ++++--- .../__snapshots__/ListItem.test.tsx.snap | 8 + .../__snapshots__/Menu.test.tsx.snap | 4 + .../__snapshots__/Searchbar.test.tsx.snap | 80 ++++--- .../__snapshots__/Snackbar.test.tsx.snap | 1 + .../__snapshots__/TextInput.test.tsx.snap | 128 +++++------- 11 files changed, 301 insertions(+), 310 deletions(-) diff --git a/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap b/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap index faeb89916b..89f341ba1c 100644 --- a/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap +++ b/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap @@ -194,7 +194,6 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -233,17 +232,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]} @@ -262,6 +254,12 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderRadius": 20, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -360,7 +358,6 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -399,17 +396,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]} @@ -428,6 +418,12 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderRadius": 20, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -582,7 +578,6 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -621,17 +616,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]} @@ -650,6 +638,12 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A "flexGrow": 1, "justifyContent": "center", }, + { + "borderRadius": 20, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -805,7 +799,6 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -843,17 +836,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]} @@ -872,6 +858,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__/__snapshots__/Banner.test.tsx.snap b/src/components/__tests__/__snapshots__/Banner.test.tsx.snap index 71e77e2936..b9908258e6 100644 --- a/src/components/__tests__/__snapshots__/Banner.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Banner.test.tsx.snap @@ -300,6 +300,7 @@ exports[`render visible banner, with custom theme 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -909,6 +910,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]} @@ -1300,6 +1302,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]} @@ -1507,6 +1510,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 628087c74b..12b3f5262e 100644 --- a/src/components/__tests__/__snapshots__/Button.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Button.test.tsx.snap @@ -2666,6 +2666,7 @@ exports[`renders button with an accessibility hint 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2871,6 +2872,7 @@ exports[`renders button with an accessibility label 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -3075,6 +3077,7 @@ exports[`renders button with button color 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -3279,6 +3282,7 @@ exports[`renders button with color 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -3483,6 +3487,7 @@ exports[`renders button with custom testID 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -3688,6 +3693,7 @@ exports[`renders button with icon 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -3940,6 +3946,7 @@ exports[`renders button with icon in reverse order 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -4194,6 +4201,7 @@ exports[`renders contained contained with mode 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -4399,6 +4407,7 @@ exports[`renders disabled button 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -4603,6 +4612,7 @@ exports[`renders loading button 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -5011,6 +5021,7 @@ exports[`renders outlined button with mode 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -5216,6 +5227,7 @@ exports[`renders text button by default 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -5420,6 +5432,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 04687e301f..79ee8c5459 100644 --- a/src/components/__tests__/__snapshots__/Chip.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Chip.test.tsx.snap @@ -121,6 +121,7 @@ exports[`renders chip with close button 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -260,11 +261,12 @@ exports[`renders chip with close button 1`] = ` @@ -300,6 +302,13 @@ exports[`renders chip with close button 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} role="button" + style={ + { + "height": "100%", + "justifyContent": "center", + "width": "100%", + } + } > @@ -653,6 +666,13 @@ exports[`renders chip with custom close button 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} role="button" + style={ + { + "height": "100%", + "justifyContent": "center", + "width": "100%", + } + } > Date: Tue, 8 Sep 2026 09:55:04 +0200 Subject: [PATCH 04/15] fix: compute per-component hit slops --- src/components/Checkbox/Checkbox.tsx | 9 + src/components/Chip/Chip.tsx | 15 +- src/components/Chip/tokens.ts | 7 + src/components/IconButton/IconButton.tsx | 58 +++- .../RadioButton/RadioButtonAndroid.tsx | 19 +- src/components/RadioButton/RadioButtonIOS.tsx | 22 +- src/components/RadioButton/tokens.ts | 7 + .../SegmentedButtons/SegmentedButtonItem.tsx | 11 +- src/components/Switch/Switch.tsx | 27 ++ .../TouchableRipple.native.tsx | 82 +---- .../TouchableRipple/TouchableRipple.tsx | 29 +- .../Appbar/__snapshots__/Appbar.test.tsx.snap | 100 +++++- .../__tests__/Checkbox/Checkbox.test.tsx | 22 +- .../__snapshots__/Checkbox.test.tsx.snap | 63 +++- .../__snapshots__/CheckboxItem.test.tsx.snap | 20 +- src/components/__tests__/Chip.test.tsx | 29 ++ src/components/__tests__/IconButton.test.tsx | 32 +- .../RadioButton/RadioButton.test.tsx | 24 +- .../__snapshots__/RadioButton.test.tsx.snap | 52 ++- .../RadioButtonGroup.test.tsx.snap | 13 +- .../RadioButtonItem.test.tsx.snap | 56 +++- .../__tests__/SegmentedButton.test.tsx | 42 +++ src/components/__tests__/Switch.test.tsx | 20 ++ .../__tests__/TouchableRipple.test.tsx | 179 +---------- .../__tests__/TouchableRippleWeb.test.tsx | 14 +- .../__snapshots__/Banner.test.tsx.snap | 4 - .../__snapshots__/Button.test.tsx.snap | 13 - .../__snapshots__/Chip.test.tsx.snap | 52 ++- .../__snapshots__/DataTable.test.tsx.snap | 303 +++++++++++++++++- .../__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 | 117 ++++++- .../__snapshots__/ListAccordion.test.tsx.snap | 6 - .../__snapshots__/ListItem.test.tsx.snap | 17 +- .../__snapshots__/ListSection.test.tsx.snap | 6 - .../__snapshots__/Menu.test.tsx.snap | 4 - .../__snapshots__/MenuItem.test.tsx.snap | 5 - .../__snapshots__/Searchbar.test.tsx.snap | 125 +++++++- .../SegmentedButton.test.tsx.snap | 18 +- .../__snapshots__/Snackbar.test.tsx.snap | 1 - .../__snapshots__/Switch.test.tsx.snap | 32 ++ .../__snapshots__/TextInput.test.tsx.snap | 200 +++++++++++- src/utils/getMinInteractiveSizeHitSlop.ts | 40 +++ 45 files changed, 1459 insertions(+), 483 deletions(-) create mode 100644 src/components/Chip/tokens.ts create mode 100644 src/components/RadioButton/tokens.ts create mode 100644 src/utils/getMinInteractiveSizeHitSlop.ts diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx index fe73752eb7..7f49338344 100644 --- a/src/components/Checkbox/Checkbox.tsx +++ b/src/components/Checkbox/Checkbox.tsx @@ -18,6 +18,7 @@ import { useInternalTheme } from '../../core/theming'; import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; import { tokens } from '../../theme/tokens'; import type { ThemeProp } from '../../theme/types'; +import getMinInteractiveSizeHitSlop from '../../utils/getMinInteractiveSizeHitSlop'; import { isKeyboardFocusEvent } from '../../utils/isKeyboardFocusEvent'; import TouchableRipple from '../TouchableRipple/TouchableRipple'; import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple'; @@ -85,6 +86,13 @@ const FOCUS_THICKNESS = tokens.md.sys.state.focusIndicator.thickness; const FOCUS_RING_SIZE = STATE_LAYER_SIZE; const FOCUS_RING_RADIUS = STATE_LAYER_SIZE / 2; +// The state layer is fixed, so the slop to reach the 48dp minimum +// interactive target is a constant rather than something to measure. +const CHECKBOX_HIT_SLOP = getMinInteractiveSizeHitSlop({ + width: STATE_LAYER_SIZE, + height: STATE_LAYER_SIZE, +}); + /** * Checkboxes allow the selection of multiple options from a set. * @@ -244,6 +252,7 @@ const Checkbox = ({ disabled={disabled} {...accessibilityProps} testID={testID} + hitSlop={rest.hitSlop ?? (disabled ? undefined : CHECKBOX_HIT_SLOP)} style={[ styles.tapTarget, Platform.OS === 'web' ? webNoOutline : undefined, diff --git a/src/components/Chip/Chip.tsx b/src/components/Chip/Chip.tsx index af5fb93522..79fec92b9a 100644 --- a/src/components/Chip/Chip.tsx +++ b/src/components/Chip/Chip.tsx @@ -14,9 +14,11 @@ import useLatestCallback from 'use-latest-callback'; import { getChipColors } from './helpers'; import type { ChipAvatarProps } from './helpers'; +import { ChipTokens } from './tokens'; import { useInternalTheme } from '../../core/theming'; import { white } from '../../theme/colors'; import type { ThemeProp } from '../../theme/types'; +import getMinInteractiveSizeHitSlop from '../../utils/getMinInteractiveSizeHitSlop'; import hasTouchHandler from '../../utils/hasTouchHandler'; import type { IconSource } from '../Icon'; import Icon from '../Icon'; @@ -198,6 +200,16 @@ const CLOSE_AFFORDANCE_WIDTH = 34; */ const CLOSE_AFFORDANCE_MIN_WIDTH = 26; +/** + * The container height is fixed by spec, so the slop to reach the 48dp minimum + * is a constant rather than something to measure. Width grows with the label + * and the whole pill is already the target, so only the vertical axis needs it. + */ +const { containerHeight: CHIP_BODY_HEIGHT } = ChipTokens; +const CHIP_BODY_HIT_SLOP = getMinInteractiveSizeHitSlop({ + height: CHIP_BODY_HEIGHT, +}); + const Chip = ({ mode = 'flat', children, @@ -323,7 +335,7 @@ const Chip = ({ aria-disabled={disabled} testID={testID} theme={theme} - hitSlop={hitSlop} + hitSlop={hitSlop ?? (disabled ? undefined : CHIP_BODY_HIT_SLOP)} > void; + /** + * Radius of every corner of the button. Defaults to a circle (half of the + * button's size). Read as a plain prop rather than out of `style`, since + * `style` may be an animated value on the UI thread that a synchronous + * `StyleSheet.flatten` cannot see. + */ + borderRadius?: number; + borderTopLeftRadius?: number; + borderTopRightRadius?: number; + borderBottomLeftRadius?: number; + borderBottomRightRadius?: number; + borderTopStartRadius?: number; + borderTopEndRadius?: number; + borderBottomStartRadius?: number; + borderBottomEndRadius?: number; style?: StyleProp>; ref?: React.Ref; /** @@ -129,6 +144,15 @@ const IconButton = ({ testID, loading = false, contentStyle, + borderRadius, + borderTopLeftRadius, + borderTopRightRadius, + borderBottomLeftRadius, + borderBottomRightRadius, + borderTopStartRadius, + borderTopEndRadius, + borderBottomStartRadius, + borderBottomEndRadius, ref, ...rest }: Props) => { @@ -152,21 +176,18 @@ const IconButton = ({ }); const buttonSize = size + 2 * PADDING; - - // 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 borderWidth = mode === 'outlined' && !selected ? 1 : 0; const shapeStyles = { - borderRadius: buttonSize / 2, - ...borderRadiusStyles, + borderRadius: borderRadius ?? buttonSize / 2, + borderTopLeftRadius, + borderTopRightRadius, + borderBottomLeftRadius, + borderBottomRightRadius, + borderTopStartRadius, + borderTopEndRadius, + borderBottomStartRadius, + borderBottomEndRadius, }; const borderStyles = { @@ -175,6 +196,14 @@ const IconButton = ({ ...shapeStyles, }; + // Computed straight from `size`, a plain prop known at render time, rather + // than measured. `buttonSize` never changes after mount without `size` also + // changing, so there is nothing to react to. A disabled button gets no + // slop of its own, only what a caller's own `hitSlop` in `rest` supplies. + const hitSlop = disabled + ? undefined + : getMinInteractiveSizeHitSlop({ width: buttonSize, height: buttonSize }); + return ( diff --git a/src/components/RadioButton/RadioButtonAndroid.tsx b/src/components/RadioButton/RadioButtonAndroid.tsx index 91520db69c..fb1bf8d9a2 100644 --- a/src/components/RadioButton/RadioButtonAndroid.tsx +++ b/src/components/RadioButton/RadioButtonAndroid.tsx @@ -4,12 +4,23 @@ import { Animated, StyleSheet, View } from 'react-native'; import { RadioButtonContext } from './RadioButtonGroup'; import type { RadioButtonContextType } from './RadioButtonGroup'; +import { RadioButtonTokens } from './tokens'; import { getSelectionControlColor, handlePress, isChecked } from './utils'; import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../theme/types'; +import getMinInteractiveSizeHitSlop from '../../utils/getMinInteractiveSizeHitSlop'; import TouchableRipple from '../TouchableRipple/TouchableRipple'; import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple'; +const { stateLayerSize: STATE_LAYER_SIZE } = RadioButtonTokens; + +// The state layer is fixed, so the slop to reach the 48dp minimum +// interactive target is a constant rather than something to measure. +const RADIO_BUTTON_HIT_SLOP = getMinInteractiveSizeHitSlop({ + width: STATE_LAYER_SIZE, + height: STATE_LAYER_SIZE, +}); + export type Props = Omit< React.PropsWithoutRef, 'children' @@ -146,6 +157,9 @@ const RadioButtonAndroid = ({ style={styles.container} testID={testID} theme={theme} + hitSlop={ + rest.hitSlop ?? (disabled ? undefined : RADIO_BUTTON_HIT_SLOP) + } > , 'children' @@ -104,12 +116,15 @@ const RadioButtonIOS = ({ style={styles.container} testID={testID} theme={theme} + hitSlop={ + rest.hitSlop ?? (disabled ? undefined : RADIO_BUTTON_HIT_SLOP) + } > @@ -125,8 +140,9 @@ RadioButtonIOS.displayName = 'RadioButton.IOS'; const styles = StyleSheet.create({ container: { - borderRadius: 18, - padding: 6, + borderRadius: STATE_LAYER_SIZE / 2, + // Centres the 24dp checkmark within the 40dp state layer. + padding: (STATE_LAYER_SIZE - CHECKMARK_SIZE) / 2, }, }); diff --git a/src/components/RadioButton/tokens.ts b/src/components/RadioButton/tokens.ts new file mode 100644 index 0000000000..a3abdfe3e0 --- /dev/null +++ b/src/components/RadioButton/tokens.ts @@ -0,0 +1,7 @@ +/** + * MD3 Radio button spec dimensions. + * @see https://m3.material.io/components/radio-button/specs + */ +export const RadioButtonTokens = { + stateLayerSize: 40, +} as const; diff --git a/src/components/SegmentedButtons/SegmentedButtonItem.tsx b/src/components/SegmentedButtons/SegmentedButtonItem.tsx index 3b9962a457..c8b8bd59a2 100644 --- a/src/components/SegmentedButtons/SegmentedButtonItem.tsx +++ b/src/components/SegmentedButtons/SegmentedButtonItem.tsx @@ -23,6 +23,7 @@ import { import { useInternalTheme } from '../../core/theming'; import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; import type { ThemeProp } from '../../theme/types'; +import getMinInteractiveSizeHitSlop from '../../utils/getMinInteractiveSizeHitSlop'; import type { IconSource } from '../Icon'; import Icon from '../Icon'; import TouchableRipple from '../TouchableRipple/TouchableRipple'; @@ -193,6 +194,14 @@ const SegmentedButtonItem = ({ const paddingVertical = getSegmentedButtonDensityPadding({ density }); + // Height is `2 * paddingVertical + content`, and content is never shorter + // than the 18dp icon (the label's own line height is taller), so that is + // the safe floor to compute slop from without needing to measure. + const contentHeight = 2 * paddingVertical + iconSize; + const defaultHitSlop = disabled + ? undefined + : getMinInteractiveSizeHitSlop({ height: contentHeight }); + const rippleStyle: ViewStyle = { borderRadius, ...segmentBorderRadius, @@ -217,7 +226,7 @@ const SegmentedButtonItem = ({ style={rippleStyle} background={background} theme={theme} - hitSlop={hitSlop} + hitSlop={hitSlop ?? defaultHitSlop} > + {/* react-native-web removed `hitSlop` in 0.13.0 (same as + TouchableRipple), so web needs a real element the browser can + hit-test instead of a native responder inset. */} + {Platform.OS === 'web' && !isDisabled && ( + + )} ): ViewStyle => { const flat = StyleSheet.flatten(style); @@ -62,35 +57,6 @@ const getUnderlayShape = (style: StyleProp): ViewStyle => { }; }; -/** - * 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; @@ -118,7 +84,6 @@ const TouchableRipple = ({ children, theme: themeOverrides, hitSlop, - onLayout, ref, ...rest }: Props) => { @@ -136,45 +101,6 @@ const TouchableRipple = ({ const disabled = disabledProp || !hasPassedTouchHandler; - 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, @@ -204,8 +130,7 @@ const TouchableRipple = ({ {...rest} ref={ref} disabled={disabled} - hitSlop={shouldExpand ? expansion : hitSlop} - onLayout={handleLayout} + hitSlop={hitSlop} style={[useForeground && styles.overflowHidden, style]} android_ripple={androidRipple} > @@ -219,8 +144,7 @@ const TouchableRipple = ({ {...rest} ref={ref} disabled={disabled} - hitSlop={shouldExpand ? expansion : hitSlop} - onLayout={handleLayout} + hitSlop={hitSlop} style={[borderless && styles.overflowHidden, style]} > {({ pressed }) => ( diff --git a/src/components/TouchableRipple/TouchableRipple.tsx b/src/components/TouchableRipple/TouchableRipple.tsx index 97da8e3f01..ccd0e36508 100644 --- a/src/components/TouchableRipple/TouchableRipple.tsx +++ b/src/components/TouchableRipple/TouchableRipple.tsx @@ -15,31 +15,21 @@ 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 '../../theme/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. + * browser can hit-test instead of a native responder inset. * @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) { + // `undefined` or `null` both mean no slop: nothing for the caller to opt + // into, so the target matches the touchable's own bounds. + if (hitSlop === undefined || hitSlop === null) { return styles.noTouchTarget; } - // 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' @@ -395,17 +385,6 @@ const styles = StyleSheet.create({ 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%' }], - }, }); export default TouchableRipple; diff --git a/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap b/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap index 89f341ba1c..3f30b0ce3e 100644 --- a/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap +++ b/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap @@ -201,8 +201,16 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -232,10 +240,17 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -255,7 +270,15 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` "justifyContent": "center", }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, }, { "overflow": "hidden", @@ -365,8 +388,16 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -396,10 +427,17 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -419,7 +457,15 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` "justifyContent": "center", }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, }, { "overflow": "hidden", @@ -585,8 +631,16 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -616,10 +670,17 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -639,7 +700,15 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A "justifyContent": "center", }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, }, { "overflow": "hidden", @@ -806,8 +875,16 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -836,10 +913,17 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -859,7 +943,15 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A "justifyContent": "center", }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, }, { "overflow": "hidden", diff --git a/src/components/__tests__/Checkbox/Checkbox.test.tsx b/src/components/__tests__/Checkbox/Checkbox.test.tsx index a72200bb4e..fa9c520ebb 100644 --- a/src/components/__tests__/Checkbox/Checkbox.test.tsx +++ b/src/components/__tests__/Checkbox/Checkbox.test.tsx @@ -1,6 +1,6 @@ import { expect, it } from '@jest/globals'; -import { render } from '../../../test-utils'; +import { render, screen } from '../../../test-utils'; import Checkbox from '../../Checkbox'; it('renders checked Checkbox with onPress', async () => { @@ -58,3 +58,23 @@ it('renders Checkbox with custom testID', async () => { expect(tree).toMatchSnapshot(); }); + +it('expands hitSlop up to the 48dp minimum when enabled', async () => { + await render(); + + // (48 - 40) / 2 on every side + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('checkbox').props.hitSlop).toEqual({ + top: 4, + bottom: 4, + left: 4, + right: 4, + }); +}); + +it('gives a disabled Checkbox no hitSlop of its own', async () => { + await render(); + + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('checkbox').props.hitSlop).toBeUndefined(); +}); diff --git a/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap b/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap index 0cd9bcc43f..fe7f4ec3ba 100644 --- a/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap +++ b/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap @@ -24,10 +24,17 @@ exports[`renders Checkbox with custom testID 1`] = ` centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -210,10 +217,17 @@ exports[`renders checked Checkbox with color 1`] = ` centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -395,10 +409,17 @@ exports[`renders checked Checkbox with onPress 1`] = ` centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -580,10 +601,17 @@ exports[`renders indeterminate Checkbox 1`] = ` centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -752,10 +780,17 @@ exports[`renders indeterminate Checkbox with color 1`] = ` centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -924,10 +959,17 @@ exports[`renders unchecked Checkbox with color 1`] = ` centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1109,10 +1151,17 @@ exports[`renders unchecked Checkbox with onPress 1`] = ` centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - 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 769d9df19a..b5dd171c87 100644 --- a/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap +++ b/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap @@ -26,7 +26,6 @@ exports[`can render leading checkbox control 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -79,10 +78,17 @@ exports[`can render leading checkbox control 1`] = ` centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -304,7 +310,6 @@ exports[`renders unchecked 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -394,10 +399,17 @@ exports[`renders unchecked 1`] = ` centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - 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 0774ad6109..5075db441e 100644 --- a/src/components/__tests__/Chip.test.tsx +++ b/src/components/__tests__/Chip.test.tsx @@ -95,6 +95,35 @@ it('renders chip with zero border radius', async () => { }); }); +it('expands hitSlop up to the 48dp minimum when enabled', async () => { + await render( + {}}> + Active chip + + ); + + // (48 - 32) / 2 top/bottom, none horizontally: the pill's width already + // covers it + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('active-chip').props.hitSlop).toEqual({ + top: 8, + bottom: 8, + left: 0, + right: 0, + }); +}); + +it('gives a disabled Chip no hitSlop of its own', async () => { + await render( + {}}> + Disabled chip + + ); + + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('disabled-chip').props.hitSlop).toBeUndefined(); +}); + describe('getChipColors - text color', () => { it('should return correct disabled color, for theme version 3', () => { expect( diff --git a/src/components/__tests__/IconButton.test.tsx b/src/components/__tests__/IconButton.test.tsx index 2e373ed3dd..5e2e181ba7 100644 --- a/src/components/__tests__/IconButton.test.tsx +++ b/src/components/__tests__/IconButton.test.tsx @@ -18,9 +18,6 @@ const styles = StyleSheet.create({ slightlyRounded: { borderRadius: 4, }, - cutCorner: { - borderTopLeftRadius: 0, - }, }); it('renders icon button by default', async () => { @@ -49,6 +46,33 @@ it('renders disabled icon button', async () => { expect(tree).toMatchSnapshot(); }); +it('expands hitSlop up to the 48dp minimum for a button smaller than that', async () => { + await render(); + + // (48 - 40) / 2 on every side, for the default 24dp icon plus 8dp padding + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('icon-button').props.hitSlop).toEqual({ + top: 4, + bottom: 4, + left: 4, + right: 4, + }); +}); + +it('gives a disabled button no hitSlop of its own', async () => { + await render(); + + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('icon-button').props.hitSlop).toBeUndefined(); +}); + +it('lets a caller-supplied hitSlop win even while disabled', async () => { + await render(); + + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('icon-button').props.hitSlop).toBe(2); +}); + it('renders icon change animated', async () => { const tree = (await render()).toJSON(); @@ -90,7 +114,7 @@ it('clips to a custom corner radius', async () => { testID="icon-button" size={36} onPress={() => {}} - style={styles.cutCorner} + borderTopLeftRadius={0} /> ); diff --git a/src/components/__tests__/RadioButton/RadioButton.test.tsx b/src/components/__tests__/RadioButton/RadioButton.test.tsx index da8a04375d..90e76d68f6 100644 --- a/src/components/__tests__/RadioButton/RadioButton.test.tsx +++ b/src/components/__tests__/RadioButton/RadioButton.test.tsx @@ -6,7 +6,7 @@ import { jest as mockJest, } from '@jest/globals'; -import { render } from '../../../test-utils'; +import { render, screen } from '../../../test-utils'; import RadioButton from '../../RadioButton'; import { RadioButtonContext } from '../../RadioButton/RadioButtonGroup'; @@ -82,4 +82,26 @@ describe('RadioButton', () => { expect(tree).toMatchSnapshot(); }); }); + + describe('hitSlop', () => { + it('expands up to the 48dp minimum when enabled', async () => { + await render(); + + // (48 - 40) / 2 on every side + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('radio').props.hitSlop).toEqual({ + top: 4, + bottom: 4, + left: 4, + right: 4, + }); + }); + + it('gives a disabled RadioButton no hitSlop of its own', async () => { + await render(); + + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('radio').props.hitSlop).toBeUndefined(); + }); + }); }); diff --git a/src/components/__tests__/RadioButton/__snapshots__/RadioButton.test.tsx.snap b/src/components/__tests__/RadioButton/__snapshots__/RadioButton.test.tsx.snap index 31dffb6970..a6e37f97d4 100644 --- a/src/components/__tests__/RadioButton/__snapshots__/RadioButton.test.tsx.snap +++ b/src/components/__tests__/RadioButton/__snapshots__/RadioButton.test.tsx.snap @@ -23,10 +23,17 @@ exports[`RadioButton RadioButton with custom testID renders properly 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -40,8 +47,8 @@ exports[`RadioButton RadioButton with custom testID renders properly 1`] = ` "overflow": "hidden", }, { - "borderRadius": 18, - "padding": 6, + "borderRadius": 20, + "padding": 8, }, ] } @@ -111,10 +118,17 @@ exports[`RadioButton on default platform renders properly 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -128,8 +142,8 @@ exports[`RadioButton on default platform renders properly 1`] = ` "overflow": "hidden", }, { - "borderRadius": 18, - "padding": 6, + "borderRadius": 20, + "padding": 8, }, ] } @@ -198,10 +212,17 @@ exports[`RadioButton on ios platform renders properly 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -215,8 +236,8 @@ exports[`RadioButton on ios platform renders properly 1`] = ` "overflow": "hidden", }, { - "borderRadius": 18, - "padding": 6, + "borderRadius": 20, + "padding": 8, }, ] } @@ -285,10 +306,17 @@ exports[`RadioButton when RadioButton is wrapped by RadioButtonContext.Provider accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -302,8 +330,8 @@ exports[`RadioButton when RadioButton is wrapped by RadioButtonContext.Provider "overflow": "hidden", }, { - "borderRadius": 18, - "padding": 6, + "borderRadius": 20, + "padding": 8, }, ] } diff --git a/src/components/__tests__/RadioButton/__snapshots__/RadioButtonGroup.test.tsx.snap b/src/components/__tests__/RadioButton/__snapshots__/RadioButtonGroup.test.tsx.snap index 54a237c099..82f06ebf6c 100644 --- a/src/components/__tests__/RadioButton/__snapshots__/RadioButtonGroup.test.tsx.snap +++ b/src/components/__tests__/RadioButton/__snapshots__/RadioButtonGroup.test.tsx.snap @@ -26,10 +26,17 @@ exports[`RadioButtonGroup renders properly 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -43,8 +50,8 @@ exports[`RadioButtonGroup renders properly 1`] = ` "overflow": "hidden", }, { - "borderRadius": 18, - "padding": 6, + "borderRadius": 20, + "padding": 8, }, ] } diff --git a/src/components/__tests__/RadioButton/__snapshots__/RadioButtonItem.test.tsx.snap b/src/components/__tests__/RadioButton/__snapshots__/RadioButtonItem.test.tsx.snap index ef32fb657b..1d8934aa6f 100644 --- a/src/components/__tests__/RadioButton/__snapshots__/RadioButtonItem.test.tsx.snap +++ b/src/components/__tests__/RadioButton/__snapshots__/RadioButtonItem.test.tsx.snap @@ -26,7 +26,6 @@ exports[`can render leading radio button control 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -78,10 +77,17 @@ exports[`can render leading radio button control 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -95,8 +101,8 @@ exports[`can render leading radio button control 1`] = ` "overflow": "hidden", }, { - "borderRadius": 18, - "padding": 6, + "borderRadius": 20, + "padding": 8, }, ] } @@ -206,7 +212,6 @@ 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,10 +299,17 @@ exports[`can render the Android radio button on different platforms 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -311,7 +323,7 @@ exports[`can render the Android radio button on different platforms 1`] = ` "overflow": "hidden", }, { - "borderRadius": 18, + "borderRadius": 20, }, ] } @@ -324,7 +336,7 @@ exports[`can render the Android radio button on different platforms 1`] = ` "borderRadius": 10, "borderWidth": 2, "height": 20, - "margin": 8, + "margin": 10, "width": 20, } } @@ -360,7 +372,6 @@ 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]} @@ -448,10 +459,17 @@ exports[`can render the iOS radio button on different platforms 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -465,8 +483,8 @@ exports[`can render the iOS radio button on different platforms 1`] = ` "overflow": "hidden", }, { - "borderRadius": 18, - "padding": 6, + "borderRadius": 20, + "padding": 8, }, ] } @@ -540,7 +558,6 @@ exports[`renders unchecked 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -628,10 +645,17 @@ exports[`renders unchecked 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -645,8 +669,8 @@ exports[`renders unchecked 1`] = ` "overflow": "hidden", }, { - "borderRadius": 18, - "padding": 6, + "borderRadius": 20, + "padding": 8, }, ] } diff --git a/src/components/__tests__/SegmentedButton.test.tsx b/src/components/__tests__/SegmentedButton.test.tsx index 807d5eb502..592dfd89f0 100644 --- a/src/components/__tests__/SegmentedButton.test.tsx +++ b/src/components/__tests__/SegmentedButton.test.tsx @@ -515,3 +515,45 @@ describe('labelStyle is handled', () => { }); }); }); + +describe('hitSlop', () => { + it('expands up to the 48dp minimum when enabled', async () => { + await render( + {}} + /> + ); + + // (48 - (2 * 9dp default padding + 18dp icon)) / 2 top/bottom, none + // horizontally + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('walking-button').props.hitSlop).toEqual({ + top: 6, + bottom: 6, + left: 0, + right: 0, + }); + }); + + it('gives a disabled button no hitSlop of its own', async () => { + await render( + {}} + /> + ); + + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('walking-button').props.hitSlop).toBeUndefined(); + }); +}); diff --git a/src/components/__tests__/Switch.test.tsx b/src/components/__tests__/Switch.test.tsx index ceff77a0e6..170ce15dbe 100644 --- a/src/components/__tests__/Switch.test.tsx +++ b/src/components/__tests__/Switch.test.tsx @@ -24,6 +24,26 @@ describe('Switch render', () => { ).toMatchSnapshot(); }); + it('expands hitSlop up to the 48dp minimum when enabled', async () => { + await render(); + + // (48 - 40) / 2 top/bottom, none horizontally: the track is already wider + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('switch').props.hitSlop).toEqual({ + top: 4, + bottom: 4, + left: 0, + right: 0, + }); + }); + + it('gives a disabled switch no hitSlop of its own', async () => { + await render(); + + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('switch').props.hitSlop).toBeUndefined(); + }); + it('renders with checked icon', async () => { expect( (await render()).toJSON() diff --git a/src/components/__tests__/TouchableRipple.test.tsx b/src/components/__tests__/TouchableRipple.test.tsx index fea4c58136..173906eb04 100644 --- a/src/components/__tests__/TouchableRipple.test.tsx +++ b/src/components/__tests__/TouchableRipple.test.tsx @@ -1,4 +1,3 @@ -import * as React from 'react'; import { Platform, Text } from 'react-native'; import type { GestureResponderEvent } from 'react-native'; @@ -97,206 +96,56 @@ describe('TouchableRipple', () => { }); }); - describe('minimum interactive size', () => { - const layout = (width: number, height: number) => ({ - nativeEvent: { layout: { width, height, x: 0, y: 0 } }, - }); - + describe('hitSlop', () => { // 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. + // out or hit-test them. This only stops the prop 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 = {}) => { + it('is not enforced or defaulted: the primitive does not measure', async () => { 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 () => { + it('passes a caller-supplied hitSlop straight through', 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 }) => ( {}} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} > 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); + expect(hitSlopOf()).toEqual({ top: 8, bottom: 8, left: 8, right: 8 }); }); - it('expands once a caller-supplied hitSlop is taken away', async () => { - const Harness = ({ hitSlop }: { hitSlop?: number }) => ( + it('still calls a caller-supplied onLayout', async () => { + const onLayout = jest.fn(); + await render( {}} + onLayout={onLayout} > 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(); + await fireEvent(screen.getByTestId('touchable'), 'layout', { + nativeEvent: { layout: { width: 32, height: 32, x: 0, y: 0 } }, + }); }); - 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']); - }); - }); }); }); diff --git a/src/components/__tests__/TouchableRippleWeb.test.tsx b/src/components/__tests__/TouchableRippleWeb.test.tsx index ccb07f18eb..a3e811a390 100644 --- a/src/components/__tests__/TouchableRippleWeb.test.tsx +++ b/src/components/__tests__/TouchableRippleWeb.test.tsx @@ -33,7 +33,9 @@ describe('TouchableRipple (web)', () => { return Array.isArray(style) ? Object.assign({}, ...style.flat()) : style; }; - it('renders a minimum sized touch target for an interactive touchable', async () => { + it("renders a touch target matching the touchable's own bounds by default", async () => { + // No minimum is enforced here: the primitive does not measure, so with no + // caller-supplied `hitSlop` the target is exactly the touchable's bounds. await render( {}}> Button @@ -41,12 +43,12 @@ describe('TouchableRipple (web)', () => { ); expect(screen.getByTestId(TARGET, HIDDEN)).toBeOnTheScreen(); - expect(styleOf(TARGET)).toMatchObject({ + expect(styleOf(TARGET)).toEqual({ position: 'absolute', - minWidth: 48, - minHeight: 48, - width: '100%', - height: '100%', + top: 0, + bottom: 0, + left: 0, + right: 0, }); }); diff --git a/src/components/__tests__/__snapshots__/Banner.test.tsx.snap b/src/components/__tests__/__snapshots__/Banner.test.tsx.snap index b9908258e6..71e77e2936 100644 --- a/src/components/__tests__/__snapshots__/Banner.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Banner.test.tsx.snap @@ -300,7 +300,6 @@ exports[`render visible banner, with custom theme 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -910,7 +909,6 @@ 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]} @@ -1302,7 +1300,6 @@ 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]} @@ -1510,7 +1507,6 @@ 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 12b3f5262e..628087c74b 100644 --- a/src/components/__tests__/__snapshots__/Button.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Button.test.tsx.snap @@ -2666,7 +2666,6 @@ exports[`renders button with an accessibility hint 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2872,7 +2871,6 @@ exports[`renders button with an accessibility label 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -3077,7 +3075,6 @@ exports[`renders button with button color 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -3282,7 +3279,6 @@ exports[`renders button with color 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -3487,7 +3483,6 @@ exports[`renders button with custom testID 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -3693,7 +3688,6 @@ exports[`renders button with icon 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -3946,7 +3940,6 @@ exports[`renders button with icon in reverse order 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -4201,7 +4194,6 @@ exports[`renders contained contained with mode 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -4407,7 +4399,6 @@ exports[`renders disabled button 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -4612,7 +4603,6 @@ exports[`renders loading button 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -5021,7 +5011,6 @@ exports[`renders outlined button with mode 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -5227,7 +5216,6 @@ exports[`renders text button by default 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -5432,7 +5420,6 @@ 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 79ee8c5459..9bdf665a2b 100644 --- a/src/components/__tests__/__snapshots__/Chip.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Chip.test.tsx.snap @@ -118,10 +118,17 @@ exports[`renders chip with close button 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 8, + "left": 0, + "right": 0, + "top": 8, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -155,6 +162,7 @@ exports[`renders chip with close button 1`] = ` "position": "relative", }, { + "minHeight": 32, "paddingLeft": 0, }, { @@ -482,10 +490,17 @@ exports[`renders chip with custom close button 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 8, + "left": 0, + "right": 0, + "top": 8, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -519,6 +534,7 @@ exports[`renders chip with custom close button 1`] = ` "position": "relative", }, { + "minHeight": 32, "paddingLeft": 0, }, { @@ -846,10 +862,17 @@ exports[`renders chip with icon 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 8, + "left": 0, + "right": 0, + "top": 8, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -883,6 +906,7 @@ exports[`renders chip with icon 1`] = ` "position": "relative", }, { + "minHeight": 32, "paddingLeft": 0, }, { @@ -1107,10 +1131,17 @@ exports[`renders chip with onPress 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 8, + "left": 0, + "right": 0, + "top": 8, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1144,6 +1175,7 @@ exports[`renders chip with onPress 1`] = ` "position": "relative", }, { + "minHeight": 32, "paddingLeft": 0, }, { @@ -1326,7 +1358,6 @@ exports[`renders outlined disabled chip 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1360,6 +1391,7 @@ exports[`renders outlined disabled chip 1`] = ` "position": "relative", }, { + "minHeight": 32, "paddingLeft": 0, }, { @@ -1539,10 +1571,17 @@ exports[`renders selected chip 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 8, + "left": 0, + "right": 0, + "top": 8, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1576,6 +1615,7 @@ exports[`renders selected chip 1`] = ` "position": "relative", }, { + "minHeight": 32, "paddingLeft": 0, }, { diff --git a/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap b/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap index 9fc184df45..359971ce61 100644 --- a/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap @@ -25,7 +25,6 @@ exports[`DataTable.Cell renders data table cell 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -401,7 +400,6 @@ exports[`DataTable.Cell renders right aligned data table cell 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -696,8 +694,16 @@ exports[`DataTable.Pagination renders data table pagination 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -727,10 +733,17 @@ exports[`DataTable.Pagination renders data table pagination 1`] = ` centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -750,7 +763,15 @@ exports[`DataTable.Pagination renders data table pagination 1`] = ` "justifyContent": "center", }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, }, { "overflow": "hidden", @@ -812,8 +833,16 @@ exports[`DataTable.Pagination renders data table pagination 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -843,10 +872,17 @@ exports[`DataTable.Pagination renders data table pagination 1`] = ` centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -866,7 +902,15 @@ exports[`DataTable.Pagination renders data table pagination 1`] = ` "justifyContent": "center", }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, }, { "overflow": "hidden", @@ -986,8 +1030,16 @@ exports[`DataTable.Pagination renders data table pagination with fast-forward bu "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -1017,10 +1069,17 @@ exports[`DataTable.Pagination renders data table pagination with fast-forward bu centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1040,7 +1099,15 @@ exports[`DataTable.Pagination renders data table pagination with fast-forward bu "justifyContent": "center", }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, }, { "overflow": "hidden", @@ -1102,8 +1169,16 @@ exports[`DataTable.Pagination renders data table pagination with fast-forward bu "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -1133,10 +1208,17 @@ exports[`DataTable.Pagination renders data table pagination with fast-forward bu centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1156,7 +1238,15 @@ exports[`DataTable.Pagination renders data table pagination with fast-forward bu "justifyContent": "center", }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, }, { "overflow": "hidden", @@ -1218,8 +1308,16 @@ exports[`DataTable.Pagination renders data table pagination with fast-forward bu "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -1249,10 +1347,17 @@ exports[`DataTable.Pagination renders data table pagination with fast-forward bu centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1272,7 +1377,15 @@ exports[`DataTable.Pagination renders data table pagination with fast-forward bu "justifyContent": "center", }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, }, { "overflow": "hidden", @@ -1334,8 +1447,16 @@ exports[`DataTable.Pagination renders data table pagination with fast-forward bu "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -1365,10 +1486,17 @@ exports[`DataTable.Pagination renders data table pagination with fast-forward bu centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1388,7 +1516,15 @@ exports[`DataTable.Pagination renders data table pagination with fast-forward bu "justifyContent": "center", }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, }, { "overflow": "hidden", @@ -1508,8 +1644,16 @@ exports[`DataTable.Pagination renders data table pagination with label 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -1539,10 +1683,17 @@ exports[`DataTable.Pagination renders data table pagination with label 1`] = ` centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1562,7 +1713,15 @@ exports[`DataTable.Pagination renders data table pagination with label 1`] = ` "justifyContent": "center", }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, }, { "overflow": "hidden", @@ -1624,8 +1783,16 @@ exports[`DataTable.Pagination renders data table pagination with label 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -1655,10 +1822,17 @@ exports[`DataTable.Pagination renders data table pagination with label 1`] = ` centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1678,7 +1852,15 @@ exports[`DataTable.Pagination renders data table pagination with label 1`] = ` "justifyContent": "center", }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, }, { "overflow": "hidden", @@ -1912,7 +2094,6 @@ exports[`DataTable.Pagination renders data table pagination with options select onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2097,8 +2278,16 @@ exports[`DataTable.Pagination renders data table pagination with options select "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -2128,10 +2317,17 @@ exports[`DataTable.Pagination renders data table pagination with options select centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2151,7 +2347,15 @@ exports[`DataTable.Pagination renders data table pagination with options select "justifyContent": "center", }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, }, { "overflow": "hidden", @@ -2213,8 +2417,16 @@ exports[`DataTable.Pagination renders data table pagination with options select "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -2244,10 +2456,17 @@ exports[`DataTable.Pagination renders data table pagination with options select centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2267,7 +2486,15 @@ exports[`DataTable.Pagination renders data table pagination with options select "justifyContent": "center", }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, }, { "overflow": "hidden", @@ -2329,8 +2556,16 @@ exports[`DataTable.Pagination renders data table pagination with options select "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -2360,10 +2595,17 @@ exports[`DataTable.Pagination renders data table pagination with options select centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2383,7 +2625,15 @@ exports[`DataTable.Pagination renders data table pagination with options select "justifyContent": "center", }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, }, { "overflow": "hidden", @@ -2445,8 +2695,16 @@ exports[`DataTable.Pagination renders data table pagination with options select "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -2476,10 +2734,17 @@ exports[`DataTable.Pagination renders data table pagination with options select centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2499,7 +2764,15 @@ exports[`DataTable.Pagination renders data table pagination with options select "justifyContent": "center", }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, }, { "overflow": "hidden", diff --git a/src/components/__tests__/__snapshots__/DrawerItem.test.tsx.snap b/src/components/__tests__/__snapshots__/DrawerItem.test.tsx.snap index 980dd7897b..4823f954c1 100644 --- a/src/components/__tests__/__snapshots__/DrawerItem.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/DrawerItem.test.tsx.snap @@ -26,7 +26,6 @@ exports[`renders DrawerItem with icon 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -186,7 +185,6 @@ exports[`renders active DrawerItem 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -346,7 +344,6 @@ exports[`renders basic DrawerItem 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} diff --git a/src/components/__tests__/__snapshots__/FAB.test.tsx.snap b/src/components/__tests__/__snapshots__/FAB.test.tsx.snap index a3cee84a46..4b06302358 100644 --- a/src/components/__tests__/__snapshots__/FAB.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/FAB.test.tsx.snap @@ -142,7 +142,6 @@ exports[`renders FAB large size 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -382,7 +381,6 @@ exports[`renders FAB medium size 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -622,7 +620,6 @@ exports[`renders FAB transitioning to not visible 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -862,7 +859,6 @@ exports[`renders FAB transitioning to visible 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1103,7 +1099,6 @@ exports[`renders FAB with aria-label 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1343,7 +1338,6 @@ exports[`renders FAB with containerColor and contentColor overrides 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1583,7 +1577,6 @@ exports[`renders FAB with containerColor override 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1823,7 +1816,6 @@ exports[`renders FAB with default props 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2063,7 +2055,6 @@ exports[`renders FAB with primary variant 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2303,7 +2294,6 @@ exports[`renders FAB with secondary variant 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2543,7 +2533,6 @@ exports[`renders FAB with tertiary variant 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2783,7 +2772,6 @@ exports[`renders FAB with tonalSecondary variant 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -3023,7 +3011,6 @@ exports[`renders FAB with tonalTertiary variant 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} diff --git a/src/components/__tests__/__snapshots__/FABExtended.test.tsx.snap b/src/components/__tests__/__snapshots__/FABExtended.test.tsx.snap index f871c16538..644a640c5a 100644 --- a/src/components/__tests__/__snapshots__/FABExtended.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/FABExtended.test.tsx.snap @@ -144,7 +144,6 @@ exports[`renders extended FAB collapsed 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -471,7 +470,6 @@ exports[`renders extended FAB expanded 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -798,7 +796,6 @@ exports[`renders extended FAB large size 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1125,7 +1122,6 @@ exports[`renders extended FAB medium size 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1452,7 +1448,6 @@ exports[`renders extended FAB not visible 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1779,7 +1774,6 @@ exports[`renders extended FAB transitioning to collapsed 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} diff --git a/src/components/__tests__/__snapshots__/FABMenu.test.tsx.snap b/src/components/__tests__/__snapshots__/FABMenu.test.tsx.snap index e9d87ba84b..8b66657112 100644 --- a/src/components/__tests__/__snapshots__/FABMenu.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/FABMenu.test.tsx.snap @@ -123,7 +123,6 @@ exports[`renders FAB.Menu closed 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -300,7 +299,6 @@ exports[`renders FAB.Menu closed 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -608,7 +606,6 @@ exports[`renders FAB.Menu closed 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -903,7 +900,6 @@ 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]} @@ -1080,7 +1076,6 @@ 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]} @@ -1388,7 +1383,6 @@ 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]} @@ -1683,7 +1677,6 @@ exports[`renders FAB.Menu open 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1860,7 +1853,6 @@ exports[`renders FAB.Menu open 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2168,7 +2160,6 @@ exports[`renders FAB.Menu open 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2463,7 +2454,6 @@ exports[`renders FAB.Menu with 6 items 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2640,7 +2630,6 @@ exports[`renders FAB.Menu with 6 items 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2817,7 +2806,6 @@ exports[`renders FAB.Menu with 6 items 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2994,7 +2982,6 @@ exports[`renders FAB.Menu with 6 items 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -3171,7 +3158,6 @@ exports[`renders FAB.Menu with 6 items 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -3348,7 +3334,6 @@ exports[`renders FAB.Menu with 6 items 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -3656,7 +3641,6 @@ exports[`renders FAB.Menu with 6 items 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -3952,7 +3936,6 @@ exports[`renders FAB.Menu with center alignment 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -4129,7 +4112,6 @@ exports[`renders FAB.Menu with center alignment 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -4437,7 +4419,6 @@ exports[`renders FAB.Menu with center alignment 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -4732,7 +4713,6 @@ exports[`renders FAB.Menu with items having icons 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -4939,7 +4919,6 @@ exports[`renders FAB.Menu with items having icons 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -5277,7 +5256,6 @@ exports[`renders FAB.Menu with items having icons 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -5572,7 +5550,6 @@ exports[`renders FAB.Menu with start alignment 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -5749,7 +5726,6 @@ exports[`renders FAB.Menu with start alignment 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -6057,7 +6033,6 @@ exports[`renders FAB.Menu with start alignment 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} diff --git a/src/components/__tests__/__snapshots__/IconButton.test.tsx.snap b/src/components/__tests__/__snapshots__/IconButton.test.tsx.snap index fcc08e35c4..4afccf1c0e 100644 --- a/src/components/__tests__/__snapshots__/IconButton.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/IconButton.test.tsx.snap @@ -14,8 +14,16 @@ exports[`renders disabled icon button 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -47,7 +55,6 @@ exports[`renders disabled icon button 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -67,7 +74,15 @@ exports[`renders disabled icon button 1`] = ` "justifyContent": "center", }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, }, { "overflow": "hidden", @@ -132,8 +147,16 @@ exports[`renders icon button by default 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -162,10 +185,17 @@ exports[`renders icon button by default 1`] = ` centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -185,7 +215,15 @@ exports[`renders icon button by default 1`] = ` "justifyContent": "center", }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, }, { "overflow": "hidden", @@ -250,8 +288,16 @@ exports[`renders icon button with color 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -280,10 +326,17 @@ exports[`renders icon button with color 1`] = ` centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -303,7 +356,15 @@ exports[`renders icon button with color 1`] = ` "justifyContent": "center", }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, }, { "overflow": "hidden", @@ -491,8 +552,16 @@ exports[`renders icon button with size 1`] = ` "width": 46, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 23, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -521,10 +590,17 @@ exports[`renders icon button with size 1`] = ` centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 1, + "left": 1, + "right": 1, + "top": 1, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -544,7 +620,15 @@ exports[`renders icon button with size 1`] = ` "justifyContent": "center", }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderRadius": 23, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, }, { "overflow": "hidden", @@ -732,8 +816,16 @@ exports[`renders icon change animated 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -762,10 +854,17 @@ exports[`renders icon change animated 1`] = ` centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -785,7 +884,15 @@ exports[`renders icon change animated 1`] = ` "justifyContent": "center", }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, }, { "overflow": "hidden", diff --git a/src/components/__tests__/__snapshots__/ListAccordion.test.tsx.snap b/src/components/__tests__/__snapshots__/ListAccordion.test.tsx.snap index 9a67eff123..c01fcf856a 100644 --- a/src/components/__tests__/__snapshots__/ListAccordion.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/ListAccordion.test.tsx.snap @@ -33,7 +33,6 @@ exports[`renders expanded accordion 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -182,7 +181,6 @@ exports[`renders expanded accordion 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -299,7 +297,6 @@ exports[`renders list accordion with children 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -507,7 +504,6 @@ 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]} @@ -708,7 +704,6 @@ exports[`renders list accordion with left items 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -916,7 +911,6 @@ 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 3486b7bed2..6e949d55da 100644 --- a/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap @@ -169,7 +169,6 @@ exports[`renders list item with custom description 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -378,10 +377,17 @@ exports[`renders list item with custom description 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 8, + "left": 0, + "right": 0, + "top": 8, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -415,6 +421,7 @@ exports[`renders list item with custom description 1`] = ` "position": "relative", }, { + "minHeight": 32, "paddingLeft": 0, }, { @@ -551,7 +558,6 @@ 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]} @@ -696,7 +702,6 @@ exports[`renders list item with left and right items 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -886,7 +891,6 @@ exports[`renders list item with left item 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1042,7 +1046,6 @@ exports[`renders list item with right item 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1154,7 +1157,6 @@ exports[`renders list item with title and description 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1295,7 +1297,6 @@ 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 94e51ef9f9..a64f2e3662 100644 --- a/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap @@ -477,7 +477,6 @@ exports[`renders list section with custom title style 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -630,7 +629,6 @@ exports[`renders list section with custom title style 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1237,7 +1235,6 @@ exports[`renders list section with subheader 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1390,7 +1387,6 @@ exports[`renders list section with subheader 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1957,7 +1953,6 @@ exports[`renders list section without subheader 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2110,7 +2105,6 @@ 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 8dbf3f2d26..3e059001aa 100644 --- a/src/components/__tests__/__snapshots__/Menu.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Menu.test.tsx.snap @@ -131,7 +131,6 @@ exports[`renders not visible menu 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -352,7 +351,6 @@ exports[`renders visible menu 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -670,7 +668,6 @@ exports[`renders visible menu 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -793,7 +790,6 @@ 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 b3083b43d2..c316a38599 100644 --- a/src/components/__tests__/__snapshots__/MenuItem.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/MenuItem.test.tsx.snap @@ -26,7 +26,6 @@ exports[`Menu Item renders menu item 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -189,7 +188,6 @@ exports[`Menu Item renders menu item 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -352,7 +350,6 @@ exports[`Menu Item renders menu item 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -515,7 +512,6 @@ exports[`Menu Item renders menu item 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -678,7 +674,6 @@ 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 d989dea1be..82d905d737 100644 --- a/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap @@ -103,8 +103,16 @@ exports[`activity indicator snapshot test 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -134,10 +142,17 @@ exports[`activity indicator snapshot test 1`] = ` centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -157,7 +172,15 @@ exports[`activity indicator snapshot test 1`] = ` "justifyContent": "center", }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, }, { "overflow": "hidden", @@ -943,8 +966,16 @@ exports[`renders with placeholder 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -974,10 +1005,17 @@ exports[`renders with placeholder 1`] = ` centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -997,7 +1035,15 @@ exports[`renders with placeholder 1`] = ` "justifyContent": "center", }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, }, { "overflow": "hidden", @@ -1107,8 +1153,16 @@ exports[`renders with placeholder 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -1138,10 +1192,17 @@ exports[`renders with placeholder 1`] = ` centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1161,7 +1222,15 @@ exports[`renders with placeholder 1`] = ` "justifyContent": "center", }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, }, { "overflow": "hidden", @@ -1317,8 +1386,16 @@ exports[`renders with text 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -1348,10 +1425,17 @@ exports[`renders with text 1`] = ` centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1371,7 +1455,15 @@ exports[`renders with text 1`] = ` "justifyContent": "center", }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, }, { "overflow": "hidden", @@ -1477,8 +1569,16 @@ exports[`renders with text 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -1508,10 +1608,17 @@ exports[`renders with text 1`] = ` centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1531,7 +1638,15 @@ exports[`renders with text 1`] = ` "justifyContent": "center", }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, }, { "overflow": "hidden", diff --git a/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap b/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap index 5fd65d1b31..2c017e23a4 100644 --- a/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap @@ -56,10 +56,17 @@ exports[`renders segmented button 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 6, + "left": 0, + "right": 0, + "top": 6, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -183,10 +190,17 @@ exports[`renders segmented button 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 6, + "left": 0, + "right": 0, + "top": 6, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - 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 42f4bf648d..353efa0fed 100644 --- a/src/components/__tests__/__snapshots__/Snackbar.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Snackbar.test.tsx.snap @@ -600,7 +600,6 @@ 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 e38a5145e1..877ce2a4c4 100644 --- a/src/components/__tests__/__snapshots__/Switch.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Switch.test.tsx.snap @@ -446,6 +446,14 @@ exports[`Switch render renders off 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 0, + "right": 0, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -644,6 +652,14 @@ exports[`Switch render renders on 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 0, + "right": 0, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -822,6 +838,14 @@ exports[`Switch render renders with checked icon 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 0, + "right": 0, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -1071,6 +1095,14 @@ exports[`Switch render renders with per-state icons 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 0, + "right": 0, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} diff --git a/src/components/__tests__/__snapshots__/TextInput.test.tsx.snap b/src/components/__tests__/__snapshots__/TextInput.test.tsx.snap index bca474a072..25bbdc381c 100644 --- a/src/components/__tests__/__snapshots__/TextInput.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/TextInput.test.tsx.snap @@ -157,8 +157,16 @@ exports[`renders filled TextInput with TextInput.Icon accessories 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, { @@ -198,11 +206,18 @@ exports[`renders filled TextInput with TextInput.Icon accessories 1`] = ` centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } multiline={false} onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -222,7 +237,15 @@ exports[`renders filled TextInput with TextInput.Icon accessories 1`] = ` "justifyContent": "center", }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, }, { "overflow": "hidden", @@ -344,8 +367,16 @@ exports[`renders filled TextInput with TextInput.Icon accessories 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, { @@ -385,11 +416,18 @@ exports[`renders filled TextInput with TextInput.Icon accessories 1`] = ` centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } multiline={false} onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -409,7 +447,15 @@ exports[`renders filled TextInput with TextInput.Icon accessories 1`] = ` "justifyContent": "center", }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, }, { "overflow": "hidden", @@ -627,8 +673,16 @@ exports[`renders filled TextInput with TextInput.Icon accessories when error is "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, { @@ -668,11 +722,18 @@ exports[`renders filled TextInput with TextInput.Icon accessories when error is centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } multiline={false} onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -692,7 +753,15 @@ exports[`renders filled TextInput with TextInput.Icon accessories when error is "justifyContent": "center", }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, }, { "overflow": "hidden", @@ -814,8 +883,16 @@ exports[`renders filled TextInput with TextInput.Icon accessories when error is "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, { @@ -855,11 +932,18 @@ exports[`renders filled TextInput with TextInput.Icon accessories when error is centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } multiline={false} onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -879,7 +963,15 @@ exports[`renders filled TextInput with TextInput.Icon accessories when error is "justifyContent": "center", }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, }, { "overflow": "hidden", @@ -1275,8 +1367,16 @@ exports[`renders outlined TextInput with TextInput.Icon accessories 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, { @@ -1316,11 +1416,18 @@ exports[`renders outlined TextInput with TextInput.Icon accessories 1`] = ` centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } multiline={false} onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1340,7 +1447,15 @@ exports[`renders outlined TextInput with TextInput.Icon accessories 1`] = ` "justifyContent": "center", }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, }, { "overflow": "hidden", @@ -1462,8 +1577,16 @@ exports[`renders outlined TextInput with TextInput.Icon accessories 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, { @@ -1503,11 +1626,18 @@ exports[`renders outlined TextInput with TextInput.Icon accessories 1`] = ` centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } multiline={false} onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1527,7 +1657,15 @@ exports[`renders outlined TextInput with TextInput.Icon accessories 1`] = ` "justifyContent": "center", }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, }, { "overflow": "hidden", @@ -1725,8 +1863,16 @@ exports[`renders outlined TextInput with TextInput.Icon accessories when error i "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, { @@ -1766,11 +1912,18 @@ exports[`renders outlined TextInput with TextInput.Icon accessories when error i centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } multiline={false} onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1790,7 +1943,15 @@ exports[`renders outlined TextInput with TextInput.Icon accessories when error i "justifyContent": "center", }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, }, { "overflow": "hidden", @@ -1912,8 +2073,16 @@ exports[`renders outlined TextInput with TextInput.Icon accessories when error i "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, { @@ -1953,11 +2122,18 @@ exports[`renders outlined TextInput with TextInput.Icon accessories when error i centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } multiline={false} onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1977,7 +2153,15 @@ exports[`renders outlined TextInput with TextInput.Icon accessories when error i "justifyContent": "center", }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, }, { "overflow": "hidden", diff --git a/src/utils/getMinInteractiveSizeHitSlop.ts b/src/utils/getMinInteractiveSizeHitSlop.ts new file mode 100644 index 0000000000..3317a79070 --- /dev/null +++ b/src/utils/getMinInteractiveSizeHitSlop.ts @@ -0,0 +1,40 @@ +import type { Insets } from 'react-native'; + +import { tokens } from '../theme/tokens'; + +const { minInteractiveSize } = tokens.md.sys.state; + +/** + * Slop needed to bring a fixed-size element up to the 48dp minimum + * interactive target, expanding outward rather than resizing. Pass the + * element's own rendered width and/or height; omit an axis that is already + * big enough on its own (e.g. a pill that grows with its label) to opt it out + * of slop entirely. Returns `undefined` when there is nothing to add, so that + * case does not create a new object on every call. + * @see https://developer.android.com/develop/ui/compose/accessibility/api-defaults + */ +const getMinInteractiveSizeHitSlop = ({ + width, + height, +}: { + width?: number; + height?: number; +}): Insets | undefined => { + const horizontal = + width === undefined ? 0 : Math.max(0, (minInteractiveSize - width) / 2); + const vertical = + height === undefined ? 0 : Math.max(0, (minInteractiveSize - height) / 2); + + if (horizontal === 0 && vertical === 0) { + return undefined; + } + + return { + top: vertical, + bottom: vertical, + left: horizontal, + right: horizontal, + }; +}; + +export default getMinInteractiveSizeHitSlop; From eee19e94e1b27220f264cb0ef83d4340c529d797 Mon Sep 17 00:00:00 2001 From: Konrad Rydygier Date: Tue, 8 Sep 2026 10:19:27 +0200 Subject: [PATCH 05/15] fix: keep Chip JSDoc attached to component declaration The component doc comment ended up separated from `const Chip =` by the hitSlop helper constants, so the docs generator could no longer find it. --- src/components/Chip/Chip.tsx | 46 ++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/src/components/Chip/Chip.tsx b/src/components/Chip/Chip.tsx index 79fec92b9a..aec703e1aa 100644 --- a/src/components/Chip/Chip.tsx +++ b/src/components/Chip/Chip.tsx @@ -158,29 +158,6 @@ export type Props = Omit & { ref?: React.Ref; }; -/** - * Chips are compact elements that can represent inputs, attributes, or actions. - * They can have an icon or avatar on the left, and a close button icon on the right. - * They are typically used to: - *
    - *
  • Present multiple options
  • - *
  • Represent attributes active or chosen
  • - *
  • Present filter options
  • - *
  • Trigger actions related to primary content
  • - *
- * - * ## Usage - * ```js - * import * as React from 'react'; - * import { Chip } from 'react-native-paper'; - * - * const MyComponent = () => ( - * console.log('Pressed')}>Example Chip - * ); - * - * 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. @@ -210,6 +187,29 @@ const CHIP_BODY_HIT_SLOP = getMinInteractiveSizeHitSlop({ height: CHIP_BODY_HEIGHT, }); +/** + * Chips are compact elements that can represent inputs, attributes, or actions. + * They can have an icon or avatar on the left, and a close button icon on the right. + * They are typically used to: + *
    + *
  • Present multiple options
  • + *
  • Represent attributes active or chosen
  • + *
  • Present filter options
  • + *
  • Trigger actions related to primary content
  • + *
+ * + * ## Usage + * ```js + * import * as React from 'react'; + * import { Chip } from 'react-native-paper'; + * + * const MyComponent = () => ( + * console.log('Pressed')}>Example Chip + * ); + * + * export default MyComponent; + * ``` + */ const Chip = ({ mode = 'flat', children, From 3a992419f37fd36becc749526661bc42a9532f19 Mon Sep 17 00:00:00 2001 From: Konrad Rydygier Date: Tue, 8 Sep 2026 10:23:09 +0200 Subject: [PATCH 06/15] test: pass explicit testID after removal of hardcoded defaults main removed the hardcoded default testIDs from Chip and IconButton (#5088). Guard Chip's close-icon testID the same way its container already is, and pass explicit testID props in the hitSlop/close-icon tests that relied on the old defaults. --- src/components/Chip/Chip.tsx | 2 +- src/components/__tests__/Chip.test.tsx | 2 +- src/components/__tests__/IconButton.test.tsx | 8 +++++--- src/components/__tests__/__snapshots__/Chip.test.tsx.snap | 2 -- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/components/Chip/Chip.tsx b/src/components/Chip/Chip.tsx index aec703e1aa..9f53c9a084 100644 --- a/src/components/Chip/Chip.tsx +++ b/src/components/Chip/Chip.tsx @@ -425,7 +425,7 @@ const Chip = ({ style={styles.closeButton} > {closeIcon ? ( diff --git a/src/components/__tests__/Chip.test.tsx b/src/components/__tests__/Chip.test.tsx index 5075db441e..dd7dc85d5a 100644 --- a/src/components/__tests__/Chip.test.tsx +++ b/src/components/__tests__/Chip.test.tsx @@ -358,7 +358,7 @@ describe('close affordance', () => { it('keeps the close glyph pinned right so it does not drift', async () => { await render( - {}} onClose={() => {}}> + {}} onClose={() => {}}> Example ); diff --git a/src/components/__tests__/IconButton.test.tsx b/src/components/__tests__/IconButton.test.tsx index 5e2e181ba7..872a68fe00 100644 --- a/src/components/__tests__/IconButton.test.tsx +++ b/src/components/__tests__/IconButton.test.tsx @@ -47,7 +47,7 @@ it('renders disabled icon button', async () => { }); it('expands hitSlop up to the 48dp minimum for a button smaller than that', async () => { - await render(); + await render(); // (48 - 40) / 2 on every side, for the default 24dp icon plus 8dp padding // eslint-disable-next-line no-restricted-syntax @@ -60,14 +60,16 @@ it('expands hitSlop up to the 48dp minimum for a button smaller than that', asyn }); it('gives a disabled button no hitSlop of its own', async () => { - await render(); + await render(); // eslint-disable-next-line no-restricted-syntax expect(screen.getByTestId('icon-button').props.hitSlop).toBeUndefined(); }); it('lets a caller-supplied hitSlop win even while disabled', async () => { - await render(); + await render( + + ); // eslint-disable-next-line no-restricted-syntax expect(screen.getByTestId('icon-button').props.hitSlop).toBe(2); diff --git a/src/components/__tests__/__snapshots__/Chip.test.tsx.snap b/src/components/__tests__/__snapshots__/Chip.test.tsx.snap index 9bdf665a2b..cb49e687a2 100644 --- a/src/components/__tests__/__snapshots__/Chip.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Chip.test.tsx.snap @@ -335,7 +335,6 @@ exports[`renders chip with close button 1`] = ` }, ] } - testID="chip-close-icon" > Date: Thu, 10 Sep 2026 12:27:40 +0200 Subject: [PATCH 07/15] fix: address review feedback on 48dp touch targets --- docs/6.x/docs/guides/migration.md | 32 ++++ eslint.config.mjs | 1 + src/components/Button/Button.tsx | 7 +- src/components/Button/utils.tsx | 16 +- src/components/Checkbox/Checkbox.tsx | 17 ++- src/components/Chip/Chip.tsx | 36 ++++- src/components/Drawer/DrawerItem.tsx | 1 + src/components/FAB/Menu.tsx | 1 + src/components/IconButton/IconButton.tsx | 27 +++- .../RadioButton/RadioButtonAndroid.tsx | 7 +- src/components/RadioButton/RadioButtonIOS.tsx | 7 +- .../SegmentedButtons/SegmentedButtonItem.tsx | 10 +- src/components/SegmentedButtons/utils.ts | 18 ++- .../TouchableRipple.native.tsx | 77 +++++----- .../TouchableRipple/TouchableRipple.tsx | 126 +++++++++------- src/components/TouchableRipple/utils.ts | 12 ++ .../Appbar/__snapshots__/Appbar.test.tsx.snap | 16 ++ src/components/__tests__/IconButton.test.tsx | 24 +++ .../__tests__/TouchableRipple.test.tsx | 5 +- .../__tests__/TouchableRippleWeb.test.tsx | 141 +++++++++++++----- .../__snapshots__/Chip.test.tsx.snap | 16 ++ .../__snapshots__/DataTable.test.tsx.snap | 48 ++++++ .../__snapshots__/IconButton.test.tsx.snap | 20 +++ .../__snapshots__/Searchbar.test.tsx.snap | 20 +++ .../__snapshots__/TextInput.test.tsx.snap | 32 ++++ src/theme/tokens/sys/state.ts | 7 - src/utils/getMinInteractiveSizeHitSlop.ts | 12 +- 27 files changed, 547 insertions(+), 189 deletions(-) diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index e51de006aa..96eaafc2c2 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -209,6 +209,38 @@ The misspelled `traileringIcon` props have been renamed: /> ``` +### Chip + +The close button (`onClose`) now fills the entire trailing 34dp column reserved for it, matching Material Design 3's touch target guidance, instead of only its 24x18 icon. Taps near the top or bottom of that column, which used to fall through to the chip's own `onPress`, now activate `onClose` instead. + +### TouchableRipple + +- `borderless` no longer clips the touchable's own content on web; it only clips the ripple itself, in its own container. A child that needs a clipped or rounded shape should carry that shape itself. +- Corner radius and border width set through `style` no longer shape the highlight underlay (native) or the ripple's self-clipping container (web). Pass them as dedicated props instead: + - `borderRadius` + - `borderTopLeftRadius` + - `borderTopRightRadius` + - `borderBottomLeftRadius` + - `borderBottomRightRadius` + - `borderTopStartRadius` + - `borderTopEndRadius` + - `borderBottomStartRadius` + - `borderBottomEndRadius` + - `borderWidth` (web only) + +e.g.: + +```diff + {}} +> + Content + +``` + ### TextInput The Paper 6.x `TextInput` is a complete rewrite with a new API. Import the component the same way, but note that the props and behavior have changed significantly. diff --git a/eslint.config.mjs b/eslint.config.mjs index ac6e65951f..776d90544b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -221,6 +221,7 @@ export default defineConfig( 'src/components/__tests__/Appbar/Appbar.test.tsx', 'src/components/__tests__/Dialog.test.tsx', 'src/components/__tests__/Searchbar.test.tsx', + 'src/components/__tests__/TouchableRippleWeb.test.tsx', ], rules: { 'testing-library/no-node-access': 'off', diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index 6f07113572..4b12466879 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -262,6 +262,10 @@ const Button = ({ }); const touchableStyle = { borderRadius }; + const touchableRippleStyle = getButtonTouchableRippleStyle( + touchableStyle, + borderWidth + ); const { color: customLabelColor, fontSize: customLabelSize } = StyleSheet.flatten(labelStyle) || {}; @@ -334,7 +338,8 @@ const Button = ({ accessible={accessible} hitSlop={hitSlop} disabled={disabled} - style={getButtonTouchableRippleStyle(touchableStyle, borderWidth)} + style={touchableRippleStyle} + {...touchableRippleStyle} testID={testID} theme={theme} ref={touchableRef} diff --git a/src/components/Button/utils.tsx b/src/components/Button/utils.tsx index ec3503c82b..bbebfd7791 100644 --- a/src/components/Button/utils.tsx +++ b/src/components/Button/utils.tsx @@ -4,6 +4,7 @@ import { black, white } from '../../theme/colors'; import { tokens } from '../../theme/tokens'; import type { InternalTheme } from '../../theme/types'; import { splitStyles } from '../../utils/splitStyles'; +import type { BorderRadiusStyle } from '../TouchableRipple/utils'; const stateOpacity = tokens.md.sys.state.opacity; @@ -191,20 +192,7 @@ export const getButtonColors = ({ }; }; -type ViewStyleBorderRadiusStyles = Partial< - Pick< - ViewStyle, - | 'borderBottomEndRadius' - | 'borderBottomLeftRadius' - | 'borderBottomRightRadius' - | 'borderBottomStartRadius' - | 'borderTopEndRadius' - | 'borderTopLeftRadius' - | 'borderTopRightRadius' - | 'borderTopStartRadius' - | 'borderRadius' - > ->; +type ViewStyleBorderRadiusStyles = Partial; export const getButtonTouchableRippleStyle = ( style?: ViewStyle, borderWidth: number = 0 diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx index 7f49338344..bdb3c9dbf2 100644 --- a/src/components/Checkbox/Checkbox.tsx +++ b/src/components/Checkbox/Checkbox.tsx @@ -79,10 +79,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`, 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. +// We don't apply `focusIndicator.outerOffset`, keeping the ring inside the +// 40dp circle: whether TouchableRipple clips content past that boundary +// depends on platform and ripple mode, so staying inside it avoids relying +// on any of that. const FOCUS_RING_SIZE = STATE_LAYER_SIZE; const FOCUS_RING_RADIUS = STATE_LAYER_SIZE / 2; @@ -252,7 +252,14 @@ const Checkbox = ({ disabled={disabled} {...accessibilityProps} testID={testID} - hitSlop={rest.hitSlop ?? (disabled ? undefined : CHECKBOX_HIT_SLOP)} + hitSlop={ + rest.hitSlop !== undefined + ? rest.hitSlop + : disabled + ? undefined + : CHECKBOX_HIT_SLOP + } + borderRadius={FOCUS_RING_RADIUS} style={[ styles.tapTarget, Platform.OS === 'web' ? webNoOutline : undefined, diff --git a/src/components/Chip/Chip.tsx b/src/components/Chip/Chip.tsx index 9f53c9a084..53f949b0fd 100644 --- a/src/components/Chip/Chip.tsx +++ b/src/components/Chip/Chip.tsx @@ -162,11 +162,10 @@ export type Props = Omit & { * 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 + * Matches material-web's own remove button, which expands to a 48px touch + * target the same way; the 24x24 dimensions in its `_trailing-icon.scss` are + * for the ripple and focus ring, not the touch target. + * @see https://github.com/material-components/material-web/blob/main/chips/internal/_shared.scss */ const CLOSE_AFFORDANCE_WIDTH = 34; @@ -186,6 +185,9 @@ const { containerHeight: CHIP_BODY_HEIGHT } = ChipTokens; const CHIP_BODY_HIT_SLOP = getMinInteractiveSizeHitSlop({ height: CHIP_BODY_HEIGHT, }); +// The close button's own box is the same fixed height as the body, so it +// needs the same vertical slop to reach 48dp. +const CLOSE_BUTTON_WEB_TOUCH_TARGET_INSET = CHIP_BODY_HIT_SLOP?.top ?? 0; /** * Chips are compact elements that can represent inputs, attributes, or actions. @@ -323,6 +325,7 @@ const Chip = ({ borderless background={background} style={[{ borderRadius }, styles.touchable]} + borderRadius={borderRadius} onPress={onPress} onLongPress={onLongPress} onPressIn={hasPassedTouchHandler ? handlePressIn : undefined} @@ -335,7 +338,13 @@ const Chip = ({ aria-disabled={disabled} testID={testID} theme={theme} - hitSlop={hitSlop ?? (disabled ? undefined : CHIP_BODY_HIT_SLOP)} + hitSlop={ + hitSlop !== undefined + ? hitSlop + : disabled + ? undefined + : CHIP_BODY_HIT_SLOP + } > + {/* react-native-web removed `hitSlop` in 0.13.0, + so web needs a real element the browser can + hit-test instead of a native responder inset. */} + {Platform.OS === 'web' && !disabled && ( + + )} ; /** * Function to execute on press. */ onPress?: (e: GestureResponderEvent) => void; + /** + * Width of the button's inner content. Defaults to the button's size. + */ + width?: number; + /** + * Height of the button's inner content. Defaults to the button's size. + */ + height?: number; /** * Radius of every corner of the button. Defaults to a circle (half of the * button's size). Read as a plain prop rather than out of `style`, since @@ -144,6 +153,8 @@ const IconButton = ({ testID, loading = false, contentStyle, + width, + height, borderRadius, borderTopLeftRadius, borderTopRightRadius, @@ -196,13 +207,15 @@ const IconButton = ({ ...shapeStyles, }; - // Computed straight from `size`, a plain prop known at render time, rather - // than measured. `buttonSize` never changes after mount without `size` also - // changing, so there is nothing to react to. A disabled button gets no - // slop of its own, only what a caller's own `hitSlop` in `rest` supplies. + const touchableWidth = width ?? buttonSize - 2 * borderWidth; + const touchableHeight = height ?? buttonSize - 2 * borderWidth; + const hitSlop = disabled ? undefined - : getMinInteractiveSizeHitSlop({ width: buttonSize, height: buttonSize }); + : getMinInteractiveSizeHitSlop({ + width: touchableWidth, + height: touchableHeight, + }); return ( & { + borderEndWidth?: number; +}; + export const getSegmentedButtonBorderRadius = ({ segment, + borderRadius, }: { theme: InternalTheme; segment?: 'first' | 'last'; -}): ViewStyle => { + borderRadius: number; +}): SegmentBorderRadiusStyle => { if (segment === 'first') { return { + borderRadius, borderTopRightRadius: 0, borderBottomRightRadius: 0, borderEndWidth: 0, }; } else if (segment === 'last') { return { + borderRadius, borderTopLeftRadius: 0, borderBottomLeftRadius: 0, }; diff --git a/src/components/TouchableRipple/TouchableRipple.native.tsx b/src/components/TouchableRipple/TouchableRipple.native.tsx index 5fc858b164..caeb449982 100644 --- a/src/components/TouchableRipple/TouchableRipple.native.tsx +++ b/src/components/TouchableRipple/TouchableRipple.native.tsx @@ -20,43 +20,6 @@ import hasTouchHandler from '../../utils/hasTouchHandler'; const ANDROID_VERSION_LOLLIPOP = 21; const ANDROID_VERSION_PIE = 28; -/** - * The underlay fills the touchable absolutely and has no radius of its own, so - * it paints square corners over a rounded one. A clipping ancestor used to hide - * that, and those ancestors have to stop clipping to reach into the `hitSlop`. - */ -const getUnderlayShape = (style: StyleProp): ViewStyle => { - const flat = StyleSheet.flatten(style); - - if (!flat) { - return {}; - } - - const { - borderRadius, - borderTopLeftRadius, - borderTopRightRadius, - borderBottomLeftRadius, - borderBottomRightRadius, - borderTopStartRadius, - borderTopEndRadius, - borderBottomStartRadius, - borderBottomEndRadius, - } = flat; - - return { - borderRadius, - borderTopLeftRadius, - borderTopRightRadius, - borderBottomLeftRadius, - borderBottomRightRadius, - borderTopStartRadius, - borderTopEndRadius, - borderBottomStartRadius, - borderBottomEndRadius, - }; -}; - export type Props = PressableProps & { borderless?: boolean; background?: PressableAndroidRippleConfig; @@ -72,6 +35,21 @@ export type Props = PressableProps & { style?: StyleProp; ref?: React.Ref; theme?: ThemeProp; + borderRadius?: number; + borderTopLeftRadius?: number; + borderTopRightRadius?: number; + borderBottomLeftRadius?: number; + borderBottomRightRadius?: number; + borderTopStartRadius?: number; + borderTopEndRadius?: number; + borderBottomStartRadius?: number; + borderBottomEndRadius?: number; + /** + * Web-only: widens the touch target back out past the touchable's own + * border. Accepted here too so both platforms share one `Props` type; has + * no effect on native, where `hitSlop` isn't offset from inside the border. + */ + borderWidth?: number; }; const TouchableRipple = ({ @@ -84,9 +62,32 @@ const TouchableRipple = ({ children, theme: themeOverrides, hitSlop, + borderRadius, + borderTopLeftRadius, + borderTopRightRadius, + borderBottomLeftRadius, + borderBottomRightRadius, + borderTopStartRadius, + borderTopEndRadius, + borderBottomStartRadius, + borderBottomEndRadius, + // consumed so it does not reach the underlying Pressable; web-only, no + // native effect + borderWidth: _borderWidth, ref, ...rest }: Props) => { + const underlayShape: ViewStyle = { + borderRadius, + borderTopLeftRadius, + borderTopRightRadius, + borderBottomLeftRadius, + borderBottomRightRadius, + borderTopStartRadius, + borderTopEndRadius, + borderBottomStartRadius, + borderBottomEndRadius, + }; const theme = useInternalTheme(themeOverrides); const { rippleEffectEnabled } = React.useContext(SettingsContext); @@ -153,7 +154,7 @@ const TouchableRipple = ({ diff --git a/src/components/TouchableRipple/TouchableRipple.tsx b/src/components/TouchableRipple/TouchableRipple.tsx index ccd0e36508..7e5c6ecce0 100644 --- a/src/components/TouchableRipple/TouchableRipple.tsx +++ b/src/components/TouchableRipple/TouchableRipple.tsx @@ -18,37 +18,6 @@ import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../theme/types'; import hasTouchHandler from '../../utils/hasTouchHandler'; -/** - * react-native-web removed `hitSlop` in 0.13.0, so web needs a real element the - * browser can hit-test instead of a native responder inset. - * @see https://github.com/necolas/react-native-web/releases/tag/0.13.0 - */ -const getTouchTargetStyle = (hitSlop: PressableProps['hitSlop']): ViewStyle => { - // `undefined` or `null` both mean no slop: nothing for the caller to opt - // into, so the target matches the touchable's own bounds. - if (hitSlop === undefined || hitSlop === null) { - return styles.noTouchTarget; - } - - const inset = (value: number | undefined) => -(value ?? 0); - - return typeof hitSlop === 'number' - ? { - position: 'absolute', - top: inset(hitSlop), - bottom: inset(hitSlop), - left: inset(hitSlop), - right: inset(hitSlop), - } - : { - position: 'absolute', - top: inset(hitSlop.top), - bottom: inset(hitSlop.bottom), - left: inset(hitSlop.left), - right: inset(hitSlop.right), - }; -}; - export type Props = PressableProps & { /** * Whether to render the ripple outside the view bounds. @@ -111,6 +80,28 @@ export type Props = PressableProps & { * @optional */ theme?: ThemeProp; + /** + * Radius of every corner of the touchable. Native-only: it shapes the + * highlight underlay there. On web the ripple container clips itself + * regardless, so this has no effect. + */ + borderRadius?: number; + borderTopLeftRadius?: number; + borderTopRightRadius?: number; + borderBottomLeftRadius?: number; + borderBottomRightRadius?: number; + borderTopStartRadius?: number; + borderTopEndRadius?: number; + borderBottomStartRadius?: number; + borderBottomEndRadius?: number; + /** + * Width of the touchable's own border, if it draws one. Web-only: the touch + * target's absolute offsets start inside the border, not the visible outer + * edge, so this widens the target back out to it. Read as a plain prop + * rather than out of `style`, since `style` can be a function of press + * state instead of a plain object to read a value off. + */ + borderWidth?: number; }; /** @@ -149,6 +140,18 @@ const TouchableRipple = ({ children, theme: themeOverrides, hitSlop, + // consumed so they do not reach the DOM; native-only, the ripple container + // clips itself regardless of shape on web + borderRadius: _borderRadius, + borderTopLeftRadius: _borderTopLeftRadius, + borderTopRightRadius: _borderTopRightRadius, + borderBottomLeftRadius: _borderBottomLeftRadius, + borderBottomRightRadius: _borderBottomRightRadius, + borderTopStartRadius: _borderTopStartRadius, + borderTopEndRadius: _borderTopEndRadius, + borderBottomStartRadius: _borderBottomStartRadius, + borderBottomEndRadius: _borderBottomEndRadius, + borderWidth, ref, ...rest }: Props) => { @@ -336,26 +339,40 @@ const TouchableRipple = ({ typeof style === 'function' ? style(state) : style, ]} > - {(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 - )} - - )} + {(state) => { + const border = borderWidth ?? 0; + const inset = (value: number | undefined) => -((value ?? 0) + border); + + const touchTargetStyle: ViewStyle | undefined = + hitSlop == null + ? undefined + : 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), + }; + + return ( + <> + {!disabled && touchTargetStyle && ( + + )} + {React.Children.only( + typeof children === 'function' ? children(state) : children + )} + + ); + }} ); }; @@ -378,13 +395,6 @@ const styles = StyleSheet.create({ cursor: 'auto', }), }, - noTouchTarget: { - position: 'absolute', - top: 0, - bottom: 0, - left: 0, - right: 0, - }, }); export default TouchableRipple; diff --git a/src/components/TouchableRipple/utils.ts b/src/components/TouchableRipple/utils.ts index 2874eafc86..e81a5036f5 100644 --- a/src/components/TouchableRipple/utils.ts +++ b/src/components/TouchableRipple/utils.ts @@ -2,6 +2,18 @@ import type { ColorValue } from 'react-native'; import type { InternalTheme } from '../../theme/types'; +export type BorderRadiusStyle = { + borderRadius?: number; + borderTopLeftRadius?: number; + borderTopRightRadius?: number; + borderBottomLeftRadius?: number; + borderBottomRightRadius?: number; + borderTopStartRadius?: number; + borderTopEndRadius?: number; + borderBottomStartRadius?: number; + borderBottomEndRadius?: number; +}; + const getUnderlayColor = ({ calculatedRippleColor, underlayColor, diff --git a/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap b/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap index 3f30b0ce3e..80c909be8a 100644 --- a/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap +++ b/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap @@ -283,6 +283,10 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` { "overflow": "hidden", }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] @@ -470,6 +474,10 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` { "overflow": "hidden", }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] @@ -713,6 +721,10 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A { "overflow": "hidden", }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] @@ -956,6 +968,10 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A { "overflow": "hidden", }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] diff --git a/src/components/__tests__/IconButton.test.tsx b/src/components/__tests__/IconButton.test.tsx index 872a68fe00..36c28686a1 100644 --- a/src/components/__tests__/IconButton.test.tsx +++ b/src/components/__tests__/IconButton.test.tsx @@ -75,6 +75,30 @@ it('lets a caller-supplied hitSlop win even while disabled', async () => { expect(screen.getByTestId('icon-button').props.hitSlop).toBe(2); }); +it('computes hitSlop from explicit width/height rather than the button size', async () => { + await render( + + ); + + // (48 - 20) / 2 on every side + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('icon-button').props.hitSlop).toEqual({ + top: 14, + bottom: 14, + left: 14, + right: 14, + }); +}); + +it('drops hitSlop when explicit width/height already meet the 48dp minimum', async () => { + await render( + + ); + + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('icon-button').props.hitSlop).toBeUndefined(); +}); + it('renders icon change animated', async () => { const tree = (await render()).toJSON(); diff --git a/src/components/__tests__/TouchableRipple.test.tsx b/src/components/__tests__/TouchableRipple.test.tsx index 173906eb04..6c8441ffb8 100644 --- a/src/components/__tests__/TouchableRipple.test.tsx +++ b/src/components/__tests__/TouchableRipple.test.tsx @@ -69,7 +69,7 @@ describe('TouchableRipple', () => { it('takes the shape of the touchable so it does not square off the corners', async () => { await render( - + Press me! ); @@ -83,7 +83,8 @@ describe('TouchableRipple', () => { await render( Press me! diff --git a/src/components/__tests__/TouchableRippleWeb.test.tsx b/src/components/__tests__/TouchableRippleWeb.test.tsx index a3e811a390..c02e2cae3a 100644 --- a/src/components/__tests__/TouchableRippleWeb.test.tsx +++ b/src/components/__tests__/TouchableRippleWeb.test.tsx @@ -1,6 +1,7 @@ import { Text } from 'react-native'; import { describe, expect, it } from '@jest/globals'; +import type { TestInstance } from 'test-renderer'; import { render, screen } from '../../test-utils'; import type TouchableRippleType from '../TouchableRipple/TouchableRipple'; @@ -17,84 +18,110 @@ import type TouchableRippleType from '../TouchableRipple/TouchableRipple'; 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; +const TOUCHABLE = 'touchable'; + +// `children` admits raw text nodes since a host element can render one, but +// the touchable's own children never do; this makes that assumption explicit +// to the type checker instead of leaving `children[0]` typed as `TestNode`. +const asElement = (node: TestInstance | string): TestInstance => { + if (typeof node === 'string') { + throw new Error('Expected an element, not a text node'); + } + return node; +}; + +// The target has no testID of its own (it would be identical, and thus +// ambiguous, on every instance) and no role, so instead of querying for it +// directly, it's addressed by position: it is always the touchable's first +// host child when rendered, and is simply absent (not a hidden sibling) +// otherwise. +const getTarget = () => { + const children = screen.getByTestId(TOUCHABLE).children; + return children.length > 1 ? asElement(children[0]) : null; +}; + +// Throws instead of returning null, so tests that expect a target don't need +// a non-null assertion to use it. +const requireTarget = () => { + const target = getTarget(); + if (target === null) { + throw new Error('Expected a touch target to be rendered'); + } + return target; +}; + +const styleOf = (node: TestInstance) => { + // eslint-disable-next-line no-restricted-syntax + const { style } = node.props; + return Array.isArray(style) ? Object.assign({}, ...style.flat()) : style; +}; describe('TouchableRipple (web)', () => { - // The target is invisible by design, so there is no user-visible assertion to - // make about it. Its style is the behaviour. - const styleOf = (testID: string) => { - // eslint-disable-next-line no-restricted-syntax - const { style } = screen.getByTestId(testID, HIDDEN).props; - return Array.isArray(style) ? Object.assign({}, ...style.flat()) : style; - }; - - it("renders a touch target matching the touchable's own bounds by default", async () => { + it('does not render a touch target when there is no hitSlop', async () => { // No minimum is enforced here: the primitive does not measure, so with no - // caller-supplied `hitSlop` the target is exactly the touchable's bounds. + // caller-supplied `hitSlop` there is nothing to expand into and no target + // to render. await render( - {}}> + {}} testID={TOUCHABLE}> Button ); - expect(screen.getByTestId(TARGET, HIDDEN)).toBeOnTheScreen(); - expect(styleOf(TARGET)).toEqual({ - position: 'absolute', - top: 0, - bottom: 0, - left: 0, - right: 0, - }); + expect(getTarget()).toBeNull(); }); 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( - {}}> + {}} testID={TOUCHABLE}> child-marker ); - const tree = JSON.stringify(screen.toJSON()); + const [first, second] = screen + .getByTestId(TOUCHABLE) + .children.map(asElement); - expect(tree.indexOf(TARGET)).toBeGreaterThan(-1); - expect(tree.indexOf(TARGET)).toBeLessThan(tree.indexOf('child-marker')); + // eslint-disable-next-line no-restricted-syntax + expect(first.props['aria-hidden']).toBe(true); + // eslint-disable-next-line no-restricted-syntax + expect(second.props.children).toBe('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(); + expect(getTarget()).toBeNull(); }); it('does not render a touch target when disabled', async () => { await render( - {}}> + {}} + testID={TOUCHABLE} + > Button ); - expect(screen.queryByTestId(TARGET, HIDDEN)).not.toBeOnTheScreen(); + expect(getTarget()).toBeNull(); }); - it('lets a caller-supplied hitSlop size the target instead', async () => { + it('lets a caller-supplied hitSlop size the target', async () => { await render( - {}}> + {}} testID={TOUCHABLE}> Button ); - expect(styleOf(TARGET)).toEqual({ + expect(styleOf(requireTarget())).toEqual({ position: 'absolute', top: -6, bottom: -6, @@ -105,12 +132,16 @@ describe('TouchableRipple (web)', () => { it('accepts a per-edge hitSlop', async () => { await render( - {}}> + {}} + testID={TOUCHABLE} + > Button ); - expect(styleOf(TARGET)).toEqual({ + expect(styleOf(requireTarget())).toEqual({ position: 'absolute', top: -4, bottom: -0, @@ -119,14 +150,44 @@ describe('TouchableRipple (web)', () => { }); }); + it('extends the target past a border, since absolute offsets start inside it', async () => { + // Offsets on a position: absolute child are relative to the parent's + // padding edge, inside its border, not its visible outer edge. Without + // adding the border back in, the target would fall short of `hitSlop` + // past what's actually visible. + await render( + {}} + testID={TOUCHABLE} + > + Button + + ); + + expect(styleOf(requireTarget())).toEqual({ + position: 'absolute', + top: -6, + bottom: -6, + left: -6, + right: -6, + }); + }); + it('no longer clips the touchable itself, which would clip the target', async () => { await render( - {}} testID="touchable"> + {}} testID={TOUCHABLE}> Button ); - const style = styleOf('touchable'); + // eslint-disable-next-line no-restricted-syntax + const { style: rawStyle } = screen.getByTestId(TOUCHABLE).props; + const style = Array.isArray(rawStyle) + ? Object.assign({}, ...rawStyle.flat()) + : rawStyle; // check we have the touchable's own style first, or the absence below passes // against any empty object diff --git a/src/components/__tests__/__snapshots__/Chip.test.tsx.snap b/src/components/__tests__/__snapshots__/Chip.test.tsx.snap index cb49e687a2..c5477e7e73 100644 --- a/src/components/__tests__/__snapshots__/Chip.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Chip.test.tsx.snap @@ -300,6 +300,14 @@ exports[`renders chip with close button 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 8, + "left": 0, + "right": 0, + "top": 8, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -671,6 +679,14 @@ exports[`renders chip with custom close button 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 8, + "left": 0, + "right": 0, + "top": 8, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} diff --git a/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap b/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap index 359971ce61..a6e2dc6d3f 100644 --- a/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap @@ -776,6 +776,10 @@ exports[`DataTable.Pagination renders data table pagination 1`] = ` { "overflow": "hidden", }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] @@ -915,6 +919,10 @@ exports[`DataTable.Pagination renders data table pagination 1`] = ` { "overflow": "hidden", }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] @@ -1112,6 +1120,10 @@ exports[`DataTable.Pagination renders data table pagination with fast-forward bu { "overflow": "hidden", }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] @@ -1251,6 +1263,10 @@ exports[`DataTable.Pagination renders data table pagination with fast-forward bu { "overflow": "hidden", }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] @@ -1390,6 +1406,10 @@ exports[`DataTable.Pagination renders data table pagination with fast-forward bu { "overflow": "hidden", }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] @@ -1529,6 +1549,10 @@ exports[`DataTable.Pagination renders data table pagination with fast-forward bu { "overflow": "hidden", }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] @@ -1726,6 +1750,10 @@ exports[`DataTable.Pagination renders data table pagination with label 1`] = ` { "overflow": "hidden", }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] @@ -1865,6 +1893,10 @@ exports[`DataTable.Pagination renders data table pagination with label 1`] = ` { "overflow": "hidden", }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] @@ -2360,6 +2392,10 @@ exports[`DataTable.Pagination renders data table pagination with options select { "overflow": "hidden", }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] @@ -2499,6 +2535,10 @@ exports[`DataTable.Pagination renders data table pagination with options select { "overflow": "hidden", }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] @@ -2638,6 +2678,10 @@ exports[`DataTable.Pagination renders data table pagination with options select { "overflow": "hidden", }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] @@ -2777,6 +2821,10 @@ exports[`DataTable.Pagination renders data table pagination with options select { "overflow": "hidden", }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] diff --git a/src/components/__tests__/__snapshots__/IconButton.test.tsx.snap b/src/components/__tests__/__snapshots__/IconButton.test.tsx.snap index 4afccf1c0e..71d404ed58 100644 --- a/src/components/__tests__/__snapshots__/IconButton.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/IconButton.test.tsx.snap @@ -87,6 +87,10 @@ exports[`renders disabled icon button 1`] = ` { "overflow": "hidden", }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] @@ -228,6 +232,10 @@ exports[`renders icon button by default 1`] = ` { "overflow": "hidden", }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] @@ -369,6 +377,10 @@ exports[`renders icon button with color 1`] = ` { "overflow": "hidden", }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] @@ -633,6 +645,10 @@ exports[`renders icon button with size 1`] = ` { "overflow": "hidden", }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] @@ -897,6 +913,10 @@ exports[`renders icon change animated 1`] = ` { "overflow": "hidden", }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] diff --git a/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap b/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap index 82d905d737..9c01dfd366 100644 --- a/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap @@ -185,6 +185,10 @@ exports[`activity indicator snapshot test 1`] = ` { "overflow": "hidden", }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] @@ -1048,6 +1052,10 @@ exports[`renders with placeholder 1`] = ` { "overflow": "hidden", }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] @@ -1235,6 +1243,10 @@ exports[`renders with placeholder 1`] = ` { "overflow": "hidden", }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] @@ -1468,6 +1480,10 @@ exports[`renders with text 1`] = ` { "overflow": "hidden", }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] @@ -1651,6 +1667,10 @@ exports[`renders with text 1`] = ` { "overflow": "hidden", }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] diff --git a/src/components/__tests__/__snapshots__/TextInput.test.tsx.snap b/src/components/__tests__/__snapshots__/TextInput.test.tsx.snap index 25bbdc381c..ba5d86731d 100644 --- a/src/components/__tests__/__snapshots__/TextInput.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/TextInput.test.tsx.snap @@ -250,6 +250,10 @@ exports[`renders filled TextInput with TextInput.Icon accessories 1`] = ` { "overflow": "hidden", }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] @@ -460,6 +464,10 @@ exports[`renders filled TextInput with TextInput.Icon accessories 1`] = ` { "overflow": "hidden", }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] @@ -766,6 +774,10 @@ exports[`renders filled TextInput with TextInput.Icon accessories when error is { "overflow": "hidden", }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] @@ -976,6 +988,10 @@ exports[`renders filled TextInput with TextInput.Icon accessories when error is { "overflow": "hidden", }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] @@ -1460,6 +1476,10 @@ exports[`renders outlined TextInput with TextInput.Icon accessories 1`] = ` { "overflow": "hidden", }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] @@ -1670,6 +1690,10 @@ exports[`renders outlined TextInput with TextInput.Icon accessories 1`] = ` { "overflow": "hidden", }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] @@ -1956,6 +1980,10 @@ exports[`renders outlined TextInput with TextInput.Icon accessories when error i { "overflow": "hidden", }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] @@ -2166,6 +2194,10 @@ exports[`renders outlined TextInput with TextInput.Icon accessories when error i { "overflow": "hidden", }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] diff --git a/src/theme/tokens/sys/state.ts b/src/theme/tokens/sys/state.ts index ab3aa94f68..d0742351bf 100644 --- a/src/theme/tokens/sys/state.ts +++ b/src/theme/tokens/sys/state.ts @@ -15,11 +15,4 @@ 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/getMinInteractiveSizeHitSlop.ts b/src/utils/getMinInteractiveSizeHitSlop.ts index 3317a79070..d379dbe5aa 100644 --- a/src/utils/getMinInteractiveSizeHitSlop.ts +++ b/src/utils/getMinInteractiveSizeHitSlop.ts @@ -1,8 +1,10 @@ import type { Insets } from 'react-native'; -import { tokens } from '../theme/tokens'; - -const { minInteractiveSize } = tokens.md.sys.state; +/** + * Minimum size of an interactive target. + * @see https://m3.material.io/foundations/designing/structure + */ +const MIN_INTERACTIVE_SIZE = 48; /** * Slop needed to bring a fixed-size element up to the 48dp minimum @@ -21,9 +23,9 @@ const getMinInteractiveSizeHitSlop = ({ height?: number; }): Insets | undefined => { const horizontal = - width === undefined ? 0 : Math.max(0, (minInteractiveSize - width) / 2); + width === undefined ? 0 : Math.max(0, (MIN_INTERACTIVE_SIZE - width) / 2); const vertical = - height === undefined ? 0 : Math.max(0, (minInteractiveSize - height) / 2); + height === undefined ? 0 : Math.max(0, (MIN_INTERACTIVE_SIZE - height) / 2); if (horizontal === 0 && vertical === 0) { return undefined; From 2985c8abb56b9e91a10ba67512560cfb39939e15 Mon Sep 17 00:00:00 2001 From: Konrad Rydygier Date: Thu, 10 Sep 2026 12:40:21 +0200 Subject: [PATCH 08/15] test: fix tests broken by rebase onto upstream main --- .../Appbar/__snapshots__/Appbar.test.tsx.snap | 140 ++++++++++++--- .../__snapshots__/CheckboxItem.test.tsx.snap | 8 + src/components/__tests__/IconButton.test.tsx | 2 +- .../__tests__/TouchableRipple.test.tsx | 13 +- .../__snapshots__/DataTable.test.tsx.snap | 8 + .../__snapshots__/IconButton.test.tsx.snap | 70 ++++++-- .../__snapshots__/Searchbar.test.tsx.snap | 70 ++++++-- .../SegmentedButton.test.tsx.snap | 104 +++++++++++ .../TouchableRipple.test.tsx.snap | 166 ++++++++++++++++++ 9 files changed, 523 insertions(+), 58 deletions(-) diff --git a/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap b/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap index 80c909be8a..532ee99c82 100644 --- a/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap +++ b/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap @@ -1162,7 +1162,6 @@ exports[`AppbarAction should be rendered with custom color 1`] = ` [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -1170,8 +1169,16 @@ exports[`AppbarAction should be rendered with custom color 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -1202,10 +1209,10 @@ exports[`AppbarAction should be rendered with custom color 1`] = ` focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } onBlur={[Function]} @@ -1229,6 +1236,24 @@ exports[`AppbarAction should be rendered with custom color 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "overflow": "hidden", + }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] @@ -1420,7 +1445,6 @@ exports[`AppbarAction should be rendered with default theme color 1`] = ` [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -1428,8 +1452,16 @@ exports[`AppbarAction should be rendered with default theme color 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -1460,10 +1492,10 @@ exports[`AppbarAction should be rendered with default theme color 1`] = ` focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } onBlur={[Function]} @@ -1487,6 +1519,24 @@ exports[`AppbarAction should be rendered with default theme color 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "overflow": "hidden", + }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] @@ -1678,7 +1728,6 @@ exports[`AppbarAction should be rendered with specific theme color if is leading [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -1686,8 +1735,16 @@ exports[`AppbarAction should be rendered with specific theme color if is leading "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -1718,10 +1775,10 @@ exports[`AppbarAction should be rendered with specific theme color if is leading focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } onBlur={[Function]} @@ -1745,6 +1802,24 @@ exports[`AppbarAction should be rendered with specific theme color if is leading "flexGrow": 1, "justifyContent": "center", }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "overflow": "hidden", + }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] @@ -1936,7 +2011,6 @@ exports[`AppbarAction should render AppbarBackAction with custom color 1`] = ` [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -1944,8 +2018,16 @@ exports[`AppbarAction should render AppbarBackAction with custom color 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -1977,10 +2059,10 @@ exports[`AppbarAction should render AppbarBackAction with custom color 1`] = ` focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } onBlur={[Function]} @@ -2004,6 +2086,24 @@ exports[`AppbarAction should render AppbarBackAction with custom color 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "overflow": "hidden", + }, + { + "height": undefined, + "width": 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 b5dd171c87..98d6c4b51d 100644 --- a/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap +++ b/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap @@ -682,6 +682,14 @@ exports[`should have maxFontSizeMultiplier set to 1.5 by default 1`] = ` centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} diff --git a/src/components/__tests__/IconButton.test.tsx b/src/components/__tests__/IconButton.test.tsx index 36c28686a1..02161487ed 100644 --- a/src/components/__tests__/IconButton.test.tsx +++ b/src/components/__tests__/IconButton.test.tsx @@ -2,7 +2,7 @@ import { StyleSheet } from 'react-native'; import { describe, expect, it } from '@jest/globals'; -import { render } from '../../test-utils'; +import { render, screen } from '../../test-utils'; import { pink500 } from '../../theme/colors'; import { LightTheme } from '../../theme/schemes'; import { tokens } from '../../theme/tokens'; diff --git a/src/components/__tests__/TouchableRipple.test.tsx b/src/components/__tests__/TouchableRipple.test.tsx index 6c8441ffb8..57ecb6bc43 100644 --- a/src/components/__tests__/TouchableRipple.test.tsx +++ b/src/components/__tests__/TouchableRipple.test.tsx @@ -68,19 +68,17 @@ describe('TouchableRipple', () => { }); it('takes the shape of the touchable so it does not square off the corners', async () => { - await render( + const { toJSON } = await render( Press me! ); - expect(screen.getByTestId('touchable-ripple-underlay')).toHaveStyle({ - borderRadius: 4, - }); + expect(toJSON()).toMatchSnapshot(); }); it('takes per-corner radii too', async () => { - await render( + const { toJSON } = await render( { ); - expect(screen.getByTestId('touchable-ripple-underlay')).toHaveStyle({ - borderTopLeftRadius: 8, - borderBottomRightRadius: 2, - }); + expect(toJSON()).toMatchSnapshot(); }); }); diff --git a/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap b/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap index a6e2dc6d3f..878acb80f7 100644 --- a/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap @@ -141,6 +141,14 @@ exports[`DataTable.Cell renders data table cell children without wrapping text c centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} diff --git a/src/components/__tests__/__snapshots__/IconButton.test.tsx.snap b/src/components/__tests__/__snapshots__/IconButton.test.tsx.snap index 71d404ed58..76a64b972f 100644 --- a/src/components/__tests__/__snapshots__/IconButton.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/IconButton.test.tsx.snap @@ -434,7 +434,6 @@ exports[`renders icon button with custom border radius 1`] = ` [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -442,8 +441,16 @@ exports[`renders icon button with custom border radius 1`] = ` "width": 52, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 26, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, { @@ -474,14 +481,6 @@ exports[`renders icon button with custom border radius 1`] = ` centered={true} collapsable={false} focusable={true} - hitSlop={ - { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, - } - } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -503,6 +502,24 @@ exports[`renders icon button with custom border radius 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 26, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "overflow": "hidden", + }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] @@ -702,7 +719,6 @@ exports[`renders icon button with small border radius 1`] = ` [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -710,8 +726,16 @@ exports[`renders icon button with small border radius 1`] = ` "width": 52, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 26, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, { @@ -742,14 +766,6 @@ exports[`renders icon button with small border radius 1`] = ` centered={true} collapsable={false} focusable={true} - hitSlop={ - { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, - } - } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -771,6 +787,24 @@ exports[`renders icon button with small border radius 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 26, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "overflow": "hidden", + }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] diff --git a/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap b/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap index 9c01dfd366..ee645748bb 100644 --- a/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap @@ -560,7 +560,6 @@ exports[`renders searchbar in "view" mode 1`] = ` [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -568,8 +567,16 @@ exports[`renders searchbar in "view" mode 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -601,10 +608,10 @@ exports[`renders searchbar in "view" mode 1`] = ` focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } onBlur={[Function]} @@ -628,6 +635,24 @@ exports[`renders searchbar in "view" mode 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "overflow": "hidden", + }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] @@ -726,7 +751,6 @@ exports[`renders searchbar in "view" mode 1`] = ` [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -734,8 +758,16 @@ exports[`renders searchbar in "view" mode 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -767,10 +799,10 @@ exports[`renders searchbar in "view" mode 1`] = ` focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } onBlur={[Function]} @@ -794,6 +826,24 @@ exports[`renders searchbar in "view" mode 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "overflow": "hidden", + }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] diff --git a/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap b/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap index 2c017e23a4..5fce9077ed 100644 --- a/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap @@ -338,6 +338,14 @@ exports[`should have \`accessibilityState={ checked: true }\` when selected show accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 6, + "left": 0, + "right": 0, + "top": 6, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -512,6 +520,14 @@ exports[`should have \`accessibilityState={ checked: true }\` when selected show accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 6, + "left": 0, + "right": 0, + "top": 6, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -638,6 +654,14 @@ exports[`should have \`accessibilityState={ checked: true }\` when selected show accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 6, + "left": 0, + "right": 0, + "top": 6, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -780,6 +804,14 @@ exports[`should not render icon when icon prop is not passed 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 6, + "left": 0, + "right": 0, + "top": 6, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -907,6 +939,14 @@ exports[`should not render icon when icon prop is not passed 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 6, + "left": 0, + "right": 0, + "top": 6, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -1048,6 +1088,14 @@ exports[`should not render icon when icon prop is passed along with label, butto accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 6, + "left": 0, + "right": 0, + "top": 6, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -1224,6 +1272,14 @@ exports[`should not render icon when icon prop is passed along with label, butto accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 6, + "left": 0, + "right": 0, + "top": 6, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -1414,6 +1470,14 @@ exports[`should render icon when icon prop is passed 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 6, + "left": 0, + "right": 0, + "top": 6, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -1588,6 +1652,14 @@ exports[`should render icon when icon prop is passed 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 6, + "left": 0, + "right": 0, + "top": 6, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -1776,6 +1848,14 @@ exports[`should render icon when icon prop is passed along with label, button is accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 6, + "left": 0, + "right": 0, + "top": 6, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -1952,6 +2032,14 @@ exports[`should render icon when icon prop is passed along with label, button is accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 6, + "left": 0, + "right": 0, + "top": 6, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -2142,6 +2230,14 @@ exports[`should render icon when icon prop is passed along with label, no matter accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 6, + "left": 0, + "right": 0, + "top": 6, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -2318,6 +2414,14 @@ exports[`should render icon when icon prop is passed along with label, no matter accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 6, + "left": 0, + "right": 0, + "top": 6, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} diff --git a/src/components/__tests__/__snapshots__/TouchableRipple.test.tsx.snap b/src/components/__tests__/__snapshots__/TouchableRipple.test.tsx.snap index 00ac206260..4683e4bd10 100644 --- a/src/components/__tests__/__snapshots__/TouchableRipple.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/TouchableRipple.test.tsx.snap @@ -49,6 +49,17 @@ exports[`TouchableRipple on iOS displays the underlay when pressed 1`] = ` "top": 0, "zIndex": 2, }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": undefined, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, { "backgroundColor": "rgba(29, 27, 32, 0.1)", }, @@ -110,6 +121,17 @@ exports[`TouchableRipple on iOS renders custom underlay color 1`] = ` "top": 0, "zIndex": 2, }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": undefined, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, { "backgroundColor": "purple", }, @@ -121,3 +143,147 @@ exports[`TouchableRipple on iOS renders custom underlay color 1`] = ` `; + +exports[`TouchableRipple on iOS takes per-corner radii too 1`] = ` + + + + Press me! + + +`; + +exports[`TouchableRipple on iOS takes the shape of the touchable so it does not square off the corners 1`] = ` + + + + Press me! + + +`; From d3671d7d33a7a750ad7db348454a291f17b8f486 Mon Sep 17 00:00:00 2001 From: Konrad Rydygier Date: Thu, 10 Sep 2026 13:09:54 +0200 Subject: [PATCH 09/15] refactor: trim redundant comments and simplify TouchableRippleWeb tests --- src/components/IconButton/IconButton.tsx | 4 +- .../SegmentedButtons/SegmentedButtonItem.tsx | 4 - .../TouchableRipple/TouchableRipple.tsx | 19 +-- .../__tests__/Checkbox/Checkbox.test.tsx | 1 - src/components/__tests__/Chip.test.tsx | 2 - src/components/__tests__/IconButton.test.tsx | 2 - .../RadioButton/RadioButton.test.tsx | 1 - .../__tests__/SegmentedButton.test.tsx | 2 - src/components/__tests__/Switch.test.tsx | 1 - .../__tests__/TouchableRippleWeb.test.tsx | 111 ++++++------------ 10 files changed, 44 insertions(+), 103 deletions(-) diff --git a/src/components/IconButton/IconButton.tsx b/src/components/IconButton/IconButton.tsx index 16ca0d3d1e..a1b0203748 100644 --- a/src/components/IconButton/IconButton.tsx +++ b/src/components/IconButton/IconButton.tsx @@ -86,9 +86,7 @@ export type Props = Omit< height?: number; /** * Radius of every corner of the button. Defaults to a circle (half of the - * button's size). Read as a plain prop rather than out of `style`, since - * `style` may be an animated value on the UI thread that a synchronous - * `StyleSheet.flatten` cannot see. + * button's size). */ borderRadius?: number; borderTopLeftRadius?: number; diff --git a/src/components/SegmentedButtons/SegmentedButtonItem.tsx b/src/components/SegmentedButtons/SegmentedButtonItem.tsx index 474920733a..b5f5c7e6d7 100644 --- a/src/components/SegmentedButtons/SegmentedButtonItem.tsx +++ b/src/components/SegmentedButtons/SegmentedButtonItem.tsx @@ -194,10 +194,6 @@ const SegmentedButtonItem = ({ }; const paddingVertical = getSegmentedButtonDensityPadding({ density }); - - // Height is `2 * paddingVertical + content`, and content is never shorter - // than the 18dp icon (the label's own line height is taller), so that is - // the safe floor to compute slop from without needing to measure. const contentHeight = 2 * paddingVertical + iconSize; const defaultHitSlop = disabled ? undefined diff --git a/src/components/TouchableRipple/TouchableRipple.tsx b/src/components/TouchableRipple/TouchableRipple.tsx index 7e5c6ecce0..b401d94cc9 100644 --- a/src/components/TouchableRipple/TouchableRipple.tsx +++ b/src/components/TouchableRipple/TouchableRipple.tsx @@ -22,9 +22,9 @@ 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 + * On web the ripple is bounded by its own container regardless of this prop. + * The touchable never clips its content, since clipping would also clip the + * touch target, so children needing a rounded shape must carry the radius * themselves. */ borderless?: boolean; @@ -97,9 +97,7 @@ export type Props = PressableProps & { /** * Width of the touchable's own border, if it draws one. Web-only: the touch * target's absolute offsets start inside the border, not the visible outer - * edge, so this widens the target back out to it. Read as a plain prop - * rather than out of `style`, since `style` can be a function of press - * state instead of a plain object to read a value off. + * edge, so this widens the target back out to it. */ borderWidth?: number; }; @@ -219,15 +217,6 @@ const TouchableRipple = ({ borderTopRightRadius: style.borderTopRightRadius, borderBottomRightRadius: style.borderBottomRightRadius, borderBottomLeftRadius: style.borderBottomLeftRadius, - // 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', }); diff --git a/src/components/__tests__/Checkbox/Checkbox.test.tsx b/src/components/__tests__/Checkbox/Checkbox.test.tsx index fa9c520ebb..393b32ad1d 100644 --- a/src/components/__tests__/Checkbox/Checkbox.test.tsx +++ b/src/components/__tests__/Checkbox/Checkbox.test.tsx @@ -62,7 +62,6 @@ it('renders Checkbox with custom testID', async () => { it('expands hitSlop up to the 48dp minimum when enabled', async () => { await render(); - // (48 - 40) / 2 on every side // eslint-disable-next-line no-restricted-syntax expect(screen.getByTestId('checkbox').props.hitSlop).toEqual({ top: 4, diff --git a/src/components/__tests__/Chip.test.tsx b/src/components/__tests__/Chip.test.tsx index dd7dc85d5a..97d48846b4 100644 --- a/src/components/__tests__/Chip.test.tsx +++ b/src/components/__tests__/Chip.test.tsx @@ -102,8 +102,6 @@ it('expands hitSlop up to the 48dp minimum when enabled', async () => { ); - // (48 - 32) / 2 top/bottom, none horizontally: the pill's width already - // covers it // eslint-disable-next-line no-restricted-syntax expect(screen.getByTestId('active-chip').props.hitSlop).toEqual({ top: 8, diff --git a/src/components/__tests__/IconButton.test.tsx b/src/components/__tests__/IconButton.test.tsx index 02161487ed..953dcb3c19 100644 --- a/src/components/__tests__/IconButton.test.tsx +++ b/src/components/__tests__/IconButton.test.tsx @@ -49,7 +49,6 @@ it('renders disabled icon button', async () => { it('expands hitSlop up to the 48dp minimum for a button smaller than that', async () => { await render(); - // (48 - 40) / 2 on every side, for the default 24dp icon plus 8dp padding // eslint-disable-next-line no-restricted-syntax expect(screen.getByTestId('icon-button').props.hitSlop).toEqual({ top: 4, @@ -80,7 +79,6 @@ it('computes hitSlop from explicit width/height rather than the button size', as ); - // (48 - 20) / 2 on every side // eslint-disable-next-line no-restricted-syntax expect(screen.getByTestId('icon-button').props.hitSlop).toEqual({ top: 14, diff --git a/src/components/__tests__/RadioButton/RadioButton.test.tsx b/src/components/__tests__/RadioButton/RadioButton.test.tsx index 90e76d68f6..e31d4efd65 100644 --- a/src/components/__tests__/RadioButton/RadioButton.test.tsx +++ b/src/components/__tests__/RadioButton/RadioButton.test.tsx @@ -87,7 +87,6 @@ describe('RadioButton', () => { it('expands up to the 48dp minimum when enabled', async () => { await render(); - // (48 - 40) / 2 on every side // eslint-disable-next-line no-restricted-syntax expect(screen.getByTestId('radio').props.hitSlop).toEqual({ top: 4, diff --git a/src/components/__tests__/SegmentedButton.test.tsx b/src/components/__tests__/SegmentedButton.test.tsx index 592dfd89f0..440d054faa 100644 --- a/src/components/__tests__/SegmentedButton.test.tsx +++ b/src/components/__tests__/SegmentedButton.test.tsx @@ -526,8 +526,6 @@ describe('hitSlop', () => { /> ); - // (48 - (2 * 9dp default padding + 18dp icon)) / 2 top/bottom, none - // horizontally // eslint-disable-next-line no-restricted-syntax expect(screen.getByTestId('walking-button').props.hitSlop).toEqual({ top: 6, diff --git a/src/components/__tests__/Switch.test.tsx b/src/components/__tests__/Switch.test.tsx index 170ce15dbe..deec243fe6 100644 --- a/src/components/__tests__/Switch.test.tsx +++ b/src/components/__tests__/Switch.test.tsx @@ -27,7 +27,6 @@ describe('Switch render', () => { it('expands hitSlop up to the 48dp minimum when enabled', async () => { await render(); - // (48 - 40) / 2 top/bottom, none horizontally: the track is already wider // eslint-disable-next-line no-restricted-syntax expect(screen.getByTestId('switch').props.hitSlop).toEqual({ top: 4, diff --git a/src/components/__tests__/TouchableRippleWeb.test.tsx b/src/components/__tests__/TouchableRippleWeb.test.tsx index c02e2cae3a..bd2c92174f 100644 --- a/src/components/__tests__/TouchableRippleWeb.test.tsx +++ b/src/components/__tests__/TouchableRippleWeb.test.tsx @@ -20,9 +20,9 @@ const TouchableRipple: typeof TouchableRippleType = const TOUCHABLE = 'touchable'; -// `children` admits raw text nodes since a host element can render one, but -// the touchable's own children never do; this makes that assumption explicit -// to the type checker instead of leaving `children[0]` typed as `TestNode`. +// Host elements can render raw text children; the touchable's own children +// never do. This narrows the type instead of leaving `children[0]` typed as +// `TestNode` at every call site. const asElement = (node: TestInstance | string): TestInstance => { if (typeof node === 'string') { throw new Error('Expected an element, not a text node'); @@ -30,18 +30,22 @@ const asElement = (node: TestInstance | string): TestInstance => { return node; }; -// The target has no testID of its own (it would be identical, and thus -// ambiguous, on every instance) and no role, so instead of querying for it -// directly, it's addressed by position: it is always the touchable's first -// host child when rendered, and is simply absent (not a hidden sibling) -// otherwise. +// The target has no testID (a hardcoded one would collide across instances) +// and no accessible role (it's `aria-hidden`), so it's matched by that marker +// instead of a user-visible query. const getTarget = () => { const children = screen.getByTestId(TOUCHABLE).children; - return children.length > 1 ? asElement(children[0]) : null; + return ( + children.find( + (child): child is TestInstance => + typeof child !== 'string' && + // eslint-disable-next-line no-restricted-syntax + !!child.props['aria-hidden'] + ) ?? null + ); }; -// Throws instead of returning null, so tests that expect a target don't need -// a non-null assertion to use it. +// Throws so tests that expect a target don't need a non-null assertion. const requireTarget = () => { const target = getTarget(); if (target === null) { @@ -57,12 +61,16 @@ const styleOf = (node: TestInstance) => { }; describe('TouchableRipple (web)', () => { - it('does not render a touch target when there is no hitSlop', async () => { - // No minimum is enforced here: the primitive does not measure, so with no - // caller-supplied `hitSlop` there is nothing to expand into and no target - // to render. + it.each([ + { when: 'there is no hitSlop', props: { onPress: () => {} } }, + { when: 'there are no touch handlers', props: { hitSlop: 4 } }, + { + when: 'it is disabled', + props: { hitSlop: 4, onPress: () => {}, disabled: true }, + }, + ])('does not render a touch target when $when', async ({ props }) => { await render( - {}} testID={TOUCHABLE}> + Button ); @@ -89,64 +97,27 @@ describe('TouchableRipple (web)', () => { expect(second.props.children).toBe('child-marker'); }); - it('does not render a touch target when there are no touch handlers', async () => { + it.each([ + { + name: 'sizes the target from a numeric hitSlop', + hitSlop: 6 as const, + expected: { top: -6, bottom: -6, left: -6, right: -6 }, + }, + { + name: 'sizes the target from a per-edge hitSlop, defaulting unset edges to zero', + hitSlop: { top: 4, left: 8 }, + expected: { top: -4, bottom: -0, left: -8, right: -0 }, + }, + ])('$name', async ({ hitSlop, expected }) => { await render( - - Not a control - - ); - - expect(getTarget()).toBeNull(); - }); - - it('does not render a touch target when disabled', async () => { - await render( - {}} - testID={TOUCHABLE} - > - Button - - ); - - expect(getTarget()).toBeNull(); - }); - - it('lets a caller-supplied hitSlop size the target', async () => { - await render( - {}} testID={TOUCHABLE}> + {}} testID={TOUCHABLE}> Button ); expect(styleOf(requireTarget())).toEqual({ position: 'absolute', - top: -6, - bottom: -6, - left: -6, - right: -6, - }); - }); - - it('accepts a per-edge hitSlop', async () => { - await render( - {}} - testID={TOUCHABLE} - > - Button - - ); - - expect(styleOf(requireTarget())).toEqual({ - position: 'absolute', - top: -4, - bottom: -0, - left: -8, - right: -0, + ...expected, }); }); @@ -183,11 +154,7 @@ describe('TouchableRipple (web)', () => { ); - // eslint-disable-next-line no-restricted-syntax - const { style: rawStyle } = screen.getByTestId(TOUCHABLE).props; - const style = Array.isArray(rawStyle) - ? Object.assign({}, ...rawStyle.flat()) - : rawStyle; + const style = styleOf(screen.getByTestId(TOUCHABLE)); // check we have the touchable's own style first, or the absence below passes // against any empty object From d6eba47304341fb347a032b1a4709ad0c3e5f62d Mon Sep 17 00:00:00 2001 From: Konrad Rydygier Date: Wed, 16 Sep 2026 12:12:33 +0200 Subject: [PATCH 10/15] chore: rework hitSlop tests and trim stale comments --- src/components/IconButton/IconButton.tsx | 3 - .../RadioButton/RadioButtonAndroid.tsx | 1 - src/components/RadioButton/RadioButtonIOS.tsx | 1 - .../__tests__/Checkbox/Checkbox.test.tsx | 23 +- .../__snapshots__/Checkbox.test.tsx.snap | 184 ++++++++ src/components/__tests__/Chip.test.tsx | 32 -- src/components/__tests__/IconButton.test.tsx | 51 +-- .../RadioButton/RadioButton.test.tsx | 25 +- .../__snapshots__/RadioButton.test.tsx.snap | 86 ++++ .../__tests__/SegmentedButton.test.tsx | 40 -- src/components/__tests__/Switch.test.tsx | 19 - .../__tests__/TouchableRipple.test.tsx | 47 +- .../__tests__/TouchableRippleWeb.test.tsx | 15 - .../__snapshots__/IconButton.test.tsx.snap | 420 ++++++++++++++++++ .../TouchableRipple.test.tsx.snap | 96 ++++ src/utils/getMinInteractiveSizeHitSlop.ts | 10 +- 16 files changed, 836 insertions(+), 217 deletions(-) diff --git a/src/components/IconButton/IconButton.tsx b/src/components/IconButton/IconButton.tsx index a1b0203748..4263654f95 100644 --- a/src/components/IconButton/IconButton.tsx +++ b/src/components/IconButton/IconButton.tsx @@ -276,9 +276,6 @@ const IconButton = ({ const styles = StyleSheet.create({ container: { - // No `overflow: 'hidden'`. An ancestor that clips also clips the touch - // target, which is why the hitSlop this component used to pass never - // applied. The overlay and the touchable clip themselves instead. margin: 6, }, touchable: { diff --git a/src/components/RadioButton/RadioButtonAndroid.tsx b/src/components/RadioButton/RadioButtonAndroid.tsx index 80007d9c7f..5bbcd5441e 100644 --- a/src/components/RadioButton/RadioButtonAndroid.tsx +++ b/src/components/RadioButton/RadioButtonAndroid.tsx @@ -210,7 +210,6 @@ const styles = StyleSheet.create({ height: 20, width: 20, borderRadius: 10, - // Centres the 20dp glyph within the 40dp state layer. margin: (STATE_LAYER_SIZE - 20) / 2, }, dot: { diff --git a/src/components/RadioButton/RadioButtonIOS.tsx b/src/components/RadioButton/RadioButtonIOS.tsx index 045388b1c8..5efb876fc8 100644 --- a/src/components/RadioButton/RadioButtonIOS.tsx +++ b/src/components/RadioButton/RadioButtonIOS.tsx @@ -146,7 +146,6 @@ RadioButtonIOS.displayName = 'RadioButton.IOS'; const styles = StyleSheet.create({ container: { borderRadius: STATE_LAYER_SIZE / 2, - // Centres the 24dp checkmark within the 40dp state layer. padding: (STATE_LAYER_SIZE - CHECKMARK_SIZE) / 2, }, }); diff --git a/src/components/__tests__/Checkbox/Checkbox.test.tsx b/src/components/__tests__/Checkbox/Checkbox.test.tsx index 393b32ad1d..cf14dda3ab 100644 --- a/src/components/__tests__/Checkbox/Checkbox.test.tsx +++ b/src/components/__tests__/Checkbox/Checkbox.test.tsx @@ -1,6 +1,6 @@ import { expect, it } from '@jest/globals'; -import { render, screen } from '../../../test-utils'; +import { render } from '../../../test-utils'; import Checkbox from '../../Checkbox'; it('renders checked Checkbox with onPress', async () => { @@ -59,21 +59,10 @@ it('renders Checkbox with custom testID', async () => { expect(tree).toMatchSnapshot(); }); -it('expands hitSlop up to the 48dp minimum when enabled', async () => { - await render(); - - // eslint-disable-next-line no-restricted-syntax - expect(screen.getByTestId('checkbox').props.hitSlop).toEqual({ - top: 4, - bottom: 4, - left: 4, - right: 4, - }); -}); - -it('gives a disabled Checkbox no hitSlop of its own', async () => { - await render(); +it('renders disabled Checkbox', async () => { + const tree = ( + await render() + ).toJSON(); - // eslint-disable-next-line no-restricted-syntax - expect(screen.getByTestId('checkbox').props.hitSlop).toBeUndefined(); + expect(tree).toMatchSnapshot(); }); diff --git a/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap b/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap index fe7f4ec3ba..c9c6809c5c 100644 --- a/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap +++ b/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap @@ -577,6 +577,190 @@ exports[`renders checked Checkbox with onPress 1`] = ` `; +exports[`renders disabled Checkbox 1`] = ` + + + + + + + + + + + + + +`; + exports[`renders indeterminate Checkbox 1`] = ` { }); }); -it('expands hitSlop up to the 48dp minimum when enabled', async () => { - await render( - {}}> - Active chip - - ); - - // eslint-disable-next-line no-restricted-syntax - expect(screen.getByTestId('active-chip').props.hitSlop).toEqual({ - top: 8, - bottom: 8, - left: 0, - right: 0, - }); -}); - -it('gives a disabled Chip no hitSlop of its own', async () => { - await render( - {}}> - Disabled chip - - ); - - // eslint-disable-next-line no-restricted-syntax - expect(screen.getByTestId('disabled-chip').props.hitSlop).toBeUndefined(); -}); - describe('getChipColors - text color', () => { it('should return correct disabled color, for theme version 3', () => { expect( @@ -338,9 +311,6 @@ describe('getChipColor - border color', () => { }); describe('close affordance', () => { - // The chip already reserved room on its right, but only the icon was tappable, - // so the body owned the rest of that column. MD3 has the primary action stop - // where the trailing one starts. it('fills the column the chip reserves for it', async () => { await render( {}} onClose={() => {}}> @@ -361,8 +331,6 @@ describe('close affordance', () => { ); - // `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', }); diff --git a/src/components/__tests__/IconButton.test.tsx b/src/components/__tests__/IconButton.test.tsx index 953dcb3c19..d735ae65aa 100644 --- a/src/components/__tests__/IconButton.test.tsx +++ b/src/components/__tests__/IconButton.test.tsx @@ -46,55 +46,28 @@ it('renders disabled icon button', async () => { expect(tree).toMatchSnapshot(); }); -it('expands hitSlop up to the 48dp minimum for a button smaller than that', async () => { - await render(); - - // eslint-disable-next-line no-restricted-syntax - expect(screen.getByTestId('icon-button').props.hitSlop).toEqual({ - top: 4, - bottom: 4, - left: 4, - right: 4, - }); -}); - -it('gives a disabled button no hitSlop of its own', async () => { - await render(); - - // eslint-disable-next-line no-restricted-syntax - expect(screen.getByTestId('icon-button').props.hitSlop).toBeUndefined(); -}); - it('lets a caller-supplied hitSlop win even while disabled', async () => { - await render( - - ); + const tree = ( + await render() + ).toJSON(); - // eslint-disable-next-line no-restricted-syntax - expect(screen.getByTestId('icon-button').props.hitSlop).toBe(2); + expect(tree).toMatchSnapshot(); }); it('computes hitSlop from explicit width/height rather than the button size', async () => { - await render( - - ); + const tree = ( + await render() + ).toJSON(); - // eslint-disable-next-line no-restricted-syntax - expect(screen.getByTestId('icon-button').props.hitSlop).toEqual({ - top: 14, - bottom: 14, - left: 14, - right: 14, - }); + expect(tree).toMatchSnapshot(); }); it('drops hitSlop when explicit width/height already meet the 48dp minimum', async () => { - await render( - - ); + const tree = ( + await render() + ).toJSON(); - // eslint-disable-next-line no-restricted-syntax - expect(screen.getByTestId('icon-button').props.hitSlop).toBeUndefined(); + expect(tree).toMatchSnapshot(); }); it('renders icon change animated', async () => { diff --git a/src/components/__tests__/RadioButton/RadioButton.test.tsx b/src/components/__tests__/RadioButton/RadioButton.test.tsx index e31d4efd65..c4f46d5234 100644 --- a/src/components/__tests__/RadioButton/RadioButton.test.tsx +++ b/src/components/__tests__/RadioButton/RadioButton.test.tsx @@ -6,7 +6,7 @@ import { jest as mockJest, } from '@jest/globals'; -import { render, screen } from '../../../test-utils'; +import { render } from '../../../test-utils'; import RadioButton from '../../RadioButton'; import { RadioButtonContext } from '../../RadioButton/RadioButtonGroup'; @@ -83,24 +83,11 @@ describe('RadioButton', () => { }); }); - describe('hitSlop', () => { - it('expands up to the 48dp minimum when enabled', async () => { - await render(); + it('renders disabled RadioButton', async () => { + const tree = ( + await render() + ).toJSON(); - // eslint-disable-next-line no-restricted-syntax - expect(screen.getByTestId('radio').props.hitSlop).toEqual({ - top: 4, - bottom: 4, - left: 4, - right: 4, - }); - }); - - it('gives a disabled RadioButton no hitSlop of its own', async () => { - await render(); - - // eslint-disable-next-line no-restricted-syntax - expect(screen.getByTestId('radio').props.hitSlop).toBeUndefined(); - }); + expect(tree).toMatchSnapshot(); }); }); diff --git a/src/components/__tests__/RadioButton/__snapshots__/RadioButton.test.tsx.snap b/src/components/__tests__/RadioButton/__snapshots__/RadioButton.test.tsx.snap index a6e37f97d4..c0514eea95 100644 --- a/src/components/__tests__/RadioButton/__snapshots__/RadioButton.test.tsx.snap +++ b/src/components/__tests__/RadioButton/__snapshots__/RadioButton.test.tsx.snap @@ -283,6 +283,92 @@ exports[`RadioButton on ios platform renders properly 1`] = ` `; +exports[`RadioButton renders disabled RadioButton 1`] = ` + + + + check + + + +`; + exports[`RadioButton when RadioButton is wrapped by RadioButtonContext.Provider renders properly 1`] = ` { }); }); }); - -describe('hitSlop', () => { - it('expands up to the 48dp minimum when enabled', async () => { - await render( - {}} - /> - ); - - // eslint-disable-next-line no-restricted-syntax - expect(screen.getByTestId('walking-button').props.hitSlop).toEqual({ - top: 6, - bottom: 6, - left: 0, - right: 0, - }); - }); - - it('gives a disabled button no hitSlop of its own', async () => { - await render( - {}} - /> - ); - - // eslint-disable-next-line no-restricted-syntax - expect(screen.getByTestId('walking-button').props.hitSlop).toBeUndefined(); - }); -}); diff --git a/src/components/__tests__/Switch.test.tsx b/src/components/__tests__/Switch.test.tsx index deec243fe6..ceff77a0e6 100644 --- a/src/components/__tests__/Switch.test.tsx +++ b/src/components/__tests__/Switch.test.tsx @@ -24,25 +24,6 @@ describe('Switch render', () => { ).toMatchSnapshot(); }); - it('expands hitSlop up to the 48dp minimum when enabled', async () => { - await render(); - - // eslint-disable-next-line no-restricted-syntax - expect(screen.getByTestId('switch').props.hitSlop).toEqual({ - top: 4, - bottom: 4, - left: 0, - right: 0, - }); - }); - - it('gives a disabled switch no hitSlop of its own', async () => { - await render(); - - // eslint-disable-next-line no-restricted-syntax - expect(screen.getByTestId('switch').props.hitSlop).toBeUndefined(); - }); - it('renders with checked icon', async () => { expect( (await render()).toJSON() diff --git a/src/components/__tests__/TouchableRipple.test.tsx b/src/components/__tests__/TouchableRipple.test.tsx index 57ecb6bc43..b49f8d786c 100644 --- a/src/components/__tests__/TouchableRipple.test.tsx +++ b/src/components/__tests__/TouchableRipple.test.tsx @@ -93,34 +93,31 @@ describe('TouchableRipple', () => { }); describe('hitSlop', () => { - // hitSlop has no user-visible effect here, the renderer does not lay views - // out or hit-test them. This only stops the prop being dropped. - /* eslint-disable no-restricted-syntax */ - const hitSlopOf = () => screen.getByTestId('touchable').props.hitSlop; - /* eslint-enable no-restricted-syntax */ - - it('is not enforced or defaulted: the primitive does not measure', async () => { - await render( - {}}> - Button - - ); - - expect(hitSlopOf()).toBeUndefined(); + it('does not add its own hitSlop when none is supplied', async () => { + const tree = ( + await render( + {}}> + Button + + ) + ).toJSON(); + + expect(tree).toMatchSnapshot(); }); it('passes a caller-supplied hitSlop straight through', async () => { - await render( - {}} - hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} - > - Button - - ); - - expect(hitSlopOf()).toEqual({ top: 8, bottom: 8, left: 8, right: 8 }); + const tree = ( + await render( + {}} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + > + Button + + ) + ).toJSON(); + + expect(tree).toMatchSnapshot(); }); it('still calls a caller-supplied onLayout', async () => { diff --git a/src/components/__tests__/TouchableRippleWeb.test.tsx b/src/components/__tests__/TouchableRippleWeb.test.tsx index bd2c92174f..c62251909f 100644 --- a/src/components/__tests__/TouchableRippleWeb.test.tsx +++ b/src/components/__tests__/TouchableRippleWeb.test.tsx @@ -20,9 +20,6 @@ const TouchableRipple: typeof TouchableRippleType = const TOUCHABLE = 'touchable'; -// Host elements can render raw text children; the touchable's own children -// never do. This narrows the type instead of leaving `children[0]` typed as -// `TestNode` at every call site. const asElement = (node: TestInstance | string): TestInstance => { if (typeof node === 'string') { throw new Error('Expected an element, not a text node'); @@ -30,9 +27,6 @@ const asElement = (node: TestInstance | string): TestInstance => { return node; }; -// The target has no testID (a hardcoded one would collide across instances) -// and no accessible role (it's `aria-hidden`), so it's matched by that marker -// instead of a user-visible query. const getTarget = () => { const children = screen.getByTestId(TOUCHABLE).children; return ( @@ -45,7 +39,6 @@ const getTarget = () => { ); }; -// Throws so tests that expect a target don't need a non-null assertion. const requireTarget = () => { const target = getTarget(); if (target === null) { @@ -79,8 +72,6 @@ describe('TouchableRipple (web)', () => { }); 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( {}} testID={TOUCHABLE}> child-marker @@ -122,10 +113,6 @@ describe('TouchableRipple (web)', () => { }); it('extends the target past a border, since absolute offsets start inside it', async () => { - // Offsets on a position: absolute child are relative to the parent's - // padding edge, inside its border, not its visible outer edge. Without - // adding the border back in, the target would fall short of `hitSlop` - // past what's actually visible. await render( { const style = styleOf(screen.getByTestId(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__/IconButton.test.tsx.snap b/src/components/__tests__/__snapshots__/IconButton.test.tsx.snap index 76a64b972f..f1fd3dfe15 100644 --- a/src/components/__tests__/__snapshots__/IconButton.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/IconButton.test.tsx.snap @@ -1,5 +1,425 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`computes hitSlop from explicit width/height rather than the button size 1`] = ` + + + + + camera + + + + +`; + +exports[`drops hitSlop when explicit width/height already meet the 48dp minimum 1`] = ` + + + + + camera + + + + +`; + +exports[`lets a caller-supplied hitSlop win even while disabled 1`] = ` + + + + + camera + + + + +`; + exports[`renders disabled icon button 1`] = ` + + Button + + +`; + +exports[`TouchableRipple hitSlop passes a caller-supplied hitSlop straight through 1`] = ` + + + Button + + +`; + exports[`TouchableRipple on iOS displays the underlay when pressed 1`] = ` Date: Fri, 28 Aug 2026 16:05:36 +0200 Subject: [PATCH 11/15] feat: add MD3 keyboard focus indicators --- src/components/Card/Card.tsx | 9 +- src/components/Checkbox/Checkbox.tsx | 54 +------ src/components/Chip/Chip.tsx | 21 ++- src/components/FAB/Menu.tsx | 41 ++---- src/components/FAB/Shell.tsx | 49 +++---- src/components/FAB/tokens.ts | 11 -- src/components/FAB/useFocusRing.ts | 41 ------ src/components/List/ListItem.tsx | 1 + .../RadioButton/RadioButtonItem.tsx | 1 + .../SegmentedButtons/SegmentedButtonItem.tsx | 1 + src/components/Switch/Switch.tsx | 53 +++---- .../TouchableRipple.native.tsx | 44 +++++- .../TouchableRipple/TouchableRipple.tsx | 42 +++++- .../__snapshots__/Checkbox.test.tsx.snap | 7 - .../__snapshots__/CheckboxItem.test.tsx.snap | 2 - .../__tests__/TouchableRipple.test.tsx | 68 +++++++++ .../TouchableRippleFocusWeb.test.tsx | 95 ++++++++++++ .../__tests__/focusRingWiring.test.tsx | 103 +++++++++++++ src/utils/__tests__/focusRingContrast.test.ts | 63 ++++++++ src/utils/__tests__/useFocusRing.test.tsx | 136 ++++++++++++++++++ src/utils/useFocusRing.ts | 91 ++++++++++++ 21 files changed, 716 insertions(+), 217 deletions(-) delete mode 100644 src/components/FAB/useFocusRing.ts create mode 100644 src/components/__tests__/TouchableRippleFocusWeb.test.tsx create mode 100644 src/components/__tests__/focusRingWiring.test.tsx create mode 100644 src/utils/__tests__/focusRingContrast.test.ts create mode 100644 src/utils/__tests__/useFocusRing.test.tsx create mode 100644 src/utils/useFocusRing.ts diff --git a/src/components/Card/Card.tsx b/src/components/Card/Card.tsx index 712110883b..df03428e8c 100644 --- a/src/components/Card/Card.tsx +++ b/src/components/Card/Card.tsx @@ -17,6 +17,7 @@ import { getCardColors } from './utils'; import { useInternalTheme } from '../../core/theming'; import type { Elevation, ThemeProp } from '../../theme/types'; import hasTouchHandler from '../../utils/hasTouchHandler'; +import { getFocusRingStyle, useFocusRing } from '../../utils/useFocusRing'; import Surface from '../Surface'; import type { SurfaceStyle } from '../Surface'; @@ -148,7 +149,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; @@ -252,6 +253,12 @@ const Card = ({ onPressIn={handlePressIn} onPressOut={handlePressOut} testID={testID} + onFocus={focusRing.onFocus} + onBlur={focusRing.onBlur} + style={[ + { borderRadius }, + getFocusRingStyle(focusRing.focused, theme.colors.secondary), + ]} > {content} diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx index bdb3c9dbf2..b4216a71a6 100644 --- a/src/components/Checkbox/Checkbox.tsx +++ b/src/components/Checkbox/Checkbox.tsx @@ -3,9 +3,7 @@ import { Platform, StyleSheet, View } from 'react-native'; import type { ColorValue, GestureResponderEvent, - NativeSyntheticEvent, StyleProp, - TargetedEvent, ViewStyle, } from 'react-native'; @@ -16,10 +14,8 @@ import { getSelectionVisualState } from './utils'; import { useLocale } from '../../core/locale'; import { useInternalTheme } from '../../core/theming'; import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; -import { tokens } from '../../theme/tokens'; import type { ThemeProp } from '../../theme/types'; import getMinInteractiveSizeHitSlop from '../../utils/getMinInteractiveSizeHitSlop'; -import { isKeyboardFocusEvent } from '../../utils/isKeyboardFocusEvent'; import TouchableRipple from '../TouchableRipple/TouchableRipple'; import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple'; @@ -77,15 +73,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`, keeping the ring inside the -// 40dp circle: whether TouchableRipple clips content past that boundary -// depends on platform and ripple mode, so staying inside it avoids relying -// on any of that. -const FOCUS_RING_SIZE = STATE_LAYER_SIZE; -const FOCUS_RING_RADIUS = STATE_LAYER_SIZE / 2; - // The state layer is fixed, so the slop to reach the 48dp minimum // interactive target is a constant rather than something to measure. const CHECKBOX_HIT_SLOP = getMinInteractiveSizeHitSlop({ @@ -137,7 +124,6 @@ const Checkbox = ({ // Web (react-native-web) doesn't auto-mirror layout, so flip the mask // anchor manually for RTL. Native handles it via `I18nManager`. const flipMaskForWebRTL = Platform.OS === 'web' && direction === 'rtl'; - const [focused, setFocused] = React.useState(false); const selected = status === 'checked' || status === 'indeterminate'; @@ -211,19 +197,6 @@ const Checkbox = ({ } const showIndeterminate = nextGlyph === 'indeterminate'; - const handleFocus = React.useCallback( - (e: NativeSyntheticEvent) => { - if (disabled) return; - if (!isKeyboardFocusEvent(e)) return; - setFocused(true); - }, - [disabled] - ); - - const handleBlur = React.useCallback(() => { - setFocused(false); - }, []); - const checked: boolean | 'mixed' = status === 'indeterminate' ? 'mixed' : status === 'checked'; @@ -247,8 +220,6 @@ const Checkbox = ({ borderless centered onPress={onPress} - onFocus={handleFocus} - onBlur={handleBlur} disabled={disabled} {...accessibilityProps} testID={testID} @@ -259,20 +230,10 @@ const Checkbox = ({ ? undefined : CHECKBOX_HIT_SLOP } - borderRadius={FOCUS_RING_RADIUS} - style={[ - styles.tapTarget, - Platform.OS === 'web' ? webNoOutline : undefined, - style, - ]} + borderRadius={STATE_LAYER_SIZE / 2} + style={[styles.tapTarget, style]} > - {focused && !disabled ? ( - - ) : null} { const theme = useInternalTheme(themeOverrides); + const closeFocusRing = useFocusRing(disabled); const [pressed, setPressed] = React.useState(false); const elevation = elevated ? (pressed ? 2 : 1) : 0; @@ -323,6 +329,7 @@ const Chip = ({ > {/* react-native-web removed `hitSlop` in 0.13.0, so web needs a real element the browser can diff --git a/src/components/FAB/Menu.tsx b/src/components/FAB/Menu.tsx index 68762494c7..2ad76305ce 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 type { InternalTheme, ThemeProp } from '../../theme/types'; import { resolveCornerRadius } from '../../theme/utils/shape'; +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 83a71f72ed..ad7b8f3116 100644 --- a/src/components/FAB/Shell.tsx +++ b/src/components/FAB/Shell.tsx @@ -17,20 +17,20 @@ import type { SharedValue } from 'react-native-reanimated'; import type { AnimatedStyle } from 'react-native-reanimated'; import Content from './Content'; -import { - Tokens, - FOCUS_RING_INSET, - FOCUS_RING_THICKNESS, - webNoOutline, -} from './tokens'; +import { Tokens } from './tokens'; import type { Size, Variant } from './tokens'; -import { useFocusRing } from './useFocusRing'; import { getDimensions, resolveColors } from './utils'; import { useInternalTheme } from '../../core/theming'; import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; import { toRawSpring } from '../../theme/tokens/sys/motion'; import type { Elevation, ThemeProp } from '../../theme/types'; import type { ShapeToken } from '../../theme/utils/shape'; +import { + getFocusRingStyle, + toStyleList, + useFocusRing, + webNoOutline, +} from '../../utils/useFocusRing'; import type { IconSource } from '../Icon'; import Surface from '../Surface'; import TouchableRipple from '../TouchableRipple/TouchableRipple'; @@ -297,15 +297,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} - ); }; @@ -389,15 +381,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/List/ListItem.tsx b/src/components/List/ListItem.tsx index feb0f19f12..73d4861e97 100644 --- a/src/components/List/ListItem.tsx +++ b/src/components/List/ListItem.tsx @@ -233,6 +233,7 @@ const ListItem = ({ handlePress({ onPress: onPress, diff --git a/src/components/SegmentedButtons/SegmentedButtonItem.tsx b/src/components/SegmentedButtons/SegmentedButtonItem.tsx index b5f5c7e6d7..0f5bada625 100644 --- a/src/components/SegmentedButtons/SegmentedButtonItem.tsx +++ b/src/components/SegmentedButtons/SegmentedButtonItem.tsx @@ -213,6 +213,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); @@ -331,10 +340,6 @@ const Switch = ({ ], })); - const focusRingAnimatedStyle = useAnimatedStyle(() => ({ - opacity: focusedSV.value, - })); - const paint = resolveSwitchPaint(colors, isEnabled, checked); const stateLayerColor = checked ? colors.checkedStateLayerColor @@ -371,10 +376,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' }} @@ -403,6 +409,9 @@ const Switch = ({ style={[ styles.track, { backgroundColor: paint.track, opacity: trackOpacityValue }, + ...toStyleList( + getFocusRingStyle(focusRing.focused, colors.focusIndicatorColor) + ), ]} > {showOutline ? ( @@ -465,22 +474,6 @@ const Switch = ({
) : null} - -
); }; @@ -552,10 +545,6 @@ const styles = StyleSheet.create({ height: SELECTED_ICON, pointerEvents: 'none', }, - focusRing: { - position: 'absolute', - pointerEvents: 'none', - }, absoluteFill: { position: 'absolute', top: 0, @@ -565,8 +554,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 caeb449982..ba0f23e35e 100644 --- a/src/components/TouchableRipple/TouchableRipple.native.tsx +++ b/src/components/TouchableRipple/TouchableRipple.native.tsx @@ -6,6 +6,8 @@ import type { ViewStyle, GestureResponderEvent, ColorValue, + NativeSyntheticEvent, + TargetedEvent, } from 'react-native'; import type { PressableProps } from './Pressable'; @@ -16,6 +18,12 @@ import type { Settings } from '../../core/settings'; import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../theme/types'; import hasTouchHandler from '../../utils/hasTouchHandler'; +import type { FocusRingPlacement } from '../../utils/useFocusRing'; +import { + getFocusRingStyle, + toStyleList, + useFocusRing, +} from '../../utils/useFocusRing'; const ANDROID_VERSION_LOLLIPOP = 21; const ANDROID_VERSION_PIE = 28; @@ -25,6 +33,18 @@ export type Props = PressableProps & { 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; @@ -74,6 +94,9 @@ const TouchableRipple = ({ // consumed so it does not reach the underlying Pressable; web-only, no // native effect borderWidth: _borderWidth, + focusRing = 'outward', + onFocus, + onBlur, ref, ...rest }: Props) => { @@ -102,6 +125,19 @@ 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 { calculatedRippleColor, calculatedUnderlayColor } = getTouchableRippleColors({ theme, @@ -132,7 +168,9 @@ const TouchableRipple = ({ ref={ref} disabled={disabled} hitSlop={hitSlop} - style={[useForeground && styles.overflowHidden, style]} + onFocus={handleFocus} + onBlur={handleBlur} + style={[useForeground && styles.overflowHidden, style, ...ringStyles]} android_ripple={androidRipple} > {React.Children.only(children)} @@ -146,7 +184,9 @@ const TouchableRipple = ({ ref={ref} disabled={disabled} hitSlop={hitSlop} - style={[borderless && styles.overflowHidden, style]} + onFocus={handleFocus} + onBlur={handleBlur} + style={[borderless && styles.overflowHidden, style, ...ringStyles]} > {({ pressed }) => ( <> diff --git a/src/components/TouchableRipple/TouchableRipple.tsx b/src/components/TouchableRipple/TouchableRipple.tsx index b401d94cc9..1213281e27 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'; @@ -17,6 +19,12 @@ import type { Settings } from '../../core/settings'; import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../theme/types'; import hasTouchHandler from '../../utils/hasTouchHandler'; +import type { FocusRingPlacement } from '../../utils/useFocusRing'; +import { + getFocusRingStyle, + toStyleList, + useFocusRing, +} from '../../utils/useFocusRing'; export type Props = PressableProps & { /** @@ -41,6 +49,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. */ @@ -150,6 +170,9 @@ const TouchableRipple = ({ borderBottomStartRadius: _borderBottomStartRadius, borderBottomEndRadius: _borderBottomEndRadius, borderWidth, + focusRing = 'outward', + onFocus, + onBlur, ref, ...rest }: Props) => { @@ -312,20 +335,35 @@ 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, - // 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) => { diff --git a/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap b/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap index c9c6809c5c..8896fa06e3 100644 --- a/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap +++ b/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap @@ -56,7 +56,6 @@ exports[`renders Checkbox with custom testID 1`] = ` "width": 40, }, undefined, - undefined, ], ] } @@ -249,7 +248,6 @@ exports[`renders checked Checkbox with color 1`] = ` "width": 40, }, undefined, - undefined, ], ] } @@ -441,7 +439,6 @@ exports[`renders checked Checkbox with onPress 1`] = ` "width": 40, }, undefined, - undefined, ], ] } @@ -817,7 +814,6 @@ exports[`renders indeterminate Checkbox 1`] = ` "width": 40, }, undefined, - undefined, ], ] } @@ -996,7 +992,6 @@ exports[`renders indeterminate Checkbox with color 1`] = ` "width": 40, }, undefined, - undefined, ], ] } @@ -1175,7 +1170,6 @@ exports[`renders unchecked Checkbox with color 1`] = ` "width": 40, }, undefined, - undefined, ], ] } @@ -1367,7 +1361,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 98d6c4b51d..48c1a35ffe 100644 --- a/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap +++ b/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap @@ -109,7 +109,6 @@ exports[`can render leading checkbox control 1`] = ` "width": 40, }, undefined, - undefined, ], ] } @@ -430,7 +429,6 @@ exports[`renders unchecked 1`] = ` "width": 40, }, undefined, - undefined, ], ] } diff --git a/src/components/__tests__/TouchableRipple.test.tsx b/src/components/__tests__/TouchableRipple.test.tsx index b49f8d786c..222853dda7 100644 --- a/src/components/__tests__/TouchableRipple.test.tsx +++ b/src/components/__tests__/TouchableRipple.test.tsx @@ -142,3 +142,71 @@ describe('TouchableRipple', () => { }); }); }); + +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__/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/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; From 20754fd8467a5ad57c30a7ed6f40f5757cf42966 Mon Sep 17 00:00:00 2001 From: Konrad Rydygier Date: Mon, 7 Sep 2026 13:41:41 +0200 Subject: [PATCH 12/15] fix: drive the focus ring with :focus-visible on web, not JS state --- src/components/Card/Card.tsx | 17 +- src/components/Chip/Chip.tsx | 28 ++- src/components/FAB/Menu.tsx | 33 +-- src/components/FAB/Shell.tsx | 39 ++-- src/components/Switch/Switch.tsx | 35 +-- .../TouchableRipple.native.tsx | 30 +-- .../TouchableRipple/TouchableRipple.tsx | 48 ++-- .../TouchableRippleFocusWeb.test.tsx | 76 +++--- .../__snapshots__/FABExtended.test.tsx.snap | 6 - .../__snapshots__/FABMenu.test.tsx.snap | 25 -- .../__snapshots__/Switch.test.tsx.snap | 6 - src/utils/__tests__/useFocusRing.test.tsx | 182 +++++++++++---- src/utils/useFocusRing.ts | 218 +++++++++++++----- 13 files changed, 455 insertions(+), 288 deletions(-) diff --git a/src/components/Card/Card.tsx b/src/components/Card/Card.tsx index df03428e8c..f1d1daed89 100644 --- a/src/components/Card/Card.tsx +++ b/src/components/Card/Card.tsx @@ -17,7 +17,7 @@ import { getCardColors } from './utils'; import { useInternalTheme } from '../../core/theming'; import type { Elevation, ThemeProp } from '../../theme/types'; import hasTouchHandler from '../../utils/hasTouchHandler'; -import { getFocusRingStyle, useFocusRing } from '../../utils/useFocusRing'; +import { useFocusRing } from '../../utils/useFocusRing'; import Surface from '../Surface'; import type { SurfaceStyle } from '../Surface'; @@ -149,7 +149,10 @@ const Card = ({ ...rest }: (OutlinedCardProps | ElevatedCardProps | ContainedCardProps) & Props) => { const theme = useInternalTheme(themeOverrides); - const focusRing = useFocusRing(disabled); + const { target: focusTarget, ring: focusRing } = useFocusRing( + disabled, + theme.colors.secondary + ); const isMode = React.useCallback( (modeToCompare: Mode) => { return cardMode === modeToCompare; @@ -253,12 +256,10 @@ const Card = ({ onPressIn={handlePressIn} onPressOut={handlePressOut} testID={testID} - onFocus={focusRing.onFocus} - onBlur={focusRing.onBlur} - style={[ - { borderRadius }, - getFocusRingStyle(focusRing.focused, theme.colors.secondary), - ]} + onFocus={focusTarget.onFocus} + onBlur={focusTarget.onBlur} + {...focusRing.dataSetProps} + style={[{ borderRadius }, ...focusRing.style]} > {content} diff --git a/src/components/Chip/Chip.tsx b/src/components/Chip/Chip.tsx index 61f16c4e48..629cbc431a 100644 --- a/src/components/Chip/Chip.tsx +++ b/src/components/Chip/Chip.tsx @@ -20,11 +20,7 @@ import { white } from '../../theme/colors'; import type { ThemeProp } from '../../theme/types'; import getMinInteractiveSizeHitSlop from '../../utils/getMinInteractiveSizeHitSlop'; import hasTouchHandler from '../../utils/hasTouchHandler'; -import { - getFocusRingStyle, - toStyleList, - useFocusRing, -} from '../../utils/useFocusRing'; +import { useFocusRing } from '../../utils/useFocusRing'; import type { IconSource } from '../Icon'; import Icon from '../Icon'; import MaterialCommunityIcon from '../MaterialCommunityIcon'; @@ -250,7 +246,14 @@ const Chip = ({ ...rest }: Props) => { const theme = useInternalTheme(themeOverrides); - const closeFocusRing = useFocusRing(disabled); + // The close affordance is a plain `Pressable`, not a `TouchableRipple` + // (see below), so it calls `useFocusRing` directly instead of going + // through `TouchableRipple`'s `focusRing` prop like the body does. + const { target: closeFocusTarget, ring: closeFocusRing } = useFocusRing( + disabled, + theme.colors.secondary, + 'inward' + ); const [pressed, setPressed] = React.useState(false); const elevation = elevated ? (pressed ? 2 : 1) : 0; @@ -439,18 +442,13 @@ const Chip = ({ aria-label={closeIconAccessibilityLabel} testID={closeIconTestID} hitSlop={disabled ? undefined : CHIP_BODY_HIT_SLOP} - onFocus={closeFocusRing.onFocus} - onBlur={closeFocusRing.onBlur} + onFocus={closeFocusTarget.onFocus} + onBlur={closeFocusTarget.onBlur} + {...closeFocusRing.dataSetProps} style={[ styles.closeButton, { borderRadius }, - ...toStyleList( - getFocusRingStyle( - closeFocusRing.focused, - theme.colors.secondary, - 'inward' - ) - ), + ...closeFocusRing.style, ]} > {/* react-native-web removed `hitSlop` in 0.13.0, diff --git a/src/components/FAB/Menu.tsx b/src/components/FAB/Menu.tsx index 2ad76305ce..eba7d6a969 100644 --- a/src/components/FAB/Menu.tsx +++ b/src/components/FAB/Menu.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Platform, StyleSheet, View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; import type { ColorValue, GestureResponderEvent } from 'react-native'; import Animated, { @@ -23,12 +23,7 @@ import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; import { toRawSpring } from '../../theme/tokens/sys/motion'; import type { InternalTheme, ThemeProp } from '../../theme/types'; import { resolveCornerRadius } from '../../theme/utils/shape'; -import { - getFocusRingStyle, - toStyleList, - useFocusRing, - webNoOutline, -} from '../../utils/useFocusRing'; +import { useFocusRing } from '../../utils/useFocusRing'; import Icon from '../Icon'; import type { IconSource } from '../Icon'; import TouchableRipple from '../TouchableRipple/TouchableRipple'; @@ -248,7 +243,17 @@ const MenuItem = ({ MenuTokens.listItem; const borderRadius = resolveCornerRadius(theme, shape); - const { focused, onFocus, onBlur } = useFocusRing(); + // `scope: 'within'`: the ring belongs on the pill below, not the inner + // `TouchableRipple` that actually receives focus. + // + // `undefined` disabled: menu items have no `disabled` prop today. Wire the + // real value through here if that ever changes. + const { target: focusTarget, ring: focusRing } = useFocusRing( + undefined, + theme.colors.secondary, + 'outward', + 'within' + ); return ( @@ -256,22 +261,20 @@ const MenuItem = ({ style={[ styles.menuItem, { height, borderRadius, backgroundColor: colors.container }, - ...toStyleList(getFocusRingStyle(focused, theme.colors.secondary)), + ...focusRing.style, ]} + {...focusRing.dataSetProps} > {overlay} {children ?? ( { if (isDisabled) { @@ -183,6 +177,16 @@ const Switch = ({ const colors = React.useMemo(() => getDefaultSwitchColors(theme), [theme]); + // `scope: 'within'`: the ring is drawn on the track below, not this + // Pressable - it also suppresses the browser's own outline here on web, so + // only the track's ring shows. + const { target: focusTarget, ring: focusRing } = useFocusRing( + isDisabled, + colors.focusIndicatorColor, + 'outward', + 'within' + ); + const reanimatedReduceMotion = reduceMotion ? ReduceMotion.Always : ReduceMotion.Never; @@ -376,11 +380,14 @@ const Switch = ({ hoveredSV.value = 0; }} onFocus={(e) => { - focusRing.onFocus(e); + // Not the ring - it's real CSS on web now. This drives the + // handle's own separate focused-visual animation, native and web + // alike, so it keeps its own keyboard-vs-pointer check. + focusTarget.onFocus?.(e); if (!isDisabled && isKeyboardFocusEvent(e)) focusedSV.value = 1; }} onBlur={() => { - focusRing.onBlur(); + focusTarget.onBlur?.(); focusedSV.value = 0; }} android_ripple={{ color: 'transparent' }} @@ -390,10 +397,7 @@ const Switch = ({ aria-label={ariaLabel} testID={testID} hitSlop={isDisabled ? undefined : SWITCH_HIT_SLOP} - style={[ - styles.touchable, - Platform.OS === 'web' ? webNoOutline : undefined, - ]} + style={[styles.touchable, ...focusTarget.style]} > {/* react-native-web removed `hitSlop` in 0.13.0 (same as TouchableRipple), so web needs a real element the browser can @@ -409,10 +413,9 @@ const Switch = ({ style={[ styles.track, { backgroundColor: paint.track, opacity: trackOpacityValue }, - ...toStyleList( - getFocusRingStyle(focusRing.focused, colors.focusIndicatorColor) - ), + ...focusRing.style, ]} + {...focusRing.dataSetProps} > {showOutline ? ( diff --git a/src/components/TouchableRipple/TouchableRipple.native.tsx b/src/components/TouchableRipple/TouchableRipple.native.tsx index ba0f23e35e..4671119a15 100644 --- a/src/components/TouchableRipple/TouchableRipple.native.tsx +++ b/src/components/TouchableRipple/TouchableRipple.native.tsx @@ -19,11 +19,7 @@ import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../theme/types'; import hasTouchHandler from '../../utils/hasTouchHandler'; import type { FocusRingPlacement } from '../../utils/useFocusRing'; -import { - getFocusRingStyle, - toStyleList, - useFocusRing, -} from '../../utils/useFocusRing'; +import { useFocusRing } from '../../utils/useFocusRing'; const ANDROID_VERSION_LOLLIPOP = 21; const ANDROID_VERSION_PIE = 28; @@ -41,8 +37,8 @@ export type Props = PressableProps & { * 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`. + * Has no effect on iOS today - see `useFocusRing`'s doc comment for why + * (`enableImperativeFocus`, off by default). */ focusRing?: FocusRingPlacement; onPress?: (e: GestureResponderEvent) => void | null; @@ -125,18 +121,22 @@ const TouchableRipple = ({ const disabled = disabledProp || !hasPassedTouchHandler; - const ring = useFocusRing(disabled || focusRing === 'none'); + // Keyed off `disabledProp`, not `disabled`: the latter also folds in + // "no press handler passed", which is a non-interactivity signal, not a + // disabled one - the ring should only react to real disablement. + const { target, ring } = useFocusRing( + disabledProp, + theme.colors.secondary, + focusRing + ); const handleFocus = (e: NativeSyntheticEvent) => { onFocus?.(e); - ring.onFocus(e); + target.onFocus?.(e); }; const handleBlur = (e: NativeSyntheticEvent) => { onBlur?.(e); - ring.onBlur(); + target.onBlur?.(); }; - const ringStyles = toStyleList( - getFocusRingStyle(ring.focused, theme.colors.secondary, focusRing) - ); const { calculatedRippleColor, calculatedUnderlayColor } = getTouchableRippleColors({ @@ -170,7 +170,7 @@ const TouchableRipple = ({ hitSlop={hitSlop} onFocus={handleFocus} onBlur={handleBlur} - style={[useForeground && styles.overflowHidden, style, ...ringStyles]} + style={[useForeground && styles.overflowHidden, style, ...ring.style]} android_ripple={androidRipple} > {React.Children.only(children)} @@ -186,7 +186,7 @@ const TouchableRipple = ({ hitSlop={hitSlop} onFocus={handleFocus} onBlur={handleBlur} - style={[borderless && styles.overflowHidden, style, ...ringStyles]} + style={[borderless && styles.overflowHidden, style, ...ring.style]} > {({ pressed }) => ( <> diff --git a/src/components/TouchableRipple/TouchableRipple.tsx b/src/components/TouchableRipple/TouchableRipple.tsx index 1213281e27..f0ca666f2f 100644 --- a/src/components/TouchableRipple/TouchableRipple.tsx +++ b/src/components/TouchableRipple/TouchableRipple.tsx @@ -3,9 +3,7 @@ import { Platform, StyleSheet, View } from 'react-native'; import type { ColorValue, GestureResponderEvent, - NativeSyntheticEvent, StyleProp, - TargetedEvent, ViewStyle, } from 'react-native'; @@ -20,11 +18,7 @@ import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../theme/types'; import hasTouchHandler from '../../utils/hasTouchHandler'; import type { FocusRingPlacement } from '../../utils/useFocusRing'; -import { - getFocusRingStyle, - toStyleList, - useFocusRing, -} from '../../utils/useFocusRing'; +import { useFocusRing } from '../../utils/useFocusRing'; export type Props = PressableProps & { /** @@ -57,8 +51,8 @@ export type Props = PressableProps & { * 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`. + * Has no effect on iOS today - see `useFocusRing`'s doc comment for why + * (`enableImperativeFocus`, off by default). */ focusRing?: FocusRingPlacement; /** @@ -171,8 +165,6 @@ const TouchableRipple = ({ borderBottomEndRadius: _borderBottomEndRadius, borderWidth, focusRing = 'outward', - onFocus, - onBlur, ref, ...rest }: Props) => { @@ -335,15 +327,19 @@ 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(); - }; + // No JS focus tracking here: the ring is real CSS, driven by the browser's + // own `:focus-visible`, keyed off the `data-focus-ring` attribute spread + // below. `onFocus`/`onBlur` reach the caller unmodified via `rest`, nothing + // to intercept. + // + // Keyed off `disabledProp`, not `disabled`: the latter also folds in + // "no press handler passed", which is a non-interactivity signal, not a + // disabled one - the ring should only react to real disablement. + const { ring } = useFocusRing( + disabledProp, + theme.colors.secondary, + focusRing + ); return ( [ styles.touchable, - // 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 + // RNW's own `state.focused` fires for mouse clicks too, which is + // exactly the distinction `:focus-visible` exists to make. + // https://github.com/necolas/react-native-web/issues/1849 state.hovered && { backgroundColor: hoverColor }, disabled && styles.disabled, typeof style === 'function' ? style(state) : style, - ...toStyleList( - getFocusRingStyle(ring.focused, theme.colors.secondary, focusRing) - ), + ...ring.style, ]} > {(state) => { diff --git a/src/components/__tests__/TouchableRippleFocusWeb.test.tsx b/src/components/__tests__/TouchableRippleFocusWeb.test.tsx index 7b34a946ff..99c5a7d107 100644 --- a/src/components/__tests__/TouchableRippleFocusWeb.test.tsx +++ b/src/components/__tests__/TouchableRippleFocusWeb.test.tsx @@ -1,4 +1,5 @@ import { Platform, Text } from 'react-native'; +import type { ViewStyle } from 'react-native'; import { afterEach, @@ -11,24 +12,16 @@ import { 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); - }); -}; - +// There is no DOM in this repo's Jest, so there is no real `:focus-visible` to +// fire - the mechanism is now the browser's, not this library's. These prove +// the wiring instead: the right `data-focus-ring[-within]` attribute and CSS +// colour variable land on the rendered element for a given `focusRing` prop. +// Real focus behaviour is a manual, browser-only check (see the PR +// description). const renderRipple = (props = {}) => render( {}} {...props}> @@ -45,46 +38,55 @@ describe('TouchableRipple focus ring (web implementation)', () => { Platform.OS = original; }); - it('rings on keyboard focus, in the theme secondary colour', async () => { + it('emits data-focus-ring="outward" and the secondary colour by default', 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)', + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('ripple').props.dataSet).toEqual({ + focusRing: 'outward', }); + expect(screen.getByTestId('ripple')).toHaveStyle( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + { + ['--rnp-focus-ring-color']: 'rgba(98, 91, 113, 1)', + } as unknown as ViewStyle + ); }); - it('does not ring on a pointer focus', async () => { - await renderRipple(); - - await focus(pointerFocus); + it('emits data-focus-ring="inward" when asked', async () => { + await renderRipple({ focusRing: 'inward' }); - expect(screen.getByTestId('ripple')).not.toHaveStyle({ - outlineWidth: thickness, + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('ripple').props.dataSet).toEqual({ + focusRing: 'inward', }); }); - it('draws inward when asked', async () => { - await renderRipple({ focusRing: 'inward' }); + it('emits no attribute when the ring is turned off', async () => { + await renderRipple({ focusRing: 'none' }); - await focus(keyboardFocus); + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('ripple').props.dataSet).toBeUndefined(); + }); - expect(screen.getByTestId('ripple')).toHaveStyle({ - outlineOffset: -thickness, - }); + it('emits no attribute when disabled', async () => { + await renderRipple({ disabled: true }); + + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('ripple').props.dataSet).toBeUndefined(); }); - it('forwards onFocus and onBlur to the caller', async () => { + // Not reference equality: RN's own `Pressable` wraps the handler it is + // given internally, on every platform, ring or no ring. What matters here + // is that this library stops doing its own extra wrapping around it. + it('still calls a caller onFocus and onBlur', async () => { const onFocus = jest.fn(); const onBlur = jest.fn(); await renderRipple({ onFocus, onBlur }); - await focus(keyboardFocus); + await act(async () => { + await fireEvent(screen.getByTestId('ripple'), 'focus'); + }); await act(async () => { await fireEvent(screen.getByTestId('ripple'), 'blur'); }); diff --git a/src/components/__tests__/__snapshots__/FABExtended.test.tsx.snap b/src/components/__tests__/__snapshots__/FABExtended.test.tsx.snap index 644a640c5a..f2acee8715 100644 --- a/src/components/__tests__/__snapshots__/FABExtended.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/FABExtended.test.tsx.snap @@ -158,7 +158,6 @@ exports[`renders extended FAB collapsed 1`] = ` }, [ null, - null, ], ] } @@ -484,7 +483,6 @@ exports[`renders extended FAB expanded 1`] = ` }, [ null, - null, ], ] } @@ -810,7 +808,6 @@ exports[`renders extended FAB large size 1`] = ` }, [ null, - null, ], ] } @@ -1136,7 +1133,6 @@ exports[`renders extended FAB medium size 1`] = ` }, [ null, - null, ], ] } @@ -1462,7 +1458,6 @@ exports[`renders extended FAB not visible 1`] = ` }, [ null, - null, ], ] } @@ -1788,7 +1783,6 @@ exports[`renders extended FAB transitioning to collapsed 1`] = ` }, [ null, - null, ], ] } diff --git a/src/components/__tests__/__snapshots__/FABMenu.test.tsx.snap b/src/components/__tests__/__snapshots__/FABMenu.test.tsx.snap index 8b66657112..fba13f6cfa 100644 --- a/src/components/__tests__/__snapshots__/FABMenu.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/FABMenu.test.tsx.snap @@ -139,7 +139,6 @@ exports[`renders FAB.Menu closed 1`] = ` { "borderRadius": 9999, }, - null, ], ] } @@ -315,7 +314,6 @@ exports[`renders FAB.Menu closed 1`] = ` { "borderRadius": 9999, }, - null, ], ] } @@ -622,7 +620,6 @@ exports[`renders FAB.Menu closed 1`] = ` { "flex": 1, }, - null, ], ] } @@ -916,7 +913,6 @@ exports[`renders FAB.Menu not expanded when trigger is not visible 1`] = ` { "borderRadius": 9999, }, - null, ], ] } @@ -1092,7 +1088,6 @@ exports[`renders FAB.Menu not expanded when trigger is not visible 1`] = ` { "borderRadius": 9999, }, - null, ], ] } @@ -1399,7 +1394,6 @@ exports[`renders FAB.Menu not expanded when trigger is not visible 1`] = ` { "flex": 1, }, - null, ], ] } @@ -1693,7 +1687,6 @@ exports[`renders FAB.Menu open 1`] = ` { "borderRadius": 9999, }, - null, ], ] } @@ -1869,7 +1862,6 @@ exports[`renders FAB.Menu open 1`] = ` { "borderRadius": 9999, }, - null, ], ] } @@ -2176,7 +2168,6 @@ exports[`renders FAB.Menu open 1`] = ` { "flex": 1, }, - null, ], ] } @@ -2470,7 +2461,6 @@ exports[`renders FAB.Menu with 6 items 1`] = ` { "borderRadius": 9999, }, - null, ], ] } @@ -2646,7 +2636,6 @@ exports[`renders FAB.Menu with 6 items 1`] = ` { "borderRadius": 9999, }, - null, ], ] } @@ -2822,7 +2811,6 @@ exports[`renders FAB.Menu with 6 items 1`] = ` { "borderRadius": 9999, }, - null, ], ] } @@ -2998,7 +2986,6 @@ exports[`renders FAB.Menu with 6 items 1`] = ` { "borderRadius": 9999, }, - null, ], ] } @@ -3174,7 +3161,6 @@ exports[`renders FAB.Menu with 6 items 1`] = ` { "borderRadius": 9999, }, - null, ], ] } @@ -3350,7 +3336,6 @@ exports[`renders FAB.Menu with 6 items 1`] = ` { "borderRadius": 9999, }, - null, ], ] } @@ -3657,7 +3642,6 @@ exports[`renders FAB.Menu with 6 items 1`] = ` { "flex": 1, }, - null, ], ] } @@ -3952,7 +3936,6 @@ exports[`renders FAB.Menu with center alignment 1`] = ` { "borderRadius": 9999, }, - null, ], ] } @@ -4128,7 +4111,6 @@ exports[`renders FAB.Menu with center alignment 1`] = ` { "borderRadius": 9999, }, - null, ], ] } @@ -4435,7 +4417,6 @@ exports[`renders FAB.Menu with center alignment 1`] = ` { "flex": 1, }, - null, ], ] } @@ -4729,7 +4710,6 @@ exports[`renders FAB.Menu with items having icons 1`] = ` { "borderRadius": 9999, }, - null, ], ] } @@ -4935,7 +4915,6 @@ exports[`renders FAB.Menu with items having icons 1`] = ` { "borderRadius": 9999, }, - null, ], ] } @@ -5272,7 +5251,6 @@ exports[`renders FAB.Menu with items having icons 1`] = ` { "flex": 1, }, - null, ], ] } @@ -5566,7 +5544,6 @@ exports[`renders FAB.Menu with start alignment 1`] = ` { "borderRadius": 9999, }, - null, ], ] } @@ -5742,7 +5719,6 @@ exports[`renders FAB.Menu with start alignment 1`] = ` { "borderRadius": 9999, }, - null, ], ] } @@ -6049,7 +6025,6 @@ exports[`renders FAB.Menu with start alignment 1`] = ` { "flex": 1, }, - null, ], ] } diff --git a/src/components/__tests__/__snapshots__/Switch.test.tsx.snap b/src/components/__tests__/__snapshots__/Switch.test.tsx.snap index 877ce2a4c4..b3b3b05b14 100644 --- a/src/components/__tests__/__snapshots__/Switch.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Switch.test.tsx.snap @@ -54,7 +54,6 @@ exports[`Switch render renders disabled off 1`] = ` "justifyContent": "center", "width": 52, }, - undefined, ] } > @@ -269,7 +268,6 @@ exports[`Switch render renders disabled on 1`] = ` "justifyContent": "center", "width": 52, }, - undefined, ] } > @@ -472,7 +470,6 @@ exports[`Switch render renders off 1`] = ` "justifyContent": "center", "width": 52, }, - undefined, ] } > @@ -678,7 +675,6 @@ exports[`Switch render renders on 1`] = ` "justifyContent": "center", "width": 52, }, - undefined, ] } > @@ -864,7 +860,6 @@ exports[`Switch render renders with checked icon 1`] = ` "justifyContent": "center", "width": 52, }, - undefined, ] } > @@ -1121,7 +1116,6 @@ exports[`Switch render renders with per-state icons 1`] = ` "justifyContent": "center", "width": 52, }, - undefined, ] } > diff --git a/src/utils/__tests__/useFocusRing.test.tsx b/src/utils/__tests__/useFocusRing.test.tsx index f7a400edd3..4a4f7e6d93 100644 --- a/src/utils/__tests__/useFocusRing.test.tsx +++ b/src/utils/__tests__/useFocusRing.test.tsx @@ -1,11 +1,20 @@ -import { Platform, Pressable, Text } from 'react-native'; - -import { describe, expect, it, jest } from '@jest/globals'; +import { Platform, Pressable, Text, View } from 'react-native'; +import type { ViewStyle } from 'react-native'; + +import { + afterEach, + beforeEach, + describe, + expect, + it, + jest, +} from '@jest/globals'; import { act, fireEvent } from '@testing-library/react-native'; import { render, screen } from '../../test-utils'; import { tokens } from '../../theme/tokens'; -import { getFocusRingStyle, useFocusRing } from '../useFocusRing'; +import type { FocusRingPlacement, FocusRingScope } from '../useFocusRing'; +import { buildFocusRingStylesheet, useFocusRing } from '../useFocusRing'; const focus = async (data?: unknown) => { await act(async () => { @@ -26,60 +35,68 @@ const Probe = ({ disabled?: boolean; onRender?: () => void; }) => { - const { focused, onFocus, onBlur } = useFocusRing(disabled); + const { target, ring } = useFocusRing(disabled, 'rebeccapurple'); onRender?.(); return ( {}} - onFocus={onFocus} - onBlur={onBlur} - style={getFocusRingStyle(focused, 'rebeccapurple')} + onFocus={target.onFocus} + onBlur={target.onBlur} + style={ring.style} > probe ); }; -describe('getFocusRingStyle', () => { - it('returns nothing when not focused', () => { - expect(getFocusRingStyle(false, 'rebeccapurple')).toBeNull(); +describe('buildFocusRingStylesheet', () => { + const css = buildFocusRingStylesheet(); + + it('rings `self` via :focus-visible and `within` via :has(:focus-visible), for both placements', () => { + expect(css).toContain('[data-focus-ring="outward"]:focus-visible'); + expect(css).toContain('[data-focus-ring="inward"]:focus-visible'); + expect(css).toContain( + '[data-focus-ring-within="outward"]:has(:focus-visible)' + ); + expect(css).toContain( + '[data-focus-ring-within="inward"]:has(:focus-visible)' + ); }); // Values must come from the design tokens, not be hardcoded here, or the // token is decorative. Derive the expectation from the token itself. - it('takes its thickness and offset from the focusIndicator tokens', () => { + it('takes its thickness and offsets 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, - }); + expect(css).toContain(`outline: ${thickness}px solid`); + expect(css).toContain(`outline-offset: ${outerOffset}px;`); + expect(css).toContain(`outline-offset: ${-thickness}px;`); }); }); -describe('useFocusRing', () => { +describe('useFocusRing (native)', () => { + // Derived from the token, not hardcoded - see the note on the stylesheet + // test above. + const { thickness, outerOffset } = tokens.md.sys.state.focusIndicator; + it('applies the outline on focus and removes it on blur', async () => { await render(); - expect(screen.getByTestId('probe')).not.toHaveStyle({ outlineWidth: 3 }); + expect(screen.getByTestId('probe')).not.toHaveStyle({ + outlineWidth: thickness, + }); await focus(); expect(screen.getByTestId('probe')).toHaveStyle({ - outlineWidth: 3, + outlineWidth: thickness, outlineColor: 'rebeccapurple', - outlineOffset: 2, + outlineOffset: outerOffset, }); await blur(); - expect(screen.getByTestId('probe')).not.toHaveStyle({ outlineWidth: 3 }); + expect(screen.getByTestId('probe')).not.toHaveStyle({ + outlineWidth: thickness, + }); }); // `disabled` goes to the hook only, never to the Pressable: RNTL will not @@ -89,21 +106,9 @@ describe('useFocusRing', () => { 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; - } + expect(screen.getByTestId('probe')).not.toHaveStyle({ + outlineWidth: thickness, + }); }); // The gate has to skip the state update, not just mask the result, or a @@ -121,7 +126,9 @@ describe('useFocusRing', () => { 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 }); + expect(screen.getByTestId('probe')).toHaveStyle({ + outlineWidth: thickness, + }); await act(async () => { await rerender(); @@ -131,6 +138,89 @@ describe('useFocusRing', () => { }); // no focus event happened in between, so nothing should be ringed - expect(screen.getByTestId('probe')).not.toHaveStyle({ outlineWidth: 3 }); + expect(screen.getByTestId('probe')).not.toHaveStyle({ + outlineWidth: thickness, + }); + }); +}); + +// There is no DOM in this repo's Jest, so these can only prove the wiring - +// the right data attribute and CSS variable land on the right element. Real +// `:focus-visible`/`:has()` behaviour is a manual, browser-only check (see +// the PR description). +describe('useFocusRing (web)', () => { + const original = Platform.OS; + beforeEach(() => { + Platform.OS = 'web'; + }); + afterEach(() => { + Platform.OS = original; + }); + + const WebProbe = ({ + disabled, + placement, + scope, + }: { + disabled?: boolean; + placement?: FocusRingPlacement; + scope?: FocusRingScope; + }) => { + const { target, ring } = useFocusRing( + disabled, + 'rebeccapurple', + placement, + scope + ); + return ( + + + + ); + }; + + it('emits data-focus-ring and the colour variable for scope "self"', async () => { + await render(); + + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('probe').props.dataSet).toEqual({ + focusRing: 'outward', + }); + expect(screen.getByTestId('probe')).toHaveStyle( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + { '--rnp-focus-ring-color': 'rebeccapurple' } as unknown as ViewStyle + ); + }); + + it('emits data-focus-ring-within for scope "within", and suppresses the target element\'s own outline', async () => { + await render(); + + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('probe').props.dataSet).toEqual({ + focusRingWithin: 'inward', + }); + expect(screen.getByTestId('target')).toHaveStyle( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + { outline: 'none' } as unknown as ViewStyle + ); + }); + + it('does not suppress the target element\'s outline for scope "self"', async () => { + await render(); + + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('target').props.style).toEqual([]); + }); + + it('emits nothing when disabled or when the ring is turned off', async () => { + await render(); + + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('probe').props.dataSet).toBeUndefined(); + + await render(); + + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('probe').props.dataSet).toBeUndefined(); }); }); diff --git a/src/utils/useFocusRing.ts b/src/utils/useFocusRing.ts index 776776f642..a13fb75b06 100644 --- a/src/utils/useFocusRing.ts +++ b/src/utils/useFocusRing.ts @@ -1,9 +1,10 @@ import * as React from 'react'; -import type { - ColorValue, - NativeSyntheticEvent, - TargetedEvent, - ViewStyle, +import { + Platform, + type ColorValue, + type NativeSyntheticEvent, + type TargetedEvent, + type ViewStyle, } from 'react-native'; import { isKeyboardFocusEvent } from './isKeyboardFocusEvent'; @@ -13,30 +14,90 @@ 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; +/** + * `'self'` - the ring is drawn on the element that receives focus. + * `'within'` - the ring is drawn on an ancestor (a track/clip view), because + * the focusable element's own box is the wrong shape or size for it. + */ +export type FocusRingScope = 'self' | 'within'; + +export type FocusRingResult = { + /** Spread onto the element that receives focus. */ + target: { + onFocus?: (e: NativeSyntheticEvent) => void; + onBlur?: () => void; + style: ViewStyle[]; + }; + /** Spread onto the element that draws the ring. */ + ring: { + style: ViewStyle[]; + /** + * Spread onto the ring element, e.g. ``. + * `dataSet` isn't in RN's core view prop types, though react-native-web + * renders it as real `data-*` attributes - the mechanism the shared + * stylesheet below keys off. Typed as `object` so it spreads onto any + * host component without a prop-type mismatch; empty (a no-op spread) + * on native and when the ring is suppressed. + */ + dataSetProps: object; + }; +}; + +const toDataSetProps = (dataSet: Record | undefined): object => + dataSet ? { dataSet } : {}; + +const EMPTY: FocusRingResult = { + target: { style: [] }, + ring: { style: [], dataSetProps: {} }, }; +/** Suppresses the browser's own focus ring, for `scope: 'within'` on web. */ +// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion +const webNoOutlineStyle = { outline: 'none' } as unknown as ViewStyle; + /** - * Tracks keyboard focus for an MD3 focus indicator. + * MD3 keyboard focus indicator, from one hook shared by every component built + * on it. * - * 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. + * On native there is no CSS, so this tracks focus in state and computes the + * ring as an `outline*` style, live. On web the ring is real CSS: a shared + * stylesheet keyed off a `data-focus-ring[-within]` attribute and + * `:focus-visible`/`:has(:focus-visible)`, with only the (theme-dependent) + * color passed through as a CSS custom property. Nothing here tracks focus in + * JS on web - the browser drives it. + * + * `scope: 'within'` is for a control whose ring belongs on an ancestor of the + * element that actually receives focus (Switch's track, FAB's clip view): + * `target` also suppresses the browser's default outline on the focused + * element itself on web, so only the ring shows. + * + * iOS: `onFocus`/`onBlur` never reach here today. Fabric's view does call + * `-becomeFirstResponder`/`-resignFirstResponder` for hardware-keyboard/Full + * Keyboard Access navigation, but only emits the JS event when the + * `enableImperativeFocus` feature flag is on, and it defaults off (old + * architecture has no equivalent path at all). Pre-existing, not something + * this hook introduces - the library's older FAB, Checkbox, and Switch rings + * were equally inert on iOS. */ -export function useFocusRing(disabled?: boolean): FocusRingState { +export function useFocusRing( + disabled: boolean | undefined, + color: ColorValue, + placement: FocusRingPlacement = 'outward', + scope: FocusRingScope = 'self' +): FocusRingResult { + const suppressed = disabled || placement === 'none'; + + // Rules of hooks: called unconditionally regardless of platform. Cheap - + // native's is a no-op until focus/blur actually fires, web's is stateless. const [focused, setFocused] = React.useState(false); const onFocus = React.useCallback( (e: NativeSyntheticEvent) => { - // Symmetric, and skipped entirely when there is nothing to show, so a - // suppressed ring costs no render. - if (!disabled) { + if (!suppressed) { setFocused(isKeyboardFocusEvent(e)); } }, - [disabled] + [suppressed] ); const onBlur = React.useCallback(() => setFocused(false), []); @@ -44,48 +105,99 @@ export function useFocusRing(disabled?: boolean): FocusRingState { // 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) { + if (suppressed) { setFocused(false); } - }, [disabled]); + }, [suppressed]); - return { focused: focused && !disabled, onFocus, onBlur }; + const isNativeFocused = focused && !suppressed; + + return React.useMemo(() => { + if (suppressed) { + return EMPTY; + } + + if (Platform.OS === 'web') { + const dataKey = scope === 'within' ? 'focusRingWithin' : 'focusRing'; + return { + target: { style: scope === 'within' ? [webNoOutlineStyle] : [] }, + ring: { + style: [ + // A style key starting with `--` becomes a real CSS custom + // property on web (react-native-web's setValueForStyles), read + // by the shared stylesheet below. Not reactive to focus - the + // browser's own `:focus-visible`/`:has()` shows the ring. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + { ['--rnp-focus-ring-color']: color } as unknown as ViewStyle, + ], + dataSetProps: toDataSetProps({ [dataKey]: placement }), + }, + }; + } + + return { + target: { onFocus, onBlur, style: [] }, + ring: { + style: isNativeFocused + ? [ + { + outlineWidth: thickness, + outlineColor: color, + outlineStyle: 'solid' as const, + outlineOffset: + placement === 'inward' ? -thickness : outerOffset, + }, + ] + : [], + dataSetProps: {}, + }, + }; + }, [suppressed, scope, color, placement, isNativeFocused, onFocus, onBlur]); } -/** - * 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; +const STYLE_ELEMENT_ATTR = 'data-rnp-focus-ring-styles'; -/** Spread into a style array so an absent ring adds no entry. */ -export const toStyleList = (s: ViewStyle | null): ViewStyle[] => (s ? [s] : []); +const focusRingRule = ( + selector: (placement: 'outward' | 'inward') => string +) => ` +${selector('outward')} { + outline: ${thickness}px solid var(--rnp-focus-ring-color); + outline-offset: ${outerOffset}px; +} +${selector('inward')} { + outline: ${thickness}px solid var(--rnp-focus-ring-color); + outline-offset: ${-thickness}px; +}`; /** - * 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. + * The shared web stylesheet text, as a pure function of the design tokens - + * exported so its contents can be asserted without a DOM. */ -// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -export const webNoOutline = { outline: 'none' } as unknown as ViewStyle; +export const buildFocusRingStylesheet = (): string => + [ + focusRingRule((p) => `[data-focus-ring="${p}"]:focus-visible`), + focusRingRule((p) => `[data-focus-ring-within="${p}"]:has(:focus-visible)`), + ].join('\n'); + +let injected = false; + +/** Idempotent: safe to call from every module that needs the ring on web. */ +export const injectFocusRingStylesheet = (): void => { + if (injected || Platform.OS !== 'web' || typeof document === 'undefined') { + return; + } + if (document.querySelector(`style[${STYLE_ELEMENT_ATTR}]`)) { + injected = true; + return; + } + + const style = document.createElement('style'); + style.setAttribute(STYLE_ELEMENT_ATTR, ''); + style.textContent = buildFocusRingStylesheet(); + document.head.appendChild(style); + injected = true; +}; + +if (Platform.OS === 'web') { + injectFocusRingStylesheet(); +} From a3e77e0fb4a4f8eafb6f7ed11c93db0f89016003 Mon Sep 17 00:00:00 2001 From: Konrad Rydygier Date: Mon, 7 Sep 2026 14:24:44 +0200 Subject: [PATCH 13/15] chore: update snapshots after rebasing onto main --- .../__snapshots__/Banner.test.tsx.snap | 4 + .../__snapshots__/Button.test.tsx.snap | 13 + .../__tests__/__snapshots__/FAB.test.tsx.snap | 312 ---------- .../__snapshots__/FABExtended.test.tsx.snap | 138 ----- .../__snapshots__/FABMenu.test.tsx.snap | 575 ------------------ .../__snapshots__/ListItem.test.tsx.snap | 8 + .../__snapshots__/Menu.test.tsx.snap | 4 + .../__snapshots__/Snackbar.test.tsx.snap | 1 + .../__snapshots__/Switch.test.tsx.snap | 138 ----- 9 files changed, 30 insertions(+), 1163 deletions(-) diff --git a/src/components/__tests__/__snapshots__/Banner.test.tsx.snap b/src/components/__tests__/__snapshots__/Banner.test.tsx.snap index 71e77e2936..b9908258e6 100644 --- a/src/components/__tests__/__snapshots__/Banner.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Banner.test.tsx.snap @@ -300,6 +300,7 @@ exports[`render visible banner, with custom theme 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -909,6 +910,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]} @@ -1300,6 +1302,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]} @@ -1507,6 +1510,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 628087c74b..12b3f5262e 100644 --- a/src/components/__tests__/__snapshots__/Button.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Button.test.tsx.snap @@ -2666,6 +2666,7 @@ exports[`renders button with an accessibility hint 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2871,6 +2872,7 @@ exports[`renders button with an accessibility label 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -3075,6 +3077,7 @@ exports[`renders button with button color 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -3279,6 +3282,7 @@ exports[`renders button with color 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -3483,6 +3487,7 @@ exports[`renders button with custom testID 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -3688,6 +3693,7 @@ exports[`renders button with icon 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -3940,6 +3946,7 @@ exports[`renders button with icon in reverse order 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -4194,6 +4201,7 @@ exports[`renders contained contained with mode 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -4399,6 +4407,7 @@ exports[`renders disabled button 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -4603,6 +4612,7 @@ exports[`renders loading button 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -5011,6 +5021,7 @@ exports[`renders outlined button with mode 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -5216,6 +5227,7 @@ exports[`renders text button by default 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -5420,6 +5432,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__/FAB.test.tsx.snap b/src/components/__tests__/__snapshots__/FAB.test.tsx.snap index 4b06302358..246de68391 100644 --- a/src/components/__tests__/__snapshots__/FAB.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/FAB.test.tsx.snap @@ -156,7 +156,6 @@ exports[`renders FAB large size 1`] = ` }, [ null, - null, ], ] } @@ -213,29 +212,6 @@ exports[`renders FAB large size 1`] = ` - `; @@ -395,7 +371,6 @@ exports[`renders FAB medium size 1`] = ` }, [ null, - null, ], ] } @@ -452,29 +427,6 @@ exports[`renders FAB medium size 1`] = `
- `; @@ -634,7 +586,6 @@ exports[`renders FAB transitioning to not visible 1`] = ` }, [ null, - null, ], ] } @@ -691,29 +642,6 @@ exports[`renders FAB transitioning to not visible 1`] = `
- `; @@ -873,7 +801,6 @@ exports[`renders FAB transitioning to visible 1`] = ` }, [ null, - null, ], ] } @@ -930,29 +857,6 @@ exports[`renders FAB transitioning to visible 1`] = `
- `; @@ -1113,7 +1017,6 @@ exports[`renders FAB with aria-label 1`] = ` }, [ null, - null, ], ] } @@ -1170,29 +1073,6 @@ exports[`renders FAB with aria-label 1`] = `
- `; @@ -1352,7 +1232,6 @@ exports[`renders FAB with containerColor and contentColor overrides 1`] = ` }, [ null, - null, ], ] } @@ -1409,29 +1288,6 @@ exports[`renders FAB with containerColor and contentColor overrides 1`] = ` - `; @@ -1591,7 +1447,6 @@ exports[`renders FAB with containerColor override 1`] = ` }, [ null, - null, ], ] } @@ -1648,29 +1503,6 @@ exports[`renders FAB with containerColor override 1`] = ` - `; @@ -1830,7 +1662,6 @@ exports[`renders FAB with default props 1`] = ` }, [ null, - null, ], ] } @@ -1887,29 +1718,6 @@ exports[`renders FAB with default props 1`] = ` - `; @@ -2069,7 +1877,6 @@ exports[`renders FAB with primary variant 1`] = ` }, [ null, - null, ], ] } @@ -2126,29 +1933,6 @@ exports[`renders FAB with primary variant 1`] = ` - `; @@ -2308,7 +2092,6 @@ exports[`renders FAB with secondary variant 1`] = ` }, [ null, - null, ], ] } @@ -2365,29 +2148,6 @@ exports[`renders FAB with secondary variant 1`] = ` - `; @@ -2547,7 +2307,6 @@ exports[`renders FAB with tertiary variant 1`] = ` }, [ null, - null, ], ] } @@ -2604,29 +2363,6 @@ exports[`renders FAB with tertiary variant 1`] = ` - `; @@ -2786,7 +2522,6 @@ exports[`renders FAB with tonalSecondary variant 1`] = ` }, [ null, - null, ], ] } @@ -2843,29 +2578,6 @@ exports[`renders FAB with tonalSecondary variant 1`] = ` - `; @@ -3025,7 +2737,6 @@ exports[`renders FAB with tonalTertiary variant 1`] = ` }, [ null, - null, ], ] } @@ -3082,28 +2793,5 @@ exports[`renders FAB with tonalTertiary variant 1`] = ` - `; diff --git a/src/components/__tests__/__snapshots__/FABExtended.test.tsx.snap b/src/components/__tests__/__snapshots__/FABExtended.test.tsx.snap index f2acee8715..8cf2e644d1 100644 --- a/src/components/__tests__/__snapshots__/FABExtended.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/FABExtended.test.tsx.snap @@ -256,29 +256,6 @@ exports[`renders extended FAB collapsed 1`] = ` - - - - - - - - @@ -745,29 +699,6 @@ exports[`renders FAB.Menu closed 1`] = ` - @@ -977,29 +908,6 @@ exports[`renders FAB.Menu not expanded when trigger is not visible 1`] = ` - - @@ -1519,29 +1404,6 @@ exports[`renders FAB.Menu not expanded when trigger is not visible 1`] = ` - @@ -1751,29 +1613,6 @@ exports[`renders FAB.Menu open 1`] = ` - - @@ -2293,29 +2109,6 @@ exports[`renders FAB.Menu open 1`] = ` - @@ -2525,29 +2318,6 @@ exports[`renders FAB.Menu with 6 items 1`] = ` - - - - - - @@ -3767,29 +3422,6 @@ exports[`renders FAB.Menu with 6 items 1`] = ` - @@ -4000,29 +3632,6 @@ exports[`renders FAB.Menu with center alignment 1`] = ` - - @@ -4542,29 +4128,6 @@ exports[`renders FAB.Menu with center alignment 1`] = ` - @@ -4804,29 +4367,6 @@ exports[`renders FAB.Menu with items having icons 1`] = ` - - @@ -5376,29 +4893,6 @@ exports[`renders FAB.Menu with items having icons 1`] = ` - @@ -5608,29 +5102,6 @@ exports[`renders FAB.Menu with start alignment 1`] = ` - - @@ -6150,29 +5598,6 @@ exports[`renders FAB.Menu with start alignment 1`] = ` - diff --git a/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap b/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap index 6e949d55da..17418c7613 100644 --- a/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap @@ -169,6 +169,7 @@ exports[`renders list item with custom description 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -388,6 +389,7 @@ exports[`renders list item with custom description 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -558,6 +560,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]} @@ -702,6 +705,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]} @@ -891,6 +895,7 @@ exports[`renders list item with left item 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1046,6 +1051,7 @@ exports[`renders list item with right item 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1157,6 +1163,7 @@ exports[`renders list item with title and description 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1297,6 +1304,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__/Menu.test.tsx.snap b/src/components/__tests__/__snapshots__/Menu.test.tsx.snap index 3e059001aa..8dbf3f2d26 100644 --- a/src/components/__tests__/__snapshots__/Menu.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Menu.test.tsx.snap @@ -131,6 +131,7 @@ exports[`renders not visible menu 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -351,6 +352,7 @@ exports[`renders visible menu 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -668,6 +670,7 @@ exports[`renders visible menu 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -790,6 +793,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__/Snackbar.test.tsx.snap b/src/components/__tests__/__snapshots__/Snackbar.test.tsx.snap index 353efa0fed..42f4bf648d 100644 --- a/src/components/__tests__/__snapshots__/Snackbar.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Snackbar.test.tsx.snap @@ -600,6 +600,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 b3b3b05b14..aa7825ecc1 100644 --- a/src/components/__tests__/__snapshots__/Switch.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Switch.test.tsx.snap @@ -188,29 +188,6 @@ exports[`Switch render renders disabled off 1`] = ` } /> - `; @@ -382,29 +359,6 @@ exports[`Switch render renders disabled on 1`] = ` } /> - `; @@ -587,29 +541,6 @@ exports[`Switch render renders off 1`] = ` } /> - `; @@ -772,29 +703,6 @@ exports[`Switch render renders on 1`] = ` } /> - `; @@ -1028,29 +936,6 @@ exports[`Switch render renders with checked icon 1`] = ` - `; @@ -1284,28 +1169,5 @@ exports[`Switch render renders with per-state icons 1`] = ` - `; From ab785816311b43138c3a4ffd79cee7b3fdac8eb8 Mon Sep 17 00:00:00 2001 From: Konrad Rydygier Date: Wed, 9 Sep 2026 08:37:04 +0200 Subject: [PATCH 14/15] test: correct snapshots after combining focus-ring and hitSlop rebase The snapshots carried over from the pre-rebase commit didn't account for the per-component hitSlop changes now merged in from 48dp-touch-targets. --- .../__snapshots__/Banner.test.tsx.snap | 4 --- .../__snapshots__/Button.test.tsx.snap | 13 -------- .../__snapshots__/Chip.test.tsx.snap | 30 ++++++++++++------- .../__snapshots__/ListItem.test.tsx.snap | 8 ----- .../__snapshots__/Menu.test.tsx.snap | 4 --- .../__snapshots__/Snackbar.test.tsx.snap | 1 - 6 files changed, 20 insertions(+), 40 deletions(-) diff --git a/src/components/__tests__/__snapshots__/Banner.test.tsx.snap b/src/components/__tests__/__snapshots__/Banner.test.tsx.snap index b9908258e6..71e77e2936 100644 --- a/src/components/__tests__/__snapshots__/Banner.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Banner.test.tsx.snap @@ -300,7 +300,6 @@ exports[`render visible banner, with custom theme 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -910,7 +909,6 @@ 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]} @@ -1302,7 +1300,6 @@ 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]} @@ -1510,7 +1507,6 @@ 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 12b3f5262e..628087c74b 100644 --- a/src/components/__tests__/__snapshots__/Button.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Button.test.tsx.snap @@ -2666,7 +2666,6 @@ exports[`renders button with an accessibility hint 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2872,7 +2871,6 @@ exports[`renders button with an accessibility label 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -3077,7 +3075,6 @@ exports[`renders button with button color 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -3282,7 +3279,6 @@ exports[`renders button with color 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -3487,7 +3483,6 @@ exports[`renders button with custom testID 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -3693,7 +3688,6 @@ exports[`renders button with icon 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -3946,7 +3940,6 @@ exports[`renders button with icon in reverse order 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -4201,7 +4194,6 @@ exports[`renders contained contained with mode 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -4407,7 +4399,6 @@ exports[`renders disabled button 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -4612,7 +4603,6 @@ exports[`renders loading button 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -5021,7 +5011,6 @@ exports[`renders outlined button with mode 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -5227,7 +5216,6 @@ exports[`renders text button by default 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -5432,7 +5420,6 @@ 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 c5477e7e73..439e667b0c 100644 --- a/src/components/__tests__/__snapshots__/Chip.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Chip.test.tsx.snap @@ -319,11 +319,16 @@ exports[`renders chip with close button 1`] = ` onStartShouldSetResponder={[Function]} role="button" style={ - { - "height": "100%", - "justifyContent": "center", - "width": "100%", - } + [ + { + "height": "100%", + "justifyContent": "center", + "width": "100%", + }, + { + "borderRadius": 8, + }, + ] } > Date: Wed, 23 Sep 2026 10:24:57 +0200 Subject: [PATCH 15/15] test: fix snapshots after rebase --- .../__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap | 1 - .../Checkbox/__snapshots__/CheckboxItem.test.tsx.snap | 1 - src/components/__tests__/__snapshots__/DataTable.test.tsx.snap | 1 - src/components/__tests__/__snapshots__/ListItem.test.tsx.snap | 3 +++ 4 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap b/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap index 8896fa06e3..3ff396ce0c 100644 --- a/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap +++ b/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap @@ -622,7 +622,6 @@ exports[`renders disabled Checkbox 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 48c1a35ffe..6dcb1ec397 100644 --- a/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap +++ b/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap @@ -711,7 +711,6 @@ exports[`should have maxFontSizeMultiplier set to 1.5 by default 1`] = ` "width": 40, }, undefined, - undefined, ], ] } diff --git a/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap b/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap index 878acb80f7..349cdccc4b 100644 --- a/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap @@ -173,7 +173,6 @@ exports[`DataTable.Cell renders data table cell children without wrapping text c "width": 40, }, undefined, - undefined, ], ] } diff --git a/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap b/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap index 6e949d55da..719af66302 100644 --- a/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap @@ -43,6 +43,9 @@ exports[`renders list item with custom content style 1`] = ` }, undefined, ], + { + "--rnp-focus-ring-color": "rgba(98, 91, 113, 1)", + }, ] } testID="list-item"