diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index a4d7123a09..0be620be03 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -279,3 +279,129 @@ const theme = { style={{ fontSize: 16, color: '#1C1B1F' }} /> ``` + +### Button + +`Button` is modernized to Material Design 3 Expressive. The button modes were +renamed, the default mode changed, and a size and shape scale were added. + +#### Mode + +- **`mode="contained"`** → **`mode="filled"`** +- **`mode="contained-tonal"`** → **`mode="tonal"`** + +```tsx +// Before (v5) + + + +// After (v6) + + +``` + +#### Default mode + +The default is now **`filled`**, not `text`, matching the MD3 emphasis +hierarchy. Pass `mode="text"` to keep the old look. + +```tsx +// Before (v5) - rendered as a text button + + +// After (v6) - same appearance + +``` + +#### Size and shape + +New `size` and `shape` props. `size` defaults to `small`, which matches the +only size 5.x had, so existing buttons keep their metrics. + +```tsx + + +``` + +The container now also morphs its corner while pressed. Pass +`animateShape={false}` to opt out. + +#### Toggle + +`selected` turns a button into a toggle. Leave it **undefined** to make it +a plain button + +```tsx + +``` + +#### Removed props + +- **`uppercase`** → `labelStyle={{ textTransform: 'uppercase' }}`. +- **`compact`** → `size="extra-small"`. +- **`contentStyle={{ flexDirection: 'row-reverse' }}`** → `iconPosition="trailing"`. +- **`elevation`** → elevation follows `mode="elevated"`. + +```tsx +// Before (v5) + + +// After (v6) + +``` + +#### Style + +`Button` renders on `Surface`, so its `style` follows the same rules: + +- **Border radius** is no longer set through `style`. Use `shape`, or override + the corner token that the size maps to. `round` is the full pill; `square` + reads `corner.medium` at `extra-small` and `small`; `corner.large` at + `medium`; and `corner.extraLarge` at `large` and `extra-large`. +- **Background color** is no longer set through `style`. Use `buttonColor`. +- **Animated styles** must come from Reanimated. A React Native + `Animated.Value` in `style` is not applied. + +```tsx +// Before (v5) + + +// After (v6) + +``` + +#### Appearance changes + +These need no code change, but the rendering differs: + +- The `outlined` label and icon use **`onSurfaceVariant`** instead of + `primary`. +- The default icon size is **20dp**, up from **18dp**. +- `labelStyle={{ fontSize }}` no longer changes the icon size. The icon follows + `size`; set both if you need them to match. +- The resting corner is the full pill radius instead of a fixed **20dp**. + +#### Card.Actions + +`Card.Actions` defaults its buttons to `outlined` for the first child and +**`filled`** for the rest, where it used to default to `contained`. It also no +longer forwards `compact` or `uppercase` to them. diff --git a/docs/6.x/docs/guides/react-navigation.md b/docs/6.x/docs/guides/react-navigation.md index 6c01200f11..33c1fac71b 100644 --- a/docs/6.x/docs/guides/react-navigation.md +++ b/docs/6.x/docs/guides/react-navigation.md @@ -86,7 +86,7 @@ function HomeScreen({ navigation }) { return ( Home Screen - diff --git a/docs/6.x/docs/guides/ripple-effect.md b/docs/6.x/docs/guides/ripple-effect.md index e6f6e503ef..7d5d97725b 100644 --- a/docs/6.x/docs/guides/ripple-effect.md +++ b/docs/6.x/docs/guides/ripple-effect.md @@ -18,7 +18,7 @@ The `rippleColor` prop is available for every pressable component which allows y diff --git a/docs/src/components/BannerExample.tsx b/docs/src/components/BannerExample.tsx index 56bd19f194..0ca2828fd9 100644 --- a/docs/src/components/BannerExample.tsx +++ b/docs/src/components/BannerExample.tsx @@ -97,10 +97,10 @@ const BannerExample = () => { - - {}} /> diff --git a/docs/src/data/screenshots.ts b/docs/src/data/screenshots.ts index 92bf8f2788..28c9865fcf 100644 --- a/docs/src/data/screenshots.ts +++ b/docs/src/data/screenshots.ts @@ -22,11 +22,11 @@ export const screenshots = { BottomNavigation: 'screenshots/bottom-navigation.gif', 'BottomNavigation.Bar': 'screenshots/bottom-navigation-tabs.jpg', Button: { - text: 'screenshots/button-1.png', - outlined: 'screenshots/button-2.png', - contained: 'screenshots/button-3.png', + filled: 'screenshots/button-3.png', + tonal: 'screenshots/button-5.png', elevated: 'screenshots/button-4.png', - 'contained-tonal': 'screenshots/button-5.png', + outlined: 'screenshots/button-2.png', + text: 'screenshots/button-1.png', }, Card: { elevated: 'screenshots/card-1.png', diff --git a/docs/src/data/themeColors.ts b/docs/src/data/themeColors.ts index 20f16962f7..d05d6b58c8 100644 --- a/docs/src/data/themeColors.ts +++ b/docs/src/data/themeColors.ts @@ -46,46 +46,82 @@ export const themeColors = { }, }, Button: { - active: { + default: { + filled: { + backgroundColor: 'theme.colors.primary', + textColor: 'theme.colors.onPrimary', + }, + tonal: { + backgroundColor: 'theme.colors.secondaryContainer', + textColor: 'theme.colors.onSecondaryContainer', + }, elevated: { - backgroundColor: 'theme.colors.elevation.level1', + backgroundColor: 'theme.colors.surfaceContainerLow', textColor: 'theme.colors.primary', }, - contained: { + outlined: { + textColor: 'theme.colors.onSurfaceVariant', + borderColor: 'theme.colors.outlineVariant', + }, + text: { + textColor: 'theme.colors.primary', + }, + }, + selected: { + filled: { backgroundColor: 'theme.colors.primary', textColor: 'theme.colors.onPrimary', }, - 'contained-tonal': { + tonal: { + backgroundColor: 'theme.colors.secondary', + textColor: 'theme.colors.onSecondary', + }, + elevated: { + backgroundColor: 'theme.colors.primary', + textColor: 'theme.colors.onPrimary', + }, + outlined: { + backgroundColor: 'theme.colors.inverseSurface', + textColor: 'theme.colors.inverseOnSurface', + }, + }, + unselected: { + filled: { + backgroundColor: 'theme.colors.surfaceContainer', + textColor: 'theme.colors.onSurfaceVariant', + }, + tonal: { backgroundColor: 'theme.colors.secondaryContainer', textColor: 'theme.colors.onSecondaryContainer', }, - outlined: { + elevated: { + backgroundColor: 'theme.colors.surfaceContainerLow', textColor: 'theme.colors.primary', - borderColor: 'theme.colors.outline', }, - text: { - textColor: 'theme.colors.primary', + outlined: { + textColor: 'theme.colors.onSurfaceVariant', + borderColor: 'theme.colors.outlineVariant', }, }, disabled: { - elevated: { - backgroundColor: 'theme.colors.surfaceDisabled', - textColor: 'theme.colors.onSurfaceDisabled', + filled: { + backgroundColor: 'theme.colors.onSurface', + textColor: 'theme.colors.onSurface', }, - contained: { - backgroundColor: 'theme.colors.surfaceDisabled', - textColor: 'theme.colors.onSurfaceDisabled', + tonal: { + backgroundColor: 'theme.colors.onSurface', + textColor: 'theme.colors.onSurface', }, - 'contained-tonal': { - backgroundColor: 'theme.colors.surfaceDisabled', - textColor: 'theme.colors.onSurfaceDisabled', + elevated: { + backgroundColor: 'theme.colors.onSurface', + textColor: 'theme.colors.onSurface', }, outlined: { - textColor: 'theme.colors.onSurfaceDisabled', - borderColor: 'theme.colors.surfaceDisabled', + textColor: 'theme.colors.onSurface', + borderColor: 'theme.colors.outlineVariant', }, text: { - textColor: 'theme.colors.onSurfaceDisabled', + textColor: 'theme.colors.onSurface', }, }, }, diff --git a/example/src/Examples/ButtonExample.tsx b/example/src/Examples/ButtonExample.tsx index 2a164e8e77..21cc82be37 100644 --- a/example/src/Examples/ButtonExample.tsx +++ b/example/src/Examples/ButtonExample.tsx @@ -1,110 +1,197 @@ +import * as React from 'react'; import { Image, StyleSheet, View } from 'react-native'; -import { Button, List, Text, useTheme } from 'react-native-paper'; +import { Button, Chip, List, Switch, Text, useTheme } from 'react-native-paper'; import ScreenWrapper from '../ScreenWrapper'; +type Mode = 'text' | 'outlined' | 'elevated' | 'filled' | 'tonal'; +type Size = 'extra-small' | 'small' | 'medium' | 'large' | 'extra-large'; +type Shape = 'round' | 'square'; +type IconPosition = 'leading' | 'trailing'; +/** + * `selected` is nullable: leaving it undefined is a plain button, which MD3 + * colours differently from a toggle that happens to be unselected. A boolean + * switch can't express all three, so this is a three-way. + */ +type ToggleState = 'off' | 'unselected' | 'selected'; + +const MODES: Mode[] = ['filled', 'tonal', 'elevated', 'outlined', 'text']; +const SIZES: Size[] = [ + 'extra-small', + 'small', + 'medium', + 'large', + 'extra-large', +]; +const SHAPES: Shape[] = ['round', 'square']; +const ICON_POSITIONS: IconPosition[] = ['leading', 'trailing']; +const TOGGLE_STATES: ToggleState[] = ['off', 'unselected', 'selected']; + +const selectedFor = (state: ToggleState) => + state === 'off' ? undefined : state === 'selected'; + +function OptionRow({ + label, + value, + options, + onChange, +}: { + label: string; + value: T; + options: readonly T[]; + onChange: (value: T) => void; +}) { + return ( + + + {label} + + + {options.map((option) => ( + onChange(option)} + style={styles.chip} + > + {option} + + ))} + + + ); +} + +const SwitchRow = ({ + label, + value, + onValueChange, +}: { + label: string; + value: boolean; + onValueChange: (value: boolean) => void; +}) => ( + + {label} + + +); + const ButtonExample = () => { const theme = useTheme(); - const color = theme.colors.inversePrimary; + // Playground state. + const [mode, setMode] = React.useState('filled'); + const [size, setSize] = React.useState('small'); + const [shape, setShape] = React.useState('round'); + const [iconPosition, setIconPosition] = + React.useState('leading'); + const [showIcon, setShowIcon] = React.useState(false); + const [disabled, setDisabled] = React.useState(false); + const [loading, setLoading] = React.useState(false); + const [toggleState, setToggleState] = React.useState('off'); + const [animateShape, setAnimateShape] = React.useState(true); + + // Selected state for the static toggle showcase below. + const [selectedToggles, setSelectedToggles] = React.useState< + Record + >({}); + const toggle = (key: string) => + setSelectedToggles((prev) => ({ ...prev, [key]: !prev[key] })); + return ( - - - - - - - + + + + + + + + + {showIcon && ( + + )} + + + + - + + - - - - - - + {MODES.map((m) => ( + + ))} - + + - - - - - + + - - - - - - + {SIZES.map((s) => ( + + ))} - + + + {SHAPES.map((shapeVariant) => ( + + {SIZES.map((s) => ( + + ))} + + ))} + + + + + {MODES.map((m) => { + const key = `toggle-${m}`; + const isSelected = !!selectedToggles[key]; + return ( + + ); + })} + + + + - - - - - - - - - - - - - - - - - - {( - [ - 'text', - 'outlined', - 'contained', - 'elevated', - 'contained-tonal', - ] as const - ).map((mode) => { - return ( - - ); - })} - - ); }; @@ -356,6 +359,34 @@ const ButtonExample = () => { ButtonExample.title = 'Button'; const styles = StyleSheet.create({ + preview: { + minHeight: 160, + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 16, + }, + optionRow: { + paddingHorizontal: 16, + paddingVertical: 4, + }, + optionLabel: { + marginBottom: 8, + }, + chips: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + }, + chip: { + marginBottom: 4, + }, + switchRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingVertical: 8, + }, row: { flexDirection: 'row', flexWrap: 'wrap', @@ -366,28 +397,14 @@ const styles = StyleSheet.create({ button: { margin: 4, }, - flexReverse: { - flexDirection: 'row-reverse', - }, - md3FontStyles: { - lineHeight: 32, - }, fontStyles: { fontWeight: '800', - fontSize: 24, - }, - flexGrow1Button: { - flexGrow: 1, - marginTop: 10, + fontSize: 20, }, - width100PercentButton: { + fullWidthButton: { width: '100%', marginTop: 10, }, - customPadding: { - paddingHorizontal: 12, - paddingVertical: 6, - }, }); export default ButtonExample; diff --git a/src/components/Banner.tsx b/src/components/Banner.tsx index dddcf330c0..06afa75142 100644 --- a/src/components/Banner.tsx +++ b/src/components/Banner.tsx @@ -255,7 +255,6 @@ const Banner = ({ {actions.map(({ label, ...others }, i) => ( * ); @@ -162,13 +226,17 @@ export type Props = Omit & { */ const Button = ({ disabled, - compact, - mode = 'text', + mode = 'filled', + size = 'small', + shape = 'round', + animateShape: animateShapeProp = true, + selected, dark, loading, icon, + iconPosition, buttonColor: customButtonColor, - textColor: customTextColor, + textColor: customLabelColor, children, 'aria-label': ariaLabel, accessibilityHint, @@ -181,27 +249,26 @@ const Button = ({ delayLongPress, style, theme: themeOverrides, - uppercase: uppercaseProp, contentStyle, labelStyle, testID = 'button', accessible, background, + rippleColor: customRippleColor, maxFontSizeMultiplier, touchableRef, ref, ...rest }: Props) => { const theme = useInternalTheme(themeOverrides); + const { direction } = useLocale(); + const isMode = (modeToCompare: ButtonMode) => mode === modeToCompare; - const isMode = React.useCallback( - (modeToCompare: ButtonMode) => { - return mode === modeToCompare; - }, - [mode] - ); - - const uppercase = uppercaseProp ?? false; + const requestedTrailingIcon = iconPosition === 'trailing'; + const shouldFlipForRTL = direction !== getDefaultDirection(); + const isTrailingIcon = shouldFlipForRTL + ? !requestedTrailingIcon + : requestedTrailingIcon; const hasPassedTouchHandler = hasTouchHandler({ onPress, @@ -210,184 +277,321 @@ const Button = ({ onLongPress, }); + const reduceMotion = useReduceMotion(); + const reanimatedReduceMotion = reduceMotion + ? ReduceMotion.Always + : ReduceMotion.Never; + const isElevationEntitled = !disabled && isMode('elevated'); - const initialElevation = 1; - const activeElevation = 2; + // Level 1 at rest, level 2 while pressed. `Surface` animates the change, so + // a `disabled` / `mode` change moves the shadow on the next render too. const [pressed, setPressed] = React.useState(false); - const elevation = isElevationEntitled ? pressed ? activeElevation : initialElevation : 0; - const handlePressIn = (e: GestureResponderEvent) => { - onPressIn?.(e); + // When the button is `selected`, flip the requested shape so the + // unselected/selected pair contrasts visually (round ↔ square). + const effectiveShape = getEffectiveButtonShape(shape, selected); + const sizeStyle = React.useMemo(() => getButtonSizeStyle(size), [size]); - if (isElevationEntitled) { - setPressed(true); - } - }; + // Shape morph: animate the corner on press (→ the pressed shape token) and on + // the `selected`/shape toggle. + const animateShape = animateShapeProp; + // A press morph under reduce-motion would pop instantly rather than animate, + // so skip it; a `selected` change still snaps to the new shape. + const morphOnPress = animateShape && !reduceMotion; + // `round` resolves to the `cornerFull` sentinel, so use the real pill radius + // (half the container height) instead: it keeps the spring bounded and lets + // the ripple clip match the container exactly. + const restingRadius = + effectiveShape === 'round' + ? sizeStyle.minHeight / 2 + : getButtonShapeRadius({ size, shape, theme, selected }); + const pressedRadius = getButtonPressedRadius({ size, theme }); + const animatedRadius = useSharedValue(restingRadius); + const restingRadiusRef = React.useRef(restingRadius); + const isRadiusMountedRef = React.useRef(false); - const handlePressOut = (e: GestureResponderEvent) => { - onPressOut?.(e); + // The morph stays imperative: a press can interrupt it mid-flight, which a + // duration-based transition can't express. + const radiusSpringConfig = React.useMemo( + () => ({ + ...toRawSpring(theme.motion.spring.fast.spatial), + reduceMotion: reanimatedReduceMotion, + }), + [theme.motion.spring.fast.spatial, reanimatedReduceMotion] + ); - if (isElevationEntitled) { - setPressed(false); - } - }; + const springRadiusTo = React.useCallback( + (toValue: number) => { + animatedRadius.value = withSpring(toValue, radiusSpringConfig); + }, + [animatedRadius, radiusSpringConfig] + ); + + const handlePressIn = React.useCallback( + (e: GestureResponderEvent) => { + onPressIn?.(e); + if (morphOnPress) { + springRadiusTo(pressedRadius); + } + if (isElevationEntitled) { + setPressed(true); + } + }, + [ + onPressIn, + morphOnPress, + springRadiusTo, + pressedRadius, + isElevationEntitled, + ] + ); - const elevationTransitionDuration = - theme.motion.duration[pressed ? 'short4' : 'short3'] * - theme.animation.scale; + const handlePressOut = React.useCallback( + (e: GestureResponderEvent) => { + onPressOut?.(e); + if (morphOnPress) { + springRadiusTo(restingRadiusRef.current); + } + if (isElevationEntitled) { + setPressed(false); + } + }, + [onPressOut, morphOnPress, springRadiusTo, isElevationEntitled] + ); - const borderRadius = theme.shapes.corner.largeIncreased; - const iconSize = 18; + // Snap on mount; animate when a toggle/shape change moves the resting radius. + React.useEffect(() => { + restingRadiusRef.current = restingRadius; + if (!isRadiusMountedRef.current) { + isRadiusMountedRef.current = true; + return; + } + if (animateShape) { + springRadiusTo(restingRadius); + } else { + animatedRadius.value = restingRadius; + } + }, [restingRadius, animateShape, animatedRadius, springRadiusTo]); const { backgroundColor, borderColor, - textColor, - textOpacity, + labelColor, + labelOpacity, borderWidth, backgroundOpacity, - } = getButtonColors({ - customButtonColor, - customTextColor, + } = React.useMemo( + () => + getButtonColors({ + customButtonColor, + customLabelColor, + theme, + mode, + size, + disabled, + dark, + selected, + }), + [ + customButtonColor, + customLabelColor, + theme, + mode, + size, + disabled, + dark, + selected, + ] + ); + + const rippleColor = React.useMemo( + () => getButtonRippleColor({ labelColor, customRippleColor }), + [labelColor, customRippleColor] + ); + + const containerColor = + backgroundOpacity < 1 ? 'transparent' : backgroundColor; + + // Snap rather than cross-fade when a transparent container is involved — see + // `getButtonTransitionDuration`. + const previousContainerColorRef = React.useRef(containerColor); + React.useEffect(() => { + previousContainerColorRef.current = containerColor; + }, [containerColor]); + + const surfaceTransitionDuration = getButtonTransitionDuration({ theme, - mode, - disabled, - dark, + pressed, + containerColor, + previousContainerColor: previousContainerColorRef.current, }); - const touchableStyle = { borderRadius }; + // The clip carries the same animated radius as the `Surface`, so the ripple + // and the disabled overlay follow the morph. + // + // TODO: revisit the focus ring's placement once #5084 lands. + // https://github.com/callstack/react-native-paper/pull/5084 adds MD3 keyboard + // focus indicators to every `TouchableRipple` consumer, so Button gets one + // with no code here. Deliberately not implemented locally: a second ring here + // would double up with it. The catch is placement — that PR defaults to + // `focusRing="outward"` and warns an outward ring is trimmed by "any clipping + // ancestor sized to its content", which is exactly this view. Button will + // likely need `focusRing="inward"`, or the ring on the outer view. + const clipStyle = useAnimatedStyle( + () => ({ borderRadius: animatedRadius.value }), + [animatedRadius] + ); - const { color: customLabelColor, fontSize: customLabelSize } = - StyleSheet.flatten(labelStyle) || {}; + const outlineStyle = React.useMemo( + () => ({ backgroundColor: containerColor, borderColor, borderWidth }), + [containerColor, borderColor, borderWidth] + ); - const font = theme.fonts.labelLarge; + const { color: labelStyleColor } = React.useMemo( + () => StyleSheet.flatten(labelStyle) || {}, + [labelStyle] + ); + + // Extra-small/small buttons are shorter than the 48dp minimum accessible + // touch target, so expand the press area with hitSlop without changing the + // visual size. A user-supplied `hitSlop` wins on the axes it sets. + const hitSlopWithMinTarget = React.useMemo(() => { + const verticalSlop = Math.max( + 0, + (MIN_TOUCH_TARGET - sizeStyle.minHeight) / 2 + ); + if (verticalSlop === 0) { + return hitSlop; + } + if (hitSlop == null) { + return { top: verticalSlop, bottom: verticalSlop }; + } + // A numeric hitSlop is an explicit uniform override — respect it as-is. + if (typeof hitSlop === 'number') { + return hitSlop; + } + return { + ...hitSlop, + top: hitSlop.top ?? verticalSlop, + bottom: hitSlop.bottom ?? verticalSlop, + }; + }, [hitSlop, sizeStyle]); - const textStyle = { - color: textColor, - ...font, - }; + const contentBoxStyle = React.useMemo( + () => ({ + minHeight: sizeStyle.minHeight - borderWidth * 2, + paddingStart: sizeStyle.paddingStart - borderWidth, + paddingEnd: sizeStyle.paddingEnd - borderWidth, + gap: sizeStyle.iconGap, + }), + [sizeStyle, borderWidth] + ); - const iconStyle = - StyleSheet.flatten(contentStyle)?.flexDirection === 'row-reverse' - ? [ - styles.iconReverse, - styles[`md3IconReverse${compact ? 'Compact' : ''}`], - isMode('text') && - styles[`md3IconReverseTextMode${compact ? 'Compact' : ''}`], - ] - : [ - styles.icon, - styles[`md3Icon${compact ? 'Compact' : ''}`], - isMode('text') && - styles[`md3IconTextMode${compact ? 'Compact' : ''}`], - ]; + const labelTypeStyle = React.useMemo( + () => ({ + color: labelColor, + ...theme.fonts[sizeStyle.labelVariant], + }), + [labelColor, theme, sizeStyle] + ); return ( - {backgroundOpacity < 1 && ( - - )} - - - {icon && loading !== true ? ( - - - - ) : null} - {loading ? ( - - ) : null} - + )} + + - {children} - - - + {icon && loading !== true ? ( + + + + ) : null} + {loading ? ( + + ) : null} + + {children} + + + + ); }; @@ -395,78 +599,21 @@ const Button = ({ const styles = StyleSheet.create({ button: { minWidth: 64, - borderStyle: 'solid', }, - compact: { - minWidth: 'auto', + clip: { + borderStyle: 'solid', + overflow: 'hidden', }, content: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', }, - icon: { - marginLeft: 12, - marginRight: -4, - }, - iconReverse: { - marginRight: 12, - marginLeft: -4, - }, - /* eslint-disable react-native/no-unused-styles */ - md3Icon: { - marginLeft: 16, - marginRight: -16, - }, - md3IconCompact: { - marginLeft: 8, - marginRight: 0, - }, - md3IconReverse: { - marginLeft: -16, - marginRight: 16, - }, - md3IconReverseCompact: { - marginLeft: 0, - marginRight: 8, + contentReverse: { + flexDirection: 'row-reverse', }, - md3IconTextMode: { - marginLeft: 12, - marginRight: -8, - }, - md3IconTextModeCompact: { - marginLeft: 6, - marginRight: 0, - }, - md3IconReverseTextMode: { - marginLeft: -8, - marginRight: 12, - }, - md3IconReverseTextModeCompact: { - marginLeft: 0, - marginRight: 6, - }, - /* eslint-enable react-native/no-unused-styles */ label: { textAlign: 'center', - marginVertical: 9, - marginHorizontal: 16, - }, - compactLabel: { - marginHorizontal: 8, - }, - uppercaseLabel: { - textTransform: 'uppercase', - }, - md3Label: { - marginVertical: 10, - marginHorizontal: 24, - }, - md3LabelText: { - marginHorizontal: 12, - }, - md3LabelTextAddons: { - marginHorizontal: 16, }, }); diff --git a/src/components/Button/tokens.ts b/src/components/Button/tokens.ts new file mode 100644 index 0000000000..2dbe07f54f --- /dev/null +++ b/src/components/Button/tokens.ts @@ -0,0 +1,147 @@ +import type { ButtonLabelVariant, ButtonMode, ButtonSize } from './utils'; +import type { ColorRole } from '../../theme/types'; +import type { ShapeToken } from '../../theme/utils/shape'; + +/** + * Per-size component tokens for the Material Design 3 (expressive) button + * sizes, modelled on Jetpack Compose's `Button{Size}Tokens`. Centralising + * these here keeps every size-specific metric in one place and references the + * theme shape tokens instead of magic numbers for the corner radii. + */ +type ButtonSizeTokens = { + containerHeight: number; + iconSize: number; + iconLabelSpace: number; + leadingSpace: number; + trailingSpace: number; + outlinedOutlineWidth: number; + labelVariant: ButtonLabelVariant; + containerShapeRound: ShapeToken; + containerShapeSquare: ShapeToken; + /** + * Corner the container morphs to while pressed. Tightens with the size: + * 8dp for XS/S, 12dp for M, 16dp for L/XL. + */ + pressedContainerShape: ShapeToken; + selectedContainerShapeRound: ShapeToken; + selectedContainerShapeSquare: ShapeToken; +}; + +const sizes = { + 'extra-small': { + containerHeight: 32, + iconSize: 20, + iconLabelSpace: 4, + leadingSpace: 12, + trailingSpace: 12, + outlinedOutlineWidth: 1, + labelVariant: 'labelLarge', + containerShapeRound: 'full', + containerShapeSquare: 'medium', + pressedContainerShape: 'small', + selectedContainerShapeRound: 'full', + selectedContainerShapeSquare: 'medium', + }, + small: { + containerHeight: 40, + iconSize: 20, + iconLabelSpace: 8, + leadingSpace: 16, + trailingSpace: 16, + outlinedOutlineWidth: 1, + labelVariant: 'labelLarge', + containerShapeRound: 'full', + containerShapeSquare: 'medium', + pressedContainerShape: 'small', + selectedContainerShapeRound: 'full', + selectedContainerShapeSquare: 'medium', + }, + medium: { + containerHeight: 56, + iconSize: 24, + iconLabelSpace: 8, + leadingSpace: 24, + trailingSpace: 24, + outlinedOutlineWidth: 1, + labelVariant: 'titleMedium', + containerShapeRound: 'full', + containerShapeSquare: 'large', + pressedContainerShape: 'medium', + selectedContainerShapeRound: 'full', + selectedContainerShapeSquare: 'large', + }, + large: { + containerHeight: 96, + iconSize: 32, + iconLabelSpace: 12, + leadingSpace: 48, + trailingSpace: 48, + outlinedOutlineWidth: 1, + labelVariant: 'headlineSmall', + containerShapeRound: 'full', + containerShapeSquare: 'extraLarge', + pressedContainerShape: 'large', + selectedContainerShapeRound: 'full', + selectedContainerShapeSquare: 'extraLarge', + }, + 'extra-large': { + containerHeight: 136, + iconSize: 40, + iconLabelSpace: 16, + leadingSpace: 64, + trailingSpace: 64, + outlinedOutlineWidth: 1, + labelVariant: 'headlineLarge', + containerShapeRound: 'full', + containerShapeSquare: 'extraLarge', + pressedContainerShape: 'large', + selectedContainerShapeRound: 'full', + selectedContainerShapeSquare: 'extraLarge', + }, +} as const satisfies Record; + +/** + * Modes that have a toggle variant. MD3 defines `ToggleButton`, + * `ElevatedToggleButton`, `TonalToggleButton` and `OutlinedToggleButton` — there + * is no text/plain toggle, so `text` keeps its plain colours when used as one. + */ +export type ButtonToggleMode = Exclude; + +type ToggleColorSpec = { + /** `'transparent'` where the spec leaves the container unfilled. */ + container: ColorRole | 'transparent'; + label: ColorRole; +}; + +/** + * Selected/unselected colour roles for the toggle variant, taken from the MD3 + * `{Filled,Elevated,Tonal,Outlined}ButtonTokens` `Unselected*` / `Selected*` + * sets. A toggle's unselected colours differ from the same mode's plain + * colours, which is why this table is separate from `getButtonColors`' defaults. + */ +const toggle = { + filled: { + unselected: { container: 'surfaceContainer', label: 'onSurfaceVariant' }, + selected: { container: 'primary', label: 'onPrimary' }, + }, + tonal: { + unselected: { + container: 'secondaryContainer', + label: 'onSecondaryContainer', + }, + selected: { container: 'secondary', label: 'onSecondary' }, + }, + elevated: { + unselected: { container: 'surfaceContainerLow', label: 'primary' }, + selected: { container: 'primary', label: 'onPrimary' }, + }, + outlined: { + unselected: { container: 'transparent', label: 'onSurfaceVariant' }, + selected: { container: 'inverseSurface', label: 'inverseOnSurface' }, + }, +} as const satisfies Record< + ButtonToggleMode, + { unselected: ToggleColorSpec; selected: ToggleColorSpec } +>; + +export const Tokens = { sizes, toggle }; diff --git a/src/components/Button/utils.tsx b/src/components/Button/utils.tsx index 058342b142..0bdc74d0b1 100644 --- a/src/components/Button/utils.tsx +++ b/src/components/Button/utils.tsx @@ -1,23 +1,179 @@ -import type { ColorValue, ViewStyle } from 'react-native'; +import type { ColorValue } from 'react-native'; +import color from 'color'; + +import { Tokens } from './tokens'; +import type { ButtonToggleMode } from './tokens'; import { black, white } from '../../theme/colors'; import { tokens } from '../../theme/tokens'; +import { resolveCornerRadius } from '../../theme/utils/shape'; import type { InternalTheme } from '../../types'; -import { splitStyles } from '../../utils/splitStyles'; const stateOpacity = tokens.md.sys.state.opacity; -export type ButtonMode = - | 'text' - | 'outlined' - | 'contained' - | 'elevated' - | 'contained-tonal'; +export type ButtonMode = 'text' | 'outlined' | 'filled' | 'elevated' | 'tonal'; + +export type ButtonIconPosition = 'leading' | 'trailing'; + +export type ButtonSize = + | 'extra-small' + | 'small' + | 'medium' + | 'large' + | 'extra-large'; + +export type ButtonLabelVariant = + | 'labelLarge' + | 'titleMedium' + | 'headlineSmall' + | 'headlineLarge'; + +export type ButtonSizeStyle = { + minHeight: number; + paddingStart: number; + paddingEnd: number; + iconSize: number; + iconGap: number; + outlineWidth: number; + labelVariant: ButtonLabelVariant; +}; + +/** + * Per-size metrics for the Material Design 3 (expressive) button sizes, read + * from the component tokens. + */ +export const getButtonSizeStyle = (size: ButtonSize): ButtonSizeStyle => { + const t = Tokens.sizes[size]; + return { + minHeight: t.containerHeight, + paddingStart: t.leadingSpace, + paddingEnd: t.trailingSpace, + iconSize: t.iconSize, + iconGap: t.iconLabelSpace, + outlineWidth: t.outlinedOutlineWidth, + labelVariant: t.labelVariant, + }; +}; + +export type ButtonShape = 'round' | 'square'; + +/** + * A selected toggle contrasts with its unselected state by flipping the shape, + * so a selected `round` button renders square and vice versa. + */ +export const getEffectiveButtonShape = ( + shape: ButtonShape, + selected?: boolean +): ButtonShape => { + if (!selected) { + return shape; + } + return shape === 'round' ? 'square' : 'round'; +}; + +/** + * Corner radius for the requested shape, read from the component tokens and + * resolved against the theme shape tokens. `round` is the full-pill radius; + * `square` uses a per-size smaller corner. A selected button resolves against + * the `selectedContainerShape*` token pair, after the shape flip above. + */ +export const getButtonShapeRadius = ({ + size, + shape, + theme, + selected, +}: { + size: ButtonSize; + shape: ButtonShape; + theme: InternalTheme; + selected?: boolean; +}): number => { + const t = Tokens.sizes[size]; + const token = + getEffectiveButtonShape(shape, selected) === 'round' + ? selected + ? t.selectedContainerShapeRound + : t.containerShapeRound + : selected + ? t.selectedContainerShapeSquare + : t.containerShapeSquare; + return resolveCornerRadius(theme, token); +}; + +/** + * Duration of `Surface`'s background and elevation transition. + * + * `Surface` cross-fades its background, and Reanimated interpolates colors in + * RGB, so a fade to or from `transparent` passes through gray. `outlined` and + * `text` are the modes that show it: their container is transparent, so the + * clip inside doesn't paint over the fade. `mode` is a static prop rather than + * a state, so there is nothing worth cross-fading — snap instead. A toggle's + * color change still animates, both of its containers being opaque, and so does + * the elevation of an `elevated` button, whose container is never transparent. + */ +export const getButtonTransitionDuration = ({ + theme, + pressed, + containerColor, + previousContainerColor, +}: { + theme: InternalTheme; + pressed: boolean; + containerColor: ColorValue; + previousContainerColor: ColorValue; +}): number => { + if ( + containerColor === 'transparent' || + previousContainerColor === 'transparent' + ) { + return 0; + } + + return ( + theme.motion.duration[pressed ? 'short4' : 'short3'] * theme.animation.scale + ); +}; + +/** Corner the container morphs to while pressed. */ +export const getButtonPressedRadius = ({ + size, + theme, +}: { + size: ButtonSize; + theme: InternalTheme; +}): number => + resolveCornerRadius(theme, Tokens.sizes[size].pressedContainerShape); type BaseProps = { isMode: (mode: ButtonMode) => boolean; theme: InternalTheme; disabled?: boolean; + selected?: boolean; +}; + +type ToggleColors = (typeof Tokens.toggle)[ButtonToggleMode][ + | 'selected' + | 'unselected']; + +const isToggleMode = (mode: ButtonMode): mode is ButtonToggleMode => + mode !== 'text'; + +/** + * The toggle colour pair for a mode, or `undefined` when the button is not a + * toggle (`selected` omitted) or the mode has no toggle colours of its own + * (`text`, which MD3 gives no toggle tokens). + */ +const getToggleColors = ({ + mode, + selected, +}: { + mode: ButtonMode; + selected?: boolean; +}) => { + if (!isToggleMode(mode) || selected === undefined) { + return undefined; + } + return Tokens.toggle[mode][selected ? 'selected' : 'unselected']; }; const isDark = ({ @@ -43,8 +199,10 @@ const getButtonBackgroundColor = ({ theme, disabled, customButtonColor, -}: BaseProps & { + toggleColors, +}: Omit & { customButtonColor?: ColorValue; + toggleColors?: ToggleColors; }) => { const { colors } = theme; if (customButtonColor && !disabled) { @@ -58,68 +216,86 @@ const getButtonBackgroundColor = ({ return colors.onSurface; } + if (toggleColors) { + const { container } = toggleColors; + return container === 'transparent' ? 'transparent' : colors[container]; + } + if (isMode('elevated')) { return colors.surfaceContainerLow; } - if (isMode('contained')) { + if (isMode('filled')) { return colors.primary; } - if (isMode('contained-tonal')) { + if (isMode('tonal')) { return colors.secondaryContainer; } return 'transparent'; }; -const getButtonTextColor = ({ +const getButtonLabelColor = ({ isMode, theme, disabled, - customTextColor, + customLabelColor, backgroundColor, dark, -}: BaseProps & { - customTextColor?: ColorValue; + toggleColors, +}: Omit & { + customLabelColor?: ColorValue; backgroundColor: ColorValue; dark?: boolean; + toggleColors?: ToggleColors; }) => { const { colors } = theme; - if (customTextColor && !disabled) { - return customTextColor; + if (customLabelColor && !disabled) { + return customLabelColor; } if (disabled) { return theme.colors.onSurface; } + if (toggleColors) { + return colors[toggleColors.label]; + } + if (typeof dark === 'boolean') { - if ( - isMode('contained') || - isMode('contained-tonal') || - isMode('elevated') - ) { + if (isMode('filled') || isMode('tonal') || isMode('elevated')) { return isDark({ dark, backgroundColor }) ? white : black; } } - if (isMode('outlined') || isMode('text') || isMode('elevated')) { + // Outlined uses the neutral on-surface-variant label per MD3 spec; text and + // elevated keep the primary accent. + if (isMode('outlined')) { + return colors.onSurfaceVariant; + } + + if (isMode('text') || isMode('elevated')) { return colors.primary; } - if (isMode('contained')) { + if (isMode('filled')) { return colors.onPrimary; } - if (isMode('contained-tonal')) { + if (isMode('tonal')) { return colors.onSecondaryContainer; } return colors.primary; }; -const getButtonBorderColor = ({ isMode, theme }: BaseProps) => { +const getButtonBorderColor = ({ isMode, theme, selected }: BaseProps) => { + // A selected outlined toggle drops its outline (the filled inverse-surface + // background takes over as the visual affordance). + if (selected && isMode('outlined')) { + return 'transparent'; + } if (isMode('outlined')) { return theme.colors.outlineVariant; } @@ -127,9 +303,16 @@ const getButtonBorderColor = ({ isMode, theme }: BaseProps) => { return 'transparent'; }; -const getButtonBorderWidth = ({ isMode }: Omit) => { +const getButtonBorderWidth = ({ + isMode, + selected, + size, +}: Omit & { size: ButtonSize }) => { + if (selected && isMode('outlined')) { + return 0; + } if (isMode('outlined')) { - return 1; + return Tokens.sizes[size].outlinedOutlineWidth; } return 0; @@ -138,43 +321,53 @@ const getButtonBorderWidth = ({ isMode }: Omit) => { export const getButtonColors = ({ theme, mode, + size = 'small', customButtonColor, - customTextColor, + customLabelColor, disabled, dark, + selected, }: { theme: InternalTheme; mode: ButtonMode; + size?: ButtonSize; customButtonColor?: ColorValue; - customTextColor?: ColorValue; + customLabelColor?: ColorValue; disabled?: boolean; dark?: boolean; + selected?: boolean; }) => { const isMode = (modeToCompare: ButtonMode) => { return mode === modeToCompare; }; + const toggleColors = disabled + ? undefined + : getToggleColors({ mode, selected }); + const backgroundColor = getButtonBackgroundColor({ isMode, theme, disabled, customButtonColor, + toggleColors, }); - const textColor = getButtonTextColor({ + const labelColor = getButtonLabelColor({ isMode, theme, disabled, - customTextColor, + customLabelColor, backgroundColor, dark, + toggleColors, }); - const borderColor = getButtonBorderColor({ isMode, theme }); + const borderColor = getButtonBorderColor({ isMode, theme, selected }); - const borderWidth = getButtonBorderWidth({ isMode, theme }); + const borderWidth = getButtonBorderWidth({ isMode, selected, size }); - const textOpacity = disabled ? stateOpacity.disabled : stateOpacity.enabled; + const labelOpacity = disabled ? stateOpacity.disabled : stateOpacity.enabled; const backgroundOpacity = disabled && !isMode('outlined') && !isMode('text') @@ -184,50 +377,36 @@ export const getButtonColors = ({ return { backgroundColor, borderColor, - textColor, - textOpacity, + labelColor, + labelOpacity, borderWidth, backgroundOpacity, }; }; -type ViewStyleBorderRadiusStyles = Partial< - Pick< - ViewStyle, - | 'borderBottomEndRadius' - | 'borderBottomLeftRadius' - | 'borderBottomRightRadius' - | 'borderBottomStartRadius' - | 'borderTopEndRadius' - | 'borderTopLeftRadius' - | 'borderTopRightRadius' - | 'borderTopStartRadius' - | 'borderRadius' - > ->; -export const getButtonTouchableRippleStyle = ( - style?: ViewStyle, - borderWidth: number = 0 -): ViewStyleBorderRadiusStyles => { - if (!style) return {}; - const touchableRippleStyle: ViewStyleBorderRadiusStyles = {}; - - const [, borderRadiusStyles] = splitStyles( - style, - (style) => style.startsWith('border') && style.endsWith('Radius') - ); +/** + * Returns the color used for the button's ripple / state layer. Defaults to + * the label color at the pressed-state opacity (per Material Design 3), unless + * a custom ripple color is provided. + * + * When the label color is not a plain string (e.g. an Android Material You + * `PlatformColor`), `undefined` is returned so `TouchableRipple` falls back to + * its own default state-layer color. + */ +export const getButtonRippleColor = ({ + labelColor, + customRippleColor, +}: { + labelColor: ColorValue; + customRippleColor?: ColorValue; +}): ColorValue | undefined => { + if (customRippleColor) { + return customRippleColor; + } - const borderRadiusKeys = - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - Object.keys(borderRadiusStyles) as Array; + if (typeof labelColor !== 'string') { + return undefined; + } - borderRadiusKeys.forEach((key) => { - const value = style[key]; - if (typeof value === 'number') { - // Only subtract borderWidth if value is greater than 0 - const radius = value > 0 ? value - borderWidth : 0; - touchableRippleStyle[key] = radius; - } - }); - return touchableRippleStyle; + return color(labelColor).alpha(stateOpacity.pressed).rgb().string(); }; diff --git a/src/components/Card/CardActions.tsx b/src/components/Card/CardActions.tsx index 529db00f52..64bdfb4cae 100644 --- a/src/components/Card/CardActions.tsx +++ b/src/components/Card/CardActions.tsx @@ -51,14 +51,11 @@ const CardActions = ({ theme, style, children, ...rest }: Props) => { return child; } - const compact = child.props.compact; - const mode = - child.props.mode ?? (index === 0 ? 'outlined' : 'contained'); + const mode = child.props.mode ?? (index === 0 ? 'outlined' : 'filled'); const childStyle = [styles.button, child.props.style]; return React.cloneElement(child, { ...child.props, - compact, mode, style: childStyle, }); diff --git a/src/components/Card/utils.tsx b/src/components/Card/utils.tsx index 644c8f4c27..415888bc6e 100644 --- a/src/components/Card/utils.tsx +++ b/src/components/Card/utils.tsx @@ -10,7 +10,6 @@ type BorderRadiusStyles = Pick< >; export type CardActionChildProps = { - compact?: boolean; mode?: string; style?: StyleProp; }; diff --git a/src/components/DataTable/DataTablePagination.tsx b/src/components/DataTable/DataTablePagination.tsx index 32f4b8b4fb..d30256d518 100644 --- a/src/components/DataTable/DataTablePagination.tsx +++ b/src/components/DataTable/DataTablePagination.tsx @@ -182,7 +182,7 @@ const PaginationDropdown = ({ onPress={() => toggleSelect(true)} style={styles.button} icon="menu-down" - contentStyle={styles.contentStyle} + iconPosition="trailing" theme={theme} > {`${numberOfItemsPerPage}`} @@ -362,9 +362,6 @@ const styles = StyleSheet.create({ iconsContainer: { flexDirection: 'row', }, - contentStyle: { - flexDirection: 'row-reverse', - }, }); export default DataTablePagination; diff --git a/src/components/Dialog/DialogActions.tsx b/src/components/Dialog/DialogActions.tsx index 7e3799451e..91bb028e5f 100644 --- a/src/components/Dialog/DialogActions.tsx +++ b/src/components/Dialog/DialogActions.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { StyleSheet, View } from 'react-native'; import type { StyleProp, ViewProps, ViewStyle } from 'react-native'; -import type { DialogActionChildProps } from './utils'; +import type { DialogChildProps } from './utils'; import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../types'; @@ -53,10 +53,8 @@ const DialogActions = (props: Props) => { return ( {React.Children.map(props.children, (child, i) => - React.isValidElement(child) + React.isValidElement(child) ? React.cloneElement(child, { - compact: true, - uppercase: false, style: [ { marginRight: i + 1 === actionsLength ? 0 : 8, diff --git a/src/components/Dialog/utils.ts b/src/components/Dialog/utils.ts index 64ccec77cc..cb60936ef9 100644 --- a/src/components/Dialog/utils.ts +++ b/src/components/Dialog/utils.ts @@ -3,8 +3,3 @@ import type { StyleProp, ViewStyle } from 'react-native'; export type DialogChildProps = { style?: StyleProp; }; - -export type DialogActionChildProps = DialogChildProps & { - compact?: boolean; - uppercase?: boolean; -}; diff --git a/src/components/Snackbar.tsx b/src/components/Snackbar.tsx index 297825c130..6826322de9 100644 --- a/src/components/Snackbar.tsx +++ b/src/components/Snackbar.tsx @@ -308,7 +308,6 @@ const Snackbar = ({ }} style={[styles.button, actionStyle]} textColor={buttonTextColor} - compact={false} mode="text" theme={theme} {...actionProps} diff --git a/src/components/__tests__/Button.test.tsx b/src/components/__tests__/Button.test.tsx index be3d573d29..df4300d3dc 100644 --- a/src/components/__tests__/Button.test.tsx +++ b/src/components/__tests__/Button.test.tsx @@ -1,14 +1,43 @@ -import { StyleSheet } from 'react-native'; +import { PlatformColor, StyleSheet } from 'react-native'; -import { describe, expect, it, jest } from '@jest/globals'; -import { userEvent } from '@testing-library/react-native'; +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { fireEvent } from '@testing-library/react-native'; +import color from 'color'; +import * as Reanimated from 'react-native-reanimated'; +import { LocaleProvider } from '../../core/locale'; import { getTheme } from '../../core/theming'; import { render, screen } from '../../test-utils'; +import { ReduceMotionContext } from '../../theme/accessibility/ReduceMotionContext'; import { pink500, white } from '../../theme/colors'; import { tokens } from '../../theme/tokens'; +import { shadow } from '../../theme/tokens/sys/elevation'; +import { toRawSpring } from '../../theme/tokens/sys/motion'; import Button from '../Button/Button'; -import { getButtonColors } from '../Button/utils'; +import { Tokens } from '../Button/tokens'; +import { + getButtonColors, + getButtonPressedRadius, + getButtonRippleColor, + getButtonShapeRadius, + getButtonSizeStyle, + getButtonTransitionDuration, +} from '../Button/utils'; +import type { ButtonLabelVariant, ButtonSize } from '../Button/utils'; + +jest.mock('react-native-reanimated', () => { + const ReanimatedModule = jest.requireActual< + typeof import('react-native-reanimated') + >('react-native-reanimated'); + + return { + __esModule: true, + ...ReanimatedModule, + default: ReanimatedModule.default, + // Wrapped so the shape-morph tests can observe the spring targets. + withSpring: jest.fn(ReanimatedModule.withSpring), + }; +}); const stateOpacity = tokens.md.sys.state.opacity; @@ -16,10 +45,13 @@ const styles = StyleSheet.create({ flexing: { flexDirection: 'row-reverse', }, + scaled: { + transform: [{ scale: 1.5 }], + }, }); -it('renders text button by default', async () => { - const tree = (await render()).toJSON(); +it('renders filled button by default', async () => { + const tree = (await render()).toJSON(); expect(tree).toMatchSnapshot(); }); @@ -40,9 +72,9 @@ it('renders outlined button with mode', async () => { expect(tree).toMatchSnapshot(); }); -it('renders contained contained with mode', async () => { +it('renders filled button with mode', async () => { const tree = ( - await render() + await render() ).toJSON(); expect(tree).toMatchSnapshot(); @@ -68,6 +100,29 @@ it('renders button with icon in reverse order', async () => { expect(tree).toMatchSnapshot(); }); +it('swaps the icon to the trailing edge under RTL', async () => { + await render( + + ); + expect(screen.getByTestId('button-content')).toHaveStyle({ + flexDirection: 'row', + }); + + await render( + + + + ); + // The content direction flips, so a "leading" icon sits on the right in RTL. + expect(screen.getByTestId('button-content')).toHaveStyle({ + flexDirection: 'row-reverse', + }); +}); + it('renders loading button', async () => { const tree = (await render()).toJSON(); @@ -155,7 +210,7 @@ it('should execute onPressIn', async () => { {null} ); - await userEvent.press(screen.getByTestId('button')); + await fireEvent(screen.getByTestId('button'), 'onPressIn'); expect(onPressInMock).toHaveBeenCalledTimes(1); }); @@ -168,96 +223,41 @@ it('should execute onPressOut', async () => { {null} ); - await userEvent.press(screen.getByTestId('button')); + await fireEvent(screen.getByTestId('button'), 'onPressOut'); expect(onPressOutMock).toHaveBeenCalledTimes(1); }); -describe('button text styles', () => { - it('applies uppercase styles if uppercase prop is truthy', async () => { +describe('icon position', () => { + it('places the icon before the label by default', async () => { await render( - ); - expect(screen.getByTestId('button-text')).toHaveStyle({ - textTransform: 'uppercase', + expect(screen.getByTestId('button-content')).toHaveStyle({ + flexDirection: 'row', }); }); - it('does not apply uppercase styles if uppercase prop is falsy', async () => { + it('places the icon after the label when iconPosition is "trailing"', async () => { await render( - ); - expect(screen.getByTestId('button-text')).not.toHaveStyle({ - textTransform: 'uppercase', + expect(screen.getByTestId('button-content')).toHaveStyle({ + flexDirection: 'row-reverse', }); }); }); -describe('button icon styles', () => { - it('should return correct icon styles for compact text button', async () => { - await render( - - ); - expect(screen.getByTestId('compact-button-icon-container')).toHaveStyle({ - marginLeft: 6, - marginRight: 0, - }); - }); - - (['outlined', 'contained', 'contained-tonal', 'elevated'] as const).forEach( - (mode) => - it(`should return correct icon styles for compact ${mode} button`, async () => { - await render( - - ); - expect(screen.getByTestId('compact-button-icon-container')).toHaveStyle( - { - marginLeft: 8, - marginRight: 0, - } - ); - }) - ); - - it('should return correct icon styles for text button', async () => { - await render( - - ); - expect(screen.getByTestId('compact-button-icon-container')).toHaveStyle({ - marginLeft: 12, - marginRight: -8, - }); - }); - - (['outlined', 'contained', 'contained-tonal', 'elevated'] as const).forEach( - (mode) => - it(`should return correct icon styles for compact ${mode} button`, async () => { - await render( - - ); - expect(screen.getByTestId('compact-button-icon-container')).toHaveStyle( - { - marginLeft: 16, - marginRight: -16, - } - ); - }) - ); -}); - describe('getButtonColors - background color', () => { const customButtonColor = '#111111'; @@ -298,7 +298,7 @@ describe('getButtonColors - background color', () => { }) ); - (['contained', 'contained-tonal', 'elevated'] as const).forEach((mode) => + (['filled', 'tonal', 'elevated'] as const).forEach((mode) => it(`should return correct disabled color, for theme version 3, ${mode} mode`, () => { return expect( getButtonColors({ @@ -314,7 +314,7 @@ describe('getButtonColors - background color', () => { }) ); - (['contained', 'contained-tonal', 'elevated'] as const).forEach((mode) => + (['filled', 'tonal', 'elevated'] as const).forEach((mode) => it(`should return correct disabled color, for theme version 3, dark theme, ${mode} mode`, () => { return expect( getButtonColors({ @@ -352,44 +352,44 @@ describe('getButtonColors - background color', () => { }); }); - it('should return correct theme color, for theme version 3, contained mode', () => { + it('should return correct theme color, for theme version 3, filled mode', () => { expect( getButtonColors({ theme: getTheme(), - mode: 'contained', + mode: 'filled', }) ).toMatchObject({ backgroundColor: getTheme().colors.primary, }); }); - it('should return correct theme color, for theme version 3, dark theme, contained mode', () => { + it('should return correct theme color, for theme version 3, dark theme, filled mode', () => { expect( getButtonColors({ theme: getTheme(true), - mode: 'contained', + mode: 'filled', }) ).toMatchObject({ backgroundColor: getTheme(true).colors.primary, }); }); - it('should return correct theme color, for theme version 3, contained-tonal mode', () => { + it('should return correct theme color, for theme version 3, tonal mode', () => { expect( getButtonColors({ theme: getTheme(), - mode: 'contained-tonal', + mode: 'tonal', }) ).toMatchObject({ backgroundColor: getTheme().colors.secondaryContainer, }); }); - it('should return correct theme color, for theme version 3, dark theme, contained-tonal mode', () => { + it('should return correct theme color, for theme version 3, dark theme, tonal mode', () => { expect( getButtonColors({ theme: getTheme(true), - mode: 'contained-tonal', + mode: 'tonal', }) ).toMatchObject({ backgroundColor: getTheme(true).colors.secondaryContainer, @@ -424,48 +424,48 @@ describe('getButtonColors - background color', () => { }); describe('getButtonColors - text color', () => { - const customTextColor = '#313131'; + const customLabelColor = '#313131'; it('should return custom text color no matter what is the theme version, when not disabled', () => { expect( getButtonColors({ - customTextColor, + customLabelColor, theme: getTheme(), disabled: false, mode: 'text', }) - ).toMatchObject({ textColor: customTextColor }); + ).toMatchObject({ labelColor: customLabelColor }); }); it('should return correct disabled text color, for theme version 3, no matter what the mode is', () => { expect( getButtonColors({ - customTextColor, + customLabelColor, theme: getTheme(), disabled: true, mode: 'text', }) ).toMatchObject({ - textColor: getTheme().colors.onSurface, - textOpacity: stateOpacity.disabled, + labelColor: getTheme().colors.onSurface, + labelOpacity: stateOpacity.disabled, }); }); it('should return correct disabled text color, for theme version 3, dark theme, no matter what the mode is', () => { expect( getButtonColors({ - customTextColor, + customLabelColor, theme: getTheme(true), disabled: true, mode: 'text', }) ).toMatchObject({ - textColor: getTheme(true).colors.onSurface, - textOpacity: stateOpacity.disabled, + labelColor: getTheme(true).colors.onSurface, + labelOpacity: stateOpacity.disabled, }); }); - (['contained', 'contained-tonal', 'elevated'] as const).forEach((mode) => + (['filled', 'tonal', 'elevated'] as const).forEach((mode) => it(`should return correct text color for dark prop, for theme version 3, ${mode} mode`, () => { expect( getButtonColors({ @@ -474,12 +474,12 @@ describe('getButtonColors - text color', () => { dark: true, }) ).toMatchObject({ - textColor: white, + labelColor: white, }); }) ); - (['outlined', 'text', 'elevated'] as const).forEach((mode) => + (['text', 'elevated'] as const).forEach((mode) => it(`should return correct theme text color, for theme version 3, ${mode} mode`, () => { expect( getButtonColors({ @@ -487,12 +487,12 @@ describe('getButtonColors - text color', () => { mode, }) ).toMatchObject({ - textColor: getTheme().colors.primary, + labelColor: getTheme().colors.primary, }); }) ); - (['outlined', 'text', 'elevated'] as const).forEach((mode) => + (['text', 'elevated'] as const).forEach((mode) => it(`should return correct theme text color, for theme version 3, dark theme, ${mode} mode`, () => { expect( getButtonColors({ @@ -500,52 +500,74 @@ describe('getButtonColors - text color', () => { mode, }) ).toMatchObject({ - textColor: getTheme(true).colors.primary, + labelColor: getTheme(true).colors.primary, }); }) ); - it('should return correct theme text color, for theme version 3, contained mode', () => { + it('should return onSurfaceVariant label color, for theme version 3, outlined mode', () => { expect( getButtonColors({ theme: getTheme(), - mode: 'contained', + mode: 'outlined', }) ).toMatchObject({ - textColor: getTheme().colors.onPrimary, + labelColor: getTheme().colors.onSurfaceVariant, }); }); - it('should return correct theme text color, for theme version 3, dark theme, contained mode', () => { + it('should return onSurfaceVariant label color, for theme version 3, dark theme, outlined mode', () => { expect( getButtonColors({ theme: getTheme(true), - mode: 'contained', + mode: 'outlined', }) ).toMatchObject({ - textColor: getTheme(true).colors.onPrimary, + labelColor: getTheme(true).colors.onSurfaceVariant, }); }); - it('should return correct theme text color, for theme version 3, contained-tonal mode', () => { + it('should return correct theme text color, for theme version 3, filled mode', () => { expect( getButtonColors({ theme: getTheme(), - mode: 'contained-tonal', + mode: 'filled', }) ).toMatchObject({ - textColor: getTheme().colors.onSecondaryContainer, + labelColor: getTheme().colors.onPrimary, }); }); - it('should return correct theme text color, for theme version 3, dark theme contained-tonal mode', () => { + it('should return correct theme text color, for theme version 3, dark theme, filled mode', () => { expect( getButtonColors({ theme: getTheme(true), - mode: 'contained-tonal', + mode: 'filled', + }) + ).toMatchObject({ + labelColor: getTheme(true).colors.onPrimary, + }); + }); + + it('should return correct theme text color, for theme version 3, tonal mode', () => { + expect( + getButtonColors({ + theme: getTheme(), + mode: 'tonal', }) ).toMatchObject({ - textColor: getTheme(true).colors.onSecondaryContainer, + labelColor: getTheme().colors.onSecondaryContainer, + }); + }); + + it('should return correct theme text color, for theme version 3, dark theme tonal mode', () => { + expect( + getButtonColors({ + theme: getTheme(true), + mode: 'tonal', + }) + ).toMatchObject({ + labelColor: getTheme(true).colors.onSecondaryContainer, }); }); }); @@ -597,32 +619,30 @@ describe('getButtonColors - border color', () => { }); }); - (['text', 'contained', 'contained-tonal', 'elevated'] as const).forEach( - (mode) => - it(`should return transparent border, for theme version 3, ${mode} mode`, () => { - expect( - getButtonColors({ - theme: getTheme(), - mode, - }) - ).toMatchObject({ - borderColor: 'transparent', - }); - }) + (['text', 'filled', 'tonal', 'elevated'] as const).forEach((mode) => + it(`should return transparent border, for theme version 3, ${mode} mode`, () => { + expect( + getButtonColors({ + theme: getTheme(), + mode, + }) + ).toMatchObject({ + borderColor: 'transparent', + }); + }) ); - (['text', 'contained', 'contained-tonal', 'elevated'] as const).forEach( - (mode) => - it(`should return transparent border, for theme version 3, dark theme, ${mode} mode`, () => { - expect( - getButtonColors({ - theme: getTheme(true), - mode, - }) - ).toMatchObject({ - borderColor: 'transparent', - }); - }) + (['text', 'filled', 'tonal', 'elevated'] as const).forEach((mode) => + it(`should return transparent border, for theme version 3, dark theme, ${mode} mode`, () => { + expect( + getButtonColors({ + theme: getTheme(true), + mode, + }) + ).toMatchObject({ + borderColor: 'transparent', + }); + }) ); }); @@ -638,17 +658,722 @@ describe('getButtonColors - border width', () => { }); }); - (['text', 'contained', 'contained-tonal', 'elevated'] as const).forEach( - (mode) => - it(`should return correct border width, for ${mode} mode`, () => { - expect( - getButtonColors({ - theme: getTheme(), - mode, - }) - ).toMatchObject({ - borderWidth: 0, - }); + (['text', 'filled', 'tonal', 'elevated'] as const).forEach((mode) => + it(`should return correct border width, for ${mode} mode`, () => { + expect( + getButtonColors({ + theme: getTheme(), + mode, + }) + ).toMatchObject({ + borderWidth: 0, + }); + }) + ); +}); + +describe('getButtonRippleColor', () => { + it('returns the custom ripple color when one is provided', () => { + expect( + getButtonRippleColor({ labelColor: '#123456', customRippleColor: 'red' }) + ).toBe('red'); + }); + + it('defaults to the label color at the pressed-state opacity', () => { + expect(getButtonRippleColor({ labelColor: '#123456' })).toBe( + color('#123456').alpha(stateOpacity.pressed).rgb().string() + ); + }); + + it('returns undefined when the label color is not a plain string', () => { + expect( + getButtonRippleColor({ labelColor: PlatformColor('?attr/colorPrimary') }) + ).toBeUndefined(); + }); +}); + +const sizeMetrics: [ + size: ButtonSize, + minHeight: number, + paddingStart: number, + paddingEnd: number, + iconSize: number, + iconGap: number, + outlineWidth: number, + labelVariant: ButtonLabelVariant, +][] = [ + ['extra-small', 32, 12, 12, 20, 4, 1, 'labelLarge'], + ['small', 40, 16, 16, 20, 8, 1, 'labelLarge'], + ['medium', 56, 24, 24, 24, 8, 1, 'titleMedium'], + ['large', 96, 48, 48, 32, 12, 1, 'headlineSmall'], + ['extra-large', 136, 64, 64, 40, 16, 1, 'headlineLarge'], +]; + +describe('getButtonSizeStyle', () => { + it.each(sizeMetrics)( + 'returns expected metrics for %s', + ( + size, + minHeight, + paddingStart, + paddingEnd, + iconSize, + iconGap, + outlineWidth, + labelVariant + ) => { + expect(getButtonSizeStyle(size)).toEqual({ + minHeight, + paddingStart, + paddingEnd, + iconSize, + iconGap, + outlineWidth, + labelVariant, + }); + } + ); +}); + +describe('size prop', () => { + it('renders a button with per-size metrics', async () => { + const tree = ( + await render( + + ) + ).toJSON(); + + expect(tree).toMatchSnapshot(); + }); + + ( + [ + ['extra-small', 14], + ['small', 14], + ['medium', 16], + ['large', 24], + ['extra-large', 32], + ] as const + ).forEach(([size, expectedFontSize]) => + it(`applies the ${size} typescale to the label`, async () => { + await render( + + ); + expect(screen.getByTestId('button-text')).toHaveStyle({ + fontSize: expectedFontSize, + }); + }) + ); +}); + +describe('accessible touch target', () => { + it('expands extra-small buttons to the 48dp minimum target', async () => { + await render( + + ); + // (48 - 32) / 2 = 8 + // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion. + expect(screen.getByTestId('button').props.hitSlop).toMatchObject({ + top: 8, + bottom: 8, + }); + }); + + it('expands small buttons to the 48dp minimum target', async () => { + await render( + + ); + // (48 - 40) / 2 = 4 + // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion. + expect(screen.getByTestId('button').props.hitSlop).toMatchObject({ + top: 4, + bottom: 4, + }); + }); + + it('does not add hitSlop for buttons already at least 48dp tall', async () => { + await render( + + ); + // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion. + expect(screen.getByTestId('button').props.hitSlop).toBeUndefined(); + }); + + it('keeps a user-supplied hitSlop axis while filling the rest', async () => { + await render( + + ); + // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion. + expect(screen.getByTestId('button').props.hitSlop).toMatchObject({ + top: 20, + bottom: 8, + }); + }); +}); + +const shapeRadii: [size: ButtonSize, round: number, square: number][] = [ + ['extra-small', 9999, 12], + ['small', 9999, 12], + ['medium', 9999, 16], + ['large', 9999, 28], + ['extra-large', 9999, 28], +]; + +describe('getButtonShapeRadius', () => { + it.each(shapeRadii)( + 'returns expected radii for size=%s', + (size, round, square) => { + const theme = getTheme(); + expect(getButtonShapeRadius({ size, shape: 'round', theme })).toBe(round); + expect(getButtonShapeRadius({ size, shape: 'square', theme })).toBe( + square + ); + } + ); +}); + +// The pressed corner tightens with the size, per the MD3 corner table. +const pressedRadii: [size: ButtonSize, pressed: number][] = [ + ['extra-small', 8], + ['small', 8], + ['medium', 12], + ['large', 16], + ['extra-large', 16], +]; + +describe('getButtonPressedRadius', () => { + it.each(pressedRadii)( + 'returns the pressed radius for size=%s', + (size, pressed) => { + expect(getButtonPressedRadius({ size, theme: getTheme() })).toBe(pressed); + } + ); +}); + +describe('getButtonTransitionDuration', () => { + const theme = getTheme(); + const opaque = theme.colors.primary; + const scaled = (key: 'short3' | 'short4') => + theme.motion.duration[key] * theme.animation.scale; + + it('cross-fades between two opaque containers', () => { + expect( + getButtonTransitionDuration({ + theme, + pressed: false, + containerColor: opaque, + previousContainerColor: theme.colors.secondaryContainer, + }) + ).toBe(scaled('short3')); + }); + + it('uses the longer duration while pressed', () => { + expect( + getButtonTransitionDuration({ + theme, + pressed: true, + containerColor: opaque, + previousContainerColor: opaque, + }) + ).toBe(scaled('short4')); + }); + + // Fading to or from `transparent` interpolates through gray, which is what + // made a mode switch leave `outlined` looking like a filled gray button. + it('snaps when the container becomes transparent', () => { + expect( + getButtonTransitionDuration({ + theme, + pressed: false, + containerColor: 'transparent', + previousContainerColor: opaque, + }) + ).toBe(0); + }); + + it('snaps when the container was transparent', () => { + expect( + getButtonTransitionDuration({ + theme, + pressed: false, + containerColor: opaque, + previousContainerColor: 'transparent', + }) + ).toBe(0); + }); +}); + +describe('shape prop', () => { + it('applies the round (full-pill) radius', async () => { + await render( + + ); + // Half the small container height (40dp) is the real pill radius. + expect(screen.getByTestId('button-container')).toHaveStyle({ + borderRadius: 20, + }); + }); + + it('applies the square radius (default size)', async () => { + await render( + + ); + expect(screen.getByTestId('button-container')).toHaveStyle({ + borderRadius: 12, + }); + }); + + it('uses the per-size square radius when both size and shape are set', async () => { + await render( + + ); + expect(screen.getByTestId('button-container')).toHaveStyle({ + borderRadius: 28, + }); + }); +}); + +describe('selected prop', () => { + it('marks the button as selected for screen readers', async () => { + await render( + + ); + + expect(screen.getByTestId('button')).toBeSelected(); + }); + + it('flips a round button into the square radius when selected', async () => { + await render( + + ); + + expect(screen.getByTestId('button-container')).toHaveStyle({ + borderRadius: 28, + }); + }); + + it('flips a square button into the round radius when selected', async () => { + await render( + + ); + + expect(screen.getByTestId('button-container')).toHaveStyle({ + borderRadius: 20, + }); + }); + + it('drops the outline when an outlined toggle is selected', () => { + expect( + getButtonColors({ + theme: getTheme(), + mode: 'outlined', + selected: true, }) + ).toMatchObject({ + borderColor: 'transparent', + borderWidth: 0, + }); + }); + + it('keeps a text button on its plain colors, having no toggle colours', async () => { + const plain = getButtonColors({ theme: getTheme(), mode: 'text' }); + + expect( + getButtonColors({ theme: getTheme(), mode: 'text', selected: false }) + ).toMatchObject(plain); + expect( + getButtonColors({ theme: getTheme(), mode: 'text', selected: true }) + ).toMatchObject(plain); + + // `selected` is still honoured for the shape flip and for screen readers. + await render( + + ); + expect(screen.getByTestId('button-container')).toHaveStyle({ + borderRadius: 12, + }); + expect(screen.getByTestId('button')).toBeSelected(); + }); + + it('leaves a plain button untouched when `selected` is omitted', () => { + expect( + getButtonColors({ theme: getTheme(), mode: 'filled' }) + ).toMatchObject({ + backgroundColor: getTheme().colors.primary, + labelColor: getTheme().colors.onPrimary, + }); + }); +}); + +describe('toggle colors', () => { + // From the MD3 {Filled,Elevated,Tonal,Outlined}ButtonTokens Unselected*/ + // Selected* sets. + type Role = keyof ReturnType['colors']; + // `null` = the spec leaves the container unfilled. + const toggleColors: [ + mode: 'filled' | 'tonal' | 'elevated' | 'outlined', + unselectedContainer: Role | null, + unselectedLabel: Role, + selectedContainer: Role, + selectedLabel: Role, + ][] = [ + ['filled', 'surfaceContainer', 'onSurfaceVariant', 'primary', 'onPrimary'], + [ + 'tonal', + 'secondaryContainer', + 'onSecondaryContainer', + 'secondary', + 'onSecondary', + ], + ['elevated', 'surfaceContainerLow', 'primary', 'primary', 'onPrimary'], + [ + 'outlined', + null, + 'onSurfaceVariant', + 'inverseSurface', + 'inverseOnSurface', + ], + ]; + + it.each(toggleColors)( + '%s toggle uses the spec roles for both states', + (mode, uContainer, uLabel, sContainer, sLabel) => { + const theme = getTheme(); + + expect(getButtonColors({ theme, mode, selected: false })).toMatchObject({ + backgroundColor: + uContainer === null ? 'transparent' : theme.colors[uContainer], + labelColor: theme.colors[uLabel], + }); + + expect(getButtonColors({ theme, mode, selected: true })).toMatchObject({ + backgroundColor: theme.colors[sContainer], + labelColor: theme.colors[sLabel], + }); + } + ); + + it('an unselected toggle differs from the same mode as a plain button', () => { + const theme = getTheme(); + const plain = getButtonColors({ theme, mode: 'filled' }); + const unselected = getButtonColors({ + theme, + mode: 'filled', + selected: false, + }); + + expect(unselected.backgroundColor).not.toBe(plain.backgroundColor); + expect(unselected.labelColor).not.toBe(plain.labelColor); + }); + + it('ignores the toggle table when disabled', () => { + const theme = getTheme(); + + expect( + getButtonColors({ theme, mode: 'filled', selected: true, disabled: true }) + ).toMatchObject({ + backgroundColor: theme.colors.onSurface, + labelColor: theme.colors.onSurface, + }); + }); +}); + +it('gives an elevated button a resting shadow, and other modes none', async () => { + // Level 1 at rest, drawn by `Surface`. + const [spotShadow] = shadow(1, getTheme().colors.shadow); + + await render( + + ); + expect(screen.getByTestId('elevated-container-outer-layer')).toHaveStyle( + spotShadow + ); + + await render( + + ); + expect(screen.getByTestId('filled-container-outer-layer')).toHaveStyle({ + shadowOpacity: 0, + }); +}); + +it('drops the shadow when an elevated button is disabled', async () => { + await render( + + ); + + expect(screen.getByTestId('elevated-container-outer-layer')).toHaveStyle({ + shadowOpacity: 0, + }); +}); + +it('forwards `style` to the shadow host', async () => { + await render( + + ); + + expect(screen.getByTestId('button-container-outer-layer')).toHaveStyle( + styles.scaled ); }); + +describe('container height', () => { + const MODES = ['filled', 'tonal', 'elevated', 'outlined', 'text'] as const; + const { containerHeight, leadingSpace } = Tokens.sizes.small; + + // The rendered container is the content box plus the outline Yoga draws + // around it, so those two have to add up to the token height in every mode. + const renderedBox = (testID: string) => { + const content = StyleSheet.flatten( + // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion. + screen.getByTestId(`${testID}-content`).props.style + ); + const clip = StyleSheet.flatten( + // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion. + screen.getByTestId(`${testID}-container`).props.style + ); + const outline = clip.borderWidth ?? 0; + return { + height: content.minHeight + outline * 2, + leading: content.paddingStart + outline, + }; + }; + + it.each(MODES)('renders %s at the token height', async (mode) => { + await render( + + ); + + expect(renderedBox('button')).toEqual({ + height: containerHeight, + leading: leadingSpace, + }); + }); + + it('keeps an outlined toggle the same size in both states', async () => { + await render( + + ); + const unselected = renderedBox('button'); + + await render( + + ); + + // Selecting drops the outline; without the inset this shrank by 2dp. + expect(renderedBox('button')).toEqual(unselected); + }); +}); + +describe('shape morph animation', () => { + beforeEach(() => { + jest.mocked(Reanimated.withSpring).mockClear(); + }); + + const springTargets = ( + spy: jest.MockedFunction + ) => spy.mock.calls.map((call) => call[0]); + + it('springs the corner radius to corner.small on press in', async () => { + const spy = jest.mocked(Reanimated.withSpring); + await render( + + ); + spy.mockClear(); + await fireEvent(screen.getByTestId('button'), 'onPressIn'); + expect(springTargets(spy)).toContain(getTheme().shapes.corner.small); + spy.mockClear(); + }); + + it('springs the corner radius back to the resting pill radius on press out', async () => { + const spy = jest.mocked(Reanimated.withSpring); + await render( + + ); + spy.mockClear(); + await fireEvent(screen.getByTestId('button'), 'onPressOut'); + // small round resting radius = minHeight (40) / 2 = 20 + expect(springTargets(spy)).toContain(20); + spy.mockClear(); + }); + + it('animates between round and square radii when toggled (no spring on mount)', async () => { + const spy = jest.mocked(Reanimated.withSpring); + await render( + + ); + // Mount snaps to the resting radius — no spring. + expect(spy).not.toHaveBeenCalled(); + await screen.rerender( + + ); + // selected flips square -> round; large round resting radius = 96 / 2 = 48 + expect(springTargets(spy)).toContain(48); + spy.mockClear(); + }); + + it('morphs a default button, with no size or shape passed', async () => { + const spy = jest.mocked(Reanimated.withSpring); + await render( + + ); + spy.mockClear(); + await fireEvent(screen.getByTestId('button'), 'onPressIn'); + expect(springTargets(spy)).toContain(getTheme().shapes.corner.small); + spy.mockClear(); + }); + + it('reads the pressed corner from the size tokens', async () => { + const spy = jest.mocked(Reanimated.withSpring); + await render( + + ); + spy.mockClear(); + await fireEvent(screen.getByTestId('button'), 'onPressIn'); + // A large button presses to `large` (16dp), not the small sizes' 8dp. + expect(springTargets(spy)).toContain(getTheme().shapes.corner.large); + spy.mockClear(); + }); + + it('springs with the same spatial config as the rest of the library', async () => { + const spy = jest.mocked(Reanimated.withSpring); + await render( + + ); + spy.mockClear(); + await fireEvent(screen.getByTestId('button'), 'onPressIn'); + + // Same spring FAB and Switch use, so the overshoot matches them. + const { damping, stiffness } = toRawSpring( + getTheme().motion.spring.fast.spatial + ); + expect(spy).toHaveBeenCalledWith( + expect.any(Number), + expect.objectContaining({ damping, stiffness }) + ); + spy.mockClear(); + }); + + it('skips the press morph under reduce motion', async () => { + const spy = jest.mocked(Reanimated.withSpring); + await render( + + + + ); + spy.mockClear(); + await fireEvent(screen.getByTestId('button'), 'onPressIn'); + expect(spy).not.toHaveBeenCalled(); + spy.mockClear(); + }); + + it('does not morph when animateShape is false', async () => { + const spy = jest.mocked(Reanimated.withSpring); + await render( + + ); + spy.mockClear(); + await fireEvent(screen.getByTestId('button'), 'onPressIn'); + expect(spy).not.toHaveBeenCalled(); + spy.mockClear(); + }); + + it('snaps instead of springing when animateShape is false and selected flips', async () => { + const spy = jest.mocked(Reanimated.withSpring); + await render( + + ); + await screen.rerender( + + ); + + // The shape still changes to the flipped radius, it just doesn't animate. + expect(spy).not.toHaveBeenCalled(); + // A direct shared-value write reaches the style on the next frame. + await jest.runAllTimersAsync(); + expect( + Reanimated.getAnimatedStyle(screen.getByTestId('button-container')) + ).toMatchObject({ borderRadius: 48 }); + spy.mockClear(); + }); +}); diff --git a/src/components/__tests__/Card/Card.test.tsx b/src/components/__tests__/Card/Card.test.tsx index 74637a8a53..f9359e597b 100644 --- a/src/components/__tests__/Card/Card.test.tsx +++ b/src/components/__tests__/Card/Card.test.tsx @@ -139,7 +139,7 @@ describe('CardActions', () => { await render( - + ); @@ -147,7 +147,7 @@ describe('CardActions', () => { expect( // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion. screen.getByTestId('card-actions').props.children[0].props.mode - ).toBe('contained'); + ).toBe('filled'); }); }); diff --git a/src/components/__tests__/__snapshots__/Banner.test.tsx.snap b/src/components/__tests__/__snapshots__/Banner.test.tsx.snap index 94a5339bab..eb8d4dfa04 100644 --- a/src/components/__tests__/__snapshots__/Banner.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Banner.test.tsx.snap @@ -184,16 +184,8 @@ exports[`render visible banner, with custom theme 1`] = ` [ {}, { - "borderStyle": "solid", "minWidth": 64, }, - { - "minWidth": "auto", - }, - { - "borderColor": "transparent", - "borderWidth": 0, - }, { "margin": 4, }, @@ -228,7 +220,7 @@ exports[`render visible banner, with custom theme 1`] = ` }, ] } - testID="button-container" + testID="button-container-outer-layer" > - + - first - + ] + } + testID="button-text" + > + first + + @@ -796,16 +812,8 @@ exports[`renders visible banner, with action buttons and with image 1`] = ` [ {}, { - "borderStyle": "solid", "minWidth": 64, }, - { - "minWidth": "auto", - }, - { - "borderColor": "transparent", - "borderWidth": 0, - }, { "margin": 4, }, @@ -840,7 +848,7 @@ exports[`renders visible banner, with action buttons and with image 1`] = ` }, ] } - testID="button-container" + testID="button-container-outer-layer" > - + - first - + ] + } + testID="button-text" + > + first + + @@ -1190,16 +1222,8 @@ exports[`renders visible banner, with action buttons and without image 1`] = ` [ {}, { - "borderStyle": "solid", "minWidth": 64, }, - { - "minWidth": "auto", - }, - { - "borderColor": "transparent", - "borderWidth": 0, - }, { "margin": 4, }, @@ -1234,7 +1258,7 @@ exports[`renders visible banner, with action buttons and without image 1`] = ` }, ] } - testID="button-container" + testID="button-container-outer-layer" > - + - first - + ] + } + testID="button-text" + > + first + + @@ -1400,16 +1448,8 @@ exports[`renders visible banner, with action buttons and without image 1`] = ` [ {}, { - "borderStyle": "solid", "minWidth": 64, }, - { - "minWidth": "auto", - }, - { - "borderColor": "transparent", - "borderWidth": 0, - }, { "margin": 4, }, @@ -1444,7 +1484,7 @@ exports[`renders visible banner, with action buttons and without image 1`] = ` }, ] } - testID="button-container" + testID="button-container-outer-layer" > - + - second - + ] + } + testID="button-text" + > + second + + diff --git a/src/components/__tests__/__snapshots__/Button.test.tsx.snap b/src/components/__tests__/__snapshots__/Button.test.tsx.snap index ff911ae1d0..a2003868c9 100644 --- a/src/components/__tests__/__snapshots__/Button.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Button.test.tsx.snap @@ -7,18 +7,12 @@ exports[`renders button with an accessibility hint 1`] = ` [ {}, { - "borderStyle": "solid", "minWidth": 64, }, undefined, - { - "borderColor": "transparent", - "borderWidth": 0, - }, - undefined, {}, { - "backgroundColor": "transparent", + "backgroundColor": "rgba(103, 80, 164, 1)", }, { "borderBottomEndRadius": undefined, @@ -47,7 +41,7 @@ exports[`renders button with an accessibility hint 1`] = ` }, ] } - testID="button-container" + testID="button-container-outer-layer" > - + - Button with accessibility hint - + ] + } + testID="button-text" + > + Button with accessibility hint + + @@ -216,18 +236,12 @@ exports[`renders button with an accessibility label 1`] = ` [ {}, { - "borderStyle": "solid", "minWidth": 64, }, undefined, - { - "borderColor": "transparent", - "borderWidth": 0, - }, - undefined, {}, { - "backgroundColor": "transparent", + "backgroundColor": "rgba(103, 80, 164, 1)", }, { "borderBottomEndRadius": undefined, @@ -256,7 +270,7 @@ exports[`renders button with an accessibility label 1`] = ` }, ] } - testID="button-container" + testID="button-container-outer-layer" > - + - Button with accessibility label - + ] + } + testID="button-text" + > + Button with accessibility label + + @@ -423,15 +463,9 @@ exports[`renders button with button color 1`] = ` [ {}, { - "borderStyle": "solid", "minWidth": 64, }, undefined, - { - "borderColor": "transparent", - "borderWidth": 0, - }, - undefined, {}, { "backgroundColor": "#e91e63", @@ -463,7 +497,7 @@ exports[`renders button with button color 1`] = ` }, ] } - testID="button-container" + testID="button-container-outer-layer" > - + - Custom Button - + ] + } + testID="button-text" + > + Custom Button + + @@ -630,18 +690,12 @@ exports[`renders button with color 1`] = ` [ {}, { - "borderStyle": "solid", "minWidth": 64, }, undefined, - { - "borderColor": "transparent", - "borderWidth": 0, - }, - undefined, {}, { - "backgroundColor": "transparent", + "backgroundColor": "rgba(103, 80, 164, 1)", }, { "borderBottomEndRadius": undefined, @@ -670,7 +724,7 @@ exports[`renders button with color 1`] = ` }, ] } - testID="button-container" + testID="button-container-outer-layer" > - + - Custom Button - + ] + } + testID="button-text" + > + Custom Button + + @@ -837,18 +917,12 @@ exports[`renders button with custom testID 1`] = ` [ {}, { - "borderStyle": "solid", "minWidth": 64, }, undefined, - { - "borderColor": "transparent", - "borderWidth": 0, - }, - undefined, {}, { - "backgroundColor": "transparent", + "backgroundColor": "rgba(103, 80, 164, 1)", }, { "borderBottomEndRadius": undefined, @@ -877,7 +951,7 @@ exports[`renders button with custom testID 1`] = ` }, ] } - testID="custom:testID-container" + testID="custom:testID-container-outer-layer" > - + - Button with custom testID - + ] + } + testID="custom:testID-text" + > + Button with custom testID + + @@ -1044,18 +1144,12 @@ exports[`renders button with icon 1`] = ` [ {}, { - "borderStyle": "solid", "minWidth": 64, }, undefined, - { - "borderColor": "transparent", - "borderWidth": 0, - }, - undefined, {}, { - "backgroundColor": "transparent", + "backgroundColor": "rgba(103, 80, 164, 1)", }, { "borderBottomEndRadius": undefined, @@ -1084,7 +1178,7 @@ exports[`renders button with icon 1`] = ` }, ] } - testID="button-container" + testID="button-container-outer-layer" > + + + camera + + - camera - - - - Icon Button - + ] + } + testID="button-text" + > + Icon Button + + @@ -1300,18 +1404,12 @@ exports[`renders button with icon in reverse order 1`] = ` [ {}, { - "borderStyle": "solid", "minWidth": 64, }, undefined, - { - "borderColor": "transparent", - "borderWidth": 0, - }, - undefined, {}, { - "backgroundColor": "transparent", + "backgroundColor": "rgba(103, 80, 164, 1)", }, { "borderBottomEndRadius": undefined, @@ -1340,7 +1438,7 @@ exports[`renders button with icon in reverse order 1`] = ` }, ] } - testID="button-container" + testID="button-container-outer-layer" > + + + chevron-right + + - chevron-right - - - - Right Icon - + ] + } + testID="button-text" + > + Right Icon + + `; -exports[`renders contained contained with mode 1`] = ` +exports[`renders disabled button 1`] = ` + - + - Contained Button - + ] + } + testID="button-text" + > + Disabled Button + + `; -exports[`renders disabled button 1`] = ` +exports[`renders filled button by default 1`] = ` - + - Disabled Button - + ] + } + testID="button-text" + > + Filled Button + + `; -exports[`renders loading button 1`] = ` +exports[`renders filled button with mode 1`] = ` + + Contained Button + + + + + +`; + +exports[`renders loading button 1`] = ` + + + + + @@ -2175,13 +2566,13 @@ exports[`renders loading button 1`] = ` collapsable={false} style={ { - "height": 18, - "transform": [ - { - "rotate": "45deg", - }, - ], - "width": 18, + "alignItems": "center", + "bottom": 0, + "justifyContent": "center", + "left": 0, + "position": "absolute", + "right": 0, + "top": 0, } } > @@ -2189,9 +2580,13 @@ exports[`renders loading button 1`] = ` collapsable={false} style={ { - "height": 9, - "overflow": "hidden", - "width": 18, + "height": 20, + "transform": [ + { + "rotate": "45deg", + }, + ], + "width": 20, } } > @@ -2199,16 +2594,9 @@ exports[`renders loading button 1`] = ` collapsable={false} style={ { - "height": 18, - "transform": [ - { - "translateY": 0, - }, - { - "rotate": "-165deg", - }, - ], - "width": 18, + "height": 10, + "overflow": "hidden", + "width": 20, } } > @@ -2216,9 +2604,16 @@ exports[`renders loading button 1`] = ` collapsable={false} style={ { - "height": 9, - "overflow": "hidden", - "width": 18, + "height": 20, + "transform": [ + { + "translateY": 0, + }, + { + "rotate": "-165deg", + }, + ], + "width": 20, } } > @@ -2226,44 +2621,40 @@ exports[`renders loading button 1`] = ` collapsable={false} style={ { - "borderColor": "rgba(103, 80, 164, 1)", - "borderRadius": 9, - "borderWidth": 1.8, - "height": 18, - "width": 18, + "height": 10, + "overflow": "hidden", + "width": 20, } } - /> + > + + - - @@ -2271,10 +2662,13 @@ exports[`renders loading button 1`] = ` collapsable={false} style={ { - "height": 9, - "overflow": "hidden", - "top": 9, - "width": 18, + "height": 20, + "transform": [ + { + "rotate": "45deg", + }, + ], + "width": 20, } } > @@ -2282,16 +2676,10 @@ exports[`renders loading button 1`] = ` collapsable={false} style={ { - "height": 18, - "transform": [ - { - "translateY": -9, - }, - { - "rotate": "345deg", - }, - ], - "width": 18, + "height": 10, + "overflow": "hidden", + "top": 10, + "width": 20, } } > @@ -2299,9 +2687,16 @@ exports[`renders loading button 1`] = ` collapsable={false} style={ { - "height": 9, - "overflow": "hidden", - "width": 18, + "height": 20, + "transform": [ + { + "translateY": -10, + }, + { + "rotate": "345deg", + }, + ], + "width": 20, } } > @@ -2309,69 +2704,73 @@ exports[`renders loading button 1`] = ` collapsable={false} style={ { - "borderColor": "rgba(103, 80, 164, 1)", - "borderRadius": 9, - "borderWidth": 1.8, - "height": 18, - "width": 18, + "height": 10, + "overflow": "hidden", + "width": 20, } } - /> + > + + - - - Loading Button - + ] + } + testID="button-text" + > + Loading Button + + @@ -2384,15 +2783,9 @@ exports[`renders outlined button with mode 1`] = ` [ {}, { - "borderStyle": "solid", "minWidth": 64, }, undefined, - { - "borderColor": "rgba(202, 196, 208, 1)", - "borderWidth": 1, - }, - undefined, {}, { "backgroundColor": "transparent", @@ -2424,7 +2817,7 @@ exports[`renders outlined button with mode 1`] = ` }, ] } - testID="button-container" + testID="button-container-outer-layer" > - + - Outlined Button - + ] + } + testID="button-text" + > + Outlined Button + + `; -exports[`renders text button by default 1`] = ` +exports[`renders text button with mode 1`] = ` - + - Text Button - + ] + } + testID="button-text" + > + Text Button + + `; -exports[`renders text button with mode 1`] = ` +exports[`size prop renders a button with per-size metrics 1`] = ` - + + + camera + + + - Text Button - + ] + } + testID="button-text" + > + Medium + + diff --git a/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap b/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap index b1b80b7341..a39909198f 100644 --- a/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap @@ -1527,14 +1527,8 @@ exports[`DataTable.Pagination renders data table pagination with options select [ {}, { - "borderStyle": "solid", "minWidth": 64, }, - undefined, - { - "borderColor": "rgba(202, 196, 208, 1)", - "borderWidth": 1, - }, { "marginRight": 16, "textAlign": "center", @@ -1570,7 +1564,7 @@ exports[`DataTable.Pagination renders data table pagination with options select }, ] } - testID="button-container" + testID="button-container-outer-layer" > + + + menu-down + + - menu-down - - - - 2 - + ] + } + testID="button-text" + > + 2 + + diff --git a/src/components/__tests__/__snapshots__/Menu.test.tsx.snap b/src/components/__tests__/__snapshots__/Menu.test.tsx.snap index afcd649f4f..d3b7da8309 100644 --- a/src/components/__tests__/__snapshots__/Menu.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Menu.test.tsx.snap @@ -19,15 +19,9 @@ exports[`renders not visible menu 1`] = ` [ {}, { - "borderStyle": "solid", "minWidth": 64, }, undefined, - { - "borderColor": "rgba(202, 196, 208, 1)", - "borderWidth": 1, - }, - undefined, {}, { "backgroundColor": "transparent", @@ -59,7 +53,7 @@ exports[`renders not visible menu 1`] = ` }, ] } - testID="button-container" + testID="button-container-outer-layer" > - + - Open menu - + ] + } + testID="button-text" + > + Open menu + + @@ -242,15 +261,9 @@ exports[`renders visible menu 1`] = ` [ {}, { - "borderStyle": "solid", "minWidth": 64, }, undefined, - { - "borderColor": "rgba(202, 196, 208, 1)", - "borderWidth": 1, - }, - undefined, {}, { "backgroundColor": "transparent", @@ -282,7 +295,7 @@ exports[`renders visible menu 1`] = ` }, ] } - testID="button-container" + testID="button-container-outer-layer" > - + - Open menu - + ] + } + testID="button-text" + > + Open menu + + diff --git a/src/components/__tests__/__snapshots__/Snackbar.test.tsx.snap b/src/components/__tests__/__snapshots__/Snackbar.test.tsx.snap index 095b214bb2..2ce38971b0 100644 --- a/src/components/__tests__/__snapshots__/Snackbar.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Snackbar.test.tsx.snap @@ -484,14 +484,8 @@ exports[`renders snackbar with action button 1`] = ` [ {}, { - "borderStyle": "solid", "minWidth": 64, }, - false, - { - "borderColor": "transparent", - "borderWidth": 0, - }, { "marginLeft": 4, "marginRight": 8, @@ -528,7 +522,7 @@ exports[`renders snackbar with action button 1`] = ` }, ] } - testID="button-container" + testID="button-container-outer-layer" > - + - Undo - + ] + } + testID="button-text" + > + Undo + +