diff --git a/docs/docs/guides/03-icons.mdx b/docs/docs/guides/03-icons.mdx index c773ce7a56..350b90d59c 100644 --- a/docs/docs/guides/03-icons.mdx +++ b/docs/docs/guides/03-icons.mdx @@ -27,7 +27,7 @@ You can pass the name of an icon from [`MaterialDesignIcons`](https://pictogramm Example: ```js - + + label="Press me" +/> ``` Local image: ```js - + + label="Press me" +/> ``` ### 4. Use custom icons @@ -131,15 +129,14 @@ Example for using an image source: }, direction: 'rtl', }} -> - Press me - + label="Press me" +/> ``` Example for using an icon name: ```js - + + label="Press me" +/> ``` diff --git a/docs/docs/guides/09-react-navigation.md b/docs/docs/guides/09-react-navigation.md index 5ea5d556fa..1213f98c35 100644 --- a/docs/docs/guides/09-react-navigation.md +++ b/docs/docs/guides/09-react-navigation.md @@ -86,9 +86,11 @@ function HomeScreen({ navigation }) { return ( Home Screen - + + mode="filled" + onPress={() => console.log('Pressed')} + label="Press me" +/> ``` ## Disable ripple effect in all components diff --git a/docs/src/components/BannerExample.tsx b/docs/src/components/BannerExample.tsx index 59ec60634a..c5d0c7df09 100644 --- a/docs/src/components/BannerExample.tsx +++ b/docs/src/components/BannerExample.tsx @@ -74,15 +74,14 @@ const BannerExample = () => { > - - - + + + label="Try on Snack" + /> ); }; diff --git a/docs/src/data/screenshots.js b/docs/src/data/screenshots.js index c1afa99a6a..bc20787a92 100644 --- a/docs/src/data/screenshots.js +++ b/docs/src/data/screenshots.js @@ -22,11 +22,11 @@ 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.js b/docs/src/data/themeColors.js index f12b955341..55f531c45f 100644 --- a/docs/src/data/themeColors.js +++ b/docs/src/data/themeColors.js @@ -47,20 +47,20 @@ const themeColors = { }, Button: { active: { - elevated: { - backgroundColor: 'theme.colors.elevation.level1', - textColor: 'theme.colors.primary', - }, - contained: { + filled: { backgroundColor: 'theme.colors.primary', textColor: 'theme.colors.onPrimary', }, - 'contained-tonal': { + tonal: { backgroundColor: 'theme.colors.secondaryContainer', textColor: 'theme.colors.onSecondaryContainer', }, - outlined: { + elevated: { + backgroundColor: 'theme.colors.elevation.level1', textColor: 'theme.colors.primary', + }, + outlined: { + textColor: 'theme.colors.onSurfaceVariant', borderColor: 'theme.colors.outline', }, text: { @@ -68,15 +68,15 @@ const themeColors = { }, }, disabled: { - elevated: { + filled: { backgroundColor: 'theme.colors.surfaceDisabled', textColor: 'theme.colors.onSurfaceDisabled', }, - contained: { + tonal: { backgroundColor: 'theme.colors.surfaceDisabled', textColor: 'theme.colors.onSurfaceDisabled', }, - 'contained-tonal': { + elevated: { backgroundColor: 'theme.colors.surfaceDisabled', textColor: 'theme.colors.onSurfaceDisabled', }, diff --git a/docs/src/utils/__tests__/themeColors.test.tsx b/docs/src/utils/__tests__/themeColors.test.tsx index 4803ea7a6b..668f1a0bbd 100644 --- a/docs/src/utils/__tests__/themeColors.test.tsx +++ b/docs/src/utils/__tests__/themeColors.test.tsx @@ -2,20 +2,20 @@ import { getMaxNestedLevel, getUniqueNestedKeys } from '../themeColors'; const Button = { active: { - elevated: { - backgroundColor: 'theme.colors.elevation.level1', - color: 'theme.colors.primary', - }, - contained: { + filled: { backgroundColor: 'theme.colors.primary', color: 'theme.colors.onPrimary', }, - 'contained-tonal': { + tonal: { backgroundColor: 'theme.colors.secondaryContainer', color: 'theme.colors.onSecondaryContainer', }, - outlined: { + elevated: { + backgroundColor: 'theme.colors.elevation.level1', color: 'theme.colors.primary', + }, + outlined: { + color: 'theme.colors.onSurfaceVariant', borderColor: 'theme.colors.outline', }, text: { @@ -23,15 +23,15 @@ const Button = { }, }, disabled: { - elevated: { + filled: { backgroundColor: 'theme.colors.surfaceDisabled', color: 'theme.colors.onSurfaceDisabled', }, - contained: { + tonal: { backgroundColor: 'theme.colors.surfaceDisabled', color: 'theme.colors.onSurfaceDisabled', }, - 'contained-tonal': { + elevated: { backgroundColor: 'theme.colors.surfaceDisabled', color: 'theme.colors.onSurfaceDisabled', }, diff --git a/example/src/DrawerItems.tsx b/example/src/DrawerItems.tsx index f9df80433c..fcfe677a2b 100644 --- a/example/src/DrawerItems.tsx +++ b/example/src/DrawerItems.tsx @@ -270,7 +270,7 @@ function DrawerItems() { example directory. - + - - - - + + + label="Play me" + /> + + + + + + + {showIcon && ( + + )} + + + + {/* `compact` is a no-op once a size is set, so only offer it for unset. */} + {size === 'unset' && ( + + )} - + + - - - - - - + {MODES.map((m) => ( + + label="Enabled" + /> + label="Disabled" + /> - - + label="Loading" + /> - + + - - - - - - + {SIZES.filter( + (s): s is Exclude => s !== 'unset' + ).map((s) => ( + - - - - - + {(['outlined', 'text', 'tonal'] as const).map((m) => { + const key = `toggle-${m}`; + const isSelected = !!selectedToggles[key]; + return ( + + label="Custom color" + /> + label="Remote image" + /> + label="Custom component" + /> - + labelStyle={styles.fontStyles} + label="Custom font" + /> - - - - - - + label="Custom radius" + /> - - - - - {( - [ - 'text', - 'outlined', - 'contained', - 'elevated', - 'contained-tonal', - ] as const - ).map((mode) => { - return ( - - ); - })} + style={styles.fullWidthButton} + label="width: 100%" + /> @@ -353,6 +338,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', @@ -363,37 +376,20 @@ const styles = StyleSheet.create({ button: { margin: 4, }, - flexReverse: { - flexDirection: 'row-reverse', - }, - md3FontStyles: { - lineHeight: 32, - }, fontStyles: { fontWeight: '800', - fontSize: 24, - }, - flexGrow1Button: { - flexGrow: 1, - marginTop: 10, - }, - width100PercentButton: { - width: '100%', - marginTop: 10, + fontSize: 20, }, customRadius: { + margin: 4, borderTopLeftRadius: 16, borderTopRightRadius: 0, borderBottomLeftRadius: 0, borderBottomRightRadius: 16, }, - noRadius: { - borderRadius: 0, - }, - customRadiusAndPadding: { - borderRadius: 4, - paddingHorizontal: 12, - paddingVertical: 6, + fullWidthButton: { + width: '100%', + marginTop: 10, }, }); diff --git a/example/src/Examples/CardExample.tsx b/example/src/Examples/CardExample.tsx index ad3451e8b0..9a7973a8de 100644 --- a/example/src/Examples/CardExample.tsx +++ b/example/src/Examples/CardExample.tsx @@ -75,8 +75,8 @@ const CardExample = () => { - - + - + + label="Long text" + /> + label="Radio buttons" + /> + label="Progress indicator" + /> + label="Undismissable Dialog" + /> + label="Custom colors" + /> + label="With icon" + /> {Platform.OS === 'android' && ( + label="Dismissable back button" + /> )} - + - + - + + - + - + + + + - - + + label={showSnackbar ? 'Hide' : 'Show'} + /> { - - + - + + /> ))} diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index 17d40fb035..6349184143 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -15,10 +15,18 @@ import { import { ButtonMode, + ButtonShape, + ButtonSize, getButtonColors, + getButtonIconStyle, + getButtonRippleColor, + getButtonShapeRadius, + getButtonSizeStyle, getButtonTouchableRippleStyle, } from './utils'; +import { getDefaultDirection, useLocale } from '../../core/locale'; import { useInternalTheme } from '../../core/theming'; +import { toRawSpring } from '../../theme/tokens/sys/motion'; import type { $Omit, Theme, ThemeProp } from '../../types'; import { forwardRef } from '../../utils/forwardRef'; import hasTouchHandler from '../../utils/hasTouchHandler'; @@ -31,26 +39,57 @@ import TouchableRipple, { } from '../TouchableRipple/TouchableRipple'; import Text from '../Typography/Text'; -export type Props = $Omit, 'mode'> & { +export type Props = $Omit< + React.ComponentProps, + 'mode' | 'children' +> & { /** - * 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. */ compact?: boolean; + /** + * Size of the button (Material Design 3 expressive). One of + * `'extra-small' | 'small' | 'medium' | 'large' | 'extra-large'`. + * + * When omitted, the button uses its legacy visuals. When set, 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). `'round'` + * uses the full-pill corner radius; `'square'` uses a smaller per-size + * corner radius. When omitted, the button keeps its legacy corner radius + * (`theme.shapes.corner.largeIncreased`). Overridden by an explicit + * `borderRadius` in `style`. + */ + shape?: ButtonShape; + /** + * Whether this button is in the selected state (Material Design 3 + * expressive toggle). When `true`: + * + * - The `shape` is flipped: `'round'` becomes `'square'` and vice versa. + * - For `outlined` and `text` modes, the button adopts a filled + * `secondaryContainer` appearance (matches `tonal`). + * - `accessibilityState.selected` is set so screen readers announce the + * toggle state. + * + * Other modes only flip the shape. + */ + selected?: boolean; /** * Custom button's background color. */ @@ -67,6 +106,10 @@ export type Props = $Omit, 'mode'> & { * 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. */ @@ -74,9 +117,14 @@ export type Props = $Omit, 'mode'> & { /** * Label text of the button. */ - children: React.ReactNode; + label?: string; + /** + * @deprecated Use `label` instead. When both `label` and `children` are set, `label` is used. + * 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. + * Make the label text uppercased. */ uppercase?: boolean; /** @@ -84,6 +132,11 @@ export type Props = $Omit, 'mode'> & { * 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. */ @@ -118,7 +171,10 @@ export type Props = $Omit, 'mode'> & { 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. + * + * Note: setting `flexDirection: 'row-reverse'` here to move the icon to the + * trailing edge is deprecated — use the `iconPosition` prop instead. */ contentStyle?: StyleProp; /** @@ -157,24 +213,43 @@ export type Props = $Omit, 'mode'> & { * import { Button } from 'react-native-paper'; * * const MyComponent = () => ( - * + * - * + * - * + * + label={`${numberOfItemsPerPage}`} + /> } > {numberOfItemsPerPageList?.map((option) => ( @@ -359,9 +358,6 @@ const styles = StyleSheet.create({ iconsContainer: { flexDirection: 'row', }, - contentStyle: { - flexDirection: 'row-reverse', - }, }); export default DataTablePagination; diff --git a/src/components/Dialog/Dialog.tsx b/src/components/Dialog/Dialog.tsx index 717445aed7..e2f9ec2491 100644 --- a/src/components/Dialog/Dialog.tsx +++ b/src/components/Dialog/Dialog.tsx @@ -73,7 +73,7 @@ const DIALOG_ELEVATION: number = 24; * return ( * * - * + * + * - * + * }> + * anchor={ + * + * + /> ) : null} {isIconButton ? ( { - const tree = render().toJSON(); +it('renders filled button by default', () => { + const tree = render().toJSON(); + const tree = render( + + ).toJSON(); + const tree = render( + ).toJSON(); + const tree = render().toJSON(); + const tree = render( + + + + + + + + + label="Custom radius" + /> ); expect(getByTestId('custom-radius-container')).toHaveStyle( @@ -186,9 +224,11 @@ it('renders outlined button with custom border radius', () => { it('renders button without border radius', () => { const { getByTestId } = render( - + + + + ); + + expect(getByTestId('button-text')).toHaveTextContent('From label'); + }); +}); + +describe('deprecated children prop', () => { + it('still renders the children as the label', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const { getByTestId } = render( + + ); + + expect(getByTestId('button-text')).toHaveTextContent('Legacy label'); + warn.mockRestore(); + }); + + it('warns about the deprecation', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + render(); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('`children` prop is deprecated') + ); + warn.mockRestore(); + }); +}); + describe('button text styles', () => { it('applies uppercase styles if uppercase prop is truthy', () => { const { getByTestId } = render( - + + + - ); - expect(getByTestId('compact-button-icon-container')).toHaveStyle({ - marginLeft: 8, - marginRight: 0, - }); - }) + (['outlined', 'filled', 'tonal', 'elevated'] as const).forEach((mode) => + it(`should return correct icon styles for compact ${mode} button`, () => { + const { getByTestId } = render( + + - ); - expect(getByTestId('compact-button-icon-container')).toHaveStyle({ - marginLeft: 16, - marginRight: -16, - }); - }) + (['outlined', 'filled', 'tonal', 'elevated'] as const).forEach((mode) => + it(`should return correct icon styles for compact ${mode} button`, () => { + const { getByTestId } = render( + + label="Compact button" + /> ); expect(getByTestId('button-container-outer-layer')).toHaveStyle({ transform: [{ scale: 1 }], @@ -727,3 +1121,101 @@ it('animated value changes correctly', () => { transform: [{ scale: 1.5 }], }); }); + +describe('shape morph animation', () => { + const lastSpringToValue = (spy: jest.SpyInstance) => + spy.mock.calls.map((call) => call[1]?.toValue); + + it('springs the corner radius to corner.small on press in', () => { + const spy = jest.spyOn(Animated, 'spring'); + const { getByTestId } = render( + + + label="Agree" + /> ); diff --git a/src/components/__tests__/Dialog.test.tsx b/src/components/__tests__/Dialog.test.tsx index 742f44b941..47a6409c58 100644 --- a/src/components/__tests__/Dialog.test.tsx +++ b/src/components/__tests__/Dialog.test.tsx @@ -110,8 +110,8 @@ describe('DialogActions', () => { it('should render passed children', () => { const { getByTestId } = render( - - + - + - + } + anchor={} + anchor={} + anchor={} + anchor={ - } + anchor={ - } + anchor={} + anchor={} + anchor={} + anchor={