diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md
index e51de006aa..96eaafc2c2 100644
--- a/docs/6.x/docs/guides/migration.md
+++ b/docs/6.x/docs/guides/migration.md
@@ -209,6 +209,38 @@ The misspelled `traileringIcon` props have been renamed:
/>
```
+### Chip
+
+The close button (`onClose`) now fills the entire trailing 34dp column reserved for it, matching Material Design 3's touch target guidance, instead of only its 24x18 icon. Taps near the top or bottom of that column, which used to fall through to the chip's own `onPress`, now activate `onClose` instead.
+
+### TouchableRipple
+
+- `borderless` no longer clips the touchable's own content on web; it only clips the ripple itself, in its own container. A child that needs a clipped or rounded shape should carry that shape itself.
+- Corner radius and border width set through `style` no longer shape the highlight underlay (native) or the ripple's self-clipping container (web). Pass them as dedicated props instead:
+ - `borderRadius`
+ - `borderTopLeftRadius`
+ - `borderTopRightRadius`
+ - `borderBottomLeftRadius`
+ - `borderBottomRightRadius`
+ - `borderTopStartRadius`
+ - `borderTopEndRadius`
+ - `borderBottomStartRadius`
+ - `borderBottomEndRadius`
+ - `borderWidth` (web only)
+
+e.g.:
+
+```diff
+ {}}
+>
+ Content
+
+```
+
### TextInput
The Paper 6.x `TextInput` is a complete rewrite with a new API. Import the component the same way, but note that the props and behavior have changed significantly.
diff --git a/eslint.config.mjs b/eslint.config.mjs
index ac6e65951f..776d90544b 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -221,6 +221,7 @@ export default defineConfig(
'src/components/__tests__/Appbar/Appbar.test.tsx',
'src/components/__tests__/Dialog.test.tsx',
'src/components/__tests__/Searchbar.test.tsx',
+ 'src/components/__tests__/TouchableRippleWeb.test.tsx',
],
rules: {
'testing-library/no-node-access': 'off',
diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx
index 6f07113572..4b12466879 100644
--- a/src/components/Button/Button.tsx
+++ b/src/components/Button/Button.tsx
@@ -262,6 +262,10 @@ const Button = ({
});
const touchableStyle = { borderRadius };
+ const touchableRippleStyle = getButtonTouchableRippleStyle(
+ touchableStyle,
+ borderWidth
+ );
const { color: customLabelColor, fontSize: customLabelSize } =
StyleSheet.flatten(labelStyle) || {};
@@ -334,7 +338,8 @@ const Button = ({
accessible={accessible}
hitSlop={hitSlop}
disabled={disabled}
- style={getButtonTouchableRippleStyle(touchableStyle, borderWidth)}
+ style={touchableRippleStyle}
+ {...touchableRippleStyle}
testID={testID}
theme={theme}
ref={touchableRef}
diff --git a/src/components/Button/utils.tsx b/src/components/Button/utils.tsx
index ec3503c82b..bbebfd7791 100644
--- a/src/components/Button/utils.tsx
+++ b/src/components/Button/utils.tsx
@@ -4,6 +4,7 @@ import { black, white } from '../../theme/colors';
import { tokens } from '../../theme/tokens';
import type { InternalTheme } from '../../theme/types';
import { splitStyles } from '../../utils/splitStyles';
+import type { BorderRadiusStyle } from '../TouchableRipple/utils';
const stateOpacity = tokens.md.sys.state.opacity;
@@ -191,20 +192,7 @@ export const getButtonColors = ({
};
};
-type ViewStyleBorderRadiusStyles = Partial<
- Pick<
- ViewStyle,
- | 'borderBottomEndRadius'
- | 'borderBottomLeftRadius'
- | 'borderBottomRightRadius'
- | 'borderBottomStartRadius'
- | 'borderTopEndRadius'
- | 'borderTopLeftRadius'
- | 'borderTopRightRadius'
- | 'borderTopStartRadius'
- | 'borderRadius'
- >
->;
+type ViewStyleBorderRadiusStyles = Partial;
export const getButtonTouchableRippleStyle = (
style?: ViewStyle,
borderWidth: number = 0
diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx
index 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 4507561a48..53f949b0fd 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 50086bbe71..3c97b585bd 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}
>
@@ -213,13 +277,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/RadioButton/RadioButtonAndroid.tsx b/src/components/RadioButton/RadioButtonAndroid.tsx
index 91520db69c..5bbcd5441e 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,8 @@ RadioButtonIOS.displayName = 'RadioButton.IOS';
const styles = StyleSheet.create({
container: {
- borderRadius: 18,
- padding: 6,
+ borderRadius: STATE_LAYER_SIZE / 2,
+ padding: (STATE_LAYER_SIZE - CHECKMARK_SIZE) / 2,
},
});
diff --git a/src/components/RadioButton/tokens.ts b/src/components/RadioButton/tokens.ts
new file mode 100644
index 0000000000..a3abdfe3e0
--- /dev/null
+++ b/src/components/RadioButton/tokens.ts
@@ -0,0 +1,7 @@
+/**
+ * MD3 Radio button spec dimensions.
+ * @see https://m3.material.io/components/radio-button/specs
+ */
+export const RadioButtonTokens = {
+ stateLayerSize: 40,
+} as const;
diff --git a/src/components/SegmentedButtons/SegmentedButtonItem.tsx b/src/components/SegmentedButtons/SegmentedButtonItem.tsx
index 3b9962a457..b5f5c7e6d7 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 && (
+
+ )}
;
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 3b98d252c7..b401d94cc9 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..cf14dda3ab 100644
--- a/src/components/__tests__/Checkbox/Checkbox.test.tsx
+++ b/src/components/__tests__/Checkbox/Checkbox.test.tsx
@@ -58,3 +58,11 @@ it('renders Checkbox with custom testID', async () => {
expect(tree).toMatchSnapshot();
});
+
+it('renders disabled Checkbox', async () => {
+ const tree = (
+ await render()
+ ).toJSON();
+
+ expect(tree).toMatchSnapshot();
+});
diff --git a/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap b/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap
index 202a95467a..c9c6809c5c 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]}
@@ -553,6 +577,190 @@ exports[`renders checked Checkbox with onPress 1`] = `
`;
+exports[`renders disabled Checkbox 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
exports[`renders indeterminate Checkbox 1`] = `
{
});
});
});
+
+describe('close affordance', () => {
+ 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
+
+ );
+
+ 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..d735ae65aa 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,30 @@ 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('computes hitSlop from explicit width/height rather than the button size', async () => {
+ const tree = (
+ await render()
+ ).toJSON();
+
+ expect(tree).toMatchSnapshot();
+});
+
+it('drops hitSlop when explicit width/height already meet the 48dp minimum', async () => {
+ const tree = (
+ await render()
+ ).toJSON();
+
+ expect(tree).toMatchSnapshot();
+});
+
it('renders icon change animated', async () => {
const tree = (await render()).toJSON();
@@ -80,6 +104,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..c4f46d5234 100644
--- a/src/components/__tests__/RadioButton/RadioButton.test.tsx
+++ b/src/components/__tests__/RadioButton/RadioButton.test.tsx
@@ -82,4 +82,12 @@ describe('RadioButton', () => {
expect(tree).toMatchSnapshot();
});
});
+
+ it('renders disabled RadioButton', async () => {
+ const tree = (
+ await render()
+ ).toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
});
diff --git a/src/components/__tests__/RadioButton/__snapshots__/RadioButton.test.tsx.snap b/src/components/__tests__/RadioButton/__snapshots__/RadioButton.test.tsx.snap
index c20910f20e..c0514eea95 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,100 @@ 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]}
+ onResponderGrant={[Function]}
+ onResponderMove={[Function]}
+ onResponderRelease={[Function]}
+ onResponderTerminate={[Function]}
+ onResponderTerminationRequest={[Function]}
+ onStartShouldSetResponder={[Function]}
+ role="radio"
+ style={
+ [
+ {
+ "overflow": "hidden",
+ },
+ {
+ "borderRadius": 20,
+ "padding": 8,
+ },
+ ]
+ }
+>
+
+
+ check
+
+
+
+`;
+
+exports[`RadioButton renders disabled RadioButton 1`] = `
+ {
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', () => {
+ 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();
+ });
+
+ 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..c62251909f
--- /dev/null
+++ b/src/components/__tests__/TouchableRippleWeb.test.tsx
@@ -0,0 +1,149 @@
+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';
+
+const asElement = (node: TestInstance | string): TestInstance => {
+ if (typeof node === 'string') {
+ throw new Error('Expected an element, not a text node');
+ }
+ return node;
+};
+
+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
+ );
+};
+
+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 () => {
+ 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 () => {
+ 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));
+
+ 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%",
+ }
+ }
>
+
+
+ camera
+
+
+
+
+`;
+
+exports[`drops hitSlop when explicit width/height already meet the 48dp minimum 1`] = `
+
+
+
+
+ camera
+
+
+
+
+`;
+
+exports[`lets a caller-supplied hitSlop win even while disabled 1`] = `
+
+
+
+
+ camera
+
+
+
+
+`;
+
+exports[`renders disabled icon button 1`] = `
+
+
+
+ Button
+
+
+`;
+
+exports[`TouchableRipple hitSlop passes a caller-supplied hitSlop straight through 1`] = `
+
+
+ Button
+
+
+`;
+
exports[`TouchableRipple on iOS displays the underlay when pressed 1`] = `
`;
+
+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..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;