From 96114558db0134fbc027678458ee7202755807d8 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/10] 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 40cb460ffb..c79a60bf96 100644 --- a/src/components/Chip/Chip.tsx +++ b/src/components/Chip/Chip.tsx @@ -175,6 +175,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, @@ -265,7 +284,7 @@ const Chip = ({ }; const contentSpacings = { - paddingRight: onClose ? 34 : 0, + paddingRight: onClose ? CLOSE_AFFORDANCE_WIDTH : 0, }; const labelTextStyle = { @@ -386,8 +405,12 @@ const Chip = ({ disabled={disabled} role="button" aria-label={closeIconAccessibilityLabel} + style={styles.closeButton} > - + {closeIcon ? ( ) : ( @@ -438,6 +461,10 @@ const styles = StyleSheet.create({ md3CloseIcon: { marginRight: 8, padding: 0, + // `styles.icon` sets `alignSelf: 'center'`, which beats `alignItems` on the + // parent. Without this the glyph centres in the wider column and moves 4dp + // left. + alignSelf: 'flex-end', }, md3LabelText: { textAlignVertical: 'center', @@ -468,9 +495,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 a55c75e6c6..1e0cfe14de 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, @@ -178,7 +178,7 @@ const IconButton = ({ pointerEvents="none" style={[ StyleSheet.absoluteFill, - { backgroundColor, opacity: backgroundOpacity }, + { backgroundColor, opacity: backgroundOpacity, borderRadius }, ]} /> )} @@ -187,15 +187,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} > @@ -218,14 +221,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 e86ad49c23..0e10c554d8 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 }) => ( @@ -114,6 +232,7 @@ const TouchableRipple = ({ testID="touchable-ripple-underlay" style={[ styles.underlay, + getUnderlayShape(style), { backgroundColor: calculatedUnderlayColor }, ]} /> diff --git a/src/components/TouchableRipple/TouchableRipple.tsx b/src/components/TouchableRipple/TouchableRipple.tsx index 128e2c017e..ada76bd255 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 bc5b884910..8f9fe276a9 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 87f54dba4e..cb634c9b38 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 f3c4cb1168..a96c63cd10 100644 --- a/src/components/__tests__/TouchableRipple.test.tsx +++ b/src/components/__tests__/TouchableRipple.test.tsx @@ -1,8 +1,9 @@ +import * as React from 'react'; import { Platform, Text } from 'react-native'; import type { GestureResponderEvent } from 'react-native'; import { describe, expect, it, jest } from '@jest/globals'; -import { userEvent } from '@testing-library/react-native'; +import { act, fireEvent, userEvent } from '@testing-library/react-native'; import { render, screen } from '../../test-utils'; import TouchableRipple from '../TouchableRipple/TouchableRipple.native'; @@ -68,5 +69,214 @@ describe('TouchableRipple', () => { const underlay = screen.getByTestId('touchable-ripple-underlay'); expect(underlay).toHaveStyle({ backgroundColor: 'purple' }); }); + + it('takes the shape of the touchable so it does not square off the corners', async () => { + await render( + + Press me! + + ); + + expect(screen.getByTestId('touchable-ripple-underlay')).toHaveStyle({ + borderRadius: 4, + }); + }); + + it('takes per-corner radii too', async () => { + await render( + + Press me! + + ); + + expect(screen.getByTestId('touchable-ripple-underlay')).toHaveStyle({ + borderTopLeftRadius: 8, + borderBottomRightRadius: 2, + }); + }); + }); + + describe('minimum interactive size', () => { + const layout = (width: number, height: number) => ({ + nativeEvent: { layout: { width, height, x: 0, y: 0 } }, + }); + + // hitSlop has no user-visible effect here, the renderer does not lay views + // out or hit-test them. Real behaviour is checked on device; this only stops + // the props being dropped. + /* eslint-disable no-restricted-syntax */ + const hitSlopOf = () => screen.getByTestId('touchable').props.hitSlop; + const onLayoutOf = () => screen.getByTestId('touchable').props.onLayout; + /* eslint-enable no-restricted-syntax */ + + const renderTouchable = async (props = {}) => { + await render( + {}} {...props}> + Button + + ); + return screen.getByTestId('touchable'); + }; + + const fireLayout = async (width: number, height: number) => { + await act(async () => { + await fireEvent( + screen.getByTestId('touchable'), + 'layout', + layout(width, height) + ); + }); + }; + + it('expands a small target out to the minimum interactive size', async () => { + await renderTouchable(); + expect(hitSlopOf()).toBeUndefined(); + + await fireLayout(32, 32); + + // (48 - 32) / 2 on every side + expect(hitSlopOf()).toEqual({ top: 8, bottom: 8, left: 8, right: 8 }); + }); + + it('expands each axis independently', async () => { + await renderTouchable(); + + await fireLayout(40, 100); + + expect(hitSlopOf()).toEqual({ top: 0, bottom: 0, left: 4, right: 4 }); + }); + + it('leaves a target that is already big enough alone', async () => { + await renderTouchable(); + + await fireLayout(48, 48); + + expect(hitSlopOf()).toBeUndefined(); + }); + + it('lets a caller-supplied hitSlop win', async () => { + await renderTouchable({ hitSlop: 2 }); + + await fireLayout(32, 32); + + expect(hitSlopOf()).toBe(2); + }); + + it('does not expand a touchable with no touch handlers', async () => { + await render( + + Not a control + + ); + + expect(hitSlopOf()).toBeUndefined(); + }); + + // Measuring and applying are separate. RN emits onLayout on mount and on + // layout change, so measuring only once interactive would mean no event ever + // arrives and the target stays small. + it('measures even while it cannot be pressed', async () => { + await renderTouchable({ disabled: true }); + + expect(onLayoutOf()).toEqual(expect.any(Function)); + expect(hitSlopOf()).toBeUndefined(); + }); + + it('does not expand a disabled touchable', async () => { + await renderTouchable({ disabled: true }); + + await fireLayout(32, 32); + + expect(hitSlopOf()).toBeUndefined(); + }); + + it('keeps the measurement across losing and regaining interactivity', async () => { + const Harness = ({ disabled }: { disabled: boolean }) => ( + {}} + > + Button + + ); + const view = await render(); + const expanded = { top: 8, bottom: 8, left: 8, right: 8 }; + + await fireLayout(32, 32); + expect(hitSlopOf()).toEqual(expanded); + + await act(async () => { + await view.rerender(); + }); + expect(hitSlopOf()).toBeUndefined(); + + // back again, with no second layout event to rely on + await act(async () => { + await view.rerender(); + }); + expect(hitSlopOf()).toEqual(expanded); + }); + + it('still calls a caller-supplied onLayout', async () => { + const onLayout = jest.fn(); + await renderTouchable({ onLayout }); + + await fireLayout(32, 32); + + expect(onLayout).toHaveBeenCalledTimes(1); + }); + + describe('render cost', () => { + // TouchableRipple renders everywhere, so the cost of measuring is worth + // pinning down. + const withProfiler = async () => { + const commits: string[] = []; + await render( + commits.push(phase)} + > + {}}> + Button + + + ); + return commits; + }; + + it('costs no extra render when the target is already big enough', async () => { + const commits = await withProfiler(); + expect(commits).toEqual(['mount']); + + await fireLayout(56, 56); + + // the updater returned the identical value, so React bails out + expect(commits).toEqual(['mount']); + }); + + it('costs one extra render when the target is too small', async () => { + const commits = await withProfiler(); + + await fireLayout(32, 32); + + expect(commits).toEqual(['mount', 'update']); + }); + + it('settles after a repeated layout at the same size', async () => { + const commits = await withProfiler(); + + await fireLayout(32, 32); + await fireLayout(32, 32); + await fireLayout(32, 32); + + // React renders once more before it can bail out on an unchanged value, + // then stops. Three more layout events, one more render. + expect(commits).toEqual(['mount', 'update', 'update']); + }); + }); }); }); diff --git a/src/components/__tests__/TouchableRippleWeb.test.tsx b/src/components/__tests__/TouchableRippleWeb.test.tsx new file mode 100644 index 0000000000..ccb07f18eb --- /dev/null +++ b/src/components/__tests__/TouchableRippleWeb.test.tsx @@ -0,0 +1,134 @@ +import { Text } from 'react-native'; + +import { describe, expect, it } from '@jest/globals'; + +import { render, screen } from '../../test-utils'; +import type TouchableRippleType from '../TouchableRipple/TouchableRipple'; + +// The web variant, required with its extension on purpose. A bare specifier +// resolves to `TouchableRipple.native.tsx` under the jest preset, so importing +// it the normal way silently tests the native file and none of this runs. +// +// The preset sets `Platform.OS` to 'ios' and there is no DOM, so this renders the +// web source on the native renderer. It pins props and element order, nothing +// more. Hit testing, stacking order, computed styles and clipping ancestors have +// to be checked in a browser. Pressing here would throw, `handlePressIn` reaches +// for `window`. +const TouchableRipple: typeof TouchableRippleType = + require('../TouchableRipple/TouchableRipple.tsx').default; + +const TARGET = 'touchable-ripple-touch-target'; + +// The target is `aria-hidden`, the button already carries the semantics. Testing +// library skips hidden elements, so queries have to opt in or they find nothing +// and the negative cases pass for free. +const HIDDEN = { includeHiddenElements: true } as const; + +describe('TouchableRipple (web)', () => { + // The target is invisible by design, so there is no user-visible assertion to + // make about it. Its style is the behaviour. + const styleOf = (testID: string) => { + // eslint-disable-next-line no-restricted-syntax + const { style } = screen.getByTestId(testID, HIDDEN).props; + return Array.isArray(style) ? Object.assign({}, ...style.flat()) : style; + }; + + it('renders a minimum sized touch target for an interactive touchable', async () => { + await render( + {}}> + Button + + ); + + expect(screen.getByTestId(TARGET, HIDDEN)).toBeOnTheScreen(); + expect(styleOf(TARGET)).toMatchObject({ + position: 'absolute', + minWidth: 48, + minHeight: 48, + width: '100%', + height: '100%', + }); + }); + + it('renders the touch target before the children so it cannot cover them', async () => { + // It hit-tests, so as the last sibling it covers anything interactive inside + // the touchable, e.g. a pressable List.Item with a control in `right`. + await render( + {}}> + child-marker + + ); + + const tree = JSON.stringify(screen.toJSON()); + + expect(tree.indexOf(TARGET)).toBeGreaterThan(-1); + expect(tree.indexOf(TARGET)).toBeLessThan(tree.indexOf('child-marker')); + }); + + it('does not render a touch target when there are no touch handlers', async () => { + await render( + + Not a control + + ); + + expect(screen.queryByTestId(TARGET, HIDDEN)).not.toBeOnTheScreen(); + }); + + it('does not render a touch target when disabled', async () => { + await render( + {}}> + Button + + ); + + expect(screen.queryByTestId(TARGET, HIDDEN)).not.toBeOnTheScreen(); + }); + + it('lets a caller-supplied hitSlop size the target instead', async () => { + await render( + {}}> + Button + + ); + + expect(styleOf(TARGET)).toEqual({ + position: 'absolute', + top: -6, + bottom: -6, + left: -6, + right: -6, + }); + }); + + it('accepts a per-edge hitSlop', async () => { + await render( + {}}> + Button + + ); + + expect(styleOf(TARGET)).toEqual({ + position: 'absolute', + top: -4, + bottom: -0, + left: -8, + right: -0, + }); + }); + + it('no longer clips the touchable itself, which would clip the target', async () => { + await render( + {}} testID="touchable"> + Button + + ); + + const style = styleOf('touchable'); + + // check we have the touchable's own style first, or the absence below passes + // against any empty object + expect(style).toMatchObject({ position: 'relative' }); + expect(style.overflow).toBeUndefined(); + }); +}); diff --git a/src/components/__tests__/__snapshots__/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 23b577a775..ceb4257935 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]} @@ -300,6 +301,7 @@ exports[`renders FAB.Menu closed 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -608,6 +610,7 @@ exports[`renders FAB.Menu closed 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -902,6 +905,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]} @@ -1079,6 +1083,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]} @@ -1387,6 +1392,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]} @@ -1681,6 +1687,7 @@ exports[`renders FAB.Menu open 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1858,6 +1865,7 @@ exports[`renders FAB.Menu open 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2166,6 +2174,7 @@ exports[`renders FAB.Menu open 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2460,6 +2469,7 @@ exports[`renders FAB.Menu with 6 items 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2637,6 +2647,7 @@ exports[`renders FAB.Menu with 6 items 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2814,6 +2825,7 @@ exports[`renders FAB.Menu with 6 items 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2991,6 +3003,7 @@ exports[`renders FAB.Menu with 6 items 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -3168,6 +3181,7 @@ exports[`renders FAB.Menu with 6 items 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -3345,6 +3359,7 @@ exports[`renders FAB.Menu with 6 items 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -3653,6 +3668,7 @@ exports[`renders FAB.Menu with 6 items 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -3948,6 +3964,7 @@ exports[`renders FAB.Menu with center alignment 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -4125,6 +4142,7 @@ exports[`renders FAB.Menu with center alignment 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -4433,6 +4451,7 @@ exports[`renders FAB.Menu with center alignment 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -4727,6 +4746,7 @@ exports[`renders FAB.Menu with items having icons 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -4933,6 +4953,7 @@ exports[`renders FAB.Menu with items having icons 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -5270,6 +5291,7 @@ exports[`renders FAB.Menu with items having icons 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -5564,6 +5586,7 @@ exports[`renders FAB.Menu with start alignment 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -5741,6 +5764,7 @@ exports[`renders FAB.Menu with start alignment 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -6049,6 +6073,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 7e7dcc158c..01e5e17a33 100644 --- a/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap @@ -477,6 +477,7 @@ exports[`renders list section with custom title style 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -630,6 +631,7 @@ exports[`renders list section with custom title style 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1237,6 +1239,7 @@ exports[`renders list section with subheader 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1390,6 +1393,7 @@ exports[`renders list section with subheader 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1957,6 +1961,7 @@ exports[`renders list section without subheader 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2110,6 +2115,7 @@ exports[`renders list section without subheader 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} diff --git a/src/components/__tests__/__snapshots__/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 4153d05f96..bddf0bfd82 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 a2989994a6b9f63eaa25ac7197dc1a468f5cac05 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/10] 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 1e0cfe14de..b6dd0d7139 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 ( @@ -178,7 +195,8 @@ const IconButton = ({ pointerEvents="none" style={[ StyleSheet.absoluteFill, - { backgroundColor, opacity: backgroundOpacity, borderRadius }, + { backgroundColor, opacity: backgroundOpacity }, + shapeStyles, ]} /> )} @@ -189,7 +207,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 0e10c554d8..b41f607b31 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 a8bc540aa9..a177d766ec 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 () => { @@ -84,6 +87,24 @@ it('renders icon button with small border radius', async () => { }); }); +it('clips to a custom corner radius', async () => { + await render( + {}} + style={styles.cutCorner} + /> + ); + + // The container stopped clipping so the touch target can escape it, so the + // touchable has to take the shape itself, corners included. + expect(screen.getByTestId('icon-button')).toHaveStyle({ + borderTopLeftRadius: 0, + }); +}); + describe('getIconButtonColor - icon color', () => { it('should return custom icon color', () => { expect( diff --git a/src/components/__tests__/TouchableRipple.test.tsx b/src/components/__tests__/TouchableRipple.test.tsx index a96c63cd10..b0ae9ea71b 100644 --- a/src/components/__tests__/TouchableRipple.test.tsx +++ b/src/components/__tests__/TouchableRipple.test.tsx @@ -221,6 +221,28 @@ describe('TouchableRipple', () => { expect(hitSlopOf()).toEqual(expanded); }); + it('expands once a caller-supplied hitSlop is taken away', async () => { + const Harness = ({ hitSlop }: { hitSlop?: number }) => ( + {}} + > + Button + + ); + const view = await render(); + + await fireLayout(32, 32); + expect(hitSlopOf()).toBe(2); + + // back to the default, with no second layout event to rely on + await act(async () => { + await view.rerender(); + }); + expect(hitSlopOf()).toEqual({ top: 8, bottom: 8, left: 8, right: 8 }); + }); + it('still calls a caller-supplied onLayout', async () => { const onLayout = jest.fn(); await renderTouchable({ onLayout }); From f912eba41dd73d1d50f3c6b81b5d051b19611c9c Mon Sep 17 00:00:00 2001 From: Konrad Rydygier Date: Mon, 7 Sep 2026 14:55:36 +0200 Subject: [PATCH 03/10] 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 +++++------- .../__snapshots__/ToggleButton.test.tsx.snap | 54 +++-- 12 files changed, 325 insertions(+), 340 deletions(-) diff --git a/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap b/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap index ad5805e1e6..ce34a55020 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 6f255f105a..8b85ed6edf 100644 --- a/src/components/__tests__/__snapshots__/Button.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Button.test.tsx.snap @@ -120,6 +120,7 @@ exports[`renders button with an accessibility hint 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -325,6 +326,7 @@ exports[`renders button with an accessibility label 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -529,6 +531,7 @@ exports[`renders button with button color 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -733,6 +736,7 @@ exports[`renders button with color 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -938,6 +942,7 @@ exports[`renders button with custom testID 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1144,6 +1149,7 @@ exports[`renders button with icon 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1396,6 +1402,7 @@ exports[`renders button with icon in reverse order 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1650,6 +1657,7 @@ exports[`renders contained contained with mode 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1855,6 +1863,7 @@ exports[`renders disabled button 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2059,6 +2068,7 @@ exports[`renders loading button 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2467,6 +2477,7 @@ exports[`renders outlined button with mode 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2672,6 +2683,7 @@ exports[`renders text button by default 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2876,6 +2888,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/10] 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 +++++++++++- .../__snapshots__/ToggleButton.test.tsx.snap | 71 +++- src/utils/getMinInteractiveSizeHitSlop.ts | 40 +++ 46 files changed, 1521 insertions(+), 492 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 c79a60bf96..71c6eafc91 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'; @@ -194,6 +196,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, @@ -319,7 +331,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 4b851de75a..2983a81bf2 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 ada76bd255..89a5c73370 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 ce34a55020..e17dbda7e7 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 8f9fe276a9..13a3c7a86a 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 cb634c9b38..c16ffe2e9e 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 a177d766ec..5d1f90faf1 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(); @@ -94,7 +118,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 ce950f671f..1855bb67e7 100644 --- a/src/components/__tests__/SegmentedButton.test.tsx +++ b/src/components/__tests__/SegmentedButton.test.tsx @@ -512,3 +512,45 @@ describe('labelStyle is handled', () => { }); }); }); + +describe('hitSlop', () => { + it('expands up to the 48dp minimum when enabled', async () => { + await render( + {}} + /> + ); + + // (48 - (2 * 9dp default padding + 18dp icon)) / 2 top/bottom, none + // horizontally + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('walking-button').props.hitSlop).toEqual({ + top: 6, + bottom: 6, + left: 0, + right: 0, + }); + }); + + it('gives a disabled button no hitSlop of its own', async () => { + await render( + {}} + /> + ); + + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('walking-button').props.hitSlop).toBeUndefined(); + }); +}); diff --git a/src/components/__tests__/Switch.test.tsx b/src/components/__tests__/Switch.test.tsx index ceff77a0e6..170ce15dbe 100644 --- a/src/components/__tests__/Switch.test.tsx +++ b/src/components/__tests__/Switch.test.tsx @@ -24,6 +24,26 @@ describe('Switch render', () => { ).toMatchSnapshot(); }); + it('expands hitSlop up to the 48dp minimum when enabled', async () => { + await render(); + + // (48 - 40) / 2 top/bottom, none horizontally: the track is already wider + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('switch').props.hitSlop).toEqual({ + top: 4, + bottom: 4, + left: 0, + right: 0, + }); + }); + + it('gives a disabled switch no hitSlop of its own', async () => { + await render(); + + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('switch').props.hitSlop).toBeUndefined(); + }); + it('renders with checked icon', async () => { expect( (await render()).toJSON() diff --git a/src/components/__tests__/TouchableRipple.test.tsx b/src/components/__tests__/TouchableRipple.test.tsx index b0ae9ea71b..bfbb97dc75 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'; @@ -99,206 +98,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 8b85ed6edf..6f255f105a 100644 --- a/src/components/__tests__/__snapshots__/Button.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Button.test.tsx.snap @@ -120,7 +120,6 @@ exports[`renders button with an accessibility hint 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -326,7 +325,6 @@ exports[`renders button with an accessibility label 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -531,7 +529,6 @@ exports[`renders button with button color 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -736,7 +733,6 @@ exports[`renders button with color 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -942,7 +938,6 @@ exports[`renders button with custom testID 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1149,7 +1144,6 @@ exports[`renders button with icon 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1402,7 +1396,6 @@ exports[`renders button with icon in reverse order 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1657,7 +1650,6 @@ exports[`renders contained contained with mode 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1863,7 +1855,6 @@ exports[`renders disabled button 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2068,7 +2059,6 @@ exports[`renders loading button 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2477,7 +2467,6 @@ exports[`renders outlined button with mode 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2683,7 +2672,6 @@ exports[`renders text button by default 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2888,7 +2876,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 e6272791e9..d05d1426d6 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]} @@ -97,7 +96,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]} @@ -392,8 +390,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, @@ -423,10 +429,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]} @@ -446,7 +459,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", @@ -508,8 +529,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, @@ -539,10 +568,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]} @@ -562,7 +598,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", @@ -682,8 +726,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, @@ -713,10 +765,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]} @@ -736,7 +795,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", @@ -798,8 +865,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, @@ -829,10 +904,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]} @@ -852,7 +934,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", @@ -914,8 +1004,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, @@ -945,10 +1043,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]} @@ -968,7 +1073,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", @@ -1030,8 +1143,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, @@ -1061,10 +1182,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]} @@ -1084,7 +1212,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", @@ -1204,8 +1340,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, @@ -1235,10 +1379,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]} @@ -1258,7 +1409,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", @@ -1320,8 +1479,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, @@ -1351,10 +1518,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]} @@ -1374,7 +1548,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", @@ -1610,7 +1792,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]} @@ -1795,8 +1976,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, @@ -1826,10 +2015,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]} @@ -1849,7 +2045,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", @@ -1911,8 +2115,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, @@ -1942,10 +2154,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]} @@ -1965,7 +2184,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", @@ -2027,8 +2254,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, @@ -2058,10 +2293,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]} @@ -2081,7 +2323,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", @@ -2143,8 +2393,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, @@ -2174,10 +2432,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]} @@ -2197,7 +2462,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 ceb4257935..23b577a775 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]} @@ -301,7 +300,6 @@ exports[`renders FAB.Menu closed 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -610,7 +608,6 @@ exports[`renders FAB.Menu closed 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -905,7 +902,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]} @@ -1083,7 +1079,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]} @@ -1392,7 +1387,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]} @@ -1687,7 +1681,6 @@ exports[`renders FAB.Menu open 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1865,7 +1858,6 @@ exports[`renders FAB.Menu open 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2174,7 +2166,6 @@ exports[`renders FAB.Menu open 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2469,7 +2460,6 @@ exports[`renders FAB.Menu with 6 items 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2647,7 +2637,6 @@ exports[`renders FAB.Menu with 6 items 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2825,7 +2814,6 @@ exports[`renders FAB.Menu with 6 items 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -3003,7 +2991,6 @@ exports[`renders FAB.Menu with 6 items 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -3181,7 +3168,6 @@ exports[`renders FAB.Menu with 6 items 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -3359,7 +3345,6 @@ exports[`renders FAB.Menu with 6 items 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -3668,7 +3653,6 @@ exports[`renders FAB.Menu with 6 items 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -3964,7 +3948,6 @@ exports[`renders FAB.Menu with center alignment 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -4142,7 +4125,6 @@ exports[`renders FAB.Menu with center alignment 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -4451,7 +4433,6 @@ exports[`renders FAB.Menu with center alignment 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -4746,7 +4727,6 @@ exports[`renders FAB.Menu with items having icons 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -4953,7 +4933,6 @@ exports[`renders FAB.Menu with items having icons 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -5291,7 +5270,6 @@ exports[`renders FAB.Menu with items having icons 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -5586,7 +5564,6 @@ exports[`renders FAB.Menu with start alignment 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -5764,7 +5741,6 @@ exports[`renders FAB.Menu with start alignment 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -6073,7 +6049,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 ba5ae19a79..e8042d7973 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", @@ -368,8 +429,16 @@ exports[`renders icon button with size 1`] = ` "width": 46, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 23, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -398,10 +467,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]} @@ -421,7 +497,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", @@ -486,8 +570,16 @@ exports[`renders icon change animated 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -516,10 +608,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]} @@ -539,7 +638,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 20dd4e818d..a0f3b3452d 100644 --- a/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap @@ -25,7 +25,6 @@ exports[`renders list item with custom description 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -235,10 +234,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]} @@ -272,6 +278,7 @@ exports[`renders list item with custom description 1`] = ` "position": "relative", }, { + "minHeight": 32, "paddingLeft": 0, }, { @@ -408,7 +415,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]} @@ -554,7 +560,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]} @@ -745,7 +750,6 @@ exports[`renders list item with left item 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -902,7 +906,6 @@ exports[`renders list item with right item 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1015,7 +1018,6 @@ exports[`renders list item with title and description 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1157,7 +1159,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 01e5e17a33..7e7dcc158c 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]} @@ -631,7 +630,6 @@ exports[`renders list section with custom title style 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1239,7 +1237,6 @@ exports[`renders list section with subheader 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1393,7 +1390,6 @@ exports[`renders list section with subheader 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1961,7 +1957,6 @@ exports[`renders list section without subheader 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2115,7 +2110,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 e36fb69a69..ed9fadb43b 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 23178a7325..02ea78d217 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", @@ -541,8 +564,16 @@ exports[`renders with placeholder 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -572,10 +603,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]} @@ -595,7 +633,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", @@ -705,8 +751,16 @@ exports[`renders with placeholder 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -736,10 +790,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]} @@ -759,7 +820,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", @@ -915,8 +984,16 @@ exports[`renders with text 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -946,10 +1023,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]} @@ -969,7 +1053,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", @@ -1075,8 +1167,16 @@ exports[`renders with text 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -1106,10 +1206,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]} @@ -1129,7 +1236,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 bddf0bfd82..84fefa5c19 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/components/__tests__/__snapshots__/ToggleButton.test.tsx.snap b/src/components/__tests__/__snapshots__/ToggleButton.test.tsx.snap index 7936342952..018760b28d 100644 --- a/src/components/__tests__/__snapshots__/ToggleButton.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/ToggleButton.test.tsx.snap @@ -14,8 +14,16 @@ exports[`renders disabled toggle button 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", - "borderRadius": 4, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, { @@ -57,7 +65,6 @@ exports[`renders disabled toggle button 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -75,7 +82,15 @@ exports[`renders disabled toggle button 1`] = ` "justifyContent": "center", }, { - "borderRadius": 4, + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, }, { "overflow": "hidden", @@ -140,8 +155,16 @@ exports[`renders toggle button 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", - "borderRadius": 4, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, { @@ -180,10 +203,17 @@ exports[`renders toggle button 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]} @@ -201,7 +231,15 @@ exports[`renders toggle button 1`] = ` "justifyContent": "center", }, { - "borderRadius": 4, + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, }, { "overflow": "hidden", @@ -266,8 +304,16 @@ exports[`renders unchecked toggle button 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", - "borderRadius": 4, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, { @@ -309,7 +355,6 @@ exports[`renders unchecked toggle button 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -327,7 +372,15 @@ exports[`renders unchecked toggle button 1`] = ` "justifyContent": "center", }, { - "borderRadius": 4, + "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 59274a7922ed3a68a227aa25a57c0c80f0fc98a8 Mon Sep 17 00:00:00 2001 From: Konrad Rydygier Date: Tue, 8 Sep 2026 10:19:27 +0200 Subject: [PATCH 05/10] 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 71c6eafc91..56e96e4aad 100644 --- a/src/components/Chip/Chip.tsx +++ b/src/components/Chip/Chip.tsx @@ -154,29 +154,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. @@ -206,6 +183,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 f7b7f1885e35d8291a623ed0f03e460916ca3ab4 Mon Sep 17 00:00:00 2001 From: Konrad Rydygier Date: Tue, 8 Sep 2026 10:23:09 +0200 Subject: [PATCH 06/10] 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 56e96e4aad..670009f833 100644 --- a/src/components/Chip/Chip.tsx +++ b/src/components/Chip/Chip.tsx @@ -420,7 +420,7 @@ const Chip = ({ style={styles.closeButton} > {closeIcon ? ( diff --git a/src/components/__tests__/Chip.test.tsx b/src/components/__tests__/Chip.test.tsx index c16ffe2e9e..bc7249120e 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 5d1f90faf1..e14f844bfb 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: Fri, 28 Aug 2026 16:05:36 +0200 Subject: [PATCH 07/10] feat: add MD3 keyboard focus indicators --- src/components/Card/Card.tsx | 9 +- src/components/Checkbox/Checkbox.tsx | 52 +------ 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, 715 insertions(+), 216 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 c1cf7ee19c..7515548ac8 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 = ({ onPress={onPress} onPressIn={handlePressIn} onPressOut={handlePressOut} + 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 7f49338344..a404ca8f5f 100644 --- a/src/components/Checkbox/Checkbox.tsx +++ b/src/components/Checkbox/Checkbox.tsx @@ -3,9 +3,7 @@ import { Platform, StyleSheet, View } from 'react-native'; import type { ColorValue, GestureResponderEvent, - NativeSyntheticEvent, StyleProp, - TargetedEvent, ViewStyle, } from 'react-native'; @@ -16,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`, 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; - // 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,25 +220,13 @@ const Checkbox = ({ borderless centered onPress={onPress} - onFocus={handleFocus} - onBlur={handleBlur} disabled={disabled} {...accessibilityProps} testID={testID} hitSlop={rest.hitSlop ?? (disabled ? undefined : CHECKBOX_HIT_SLOP)} - style={[ - styles.tapTarget, - Platform.OS === 'web' ? webNoOutline : undefined, - style, - ]} + 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; @@ -317,6 +323,7 @@ const Chip = ({ > ({ - 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 6a07babb47..38ac9f0aea 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} - ); }; @@ -391,15 +383,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 740b3df5d2..0c35ee84b3 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 2983a81bf2..d4c4d3b836 100644 --- a/src/components/SegmentedButtons/SegmentedButtonItem.tsx +++ b/src/components/SegmentedButtons/SegmentedButtonItem.tsx @@ -216,6 +216,7 @@ const SegmentedButtonItem = ({ { + if (isDisabled) { + focusedSV.value = 0; + } + }, [isDisabled, focusedSV]); const checkedSV = useSharedValue(checked ? 1 : 0); const hasIconSV = useSharedValue(hasIcon ? 1 : 0); const isDisabledSV = useSharedValue(isDisabled ? 1 : 0); @@ -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 97454244f0..0b6ea9ede5 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; @@ -62,6 +70,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; @@ -84,6 +104,9 @@ const TouchableRipple = ({ children, theme: themeOverrides, hitSlop, + focusRing = 'outward', + onFocus, + onBlur, ref, ...rest }: Props) => { @@ -101,6 +124,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, @@ -131,7 +167,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)} @@ -145,7 +183,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 89a5c73370..44e900f9ef 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'; /** * react-native-web removed `hitSlop` in 0.13.0, so web needs a real element the @@ -72,6 +80,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. */ @@ -149,6 +169,9 @@ const TouchableRipple = ({ children, theme: themeOverrides, hitSlop, + focusRing = 'outward', + onFocus, + onBlur, ref, ...rest }: Props) => { @@ -320,20 +343,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 fe7f4ec3ba..b4f716826c 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, ], ] } @@ -633,7 +630,6 @@ exports[`renders indeterminate Checkbox 1`] = ` "width": 40, }, undefined, - undefined, ], ] } @@ -812,7 +808,6 @@ exports[`renders indeterminate Checkbox with color 1`] = ` "width": 40, }, undefined, - undefined, ], ] } @@ -991,7 +986,6 @@ exports[`renders unchecked Checkbox with color 1`] = ` "width": 40, }, undefined, - undefined, ], ] } @@ -1183,7 +1177,6 @@ exports[`renders unchecked Checkbox with onPress 1`] = ` "width": 40, }, undefined, - undefined, ], ] } diff --git a/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap b/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap index 13a3c7a86a..770e4ef105 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 bfbb97dc75..1feb4c55c9 100644 --- a/src/components/__tests__/TouchableRipple.test.tsx +++ b/src/components/__tests__/TouchableRipple.test.tsx @@ -151,3 +151,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 6025abfb9ebc3e1eb74af9d7e6eeba0c59dc601b Mon Sep 17 00:00:00 2001 From: Konrad Rydygier Date: Mon, 7 Sep 2026 13:41:41 +0200 Subject: [PATCH 08/10] 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 7515548ac8..6b9d164469 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 = ({ onPress={onPress} onPressIn={handlePressIn} onPressOut={handlePressOut} - 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 603666fdfc..38e8a542e1 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'; @@ -243,7 +239,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; @@ -424,18 +427,13 @@ const Chip = ({ disabled={disabled} role="button" aria-label={closeIconAccessibilityLabel} - 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, ]} > @@ -256,21 +261,19 @@ 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 0b6ea9ede5..376098d9a5 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; @@ -78,8 +74,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; @@ -124,18 +120,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({ @@ -169,7 +169,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)} @@ -185,7 +185,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 44e900f9ef..59a26b3836 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'; /** * react-native-web removed `hitSlop` in 0.13.0, so web needs a real element the @@ -88,8 +82,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; /** @@ -170,8 +164,6 @@ const TouchableRipple = ({ theme: themeOverrides, hitSlop, focusRing = 'outward', - onFocus, - onBlur, ref, ...rest }: Props) => { @@ -343,15 +335,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 23b577a775..845723ddae 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, ], ] } @@ -316,7 +315,6 @@ exports[`renders FAB.Menu closed 1`] = ` { "borderRadius": 9999, }, - null, ], ] } @@ -624,7 +622,6 @@ exports[`renders FAB.Menu closed 1`] = ` { "flex": 1, }, - null, ], ] } @@ -918,7 +915,6 @@ exports[`renders FAB.Menu not expanded when trigger is not visible 1`] = ` { "borderRadius": 9999, }, - null, ], ] } @@ -1095,7 +1091,6 @@ exports[`renders FAB.Menu not expanded when trigger is not visible 1`] = ` { "borderRadius": 9999, }, - null, ], ] } @@ -1403,7 +1398,6 @@ exports[`renders FAB.Menu not expanded when trigger is not visible 1`] = ` { "flex": 1, }, - null, ], ] } @@ -1697,7 +1691,6 @@ exports[`renders FAB.Menu open 1`] = ` { "borderRadius": 9999, }, - null, ], ] } @@ -1874,7 +1867,6 @@ exports[`renders FAB.Menu open 1`] = ` { "borderRadius": 9999, }, - null, ], ] } @@ -2182,7 +2174,6 @@ exports[`renders FAB.Menu open 1`] = ` { "flex": 1, }, - null, ], ] } @@ -2476,7 +2467,6 @@ exports[`renders FAB.Menu with 6 items 1`] = ` { "borderRadius": 9999, }, - null, ], ] } @@ -2653,7 +2643,6 @@ exports[`renders FAB.Menu with 6 items 1`] = ` { "borderRadius": 9999, }, - null, ], ] } @@ -2830,7 +2819,6 @@ exports[`renders FAB.Menu with 6 items 1`] = ` { "borderRadius": 9999, }, - null, ], ] } @@ -3007,7 +2995,6 @@ exports[`renders FAB.Menu with 6 items 1`] = ` { "borderRadius": 9999, }, - null, ], ] } @@ -3184,7 +3171,6 @@ exports[`renders FAB.Menu with 6 items 1`] = ` { "borderRadius": 9999, }, - null, ], ] } @@ -3361,7 +3347,6 @@ exports[`renders FAB.Menu with 6 items 1`] = ` { "borderRadius": 9999, }, - null, ], ] } @@ -3669,7 +3654,6 @@ exports[`renders FAB.Menu with 6 items 1`] = ` { "flex": 1, }, - null, ], ] } @@ -3964,7 +3948,6 @@ exports[`renders FAB.Menu with center alignment 1`] = ` { "borderRadius": 9999, }, - null, ], ] } @@ -4141,7 +4124,6 @@ exports[`renders FAB.Menu with center alignment 1`] = ` { "borderRadius": 9999, }, - null, ], ] } @@ -4449,7 +4431,6 @@ exports[`renders FAB.Menu with center alignment 1`] = ` { "flex": 1, }, - null, ], ] } @@ -4743,7 +4724,6 @@ exports[`renders FAB.Menu with items having icons 1`] = ` { "borderRadius": 9999, }, - null, ], ] } @@ -4949,7 +4929,6 @@ exports[`renders FAB.Menu with items having icons 1`] = ` { "borderRadius": 9999, }, - null, ], ] } @@ -5286,7 +5265,6 @@ exports[`renders FAB.Menu with items having icons 1`] = ` { "flex": 1, }, - null, ], ] } @@ -5580,7 +5558,6 @@ exports[`renders FAB.Menu with start alignment 1`] = ` { "borderRadius": 9999, }, - null, ], ] } @@ -5757,7 +5734,6 @@ exports[`renders FAB.Menu with start alignment 1`] = ` { "borderRadius": 9999, }, - null, ], ] } @@ -6065,7 +6041,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 ed146ba0cdb1862cd27cf9dde9ece411ff219f8c Mon Sep 17 00:00:00 2001 From: Konrad Rydygier Date: Mon, 7 Sep 2026 14:24:44 +0200 Subject: [PATCH 09/10] 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 6f255f105a..8b85ed6edf 100644 --- a/src/components/__tests__/__snapshots__/Button.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Button.test.tsx.snap @@ -120,6 +120,7 @@ exports[`renders button with an accessibility hint 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -325,6 +326,7 @@ exports[`renders button with an accessibility label 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -529,6 +531,7 @@ exports[`renders button with button color 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -733,6 +736,7 @@ exports[`renders button with color 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -938,6 +942,7 @@ exports[`renders button with custom testID 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1144,6 +1149,7 @@ exports[`renders button with icon 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1396,6 +1402,7 @@ exports[`renders button with icon in reverse order 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1650,6 +1657,7 @@ exports[`renders contained contained with mode 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1855,6 +1863,7 @@ exports[`renders disabled button 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2059,6 +2068,7 @@ exports[`renders loading button 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2467,6 +2477,7 @@ exports[`renders outlined button with mode 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2672,6 +2683,7 @@ exports[`renders text button by default 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2876,6 +2888,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`] = ` - - - - - - - - @@ -747,29 +701,6 @@ exports[`renders FAB.Menu closed 1`] = ` - @@ -980,29 +911,6 @@ exports[`renders FAB.Menu not expanded when trigger is not visible 1`] = ` - - @@ -1523,29 +1408,6 @@ exports[`renders FAB.Menu not expanded when trigger is not visible 1`] = ` - @@ -1756,29 +1618,6 @@ exports[`renders FAB.Menu open 1`] = ` - - @@ -2299,29 +2115,6 @@ exports[`renders FAB.Menu open 1`] = ` - @@ -2532,29 +2325,6 @@ exports[`renders FAB.Menu with 6 items 1`] = ` - - - - - - @@ -3779,29 +3434,6 @@ exports[`renders FAB.Menu with 6 items 1`] = ` - @@ -4013,29 +3645,6 @@ exports[`renders FAB.Menu with center alignment 1`] = ` - - @@ -4556,29 +4142,6 @@ exports[`renders FAB.Menu with center alignment 1`] = ` - @@ -4818,29 +4381,6 @@ exports[`renders FAB.Menu with items having icons 1`] = ` - - @@ -5390,29 +4907,6 @@ exports[`renders FAB.Menu with items having icons 1`] = ` - @@ -5623,29 +5117,6 @@ exports[`renders FAB.Menu with start alignment 1`] = ` - - @@ -6166,29 +5614,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 a0f3b3452d..4734e97489 100644 --- a/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap @@ -25,6 +25,7 @@ exports[`renders list item with custom description 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -245,6 +246,7 @@ exports[`renders list item with custom description 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -415,6 +417,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]} @@ -560,6 +563,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]} @@ -750,6 +754,7 @@ exports[`renders list item with left item 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -906,6 +911,7 @@ exports[`renders list item with right item 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1018,6 +1024,7 @@ exports[`renders list item with title and description 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1159,6 +1166,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 ed9fadb43b..e36fb69a69 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 e1f7d104e4f471af9c4da17e83f69911c7d65222 Mon Sep 17 00:00:00 2001 From: Konrad Rydygier Date: Wed, 9 Sep 2026 08:37:04 +0200 Subject: [PATCH 10/10] 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 8b85ed6edf..6f255f105a 100644 --- a/src/components/__tests__/__snapshots__/Button.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Button.test.tsx.snap @@ -120,7 +120,6 @@ exports[`renders button with an accessibility hint 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -326,7 +325,6 @@ exports[`renders button with an accessibility label 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -531,7 +529,6 @@ exports[`renders button with button color 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -736,7 +733,6 @@ exports[`renders button with color 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -942,7 +938,6 @@ exports[`renders button with custom testID 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1149,7 +1144,6 @@ exports[`renders button with icon 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1402,7 +1396,6 @@ exports[`renders button with icon in reverse order 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1657,7 +1650,6 @@ exports[`renders contained contained with mode 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1863,7 +1855,6 @@ exports[`renders disabled button 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2068,7 +2059,6 @@ exports[`renders loading button 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2477,7 +2467,6 @@ exports[`renders outlined button with mode 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2683,7 +2672,6 @@ exports[`renders text button by default 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} - onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2888,7 +2876,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 cb49e687a2..e9d20a714d 100644 --- a/src/components/__tests__/__snapshots__/Chip.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Chip.test.tsx.snap @@ -311,11 +311,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, + }, + ] } >