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
7 changes: 4 additions & 3 deletions src/components/Checkbox/Checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,10 @@ const {

const FOCUS_THICKNESS = tokens.md.sys.state.focusIndicator.thickness;
// Focus indicator is a circular ring at the 40dp state-layer boundary.
// We don't apply `focusIndicator.outerOffset` here because the surrounding
// `TouchableRipple borderless` clips overflow to the tap-target shape,
// so a ring drawn outside the 40dp circle would be cropped.
// We don't apply `focusIndicator.outerOffset`, so the ring stays inside the 40dp
// circle. `TouchableRipple borderless` used to crop anything outside it; on web
// it no longer does, since the touchable cannot clip without clipping the touch
// target. Native still clips. Check both when revisiting the offset.
const FOCUS_RING_SIZE = STATE_LAYER_SIZE;
const FOCUS_RING_RADIUS = STATE_LAYER_SIZE / 2;

Expand Down
43 changes: 40 additions & 3 deletions src/components/Chip/Chip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,25 @@ export type Props = $Omit<React.ComponentProps<typeof Surface>, 'mode'> & {
* export default MyComponent;
* ```
*/
/**
* Room the chip reserves on its right for the close button, which fills all of
* it, so the body stops here and the two divide the chip.
*
* MD3 splits the same way and does not give a chip's trailing action 48dp; in
* material-web it is 24x24 with no expansion. This column is wider than that and
* gets no vertical expansion, so the strips above and below belong to the body
* and a near miss activates the chip rather than deleting it.
* @see https://github.com/material-components/material-web/blob/main/chips/internal/_trailing-icon.scss
*/
const CLOSE_AFFORDANCE_WIDTH = 34;

/**
* Floor for the clamp below. The glyph is 18dp and sits 8dp from the right, so
* under this it hangs over the chip body, and part of the visible icon would
* activate the chip instead of removing it.
*/
const CLOSE_AFFORDANCE_MIN_WIDTH = 26;

const Chip = ({
mode = 'flat',
children,
Expand Down Expand Up @@ -273,7 +292,7 @@ const Chip = ({
: 8 * multiplier,
};
const contentSpacings = {
paddingRight: onClose ? 34 : 0,
paddingRight: onClose ? CLOSE_AFFORDANCE_WIDTH : 0,
};
const labelTextStyle = {
color: textColor,
Expand Down Expand Up @@ -399,8 +418,12 @@ const Chip = ({
disabled={disabled}
role="button"
aria-label={closeIconAccessibilityLabel}
style={styles.closeButton}
>
<View style={[styles.icon, styles.closeIcon, styles.md3CloseIcon]}>
<View
testID={`${testID}-close-icon`}
style={[styles.icon, styles.closeIcon, styles.md3CloseIcon]}
>
{closeIcon ? (
<Icon source={closeIcon} color={iconColor} size={iconSize} />
) : (
Expand Down Expand Up @@ -451,6 +474,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',
Expand Down Expand Up @@ -481,9 +508,19 @@ const styles = StyleSheet.create({
closeButtonStyle: {
position: 'absolute',
right: 0,
width: CLOSE_AFFORDANCE_WIDTH,
// A chip narrower than this column would hand the whole thing to the close
// button. Never more than half, never less than the glyph needs; minWidth
// wins over maxWidth.
minWidth: CLOSE_AFFORDANCE_MIN_WIDTH,
maxWidth: '50%',
height: '100%',
},
closeButton: {
width: '100%',
height: '100%',
// Vertical only. The glyph pins itself horizontally with `alignSelf`.
justifyContent: 'center',
alignItems: 'center',
},
touchable: {
width: '100%',
Expand Down
48 changes: 34 additions & 14 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 { Animated, StyleSheet, View } from 'react-native';
import { Animated, Platform, StyleSheet, View } from 'react-native';
import type {
ColorValue,
GestureResponderEvent,
Expand All @@ -10,6 +10,7 @@ import type {
import { getIconButtonColor } from './utils';
import { useInternalTheme } from '../../core/theming';
import type { $RemoveChildren, ThemeProp } from '../../types';
import { splitStyles } from '../../utils/splitStyles';
import ActivityIndicator from '../ActivityIndicator';
import CrossFadeIcon from '../CrossFadeIcon';
import Icon from '../Icon';
Expand Down Expand Up @@ -147,16 +148,26 @@ const IconButton = ({

const buttonSize = size + 2 * PADDING;

const {
borderWidth = mode === 'outlined' && !selected ? 1 : 0,
borderRadius = buttonSize / 2,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
} = (StyleSheet.flatten(style) || {}) as ViewStyle;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const flattenedStyle = (StyleSheet.flatten(style) || {}) as ViewStyle;

const { borderWidth = mode === 'outlined' && !selected ? 1 : 0 } =
flattenedStyle;

const [, borderRadiusStyles] = splitStyles(
flattenedStyle,
(style) => style.startsWith('border') && style.endsWith('Radius')
);

const shapeStyles = {
borderRadius: buttonSize / 2,
...borderRadiusStyles,
};

const borderStyles = {
borderWidth,
borderRadius,
borderColor,
...shapeStyles,
};

return (
Expand All @@ -182,6 +193,7 @@ const IconButton = ({
style={[
StyleSheet.absoluteFill,
{ backgroundColor, opacity: backgroundOpacity },
shapeStyles,
]}
/>
)}
Expand All @@ -190,15 +202,18 @@ 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,
contentStyle,
]}
role="button"
aria-disabled={disabled}
disabled={disabled}
hitSlop={
TouchableRipple.supported
? { top: 10, left: 10, bottom: 10, right: 10 }
: { top: 6, left: 6, bottom: 6, right: 6 }
}
testID={testID}
{...rest}
>
Expand All @@ -216,7 +231,9 @@ const IconButton = ({

const styles = StyleSheet.create({
container: {
overflow: 'hidden',
// 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,
elevation: 0,
},
Expand All @@ -225,6 +242,9 @@ const styles = StyleSheet.create({
justifyContent: 'center',
alignItems: 'center',
},
clipToShape: {
overflow: 'hidden',
},
});

export default IconButton;
117 changes: 117 additions & 0 deletions src/components/TouchableRipple/TouchableRipple.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import type {
ViewStyle,
GestureResponderEvent,
ColorValue,
Insets,
LayoutChangeEvent,
} from 'react-native';

import type { PressableProps } from './Pressable';
Expand All @@ -14,12 +16,81 @@ import { getTouchableRippleColors } from './utils';
import { SettingsContext } from '../../core/settings';
import type { Settings } from '../../core/settings';
import { useInternalTheme } from '../../core/theming';
import { tokens } from '../../theme/tokens';
import type { ThemeProp } from '../../types';
import hasTouchHandler from '../../utils/hasTouchHandler';

const ANDROID_VERSION_LOLLIPOP = 21;
const ANDROID_VERSION_PIE = 28;

const { minInteractiveSize } = tokens.md.sys.state;

/**
* The underlay fills the touchable absolutely and has no radius of its own, so
* it paints square corners over a rounded one. A clipping ancestor used to hide
* that, and those ancestors have to stop clipping for the expansion to work.
*/
const getUnderlayShape = (style: StyleProp<ViewStyle>): ViewStyle => {
const flat = StyleSheet.flatten(style);

if (!flat) {
return {};
}

const {
borderRadius,
borderTopLeftRadius,
borderTopRightRadius,
borderBottomLeftRadius,
borderBottomRightRadius,
borderTopStartRadius,
borderTopEndRadius,
borderBottomStartRadius,
borderBottomEndRadius,
} = flat;

return {
borderRadius,
borderTopLeftRadius,
borderTopRightRadius,
borderBottomLeftRadius,
borderBottomRightRadius,
borderTopStartRadius,
borderTopEndRadius,
borderBottomStartRadius,
borderBottomEndRadius,
};
};

/**
* Slop needed to bring a rendered size up to `minInteractiveSize`. Expands
* outside the bounds rather than resizing, so a 40dp state layer keeps its 40dp
* and gains 4dp per side. Returns undefined when the size is already enough, so
* that case does not re-render.
* @see https://developer.android.com/develop/ui/compose/accessibility/api-defaults
*/
const getExpansion = (width: number, height: number): Insets | undefined => {
// A collapsed touchable would otherwise claim 24dp of slop around a point
// where nothing is drawn.
if (width === 0 || height === 0) {
return undefined;
}

const horizontal = Math.max(0, (minInteractiveSize - width) / 2);
const vertical = Math.max(0, (minInteractiveSize - height) / 2);

if (horizontal === 0 && vertical === 0) {
return undefined;
}

return {
top: vertical,
bottom: vertical,
left: horizontal,
right: horizontal,
};
};

export type Props = PressableProps & {
borderless?: boolean;
background?: PressableAndroidRippleConfig;
Expand All @@ -46,6 +117,8 @@ const TouchableRipple = ({
underlayColor,
children,
theme: themeOverrides,
hitSlop,
onLayout,
ref,
...rest
}: Props) => {
Expand All @@ -63,6 +136,45 @@ const TouchableRipple = ({

const disabled = disabledProp || !hasPassedTouchHandler;

const [expansion, setExpansion] = React.useState<Insets | undefined>(
undefined
);

// Gates whether the measurement is applied, not whether it happens. RN emits
// onLayout on mount and on layout change, so a touchable that mounts disabled,
// or with a caller hitSlop, gets no event once that goes away and would stay
// small. A caller hitSlop wins while it is set; `null` counts as set, it means
// "no slop".
const shouldExpand = hitSlop === undefined && !disabled;

const handleLayout = React.useCallback(
(event: LayoutChangeEvent) => {
onLayout?.(event);

const { width, height } = event.nativeEvent.layout;
const next = getExpansion(width, height);

setExpansion((current) => {
// Nothing changed, so a big enough touchable does not re-render.
if (current === next) {
return current;
}
if (
current &&
next &&
current.top === next.top &&
current.bottom === next.bottom &&
current.left === next.left &&
current.right === next.right
) {
return current;
}
return next;
});
},
[onLayout]
);

const { calculatedRippleColor, calculatedUnderlayColor } =
getTouchableRippleColors({
theme,
Expand Down Expand Up @@ -92,6 +204,8 @@ const TouchableRipple = ({
{...rest}
ref={ref}
disabled={disabled}
hitSlop={shouldExpand ? expansion : hitSlop}
onLayout={handleLayout}
style={[useForeground && styles.overflowHidden, style]}
android_ripple={androidRipple}
>
Expand All @@ -105,6 +219,8 @@ const TouchableRipple = ({
{...rest}
ref={ref}
disabled={disabled}
hitSlop={shouldExpand ? expansion : hitSlop}
onLayout={handleLayout}
style={[borderless && styles.overflowHidden, style]}
>
{({ pressed }) => (
Expand All @@ -114,6 +230,7 @@ const TouchableRipple = ({
testID="touchable-ripple-underlay"
style={[
styles.underlay,
getUnderlayShape(style),
{ backgroundColor: calculatedUnderlayColor },
]}
/>
Expand Down
Loading