diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md
index 35a8f74830..8680ed79f3 100644
--- a/docs/6.x/docs/guides/migration.md
+++ b/docs/6.x/docs/guides/migration.md
@@ -188,6 +188,38 @@ e.g.:
- The default elevation changed from level `1` to level `3`.
- The `style` prop no longer configures the background color or border radius. You can override `theme.colors.surfaceContainerHigh` and `theme.shapes.corner.extraLarge` using the `theme` prop instead.
+### Chip
+
+The close button (`onClose`) now fills the entire trailing 34dp column reserved for it, matching Material Design 3's touch target guidance, instead of only its 24x18 icon. Taps near the top or bottom of that column, which used to fall through to the chip's own `onPress`, now activate `onClose` instead.
+
+### TouchableRipple
+
+- `borderless` no longer clips the touchable's own content on web; it only clips the ripple itself, in its own container. A child that needs a clipped or rounded shape should carry that shape itself.
+- Corner radius and border width set through `style` no longer shape the highlight underlay (native) or the ripple's self-clipping container (web). Pass them as dedicated props instead:
+ - `borderRadius`
+ - `borderTopLeftRadius`
+ - `borderTopRightRadius`
+ - `borderBottomLeftRadius`
+ - `borderBottomRightRadius`
+ - `borderTopStartRadius`
+ - `borderTopEndRadius`
+ - `borderBottomStartRadius`
+ - `borderBottomEndRadius`
+ - `borderWidth` (web only)
+
+e.g.:
+
+```diff
+ {}}
+>
+ Content
+
+```
+
### TextInput
The Paper 6.x `TextInput` is a complete rewrite with a new API. Import the component the same way, but note that the props and behavior have changed significantly.
diff --git a/eslint.config.mjs b/eslint.config.mjs
index ac6e65951f..776d90544b 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -221,6 +221,7 @@ export default defineConfig(
'src/components/__tests__/Appbar/Appbar.test.tsx',
'src/components/__tests__/Dialog.test.tsx',
'src/components/__tests__/Searchbar.test.tsx',
+ 'src/components/__tests__/TouchableRippleWeb.test.tsx',
],
rules: {
'testing-library/no-node-access': 'off',
diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx
index 82b327dc74..36ec075c39 100644
--- a/src/components/Button/Button.tsx
+++ b/src/components/Button/Button.tsx
@@ -262,6 +262,10 @@ const Button = ({
});
const touchableStyle = { borderRadius };
+ const touchableRippleStyle = getButtonTouchableRippleStyle(
+ touchableStyle,
+ borderWidth
+ );
const { color: customLabelColor, fontSize: customLabelSize } =
StyleSheet.flatten(labelStyle) || {};
@@ -334,7 +338,8 @@ const Button = ({
accessible={accessible}
hitSlop={hitSlop}
disabled={disabled}
- style={getButtonTouchableRippleStyle(touchableStyle, borderWidth)}
+ style={touchableRippleStyle}
+ {...touchableRippleStyle}
testID={testID}
theme={theme}
ref={touchableRef}
diff --git a/src/components/Button/utils.tsx b/src/components/Button/utils.tsx
index ec3503c82b..bbebfd7791 100644
--- a/src/components/Button/utils.tsx
+++ b/src/components/Button/utils.tsx
@@ -4,6 +4,7 @@ import { black, white } from '../../theme/colors';
import { tokens } from '../../theme/tokens';
import type { InternalTheme } from '../../theme/types';
import { splitStyles } from '../../utils/splitStyles';
+import type { BorderRadiusStyle } from '../TouchableRipple/utils';
const stateOpacity = tokens.md.sys.state.opacity;
@@ -191,20 +192,7 @@ export const getButtonColors = ({
};
};
-type ViewStyleBorderRadiusStyles = Partial<
- Pick<
- ViewStyle,
- | 'borderBottomEndRadius'
- | 'borderBottomLeftRadius'
- | 'borderBottomRightRadius'
- | 'borderBottomStartRadius'
- | 'borderTopEndRadius'
- | 'borderTopLeftRadius'
- | 'borderTopRightRadius'
- | 'borderTopStartRadius'
- | 'borderRadius'
- >
->;
+type ViewStyleBorderRadiusStyles = Partial;
export const getButtonTouchableRippleStyle = (
style?: ViewStyle,
borderWidth: number = 0
diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx
index 3bccccf33b..bdb3c9dbf2 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';
@@ -78,12 +79,20 @@ 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;
+// 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.
*
@@ -243,6 +252,14 @@ const Checkbox = ({
disabled={disabled}
{...accessibilityProps}
testID={testID}
+ hitSlop={
+ rest.hitSlop !== undefined
+ ? rest.hitSlop
+ : disabled
+ ? undefined
+ : CHECKBOX_HIT_SLOP
+ }
+ borderRadius={FOCUS_RING_RADIUS}
style={[
styles.tapTarget,
Platform.OS === 'web' ? webNoOutline : undefined,
diff --git a/src/components/Chip/Chip.tsx b/src/components/Chip/Chip.tsx
index 5a015fcf8d..df6e2ebd0f 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';
@@ -156,6 +158,37 @@ export type Props = Omit & {
ref?: React.Ref;
};
+/**
+ * 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.
+ *
+ * Matches material-web's own remove button, which expands to a 48px touch
+ * target the same way; the 24x24 dimensions in its `_trailing-icon.scss` are
+ * for the ripple and focus ring, not the touch target.
+ * @see https://github.com/material-components/material-web/blob/main/chips/internal/_shared.scss
+ */
+const CLOSE_AFFORDANCE_WIDTH = 34;
+
+/**
+ * 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;
+
+/**
+ * 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;
+
/**
* 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.
@@ -270,7 +303,7 @@ const Chip = ({
};
const contentSpacings = {
- paddingRight: onClose ? 34 : 0,
+ paddingRight: onClose ? CLOSE_AFFORDANCE_WIDTH : 0,
};
const labelTextStyle = {
@@ -292,6 +325,7 @@ const Chip = ({
borderless
background={background}
style={[{ borderRadius }, styles.touchable]}
+ borderRadius={borderRadius}
onPress={onPress}
onLongPress={onLongPress}
onPressIn={hasPassedTouchHandler ? handlePressIn : undefined}
@@ -304,7 +338,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 && (
+
+ )}
+
{closeIcon ? (
) : (
@@ -428,6 +479,7 @@ const styles = StyleSheet.create({
},
md3Content: {
paddingLeft: 0,
+ minHeight: CHIP_BODY_HEIGHT,
},
icon: {
padding: 4,
@@ -443,6 +495,10 @@ const styles = StyleSheet.create({
md3CloseIcon: {
marginRight: 8,
padding: 0,
+ // `styles.icon` sets `alignSelf: 'center'`, which beats `alignItems` on the
+ // parent. Without this the glyph centres in the wider column and moves 4dp
+ // left.
+ alignSelf: 'flex-end',
},
md3LabelText: {
textAlignVertical: 'center',
@@ -473,9 +529,27 @@ 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',
+ ...(Platform.OS === 'web' && { position: 'relative' }),
+ },
+ 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/Drawer/DrawerItem.tsx b/src/components/Drawer/DrawerItem.tsx
index dda753186b..9249fddec6 100644
--- a/src/components/Drawer/DrawerItem.tsx
+++ b/src/components/Drawer/DrawerItem.tsx
@@ -129,6 +129,7 @@ const DrawerItem = ({
{ backgroundColor, borderRadius },
style,
]}
+ borderRadius={borderRadius}
role="button"
aria-selected={active}
aria-label={ariaLabel}
diff --git a/src/components/FAB/Menu.tsx b/src/components/FAB/Menu.tsx
index e4884647d6..68762494c7 100644
--- a/src/components/FAB/Menu.tsx
+++ b/src/components/FAB/Menu.tsx
@@ -273,6 +273,7 @@ const MenuItem = ({
{ borderRadius },
Platform.OS === 'web' ? webNoOutline : null,
]}
+ borderRadius={borderRadius}
testID={testID}
>
;
/**
* Function to execute on press.
*/
onPress?: (e: GestureResponderEvent) => void;
+ /**
+ * Width of the button's inner content. Defaults to the button's size.
+ */
+ width?: number;
+ /**
+ * Height of the button's inner content. Defaults to the button's size.
+ */
+ height?: number;
+ /**
+ * Radius of every corner of the button. Defaults to a circle (half of the
+ * button's size).
+ */
+ borderRadius?: number;
+ borderTopLeftRadius?: number;
+ borderTopRightRadius?: number;
+ borderBottomLeftRadius?: number;
+ borderBottomRightRadius?: number;
+ borderTopStartRadius?: number;
+ borderTopEndRadius?: number;
+ borderBottomStartRadius?: number;
+ borderBottomEndRadius?: number;
style?: StyleProp>;
ref?: React.Ref;
/**
@@ -128,6 +151,17 @@ const IconButton = ({
testID,
loading = false,
contentStyle,
+ width,
+ height,
+ borderRadius,
+ borderTopLeftRadius,
+ borderTopRightRadius,
+ borderBottomLeftRadius,
+ borderBottomRightRadius,
+ borderTopStartRadius,
+ borderTopEndRadius,
+ borderBottomStartRadius,
+ borderBottomEndRadius,
ref,
...rest
}: Props) => {
@@ -151,13 +185,36 @@ const IconButton = ({
});
const buttonSize = size + 2 * PADDING;
+ const borderWidth = mode === 'outlined' && !selected ? 1 : 0;
+
+ const shapeStyles = {
+ borderRadius: borderRadius ?? buttonSize / 2,
+ borderTopLeftRadius,
+ borderTopRightRadius,
+ borderBottomLeftRadius,
+ borderBottomRightRadius,
+ borderTopStartRadius,
+ borderTopEndRadius,
+ borderBottomStartRadius,
+ borderBottomEndRadius,
+ };
const borderStyles = {
- borderWidth: mode === 'outlined' && !selected ? 1 : 0,
- borderRadius: buttonSize / 2,
+ borderWidth,
borderColor,
+ ...shapeStyles,
};
+ const touchableWidth = width ?? buttonSize - 2 * borderWidth;
+ const touchableHeight = height ?? buttonSize - 2 * borderWidth;
+
+ const hitSlop = disabled
+ ? undefined
+ : getMinInteractiveSizeHitSlop({
+ width: touchableWidth,
+ height: touchableHeight,
+ });
+
return (
)}
@@ -186,16 +244,22 @@ const IconButton = ({
centered
onPress={onPress}
aria-label={ariaLabel}
- style={[styles.touchable, contentStyle]}
+ style={[
+ styles.touchable,
+ shapeStyles,
+ // The Surface used to clip the ripple, so the touchable does it now.
+ // Native only: its own overflow does not clip its hitSlop, but on web
+ // it would clip the touch target, where the container already clips.
+ Platform.OS !== 'web' && styles.clipToShape,
+ { width, height },
+ contentStyle,
+ ]}
+ {...shapeStyles}
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}
>
@@ -212,14 +276,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/RadioButton/RadioButtonAndroid.tsx b/src/components/RadioButton/RadioButtonAndroid.tsx
index 91520db69c..80007d9c7f 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,14 @@ const RadioButtonAndroid = ({
style={styles.container}
testID={testID}
theme={theme}
+ hitSlop={
+ rest.hitSlop !== undefined
+ ? rest.hitSlop
+ : disabled
+ ? undefined
+ : RADIO_BUTTON_HIT_SLOP
+ }
+ borderRadius={STATE_LAYER_SIZE / 2}
>
,
'children'
@@ -104,12 +116,20 @@ const RadioButtonIOS = ({
style={styles.container}
testID={testID}
theme={theme}
+ hitSlop={
+ rest.hitSlop !== undefined
+ ? rest.hitSlop
+ : disabled
+ ? undefined
+ : RADIO_BUTTON_HIT_SLOP
+ }
+ borderRadius={STATE_LAYER_SIZE / 2}
>
@@ -125,8 +145,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 33886d7ec1..afbf1166c4 100644
--- a/src/components/SegmentedButtons/SegmentedButtonItem.tsx
+++ b/src/components/SegmentedButtons/SegmentedButtonItem.tsx
@@ -20,9 +20,11 @@ import {
getSegmentedButtonColors,
getSegmentedButtonDensityPadding,
} from './utils';
+import type { SegmentBorderRadiusStyle } from './utils';
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';
@@ -172,6 +174,7 @@ const SegmentedButtonItem = ({
const segmentBorderRadius = getSegmentedButtonBorderRadius({
theme,
segment,
+ borderRadius,
});
const showIcon = !icon ? false : label && checked ? !showSelectedCheck : true;
@@ -187,16 +190,19 @@ const SegmentedButtonItem = ({
backgroundColor,
borderColor,
borderWidth,
- borderRadius,
...segmentBorderRadius,
};
const paddingVertical = getSegmentedButtonDensityPadding({ density });
+ const contentHeight = 2 * paddingVertical + iconSize;
+ const defaultHitSlop = disabled
+ ? undefined
+ : getMinInteractiveSizeHitSlop({ height: contentHeight });
- const rippleStyle: ViewStyle = {
- borderRadius,
+ const rippleStyle: SegmentBorderRadiusStyle = {
...segmentBorderRadius,
};
+ const { borderEndWidth: _borderEndWidth, ...touchableShape } = rippleStyle;
const labelTextStyle: TextStyle = {
...theme.fonts.labelLarge,
@@ -215,9 +221,10 @@ const SegmentedButtonItem = ({
disabled={disabled}
testID={testID}
style={rippleStyle}
+ {...touchableShape}
background={background}
theme={theme}
- hitSlop={hitSlop}
+ hitSlop={hitSlop !== undefined ? hitSlop : defaultHitSlop}
>
& {
+ borderEndWidth?: number;
+};
+
export const getSegmentedButtonBorderRadius = ({
segment,
+ borderRadius,
}: {
theme: InternalTheme;
segment?: 'first' | 'last';
-}): ViewStyle => {
+ borderRadius: number;
+}): SegmentBorderRadiusStyle => {
if (segment === 'first') {
return {
+ borderRadius,
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
borderEndWidth: 0,
};
} else if (segment === 'last') {
return {
+ borderRadius,
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
};
diff --git a/src/components/Switch/Switch.tsx b/src/components/Switch/Switch.tsx
index 2c86f8d963..7c64b537fc 100644
--- a/src/components/Switch/Switch.tsx
+++ b/src/components/Switch/Switch.tsx
@@ -30,6 +30,7 @@ import { tokens } from '../../theme/tokens';
import { toRawSpring } from '../../theme/tokens/sys/motion';
import { cornerFull } from '../../theme/tokens/sys/shape';
import type { StateOpacityKey, ThemeProp } from '../../theme/types';
+import getMinInteractiveSizeHitSlop from '../../utils/getMinInteractiveSizeHitSlop';
import { isKeyboardFocusEvent } from '../../utils/isKeyboardFocusEvent';
import Icon, { type IconSource } from '../Icon';
@@ -88,6 +89,13 @@ const { thickness: FOCUS_THICKNESS, outerOffset: FOCUS_OUTER_OFFSET } =
const FOCUS_RING_INSET = -(FOCUS_OUTER_OFFSET + FOCUS_THICKNESS);
const OVERLAY_TOP = (STATE_LAYER_SIZE - TRACK_HEIGHT) / 2;
+// The state layer is fixed size, so the slop to reach the 48dp minimum
+// interactive target is a constant rather than something to measure.
+const SWITCH_HIT_SLOP = getMinInteractiveSizeHitSlop({
+ height: STATE_LAYER_SIZE,
+});
+const SWITCH_HIT_SLOP_INSET = SWITCH_HIT_SLOP?.top ?? 0;
+
// Hold-then-grow: a brief delay before snapping to PRESSED_HANDLE so a quick
// tap doesn't flash the press-grow visual.
const PRESS_GROW_DELAY = 100;
@@ -375,11 +383,22 @@ const Switch = ({
aria-checked={checked}
aria-label={ariaLabel}
testID={testID}
+ hitSlop={isDisabled ? undefined : SWITCH_HIT_SLOP}
style={[
styles.touchable,
Platform.OS === 'web' ? webNoOutline : undefined,
]}
>
+ {/* 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 && (
+
+ )}
;
};
+type Position = 'first' | 'middle' | 'last';
+
+// Buttons in a row sit flush against each other, so an unrestricted hitSlop
+// would have each button's expanded target reach into its neighbour's own
+// visible bounds. On web, whichever button is later in the row wins that
+// overlap, so a tap meant for one button's own edge could activate the other
+// instead. Zero the slop on every edge shared with a neighbour; the outer
+// edges (and, for a single button, every edge) keep the usual slop.
+const DEFAULT_HIT_SLOP = getMinInteractiveSizeHitSlop({
+ width: TOGGLE_BUTTON_SIZE,
+ height: TOGGLE_BUTTON_SIZE,
+});
+
+const HIT_SLOP_BY_POSITION: Record =
+ DEFAULT_HIT_SLOP
+ ? {
+ first: { ...DEFAULT_HIT_SLOP, right: 0 },
+ middle: { ...DEFAULT_HIT_SLOP, left: 0, right: 0 },
+ last: { ...DEFAULT_HIT_SLOP, left: 0 },
+ }
+ : { first: undefined, middle: undefined, last: undefined };
+
+const RADIUS_OVERRIDES_BY_POSITION: Record = {
+ first: { borderTopRightRadius: 0, borderBottomRightRadius: 0 },
+ middle: { borderRadius: 0 },
+ last: { borderTopLeftRadius: 0, borderBottomLeftRadius: 0 },
+};
+
/**
* Toggle button row renders a group of toggle buttons in a row.
*
@@ -53,18 +82,29 @@ const ToggleButtonRow = ({ value, onValueChange, children, style }: Props) => {
{React.Children.map(children, (child, i) => {
// @ts-expect-error: TypeScript complains about child.type but it doesn't matter
if (child && child.type === ToggleButton) {
+ const position: Position =
+ i === 0 ? 'first' : i === count - 1 ? 'last' : 'middle';
+
// @ts-expect-error: We're sure that child is a React Element
return React.cloneElement(child, {
style: [
styles.button,
- i === 0
- ? styles.first
- : i === count - 1
- ? styles.last
- : styles.middle,
+ position !== 'first' && styles.noLeftBorder,
// @ts-expect-error: We're sure that child is a React Element
child.props.style,
],
+ ...RADIUS_OVERRIDES_BY_POSITION[position],
+ hitSlop:
+ // @ts-expect-error: We're sure that child is a React Element
+ child.props.hitSlop !== undefined
+ ? // @ts-expect-error: We're sure that child is a React Element
+ child.props.hitSlop
+ : // @ts-expect-error: We're sure that child is a React Element
+ child.props.disabled
+ ? undefined
+ : count > 1
+ ? HIT_SLOP_BY_POSITION[position]
+ : DEFAULT_HIT_SLOP,
});
}
@@ -84,21 +124,8 @@ const styles = StyleSheet.create({
button: {
borderWidth: StyleSheet.hairlineWidth,
},
-
- first: {
- borderTopRightRadius: 0,
- borderBottomRightRadius: 0,
- },
-
- middle: {
- borderRadius: 0,
- borderLeftWidth: 0,
- },
-
- last: {
+ noLeftBorder: {
borderLeftWidth: 0,
- borderTopLeftRadius: 0,
- borderBottomLeftRadius: 0,
},
});
diff --git a/src/components/TouchableRipple/TouchableRipple.native.tsx b/src/components/TouchableRipple/TouchableRipple.native.tsx
index ec7b13dd91..caeb449982 100644
--- a/src/components/TouchableRipple/TouchableRipple.native.tsx
+++ b/src/components/TouchableRipple/TouchableRipple.native.tsx
@@ -35,6 +35,21 @@ export type Props = PressableProps & {
style?: StyleProp;
ref?: React.Ref;
theme?: ThemeProp;
+ borderRadius?: number;
+ borderTopLeftRadius?: number;
+ borderTopRightRadius?: number;
+ borderBottomLeftRadius?: number;
+ borderBottomRightRadius?: number;
+ borderTopStartRadius?: number;
+ borderTopEndRadius?: number;
+ borderBottomStartRadius?: number;
+ borderBottomEndRadius?: number;
+ /**
+ * Web-only: widens the touch target back out past the touchable's own
+ * border. Accepted here too so both platforms share one `Props` type; has
+ * no effect on native, where `hitSlop` isn't offset from inside the border.
+ */
+ borderWidth?: number;
};
const TouchableRipple = ({
@@ -46,9 +61,33 @@ const TouchableRipple = ({
underlayColor,
children,
theme: themeOverrides,
+ hitSlop,
+ borderRadius,
+ borderTopLeftRadius,
+ borderTopRightRadius,
+ borderBottomLeftRadius,
+ borderBottomRightRadius,
+ borderTopStartRadius,
+ borderTopEndRadius,
+ borderBottomStartRadius,
+ borderBottomEndRadius,
+ // consumed so it does not reach the underlying Pressable; web-only, no
+ // native effect
+ borderWidth: _borderWidth,
ref,
...rest
}: Props) => {
+ const underlayShape: ViewStyle = {
+ borderRadius,
+ borderTopLeftRadius,
+ borderTopRightRadius,
+ borderBottomLeftRadius,
+ borderBottomRightRadius,
+ borderTopStartRadius,
+ borderTopEndRadius,
+ borderBottomStartRadius,
+ borderBottomEndRadius,
+ };
const theme = useInternalTheme(themeOverrides);
const { rippleEffectEnabled } = React.useContext(SettingsContext);
@@ -92,6 +131,7 @@ const TouchableRipple = ({
{...rest}
ref={ref}
disabled={disabled}
+ hitSlop={hitSlop}
style={[useForeground && styles.overflowHidden, style]}
android_ripple={androidRipple}
>
@@ -105,6 +145,7 @@ const TouchableRipple = ({
{...rest}
ref={ref}
disabled={disabled}
+ hitSlop={hitSlop}
style={[borderless && styles.overflowHidden, style]}
>
{({ pressed }) => (
@@ -113,6 +154,7 @@ const TouchableRipple = ({
diff --git a/src/components/TouchableRipple/TouchableRipple.tsx b/src/components/TouchableRipple/TouchableRipple.tsx
index 128e2c017e..50357830ea 100644
--- a/src/components/TouchableRipple/TouchableRipple.tsx
+++ b/src/components/TouchableRipple/TouchableRipple.tsx
@@ -21,6 +21,11 @@ import hasTouchHandler from '../../utils/hasTouchHandler';
export type Props = PressableProps & {
/**
* Whether to render the ripple outside the view bounds.
+ *
+ * On web the ripple is bounded by its own container regardless of this prop.
+ * The touchable never clips its content, since clipping would also clip the
+ * touch target, so children needing a rounded shape must carry the radius
+ * themselves.
*/
borderless?: boolean;
/**
@@ -75,6 +80,26 @@ export type Props = PressableProps & {
* @optional
*/
theme?: ThemeProp;
+ /**
+ * Radius of every corner of the touchable. Native-only: it shapes the
+ * highlight underlay there. On web the ripple container clips itself
+ * regardless, so this has no effect.
+ */
+ borderRadius?: number;
+ borderTopLeftRadius?: number;
+ borderTopRightRadius?: number;
+ borderBottomLeftRadius?: number;
+ borderBottomRightRadius?: number;
+ borderTopStartRadius?: number;
+ borderTopEndRadius?: number;
+ borderBottomStartRadius?: number;
+ borderBottomEndRadius?: number;
+ /**
+ * Width of the touchable's own border, if it draws one. Web-only: the touch
+ * target's absolute offsets start inside the border, not the visible outer
+ * edge, so this widens the target back out to it.
+ */
+ borderWidth?: number;
};
/**
@@ -105,12 +130,26 @@ 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,
+ // consumed so they do not reach the DOM; native-only, the ripple container
+ // clips itself regardless of shape on web
+ borderRadius: _borderRadius,
+ borderTopLeftRadius: _borderTopLeftRadius,
+ borderTopRightRadius: _borderTopRightRadius,
+ borderBottomLeftRadius: _borderBottomLeftRadius,
+ borderBottomRightRadius: _borderBottomRightRadius,
+ borderTopStartRadius: _borderTopStartRadius,
+ borderTopEndRadius: _borderTopEndRadius,
+ borderBottomStartRadius: _borderBottomStartRadius,
+ borderBottomEndRadius: _borderBottomEndRadius,
+ borderWidth,
ref,
...rest
}: Props) => {
@@ -178,7 +217,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 +321,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 +328,40 @@ const TouchableRipple = ({
typeof style === 'function' ? style(state) : style,
]}
>
- {(state) =>
- React.Children.only(
- typeof children === 'function' ? children(state) : children
- )
- }
+ {(state) => {
+ const border = borderWidth ?? 0;
+ const inset = (value: number | undefined) => -((value ?? 0) + border);
+
+ const touchTargetStyle: ViewStyle | undefined =
+ hitSlop == null
+ ? undefined
+ : typeof hitSlop === 'number'
+ ? {
+ position: 'absolute',
+ top: inset(hitSlop),
+ bottom: inset(hitSlop),
+ left: inset(hitSlop),
+ right: inset(hitSlop),
+ }
+ : {
+ position: 'absolute',
+ top: inset(hitSlop.top),
+ bottom: inset(hitSlop.bottom),
+ left: inset(hitSlop.left),
+ right: inset(hitSlop.right),
+ };
+
+ return (
+ <>
+ {!disabled && touchTargetStyle && (
+
+ )}
+ {React.Children.only(
+ typeof children === 'function' ? children(state) : children
+ )}
+ >
+ );
+ }}
);
};
@@ -317,9 +384,6 @@ const styles = StyleSheet.create({
cursor: 'auto',
}),
},
- borderless: {
- overflow: 'hidden',
- },
});
export default TouchableRipple;
diff --git a/src/components/TouchableRipple/utils.ts b/src/components/TouchableRipple/utils.ts
index 2874eafc86..e81a5036f5 100644
--- a/src/components/TouchableRipple/utils.ts
+++ b/src/components/TouchableRipple/utils.ts
@@ -2,6 +2,18 @@ import type { ColorValue } from 'react-native';
import type { InternalTheme } from '../../theme/types';
+export type BorderRadiusStyle = {
+ borderRadius?: number;
+ borderTopLeftRadius?: number;
+ borderTopRightRadius?: number;
+ borderBottomLeftRadius?: number;
+ borderBottomRightRadius?: number;
+ borderTopStartRadius?: number;
+ borderTopEndRadius?: number;
+ borderBottomStartRadius?: number;
+ borderBottomEndRadius?: number;
+};
+
const getUnderlayColor = ({
calculatedRippleColor,
underlayColor,
diff --git a/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap b/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap
index faeb89916b..532ee99c82 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,
@@ -202,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,
@@ -235,10 +242,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 +269,24 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = `
"flexGrow": 1,
"justifyContent": "center",
},
+ {
+ "borderBottomEndRadius": undefined,
+ "borderBottomLeftRadius": undefined,
+ "borderBottomRightRadius": undefined,
+ "borderBottomStartRadius": undefined,
+ "borderRadius": 20,
+ "borderTopEndRadius": undefined,
+ "borderTopLeftRadius": undefined,
+ "borderTopRightRadius": undefined,
+ "borderTopStartRadius": undefined,
+ },
+ {
+ "overflow": "hidden",
+ },
+ {
+ "height": undefined,
+ "width": undefined,
+ },
undefined,
],
]
@@ -360,7 +385,6 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = `
[
{
"margin": 6,
- "overflow": "hidden",
},
{
"backgroundColor": undefined,
@@ -368,8 +392,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,
@@ -401,10 +433,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 +460,24 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = `
"flexGrow": 1,
"justifyContent": "center",
},
+ {
+ "borderBottomEndRadius": undefined,
+ "borderBottomLeftRadius": undefined,
+ "borderBottomRightRadius": undefined,
+ "borderBottomStartRadius": undefined,
+ "borderRadius": 20,
+ "borderTopEndRadius": undefined,
+ "borderTopLeftRadius": undefined,
+ "borderTopRightRadius": undefined,
+ "borderTopStartRadius": undefined,
+ },
+ {
+ "overflow": "hidden",
+ },
+ {
+ "height": undefined,
+ "width": undefined,
+ },
undefined,
],
]
@@ -582,7 +632,6 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A
[
{
"margin": 6,
- "overflow": "hidden",
},
{
"backgroundColor": undefined,
@@ -590,8 +639,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,
@@ -623,10 +680,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 +707,24 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A
"flexGrow": 1,
"justifyContent": "center",
},
+ {
+ "borderBottomEndRadius": undefined,
+ "borderBottomLeftRadius": undefined,
+ "borderBottomRightRadius": undefined,
+ "borderBottomStartRadius": undefined,
+ "borderRadius": 20,
+ "borderTopEndRadius": undefined,
+ "borderTopLeftRadius": undefined,
+ "borderTopRightRadius": undefined,
+ "borderTopStartRadius": undefined,
+ },
+ {
+ "overflow": "hidden",
+ },
+ {
+ "height": undefined,
+ "width": undefined,
+ },
undefined,
],
]
@@ -805,7 +880,6 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A
[
{
"margin": 6,
- "overflow": "hidden",
},
{
"backgroundColor": undefined,
@@ -813,8 +887,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,
@@ -845,10 +927,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 +954,24 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A
"flexGrow": 1,
"justifyContent": "center",
},
+ {
+ "borderBottomEndRadius": undefined,
+ "borderBottomLeftRadius": undefined,
+ "borderBottomRightRadius": undefined,
+ "borderBottomStartRadius": undefined,
+ "borderRadius": 20,
+ "borderTopEndRadius": undefined,
+ "borderTopLeftRadius": undefined,
+ "borderTopRightRadius": undefined,
+ "borderTopStartRadius": undefined,
+ },
+ {
+ "overflow": "hidden",
+ },
+ {
+ "height": undefined,
+ "width": undefined,
+ },
undefined,
],
]
@@ -1062,7 +1162,6 @@ exports[`AppbarAction should be rendered with custom color 1`] = `
[
{
"margin": 6,
- "overflow": "hidden",
},
{
"backgroundColor": undefined,
@@ -1070,8 +1169,16 @@ exports[`AppbarAction should be rendered with custom color 1`] = `
"width": 40,
},
{
+ "borderBottomEndRadius": undefined,
+ "borderBottomLeftRadius": undefined,
+ "borderBottomRightRadius": undefined,
+ "borderBottomStartRadius": undefined,
"borderColor": "rgba(202, 196, 208, 1)",
"borderRadius": 20,
+ "borderTopEndRadius": undefined,
+ "borderTopLeftRadius": undefined,
+ "borderTopRightRadius": undefined,
+ "borderTopStartRadius": undefined,
"borderWidth": 0,
},
undefined,
@@ -1102,10 +1209,10 @@ exports[`AppbarAction should be rendered with custom color 1`] = `
focusable={true}
hitSlop={
{
- "bottom": 6,
- "left": 6,
- "right": 6,
- "top": 6,
+ "bottom": 4,
+ "left": 4,
+ "right": 4,
+ "top": 4,
}
}
onBlur={[Function]}
@@ -1129,6 +1236,24 @@ exports[`AppbarAction should be rendered with custom color 1`] = `
"flexGrow": 1,
"justifyContent": "center",
},
+ {
+ "borderBottomEndRadius": undefined,
+ "borderBottomLeftRadius": undefined,
+ "borderBottomRightRadius": undefined,
+ "borderBottomStartRadius": undefined,
+ "borderRadius": 20,
+ "borderTopEndRadius": undefined,
+ "borderTopLeftRadius": undefined,
+ "borderTopRightRadius": undefined,
+ "borderTopStartRadius": undefined,
+ },
+ {
+ "overflow": "hidden",
+ },
+ {
+ "height": undefined,
+ "width": undefined,
+ },
undefined,
],
]
@@ -1320,7 +1445,6 @@ exports[`AppbarAction should be rendered with default theme color 1`] = `
[
{
"margin": 6,
- "overflow": "hidden",
},
{
"backgroundColor": undefined,
@@ -1328,8 +1452,16 @@ exports[`AppbarAction should be rendered with default theme color 1`] = `
"width": 40,
},
{
+ "borderBottomEndRadius": undefined,
+ "borderBottomLeftRadius": undefined,
+ "borderBottomRightRadius": undefined,
+ "borderBottomStartRadius": undefined,
"borderColor": "rgba(202, 196, 208, 1)",
"borderRadius": 20,
+ "borderTopEndRadius": undefined,
+ "borderTopLeftRadius": undefined,
+ "borderTopRightRadius": undefined,
+ "borderTopStartRadius": undefined,
"borderWidth": 0,
},
undefined,
@@ -1360,10 +1492,10 @@ exports[`AppbarAction should be rendered with default theme color 1`] = `
focusable={true}
hitSlop={
{
- "bottom": 6,
- "left": 6,
- "right": 6,
- "top": 6,
+ "bottom": 4,
+ "left": 4,
+ "right": 4,
+ "top": 4,
}
}
onBlur={[Function]}
@@ -1387,6 +1519,24 @@ exports[`AppbarAction should be rendered with default theme color 1`] = `
"flexGrow": 1,
"justifyContent": "center",
},
+ {
+ "borderBottomEndRadius": undefined,
+ "borderBottomLeftRadius": undefined,
+ "borderBottomRightRadius": undefined,
+ "borderBottomStartRadius": undefined,
+ "borderRadius": 20,
+ "borderTopEndRadius": undefined,
+ "borderTopLeftRadius": undefined,
+ "borderTopRightRadius": undefined,
+ "borderTopStartRadius": undefined,
+ },
+ {
+ "overflow": "hidden",
+ },
+ {
+ "height": undefined,
+ "width": undefined,
+ },
undefined,
],
]
@@ -1578,7 +1728,6 @@ exports[`AppbarAction should be rendered with specific theme color if is leading
[
{
"margin": 6,
- "overflow": "hidden",
},
{
"backgroundColor": undefined,
@@ -1586,8 +1735,16 @@ exports[`AppbarAction should be rendered with specific theme color if is leading
"width": 40,
},
{
+ "borderBottomEndRadius": undefined,
+ "borderBottomLeftRadius": undefined,
+ "borderBottomRightRadius": undefined,
+ "borderBottomStartRadius": undefined,
"borderColor": "rgba(202, 196, 208, 1)",
"borderRadius": 20,
+ "borderTopEndRadius": undefined,
+ "borderTopLeftRadius": undefined,
+ "borderTopRightRadius": undefined,
+ "borderTopStartRadius": undefined,
"borderWidth": 0,
},
undefined,
@@ -1618,10 +1775,10 @@ exports[`AppbarAction should be rendered with specific theme color if is leading
focusable={true}
hitSlop={
{
- "bottom": 6,
- "left": 6,
- "right": 6,
- "top": 6,
+ "bottom": 4,
+ "left": 4,
+ "right": 4,
+ "top": 4,
}
}
onBlur={[Function]}
@@ -1645,6 +1802,24 @@ exports[`AppbarAction should be rendered with specific theme color if is leading
"flexGrow": 1,
"justifyContent": "center",
},
+ {
+ "borderBottomEndRadius": undefined,
+ "borderBottomLeftRadius": undefined,
+ "borderBottomRightRadius": undefined,
+ "borderBottomStartRadius": undefined,
+ "borderRadius": 20,
+ "borderTopEndRadius": undefined,
+ "borderTopLeftRadius": undefined,
+ "borderTopRightRadius": undefined,
+ "borderTopStartRadius": undefined,
+ },
+ {
+ "overflow": "hidden",
+ },
+ {
+ "height": undefined,
+ "width": undefined,
+ },
undefined,
],
]
@@ -1836,7 +2011,6 @@ exports[`AppbarAction should render AppbarBackAction with custom color 1`] = `
[
{
"margin": 6,
- "overflow": "hidden",
},
{
"backgroundColor": undefined,
@@ -1844,8 +2018,16 @@ exports[`AppbarAction should render AppbarBackAction with custom color 1`] = `
"width": 40,
},
{
+ "borderBottomEndRadius": undefined,
+ "borderBottomLeftRadius": undefined,
+ "borderBottomRightRadius": undefined,
+ "borderBottomStartRadius": undefined,
"borderColor": "rgba(202, 196, 208, 1)",
"borderRadius": 20,
+ "borderTopEndRadius": undefined,
+ "borderTopLeftRadius": undefined,
+ "borderTopRightRadius": undefined,
+ "borderTopStartRadius": undefined,
"borderWidth": 0,
},
undefined,
@@ -1877,10 +2059,10 @@ exports[`AppbarAction should render AppbarBackAction with custom color 1`] = `
focusable={true}
hitSlop={
{
- "bottom": 6,
- "left": 6,
- "right": 6,
- "top": 6,
+ "bottom": 4,
+ "left": 4,
+ "right": 4,
+ "top": 4,
}
}
onBlur={[Function]}
@@ -1904,6 +2086,24 @@ exports[`AppbarAction should render AppbarBackAction with custom color 1`] = `
"flexGrow": 1,
"justifyContent": "center",
},
+ {
+ "borderBottomEndRadius": undefined,
+ "borderBottomLeftRadius": undefined,
+ "borderBottomRightRadius": undefined,
+ "borderBottomStartRadius": undefined,
+ "borderRadius": 20,
+ "borderTopEndRadius": undefined,
+ "borderTopLeftRadius": undefined,
+ "borderTopRightRadius": undefined,
+ "borderTopStartRadius": undefined,
+ },
+ {
+ "overflow": "hidden",
+ },
+ {
+ "height": undefined,
+ "width": undefined,
+ },
undefined,
],
]
diff --git a/src/components/__tests__/Checkbox/Checkbox.test.tsx b/src/components/__tests__/Checkbox/Checkbox.test.tsx
index a72200bb4e..393b32ad1d 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,22 @@ it('renders Checkbox with custom testID', async () => {
expect(tree).toMatchSnapshot();
});
+
+it('expands hitSlop up to the 48dp minimum when enabled', async () => {
+ await render();
+
+ // eslint-disable-next-line no-restricted-syntax
+ expect(screen.getByTestId('checkbox').props.hitSlop).toEqual({
+ top: 4,
+ bottom: 4,
+ left: 4,
+ right: 4,
+ });
+});
+
+it('gives a disabled Checkbox no hitSlop of its own', async () => {
+ await render();
+
+ // 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 202a95467a..fe7f4ec3ba 100644
--- a/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap
+++ b/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap
@@ -24,6 +24,14 @@ 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]}
@@ -209,6 +217,14 @@ 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]}
@@ -393,6 +409,14 @@ 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]}
@@ -577,6 +601,14 @@ 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]}
@@ -748,6 +780,14 @@ 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]}
@@ -919,6 +959,14 @@ 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]}
@@ -1103,6 +1151,14 @@ 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]}
diff --git a/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap b/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap
index e2d338f791..98d6c4b51d 100644
--- a/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap
+++ b/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap
@@ -78,6 +78,14 @@ 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]}
@@ -391,6 +399,14 @@ 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]}
@@ -666,6 +682,14 @@ exports[`should have maxFontSizeMultiplier set to 1.5 by default 1`] = `
centered={true}
collapsable={false}
focusable={true}
+ hitSlop={
+ {
+ "bottom": 4,
+ "left": 4,
+ "right": 4,
+ "top": 4,
+ }
+ }
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
diff --git a/src/components/__tests__/Chip.test.tsx b/src/components/__tests__/Chip.test.tsx
index a7d522e4f0..97d48846b4 100644
--- a/src/components/__tests__/Chip.test.tsx
+++ b/src/components/__tests__/Chip.test.tsx
@@ -95,6 +95,33 @@ it('renders chip with zero border radius', async () => {
});
});
+it('expands hitSlop up to the 48dp minimum when enabled', async () => {
+ await render(
+ {}}>
+ Active chip
+
+ );
+
+ // eslint-disable-next-line no-restricted-syntax
+ expect(screen.getByTestId('active-chip').props.hitSlop).toEqual({
+ top: 8,
+ bottom: 8,
+ left: 0,
+ right: 0,
+ });
+});
+
+it('gives a disabled Chip no hitSlop of its own', async () => {
+ await render(
+ {}}>
+ Disabled chip
+
+ );
+
+ // eslint-disable-next-line no-restricted-syntax
+ expect(screen.getByTestId('disabled-chip').props.hitSlop).toBeUndefined();
+});
+
describe('getChipColors - text color', () => {
it('should return correct disabled color, for theme version 3', () => {
expect(
@@ -309,3 +336,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__/IconButton.test.tsx b/src/components/__tests__/IconButton.test.tsx
index 914f1e2c55..953dcb3c19 100644
--- a/src/components/__tests__/IconButton.test.tsx
+++ b/src/components/__tests__/IconButton.test.tsx
@@ -2,7 +2,7 @@ import { StyleSheet } from 'react-native';
import { describe, expect, it } from '@jest/globals';
-import { render } from '../../test-utils';
+import { render, screen } from '../../test-utils';
import { pink500 } from '../../theme/colors';
import { LightTheme } from '../../theme/schemes';
import { tokens } from '../../theme/tokens';
@@ -46,6 +46,57 @@ it('renders disabled icon button', async () => {
expect(tree).toMatchSnapshot();
});
+it('expands hitSlop up to the 48dp minimum for a button smaller than that', async () => {
+ await render();
+
+ // eslint-disable-next-line no-restricted-syntax
+ expect(screen.getByTestId('icon-button').props.hitSlop).toEqual({
+ top: 4,
+ bottom: 4,
+ left: 4,
+ right: 4,
+ });
+});
+
+it('gives a disabled button no hitSlop of its own', async () => {
+ await render();
+
+ // eslint-disable-next-line no-restricted-syntax
+ expect(screen.getByTestId('icon-button').props.hitSlop).toBeUndefined();
+});
+
+it('lets a caller-supplied hitSlop win even while disabled', async () => {
+ await render(
+
+ );
+
+ // eslint-disable-next-line no-restricted-syntax
+ expect(screen.getByTestId('icon-button').props.hitSlop).toBe(2);
+});
+
+it('computes hitSlop from explicit width/height rather than the button size', async () => {
+ await render(
+
+ );
+
+ // eslint-disable-next-line no-restricted-syntax
+ expect(screen.getByTestId('icon-button').props.hitSlop).toEqual({
+ top: 14,
+ bottom: 14,
+ left: 14,
+ right: 14,
+ });
+});
+
+it('drops hitSlop when explicit width/height already meet the 48dp minimum', async () => {
+ await render(
+
+ );
+
+ // eslint-disable-next-line no-restricted-syntax
+ expect(screen.getByTestId('icon-button').props.hitSlop).toBeUndefined();
+});
+
it('renders icon change animated', async () => {
const tree = (await render()).toJSON();
@@ -80,6 +131,24 @@ it('renders icon button with small border radius', async () => {
expect(toJSON()).toMatchSnapshot();
});
+it('clips to a custom corner radius', async () => {
+ await render(
+ {}}
+ borderTopLeftRadius={0}
+ />
+ );
+
+ // The container stopped clipping so the touch target can escape it, so the
+ // touchable has to take the shape itself, corners included.
+ expect(screen.getByTestId('icon-button')).toHaveStyle({
+ borderTopLeftRadius: 0,
+ });
+});
+
describe('getIconButtonColor - icon color', () => {
it('should return custom icon color', () => {
expect(
diff --git a/src/components/__tests__/RadioButton/RadioButton.test.tsx b/src/components/__tests__/RadioButton/RadioButton.test.tsx
index da8a04375d..e31d4efd65 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,25 @@ describe('RadioButton', () => {
expect(tree).toMatchSnapshot();
});
});
+
+ describe('hitSlop', () => {
+ it('expands up to the 48dp minimum when enabled', async () => {
+ await render();
+
+ // 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 c20910f20e..a6e37f97d4 100644
--- a/src/components/__tests__/RadioButton/__snapshots__/RadioButton.test.tsx.snap
+++ b/src/components/__tests__/RadioButton/__snapshots__/RadioButton.test.tsx.snap
@@ -23,6 +23,14 @@ 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]}
@@ -39,8 +47,8 @@ exports[`RadioButton RadioButton with custom testID renders properly 1`] = `
"overflow": "hidden",
},
{
- "borderRadius": 18,
- "padding": 6,
+ "borderRadius": 20,
+ "padding": 8,
},
]
}
@@ -110,6 +118,14 @@ 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]}
@@ -126,8 +142,8 @@ exports[`RadioButton on default platform renders properly 1`] = `
"overflow": "hidden",
},
{
- "borderRadius": 18,
- "padding": 6,
+ "borderRadius": 20,
+ "padding": 8,
},
]
}
@@ -196,6 +212,14 @@ 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]}
@@ -212,8 +236,8 @@ exports[`RadioButton on ios platform renders properly 1`] = `
"overflow": "hidden",
},
{
- "borderRadius": 18,
- "padding": 6,
+ "borderRadius": 20,
+ "padding": 8,
},
]
}
@@ -282,6 +306,14 @@ 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]}
@@ -298,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 1ae7f560be..82f06ebf6c 100644
--- a/src/components/__tests__/RadioButton/__snapshots__/RadioButtonGroup.test.tsx.snap
+++ b/src/components/__tests__/RadioButton/__snapshots__/RadioButtonGroup.test.tsx.snap
@@ -26,6 +26,14 @@ 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]}
@@ -42,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 5867b1408c..1d8934aa6f 100644
--- a/src/components/__tests__/RadioButton/__snapshots__/RadioButtonItem.test.tsx.snap
+++ b/src/components/__tests__/RadioButton/__snapshots__/RadioButtonItem.test.tsx.snap
@@ -77,6 +77,14 @@ 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]}
@@ -93,8 +101,8 @@ exports[`can render leading radio button control 1`] = `
"overflow": "hidden",
},
{
- "borderRadius": 18,
- "padding": 6,
+ "borderRadius": 20,
+ "padding": 8,
},
]
}
@@ -291,6 +299,14 @@ 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]}
@@ -307,7 +323,7 @@ exports[`can render the Android radio button on different platforms 1`] = `
"overflow": "hidden",
},
{
- "borderRadius": 18,
+ "borderRadius": 20,
},
]
}
@@ -320,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,
}
}
@@ -443,6 +459,14 @@ 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]}
@@ -459,8 +483,8 @@ exports[`can render the iOS radio button on different platforms 1`] = `
"overflow": "hidden",
},
{
- "borderRadius": 18,
- "padding": 6,
+ "borderRadius": 20,
+ "padding": 8,
},
]
}
@@ -621,6 +645,14 @@ 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]}
@@ -637,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 47134613ba..23c868e063 100644
--- a/src/components/__tests__/SegmentedButton.test.tsx
+++ b/src/components/__tests__/SegmentedButton.test.tsx
@@ -515,3 +515,43 @@ describe('labelStyle is handled', () => {
});
});
});
+
+describe('hitSlop', () => {
+ it('expands up to the 48dp minimum when enabled', async () => {
+ await render(
+ {}}
+ />
+ );
+
+ // eslint-disable-next-line no-restricted-syntax
+ expect(screen.getByTestId('walking-button').props.hitSlop).toEqual({
+ top: 6,
+ bottom: 6,
+ left: 0,
+ right: 0,
+ });
+ });
+
+ it('gives a disabled button no hitSlop of its own', async () => {
+ await render(
+ {}}
+ />
+ );
+
+ // eslint-disable-next-line no-restricted-syntax
+ expect(screen.getByTestId('walking-button').props.hitSlop).toBeUndefined();
+ });
+});
diff --git a/src/components/__tests__/Switch.test.tsx b/src/components/__tests__/Switch.test.tsx
index ceff77a0e6..deec243fe6 100644
--- a/src/components/__tests__/Switch.test.tsx
+++ b/src/components/__tests__/Switch.test.tsx
@@ -24,6 +24,25 @@ describe('Switch render', () => {
).toMatchSnapshot();
});
+ it('expands hitSlop up to the 48dp minimum when enabled', async () => {
+ await render();
+
+ // eslint-disable-next-line no-restricted-syntax
+ expect(screen.getByTestId('switch').props.hitSlop).toEqual({
+ top: 4,
+ bottom: 4,
+ left: 0,
+ right: 0,
+ });
+ });
+
+ it('gives a disabled switch no hitSlop of its own', async () => {
+ await render();
+
+ // eslint-disable-next-line no-restricted-syntax
+ expect(screen.getByTestId('switch').props.hitSlop).toBeUndefined();
+ });
+
it('renders with checked icon', async () => {
expect(
(await render()).toJSON()
diff --git a/src/components/__tests__/ToggleButton.test.tsx b/src/components/__tests__/ToggleButton.test.tsx
index 006ae179c5..8876157043 100644
--- a/src/components/__tests__/ToggleButton.test.tsx
+++ b/src/components/__tests__/ToggleButton.test.tsx
@@ -1,6 +1,6 @@
import { describe, expect, it } from '@jest/globals';
-import { render } from '../../test-utils';
+import { render, screen } from '../../test-utils';
import { DarkTheme, LightTheme } from '../../theme/schemes';
import ToggleButton from '../ToggleButton';
import { getToggleButtonColor } from '../ToggleButton/utils';
@@ -33,6 +33,107 @@ it('renders unchecked toggle button', async () => {
expect(tree).toMatchSnapshot();
});
+describe('ToggleButton.Row', () => {
+ it('divides hitSlop between adjoining buttons, zeroing the shared edge', async () => {
+ await render(
+ {}}>
+
+
+
+
+ );
+
+ // eslint-disable-next-line no-restricted-syntax
+ expect(screen.getByTestId('first').props.hitSlop).toEqual({
+ top: 3,
+ bottom: 3,
+ left: 3,
+ right: 0,
+ });
+ // eslint-disable-next-line no-restricted-syntax
+ expect(screen.getByTestId('middle').props.hitSlop).toEqual({
+ top: 3,
+ bottom: 3,
+ left: 0,
+ right: 0,
+ });
+ // eslint-disable-next-line no-restricted-syntax
+ expect(screen.getByTestId('last').props.hitSlop).toEqual({
+ top: 3,
+ bottom: 3,
+ left: 0,
+ right: 3,
+ });
+ });
+
+ it('keeps the usual hitSlop for a lone button in a row', async () => {
+ await render(
+ {}}>
+
+
+ );
+
+ // eslint-disable-next-line no-restricted-syntax
+ expect(screen.getByTestId('only').props.hitSlop).toEqual({
+ top: 3,
+ bottom: 3,
+ left: 3,
+ right: 3,
+ });
+ });
+
+ it('gives a disabled button in a row no hitSlop of its own', async () => {
+ await render(
+ {}}>
+
+
+
+ );
+
+ // eslint-disable-next-line no-restricted-syntax
+ expect(screen.getByTestId('first').props.hitSlop).toBeUndefined();
+ });
+
+ it('lets a caller-supplied hitSlop win over the row default', async () => {
+ const customHitSlop = { top: 2, bottom: 2, left: 2, right: 2 };
+
+ await render(
+ {}}>
+
+
+
+ );
+
+ // eslint-disable-next-line no-restricted-syntax
+ expect(screen.getByTestId('first').props.hitSlop).toBe(customHitSlop);
+ });
+
+ it('squares off the corners shared with a neighbour', async () => {
+ await render(
+ {}}>
+
+
+
+
+ );
+
+ expect(screen.getByTestId('first')).toHaveStyle({
+ borderTopRightRadius: 0,
+ borderBottomRightRadius: 0,
+ });
+ expect(screen.getByTestId('middle')).toHaveStyle({ borderRadius: 0 });
+ expect(screen.getByTestId('last')).toHaveStyle({
+ borderTopLeftRadius: 0,
+ borderBottomLeftRadius: 0,
+ });
+ });
+});
+
describe('getToggleButtonColor', () => {
it('should return correct color when checked and theme version 3', () => {
expect(getToggleButtonColor({ theme: LightTheme, checked: true })).toBe(
diff --git a/src/components/__tests__/TouchableRipple.test.tsx b/src/components/__tests__/TouchableRipple.test.tsx
index 663d540bec..57ecb6bc43 100644
--- a/src/components/__tests__/TouchableRipple.test.tsx
+++ b/src/components/__tests__/TouchableRipple.test.tsx
@@ -2,7 +2,7 @@ import { Platform, Text } from 'react-native';
import type { GestureResponderEvent } from 'react-native';
import { describe, expect, it, jest } from '@jest/globals';
-import { userEvent } from '@testing-library/react-native';
+import { act, fireEvent, userEvent } from '@testing-library/react-native';
import { render, screen } from '../../test-utils';
import TouchableRipple from '../TouchableRipple/TouchableRipple.native';
@@ -66,5 +66,82 @@ describe('TouchableRipple', () => {
expect(toJSON()).toMatchSnapshot();
});
+
+ it('takes the shape of the touchable so it does not square off the corners', async () => {
+ const { toJSON } = await render(
+
+ Press me!
+
+ );
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('takes per-corner radii too', async () => {
+ const { toJSON } = await render(
+
+ Press me!
+
+ );
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+ });
+
+ describe('hitSlop', () => {
+ // hitSlop has no user-visible effect here, the renderer does not lay views
+ // out or hit-test them. This only stops the prop being dropped.
+ /* eslint-disable no-restricted-syntax */
+ const hitSlopOf = () => screen.getByTestId('touchable').props.hitSlop;
+ /* eslint-enable no-restricted-syntax */
+
+ it('is not enforced or defaulted: the primitive does not measure', async () => {
+ await render(
+ {}}>
+ Button
+
+ );
+
+ expect(hitSlopOf()).toBeUndefined();
+ });
+
+ it('passes a caller-supplied hitSlop straight through', async () => {
+ await render(
+ {}}
+ hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
+ >
+ Button
+
+ );
+
+ expect(hitSlopOf()).toEqual({ top: 8, bottom: 8, left: 8, right: 8 });
+ });
+
+ it('still calls a caller-supplied onLayout', async () => {
+ const onLayout = jest.fn();
+ await render(
+ {}}
+ onLayout={onLayout}
+ >
+ Button
+
+ );
+
+ await act(async () => {
+ await fireEvent(screen.getByTestId('touchable'), 'layout', {
+ nativeEvent: { layout: { width: 32, height: 32, x: 0, y: 0 } },
+ });
+ });
+
+ expect(onLayout).toHaveBeenCalledTimes(1);
+ });
});
});
diff --git a/src/components/__tests__/TouchableRippleWeb.test.tsx b/src/components/__tests__/TouchableRippleWeb.test.tsx
new file mode 100644
index 0000000000..bd2c92174f
--- /dev/null
+++ b/src/components/__tests__/TouchableRippleWeb.test.tsx
@@ -0,0 +1,164 @@
+import { Text } from 'react-native';
+
+import { describe, expect, it } from '@jest/globals';
+import type { TestInstance } from 'test-renderer';
+
+import { render, screen } from '../../test-utils';
+import type TouchableRippleType from '../TouchableRipple/TouchableRipple';
+
+// 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 TOUCHABLE = 'touchable';
+
+// Host elements can render raw text children; the touchable's own children
+// never do. This narrows the type instead of leaving `children[0]` typed as
+// `TestNode` at every call site.
+const asElement = (node: TestInstance | string): TestInstance => {
+ if (typeof node === 'string') {
+ throw new Error('Expected an element, not a text node');
+ }
+ return node;
+};
+
+// The target has no testID (a hardcoded one would collide across instances)
+// and no accessible role (it's `aria-hidden`), so it's matched by that marker
+// instead of a user-visible query.
+const getTarget = () => {
+ const children = screen.getByTestId(TOUCHABLE).children;
+ return (
+ children.find(
+ (child): child is TestInstance =>
+ typeof child !== 'string' &&
+ // eslint-disable-next-line no-restricted-syntax
+ !!child.props['aria-hidden']
+ ) ?? null
+ );
+};
+
+// Throws so tests that expect a target don't need a non-null assertion.
+const requireTarget = () => {
+ const target = getTarget();
+ if (target === null) {
+ throw new Error('Expected a touch target to be rendered');
+ }
+ return target;
+};
+
+const styleOf = (node: TestInstance) => {
+ // eslint-disable-next-line no-restricted-syntax
+ const { style } = node.props;
+ return Array.isArray(style) ? Object.assign({}, ...style.flat()) : style;
+};
+
+describe('TouchableRipple (web)', () => {
+ it.each([
+ { when: 'there is no hitSlop', props: { onPress: () => {} } },
+ { when: 'there are no touch handlers', props: { hitSlop: 4 } },
+ {
+ when: 'it is disabled',
+ props: { hitSlop: 4, onPress: () => {}, disabled: true },
+ },
+ ])('does not render a touch target when $when', async ({ props }) => {
+ await render(
+
+ Button
+
+ );
+
+ expect(getTarget()).toBeNull();
+ });
+
+ it('renders the touch target before the children so it cannot cover them', async () => {
+ // It hit-tests, so as the last sibling it covers anything interactive inside
+ // the touchable, e.g. a pressable List.Item with a control in `right`.
+ await render(
+ {}} testID={TOUCHABLE}>
+ child-marker
+
+ );
+
+ const [first, second] = screen
+ .getByTestId(TOUCHABLE)
+ .children.map(asElement);
+
+ // eslint-disable-next-line no-restricted-syntax
+ expect(first.props['aria-hidden']).toBe(true);
+ // eslint-disable-next-line no-restricted-syntax
+ expect(second.props.children).toBe('child-marker');
+ });
+
+ it.each([
+ {
+ name: 'sizes the target from a numeric hitSlop',
+ hitSlop: 6 as const,
+ expected: { top: -6, bottom: -6, left: -6, right: -6 },
+ },
+ {
+ name: 'sizes the target from a per-edge hitSlop, defaulting unset edges to zero',
+ hitSlop: { top: 4, left: 8 },
+ expected: { top: -4, bottom: -0, left: -8, right: -0 },
+ },
+ ])('$name', async ({ hitSlop, expected }) => {
+ await render(
+ {}} testID={TOUCHABLE}>
+ Button
+
+ );
+
+ expect(styleOf(requireTarget())).toEqual({
+ position: 'absolute',
+ ...expected,
+ });
+ });
+
+ it('extends the target past a border, since absolute offsets start inside it', async () => {
+ // Offsets on a position: absolute child are relative to the parent's
+ // padding edge, inside its border, not its visible outer edge. Without
+ // adding the border back in, the target would fall short of `hitSlop`
+ // past what's actually visible.
+ await render(
+ {}}
+ testID={TOUCHABLE}
+ >
+ Button
+
+ );
+
+ expect(styleOf(requireTarget())).toEqual({
+ position: 'absolute',
+ top: -6,
+ bottom: -6,
+ left: -6,
+ right: -6,
+ });
+ });
+
+ it('no longer clips the touchable itself, which would clip the target', async () => {
+ await render(
+ {}} testID={TOUCHABLE}>
+ Button
+
+ );
+
+ const style = styleOf(screen.getByTestId(TOUCHABLE));
+
+ // check we have the touchable's own style first, or the absence below passes
+ // against any empty object
+ expect(style).toMatchObject({ position: 'relative' });
+ expect(style.overflow).toBeUndefined();
+ });
+});
diff --git a/src/components/__tests__/__snapshots__/Chip.test.tsx.snap b/src/components/__tests__/__snapshots__/Chip.test.tsx.snap
index 04687e301f..c5477e7e73 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,13 @@ exports[`renders chip with close button 1`] = `
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
role="button"
+ style={
+ {
+ "height": "100%",
+ "justifyContent": "center",
+ "width": "100%",
+ }
+ }
>
@@ -643,6 +679,14 @@ exports[`renders chip with custom close button 1`] = `
accessible={true}
collapsable={false}
focusable={true}
+ hitSlop={
+ {
+ "bottom": 8,
+ "left": 0,
+ "right": 0,
+ "top": 8,
+ }
+ }
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
@@ -653,6 +697,13 @@ exports[`renders chip with custom close button 1`] = `
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
role="button"
+ style={
+ {
+ "height": "100%",
+ "justifyContent": "center",
+ "width": "100%",
+ }
+ }
>
`;
+
+exports[`TouchableRipple on iOS takes per-corner radii too 1`] = `
+
+
+
+ Press me!
+
+
+`;
+
+exports[`TouchableRipple on iOS takes the shape of the touchable so it does not square off the corners 1`] = `
+
+
+
+ Press me!
+
+
+`;
diff --git a/src/utils/getMinInteractiveSizeHitSlop.ts b/src/utils/getMinInteractiveSizeHitSlop.ts
new file mode 100644
index 0000000000..d379dbe5aa
--- /dev/null
+++ b/src/utils/getMinInteractiveSizeHitSlop.ts
@@ -0,0 +1,42 @@
+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;
+
+/**
+ * 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, (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;