Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/6.x/docs/guides/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,18 @@ The misspelled `traileringIcon` props have been renamed:
/>
```

### Chip

The close button (`onClose`) now fills the trailing 34dp column reserved for it, instead of only the 26x18 area around its icon, and its touch target extends to 48dp in height. Taps in that column that used to activate the chip's `onPress` now activate `onClose`.

### IconButton

Corner radii set through `style` no longer shape the press, hover and disabled feedback.

### TouchableRipple

`borderless` has no effect on web. The web ripple is always clipped to the touchable's shape, including when `centered` is set, and the touchable no longer clips its own content. A child that needs a clipped or rounded shape should carry that shape itself.

### TextInput

The Paper 6.x `TextInput` is a complete rewrite with a new API. Import the component the same way, but note that the props and behavior have changed significantly.
Expand Down
7 changes: 4 additions & 3 deletions src/components/Checkbox/Checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,10 @@ const {

const FOCUS_THICKNESS = tokens.md.sys.state.focusIndicator.thickness;
// Focus indicator is a circular ring at the 40dp state-layer boundary.
// We don't apply `focusIndicator.outerOffset` here because the surrounding
// `TouchableRipple borderless` clips overflow to the tap-target shape,
// so a ring drawn outside the 40dp circle would be cropped.
// We don't apply `focusIndicator.outerOffset`, keeping the ring inside the
// 40dp circle: whether TouchableRipple clips content past that boundary
// depends on platform and ripple mode, so staying inside it avoids relying
// on any of that.
const FOCUS_RING_SIZE = STATE_LAYER_SIZE;
const FOCUS_RING_RADIUS = STATE_LAYER_SIZE / 2;

Expand Down
116 changes: 112 additions & 4 deletions src/components/Chip/Chip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,28 @@ import { Platform, StyleSheet, Pressable, View } from 'react-native';
import type {
ColorValue,
GestureResponderEvent,
NativeSyntheticEvent,
PressableAndroidRippleConfig,
StyleProp,
TargetedEvent,
TextProps,
TextStyle,
ViewProps,
ViewStyle,
} from 'react-native';

import useLatestCallback from 'use-latest-callback';

import { getChipColors } from './helpers';
import type { ChipAvatarProps } from './helpers';
import { ChipTokens } from './tokens';
import { useInternalTheme } from '../../core/theming';
import { white } from '../../theme/colors';
import { tokens } from '../../theme/tokens';
import type { ThemeProp } from '../../theme/types';
import getMinInteractiveSizeHitSlop from '../../utils/getMinInteractiveSizeHitSlop';
import hasTouchHandler from '../../utils/hasTouchHandler';
import { isKeyboardFocusEvent } from '../../utils/isKeyboardFocusEvent';
import type { IconSource } from '../Icon';
import Icon from '../Icon';
import MaterialCommunityIcon from '../MaterialCommunityIcon';
Expand Down Expand Up @@ -156,6 +163,38 @@ export type Props = Omit<ViewProps, 'style'> & {
ref?: React.Ref<View>;
};

/**
* Width reserved on the right for the close button.
*/
Comment on lines +166 to +168

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment repeats the review discussion about material-web's SCSS. The earlier review asked to keep only comments that explain unclear code. A short description is enough here.

Suggested change
/**
* 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
*/
/**
* Width reserved on the right for the close button.
*/

(via Claude, verify if it makes sense)

const CLOSE_AFFORDANCE_WIDTH = 34;

/**
* Floor for the clamp below. The 18dp glyph is centered in the column, so at
* this width it sits 4dp from each side. Any narrower and part of the visible
* icon would activate the chip instead of removing it.
*/
const CLOSE_AFFORDANCE_MIN_WIDTH = 26;

/**
* The container height is fixed by spec, so the slop to reach the 48dp minimum
* is a constant rather than something to measure. Width grows with the label
* and the whole pill is already the target, so only the vertical axis needs it.
*/
const { containerHeight: CHIP_BODY_HEIGHT } = ChipTokens;
const CHIP_BODY_HIT_SLOP = getMinInteractiveSizeHitSlop({
height: CHIP_BODY_HEIGHT,
});
// The close button's own box is the same fixed height as the body, so it
// needs the same vertical slop to reach 48dp.
const CLOSE_BUTTON_WEB_TOUCH_TARGET_INSET = CHIP_BODY_HIT_SLOP?.top ?? 0;

const CLOSE_ICON_SIZE = 18;
const CLOSE_FOCUS_THICKNESS = tokens.md.sys.state.focusIndicator.thickness;

// Web-only style; `outline` is outside ViewStyle.
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const webNoOutline = { outline: 'none' } as unknown as ViewStyle;

/**
* Chips are compact elements that can represent inputs, attributes, or actions.
* They can have an icon or avatar on the left, and a close button icon on the right.
Expand Down Expand Up @@ -214,6 +253,7 @@ const Chip = ({
const theme = useInternalTheme(themeOverrides);

const [pressed, setPressed] = React.useState(false);
const [closeFocused, setCloseFocused] = React.useState(false);
const elevation = elevated ? (pressed ? 2 : 1) : 0;

const hasPassedTouchHandler = hasTouchHandler({
Expand All @@ -235,6 +275,18 @@ const Chip = ({
setPressed(false);
});

const handleCloseFocus = useLatestCallback(
(e: NativeSyntheticEvent<TargetedEvent>) => {
if (Platform.OS === 'web' && isKeyboardFocusEvent(e)) {
setCloseFocused(true);
}
}
);

const handleCloseBlur = useLatestCallback(() => {
setCloseFocused(false);
});

const elevationTransitionDuration =
theme.motion.duration[pressed ? 'short4' : 'short3'] *
theme.animation.scale;
Expand Down Expand Up @@ -270,7 +322,7 @@ const Chip = ({
};

const contentSpacings = {
paddingRight: onClose ? 34 : 0,
paddingRight: onClose ? CLOSE_AFFORDANCE_WIDTH : 0,
};

const labelTextStyle = {
Expand Down Expand Up @@ -304,7 +356,13 @@ const Chip = ({
aria-disabled={disabled}
testID={testID}
theme={theme}
hitSlop={hitSlop}
hitSlop={
hitSlop !== undefined
? hitSlop
: disabled
? undefined
: CHIP_BODY_HIT_SLOP
}
>
<View
style={[
Expand Down Expand Up @@ -391,8 +449,32 @@ const Chip = ({
role="button"
aria-label={closeIconAccessibilityLabel}
testID={closeIconTestID}
onFocus={handleCloseFocus}
onBlur={handleCloseBlur}
// The browser outline would wrap the whole column and cover the
// label, so a ring is drawn around the glyph instead.
style={[
styles.closeButton,
Platform.OS === 'web' ? webNoOutline : 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 && (
<View aria-hidden style={styles.closeButtonWebTouchTarget} />
)}
<View style={[styles.icon, styles.closeIcon, styles.md3CloseIcon]}>
{closeFocused && !disabled ? (
<View
pointerEvents="none"
style={[
styles.closeFocusRing,
{ borderColor: theme.colors.secondary },
]}
/>
) : null}
{closeIcon ? (
<Icon source={closeIcon} color={iconColor} size={iconSize} />
) : (
Expand Down Expand Up @@ -428,6 +510,7 @@ const styles = StyleSheet.create({
},
md3Content: {
paddingLeft: 0,
minHeight: CHIP_BODY_HEIGHT,
},
icon: {
padding: 4,
Expand All @@ -441,7 +524,7 @@ const styles = StyleSheet.create({
marginRight: 4,
},
md3CloseIcon: {
marginRight: 8,
marginRight: 0,
padding: 0,
},
md3LabelText: {
Expand Down Expand Up @@ -473,9 +556,34 @@ const styles = StyleSheet.create({
closeButtonStyle: {
position: 'absolute',
right: 0,
width: CLOSE_AFFORDANCE_WIDTH,
// A chip narrower than this column would hand the whole thing to the close
// button. Never more than half, never less than the glyph needs; minWidth
// wins over maxWidth.
minWidth: CLOSE_AFFORDANCE_MIN_WIDTH,
maxWidth: '50%',
height: '100%',
},
closeButton: {
width: '100%',
height: '100%',
justifyContent: 'center',
alignItems: 'center',
},
closeFocusRing: {
position: 'absolute',
top: -CLOSE_FOCUS_THICKNESS,
left: -CLOSE_FOCUS_THICKNESS,
width: CLOSE_ICON_SIZE + 2 * CLOSE_FOCUS_THICKNESS,
height: CLOSE_ICON_SIZE + 2 * CLOSE_FOCUS_THICKNESS,
borderRadius: CLOSE_ICON_SIZE / 2 + CLOSE_FOCUS_THICKNESS,
borderWidth: CLOSE_FOCUS_THICKNESS,
},
closeButtonWebTouchTarget: {
position: 'absolute',
top: -CLOSE_BUTTON_WEB_TOUCH_TARGET_INSET,
bottom: -CLOSE_BUTTON_WEB_TOUCH_TARGET_INSET,
left: 0,
right: 0,
},
touchable: {
width: '100%',
Expand Down
7 changes: 7 additions & 0 deletions src/components/Chip/tokens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* MD3 Chip spec dimensions.
* @see https://m3.material.io/components/chips/specs
*/
export const ChipTokens = {
containerHeight: 32,
} as const;
39 changes: 29 additions & 10 deletions src/components/IconButton/IconButton.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as React from 'react';
import { StyleSheet, View } from 'react-native';
import { Platform, StyleSheet, View } from 'react-native';
import type {
ColorValue,
GestureResponderEvent,
Expand All @@ -12,6 +12,7 @@ import Animated, { type AnimatedStyle } from 'react-native-reanimated';
import { getIconButtonColor } from './utils';
import { useInternalTheme } from '../../core/theming';
import type { ThemeProp } from '../../theme/types';
import getMinInteractiveSizeHitSlop from '../../utils/getMinInteractiveSizeHitSlop';
import ActivityIndicator from '../ActivityIndicator';
import CrossFadeIcon from '../CrossFadeIcon';
import Icon from '../Icon';
Expand Down Expand Up @@ -151,13 +152,25 @@ const IconButton = ({
});

const buttonSize = size + 2 * PADDING;
const borderWidth = mode === 'outlined' && !selected ? 1 : 0;

const shapeStyle = { borderRadius: buttonSize / 2 };

const borderStyles = {
borderWidth: mode === 'outlined' && !selected ? 1 : 0,
borderRadius: buttonSize / 2,
borderWidth,
borderColor,
...shapeStyle,
};

const touchableSize = buttonSize - 2 * borderWidth;

const hitSlop = disabled
? undefined
: getMinInteractiveSizeHitSlop({
width: touchableSize,
height: touchableSize,
});

return (
<Animated.View
ref={ref}
Expand All @@ -178,6 +191,7 @@ const IconButton = ({
style={[
StyleSheet.absoluteFill,
{ backgroundColor, opacity: backgroundOpacity },
shapeStyle,
]}
/>
)}
Expand All @@ -186,16 +200,19 @@ const IconButton = ({
centered
onPress={onPress}
aria-label={ariaLabel}
style={[styles.touchable, contentStyle]}
style={[
styles.touchable,
shapeStyle,
// Clip the ripple to the button shape. Not on web, where
// `overflow: 'hidden'` would also clip the hit slop target.
Platform.OS !== 'web' && styles.clipToShape,
contentStyle,
]}
role="button"
aria-disabled={disabled}
disabled={disabled}
hitSlop={
TouchableRipple.supported
? { top: 10, left: 10, bottom: 10, right: 10 }
: { top: 6, left: 6, bottom: 6, right: 6 }
}
testID={testID}
hitSlop={hitSlop}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex: We need to account for joined controls when adding this default. ToggleButton.Row renders 42x42 buttons with no gap, and on web the right button's expanded target covers the last 3px inside the left button's visible box. Browser hit testing selects the right button in that strip. We need component-specific slop for joined buttons so pressing inside one option doesn't activate its neighbor.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While working on ToggleButton.Row, I divided up hitSlop so adjacent buttons don't steal presses from each other on the shared edge, but that protection only applies to the horizontal Row layout.

When ToggleButton.Group is used bare, stacking buttons vertically (as shown in the "Group & enums" example), each button keeps its full, unshared hitSlop. Since there's no gap between them, pressing near the bottom edge of one button can activate the next one down instead. It is the same kind of overlap issue I fixed for Row, just not handled for vertical stacking.

Since Group is public API and documented to work without Row, this is reachable by anyone stacking toggle buttons vertically, not just in the example.

Options I see:

  • Add a ToggleButton.Column — mirrors Row's existing logic, just for top/bottom instead of left/right
  • Make ToggleButtonGroup orientation-aware so it handles this safely by default, without a new component

Which direction would you like for this PR, or should it be handled separately from the 48dp work?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After private chat we decided to remove any changes to ToggleButton components. So my question was resolved :)

{...rest}
>
<View style={{ opacity: iconOpacity }}>
Expand All @@ -213,13 +230,15 @@ const IconButton = ({
const styles = StyleSheet.create({
container: {
margin: 6,
overflow: 'hidden',
},
touchable: {
flexGrow: 1,
justifyContent: 'center',
alignItems: 'center',
},
clipToShape: {
overflow: 'hidden',
},
});

export default IconButton;
7 changes: 6 additions & 1 deletion src/components/SegmentedButtons/SegmentedButtonItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
import { useInternalTheme } from '../../core/theming';
import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext';
import type { ThemeProp } from '../../theme/types';
import getMinInteractiveSizeHitSlop from '../../utils/getMinInteractiveSizeHitSlop';
import type { IconSource } from '../Icon';
import Icon from '../Icon';
import TouchableRipple from '../TouchableRipple/TouchableRipple';
Expand Down Expand Up @@ -192,6 +193,10 @@ const SegmentedButtonItem = ({
};

const paddingVertical = getSegmentedButtonDensityPadding({ density });
const contentHeight = 2 * paddingVertical + iconSize;
const defaultHitSlop = disabled
? undefined
: getMinInteractiveSizeHitSlop({ height: contentHeight });

const rippleStyle: ViewStyle = {
borderRadius,
Expand All @@ -217,7 +222,7 @@ const SegmentedButtonItem = ({
style={rippleStyle}
background={background}
theme={theme}
hitSlop={hitSlop}
hitSlop={hitSlop !== undefined ? hitSlop : defaultHitSlop}
>
<View
style={[styles.content, { paddingVertical, opacity: textOpacity }]}
Expand Down
9 changes: 9 additions & 0 deletions src/components/TextInput/TextInputIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,17 @@ import { ACCESSORY_SIZE } from './constants';
import { styles } from './styles';
import { getIconColor } from './utils';
import { useInternalTheme } from '../../core/theming';
import getMinInteractiveSizeHitSlop from '../../utils/getMinInteractiveSizeHitSlop';
import IconButton from '../IconButton/IconButton';
import type { Props as IconButtonProps } from '../IconButton/IconButton';

// TextInput shrinks the button to ACCESSORY_SIZE through `style`, which
// IconButton can't see when computing its own slop.
const ACCESSORY_HIT_SLOP = getMinInteractiveSizeHitSlop({
width: ACCESSORY_SIZE,
height: ACCESSORY_SIZE,
});

export type TextInputAccessoryProps = {
style: StyleProp<ViewStyle>;
multiline: boolean;
Expand Down Expand Up @@ -82,6 +90,7 @@ const TextInputIcon = ({
return (
<View style={styles.iconWrapper}>
<IconButton
hitSlop={ACCESSORY_HIT_SLOP}
{...rest}
icon={icon}
iconColor={color}
Expand Down
Loading
Loading