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