diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index e51de006aa..f10d692402 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -209,6 +209,18 @@ The misspelled `traileringIcon` props have been renamed: /> ``` +### Chip + +The close button (`onClose`) now fills the trailing 34dp column reserved for it, instead of only the 26x18 area around its icon, and its touch target extends to 48dp in height. Taps in that column that used to activate the chip's `onPress` now activate `onClose`. + +### IconButton + +Corner radii set through `style` no longer shape the press, hover and disabled feedback. + +### TouchableRipple + +`borderless` has no effect on web. The web ripple is always clipped to the touchable's shape, including when `centered` is set, and the touchable no longer clips its own content. A child that needs a clipped or rounded shape should carry that shape itself. + ### TextInput The Paper 6.x `TextInput` is a complete rewrite with a new API. Import the component the same way, but note that the props and behavior have changed significantly. diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx index 3bccccf33b..2d63c249e0 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`, keeping the ring inside the +// 40dp circle: whether TouchableRipple clips content past that boundary +// depends on platform and ripple mode, so staying inside it avoids relying +// on any of that. const FOCUS_RING_SIZE = STATE_LAYER_SIZE; const FOCUS_RING_RADIUS = STATE_LAYER_SIZE / 2; diff --git a/src/components/Chip/Chip.tsx b/src/components/Chip/Chip.tsx index 4507561a48..732cf7da42 100644 --- a/src/components/Chip/Chip.tsx +++ b/src/components/Chip/Chip.tsx @@ -3,21 +3,28 @@ import { Platform, StyleSheet, Pressable, View } from 'react-native'; import type { ColorValue, GestureResponderEvent, + NativeSyntheticEvent, PressableAndroidRippleConfig, StyleProp, + TargetedEvent, TextProps, TextStyle, ViewProps, + ViewStyle, } from 'react-native'; 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 { tokens } from '../../theme/tokens'; import type { ThemeProp } from '../../theme/types'; +import getMinInteractiveSizeHitSlop from '../../utils/getMinInteractiveSizeHitSlop'; import hasTouchHandler from '../../utils/hasTouchHandler'; +import { isKeyboardFocusEvent } from '../../utils/isKeyboardFocusEvent'; import type { IconSource } from '../Icon'; import Icon from '../Icon'; import MaterialCommunityIcon from '../MaterialCommunityIcon'; @@ -156,6 +163,38 @@ export type Props = Omit & { ref?: React.Ref; }; +/** + * Width reserved on the right for the close button. + */ +const CLOSE_AFFORDANCE_WIDTH = 34; + +/** + * Floor for the clamp below. The 18dp glyph is centered in the column, so at + * this width it sits 4dp from each side. Any narrower and part of the visible + * icon would activate the chip instead of removing it. + */ +const CLOSE_AFFORDANCE_MIN_WIDTH = 26; + +/** + * The container height is fixed by spec, so the slop to reach the 48dp minimum + * is a constant rather than something to measure. Width grows with the label + * and the whole pill is already the target, so only the vertical axis needs it. + */ +const { containerHeight: CHIP_BODY_HEIGHT } = ChipTokens; +const CHIP_BODY_HIT_SLOP = getMinInteractiveSizeHitSlop({ + height: CHIP_BODY_HEIGHT, +}); +// The close button's own box is the same fixed height as the body, so it +// needs the same vertical slop to reach 48dp. +const CLOSE_BUTTON_WEB_TOUCH_TARGET_INSET = CHIP_BODY_HIT_SLOP?.top ?? 0; + +const CLOSE_ICON_SIZE = 18; +const CLOSE_FOCUS_THICKNESS = tokens.md.sys.state.focusIndicator.thickness; + +// Web-only style; `outline` is outside ViewStyle. +// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion +const webNoOutline = { outline: 'none' } as unknown as ViewStyle; + /** * 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. @@ -214,6 +253,7 @@ const Chip = ({ const theme = useInternalTheme(themeOverrides); const [pressed, setPressed] = React.useState(false); + const [closeFocused, setCloseFocused] = React.useState(false); const elevation = elevated ? (pressed ? 2 : 1) : 0; const hasPassedTouchHandler = hasTouchHandler({ @@ -235,6 +275,18 @@ const Chip = ({ setPressed(false); }); + const handleCloseFocus = useLatestCallback( + (e: NativeSyntheticEvent) => { + if (Platform.OS === 'web' && isKeyboardFocusEvent(e)) { + setCloseFocused(true); + } + } + ); + + const handleCloseBlur = useLatestCallback(() => { + setCloseFocused(false); + }); + const elevationTransitionDuration = theme.motion.duration[pressed ? 'short4' : 'short3'] * theme.animation.scale; @@ -270,7 +322,7 @@ const Chip = ({ }; const contentSpacings = { - paddingRight: onClose ? 34 : 0, + paddingRight: onClose ? CLOSE_AFFORDANCE_WIDTH : 0, }; const labelTextStyle = { @@ -304,7 +356,13 @@ const Chip = ({ aria-disabled={disabled} testID={testID} theme={theme} - hitSlop={hitSlop} + hitSlop={ + hitSlop !== undefined + ? hitSlop + : disabled + ? undefined + : CHIP_BODY_HIT_SLOP + } > + {/* react-native-web removed `hitSlop` in 0.13.0, + so web needs a real element the browser can + hit-test instead of a native responder inset. */} + {Platform.OS === 'web' && !disabled && ( + + )} + {closeFocused && !disabled ? ( + + ) : null} {closeIcon ? ( ) : ( @@ -428,6 +510,7 @@ const styles = StyleSheet.create({ }, md3Content: { paddingLeft: 0, + minHeight: CHIP_BODY_HEIGHT, }, icon: { padding: 4, @@ -441,7 +524,7 @@ const styles = StyleSheet.create({ marginRight: 4, }, md3CloseIcon: { - marginRight: 8, + marginRight: 0, padding: 0, }, md3LabelText: { @@ -473,9 +556,34 @@ 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%', justifyContent: 'center', - alignItems: 'center', + }, + closeFocusRing: { + position: 'absolute', + top: -CLOSE_FOCUS_THICKNESS, + left: -CLOSE_FOCUS_THICKNESS, + width: CLOSE_ICON_SIZE + 2 * CLOSE_FOCUS_THICKNESS, + height: CLOSE_ICON_SIZE + 2 * CLOSE_FOCUS_THICKNESS, + borderRadius: CLOSE_ICON_SIZE / 2 + CLOSE_FOCUS_THICKNESS, + borderWidth: CLOSE_FOCUS_THICKNESS, + }, + closeButtonWebTouchTarget: { + position: 'absolute', + top: -CLOSE_BUTTON_WEB_TOUCH_TARGET_INSET, + bottom: -CLOSE_BUTTON_WEB_TOUCH_TARGET_INSET, + left: 0, + right: 0, }, touchable: { width: '100%', diff --git a/src/components/Chip/tokens.ts b/src/components/Chip/tokens.ts new file mode 100644 index 0000000000..772ef9d0e0 --- /dev/null +++ b/src/components/Chip/tokens.ts @@ -0,0 +1,7 @@ +/** + * MD3 Chip spec dimensions. + * @see https://m3.material.io/components/chips/specs + */ +export const ChipTokens = { + containerHeight: 32, +} as const; diff --git a/src/components/IconButton/IconButton.tsx b/src/components/IconButton/IconButton.tsx index 1d673a3d27..fc2c4b2189 100644 --- a/src/components/IconButton/IconButton.tsx +++ b/src/components/IconButton/IconButton.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { StyleSheet, View } from 'react-native'; +import { Platform, StyleSheet, View } from 'react-native'; import type { ColorValue, GestureResponderEvent, @@ -12,6 +12,7 @@ import Animated, { type AnimatedStyle } from 'react-native-reanimated'; import { getIconButtonColor } from './utils'; import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../theme/types'; +import getMinInteractiveSizeHitSlop from '../../utils/getMinInteractiveSizeHitSlop'; import ActivityIndicator from '../ActivityIndicator'; import CrossFadeIcon from '../CrossFadeIcon'; import Icon from '../Icon'; @@ -151,13 +152,25 @@ const IconButton = ({ }); const buttonSize = size + 2 * PADDING; + const borderWidth = mode === 'outlined' && !selected ? 1 : 0; + + const shapeStyle = { borderRadius: buttonSize / 2 }; const borderStyles = { - borderWidth: mode === 'outlined' && !selected ? 1 : 0, - borderRadius: buttonSize / 2, + borderWidth, borderColor, + ...shapeStyle, }; + const touchableSize = buttonSize - 2 * borderWidth; + + const hitSlop = disabled + ? undefined + : getMinInteractiveSizeHitSlop({ + width: touchableSize, + height: touchableSize, + }); + return ( )} @@ -186,16 +200,19 @@ const IconButton = ({ centered onPress={onPress} aria-label={ariaLabel} - style={[styles.touchable, contentStyle]} + style={[ + styles.touchable, + shapeStyle, + // Clip the ripple to the button shape. Not on web, where + // `overflow: 'hidden'` would also clip the hit slop target. + Platform.OS !== 'web' && styles.clipToShape, + contentStyle, + ]} role="button" aria-disabled={disabled} disabled={disabled} - hitSlop={ - TouchableRipple.supported - ? { top: 10, left: 10, bottom: 10, right: 10 } - : { top: 6, left: 6, bottom: 6, right: 6 } - } testID={testID} + hitSlop={hitSlop} {...rest} > @@ -213,13 +230,15 @@ const IconButton = ({ const styles = StyleSheet.create({ container: { margin: 6, - overflow: 'hidden', }, touchable: { flexGrow: 1, justifyContent: 'center', alignItems: 'center', }, + clipToShape: { + overflow: 'hidden', + }, }); export default IconButton; diff --git a/src/components/SegmentedButtons/SegmentedButtonItem.tsx b/src/components/SegmentedButtons/SegmentedButtonItem.tsx index 3b9962a457..653ca6cd02 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'; @@ -192,6 +193,10 @@ const SegmentedButtonItem = ({ }; const paddingVertical = getSegmentedButtonDensityPadding({ density }); + const contentHeight = 2 * paddingVertical + iconSize; + const defaultHitSlop = disabled + ? undefined + : getMinInteractiveSizeHitSlop({ height: contentHeight }); const rippleStyle: ViewStyle = { borderRadius, @@ -217,7 +222,7 @@ const SegmentedButtonItem = ({ style={rippleStyle} background={background} theme={theme} - hitSlop={hitSlop} + hitSlop={hitSlop !== undefined ? hitSlop : defaultHitSlop} > ; multiline: boolean; @@ -82,6 +90,7 @@ const TextInputIcon = ({ return ( { @@ -178,7 +185,7 @@ const TouchableRipple = ({ borderTopRightRadius: style.borderTopRightRadius, borderBottomRightRadius: style.borderBottomRightRadius, borderBottomLeftRadius: style.borderBottomLeftRadius, - overflow: centered ? 'visible' : 'hidden', + overflow: 'hidden', }); // Create span to show the ripple effect @@ -282,7 +289,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 +296,37 @@ const TouchableRipple = ({ typeof style === 'function' ? style(state) : style, ]} > - {(state) => - React.Children.only( - typeof children === 'function' ? children(state) : children - ) - } + {(state) => { + const touchTargetStyle: ViewStyle | undefined = + hitSlop == null + ? undefined + : typeof hitSlop === 'number' + ? { + position: 'absolute', + top: -hitSlop, + bottom: -hitSlop, + left: -hitSlop, + right: -hitSlop, + } + : { + position: 'absolute', + top: -(hitSlop.top ?? 0), + bottom: -(hitSlop.bottom ?? 0), + left: -(hitSlop.left ?? 0), + right: -(hitSlop.right ?? 0), + }; + + return ( + <> + {!disabled && touchTargetStyle && ( + + )} + {React.Children.only( + typeof children === 'function' ? children(state) : children + )} + + ); + }} ); }; @@ -317,9 +349,6 @@ const styles = StyleSheet.create({ cursor: 'auto', }), }, - borderless: { - overflow: 'hidden', - }, }); 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 faeb89916b..5304d51a25 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, @@ -235,10 +234,10 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } onBlur={[Function]} @@ -262,6 +261,12 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderRadius": 20, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -360,7 +365,6 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -401,10 +405,10 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } onBlur={[Function]} @@ -428,6 +432,12 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderRadius": 20, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -582,7 +592,6 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -623,10 +632,10 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } onBlur={[Function]} @@ -650,6 +659,12 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A "flexGrow": 1, "justifyContent": "center", }, + { + "borderRadius": 20, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -805,7 +820,6 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -845,10 +859,10 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } onBlur={[Function]} @@ -872,6 +886,12 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A "flexGrow": 1, "justifyContent": "center", }, + { + "borderRadius": 20, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -1062,7 +1082,6 @@ exports[`AppbarAction should be rendered with custom color 1`] = ` [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -1102,10 +1121,10 @@ exports[`AppbarAction should be rendered with custom color 1`] = ` focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } onBlur={[Function]} @@ -1129,6 +1148,12 @@ exports[`AppbarAction should be rendered with custom color 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderRadius": 20, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -1320,7 +1345,6 @@ exports[`AppbarAction should be rendered with default theme color 1`] = ` [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -1360,10 +1384,10 @@ exports[`AppbarAction should be rendered with default theme color 1`] = ` focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } onBlur={[Function]} @@ -1387,6 +1411,12 @@ exports[`AppbarAction should be rendered with default theme color 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderRadius": 20, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -1578,7 +1608,6 @@ exports[`AppbarAction should be rendered with specific theme color if is leading [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -1618,10 +1647,10 @@ exports[`AppbarAction should be rendered with specific theme color if is leading focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } onBlur={[Function]} @@ -1645,6 +1674,12 @@ exports[`AppbarAction should be rendered with specific theme color if is leading "flexGrow": 1, "justifyContent": "center", }, + { + "borderRadius": 20, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -1836,7 +1871,6 @@ exports[`AppbarAction should render AppbarBackAction with custom color 1`] = ` [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -1877,10 +1911,10 @@ exports[`AppbarAction should render AppbarBackAction with custom color 1`] = ` focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } onBlur={[Function]} @@ -1904,6 +1938,12 @@ exports[`AppbarAction should render AppbarBackAction with custom color 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderRadius": 20, + }, + { + "overflow": "hidden", + }, undefined, ], ] diff --git a/src/components/__tests__/Chip.test.tsx b/src/components/__tests__/Chip.test.tsx index a7d522e4f0..224ce1a30b 100644 --- a/src/components/__tests__/Chip.test.tsx +++ b/src/components/__tests__/Chip.test.tsx @@ -309,3 +309,11 @@ describe('getChipColor - border color', () => { }); }); }); + +describe('close affordance', () => { + it('is not rendered without onClose', async () => { + await render( {}}>Example); + + expect(screen.queryByLabelText('Close')).not.toBeOnTheScreen(); + }); +}); diff --git a/src/components/__tests__/IconButton.test.tsx b/src/components/__tests__/IconButton.test.tsx index 914f1e2c55..2f84d69db5 100644 --- a/src/components/__tests__/IconButton.test.tsx +++ b/src/components/__tests__/IconButton.test.tsx @@ -46,6 +46,14 @@ it('renders disabled icon button', async () => { expect(tree).toMatchSnapshot(); }); +it('lets a caller-supplied hitSlop win even while disabled', async () => { + const tree = ( + await render() + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + it('renders icon change animated', async () => { const tree = (await render()).toJSON(); diff --git a/src/components/__tests__/TouchableRipple.test.tsx b/src/components/__tests__/TouchableRipple.test.tsx index 663d540bec..9fe8821e81 100644 --- a/src/components/__tests__/TouchableRipple.test.tsx +++ b/src/components/__tests__/TouchableRipple.test.tsx @@ -67,4 +67,33 @@ describe('TouchableRipple', () => { expect(toJSON()).toMatchSnapshot(); }); }); + + describe('hitSlop', () => { + it('does not add its own hitSlop when none is supplied', async () => { + const tree = ( + await render( + {}}> + Button + + ) + ).toJSON(); + + expect(tree).toMatchSnapshot(); + }); + + it('passes a caller-supplied hitSlop straight through', async () => { + const tree = ( + await render( + {}} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + > + Button + + ) + ).toJSON(); + + expect(tree).toMatchSnapshot(); + }); + }); }); diff --git a/src/components/__tests__/TouchableRippleWeb.test.tsx b/src/components/__tests__/TouchableRippleWeb.test.tsx new file mode 100644 index 0000000000..a947e4aef3 --- /dev/null +++ b/src/components/__tests__/TouchableRippleWeb.test.tsx @@ -0,0 +1,66 @@ +import { Text } from 'react-native'; + +import { describe, expect, it } from '@jest/globals'; + +import { render } 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. +// +// This renders the web source on the native renderer, so it only pins the +// rendered tree. Hit testing and clipping have to be checked in a browser. +const TouchableRipple: typeof TouchableRippleType = + require('../TouchableRipple/TouchableRipple.tsx').default; + +describe('TouchableRipple (web)', () => { + it.each([ + { when: 'there is no hitSlop', props: { onPress: () => {} } }, + { when: 'there are no touch handlers', props: { hitSlop: 4 } }, + { + when: 'it is disabled', + props: { hitSlop: 4, onPress: () => {}, disabled: true }, + }, + ])('renders no touch target when $when', async ({ props }) => { + const tree = ( + await render( + + Button + + ) + ).toJSON(); + + expect(tree).toMatchSnapshot(); + }); + + it.each([ + { name: 'a numeric hitSlop', hitSlop: 6 }, + { name: 'a per-edge hitSlop', hitSlop: { top: 4, left: 8 } }, + ])( + 'renders a touch target behind the children sized from $name', + async ({ hitSlop }) => { + const tree = ( + await render( + {}}> + Button + + ) + ).toJSON(); + + expect(tree).toMatchSnapshot(); + } + ); + + it('does not clip its content when borderless', async () => { + const tree = ( + await render( + {}}> + Button + + ) + ).toJSON(); + + expect(tree).toMatchSnapshot(); + }); +}); diff --git a/src/components/__tests__/__snapshots__/Chip.test.tsx.snap b/src/components/__tests__/__snapshots__/Chip.test.tsx.snap index 04687e301f..5991406d03 100644 --- a/src/components/__tests__/__snapshots__/Chip.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Chip.test.tsx.snap @@ -118,6 +118,14 @@ exports[`renders chip with close button 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 8, + "left": 0, + "right": 0, + "top": 8, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -154,6 +162,7 @@ exports[`renders chip with close button 1`] = ` "position": "relative", }, { + "minHeight": 32, "paddingLeft": 0, }, { @@ -260,11 +269,12 @@ exports[`renders chip with close button 1`] = ` @@ -290,6 +300,14 @@ exports[`renders chip with close button 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 8, + "left": 0, + "right": 0, + "top": 8, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -300,6 +318,16 @@ exports[`renders chip with close button 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} role="button" + style={ + [ + { + "height": "100%", + "justifyContent": "center", + "width": "100%", + }, + undefined, + ] + } > @@ -643,6 +681,14 @@ exports[`renders chip with custom close button 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 8, + "left": 0, + "right": 0, + "top": 8, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -653,6 +699,16 @@ exports[`renders chip with custom close button 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} role="button" + style={ + [ + { + "height": "100%", + "justifyContent": "center", + "width": "100%", + }, + undefined, + ] + } > + + + camera + + + + +`; + +exports[`renders disabled icon button 1`] = ` + + + + Button + + +`; + +exports[`TouchableRipple hitSlop passes a caller-supplied hitSlop straight through 1`] = ` + + + Button + + +`; + exports[`TouchableRipple on iOS displays the underlay when pressed 1`] = ` + + Button + + +`; + +exports[`TouchableRipple (web) renders a touch target behind the children sized from a numeric hitSlop 1`] = ` + + + + Button + + +`; + +exports[`TouchableRipple (web) renders a touch target behind the children sized from a per-edge hitSlop 1`] = ` + + + + Button + + +`; + +exports[`TouchableRipple (web) renders no touch target when it is disabled 1`] = ` + + + Button + + +`; + +exports[`TouchableRipple (web) renders no touch target when there are no touch handlers 1`] = ` + + + Button + + +`; + +exports[`TouchableRipple (web) renders no touch target when there is no hitSlop 1`] = ` + + + Button + + +`; diff --git a/src/utils/getMinInteractiveSizeHitSlop.ts b/src/utils/getMinInteractiveSizeHitSlop.ts new file mode 100644 index 0000000000..cb6a017432 --- /dev/null +++ b/src/utils/getMinInteractiveSizeHitSlop.ts @@ -0,0 +1,40 @@ +import type { Insets } from 'react-native'; + +/** + * Minimum size of an interactive target. + * @see https://m3.material.io/foundations/designing/structure + */ +const MIN_INTERACTIVE_SIZE = 48; + +/** + * Hit slop needed to bring a fixed-size element up to the 48dp minimum + * interactive target, expanding outward rather than resizing. 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, (MIN_INTERACTIVE_SIZE - width) / 2); + const vertical = + height === undefined ? 0 : Math.max(0, (MIN_INTERACTIVE_SIZE - height) / 2); + + if (horizontal === 0 && vertical === 0) { + return undefined; + } + + return { + top: vertical, + bottom: vertical, + left: horizontal, + right: horizontal, + }; +}; + +export default getMinInteractiveSizeHitSlop;