diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index 72aee6d00f..82d4394a1c 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -253,7 +253,7 @@ When the dialog is `dismissable`, screen reader users can dismiss it with a visu #### `Dialog.Actions` -`Dialog.Actions` no longer injects `compact` on its buttons. To keep the previous behavior, you need to add the `compact` prop to each button: +`Dialog.Actions` no longer injects `compact` on its buttons. To get close to the previous look, add `size="extra-small"` (which replaces `compact`) and `mode="text"` (since the default [`Button`](#button) mode is now `filled`) to each button. The buttons get shorter: `extra-small` is **32dp** tall, while `compact` buttons kept the **40dp** height: ```tsx // Before (v5) @@ -263,7 +263,7 @@ When the dialog is `dismissable`, screen reader users can dismiss it with a visu // After (v6) - @@ -428,6 +428,132 @@ const theme = { /> ``` +### 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 keeps the +**40dp** height of the only size 5.x had. Its horizontal padding is different, +see the appearance changes below. + +```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. It is ignored on `mode="text"`, which MD3 gives no toggle. + +```tsx + +``` + +#### Removed props + +- **`uppercase`** → `labelStyle={{ textTransform: 'uppercase' }}`. +- **`compact`** → `size="extra-small"`. This also changes the height: + `extra-small` is **32dp** tall, while `compact` only reduced the horizontal + margins and the min width and kept the **40dp** height. +- **`contentStyle={{ flexDirection: 'row-reverse' }}`** → `iconPosition="trailing"`. + +```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**. +- At the default `small` size, the horizontal padding is **16dp** for every + mode. In 5.x, the label margins were **24dp** for `filled`, `tonal`, + `elevated` and `outlined`, and **12dp** for `text` (**16dp** with an icon). + Existing buttons change width. + ### Card #### `Card` layout @@ -451,7 +577,7 @@ This means, in a typical card layout, the Card will automatically apply the nece Card content - + @@ -498,7 +624,7 @@ We have also added a `direction` prop to the `Card` component, which lets you ar Card content - + @@ -506,7 +632,7 @@ We have also added a `direction` prop to the `Card` component, which lets you ar #### `Card.Actions` -`Card.Actions` no longer injects `mode` on the buttons. To keep the previous behavior, you need to set `mode="outlined"` on the first button and `mode="contained"` on the rest: +`Card.Actions` no longer injects `mode` on the buttons. To keep the previous behavior, you need to set `mode="outlined"` on the first button and `mode="filled"` (formerly `contained`) on the rest: ```tsx // Before (v5) @@ -518,7 +644,7 @@ We have also added a `direction` prop to the `Card` component, which lets you ar // After (v6) - + ``` 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 b7c59d424d..50abc5bf10 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 118114da27..cac194c50d 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/DrawerItems.tsx b/example/src/DrawerItems.tsx index 7e39596f9b..6b758e0895 100644 --- a/example/src/DrawerItems.tsx +++ b/example/src/DrawerItems.tsx @@ -251,7 +251,9 @@ function DrawerItems() { example directory. - + diff --git a/example/src/Examples/ButtonExample.tsx b/example/src/Examples/ButtonExample.tsx index 2a164e8e77..66424d1c57 100644 --- a/example/src/Examples/ButtonExample.tsx +++ b/example/src/Examples/ButtonExample.tsx @@ -1,110 +1,199 @@ +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', +]; +// MD3 defines no text toggle, so `text` is absent from the toggle showcase. +const TOGGLE_MODES: Mode[] = MODES.filter((mode) => mode !== 'text'); +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) => ( + + ))} + + ))} + + + + + {TOGGLE_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 +361,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 +399,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/example/src/Examples/CardExample.tsx b/example/src/Examples/CardExample.tsx index 9c4e04f8e2..09b732d9d0 100644 --- a/example/src/Examples/CardExample.tsx +++ b/example/src/Examples/CardExample.tsx @@ -77,7 +77,7 @@ const CardExample = () => { - @@ -109,14 +109,16 @@ const CardExample = () => { diff --git a/example/src/Examples/Dialogs/DialogWithDismissableBackButton.tsx b/example/src/Examples/Dialogs/DialogWithDismissableBackButton.tsx index d7a37e5569..dd52bd5d92 100644 --- a/example/src/Examples/Dialogs/DialogWithDismissableBackButton.tsx +++ b/example/src/Examples/Dialogs/DialogWithDismissableBackButton.tsx @@ -23,10 +23,12 @@ const DialogWithDismissableBackButton = ({ - - + ); diff --git a/example/src/Examples/Dialogs/DialogWithIcon.tsx b/example/src/Examples/Dialogs/DialogWithIcon.tsx index 0d5f595f9c..5a577343cd 100644 --- a/example/src/Examples/Dialogs/DialogWithIcon.tsx +++ b/example/src/Examples/Dialogs/DialogWithIcon.tsx @@ -22,10 +22,12 @@ const DialogWithIcon = ({ - - + ); diff --git a/example/src/Examples/Dialogs/DialogWithLongText.tsx b/example/src/Examples/Dialogs/DialogWithLongText.tsx index eed30ec358..bd0dd349e4 100644 --- a/example/src/Examples/Dialogs/DialogWithLongText.tsx +++ b/example/src/Examples/Dialogs/DialogWithLongText.tsx @@ -61,7 +61,9 @@ const DialogWithLongText = ({ - + ); diff --git a/example/src/Examples/Dialogs/DialogWithRadioBtns.tsx b/example/src/Examples/Dialogs/DialogWithRadioBtns.tsx index 5a729d6299..73b61b14eb 100644 --- a/example/src/Examples/Dialogs/DialogWithRadioBtns.tsx +++ b/example/src/Examples/Dialogs/DialogWithRadioBtns.tsx @@ -82,8 +82,12 @@ const DialogWithRadioBtns = ({ visible, close }: Props) => { - - + + ); diff --git a/example/src/Examples/Dialogs/DialogWithUndismissableBackButton.tsx b/example/src/Examples/Dialogs/DialogWithUndismissableBackButton.tsx index d775ba011c..deae141b13 100644 --- a/example/src/Examples/Dialogs/DialogWithUndismissableBackButton.tsx +++ b/example/src/Examples/Dialogs/DialogWithUndismissableBackButton.tsx @@ -18,10 +18,12 @@ const DialogWithUndismissableBackButton = ({ - - + ); diff --git a/example/src/Examples/Dialogs/UndismissableDialog.tsx b/example/src/Examples/Dialogs/UndismissableDialog.tsx index 79dc777484..fa10e5c6a4 100644 --- a/example/src/Examples/Dialogs/UndismissableDialog.tsx +++ b/example/src/Examples/Dialogs/UndismissableDialog.tsx @@ -15,10 +15,12 @@ const UndismissableDialog = ({ This is an undismissable dialog!! - - + ); diff --git a/example/src/Examples/TeamDetails.tsx b/example/src/Examples/TeamDetails.tsx index 463e5148d8..cb06e817d6 100644 --- a/example/src/Examples/TeamDetails.tsx +++ b/example/src/Examples/TeamDetails.tsx @@ -96,7 +96,7 @@ const News = () => { - @@ -117,7 +117,7 @@ const News = () => { - diff --git a/src/components/Banner.tsx b/src/components/Banner.tsx index 909f75bf3f..1cc64c824b 100644 --- a/src/components/Banner.tsx +++ b/src/components/Banner.tsx @@ -256,7 +256,6 @@ const Banner = ({ {actions.map(({ label, ...others }, i) => ( * ); @@ -162,13 +225,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 +248,26 @@ const Button = ({ delayLongPress, style, theme: themeOverrides, - uppercase: uppercaseProp, contentStyle, labelStyle, testID, 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,182 +276,272 @@ const Button = ({ onLongPress, }); + const reduceMotion = useReduceMotion(); + 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); + // MD3 defines no text toggle, so `selected` is inert on `mode="text"`: no + // toggle colors, no shape flip, and no toggle state. Every other use of + // the toggle state goes through this value. + const toggleSelected = isMode('text') ? undefined : selected; + // ARIA has no `aria-selected` on the button role, and React Native's types + // have no `aria-pressed`. + const toggleStateProps = + Platform.OS === 'web' + ? { 'aria-pressed': toggleSelected } + : { 'aria-selected': toggleSelected }; - if (isElevationEntitled) { - setPressed(true); - } - }; + // When the button is `selected`, flip the requested shape so the + // unselected/selected pair contrasts visually (round ↔ square). + const effectiveShape = getEffectiveButtonShape(shape, toggleSelected); + const sizeStyle = React.useMemo(() => getButtonSizeStyle(size), [size]); - const handlePressOut = (e: GestureResponderEvent) => { - onPressOut?.(e); + const effectiveHitSlop = + hitSlop === undefined ? getButtonHitSlop(size) : hitSlop; - if (isElevationEntitled) { - setPressed(false); - } - }; + // A CSS transition shaped like the MD3 fast spatial spring animates the + // corner. Under reduce motion, or with `animateShape` off, it snaps. + const animatesShape = animateShapeProp && !reduceMotion; + // `round` resolves to the `cornerFull` sentinel, so use the real pill radius + // (half the container height) instead: it keeps the transition bounded and + // lets the ripple clip match the container exactly. + const restingRadius = + effectiveShape === 'round' + ? sizeStyle.minHeight / 2 + : getButtonShapeRadius({ size, shape: effectiveShape, theme }); + const radius = + animatesShape && pressed + ? getButtonPressedRadius({ size, theme }) + : restingRadius; + const shapeSpring = React.useMemo(() => { + const { points, duration } = toSpringCurve( + theme.motion.spring.fast.spatial + ); + return { timingFunction: linear(...points), duration }; + }, [theme.motion.spring.fast.spatial]); + const shapeTransitionDuration = animatesShape + ? shapeSpring.duration * theme.animation.scale + : 0; - const elevationTransitionDuration = - theme.motion.duration[pressed ? 'short4' : 'short3'] * - theme.animation.scale; + const tracksPress = animatesShape || isElevationEntitled; - const borderRadius = theme.shapes.corner.largeIncreased; - const iconSize = 18; + const handlePressIn = React.useCallback( + (e: GestureResponderEvent) => { + onPressIn?.(e); + if (tracksPress) { + setPressed(true); + } + }, + [onPressIn, tracksPress] + ); + + const handlePressOut = React.useCallback( + (e: GestureResponderEvent) => { + onPressOut?.(e); + // Unconditional, so a stale `pressed` can't stick. + setPressed(false); + }, + [onPressOut] + ); const { backgroundColor, borderColor, - textColor, - textOpacity, + labelColor, + labelOpacity, borderWidth, backgroundOpacity, - } = getButtonColors({ - customButtonColor, - customTextColor, + } = React.useMemo( + () => + getButtonColors({ + customButtonColor, + customLabelColor, + theme, + mode, + size, + disabled, + dark, + selected: toggleSelected, + }), + [ + customButtonColor, + customLabelColor, + theme, + mode, + size, + disabled, + dark, + toggleSelected, + ] + ); + + const rippleColor = React.useMemo( + () => getButtonRippleColor({ labelColor, customRippleColor }), + [labelColor, customRippleColor] + ); + + const containerColor = + backgroundOpacity < 1 ? 'transparent' : backgroundColor; + + // Snap rather than animate when a transparent container is involved. + const [committedContainerColor, setCommittedContainerColor] = + React.useState(containerColor); + React.useEffect(() => { + setCommittedContainerColor(containerColor); + }, [containerColor]); + + const surfaceTransitionDuration = getButtonTransitionDuration({ theme, - mode, - disabled, - dark, + pressed, + containerColor, + previousContainerColor: committedContainerColor, }); - const touchableStyle = { borderRadius }; + // The clip carries the same radius transition 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 shapeTransitionStyle = { + transitionProperty: 'borderRadius', + transitionDuration: shapeTransitionDuration, + transitionTimingFunction: shapeSpring.timingFunction, + } as const; - const { color: customLabelColor, fontSize: customLabelSize } = - StyleSheet.flatten(labelStyle) || {}; + const clipStyle = { ...shapeTransitionStyle, borderRadius: radius }; - const font = theme.fonts.labelLarge; - - const textStyle = { - color: textColor, - ...font, + const innerClipStyle = { + ...shapeTransitionStyle, + borderRadius: Math.max(0, radius - 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 outlineStyle = React.useMemo( + () => ({ backgroundColor: containerColor, borderColor, borderWidth }), + [containerColor, borderColor, borderWidth] + ); + + const { color: labelStyleColor } = React.useMemo( + () => StyleSheet.flatten(labelStyle) || {}, + [labelStyle] + ); + + const contentBoxStyle = React.useMemo( + () => getButtonContentBoxStyle(sizeStyle, borderWidth), + [sizeStyle, borderWidth] + ); + + const labelTypeStyle = React.useMemo( + () => ({ + color: labelColor, + ...theme.fonts[sizeStyle.labelVariant], + }), + [labelColor, theme, sizeStyle] + ); return ( - {backgroundOpacity < 1 && ( - - )} - - - {icon && loading !== true ? ( - - - - ) : null} - {loading ? ( - - ) : null} - + {backgroundOpacity < 1 && ( + + )} + + - {children} - - - + {icon && loading !== true ? ( + + + + ) : null} + {loading ? ( + + ) : null} + + {children} + + + + ); }; @@ -393,78 +549,20 @@ const Button = ({ const styles = StyleSheet.create({ button: { minWidth: 64, - borderStyle: 'solid', }, - compact: { - minWidth: 'auto', + clip: { + borderStyle: 'solid', }, 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, - }, - md3IconTextMode: { - marginLeft: 12, - marginRight: -8, + contentReverse: { + flexDirection: 'row-reverse', }, - 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..9fc5c23778 --- /dev/null +++ b/src/components/Button/tokens.ts @@ -0,0 +1,141 @@ +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; + outerBoundHeight: 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; +}; + +const sizes = { + 'extra-small': { + containerHeight: 32, + iconSize: 20, + iconLabelSpace: 4, + leadingSpace: 12, + trailingSpace: 12, + outlinedOutlineWidth: 1, + outerBoundHeight: 48, + labelVariant: 'labelLarge', + containerShapeRound: 'full', + containerShapeSquare: 'medium', + pressedContainerShape: 'small', + }, + small: { + containerHeight: 40, + iconSize: 20, + iconLabelSpace: 8, + leadingSpace: 16, + trailingSpace: 16, + outlinedOutlineWidth: 1, + outerBoundHeight: 48, + labelVariant: 'labelLarge', + containerShapeRound: 'full', + containerShapeSquare: 'medium', + pressedContainerShape: 'small', + }, + medium: { + containerHeight: 56, + iconSize: 24, + iconLabelSpace: 8, + leadingSpace: 24, + trailingSpace: 24, + outlinedOutlineWidth: 1, + outerBoundHeight: 64, + labelVariant: 'titleMedium', + containerShapeRound: 'full', + containerShapeSquare: 'large', + pressedContainerShape: 'medium', + }, + large: { + containerHeight: 96, + iconSize: 32, + iconLabelSpace: 12, + leadingSpace: 48, + trailingSpace: 48, + outlinedOutlineWidth: 2, + outerBoundHeight: 104, + labelVariant: 'headlineSmall', + containerShapeRound: 'full', + containerShapeSquare: 'extraLarge', + pressedContainerShape: 'large', + }, + 'extra-large': { + containerHeight: 136, + iconSize: 40, + iconLabelSpace: 16, + leadingSpace: 64, + trailingSpace: 64, + outlinedOutlineWidth: 3, + outerBoundHeight: 144, + labelVariant: 'headlineLarge', + containerShapeRound: 'full', + containerShapeSquare: 'extraLarge', + pressedContainerShape: 'large', + }, +} as const satisfies Record; + +/** + * Modes that have a toggle variant. MD3 defines the toggle for the filled, + * elevated, tonal and outlined styles only, so `text` has no toggle at all and + * `selected` is ignored there. + */ +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 ec3503c82b..d9f7a467f6 100644 --- a/src/components/Button/utils.tsx +++ b/src/components/Button/utils.tsx @@ -1,23 +1,193 @@ -import type { ColorValue, ViewStyle } from 'react-native'; +import type { ColorValue, Insets } 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 type { InternalTheme } from '../../theme/types'; -import { splitStyles } from '../../utils/splitStyles'; +import { resolveCornerRadius } from '../../theme/utils/shape'; 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; + outerBoundHeight: number; +}; + +/** + * 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, + outerBoundHeight: t.outerBoundHeight, + }; +}; + +/** The content box sits inside the outline, so the outline can't grow it. */ +export const getButtonContentBoxStyle = ( + sizeStyle: ButtonSizeStyle, + borderWidth: number +) => ({ + minHeight: sizeStyle.minHeight - borderWidth * 2, + paddingStart: sizeStyle.paddingStart - borderWidth, + paddingEnd: sizeStyle.paddingEnd - borderWidth, + gap: sizeStyle.iconGap, +}); + +/** + * Slop that brings the touch target up to the size's `outerBoundHeight`, + * expanding outside the container. + * + * Returns `undefined` when the container is already tall enough. + */ +export const getButtonHitSlop = (size: ButtonSize): Insets | undefined => { + const { containerHeight, outerBoundHeight } = Tokens.sizes[size]; + const vertical = (outerBoundHeight - containerHeight) / 2; + + if (vertical <= 0) { + return undefined; + } + + return { top: vertical, bottom: vertical, left: 0, right: 0 }; +}; + +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 a 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. Pass the shape after the `selected` flip. + */ +export const getButtonShapeRadius = ({ + size, + shape, + theme, +}: { + size: ButtonSize; + shape: ButtonShape; + theme: InternalTheme; +}): number => { + const t = Tokens.sizes[size]; + const token = + shape === 'round' ? t.containerShapeRound : t.containerShapeSquare; + return resolveCornerRadius(theme, token); +}; + +/** + * Duration of `Surface`'s background and elevation transition. + * + * Snaps when the container is or was 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 at all (`text`). + */ +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 +213,10 @@ const getButtonBackgroundColor = ({ theme, disabled, customButtonColor, -}: BaseProps & { + toggleColors, +}: Omit & { customButtonColor?: ColorValue; + toggleColors?: ToggleColors; }) => { const { colors } = theme; if (customButtonColor && !disabled) { @@ -58,68 +230,91 @@ 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, + disabled, + selected, +}: BaseProps) => { + // A selected outlined toggle drops its outline (the filled inverse-surface + // background takes over as the visual affordance). + if (selected && !disabled && isMode('outlined')) { + return 'transparent'; + } if (isMode('outlined')) { return theme.colors.outlineVariant; } @@ -127,9 +322,17 @@ const getButtonBorderColor = ({ isMode, theme }: BaseProps) => { return 'transparent'; }; -const getButtonBorderWidth = ({ isMode }: Omit) => { +const getButtonBorderWidth = ({ + isMode, + disabled, + selected, + size, +}: Omit & { size: ButtonSize }) => { + if (selected && !disabled && isMode('outlined')) { + return 0; + } if (isMode('outlined')) { - return 1; + return Tokens.sizes[size].outlinedOutlineWidth; } return 0; @@ -138,43 +341,63 @@ 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, + disabled, + selected, + }); - const borderWidth = getButtonBorderWidth({ isMode, theme }); + const borderWidth = getButtonBorderWidth({ + isMode, + disabled, + 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 +407,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/Card.tsx b/src/components/Card/Card.tsx index 75a3941bab..d6cc5c69fa 100644 --- a/src/components/Card/Card.tsx +++ b/src/components/Card/Card.tsx @@ -131,7 +131,7 @@ const DEFAULT_CARD_PADDING = 16; * * * - * + * * * * ); diff --git a/src/components/Card/CardActions.tsx b/src/components/Card/CardActions.tsx index 8246a7ec65..a126824025 100644 --- a/src/components/Card/CardActions.tsx +++ b/src/components/Card/CardActions.tsx @@ -27,7 +27,7 @@ export type Props = ViewProps & { * * * - * + * * * * ); diff --git a/src/components/Card/utils.tsx b/src/components/Card/utils.tsx index 75cbc29b99..5f3cbb5263 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 0fd2cb7a04..da165c5dc6 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}`} @@ -357,9 +357,6 @@ const styles = StyleSheet.create({ iconsContainer: { flexDirection: 'row', }, - contentStyle: { - flexDirection: 'row-reverse', - }, }); export default DataTablePagination; diff --git a/src/components/Dialog/Dialog.tsx b/src/components/Dialog/Dialog.tsx index 218353d5e8..b15803ea0c 100644 --- a/src/components/Dialog/Dialog.tsx +++ b/src/components/Dialog/Dialog.tsx @@ -87,7 +87,7 @@ const DIALOG_ELEVATION: Elevation = 3; * This is simple dialog * * - * + * * * * diff --git a/src/components/Dialog/DialogActions.tsx b/src/components/Dialog/DialogActions.tsx index fb8e0c56e4..29ab90006f 100644 --- a/src/components/Dialog/DialogActions.tsx +++ b/src/components/Dialog/DialogActions.tsx @@ -33,8 +33,8 @@ export type Props = ViewProps & { * return ( * * - * - * + * + * * * * ); diff --git a/src/components/Dialog/utils.ts b/src/components/Dialog/utils.ts index 40ca761321..2d546489f5 100644 --- a/src/components/Dialog/utils.ts +++ b/src/components/Dialog/utils.ts @@ -8,8 +8,3 @@ export const DialogTitleIdContext = React.createContext( 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 fac1f7a049..9c56568036 100644 --- a/src/components/Snackbar.tsx +++ b/src/components/Snackbar.tsx @@ -314,7 +314,6 @@ const Snackbar = ({ }} style={[styles.button, actionStyle]} textColor={buttonTextColor} - compact={false} mode="text" theme={theme} {...actionProps} diff --git a/src/components/Surface.tsx b/src/components/Surface.tsx index a91fd938d5..10da26b27b 100644 --- a/src/components/Surface.tsx +++ b/src/components/Surface.tsx @@ -6,6 +6,7 @@ import Animated, { cubicBezier, isSharedValue, type AnimatedStyle, + type CSSTransitionTimingFunction, useAnimatedStyle, } from 'react-native-reanimated'; @@ -95,6 +96,15 @@ export type Props = Omit & * milliseconds. */ transitionDuration?: number; + /** + * Duration of the `borderRadius` transition in milliseconds. Undefined + * means no transition. + */ + shapeTransitionDuration?: number; + /** + * Timing function of the `borderRadius` transition. + */ + shapeTransitionTimingFunction?: CSSTransitionTimingFunction; /** * Style of the Surface. * @@ -191,6 +201,8 @@ const Surface = ({ testID, mode = 'elevated', transitionDuration: customTransitionDuration, + shapeTransitionDuration, + shapeTransitionTimingFunction, ref, ...rest }: Props) => { @@ -203,8 +215,13 @@ const Surface = ({ const backgroundStyle = { backgroundColor }; + // A CSS transition can't animate a value set by the animated style. + const transitionsRadius = + shapeTransitionDuration !== undefined && typeof borderRadius === 'number'; + const radiusStyles = transitionsRadius ? [{ borderRadius }] : []; + const shapeProps = { - borderRadius, + ...(transitionsRadius ? null : { borderRadius }), borderBottomEndRadius, borderBottomLeftRadius, borderBottomRightRadius, @@ -230,21 +247,51 @@ const Surface = ({ const transitionDuration = customTransitionDuration ?? theme.motion.duration.short3 * theme.animation.scale; - const transitionDurationStyle: AnimatedStyle = { - transitionDuration, - }; const transitionTimingFunction = cubicBezier(...theme.motion.easing.standard); - const transitionProperty = + const colorTransitionProperty = // FIXME: Reanimated can't animate PlatformColor and DynamicColorIOS typeof backgroundColor === 'string' ? ['backgroundColor' as const] : []; + const getTransitionStyles = ( + shadowProperties: ( + | 'boxShadow' + | 'elevation' + | 'shadowOpacity' + | 'shadowOffset' + | 'shadowRadius' + )[] + ) => { + const properties = [...colorTransitionProperty, ...shadowProperties]; + const transitionStyle: AnimatedStyle = { + transitionTimingFunction: + transitionsRadius && shapeTransitionTimingFunction !== undefined + ? [ + ...properties.map(() => transitionTimingFunction), + shapeTransitionTimingFunction, + ] + : transitionTimingFunction, + transitionProperty: transitionsRadius + ? [...properties, 'borderRadius'] + : properties, + }; + const transitionDurationStyle: AnimatedStyle = { + transitionDuration: + transitionsRadius && shapeTransitionDuration !== undefined + ? [ + ...properties.map(() => transitionDuration), + shapeTransitionDuration, + ] + : transitionDuration, + }; + return { transitionStyle, transitionDurationStyle }; + }; + if (Platform.OS === 'web') { const [elevationShadow] = shadow(elevation, theme.colors.shadow); - const transitionStyle: AnimatedStyle = { - transitionTimingFunction, - transitionProperty: [...transitionProperty, 'boxShadow'], - }; + const { transitionStyle, transitionDurationStyle } = getTransitionStyles([ + 'boxShadow', + ]); return ( @@ -269,10 +317,9 @@ const Surface = ({ if (Platform.OS === 'android') { const elevationAndroid = androidElevationLevels[elevation]; - const transitionStyle: AnimatedStyle = { - transitionTimingFunction, - transitionProperty: [...transitionProperty, 'elevation'], - }; + const { transitionStyle, transitionDurationStyle } = getTransitionStyles([ + 'elevation', + ]); return ( @@ -295,15 +343,11 @@ const Surface = ({ const [spotShadow, ambientShadow] = shadow(elevation, theme.colors.shadow); - const transitionStyle: AnimatedStyle = { - transitionTimingFunction, - transitionProperty: [ - ...transitionProperty, - 'shadowOpacity', - 'shadowOffset', - 'shadowRadius', - ], - }; + const { transitionStyle, transitionDurationStyle } = getTransitionStyles([ + 'shadowOpacity', + 'shadowOffset', + 'shadowRadius', + ]); return ( diff --git a/src/components/__tests__/Button.test.tsx b/src/components/__tests__/Button.test.tsx index 7ea496a419..9cc74076a0 100644 --- a/src/components/__tests__/Button.test.tsx +++ b/src/components/__tests__/Button.test.tsx @@ -1,25 +1,67 @@ -import { StyleSheet } from 'react-native'; +import { Platform, PlatformColor, StyleSheet } from 'react-native'; -import { describe, expect, it, jest } from '@jest/globals'; -import { userEvent } from '@testing-library/react-native'; +import { afterEach, describe, expect, it, jest } from '@jest/globals'; +import { fireEvent } from '@testing-library/react-native'; +import color from 'color'; +import { LocaleProvider } from '../../core/locale'; import { render, screen } from '../../test-utils'; +import { ReduceMotionContext } from '../../theme/accessibility/ReduceMotionContext'; import { pink500, white } from '../../theme/colors'; -import { DarkTheme, LightTheme } from '../../theme/schemes'; +import { LightTheme } from '../../theme/schemes'; import { tokens } from '../../theme/tokens'; +import { shadow } from '../../theme/tokens/sys/elevation'; import Button from '../Button/Button'; -import { getButtonColors } from '../Button/utils'; +import { Tokens } from '../Button/tokens'; +import { + getButtonColors, + getButtonContentBoxStyle, + getButtonHitSlop, + getButtonPressedRadius, + getButtonRippleColor, + getButtonShapeRadius, + getButtonSizeStyle, + getButtonTransitionDuration, +} from '../Button/utils'; +import type { ButtonLabelVariant, ButtonSize } from '../Button/utils'; const stateOpacity = tokens.md.sys.state.opacity; +afterEach(() => { + jest.restoreAllMocks(); +}); + 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(); +// The content row has no test ID, so it is reached from its label. +const parentOf = ( + element: ReturnType, + describeChild: string +) => { + const { parent } = element; + + if (!parent) { + throw new Error( + `Expected ${describeChild} to be wrapped in a parent view.` + ); + } + + return parent; +}; + +const labelOf = (label: string) => screen.getByText(label); +const contentOf = (label: string) => + parentOf(labelOf(label), `the "${label}" label`); + +it('renders filled button by default', async () => { + const tree = (await render()).toJSON(); expect(tree).toMatchSnapshot(); }); @@ -40,14 +82,6 @@ it('renders outlined button with mode', async () => { expect(tree).toMatchSnapshot(); }); -it('renders contained contained with mode', async () => { - const tree = ( - await render() - ).toJSON(); - - expect(tree).toMatchSnapshot(); -}); - it('renders button with icon', async () => { const tree = ( await render() @@ -56,16 +90,27 @@ it('renders button with icon', async () => { expect(tree).toMatchSnapshot(); }); -it('renders button with icon in reverse order', async () => { - const tree = ( - await render( - - ) - ).toJSON(); +it('swaps the icon to the trailing edge under RTL', async () => { + await render( + + ); + expect(contentOf('Icon')).toHaveStyle({ + flexDirection: 'row', + }); - expect(tree).toMatchSnapshot(); + await render( + + + + ); + // The content direction flips, so a "leading" icon sits on the right in RTL. + expect(contentOf('Icon')).toHaveStyle({ + flexDirection: 'row-reverse', + }); }); it('renders loading button', async () => { @@ -98,52 +143,28 @@ it('renders active button if only onLongPress handler is passed', async () => { expect(screen.getByTestId('active-button')).toBeEnabled(); }); -it('renders button with color', async () => { - const tree = ( - await render() - ).toJSON(); +it('applies a custom label color', async () => { + await render(); - expect(tree).toMatchSnapshot(); + expect(screen.getByText('Custom Button')).toHaveStyle({ color: pink500 }); }); -it('renders button with button color', async () => { - const tree = ( - await render() - ).toJSON(); - - expect(tree).toMatchSnapshot(); -}); - -it('renders button with custom testID', async () => { - const tree = ( - await render( - - ) - ).toJSON(); +it('applies a custom container color', async () => { + await render(); - expect(tree).toMatchSnapshot(); + expect(screen.root).toHaveStyle({ backgroundColor: pink500 }); }); -it('renders button with an accessibility label', async () => { - const tree = ( - await render( - - ) - ).toJSON(); +it('exposes the accessibility label', async () => { + await render(); - expect(tree).toMatchSnapshot(); + expect(screen.getByRole('button', { name: 'label' })).toBeOnTheScreen(); }); -it('renders button with an accessibility hint', async () => { - const tree = ( - await render( - - ) - ).toJSON(); +it('exposes the accessibility hint', async () => { + await render(); - expect(tree).toMatchSnapshot(); + expect(screen.getByRole('button')).toHaveProp('accessibilityHint', 'hint'); }); it('should execute onPressIn', async () => { @@ -155,7 +176,7 @@ it('should execute onPressIn', async () => { {null} ); - await userEvent.press(screen.getByTestId('button')); + await fireEvent(screen.getByTestId('button'), 'onPressIn'); expect(onPressInMock).toHaveBeenCalledTimes(1); }); @@ -168,80 +189,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.getByText('Test')).toHaveStyle({ - textTransform: 'uppercase', + expect(contentOf('Press me')).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.getByText('Test')).not.toHaveStyle({ - textTransform: 'uppercase', + expect(contentOf('Next')).toHaveStyle({ + flexDirection: 'row-reverse', }); }); }); -describe('button icon styles', () => { - it('should return correct icon styles for compact text button', async () => { - const { toJSON } = await render( - - ); - expect(toJSON()).toMatchSnapshot(); - }); - - (['outlined', 'contained', 'contained-tonal', 'elevated'] as const).forEach( - (mode) => - it(`should return correct icon styles for compact ${mode} button`, async () => { - const { toJSON } = await render( - - ); - expect(toJSON()).toMatchSnapshot(); - }) - ); - - it('should return correct icon styles for text button', async () => { - const { toJSON } = await render( - - ); - expect(toJSON()).toMatchSnapshot(); - }); - - (['outlined', 'contained', 'contained-tonal', 'elevated'] as const).forEach( - (mode) => - it(`should return correct icon styles for compact ${mode} button`, async () => { - const { toJSON } = await render( - - ); - expect(toJSON()).toMatchSnapshot(); - }) - ); -}); - describe('getButtonColors - background color', () => { const customButtonColor = '#111111'; @@ -269,20 +251,7 @@ describe('getButtonColors - background color', () => { }) ); - (['outlined', 'text'] as const).forEach((mode) => - it(`should return correct disabled color, for theme version 3, dark theme, ${mode} mode`, () => { - expect( - getButtonColors({ - customButtonColor, - theme: LightTheme, - mode, - disabled: true, - }) - ).toMatchObject({ backgroundColor: 'transparent' }); - }) - ); - - (['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({ @@ -298,22 +267,6 @@ describe('getButtonColors - background color', () => { }) ); - (['contained', 'contained-tonal', 'elevated'] as const).forEach((mode) => - it(`should return correct disabled color, for theme version 3, dark theme, ${mode} mode`, () => { - return expect( - getButtonColors({ - customButtonColor, - theme: DarkTheme, - mode, - disabled: true, - }) - ).toMatchObject({ - backgroundColor: DarkTheme.colors.onSurface, - backgroundOpacity: stateOpacity.pressed, - }); - }) - ); - it('should return correct theme color, for theme version 3, elevated mode', () => { expect( getButtonColors({ @@ -325,61 +278,28 @@ describe('getButtonColors - background color', () => { }); }); - it('should return correct theme color, for theme version 3, dark theme, elevated mode', () => { - expect( - getButtonColors({ - theme: DarkTheme, - mode: 'elevated', - }) - ).toMatchObject({ - backgroundColor: DarkTheme.colors.surfaceContainerLow, - }); - }); - - 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: LightTheme, - mode: 'contained', + mode: 'filled', }) ).toMatchObject({ backgroundColor: LightTheme.colors.primary, }); }); - it('should return correct theme color, for theme version 3, dark theme, contained mode', () => { - expect( - getButtonColors({ - theme: DarkTheme, - mode: 'contained', - }) - ).toMatchObject({ - backgroundColor: DarkTheme.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: LightTheme, - mode: 'contained-tonal', + mode: 'tonal', }) ).toMatchObject({ backgroundColor: LightTheme.colors.secondaryContainer, }); }); - it('should return correct theme color, for theme version 3, dark theme, contained-tonal mode', () => { - expect( - getButtonColors({ - theme: DarkTheme, - mode: 'contained-tonal', - }) - ).toMatchObject({ - backgroundColor: DarkTheme.colors.secondaryContainer, - }); - }); - (['text', 'outlined'] as const).forEach((mode) => it(`should return transparent color, for theme version 3, ${mode} mode`, () => { return expect( @@ -392,64 +312,37 @@ describe('getButtonColors - background color', () => { }); }) ); - - (['text', 'outlined'] as const).forEach((mode) => - it(`should return transparent color, for theme version 3, dark theme, ${mode} mode`, () => { - return expect( - getButtonColors({ - theme: DarkTheme, - mode, - }) - ).toMatchObject({ - backgroundColor: 'transparent', - }); - }) - ); }); 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: LightTheme, 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: LightTheme, disabled: true, mode: 'text', }) ).toMatchObject({ - textColor: LightTheme.colors.onSurface, - textOpacity: stateOpacity.disabled, + labelColor: LightTheme.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, - theme: DarkTheme, - disabled: true, - mode: 'text', - }) - ).toMatchObject({ - textColor: DarkTheme.colors.onSurface, - textOpacity: 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({ @@ -458,12 +351,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({ @@ -471,65 +364,41 @@ describe('getButtonColors - text color', () => { mode, }) ).toMatchObject({ - textColor: LightTheme.colors.primary, - }); - }) - ); - - (['outlined', 'text', 'elevated'] as const).forEach((mode) => - it(`should return correct theme text color, for theme version 3, dark theme, ${mode} mode`, () => { - expect( - getButtonColors({ - theme: DarkTheme, - mode, - }) - ).toMatchObject({ - textColor: DarkTheme.colors.primary, + labelColor: LightTheme.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: LightTheme, - mode: 'contained', - }) - ).toMatchObject({ - textColor: LightTheme.colors.onPrimary, - }); - }); - - it('should return correct theme text color, for theme version 3, dark theme, contained mode', () => { - expect( - getButtonColors({ - theme: DarkTheme, - mode: 'contained', + mode: 'outlined', }) ).toMatchObject({ - textColor: DarkTheme.colors.onPrimary, + labelColor: LightTheme.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: LightTheme, - mode: 'contained-tonal', + mode: 'filled', }) ).toMatchObject({ - textColor: LightTheme.colors.onSecondaryContainer, + labelColor: LightTheme.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, tonal mode', () => { expect( getButtonColors({ - theme: DarkTheme, - mode: 'contained-tonal', + theme: LightTheme, + mode: 'tonal', }) ).toMatchObject({ - textColor: DarkTheme.colors.onSecondaryContainer, + labelColor: LightTheme.colors.onSecondaryContainer, }); }); }); @@ -547,92 +416,730 @@ describe('getButtonColors - border color', () => { }); }); - it('should return correct border color, for theme version 3, when disabled, dark theme, outlined mode', () => { + it('should return correct border color, for theme version 3, outlined mode', () => { expect( getButtonColors({ - theme: DarkTheme, - disabled: true, + theme: LightTheme, mode: 'outlined', }) ).toMatchObject({ - borderColor: DarkTheme.colors.outlineVariant, + borderColor: LightTheme.colors.outlineVariant, }); }); - it('should return correct border color, for theme version 3, outlined mode', () => { + (['text', 'filled', 'tonal', 'elevated'] as const).forEach((mode) => + it(`should return transparent border, for theme version 3, ${mode} mode`, () => { + expect( + getButtonColors({ + theme: LightTheme, + mode, + }) + ).toMatchObject({ + borderColor: 'transparent', + }); + }) + ); +}); + +describe('getButtonColors - border width', () => { + it('should return correct border width, for theme version 3, outlined mode', () => { expect( getButtonColors({ theme: LightTheme, mode: 'outlined', }) ).toMatchObject({ - borderColor: LightTheme.colors.outlineVariant, + borderWidth: 1, }); }); - it('should return correct border color, for theme version 3, dark theme, outlined mode', () => { + (['text', 'filled', 'tonal', 'elevated'] as const).forEach((mode) => + it(`should return correct border width, for ${mode} mode`, () => { + expect( + getButtonColors({ + theme: LightTheme, + mode, + }) + ).toMatchObject({ + borderWidth: 0, + }); + }) + ); +}); + +describe('getButtonRippleColor', () => { + it('returns the custom ripple color when one is provided', () => { expect( - getButtonColors({ - theme: DarkTheme, - mode: 'outlined', + 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, + outerBoundHeight: number, +][] = [ + ['extra-small', 32, 12, 12, 20, 4, 1, 'labelLarge', 48], + ['small', 40, 16, 16, 20, 8, 1, 'labelLarge', 48], + ['medium', 56, 24, 24, 24, 8, 1, 'titleMedium', 64], + ['large', 96, 48, 48, 32, 12, 2, 'headlineSmall', 104], + ['extra-large', 136, 64, 64, 40, 16, 3, 'headlineLarge', 144], +]; + +describe('getButtonSizeStyle', () => { + it.each(sizeMetrics)( + 'returns expected metrics for %s', + ( + size, + minHeight, + paddingStart, + paddingEnd, + iconSize, + iconGap, + outlineWidth, + labelVariant, + outerBoundHeight + ) => { + expect(getButtonSizeStyle(size)).toEqual({ + minHeight, + paddingStart, + paddingEnd, + iconSize, + iconGap, + outlineWidth, + labelVariant, + outerBoundHeight, + }); + } + ); +}); + +describe('getButtonHitSlop', () => { + // The slop closes the gap between the drawn container and the outer bound, + // halved because it applies to both edges. + it.each(sizeMetrics)('reaches the %s outer bound', (size) => { + const { containerHeight, outerBoundHeight } = Tokens.sizes[size]; + const slop = getButtonHitSlop(size); + + expect(containerHeight + (slop?.top ?? 0) + (slop?.bottom ?? 0)).toBe( + outerBoundHeight + ); + }); + + // The outer bound is a height; width comes off the 64dp minimum, which + // already clears the target. + it.each(sizeMetrics)('leaves the %s width alone', (size) => { + expect(getButtonHitSlop(size)).toMatchObject({ left: 0, right: 0 }); + }); +}); + +describe('size prop', () => { + ( + [ + ['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(labelOf('X')).toHaveStyle({ + fontSize: expectedFontSize, + }); + }) + ); +}); + +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 = LightTheme; + 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: LightTheme })).toBe(pressed); + } + ); +}); + +describe('getButtonTransitionDuration', () => { + const theme = LightTheme; + 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, }) - ).toMatchObject({ - borderColor: DarkTheme.colors.outlineVariant, - }); + ).toBe(scaled('short3')); }); - (['text', 'contained', 'contained-tonal', 'elevated'] as const).forEach( - (mode) => - it(`should return transparent border, for theme version 3, ${mode} mode`, () => { - expect( - getButtonColors({ - theme: LightTheme, - mode, - }) - ).toMatchObject({ - borderColor: 'transparent', - }); + it('uses the longer duration while pressed', () => { + expect( + getButtonTransitionDuration({ + theme, + pressed: true, + containerColor: opaque, + previousContainerColor: opaque, }) - ); + ).toBe(scaled('short4')); + }); - (['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: DarkTheme, - mode, - }) - ).toMatchObject({ - borderColor: 'transparent', - }); + 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('getButtonColors - border width', () => { - it('should return correct border width, for theme version 3, outlined mode', () => { +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')).toHaveStyle({ + borderRadius: 20, + }); + }); + + it('applies the square radius (default size)', async () => { + await render( + + ); + expect(screen.getByTestId('button')).toHaveStyle({ + borderRadius: 12, + }); + }); +}); + +describe('selected prop', () => { + it('marks the button as selected for screen readers', async () => { + await render( + + ); + + expect(screen.getByTestId('button')).toBeSelected(); + }); + + it('announces the toggle state with aria-pressed on web', async () => { + jest.replaceProperty(Platform, 'OS', 'web'); + + await render( + + ); + + expect(screen.getByTestId('button')).toHaveProp('aria-pressed', true); + expect(screen.getByTestId('button')).not.toHaveProp('aria-selected'); + }); + + it('flips a round button into the square radius when selected', async () => { + await render( + + ); + + expect(screen.getByTestId('button')).toHaveStyle({ + borderRadius: 28, + }); + }); + + it('flips a square button into the round radius when selected', async () => { + await render( + + ); + + expect(screen.getByTestId('button')).toHaveStyle({ + borderRadius: 20, + }); + }); + + it('drops the outline when an outlined toggle is selected', () => { expect( getButtonColors({ theme: LightTheme, mode: 'outlined', + selected: true, }) ).toMatchObject({ - borderWidth: 1, + borderColor: 'transparent', + borderWidth: 0, }); }); - (['text', 'contained', 'contained-tonal', 'elevated'] as const).forEach( - (mode) => - it(`should return correct border width, for ${mode} mode`, () => { - expect( - getButtonColors({ - theme: LightTheme, - mode, - }) - ).toMatchObject({ - borderWidth: 0, - }); - }) + // MD3 gives the text style no toggle, so `selected` is inert there. + it('ignores `selected` entirely on a text button', async () => { + const plain = getButtonColors({ theme: LightTheme, mode: 'text' }); + + expect( + getButtonColors({ theme: LightTheme, mode: 'text', selected: false }) + ).toMatchObject(plain); + expect( + getButtonColors({ theme: LightTheme, mode: 'text', selected: true }) + ).toMatchObject(plain); + + await render( + + ); + // No shape flip: a round text button stays the pill, 40 / 2 = 20. + expect(screen.getByTestId('button')).toHaveStyle({ + borderRadius: 20, + }); + // And no toggle state is announced. + expect(screen.getByTestId('button')).not.toBeSelected(); + }); + + it('leaves a plain button untouched when `selected` is omitted', () => { + expect( + getButtonColors({ theme: LightTheme, mode: 'filled' }) + ).toMatchObject({ + backgroundColor: LightTheme.colors.primary, + labelColor: LightTheme.colors.onPrimary, + }); + }); +}); + +describe('toggle colors', () => { + // From the MD3 {Filled,Elevated,Tonal,Outlined}ButtonTokens Unselected*/ + // Selected* sets. + type Role = keyof (typeof LightTheme)['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 = LightTheme; + + 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 = LightTheme; + 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 = LightTheme; + + 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, LightTheme.colors.shadow); + + await render( + + ); + expect(screen.root).toHaveStyle(spotShadow); + + await render( + + ); + expect(screen.root).toHaveStyle({ + shadowOpacity: 0, + }); +}); + +it('drops the shadow when an elevated button is disabled', async () => { + await render( + ); + + expect(screen.root).toHaveStyle({ + shadowOpacity: 0, + }); +}); + +it('forwards `style` to the shadow host', async () => { + await render( + + ); + + expect(screen.root).toHaveStyle(styles.scaled); +}); + +describe('container height', () => { + const MODES = ['filled', 'tonal', 'elevated', 'outlined', 'text'] as const; + + // The content box plus the outline drawn around it must add up to the token + // metrics, or the outline grows the container. + const boxOf = (size: ButtonSize, borderWidth: number) => { + const content = getButtonContentBoxStyle( + getButtonSizeStyle(size), + borderWidth + ); + return { + height: content.minHeight + borderWidth * 2, + leading: content.paddingStart + borderWidth, + }; + }; + + it.each(MODES)('keeps %s on the token height', (mode) => { + const { borderWidth } = getButtonColors({ theme: LightTheme, mode }); + + expect(boxOf('small', borderWidth)).toEqual({ + height: Tokens.sizes.small.containerHeight, + leading: Tokens.sizes.small.leadingSpace, + }); + }); + + it.each(sizeMetrics)( + 'insets the %s outline so the box stays on the token metrics', + (size) => { + const { borderWidth } = getButtonColors({ + theme: LightTheme, + mode: 'outlined', + size, + }); + + expect(boxOf(size, borderWidth)).toEqual({ + height: Tokens.sizes[size].containerHeight, + leading: Tokens.sizes[size].leadingSpace, + }); + } + ); + + it('keeps an outlined toggle the same size in both states', () => { + const widthWhen = (selected: boolean) => + getButtonColors({ theme: LightTheme, mode: 'outlined', selected }) + .borderWidth; + + // Selecting drops the outline; without the inset this shrank by 2dp. + expect(boxOf('small', widthWhen(true))).toEqual( + boxOf('small', widthWhen(false)) + ); + }); +}); + +describe('touch target', () => { + it('leaves the outer view unclipped for the expanded target', async () => { + await render( + + ); + + // The slop expands past the container, so a clipping ancestor would make + // it inert. + expect(screen.root).not.toHaveStyle({ overflow: 'hidden' }); + }); + + it('still clips the ripple to the container radius', async () => { + await render( + + ); + + expect(screen.getByTestId('button')).toHaveStyle({ + overflow: 'hidden', + borderRadius: Tokens.sizes.small.containerHeight / 2, + }); + }); + + // Inside the outline, the ripple has to round a border width tighter, or + // the background shows through at the corners. + it.each(sizeMetrics)( + 'insets the %s ripple radius by the outline width', + async (size) => { + await render( + + ); + + expect(screen.getByTestId('button')).toHaveStyle({ + borderRadius: + getButtonShapeRadius({ size, shape: 'square', theme: LightTheme }) - + Tokens.sizes[size].outlinedOutlineWidth, + }); + } + ); + + it('leaves the ripple radius alone when there is no outline', async () => { + await render( + + ); + + expect(screen.getByTestId('button')).toHaveStyle({ + borderRadius: getButtonShapeRadius({ + size: 'small', + shape: 'square', + theme: LightTheme, + }), + }); + }); + + it('expands the target to the outer bound', async () => { + await render( + + ); + + expect(screen.getByTestId('button')).toHaveProp( + 'hitSlop', + getButtonHitSlop('medium') + ); + }); + + it('lets a caller hitSlop win', async () => { + await render( + + ); + + expect(screen.getByTestId('button')).toHaveProp('hitSlop', 12); + }); + + it('takes `null` as an opt out', async () => { + await render( + + ); + + expect(screen.getByTestId('button')).toHaveProp('hitSlop', null); + }); +}); + +describe('shape morph animation', () => { + // `filled` has no outline, so the touchable's radius is the container's. + it('moves the corner to corner.small while pressed', async () => { + await render( + + ); + await fireEvent(screen.getByTestId('button'), 'onPressIn'); + expect(screen.getByTestId('button')).toHaveStyle({ + borderRadius: LightTheme.shapes.corner.small, + }); + }); + + it('returns to the resting pill radius on press out', async () => { + await render( + + ); + await fireEvent(screen.getByTestId('button'), 'onPressIn'); + await fireEvent(screen.getByTestId('button'), 'onPressOut'); + // small round resting radius = minHeight (40) / 2 = 20 + expect(screen.getByTestId('button')).toHaveStyle({ borderRadius: 20 }); + }); + + it('reads the pressed corner from the size tokens', async () => { + await render( + + ); + await fireEvent(screen.getByTestId('button'), 'onPressIn'); + // A large button presses to `large` (16dp), not the small sizes' 8dp. + expect(screen.getByTestId('button')).toHaveStyle({ + borderRadius: LightTheme.shapes.corner.large, + }); + }); + + it('skips the press morph under reduce motion', async () => { + await render( + + + + ); + await fireEvent(screen.getByTestId('button'), 'onPressIn'); + expect(screen.getByTestId('button')).toHaveStyle({ borderRadius: 20 }); + }); + + it('does not morph when animateShape is false', async () => { + await render( + + ); + await fireEvent(screen.getByTestId('button'), 'onPressIn'); + expect(screen.getByTestId('button')).toHaveStyle({ borderRadius: 20 }); + }); + + it('still changes the shape when animateShape is false and selected flips', async () => { + await render( + + ); + await screen.rerender( + + ); + + expect(screen.getByTestId('button')).toHaveStyle({ borderRadius: 48 }); + }); }); diff --git a/src/components/__tests__/Card/Card.test.tsx b/src/components/__tests__/Card/Card.test.tsx index 2fff70a432..2bfb8584a2 100644 --- a/src/components/__tests__/Card/Card.test.tsx +++ b/src/components/__tests__/Card/Card.test.tsx @@ -148,13 +148,13 @@ describe('CardActions', () => { await render( - Agree + Agree ); expect(buttonProps).toHaveBeenCalledWith( - expect.objectContaining({ mode: 'contained' }) + expect.objectContaining({ mode: 'filled' }) ); }); diff --git a/src/components/__tests__/__snapshots__/Banner.test.tsx.snap b/src/components/__tests__/__snapshots__/Banner.test.tsx.snap index 71e77e2936..43ddd8c54a 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, }, @@ -209,7 +201,7 @@ exports[`render visible banner, with custom theme 1`] = ` "borderCurve": "continuous", "borderEndEndRadius": undefined, "borderEndStartRadius": undefined, - "borderRadius": 20, + "borderRadius": undefined, "borderStartEndRadius": undefined, "borderStartStartRadius": undefined, "borderTopEndRadius": undefined, @@ -217,6 +209,9 @@ exports[`render visible banner, with custom theme 1`] = ` "borderTopRightRadius": undefined, "borderTopStartRadius": undefined, }, + { + "borderRadius": 20, + }, { "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { @@ -256,7 +251,7 @@ exports[`render visible banner, with custom theme 1`] = ` "borderCurve": "continuous", "borderEndEndRadius": undefined, "borderEndStartRadius": undefined, - "borderRadius": 20, + "borderRadius": undefined, "borderStartEndRadius": undefined, "borderStartStartRadius": undefined, "borderTopEndRadius": undefined, @@ -264,6 +259,9 @@ exports[`render visible banner, with custom theme 1`] = ` "borderTopRightRadius": undefined, "borderTopStartRadius": undefined, }, + { + "borderRadius": 20, + }, { "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { @@ -277,40 +275,16 @@ exports[`render visible banner, with custom theme 1`] = ` } /> - + - first - + ] + } + > + first + + @@ -793,16 +818,8 @@ exports[`renders visible banner, with action buttons and with image 1`] = ` [ {}, { - "borderStyle": "solid", "minWidth": 64, }, - { - "minWidth": "auto", - }, - { - "borderColor": "transparent", - "borderWidth": 0, - }, { "margin": 4, }, @@ -818,7 +835,7 @@ exports[`renders visible banner, with action buttons and with image 1`] = ` "borderCurve": "continuous", "borderEndEndRadius": undefined, "borderEndStartRadius": undefined, - "borderRadius": 20, + "borderRadius": undefined, "borderStartEndRadius": undefined, "borderStartStartRadius": undefined, "borderTopEndRadius": undefined, @@ -826,6 +843,9 @@ exports[`renders visible banner, with action buttons and with image 1`] = ` "borderTopRightRadius": undefined, "borderTopStartRadius": undefined, }, + { + "borderRadius": 20, + }, { "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { @@ -865,7 +885,7 @@ exports[`renders visible banner, with action buttons and with image 1`] = ` "borderCurve": "continuous", "borderEndEndRadius": undefined, "borderEndStartRadius": undefined, - "borderRadius": 20, + "borderRadius": undefined, "borderStartEndRadius": undefined, "borderStartStartRadius": undefined, "borderTopEndRadius": undefined, @@ -873,6 +893,9 @@ exports[`renders visible banner, with action buttons and with image 1`] = ` "borderTopRightRadius": undefined, "borderTopStartRadius": undefined, }, + { + "borderRadius": 20, + }, { "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { @@ -886,40 +909,16 @@ exports[`renders visible banner, with action buttons and with image 1`] = ` } /> - + - first - + ] + } + > + first + + @@ -1184,16 +1234,8 @@ exports[`renders visible banner, with action buttons and without image 1`] = ` [ {}, { - "borderStyle": "solid", "minWidth": 64, }, - { - "minWidth": "auto", - }, - { - "borderColor": "transparent", - "borderWidth": 0, - }, { "margin": 4, }, @@ -1209,7 +1251,7 @@ exports[`renders visible banner, with action buttons and without image 1`] = ` "borderCurve": "continuous", "borderEndEndRadius": undefined, "borderEndStartRadius": undefined, - "borderRadius": 20, + "borderRadius": undefined, "borderStartEndRadius": undefined, "borderStartStartRadius": undefined, "borderTopEndRadius": undefined, @@ -1217,6 +1259,9 @@ exports[`renders visible banner, with action buttons and without image 1`] = ` "borderTopRightRadius": undefined, "borderTopStartRadius": undefined, }, + { + "borderRadius": 20, + }, { "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { @@ -1256,7 +1301,7 @@ exports[`renders visible banner, with action buttons and without image 1`] = ` "borderCurve": "continuous", "borderEndEndRadius": undefined, "borderEndStartRadius": undefined, - "borderRadius": 20, + "borderRadius": undefined, "borderStartEndRadius": undefined, "borderStartStartRadius": undefined, "borderTopEndRadius": undefined, @@ -1264,6 +1309,9 @@ exports[`renders visible banner, with action buttons and without image 1`] = ` "borderTopRightRadius": undefined, "borderTopStartRadius": undefined, }, + { + "borderRadius": 20, + }, { "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { @@ -1277,40 +1325,16 @@ exports[`renders visible banner, with action buttons and without image 1`] = ` } /> - + - first - + ] + } + > + first + + @@ -1391,16 +1466,8 @@ exports[`renders visible banner, with action buttons and without image 1`] = ` [ {}, { - "borderStyle": "solid", "minWidth": 64, }, - { - "minWidth": "auto", - }, - { - "borderColor": "transparent", - "borderWidth": 0, - }, { "margin": 4, }, @@ -1416,7 +1483,7 @@ exports[`renders visible banner, with action buttons and without image 1`] = ` "borderCurve": "continuous", "borderEndEndRadius": undefined, "borderEndStartRadius": undefined, - "borderRadius": 20, + "borderRadius": undefined, "borderStartEndRadius": undefined, "borderStartStartRadius": undefined, "borderTopEndRadius": undefined, @@ -1424,6 +1491,9 @@ exports[`renders visible banner, with action buttons and without image 1`] = ` "borderTopRightRadius": undefined, "borderTopStartRadius": undefined, }, + { + "borderRadius": 20, + }, { "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { @@ -1463,7 +1533,7 @@ exports[`renders visible banner, with action buttons and without image 1`] = ` "borderCurve": "continuous", "borderEndEndRadius": undefined, "borderEndStartRadius": undefined, - "borderRadius": 20, + "borderRadius": undefined, "borderStartEndRadius": undefined, "borderStartStartRadius": undefined, "borderTopEndRadius": undefined, @@ -1471,6 +1541,9 @@ exports[`renders visible banner, with action buttons and without image 1`] = ` "borderTopRightRadius": undefined, "borderTopStartRadius": undefined, }, + { + "borderRadius": 20, + }, { "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { @@ -1484,40 +1557,16 @@ exports[`renders visible banner, with action buttons and without image 1`] = ` } /> - + - second - + ] + } + > + second + + diff --git a/src/components/__tests__/__snapshots__/Button.test.tsx.snap b/src/components/__tests__/__snapshots__/Button.test.tsx.snap index 628087c74b..8f860d6168 100644 --- a/src/components/__tests__/__snapshots__/Button.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Button.test.tsx.snap @@ -1,22 +1,14 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`button icon styles should return correct icon styles for compact contained button 1`] = ` +exports[`renders button with icon 1`] = ` @@ -159,123 +169,114 @@ exports[`button icon styles should return correct icon styles for compact contai style={ [ { - "marginLeft": 12, - "marginRight": -4, + "alignItems": "center", + "flexDirection": "row", + "justifyContent": "center", + }, + false, + { + "gap": 8, + "minHeight": 40, + "paddingEnd": 16, + "paddingStart": 16, }, { - "marginLeft": 8, - "marginRight": 0, + "opacity": 1, }, - false, + undefined, ] } > + + + camera + + - camera - - - - Compact - contained - button - + ] + } + > + Icon Button + + `; -exports[`button icon styles should return correct icon styles for compact contained button 2`] = ` +exports[`renders disabled button 1`] = ` + @@ -414,122 +455,83 @@ exports[`button icon styles should return correct icon styles for compact contai style={ [ { - "marginLeft": 12, - "marginRight": -4, + "alignItems": "center", + "flexDirection": "row", + "justifyContent": "center", }, + false, { - "marginLeft": 16, - "marginRight": -16, + "gap": 8, + "minHeight": 40, + "paddingEnd": 16, + "paddingStart": 16, }, - false, + { + "opacity": 0.38, + }, + undefined, ] } > - camera - - - - contained - button - + ] + } + > + Disabled Button + + `; -exports[`button icon styles should return correct icon styles for compact contained-tonal button 1`] = ` +exports[`renders filled button by default 1`] = ` @@ -668,123 +688,83 @@ exports[`button icon styles should return correct icon styles for compact contai style={ [ { - "marginLeft": 12, - "marginRight": -4, + "alignItems": "center", + "flexDirection": "row", + "justifyContent": "center", }, + false, { - "marginLeft": 8, - "marginRight": 0, + "gap": 8, + "minHeight": 40, + "paddingEnd": 16, + "paddingStart": 16, }, - false, + { + "opacity": 1, + }, + undefined, ] } > - camera - - - - Compact - contained-tonal - button - + ] + } + > + Filled Button + + `; -exports[`button icon styles should return correct icon styles for compact contained-tonal button 2`] = ` +exports[`renders loading button 1`] = ` - - - camera - - - - contained-tonal - button - - - - -`; - -exports[`button icon styles should return correct icon styles for compact elevated button 1`] = ` - - - - @@ -1177,3942 +921,271 @@ exports[`button icon styles should return correct icon styles for compact elevat style={ [ { - "marginLeft": 12, - "marginRight": -4, - }, - { - "marginLeft": 8, - "marginRight": 0, - }, - false, - ] - } - > - - camera - - - - Compact - elevated - button - - - - -`; - -exports[`button icon styles should return correct icon styles for compact elevated button 2`] = ` - - - - - - - camera - - - - elevated - button - - - - -`; - -exports[`button icon styles should return correct icon styles for compact outlined button 1`] = ` - - - - - - - camera - - - - Compact - outlined - button - - - - -`; - -exports[`button icon styles should return correct icon styles for compact outlined button 2`] = ` - - - - - - - camera - - - - outlined - button - - - - -`; - -exports[`button icon styles should return correct icon styles for compact text button 1`] = ` - - - - - - - camera - - - - Compact text button - - - - -`; - -exports[`button icon styles should return correct icon styles for text button 1`] = ` - - - - - - - camera - - - - text button - - - - -`; - -exports[`renders button with an accessibility hint 1`] = ` - - - - - - Button with accessibility hint - - - - -`; - -exports[`renders button with an accessibility label 1`] = ` - - - - - - Button with accessibility label - - - - -`; - -exports[`renders button with button color 1`] = ` - - - - - - Custom Button - - - - -`; - -exports[`renders button with color 1`] = ` - - - - - - Custom Button - - - - -`; - -exports[`renders button with custom testID 1`] = ` - - - - - - Button with custom testID - - - - -`; - -exports[`renders button with icon 1`] = ` - - - - - - - camera - - - - Icon Button - - - - -`; - -exports[`renders button with icon in reverse order 1`] = ` - - - - - - - chevron-right - - - - Right Icon - - - - -`; - -exports[`renders contained contained with mode 1`] = ` - - - - - - Contained Button - - - - -`; - -exports[`renders disabled button 1`] = ` - - - - - - Disabled Button - - - - -`; - -exports[`renders loading button 1`] = ` - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Loading Button - - - - -`; - -exports[`renders outlined button with mode 1`] = ` - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + - Outlined Button - + ] + } + > + Loading Button + + `; -exports[`renders text button by default 1`] = ` +exports[`renders outlined button with mode 1`] = ` - + - Text Button - + ] + } + > + Outlined Button + + @@ -5308,15 +1416,9 @@ exports[`renders text button with mode 1`] = ` [ {}, { - "borderStyle": "solid", "minWidth": 64, }, undefined, - { - "borderColor": "transparent", - "borderWidth": 0, - }, - undefined, {}, { "backgroundColor": "transparent", @@ -5329,7 +1431,7 @@ exports[`renders text button with mode 1`] = ` "borderCurve": "continuous", "borderEndEndRadius": undefined, "borderEndStartRadius": undefined, - "borderRadius": 20, + "borderRadius": undefined, "borderStartEndRadius": undefined, "borderStartStartRadius": undefined, "borderTopEndRadius": undefined, @@ -5337,6 +1439,9 @@ exports[`renders text button with mode 1`] = ` "borderTopRightRadius": undefined, "borderTopStartRadius": undefined, }, + { + "borderRadius": 20, + }, { "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { @@ -5376,7 +1481,7 @@ exports[`renders text button with mode 1`] = ` "borderCurve": "continuous", "borderEndEndRadius": undefined, "borderEndStartRadius": undefined, - "borderRadius": 20, + "borderRadius": undefined, "borderStartEndRadius": undefined, "borderStartStartRadius": undefined, "borderTopEndRadius": undefined, @@ -5384,6 +1489,9 @@ exports[`renders text button with mode 1`] = ` "borderTopRightRadius": undefined, "borderTopStartRadius": undefined, }, + { + "borderRadius": 20, + }, { "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { @@ -5397,40 +1505,16 @@ exports[`renders text button with mode 1`] = ` } /> - + - Text Button - + ] + } + > + Text Button + + diff --git a/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap b/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap index 9084841e9b..1fb036169a 100644 --- a/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap @@ -1811,14 +1811,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", @@ -1835,7 +1829,7 @@ exports[`DataTable.Pagination renders data table pagination with options select "borderCurve": "continuous", "borderEndEndRadius": undefined, "borderEndStartRadius": undefined, - "borderRadius": 20, + "borderRadius": undefined, "borderStartEndRadius": undefined, "borderStartStartRadius": undefined, "borderTopEndRadius": undefined, @@ -1843,6 +1837,9 @@ exports[`DataTable.Pagination renders data table pagination with options select "borderTopRightRadius": undefined, "borderTopStartRadius": undefined, }, + { + "borderRadius": 20, + }, { "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { @@ -1882,7 +1879,7 @@ exports[`DataTable.Pagination renders data table pagination with options select "borderCurve": "continuous", "borderEndEndRadius": undefined, "borderEndStartRadius": undefined, - "borderRadius": 20, + "borderRadius": undefined, "borderStartEndRadius": undefined, "borderStartStartRadius": undefined, "borderTopEndRadius": undefined, @@ -1890,6 +1887,9 @@ exports[`DataTable.Pagination renders data table pagination with options select "borderTopRightRadius": undefined, "borderTopStartRadius": undefined, }, + { + "borderRadius": 20, + }, { "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { @@ -1903,61 +1903,72 @@ exports[`DataTable.Pagination renders data table pagination with options select } /> @@ -1965,95 +1976,98 @@ exports[`DataTable.Pagination renders data table pagination with options select style={ [ { - "marginLeft": -4, - "marginRight": 12, + "alignItems": "center", + "flexDirection": "row", + "justifyContent": "center", + }, + { + "flexDirection": "row-reverse", + }, + { + "gap": 8, + "minHeight": 38, + "paddingEnd": 15, + "paddingStart": 15, }, { - "marginLeft": -16, - "marginRight": 16, + "opacity": 1, }, - false, + undefined, ] } > + + + menu-down + + - menu-down - - - - 2 - + ] + } + > + 2 + + diff --git a/src/components/__tests__/__snapshots__/Menu.test.tsx.snap b/src/components/__tests__/__snapshots__/Menu.test.tsx.snap index 3e059001aa..b6ac626ac9 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", @@ -40,7 +34,7 @@ exports[`renders not visible menu 1`] = ` "borderCurve": "continuous", "borderEndEndRadius": undefined, "borderEndStartRadius": undefined, - "borderRadius": 20, + "borderRadius": undefined, "borderStartEndRadius": undefined, "borderStartStartRadius": undefined, "borderTopEndRadius": undefined, @@ -48,6 +42,9 @@ exports[`renders not visible menu 1`] = ` "borderTopRightRadius": undefined, "borderTopStartRadius": undefined, }, + { + "borderRadius": 20, + }, { "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { @@ -87,7 +84,7 @@ exports[`renders not visible menu 1`] = ` "borderCurve": "continuous", "borderEndEndRadius": undefined, "borderEndStartRadius": undefined, - "borderRadius": 20, + "borderRadius": undefined, "borderStartEndRadius": undefined, "borderStartStartRadius": undefined, "borderTopEndRadius": undefined, @@ -95,6 +92,9 @@ exports[`renders not visible menu 1`] = ` "borderTopRightRadius": undefined, "borderTopStartRadius": undefined, }, + { + "borderRadius": 20, + }, { "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { @@ -108,110 +108,138 @@ exports[`renders not visible menu 1`] = ` } /> - + - Open menu - + ] + } + > + Open menu + + @@ -239,15 +267,9 @@ exports[`renders visible menu 1`] = ` [ {}, { - "borderStyle": "solid", "minWidth": 64, }, undefined, - { - "borderColor": "rgba(202, 196, 208, 1)", - "borderWidth": 1, - }, - undefined, {}, { "backgroundColor": "transparent", @@ -260,7 +282,7 @@ exports[`renders visible menu 1`] = ` "borderCurve": "continuous", "borderEndEndRadius": undefined, "borderEndStartRadius": undefined, - "borderRadius": 20, + "borderRadius": undefined, "borderStartEndRadius": undefined, "borderStartStartRadius": undefined, "borderTopEndRadius": undefined, @@ -268,6 +290,9 @@ exports[`renders visible menu 1`] = ` "borderTopRightRadius": undefined, "borderTopStartRadius": undefined, }, + { + "borderRadius": 20, + }, { "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { @@ -307,7 +332,7 @@ exports[`renders visible menu 1`] = ` "borderCurve": "continuous", "borderEndEndRadius": undefined, "borderEndStartRadius": undefined, - "borderRadius": 20, + "borderRadius": undefined, "borderStartEndRadius": undefined, "borderStartStartRadius": undefined, "borderTopEndRadius": undefined, @@ -315,6 +340,9 @@ exports[`renders visible menu 1`] = ` "borderTopRightRadius": undefined, "borderTopStartRadius": undefined, }, + { + "borderRadius": 20, + }, { "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { @@ -328,110 +356,138 @@ exports[`renders visible menu 1`] = ` } /> - + - Open menu - + ] + } + > + Open menu + + @@ -916,15 +972,9 @@ exports[`respects anchorPosition bottom 1`] = ` [ {}, { - "borderStyle": "solid", "minWidth": 64, }, undefined, - { - "borderColor": "rgba(202, 196, 208, 1)", - "borderWidth": 1, - }, - undefined, {}, { "backgroundColor": "transparent", @@ -937,7 +987,7 @@ exports[`respects anchorPosition bottom 1`] = ` "borderCurve": "continuous", "borderEndEndRadius": undefined, "borderEndStartRadius": undefined, - "borderRadius": 20, + "borderRadius": undefined, "borderStartEndRadius": undefined, "borderStartStartRadius": undefined, "borderTopEndRadius": undefined, @@ -945,6 +995,9 @@ exports[`respects anchorPosition bottom 1`] = ` "borderTopRightRadius": undefined, "borderTopStartRadius": undefined, }, + { + "borderRadius": 20, + }, { "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { @@ -984,7 +1037,7 @@ exports[`respects anchorPosition bottom 1`] = ` "borderCurve": "continuous", "borderEndEndRadius": undefined, "borderEndStartRadius": undefined, - "borderRadius": 20, + "borderRadius": undefined, "borderStartEndRadius": undefined, "borderStartStartRadius": undefined, "borderTopEndRadius": undefined, @@ -992,6 +1045,9 @@ exports[`respects anchorPosition bottom 1`] = ` "borderTopRightRadius": undefined, "borderTopStartRadius": undefined, }, + { + "borderRadius": 20, + }, { "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { @@ -1005,111 +1061,139 @@ exports[`respects anchorPosition bottom 1`] = ` } /> - + - Open menu - + ] + } + > + Open menu + + @@ -1595,15 +1679,9 @@ exports[`uses the default anchorPosition of top 1`] = ` [ {}, { - "borderStyle": "solid", "minWidth": 64, }, undefined, - { - "borderColor": "rgba(202, 196, 208, 1)", - "borderWidth": 1, - }, - undefined, {}, { "backgroundColor": "transparent", @@ -1616,7 +1694,7 @@ exports[`uses the default anchorPosition of top 1`] = ` "borderCurve": "continuous", "borderEndEndRadius": undefined, "borderEndStartRadius": undefined, - "borderRadius": 20, + "borderRadius": undefined, "borderStartEndRadius": undefined, "borderStartStartRadius": undefined, "borderTopEndRadius": undefined, @@ -1624,6 +1702,9 @@ exports[`uses the default anchorPosition of top 1`] = ` "borderTopRightRadius": undefined, "borderTopStartRadius": undefined, }, + { + "borderRadius": 20, + }, { "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { @@ -1663,7 +1744,7 @@ exports[`uses the default anchorPosition of top 1`] = ` "borderCurve": "continuous", "borderEndEndRadius": undefined, "borderEndStartRadius": undefined, - "borderRadius": 20, + "borderRadius": undefined, "borderStartEndRadius": undefined, "borderStartStartRadius": undefined, "borderTopEndRadius": undefined, @@ -1671,6 +1752,9 @@ exports[`uses the default anchorPosition of top 1`] = ` "borderTopRightRadius": undefined, "borderTopStartRadius": undefined, }, + { + "borderRadius": 20, + }, { "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { @@ -1684,111 +1768,139 @@ exports[`uses the default anchorPosition of top 1`] = ` } /> - + - Open menu - + ] + } + > + Open menu + + diff --git a/src/components/__tests__/__snapshots__/Snackbar.test.tsx.snap b/src/components/__tests__/__snapshots__/Snackbar.test.tsx.snap index 353efa0fed..af749470ff 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, @@ -509,7 +503,7 @@ exports[`renders snackbar with action button 1`] = ` "borderCurve": "continuous", "borderEndEndRadius": undefined, "borderEndStartRadius": undefined, - "borderRadius": 20, + "borderRadius": undefined, "borderStartEndRadius": undefined, "borderStartStartRadius": undefined, "borderTopEndRadius": undefined, @@ -517,6 +511,9 @@ exports[`renders snackbar with action button 1`] = ` "borderTopRightRadius": undefined, "borderTopStartRadius": undefined, }, + { + "borderRadius": 20, + }, { "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { @@ -556,7 +553,7 @@ exports[`renders snackbar with action button 1`] = ` "borderCurve": "continuous", "borderEndEndRadius": undefined, "borderEndStartRadius": undefined, - "borderRadius": 20, + "borderRadius": undefined, "borderStartEndRadius": undefined, "borderStartStartRadius": undefined, "borderTopEndRadius": undefined, @@ -564,6 +561,9 @@ exports[`renders snackbar with action button 1`] = ` "borderTopRightRadius": undefined, "borderTopStartRadius": undefined, }, + { + "borderRadius": 20, + }, { "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { @@ -577,40 +577,16 @@ exports[`renders snackbar with action button 1`] = ` } /> - + - Undo - + ] + } + > + Undo + + diff --git a/src/theme/__tests__/motion.test.ts b/src/theme/__tests__/motion.test.ts new file mode 100644 index 0000000000..44c45f0c0c --- /dev/null +++ b/src/theme/__tests__/motion.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from '@jest/globals'; + +import { + expressiveMotion, + standardMotion, + toSpringCurve, +} from '../tokens/sys/motion'; + +describe('toSpringCurve', () => { + const { spring } = expressiveMotion; + + it('starts at 0 and ends at 1', () => { + const { points } = toSpringCurve(spring.fast.spatial); + + expect(points[0]).toBe(0); + expect(points[points.length - 1]).toBe(1); + }); + + // A damping ratio of 0.6 overshoots by e^(-π · 0.6 / √(1 - 0.6²)) ≈ 9.5%. + it('overshoots an underdamped spring by the expected amount', () => { + const { points } = toSpringCurve(spring.fast.spatial); + + expect(Math.max(...points)).toBeCloseTo(1.095, 1); + }); + + it('never overshoots a critically damped spring', () => { + const { points } = toSpringCurve(spring.fast.effects); + + expect(Math.max(...points)).toBeLessThanOrEqual(1); + }); + + it('settles a stiffer spring sooner', () => { + const fast = toSpringCurve(spring.fast.spatial).duration; + const slow = toSpringCurve(spring.slow.spatial).duration; + + expect(fast).toBeLessThan(slow); + }); + + it('settles the expressive fast spatial spring in about 400 ms', () => { + expect(toSpringCurve(spring.fast.spatial).duration).toBeCloseTo(407, -1); + }); + + it('settles the standard fast spatial spring sooner', () => { + expect( + toSpringCurve(standardMotion.spring.fast.spatial).duration + ).toBeLessThan(toSpringCurve(spring.fast.spatial).duration); + }); +}); diff --git a/src/theme/tokens/sys/motion.ts b/src/theme/tokens/sys/motion.ts index 878d63e0e9..f9e6dee79c 100644 --- a/src/theme/tokens/sys/motion.ts +++ b/src/theme/tokens/sys/motion.ts @@ -111,3 +111,54 @@ export function toRawSpring({ mass: 1, // as per MD specs }; } + +const SPRING_SETTLE_THRESHOLD = 0.001; +const SPRING_SAMPLE_COUNT = 30; + +/** + * Samples a `SpringConfig` into evenly spaced progress points from 0 to 1, + * with the time the spring takes to settle in milliseconds. + * + * @example + * const { points, duration } = toSpringCurve(theme.motion.spring.fast.spatial); + * + * const style = { + * transitionProperty: 'borderRadius', + * transitionDuration: duration * theme.animation.scale, + * transitionTimingFunction: linear(...points), + * }; + */ +export function toSpringCurve(spring: SpringConfig): { + points: number[]; + duration: number; +} { + const { stiffness, damping, mass } = toRawSpring(spring); + const frequency = Math.sqrt(stiffness / mass); + const ratio = damping / (2 * Math.sqrt(stiffness * mass)); + const settleTime = + Math.log(1 / SPRING_SETTLE_THRESHOLD) / (ratio * frequency); + + const getPosition = (time: number) => { + if (ratio >= 1) { + return 1 - Math.exp(-frequency * time) * (1 + frequency * time); + } + + const decay = ratio * frequency; + const dampedFrequency = frequency * Math.sqrt(1 - ratio * ratio); + + return ( + 1 - + Math.exp(-decay * time) * + (Math.cos(dampedFrequency * time) + + (decay / dampedFrequency) * Math.sin(dampedFrequency * time)) + ); + }; + + const points = Array.from({ length: SPRING_SAMPLE_COUNT + 1 }, (_, index) => + index === SPRING_SAMPLE_COUNT + ? 1 + : getPosition((settleTime * index) / SPRING_SAMPLE_COUNT) + ); + + return { points, duration: settleTime * 1000 }; +}