From 499e39827330bcf373253686d50b2cb9311178c4 Mon Sep 17 00:00:00 2001 From: Aziz Becha Date: Tue, 12 May 2026 20:47:01 +0100 Subject: [PATCH 01/24] refactor: add Button label prop and deprecate children Introduce a `label?: string` prop as the primary way to set the button text. The `children` prop keeps working as a deprecated fallback (when both are set, `label` wins) and emits a dev-only warning. This decouples the button layout from arbitrary child structures and makes `uppercase` work reliably, since the label is always a string. --- src/components/Button/Button.tsx | 34 ++++- src/components/__tests__/Button.test.tsx | 167 +++++++++++++++-------- 2 files changed, 137 insertions(+), 64 deletions(-) diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index 17d40fb035..1e4493f1db 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -31,7 +31,10 @@ 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. @@ -74,9 +77,14 @@ export type Props = $Omit, 'mode'> & { /** * Label text of the button. */ - children: React.ReactNode; + label?: string; /** - * Make the label text uppercased. Note that this won't work if you pass React elements as children. + * @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. */ uppercase?: boolean; /** @@ -157,9 +165,12 @@ export type Props = $Omit, 'mode'> & { * import { Button } from 'react-native-paper'; * * const MyComponent = () => ( - * + * ).toJSON(); + 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 +192,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( - + + + + + + + label="Compact button" + /> ); expect(getByTestId('button-container-outer-layer')).toHaveStyle({ transform: [{ scale: 1 }], From e30b72db2fde13ab19c5c82931bd645234bf470f Mon Sep 17 00:00:00 2001 From: Aziz Becha Date: Tue, 12 May 2026 20:53:10 +0100 Subject: [PATCH 02/24] refactor: migrate internal Button usages to label Update the components that compose Button (Banner, Snackbar, DataTablePagination) to pass the new `label` prop instead of children, and update the `## Usage` / `@example` JSDoc blocks (and the test files) accordingly so nothing relies on the deprecated `children` prop. --- src/components/Banner.tsx | 5 ++-- src/components/Card/Card.tsx | 4 +-- src/components/Card/CardActions.tsx | 4 +-- .../DataTable/DataTablePagination.tsx | 5 ++-- src/components/Dialog/Dialog.tsx | 4 +-- src/components/Dialog/DialogActions.tsx | 4 +-- src/components/Menu/Menu.tsx | 2 +- src/components/Modal.tsx | 4 +-- src/components/Snackbar.tsx | 7 +++-- src/components/__tests__/Card/Card.test.tsx | 7 +++-- src/components/__tests__/Dialog.test.tsx | 12 ++++----- src/components/__tests__/Menu.test.tsx | 26 +++++++------------ 12 files changed, 35 insertions(+), 49 deletions(-) diff --git a/src/components/Banner.tsx b/src/components/Banner.tsx index b1a00b4ce4..428a225d37 100644 --- a/src/components/Banner.tsx +++ b/src/components/Banner.tsx @@ -246,10 +246,9 @@ const Banner = ({ style={styles.button} textColor={colors.primary} theme={theme} + label={label} {...others} - > - {label} - + /> ))} diff --git a/src/components/Card/Card.tsx b/src/components/Card/Card.tsx index 6dc433322a..1e1677c5d2 100644 --- a/src/components/Card/Card.tsx +++ b/src/components/Card/Card.tsx @@ -127,8 +127,8 @@ export type Props = $Omit, 'mode'> & { * * * - * - * + * - * + * + label={`${numberOfItemsPerPage}`} + /> } > {numberOfItemsPerPageList?.map((option) => ( 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 { 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={ + - - - - + + label="Icon" + /> + + label="Default" + /> + label="Custom" + /> + label="Disabled" + /> + label="Icon" + /> + label="Loading" + /> + iconPosition="trailing" + label="Icon right" + /> - + + label="Custom" + /> + label="Disabled" + /> + label="Icon" + /> + label="Loading" + /> + iconPosition="trailing" + label="Icon right" + /> - + + label="Custom" + /> + label="Disabled" + /> + label="Icon" + /> + label="Loading" + /> + iconPosition="trailing" + label="Icon right" + /> - + + label="Custom" + /> + label="Disabled" + /> + label="Icon" + /> + label="Loading" + /> + iconPosition="trailing" + label="Icon right" + /> @@ -244,17 +241,15 @@ const ButtonExample = () => { }} onPress={() => {}} style={styles.button} - > - Remote image - + label="Remote image" + /> + label="Required asset" + /> + label="Custom component" + /> - + label="Custom Font" + /> + - + label="Custom radius" + /> + + label="Custom radius and padding" + /> @@ -306,18 +304,16 @@ const ButtonExample = () => { mode="contained" onPress={() => {}} style={styles.flexGrow1Button} - > - flex-grow: 1 - + label="flex-grow: 1" + /> + label="width: 100%" + /> @@ -339,9 +335,8 @@ const ButtonExample = () => { onPress={() => {}} style={styles.button} icon="camera" - > - Compact {mode} - + label={`Compact ${mode}`} + /> ); })} @@ -363,9 +358,6 @@ const styles = StyleSheet.create({ button: { margin: 4, }, - flexReverse: { - flexDirection: 'row-reverse', - }, md3FontStyles: { lineHeight: 32, }, 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'} + /> { - - + - + + + 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..c37bf2bad9 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 - + + 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..0bae591bac 100644 --- a/docs/src/components/BannerExample.tsx +++ b/docs/src/components/BannerExample.tsx @@ -74,15 +74,19 @@ const BannerExample = () => { > - - - + + + label="Try on Snack" + /> ); }; From 1269af3c1e23548915adaa2b8a79b8f135c0727e Mon Sep 17 00:00:00 2001 From: Aziz Becha Date: Wed, 13 May 2026 13:59:26 +0100 Subject: [PATCH 08/24] feat: add Button size prop (MD3 expressive) Add a `size?: 'extra-small' | 'small' | 'medium' | 'large' | 'extra-large'` prop. When omitted, the Button keeps its current visuals; when set, the per-size MD3 metrics (minHeight, horizontal padding, icon size, icon/label gap, label typescale) are applied via a new `getButtonSizeStyle` helper. --- src/components/Button/Button.tsx | 67 ++++-- src/components/Button/utils.tsx | 67 ++++++ src/components/__tests__/Button.test.tsx | 56 ++++- .../__snapshots__/Button.test.tsx.snap | 192 ++++++++++++++++++ 4 files changed, 366 insertions(+), 16 deletions(-) diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index fc71192a2d..075afc2b63 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -15,9 +15,11 @@ import { import { ButtonMode, + ButtonSize, getButtonColors, getButtonIconStyle, getButtonRippleColor, + getButtonSizeStyle, getButtonTouchableRippleStyle, } from './utils'; import { useInternalTheme } from '../../core/theming'; @@ -56,6 +58,15 @@ export type Props = $Omit< * 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; /** * Custom button's background color. */ @@ -202,6 +213,7 @@ const Button = ( disabled, compact, mode = 'text', + size, dark, loading, icon, @@ -388,22 +400,29 @@ const Button = ( [labelStyle] ); + const sizeStyle = React.useMemo( + () => (size ? getButtonSizeStyle(size) : undefined), + [size] + ); + const textStyle = React.useMemo( () => ({ color: textColor, - ...(theme as Theme).fonts.labelLarge, + ...(theme as Theme).fonts[sizeStyle?.labelVariant ?? 'labelLarge'], }), - [textColor, theme] + [textColor, theme, sizeStyle] ); const iconStyle = React.useMemo( () => - getButtonIconStyle({ - mode, - compact, - position: isTrailingIcon ? 'trailing' : 'leading', - }), - [mode, compact, isTrailingIcon] + sizeStyle + ? null + : getButtonIconStyle({ + mode, + compact, + position: isTrailingIcon ? 'trailing' : 'leading', + }), + [mode, compact, isTrailingIcon, sizeStyle] ); return ( @@ -460,15 +479,27 @@ const Button = ( style={[ styles.content, isTrailingIcon && styles.contentReverse, + ...(sizeStyle + ? [ + { + minHeight: sizeStyle.minHeight, + paddingHorizontal: sizeStyle.paddingHorizontal, + gap: sizeStyle.iconGap, + }, + ] + : []), { opacity: textOpacity }, contentStyle, ]} > {icon && loading !== true ? ( - + ) : null} = { + 'extra-small': { + minHeight: 32, + paddingHorizontal: 12, + iconSize: 16, + iconGap: 4, + labelVariant: 'labelLarge', + }, + small: { + minHeight: 40, + paddingHorizontal: 16, + iconSize: 20, + iconGap: 8, + labelVariant: 'labelLarge', + }, + medium: { + minHeight: 56, + paddingHorizontal: 24, + iconSize: 24, + iconGap: 8, + labelVariant: 'titleMedium', + }, + large: { + minHeight: 96, + paddingHorizontal: 48, + iconSize: 32, + iconGap: 12, + labelVariant: 'headlineSmall', + }, + 'extra-large': { + minHeight: 136, + paddingHorizontal: 64, + iconSize: 40, + iconGap: 16, + labelVariant: 'headlineLarge', + }, +}; + +export const getButtonSizeStyle = (size: ButtonSize): ButtonSizeStyle => + BUTTON_SIZE_STYLES[size]; + /** * Returns the margins applied to the button's icon (or loading indicator) * depending on the button mode, density and the position of the icon relative diff --git a/src/components/__tests__/Button.test.tsx b/src/components/__tests__/Button.test.tsx index 94cf3ab0db..df026b86b3 100644 --- a/src/components/__tests__/Button.test.tsx +++ b/src/components/__tests__/Button.test.tsx @@ -9,7 +9,11 @@ import { render } from '../../test-utils'; import { pink500, white } from '../../theme/colors'; import { tokens } from '../../theme/tokens'; import Button from '../Button/Button'; -import { getButtonColors, getButtonRippleColor } from '../Button/utils'; +import { + getButtonColors, + getButtonRippleColor, + getButtonSizeStyle, +} from '../Button/utils'; const { stateOpacity } = tokens.md.ref; @@ -825,6 +829,56 @@ describe('getButtonRippleColor', () => { }); }); +describe('getButtonSizeStyle', () => { + it.each([ + ['extra-small', 32, 12, 16, 4, 'labelLarge'], + ['small', 40, 16, 20, 8, 'labelLarge'], + ['medium', 56, 24, 24, 8, 'titleMedium'], + ['large', 96, 48, 32, 12, 'headlineSmall'], + ['extra-large', 136, 64, 40, 16, 'headlineLarge'], + ] as const)( + 'returns expected metrics for %s', + (size, minHeight, paddingHorizontal, iconSize, iconGap, labelVariant) => { + expect(getButtonSizeStyle(size)).toEqual({ + minHeight, + paddingHorizontal, + iconSize, + iconGap, + labelVariant, + }); + } + ); +}); + +describe('size prop', () => { + it('renders a button with per-size metrics', () => { + const tree = render( +