diff --git a/docs/6.x/docs/components/Chip/Chip.mdx b/docs/6.x/docs/components/Chip/Chip.mdx index f712768036..ffd3e27f30 100644 --- a/docs/6.x/docs/components/Chip/Chip.mdx +++ b/docs/6.x/docs/components/Chip/Chip.mdx @@ -100,14 +100,6 @@ export default MyComponent;
-### showSelectedOverlay Available in v5.x with theme version 3 - -
- - - -
- ### showSelectedCheck
@@ -196,15 +188,7 @@ export default MyComponent;
-### compact Available in v5.x with theme version 3 - -
- - - -
- -### elevated Available in v5.x with theme version 3 +### elevated
diff --git a/docs/src/data/componentDocs6x.json b/docs/src/data/componentDocs6x.json index 56b356912b..5d6a8a88d4 100644 --- a/docs/src/data/componentDocs6x.json +++ b/docs/src/data/componentDocs6x.json @@ -4336,9 +4336,9 @@ } ] }, - "description": "Mode of the chip.\n- `flat` - flat chip without outline.\n- `outlined` - chip with an outline.", + "description": "Mode of the chip.\n- `flat` - chip with a filled container.\n- `outlined` - chip with an outline when unselected.", "defaultValue": { - "value": "'flat'", + "value": "'outlined'", "computed": false } }, @@ -4355,7 +4355,7 @@ "tsType": { "name": "IconSource" }, - "description": "Icon to display for the `Chip`. Both icon and avatar cannot be specified." + "description": "Leading icon to display for the `Chip`. Takes precedence over `avatar` when both are specified." }, "avatar": { "required": false, @@ -4363,7 +4363,7 @@ "name": "ReactReactNode", "raw": "React.ReactNode" }, - "description": "Avatar to display for the `Chip`. Both icon and avatar cannot be specified." + "description": "Leading avatar to display for the `Chip`. Ignored when `icon` is also specified." }, "closeIcon": { "required": false, @@ -4388,14 +4388,7 @@ "tsType": { "name": "ColorValue" }, - "description": "Whether to style the chip color as selected.\nNote: With theme version 3 `selectedColor` doesn't apply to the `icon`.\n If you want specify custom color for the `icon`, render your own `Icon` component." - }, - "showSelectedOverlay": { - "required": false, - "tsType": { - "name": "boolean" - }, - "description": "@supported Available in v5.x with theme version 3\nWhether to display overlay on selected chip" + "description": "Custom color to use for the label, leading icon, trailing icon, and outlined border,\noverriding the default selected/unselected theme colors." }, "showSelectedCheck": { "required": false, @@ -4424,7 +4417,7 @@ "tsType": { "name": "PressableAndroidRippleConfig" }, - "description": "Type of background drawabale to display the feedback (Android).\nhttps://reactnative.dev/docs/pressable#rippleconfig" + "description": "Type of background drawable to display the feedback (Android).\nhttps://reactnative.dev/docs/pressable#rippleconfig" }, "aria-label": { "required": false, @@ -4471,9 +4464,16 @@ "tsType": { "name": "signature", "type": "function", - "raw": "() => void", + "raw": "(e: GestureResponderEvent) => void", "signature": { - "arguments": [], + "arguments": [ + { + "name": "e", + "type": { + "name": "GestureResponderEvent" + } + } + ], "return": { "name": "void" } @@ -4547,19 +4547,12 @@ }, "description": "The number of milliseconds a user must touch the element before executing `onLongPress`." }, - "compact": { - "required": false, - "tsType": { - "name": "boolean" - }, - "description": "@supported Available in v5.x with theme version 3\nSets smaller horizontal paddings `12dp` around label, when there is only label." - }, "elevated": { "required": false, "tsType": { "name": "boolean" }, - "description": "@supported Available in v5.x with theme version 3\nWhether chip should have the elevation.", + "description": "Whether the chip should have elevation.", "defaultValue": { "value": "false", "computed": false @@ -4576,7 +4569,7 @@ ], "raw": "StyleProp" }, - "description": "Style of chip's text" + "description": "Style of chip's text." }, "style": { "required": false, @@ -4628,7 +4621,7 @@ "tsType": { "name": "EllipsizeProp" }, - "description": "Ellipsize Mode for the children text" + "description": "Ellipsize Mode for the label text." }, "maxFontSizeMultiplier": { "required": false, diff --git a/example/src/Examples/ChipExample.tsx b/example/src/Examples/ChipExample.tsx index 8ee757fa0e..5483f35258 100644 --- a/example/src/Examples/ChipExample.tsx +++ b/example/src/Examples/ChipExample.tsx @@ -2,11 +2,14 @@ import * as React from 'react'; import { Image, StyleSheet, View } from 'react-native'; import color from 'color'; -import { Chip, List, Palette, Snackbar, Text } from 'react-native-paper'; +import { Chip, List, Palette, Snackbar } from 'react-native-paper'; import ScreenWrapper from '../ScreenWrapper'; +const filters = ['All', 'Unread', 'Starred']; + const ChipExample = () => { + const [selectedFilter, setSelectedFilter] = React.useState(filters[0]); const [snackbarProperties, setSnackbarProperties] = React.useState({ visible: false, text: '', @@ -16,51 +19,62 @@ const ChipExample = () => { return ( <> - + - {}} style={styles.chip}> - Simple + {filters.map((filter) => ( + setSelectedFilter(filter)} + style={styles.chip} + > + {filter} + + ))} + {}} style={styles.chip}> + With icon {}} style={styles.chip} > - With selected overlay + No check - {}} style={styles.chip}> - Elevated + + Disabled - {}}> - Compact chip + + + + + + {}} style={styles.chip}> + Outlined {}} - onClose={() => - setSnackbarProperties({ - visible: true, - text: 'Close button pressed', - }) - } style={styles.chip} - closeIconAccessibilityLabel="Close icon accessibility label" > - Close button + Flat {}} - onClose={() => - setSnackbarProperties({ - visible: true, - text: 'Heart icon close button pressed', - }) - } style={styles.chip} > - Icon + Elevated + + + + + { /> } onPress={() => {}} - onClose={() => - setSnackbarProperties({ - visible: true, - text: 'Avatar close button pressed', - }) - } style={styles.chip} > Avatar @@ -90,67 +98,10 @@ const ChipExample = () => { onPress={() => {}} style={styles.chip} > - Avatar (selected) - - - setSnackbarProperties({ - visible: true, - text: 'Disabled heart icon close button pressed', - }) - } - style={styles.chip} - > - Icon (disabled) + Selected avatar - } - style={styles.chip} - > - Avatar (disabled) - - - - - - {}} style={styles.chip}> - Simple - - {}} - style={styles.chip} - > - With selected overlay - - {}} - style={styles.chip} - > - Elevated - - {}} - style={styles.chip} - > - Compact chip - - {}} onClose={() => setSnackbarProperties({ @@ -160,208 +111,52 @@ const ChipExample = () => { } style={styles.chip} > - Close button + Removable {}} onClose={() => setSnackbarProperties({ visible: true, - text: 'Heart icon close button pressed', + text: 'Custom close button pressed', }) } style={styles.chip} + closeIconAccessibilityLabel="Custom close icon accessibility label" > - Icon - - - } - onPress={() => {}} - style={styles.chip} - > - Avatar - - - } - onPress={() => {}} - style={styles.chip} - > - Avatar (selected) - - - setSnackbarProperties({ - visible: true, - text: 'Disabled close button pressed', - }) - } - style={styles.chip} - > - Icon (disabled) - - - } - style={styles.chip} - > - Avatar (disabled) + Custom close - + + - {}} - compact - avatar={ - - } - style={[styles.chip, styles.customBorderRadius]} - > - Compact with custom border radius - {}} - compact - avatar={ - - } - style={[styles.chip, styles.customBorderRadius]} - > - Compact with custom border radius - - {}} - onLongPress={() => - setSnackbarProperties({ visible: true, text: '' }) - } - style={styles.chip} - > - With onLongPress - - {}} - style={[ - styles.chip, - { - backgroundColor: color(customColor).alpha(0.2).rgb().string(), - }, - ]} - selectedColor={customColor} - > - Flat selected chip with custom color - - {}} - style={styles.chip} selectedColor={customColor} - > - Flat unselected chip with custom color - - {}} style={[ styles.chip, { backgroundColor: color(customColor).alpha(0.2).rgb().string(), }, ]} - selectedColor={customColor} - > - Outlined selected chip with custom color - - {}} - style={styles.chip} - selectedColor={customColor} - > - Outlined unselected chip with custom color - - {}} - style={styles.chip} - textStyle={styles.tiny} - > - With custom size - - {}} - onClose={() => - setSnackbarProperties({ - visible: true, - text: 'Close button pressed', - }) - } - style={styles.bigTextFlex} - textStyle={styles.bigTextStyle} - ellipsizeMode="middle" > - With a very big text: React Native Paper is a high-quality, - standard-compliant Material Design library that has you covered in - all major use-cases. + Custom color {}} - onClose={() => - setSnackbarProperties({ - visible: true, - text: 'Custom icon close button pressed', - }) - } - closeIcon="arrow-down" - style={styles.chip} - closeIconAccessibilityLabel="Custom Close icon accessibility label" + style={[styles.chip, styles.customBorderRadius]} > - With custom close icon + Rounded - {}} - style={styles.chip} - textStyle={styles.tiny} - > - With custom text + {}} style={styles.fullWidthChip}> + Full width chip - {}} style={styles.fullWidthChip}> - Full width chip - ({ onChange(option)} > {option} diff --git a/example/src/Examples/TeamDetails.tsx b/example/src/Examples/TeamDetails.tsx index 5970274f31..59c5d2c63e 100644 --- a/example/src/Examples/TeamDetails.tsx +++ b/example/src/Examples/TeamDetails.tsx @@ -57,12 +57,7 @@ const News = () => { style={styles.chipsContainer} contentContainerStyle={styles.chipsContent} > - {}} - style={styles.chip} - showSelectedOverlay - > + {}} style={styles.chip}> Latest {}} style={styles.chip}> diff --git a/src/components/Chip/Chip.tsx b/src/components/Chip/Chip.tsx index 1b09c327a8..e36295cf76 100644 --- a/src/components/Chip/Chip.tsx +++ b/src/components/Chip/Chip.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Animated, Platform, StyleSheet, Pressable, View } from 'react-native'; +import { Animated, Platform, StyleSheet, View } from 'react-native'; import type { ColorValue, GestureResponderEvent, @@ -9,27 +9,24 @@ import type { ViewStyle, } from 'react-native'; -import useLatestCallback from 'use-latest-callback'; - -import { getChipColors } from './helpers'; -import type { ChipAvatarProps } from './helpers'; +import { ChipTokens } from './tokens'; +import { getChipColors } from './utils'; +import type { ChipAvatarProps } from './utils'; import { useInternalTheme } from '../../core/theming'; -import { white } from '../../theme/colors'; -import type { $Omit, EllipsizeProp, Theme, ThemeProp } from '../../types'; +import type { EllipsizeProp, ThemeProp } from '../../types'; import hasTouchHandler from '../../utils/hasTouchHandler'; import type { IconSource } from '../Icon'; import Icon from '../Icon'; -import MaterialCommunityIcon from '../MaterialCommunityIcon'; import Surface from '../Surface'; import TouchableRipple from '../TouchableRipple/TouchableRipple'; import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple'; import Text from '../Typography/Text'; -export type Props = $Omit, 'mode'> & { +export type Props = Omit, 'mode'> & { /** * Mode of the chip. - * - `flat` - flat chip without outline. - * - `outlined` - chip with an outline. + * - `flat` - chip with a filled container. + * - `outlined` - chip with an outline when unselected. */ mode?: 'flat' | 'outlined'; /** @@ -37,11 +34,11 @@ export type Props = $Omit, 'mode'> & { */ children: React.ReactNode; /** - * Icon to display for the `Chip`. Both icon and avatar cannot be specified. + * Leading icon to display for the `Chip`. Takes precedence over `avatar` when both are specified. */ icon?: IconSource; /** - * Avatar to display for the `Chip`. Both icon and avatar cannot be specified. + * Leading avatar to display for the `Chip`. Ignored when `icon` is also specified. */ avatar?: React.ReactNode; /** @@ -53,16 +50,10 @@ export type Props = $Omit, 'mode'> & { */ selected?: boolean; /** - * Whether to style the chip color as selected. - * Note: With theme version 3 `selectedColor` doesn't apply to the `icon`. - * If you want specify custom color for the `icon`, render your own `Icon` component. + * Custom color to use for the label, leading icon, trailing icon, and outlined border, + * overriding the default selected/unselected theme colors. */ selectedColor?: ColorValue; - /** - * @supported Available in v5.x with theme version 3 - * Whether to display overlay on selected chip - */ - showSelectedOverlay?: boolean; /** * Whether to display default check icon on selected chip. * Note: Check will not be shown if `icon` is specified. If specified, `icon` will be shown regardless of `selected`. @@ -73,7 +64,7 @@ export type Props = $Omit, 'mode'> & { */ disabled?: boolean; /** - * Type of background drawabale to display the feedback (Android). + * Type of background drawable to display the feedback (Android). * https://reactnative.dev/docs/pressable#rippleconfig */ background?: PressableAndroidRippleConfig; @@ -92,7 +83,7 @@ export type Props = $Omit, 'mode'> & { /** * Function to execute on long press. */ - onLongPress?: () => void; + onLongPress?: (e: GestureResponderEvent) => void; /** * Function to execute as soon as the touchable element is pressed and invoked even before onPress. */ @@ -110,17 +101,11 @@ export type Props = $Omit, 'mode'> & { */ delayLongPress?: number; /** - * @supported Available in v5.x with theme version 3 - * Sets smaller horizontal paddings `12dp` around label, when there is only label. - */ - compact?: boolean; - /** - * @supported Available in v5.x with theme version 3 - * Whether chip should have the elevation. + * Whether the chip should have elevation. */ elevated?: boolean; /** - * Style of chip's text + * Style of chip's text. */ textStyle?: StyleProp; style?: Animated.WithAnimatedValue>; @@ -137,7 +122,7 @@ export type Props = $Omit, 'mode'> & { */ testID?: string; /** - * Ellipsize Mode for the children text + * Ellipsize Mode for the label text. */ ellipsizeMode?: EllipsizeProp; /** @@ -170,7 +155,7 @@ export type Props = $Omit, 'mode'> & { * ``` */ const Chip = ({ - mode = 'flat', + mode = 'outlined', children, icon, avatar, @@ -194,18 +179,13 @@ const Chip = ({ selectedColor, showSelectedCheck = true, ellipsizeMode, - compact, elevated = false, maxFontSizeMultiplier, hitSlop, ...rest }: Props) => { const theme = useInternalTheme(themeOverrides); - const isWeb = Platform.OS === 'web'; - - const { current: elevation } = React.useRef( - new Animated.Value(elevated ? 1 : 0) - ); + const isOutlined = mode === 'outlined'; const hasPassedTouchHandler = hasTouchHandler({ onPress, @@ -213,35 +193,9 @@ const Chip = ({ onPressIn, onPressOut, }); + const isTouchableDisabled = disabled || !hasPassedTouchHandler; - const isOutlined = mode === 'outlined'; - - const handlePressIn = useLatestCallback((e: GestureResponderEvent) => { - const { scale } = theme.animation; - onPressIn?.(e); - Animated.timing(elevation, { - toValue: elevated ? 2 : 0, - duration: 200 * scale, - useNativeDriver: - isWeb || Platform.constants.reactNativeVersion.minor <= 72, - }).start(); - }); - - const handlePressOut = useLatestCallback((e: GestureResponderEvent) => { - const { scale } = theme.animation; - onPressOut?.(e); - Animated.timing(elevation, { - toValue: elevated ? 1 : 0, - duration: 150 * scale, - useNativeDriver: - isWeb || Platform.constants.reactNativeVersion.minor <= 72, - }).start(); - }); - - const opacity = 0.38; const defaultBorderRadius = theme.shapes.corner.small; - const iconSize = 18; - const { backgroundColor: customBackgroundColor, borderRadius = defaultBorderRadius, @@ -251,38 +205,55 @@ const Chip = ({ borderColor, textColor, iconColor, + closeIconColor, contentOpacity, selectedBackgroundColor, backgroundColor, + rippleColor, + avatarOverlayColor, } = getChipColors({ isOutlined, + selected, + elevated, theme, selectedColor, customBackgroundColor, disabled, }); - const elevationStyle = elevation; - const multiplier = compact ? 1.5 : 2; - const labelSpacings = { - marginRight: onClose ? 0 : 8 * multiplier, - marginLeft: - avatar || icon || (selected && showSelectedCheck) - ? 4 * multiplier - : 8 * multiplier, - }; - const contentSpacings = { - paddingRight: onClose ? 34 : 0, - }; - const labelTextStyle = { - color: textColor, - ...(theme as Theme).fonts.labelLarge, + const hasAvatar = !!avatar && !icon; + const showSelectedIcon = selected && showSelectedCheck && !icon; + const showLeadingIcon = !!icon || showSelectedIcon; + const hasLeading = hasAvatar || showLeadingIcon; + const hasClose = !!onClose; + + const leftPadding = hasAvatar + ? ChipTokens.avatarLeadingPadding + : hasLeading + ? ChipTokens.iconLeadingPadding + : ChipTokens.leadingPadding; + const rightPadding = hasClose + ? ChipTokens.closeTrailingPadding + : ChipTokens.trailingPadding; + const touchTargetInset = + (ChipTokens.minimumTouchTarget - ChipTokens.containerHeight) / 2; + const touchTargetHitSlop = { + top: touchTargetInset, + bottom: touchTargetInset, }; + const closeAndroidRipple = + Platform.OS === 'android' + ? { + color: rippleColor, + borderless: true, + radius: ChipTokens.trailingIconTouchTarget / 2, + } + : undefined; + return ( - - {avatar && !icon ? ( - - {React.isValidElement(avatar) - ? React.cloneElement(avatar, { - style: [styles.avatar, avatar.props.style], - }) - : avatar} - - ) : null} - {icon || (selected && showSelectedCheck) ? ( - - {icon ? ( + + + + {hasAvatar ? ( + + {React.isValidElement(avatar) + ? React.cloneElement(avatar, { + style: [styles.avatar, avatar.props.style], + }) + : avatar} + {showSelectedIcon ? ( + - ) : ( - - )} - - ) : null} - - {children} - - - - {onClose ? ( - - - - {closeIcon ? ( - - ) : ( - - )} - - - + + ) : null} + + ) : null} + {showLeadingIcon && !hasAvatar ? ( + + + + ) : null} + + {children} + + + {hasClose ? ( + + + ) : null} ); @@ -420,72 +383,71 @@ const Chip = ({ const styles = StyleSheet.create({ container: { - borderWidth: StyleSheet.hairlineWidth, + height: ChipTokens.containerHeight, + borderWidth: ChipTokens.outlineWidth, borderStyle: 'solid', - flexDirection: Platform.select({ default: 'column', web: 'row' }), - }, - md3Container: { - borderWidth: 1, - }, - content: { flexDirection: 'row', alignItems: 'center', - paddingLeft: 4, - position: 'relative', + alignSelf: 'flex-start', }, - md3Content: { - paddingLeft: 0, - }, - icon: { - padding: 4, - alignSelf: 'center', + touchable: { + height: '100%', + flexGrow: 1, + flexShrink: 1, }, - md3Icon: { - paddingLeft: 8, - paddingRight: 0, + rippleLayer: { + ...StyleSheet.absoluteFill, }, - closeIcon: { - marginRight: 4, + rippleContent: { + flex: 1, }, - md3CloseIcon: { - marginRight: 8, - padding: 0, + content: { + height: '100%', + flexGrow: 1, + flexShrink: 1, + flexDirection: 'row', + alignItems: 'center', + position: 'relative', + overflow: 'hidden', }, - md3LabelText: { - textAlignVertical: 'center', - marginVertical: 6, + avatarWrapper: { + width: ChipTokens.avatarSize, + height: ChipTokens.avatarSize, + borderRadius: ChipTokens.avatarSize / 2, + marginRight: ChipTokens.leadingLabelGap, + overflow: 'hidden', }, avatar: { - width: 24, - height: 24, - borderRadius: 12, + width: ChipTokens.avatarSize, + height: ChipTokens.avatarSize, + borderRadius: ChipTokens.avatarSize / 2, }, - avatarWrapper: { - marginRight: 4, - }, - md3AvatarWrapper: { - marginLeft: 4, - marginRight: 0, + avatarSelectedOverlay: { + ...StyleSheet.absoluteFill, + alignItems: 'center', + justifyContent: 'center', }, - md3SelectedIcon: { - paddingLeft: 4, + leadingIcon: { + width: ChipTokens.leadingIconSize, + height: ChipTokens.leadingIconSize, + marginRight: ChipTokens.leadingLabelGap, + alignItems: 'center', + justifyContent: 'center', }, - // eslint-disable-next-line react-native/no-color-literals - avatarSelected: { - position: 'absolute', - top: 4, - left: 4, - backgroundColor: 'rgba(0, 0, 0, .29)', + labelText: { + textAlignVertical: 'center', + includeFontPadding: false, }, - closeButtonStyle: { - position: 'absolute', - right: 0, + closeButton: { + width: ChipTokens.trailingIconTouchTarget, height: '100%', - justifyContent: 'center', + borderRadius: ChipTokens.trailingIconTouchTarget / 2, + overflow: 'hidden', alignItems: 'center', + justifyContent: 'center', }, - touchable: { - width: '100%', + disabled: { + opacity: ChipTokens.disabledContentOpacity, }, }); diff --git a/src/components/Chip/helpers.tsx b/src/components/Chip/helpers.tsx deleted file mode 100644 index 4b0fdf9e06..0000000000 --- a/src/components/Chip/helpers.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import type { ColorValue, StyleProp, ViewStyle } from 'react-native'; - -import color from 'color'; - -import { tokens } from '../../theme/tokens'; -import type { InternalTheme, Theme } from '../../types'; - -const md3 = (theme: InternalTheme) => theme as Theme; - -const stateOpacity = tokens.md.sys.state.opacity; - -export type ChipAvatarProps = { - style?: StyleProp; -}; - -type BaseProps = { - theme: InternalTheme; - isOutlined: boolean; - disabled?: boolean; -}; - -const getBorderColor = ({ - theme, - isOutlined, - disabled, - selectedColor, -}: BaseProps & { backgroundColor: ColorValue; selectedColor?: ColorValue }) => { - const isSelectedColor = selectedColor !== undefined; - const { colors } = md3(theme); - - if (!isOutlined) { - // If the Chip mode is "flat", set border color to transparent - return 'transparent'; - } - - if (disabled) { - return colors.surfaceContainer; - } - - if (isSelectedColor) { - if (typeof selectedColor === 'string') { - return color(selectedColor).alpha(0.29).rgb().string(); - } - // PlatformColor / OpaqueColorValue: skip the alpha pass and render opaque. - return selectedColor; - } - - return colors.outlineVariant; -}; - -const getTextColor = ({ - theme, - isOutlined, - disabled, - selectedColor, -}: BaseProps & { - selectedColor?: ColorValue; -}) => { - const isSelectedColor = selectedColor !== undefined; - const { colors } = md3(theme); - if (disabled) { - return colors.onSurface; - } - - if (isSelectedColor) { - return selectedColor; - } - - if (isOutlined) { - return colors.onSurfaceVariant; - } - - return colors.onSecondaryContainer; -}; - -const getDefaultBackgroundColor = ({ - theme, - isOutlined, -}: Omit) => { - const { colors } = md3(theme); - if (isOutlined) { - return colors.surface; - } - - return colors.secondaryContainer; -}; - -const getBackgroundColor = ({ - theme, - isOutlined, - disabled, - customBackgroundColor, -}: BaseProps & { - customBackgroundColor?: ColorValue; -}) => { - const { colors } = md3(theme); - if (typeof customBackgroundColor === 'string') { - return customBackgroundColor; - } - - if (disabled) { - if (isOutlined) { - return 'transparent'; - } - return colors.surfaceContainerLow; - } - - return getDefaultBackgroundColor({ theme, isOutlined }); -}; - -const getSelectedBackgroundColor = ({ - theme, - isOutlined, - disabled, - customBackgroundColor, -}: BaseProps & { - customBackgroundColor?: ColorValue; -}) => { - return getBackgroundColor({ - theme, - disabled, - isOutlined, - customBackgroundColor, - }); -}; - -const getIconColor = ({ - theme, - isOutlined, - disabled, - selectedColor, -}: BaseProps & { - selectedColor?: ColorValue; -}) => { - const isSelectedColor = selectedColor !== undefined; - const { colors } = md3(theme); - if (disabled) { - return colors.onSurface; - } - - if (isSelectedColor) { - return selectedColor; - } - - if (isOutlined) { - return colors.onSurfaceVariant; - } - - return colors.onSecondaryContainer; -}; - -export const getChipColors = ({ - isOutlined, - theme, - selectedColor, - customBackgroundColor, - disabled, -}: BaseProps & { - customBackgroundColor?: ColorValue; - disabled?: boolean; - selectedColor?: ColorValue; -}) => { - const baseChipColorProps = { theme, isOutlined, disabled }; - - const backgroundColor = getBackgroundColor({ - ...baseChipColorProps, - customBackgroundColor, - }); - - const selectedBackgroundColor = getSelectedBackgroundColor({ - ...baseChipColorProps, - customBackgroundColor, - }); - - const contentOpacity = disabled - ? stateOpacity.disabled - : stateOpacity.enabled; - - return { - borderColor: getBorderColor({ - ...baseChipColorProps, - selectedColor, - backgroundColor, - }), - textColor: getTextColor({ - ...baseChipColorProps, - selectedColor, - }), - iconColor: getIconColor({ - ...baseChipColorProps, - selectedColor, - }), - contentOpacity, - backgroundColor, - selectedBackgroundColor, - }; -}; diff --git a/src/components/Chip/tokens.ts b/src/components/Chip/tokens.ts new file mode 100644 index 0000000000..aa87a2d3c2 --- /dev/null +++ b/src/components/Chip/tokens.ts @@ -0,0 +1,45 @@ +import type { ColorRole, TypescaleKey } from '../../theme/types'; + +/** + * MD3 Chip component tokens. + * @see https://m3.material.io/components/chips/specs + */ +const sizes = { + containerHeight: 32, + minimumTouchTarget: 48, + outlineWidth: 1, + leadingIconSize: 18, + trailingIconSize: 18, + avatarSize: 24, + selectedIconSize: 18, + leadingPadding: 16, + trailingPadding: 16, + iconLeadingPadding: 8, + avatarLeadingPadding: 4, + closeTrailingPadding: 8, + leadingLabelGap: 8, + trailingIconTouchTarget: 32, + labelTypescale: 'labelLarge' as TypescaleKey, + + disabledContentOpacity: 0.38, + + flatElevation: 0, + elevatedElevation: 1, +} as const; + +const colors = { + elevatedContainerColor: 'surfaceContainerLow', + flatContainerColor: 'surfaceContainerLow', + selectedContainerColor: 'secondaryContainer', + outlinedContainerColor: 'surface', + labelColor: 'onSurfaceVariant', + selectedLabelColor: 'onSecondaryContainer', + leadingIconColor: 'primary', + selectedIconColor: 'onSecondaryContainer', + trailingIconColor: 'onSurfaceVariant', + selectedTrailingIconColor: 'onSecondaryContainer', + outlineColor: 'outline', + disabledColor: 'onSurface', +} as const satisfies Record; + +export const ChipTokens = { ...sizes, ...colors }; diff --git a/src/components/Chip/utils.ts b/src/components/Chip/utils.ts new file mode 100644 index 0000000000..6f84ac7387 --- /dev/null +++ b/src/components/Chip/utils.ts @@ -0,0 +1,195 @@ +import type { ColorValue, StyleProp, ViewStyle } from 'react-native'; + +import { ChipTokens } from './tokens'; +import type { InternalTheme } from '../../types'; + +export type ChipAvatarProps = { + style?: StyleProp; +}; + +type BaseProps = { + theme: InternalTheme; + isOutlined: boolean; + selected?: boolean; + disabled?: boolean; + elevated?: boolean; +}; + +const getContainerColor = ({ + theme, + isOutlined, + selected, + disabled, + elevated, + customBackgroundColor, +}: BaseProps & { + customBackgroundColor?: ColorValue; +}) => { + if (disabled) { + return isOutlined ? 'transparent' : theme.colors.stateLayerPressed; + } + + if (customBackgroundColor !== undefined) { + return customBackgroundColor; + } + + if (selected) { + return theme.colors[ChipTokens.selectedContainerColor]; + } + + if (isOutlined) { + return theme.colors[ChipTokens.outlinedContainerColor]; + } + + return elevated + ? theme.colors[ChipTokens.elevatedContainerColor] + : theme.colors[ChipTokens.flatContainerColor]; +}; + +const getBorderColor = ({ + theme, + isOutlined, + selected, + disabled, + selectedColor, +}: BaseProps & { + selectedColor?: ColorValue; +}) => { + if (!isOutlined || selected) { + return 'transparent'; + } + + if (disabled) { + return theme.colors.outlineVariant; + } + + if (selectedColor !== undefined) { + return selectedColor; + } + + return theme.colors[ChipTokens.outlineColor]; +}; + +const getLabelColor = ({ + theme, + selected, + disabled, + selectedColor, +}: BaseProps & { + selectedColor?: ColorValue; +}) => { + if (disabled) { + return theme.colors[ChipTokens.disabledColor]; + } + + if (selectedColor !== undefined) { + return selectedColor; + } + + if (selected) { + return theme.colors[ChipTokens.selectedLabelColor]; + } + + return theme.colors[ChipTokens.labelColor]; +}; + +const getLeadingIconColor = ({ + theme, + selected, + disabled, + selectedColor, +}: BaseProps & { + selectedColor?: ColorValue; +}) => { + if (disabled) { + return theme.colors[ChipTokens.disabledColor]; + } + + if (selectedColor !== undefined) { + return selectedColor; + } + + if (selected) { + return theme.colors[ChipTokens.selectedIconColor]; + } + + return theme.colors[ChipTokens.leadingIconColor]; +}; + +const getTrailingIconColor = ({ + theme, + selected, + disabled, + selectedColor, +}: BaseProps & { + selectedColor?: ColorValue; +}) => { + if (disabled) { + return theme.colors[ChipTokens.disabledColor]; + } + + if (selectedColor !== undefined) { + return selectedColor; + } + + if (selected) { + return theme.colors[ChipTokens.selectedTrailingIconColor]; + } + + return theme.colors[ChipTokens.trailingIconColor]; +}; + +export const getChipColors = ({ + isOutlined, + theme, + selected, + selectedColor, + customBackgroundColor, + disabled, + elevated, +}: BaseProps & { + customBackgroundColor?: ColorValue; + disabled?: boolean; + selectedColor?: ColorValue; +}) => { + const baseChipColorProps = { + theme, + isOutlined, + selected, + disabled, + elevated, + }; + + const contentOpacity = disabled ? ChipTokens.disabledContentOpacity : 1; + + return { + borderColor: getBorderColor({ + ...baseChipColorProps, + selectedColor, + }), + textColor: getLabelColor({ + ...baseChipColorProps, + selectedColor, + }), + iconColor: getLeadingIconColor({ + ...baseChipColorProps, + selectedColor, + }), + closeIconColor: getTrailingIconColor({ + ...baseChipColorProps, + selectedColor, + }), + contentOpacity, + backgroundColor: getContainerColor({ + ...baseChipColorProps, + customBackgroundColor, + }), + selectedBackgroundColor: getContainerColor({ + ...baseChipColorProps, + selected: true, + customBackgroundColor, + }), + rippleColor: theme.colors.stateLayerPressed, + avatarOverlayColor: theme.colors.stateLayerPressed, + }; +}; diff --git a/src/components/__tests__/Chip.test.tsx b/src/components/__tests__/Chip.test.tsx index 644906ae33..ec24db14d1 100644 --- a/src/components/__tests__/Chip.test.tsx +++ b/src/components/__tests__/Chip.test.tsx @@ -2,15 +2,12 @@ import { Animated } from 'react-native'; import { describe, expect, it, jest } from '@jest/globals'; import { act } from '@testing-library/react-native'; -import color from 'color'; import { getTheme } from '../../core/theming'; import { render, screen } from '../../test-utils'; -import { tokens } from '../../theme/tokens'; +import * as Avatar from '../Avatar/Avatar'; import Chip from '../Chip/Chip'; -import { getChipColors } from '../Chip/helpers'; - -const stateOpacity = tokens.md.sys.state.opacity; +import { getChipColors } from '../Chip/utils'; it('renders chip with onPress', async () => { const tree = ( @@ -70,6 +67,40 @@ it('renders selected chip', async () => { expect(tree).toMatchSnapshot(); }); +it('renders chip with avatar', async () => { + const tree = ( + await render( + }>Example Chip + ) + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('renders selected chip with avatar and a check overlay', async () => { + const tree = ( + await render( + }> + Example Chip + + ) + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('prefers icon over avatar when both are specified', async () => { + const tree = ( + await render( + }> + Example Chip + + ) + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + it('renders disabled chip if there is no touch handler passed', async () => { await render(Disabled chip); @@ -86,6 +117,60 @@ it('renders active chip if only onLongPress handler is passed', async () => { expect(screen.getByTestId('active-chip')).toBeEnabled(); }); +it('applies disabled opacity to the close button', async () => { + await render( + {}} testID="disabled-chip"> + Disabled chip + + ); + + expect(screen.getByTestId('disabled-chip-close')).toHaveStyle({ + opacity: 0.38, + }); +}); + +it('spans the chip ripple behind the close button', async () => { + await render( + {}} onClose={() => {}} testID="chip"> + Removable chip + + ); + + expect(screen.getByTestId('chip')).toHaveStyle({ + bottom: 0, + left: 0, + position: 'absolute', + right: 0, + top: 0, + }); +}); + +it('clips the ripple to custom chip border radius', async () => { + await render( + {}} testID="rounded-chip" style={{ borderRadius: 16 }}> + Rounded chip + + ); + + expect(screen.getByTestId('rounded-chip')).toHaveStyle({ + borderRadius: 16, + overflow: 'hidden', + }); +}); + +it('renders close button with a circular state layer', async () => { + await render( + {}} testID="chip"> + Removable chip + + ); + + expect(screen.getByTestId('chip-close')).toHaveStyle({ + borderRadius: 16, + overflow: 'hidden', + }); +}); + it('renders chip with zero border radius', async () => { await render( @@ -108,7 +193,7 @@ describe('getChipColors - text color', () => { }) ).toMatchObject({ textColor: getTheme().colors.onSurface, - contentOpacity: stateOpacity.disabled, + contentOpacity: 0.38, }); }); @@ -119,7 +204,7 @@ describe('getChipColors - text color', () => { isOutlined: false, }) ).toMatchObject({ - textColor: getTheme().colors.onSecondaryContainer, + textColor: getTheme().colors.onSurfaceVariant, }); }); @@ -157,7 +242,7 @@ describe('getChipColors - icon color', () => { }) ).toMatchObject({ iconColor: getTheme().colors.onSurface, - contentOpacity: stateOpacity.disabled, + contentOpacity: 0.38, }); }); @@ -168,7 +253,7 @@ describe('getChipColors - icon color', () => { isOutlined: false, }) ).toMatchObject({ - iconColor: getTheme().colors.onSecondaryContainer, + iconColor: getTheme().colors.primary, }); }); @@ -179,7 +264,7 @@ describe('getChipColors - icon color', () => { isOutlined: true, }) ).toMatchObject({ - iconColor: getTheme().colors.onSurfaceVariant, + iconColor: getTheme().colors.primary, }); }); @@ -226,6 +311,7 @@ describe('getChipColor - selected background color', () => { getChipColors({ theme: getTheme(), isOutlined: false, + selected: true, }) ).toMatchObject({ selectedBackgroundColor: getTheme().colors.secondaryContainer, @@ -264,7 +350,43 @@ describe('getChipColor - background color', () => { isOutlined: false, }) ).toMatchObject({ - backgroundColor: getTheme().colors.secondaryContainer, + backgroundColor: getTheme().colors.surfaceContainerLow, + }); + }); + + it('uses the precomputed state layer color for disabled filled chips', () => { + const theme = getTheme(); + + expect( + getChipColors({ + theme, + disabled: true, + isOutlined: false, + }) + ).toMatchObject({ + backgroundColor: theme.colors.stateLayerPressed, + }); + }); +}); + +describe('getChipColor - ripple color', () => { + it('uses the precomputed state layer color', () => { + const theme = { + ...getTheme(), + colors: { + ...getTheme().colors, + stateLayerPressed: 'rgba(29, 27, 32, 0.1)', + }, + }; + + expect( + getChipColors({ + theme, + isOutlined: true, + }) + ).toMatchObject({ + rippleColor: 'rgba(29, 27, 32, 0.1)', + avatarOverlayColor: 'rgba(29, 27, 32, 0.1)', }); }); }); @@ -313,7 +435,21 @@ describe('getChipColor - border color', () => { isOutlined: true, }) ).toMatchObject({ - borderColor: color('purple').alpha(0.29).rgb().string(), + borderColor: 'purple', + }); + }); + + it('uses the tokenized outline color for disabled outlined chips', () => { + const theme = getTheme(); + + expect( + getChipColors({ + theme, + disabled: true, + isOutlined: true, + }) + ).toMatchObject({ + borderColor: theme.colors.outlineVariant, }); }); @@ -336,7 +472,7 @@ describe('getChipColor - border color', () => { isOutlined: true, }) ).toMatchObject({ - borderColor: getTheme(false).colors.outlineVariant, + borderColor: getTheme(false).colors.outline, }); }); @@ -347,7 +483,7 @@ describe('getChipColor - border color', () => { isOutlined: true, }) ).toMatchObject({ - borderColor: getTheme(true).colors.outlineVariant, + borderColor: getTheme(true).colors.outline, }); }); diff --git a/src/components/__tests__/ListItem.test.tsx b/src/components/__tests__/ListItem.test.tsx index b50f4e7d3f..b635b9f2f8 100644 --- a/src/components/__tests__/ListItem.test.tsx +++ b/src/components/__tests__/ListItem.test.tsx @@ -149,17 +149,18 @@ it('renders with a description with typeof number', async () => { it('calling onPress on ListItem right component', async () => { Platform.OS = 'web'; const onPress = jest.fn<(event: GestureResponderEvent) => void>(); + const user = userEvent.setup(); await render( } /> ); - await userEvent.press(screen.getByTestId('icon-button')); + await user.press(screen.getByTestId('icon-button')); expect(onPress).toHaveBeenCalledTimes(1); }); diff --git a/src/components/__tests__/__snapshots__/Chip.test.tsx.snap b/src/components/__tests__/__snapshots__/Chip.test.tsx.snap index 7bf18dde0e..71f1efba55 100644 --- a/src/components/__tests__/__snapshots__/Chip.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Chip.test.tsx.snap @@ -1,12 +1,14 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`renders chip with close button 1`] = ` +exports[`prefers icon over avatar when both are specified 1`] = ` - + + + - - information - - + } + > - Example Chip + information - - - - - - close - - - + { + "color": "rgba(73, 69, 79, 1)", + }, + undefined, + ], + ], + ] + } + > + Example Chip + `; -exports[`renders chip with custom close button 1`] = ` +exports[`renders chip with avatar 1`] = ` + + + @@ -411,198 +370,937 @@ exports[`renders chip with custom close button 1`] = ` style={ [ { - "alignSelf": "center", - "padding": 4, + "backgroundColor": "rgba(103, 80, 164, 1)", + "borderRadius": 12, + "height": 24, + "width": 24, }, { - "paddingLeft": 8, - "paddingRight": 0, + "alignItems": "center", + "justifyContent": "center", + }, + { + "borderRadius": 12, + "height": 24, + "width": 24, }, - null, ] } > - information + XD - + - Example Chip - - - - - + Example Chip + + + + +`; + +exports[`renders chip with close button 1`] = ` + + + + + + + + + information + + + + Example Chip + + + + + close + + + + +`; + +exports[`renders chip with custom close button 1`] = ` + + + + + + + + + information + + + + Example Chip + + + + + arrow-down + + + + +`; + +exports[`renders chip with icon 1`] = ` + + + + + + + - - + + - arrow-down - - - + { + "color": "rgba(73, 69, 79, 1)", + }, + undefined, + ], + ], + ] + } + > + Example Chip + `; -exports[`renders chip with icon 1`] = ` +exports[`renders chip with onPress 1`] = ` + + + - - - information - - - - Example Chip - - + ], + ] + } + > + Example Chip + `; -exports[`renders chip with onPress 1`] = ` +exports[`renders outlined disabled chip 1`] = ` + + + - - Example Chip - - + ], + ] + } + > + Example Chip + `; -exports[`renders outlined disabled chip 1`] = ` +exports[`renders selected chip 1`] = ` + + + - Example Chip + check + + Example Chip + `; -exports[`renders selected chip 1`] = ` +exports[`renders selected chip with avatar and a check overlay 1`] = ` + + + @@ -1234,14 +2014,72 @@ exports[`renders selected chip 1`] = ` style={ [ { - "alignSelf": "center", - "padding": 4, + "backgroundColor": "rgba(103, 80, 164, 1)", + "borderRadius": 12, + "height": 24, + "width": 24, + }, + { + "alignItems": "center", + "justifyContent": "center", + }, + { + "borderRadius": 12, + "height": 24, + "width": 24, + }, + ] + } + > + + XD + + + @@ -1253,7 +2091,7 @@ exports[`renders selected chip 1`] = ` style={ [ { - "color": "rgba(29, 25, 43, 1)", + "color": "rgba(254, 247, 255, 1)", "fontSize": 18, }, [ @@ -1275,52 +2113,43 @@ exports[`renders selected chip 1`] = ` check - + - Example Chip - - + ], + ] + } + > + Example Chip + diff --git a/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap b/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap index f06b87045f..c83f9df476 100644 --- a/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap @@ -121,8 +121,10 @@ exports[`renders list item with custom description 1`] = ` collapsable={false} style={ { - "backgroundColor": "rgba(232, 222, 248, 1)", + "alignSelf": "flex-start", + "backgroundColor": "rgba(254, 247, 255, 1)", "borderRadius": 8, + "height": 32, "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { "height": 0, @@ -138,13 +140,14 @@ exports[`renders list item with custom description 1`] = ` collapsable={false} style={ { - "backgroundColor": "rgba(232, 222, 248, 1)", - "borderColor": "transparent", + "alignItems": "center", + "backgroundColor": "rgba(254, 247, 255, 1)", + "borderColor": "rgba(121, 116, 126, 1)", "borderRadius": 8, "borderStyle": "solid", "borderWidth": 1, - "flex": undefined, - "flexDirection": "column", + "flex": 1, + "flexDirection": "row", "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { "height": 0, @@ -177,6 +180,12 @@ exports[`renders list item with custom description 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 8, + "top": 8, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -194,10 +203,19 @@ exports[`renders list item with custom description 1`] = ` }, [ { - "borderRadius": 8, + "flexGrow": 1, + "flexShrink": 1, + "height": "100%", }, { - "width": "100%", + "bottom": 0, + "left": 0, + "position": "absolute", + "right": 0, + "top": 0, + }, + { + "borderRadius": 8, }, ], ] @@ -206,116 +224,110 @@ exports[`renders list item with custom description 1`] = ` > - + + + - - file-pdf-box - - + } + > - DOCS.pdf + file-pdf-box + + DOCS.pdf +