Skip to content
Draft
10 changes: 9 additions & 1 deletion src/components/Card/Card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { getCardColors } from './utils';
import { useInternalTheme } from '../../core/theming';
import type { Elevation, ThemeProp } from '../../theme/types';
import hasTouchHandler from '../../utils/hasTouchHandler';
import { useFocusRing } from '../../utils/useFocusRing';
import Surface from '../Surface';
import type { SurfaceStyle } from '../Surface';

Expand Down Expand Up @@ -148,7 +149,10 @@ const Card = ({
...rest
}: (OutlinedCardProps | ElevatedCardProps | ContainedCardProps) & Props) => {
const theme = useInternalTheme(themeOverrides);

const { target: focusTarget, ring: focusRing } = useFocusRing(
disabled,
theme.colors.secondary
);
const isMode = React.useCallback(
(modeToCompare: Mode) => {
return cardMode === modeToCompare;
Expand Down Expand Up @@ -252,6 +256,10 @@ const Card = ({
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
onFocus={focusTarget.onFocus}
onBlur={focusTarget.onBlur}
{...focusRing.dataSetProps}
style={[{ borderRadius }, ...focusRing.style]}
>
{content}
</Pressable>
Expand Down
58 changes: 9 additions & 49 deletions src/components/Checkbox/Checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@ import { Platform, StyleSheet, View } from 'react-native';
import type {
ColorValue,
GestureResponderEvent,
NativeSyntheticEvent,
StyleProp,
TargetedEvent,
ViewStyle,
} from 'react-native';

Expand All @@ -16,9 +14,8 @@ import { getSelectionVisualState } from './utils';
import { useLocale } from '../../core/locale';
import { useInternalTheme } from '../../core/theming';
import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext';
import { tokens } from '../../theme/tokens';
import type { ThemeProp } from '../../theme/types';
import { isKeyboardFocusEvent } from '../../utils/isKeyboardFocusEvent';
import getMinInteractiveSizeHitSlop from '../../utils/getMinInteractiveSizeHitSlop';
import TouchableRipple from '../TouchableRipple/TouchableRipple';
import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple';

Expand Down Expand Up @@ -76,13 +73,12 @@ const {
stateLayerSize: STATE_LAYER_SIZE,
} = CheckboxTokens;

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.
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.
Expand Down Expand Up @@ -128,7 +124,6 @@ const Checkbox = ({
// Web (react-native-web) doesn't auto-mirror layout, so flip the mask
// anchor manually for RTL. Native handles it via `I18nManager`.
const flipMaskForWebRTL = Platform.OS === 'web' && direction === 'rtl';
const [focused, setFocused] = React.useState(false);

const selected = status === 'checked' || status === 'indeterminate';

Expand Down Expand Up @@ -202,19 +197,6 @@ const Checkbox = ({
}
const showIndeterminate = nextGlyph === 'indeterminate';

const handleFocus = React.useCallback(
(e: NativeSyntheticEvent<TargetedEvent>) => {
if (disabled) return;
if (!isKeyboardFocusEvent(e)) return;
setFocused(true);
},
[disabled]
);

const handleBlur = React.useCallback(() => {
setFocused(false);
}, []);

const checked: boolean | 'mixed' =
status === 'indeterminate' ? 'mixed' : status === 'checked';

Expand All @@ -238,24 +220,13 @@ const Checkbox = ({
borderless
centered
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={disabled}
{...accessibilityProps}
testID={testID}
style={[
styles.tapTarget,
Platform.OS === 'web' ? webNoOutline : undefined,
style,
]}
hitSlop={rest.hitSlop ?? (disabled ? undefined : CHECKBOX_HIT_SLOP)}
style={[styles.tapTarget, style]}
>
<View pointerEvents="none" style={styles.tapTargetInner}>
{focused && !disabled ? (
<View
pointerEvents="none"
style={[styles.focusRing, { borderColor: theme.colors.secondary }]}
/>
) : null}
<View style={[styles.container, { opacity: visual.containerOpacity }]}>
<Animated.View
pointerEvents="none"
Expand Down Expand Up @@ -301,10 +272,6 @@ const Checkbox = ({
);
};

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

const styles = StyleSheet.create({
tapTarget: {
width: STATE_LAYER_SIZE,
Expand All @@ -319,13 +286,6 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
},
focusRing: {
position: 'absolute',
width: FOCUS_RING_SIZE,
height: FOCUS_RING_SIZE,
borderRadius: FOCUS_RING_RADIUS,
borderWidth: FOCUS_THICKNESS,
},
container: {
width: CONTAINER_SIZE,
height: CONTAINER_SIZE,
Expand Down
75 changes: 71 additions & 4 deletions src/components/Chip/Chip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,13 @@ 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 { useFocusRing } from '../../utils/useFocusRing';
import type { IconSource } from '../Icon';
import Icon from '../Icon';
import MaterialCommunityIcon from '../MaterialCommunityIcon';
Expand Down Expand Up @@ -152,6 +155,35 @@ export type Props = Omit<ViewProps, 'style'> & {
ref?: React.Ref<View>;
};

/**
* 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;

/**
* 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,
});

/**
* 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 @@ -207,6 +239,14 @@ const Chip = ({
...rest
}: Props) => {
const theme = useInternalTheme(themeOverrides);
// The close affordance is a plain `Pressable`, not a `TouchableRipple`
// (see below), so it calls `useFocusRing` directly instead of going
// through `TouchableRipple`'s `focusRing` prop like the body does.
const { target: closeFocusTarget, ring: closeFocusRing } = useFocusRing(
disabled,
theme.colors.secondary,
'inward'
);

const [pressed, setPressed] = React.useState(false);
const elevation = elevated ? (pressed ? 2 : 1) : 0;
Expand Down Expand Up @@ -265,7 +305,7 @@ const Chip = ({
};

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

const labelTextStyle = {
Expand All @@ -286,6 +326,7 @@ const Chip = ({
>
<TouchableRipple
borderless
focusRing="inward"
background={background}
style={[{ borderRadius }, styles.touchable]}
onPress={onPress}
Expand All @@ -300,7 +341,7 @@ const Chip = ({
aria-disabled={disabled}
testID={testID}
theme={theme}
hitSlop={hitSlop}
hitSlop={hitSlop ?? (disabled ? undefined : CHIP_BODY_HIT_SLOP)}
>
<View
style={[
Expand Down Expand Up @@ -386,8 +427,19 @@ const Chip = ({
disabled={disabled}
role="button"
aria-label={closeIconAccessibilityLabel}
onFocus={closeFocusTarget.onFocus}
onBlur={closeFocusTarget.onBlur}
{...closeFocusRing.dataSetProps}
style={[
styles.closeButton,
{ borderRadius },
...closeFocusRing.style,
]}
>
<View style={[styles.icon, styles.closeIcon, styles.md3CloseIcon]}>
<View
testID={testID ? `${testID}-close-icon` : undefined}
style={[styles.icon, styles.closeIcon, styles.md3CloseIcon]}
>
{closeIcon ? (
<Icon source={closeIcon} color={iconColor} size={iconSize} />
) : (
Expand Down Expand Up @@ -423,6 +475,7 @@ const styles = StyleSheet.create({
},
md3Content: {
paddingLeft: 0,
minHeight: CHIP_BODY_HEIGHT,
},
icon: {
padding: 4,
Expand All @@ -438,6 +491,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 @@ -468,9 +525,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
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;
Loading
Loading