From 00e72dedfb8daceeeee6890bb5d3e95bc7d8f7e6 Mon Sep 17 00:00:00 2001 From: matkoson Date: Thu, 18 Jun 2026 13:34:40 +0200 Subject: [PATCH 01/11] refactor(list): replace React.Children injection in List.Accordion with context List.Accordion used React.Children.map + cloneElement to inject paddingLeft and theme into expanded children, which makes composition harder (children had to be direct, prop-mergeable elements). Replace it with ListAccordionContext: the accordion exposes whether descendants should indent (leftIndent), and List.Item consumes it, applying the indent to its own container so the ripple/background stays full-width. Behavior preserved for List.Item children; theme now flows via context. Adds characterization tests. Refs #4989. --- src/components/List/ListAccordion.tsx | 28 +++++-------------- src/components/List/ListAccordionContext.tsx | 15 ++++++++++ src/components/List/ListItem.tsx | 12 +++++++- .../__tests__/ListAccordion.test.tsx | 24 ++++++++++++++++ 4 files changed, 57 insertions(+), 22 deletions(-) create mode 100644 src/components/List/ListAccordionContext.tsx diff --git a/src/components/List/ListAccordion.tsx b/src/components/List/ListAccordion.tsx index 97730d58e6..9a3dcf0fde 100644 --- a/src/components/List/ListAccordion.tsx +++ b/src/components/List/ListAccordion.tsx @@ -12,8 +12,9 @@ import type { ViewStyle, } from 'react-native'; +import { ListAccordionContext } from './ListAccordionContext'; import { ListAccordionGroupContext } from './ListAccordionGroup'; -import type { ListChildProps, Style } from './utils'; +import type { Style } from './utils'; import { getAccordionColors, getLeftStyles } from './utils'; import { useLocale } from '../../core/locale'; import { useInternalTheme } from '../../core/theming'; @@ -324,23 +325,11 @@ const ListAccordion = ({ - {isExpanded - ? React.Children.map(children, (child) => { - if ( - left && - React.isValidElement(child) && - !child.props.left && - !child.props.right - ) { - return React.cloneElement(child, { - style: [styles.child, child.props.style], - theme, - }); - } - - return child; - }) - : null} + {isExpanded ? ( + + {children} + + ) : null} ); }; @@ -374,9 +363,6 @@ const styles = StyleSheet.create({ marginVertical: 6, paddingLeft: 8, }, - child: { - paddingLeft: 40, - }, content: { flex: 1, justifyContent: 'center', diff --git a/src/components/List/ListAccordionContext.tsx b/src/components/List/ListAccordionContext.tsx new file mode 100644 index 0000000000..306d9788bc --- /dev/null +++ b/src/components/List/ListAccordionContext.tsx @@ -0,0 +1,15 @@ +import * as React from 'react'; + +export type ListAccordionContextType = { + /** + * Whether descendant items that don't render their own `left`/`right` + * element should be indented to align under the accordion's content + * (past the leading icon). + */ + leftIndent: boolean; +}; + +export const ListAccordionContext = + React.createContext({ leftIndent: false }); + +ListAccordionContext.displayName = 'ListAccordionContext'; diff --git a/src/components/List/ListItem.tsx b/src/components/List/ListItem.tsx index a6f0181f02..2c0d3452cd 100644 --- a/src/components/List/ListItem.tsx +++ b/src/components/List/ListItem.tsx @@ -10,6 +10,7 @@ import type { ViewStyle, } from 'react-native'; +import { ListAccordionContext } from './ListAccordionContext'; import { getLeftStyles, getRightStyles } from './utils'; import type { Style } from './utils'; import { useInternalTheme } from '../../core/theming'; @@ -161,6 +162,8 @@ const ListItem = ({ ...rest }: Props) => { const theme = useInternalTheme(themeOverrides); + const { leftIndent } = React.useContext(ListAccordionContext); + const shouldIndent = leftIndent && !left && !right; const [alignToTop, setAlignToTop] = React.useState(false); const onDescriptionTextLayout = ( @@ -228,7 +231,11 @@ const ListItem = ({ { + const { getByTestId } = render( + } + title="Accordion with indented children" + expanded + > + + + ); + + expect(getByTestId('accordion-child')).toHaveStyle({ paddingLeft: 40 }); +}); + +it('does not indent expanded accordion children when the accordion has no left icon', () => { + const { getByTestId } = render( + + + + ); + + expect(getByTestId('accordion-child')).not.toHaveStyle({ paddingLeft: 40 }); +}); + describe('ListAccordion', () => { it('should not throw an error when id={0}', async () => { const ListAccordionTest = () => ( From 0e91ea52ef0754e38f8e948237505ea4352da0e3 Mon Sep 17 00:00:00 2001 From: matkoson Date: Tue, 23 Jun 2026 15:15:15 +0200 Subject: [PATCH 02/11] refactor(dialog): remove child composition injection Dialog cloned its children with React.Children + cloneElement to inject the theme, and DialogActions inspected the child count/index to space the first and last action. Both couple the components to the exact shape of their children. Remove the cloning: Dialog.Title/Content/Actions/Icon resolve the theme themselves via useInternalTheme, and DialogActions spaces its actions with a container `gap` instead of per-child injection. BREAKING CHANGE: Dialog no longer clones its children to forward an injected theme. The subcomponents read the theme from context (PaperProvider) or an explicit `theme` prop, so pass `theme` directly to a Dialog subcomponent if you previously relied on Dialog forwarding it. Wrapping or conditionally rendering Dialog.Actions children no longer affects their spacing. Refs #4989 --- src/components/Dialog/Dialog.tsx | 16 ++---- src/components/Dialog/DialogActions.tsx | 20 ++------ src/components/Dialog/DialogIcon.tsx | 9 +++- src/components/Dialog/DialogTitle.tsx | 2 +- src/components/__tests__/Dialog.test.tsx | 65 +++++++++++++++++++++--- 5 files changed, 75 insertions(+), 37 deletions(-) diff --git a/src/components/Dialog/Dialog.tsx b/src/components/Dialog/Dialog.tsx index e79804d324..a41f29aa11 100644 --- a/src/components/Dialog/Dialog.tsx +++ b/src/components/Dialog/Dialog.tsx @@ -12,7 +12,6 @@ import DialogTitle from './DialogTitle'; import { useInternalTheme } from '../../core/theming'; import type { Theme, ThemeProp } from '../../types'; import Modal from '../Modal'; -import type { DialogChildProps } from './utils'; export type Props = { /** @@ -51,6 +50,8 @@ const DIALOG_ELEVATION: number = 24; /** * Dialogs inform users about a specific task and may contain critical information, require decisions, or involve multiple tasks. * To render the `Dialog` above other components, you'll need to wrap it with the [`Portal`](../Portal) component. + * Dialog owns the top content inset, so first-slot components render without + * adding their own top offset. * * ## Usage * ```js @@ -122,17 +123,7 @@ const Dialog = ({ theme={theme} testID={testID} > - {React.Children.toArray(children) - .filter((child) => child != null && typeof child !== 'boolean') - .map((child, i) => { - if (i === 0 && React.isValidElement(child)) { - return React.cloneElement(child, { - style: [{ marginTop: 24 }, child.props.style], - }); - } - - return child; - })} + {children} ); }; @@ -160,6 +151,7 @@ const styles = StyleSheet.create({ marginVertical: Platform.OS === 'android' ? 44 : 0, elevation: DIALOG_ELEVATION, justifyContent: 'flex-start', + paddingTop: 24, }, }); diff --git a/src/components/Dialog/DialogActions.tsx b/src/components/Dialog/DialogActions.tsx index 7e3799451e..c4eaa48c3f 100644 --- a/src/components/Dialog/DialogActions.tsx +++ b/src/components/Dialog/DialogActions.tsx @@ -2,7 +2,6 @@ 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 { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../types'; @@ -20,6 +19,8 @@ export type Props = ViewProps & { /** * A component to show a list of actions in a Dialog. + * Actions are rendered directly, so configure each action button's props + * explicitly when you need non-default behavior. * * ## Usage * ```js @@ -48,24 +49,10 @@ export type Props = ViewProps & { */ const DialogActions = (props: Props) => { useInternalTheme(props.theme); - const actionsLength = React.Children.toArray(props.children).length; return ( - {React.Children.map(props.children, (child, i) => - React.isValidElement(child) - ? React.cloneElement(child, { - compact: true, - uppercase: false, - style: [ - { - marginRight: i + 1 === actionsLength ? 0 : 8, - }, - child.props.style, - ], - }) - : child - )} + {props.children} ); }; @@ -78,6 +65,7 @@ const styles = StyleSheet.create({ flexGrow: 1, alignItems: 'center', justifyContent: 'flex-end', + gap: 8, paddingBottom: 24, paddingHorizontal: 24, }, diff --git a/src/components/Dialog/DialogIcon.tsx b/src/components/Dialog/DialogIcon.tsx index 199becd06c..62935052ff 100644 --- a/src/components/Dialog/DialogIcon.tsx +++ b/src/components/Dialog/DialogIcon.tsx @@ -24,6 +24,10 @@ export type Props = { * @optional */ theme?: ThemeProp; + /** + * testID to be used on tests. + */ + testID?: string; }; /** @@ -68,6 +72,7 @@ const DialogIcon = ({ color, icon, theme: themeOverrides, + testID, }: Props) => { const theme = useInternalTheme(themeOverrides); const { colors } = theme as Theme; @@ -76,7 +81,7 @@ const DialogIcon = ({ const iconColor = color || colors.secondary; return ( - + ); @@ -88,7 +93,7 @@ const styles = StyleSheet.create({ wrapper: { alignItems: 'center', justifyContent: 'center', - paddingTop: 24, + paddingTop: 0, }, }); diff --git a/src/components/Dialog/DialogTitle.tsx b/src/components/Dialog/DialogTitle.tsx index bdf5021379..f361f911ab 100644 --- a/src/components/Dialog/DialogTitle.tsx +++ b/src/components/Dialog/DialogTitle.tsx @@ -81,7 +81,7 @@ const styles = StyleSheet.create({ marginHorizontal: 24, }, v3Text: { - marginTop: 16, + marginTop: 0, marginBottom: 16, }, }); diff --git a/src/components/__tests__/Dialog.test.tsx b/src/components/__tests__/Dialog.test.tsx index 59785c52cf..a0c5fab39b 100644 --- a/src/components/__tests__/Dialog.test.tsx +++ b/src/components/__tests__/Dialog.test.tsx @@ -93,17 +93,52 @@ describe('Dialog', () => { expect(onDismiss).toHaveBeenCalledTimes(1); }); - it('should apply top margin to the first child if the dialog is V3', async () => { + it('should apply top spacing to the dialog surface for a title-first dialog', async () => { await render( - - + + Test Dialog Content ); + expect(screen.getByTestId('dialog-surface')).toHaveStyle({ + paddingTop: 24, + }); + expect(screen.getByTestId('dialog-title')).toHaveStyle({ + marginTop: 0, + }); + }); + + it('should apply top spacing to the dialog surface for a content-first dialog', async () => { + await render( + + + Test Dialog Content + + + ); + + expect(screen.getByTestId('dialog-surface')).toHaveStyle({ + paddingTop: 24, + }); expect(screen.getByTestId('dialog-content')).toHaveStyle({ - marginTop: 24, + paddingBottom: 24, + }); + }); + + it('should apply top spacing to the dialog surface for an icon-first dialog', async () => { + await render( + + + + ); + + expect(screen.getByTestId('dialog-surface')).toHaveStyle({ + paddingTop: 24, + }); + expect(screen.getByTestId('dialog-icon')).toHaveStyle({ + paddingTop: 0, }); }); }); @@ -133,11 +168,29 @@ describe('DialogActions', () => { const dialogActionButtons = dialogActionsContainer.children; expect(dialogActionsContainer).toHaveStyle({ + gap: 8, paddingBottom: 24, paddingHorizontal: 24, }); - expect(dialogActionButtons[0]).toHaveStyle({ marginRight: 8 }); - expect(dialogActionButtons[1]).toHaveStyle({ marginRight: 0 }); + expect(dialogActionButtons[0]).not.toHaveStyle({ marginRight: 8 }); + expect(dialogActionButtons[1]).not.toHaveStyle({ marginRight: 0 }); + }); + + it('should not inject button props into actions', async () => { + await render( + + + + + ); + + const dialogActionsContainer = screen.getByTestId('dialog-actions'); + const [cancelButton, okButton] = dialogActionsContainer.props.children; + + expect(cancelButton.props.compact).toBeUndefined(); + expect(cancelButton.props.uppercase).toBeUndefined(); + expect(okButton.props.compact).toBeUndefined(); + expect(okButton.props.uppercase).toBeUndefined(); }); it('should apply custom styles', async () => { From 41ecff893f408fb6619b2121bd7e04a53c78b5cb Mon Sep 17 00:00:00 2001 From: matkoson Date: Tue, 23 Jun 2026 15:15:42 +0200 Subject: [PATCH 03/11] refactor(card): remove child composition injection Card used React.Children.count/map + cloneElement to inspect sibling position and inject props (the theme, and top padding for the first child) into Card.Content/Cover/Title/Actions. That makes composition fragile: children had to be direct, recognised elements. Render the children directly instead and let each subcomponent own its own theme resolution and padding. BREAKING CHANGE: Card no longer clones its children to inject a theme or sibling-derived padding. Card.Content/Cover/Title/Actions read the theme from context or an explicit `theme` prop and apply their own spacing, so wrapping or reordering them keeps working; pass `theme` directly if you previously relied on Card forwarding it. Refs #4989 --- example/src/Examples/CardExample.tsx | 12 ++-- example/src/Examples/TeamDetails.tsx | 16 +++-- src/components/Card/Card.tsx | 30 +++----- src/components/Card/CardActions.tsx | 29 ++------ src/components/Card/CardContent.tsx | 61 ++-------------- src/components/Card/CardCover.tsx | 18 +---- src/components/Card/CardTitle.tsx | 8 --- src/components/Card/utils.tsx | 4 -- src/components/__tests__/Card/Card.test.tsx | 69 +++++++++++++++++++ .../Card/__snapshots__/Card.test.tsx.snap | 4 ++ 10 files changed, 115 insertions(+), 136 deletions(-) diff --git a/example/src/Examples/CardExample.tsx b/example/src/Examples/CardExample.tsx index ad3451e8b0..9de8b5cbbc 100644 --- a/example/src/Examples/CardExample.tsx +++ b/example/src/Examples/CardExample.tsx @@ -75,8 +75,12 @@ const CardExample = () => { - - + + @@ -104,10 +108,10 @@ const CardExample = () => { /> - - diff --git a/example/src/Examples/TeamDetails.tsx b/example/src/Examples/TeamDetails.tsx index 5970274f31..463e5148d8 100644 --- a/example/src/Examples/TeamDetails.tsx +++ b/example/src/Examples/TeamDetails.tsx @@ -93,8 +93,12 @@ const News = () => { - - + + @@ -110,8 +114,12 @@ const News = () => { - - + + diff --git a/src/components/Card/Card.tsx b/src/components/Card/Card.tsx index e1cc58c52a..fed3d55343 100644 --- a/src/components/Card/Card.tsx +++ b/src/components/Card/Card.tsx @@ -95,6 +95,8 @@ export type Props = $Omit, 'mode'> & { /** * A card is a sheet of material that serves as an entry point to more detailed information. + * Card clips its inner content to the card shape and renders children directly; + * section spacing is owned by the section components themselves. * * ## Usage * ```js @@ -112,8 +114,8 @@ export type Props = $Omit, 'mode'> & { * * * - * - * + * + * * * * ); @@ -185,13 +187,6 @@ const Card = ({ runElevationAnimation('out'); }); - const total = React.Children.count(children); - const siblings = React.Children.map(children, (child) => - React.isValidElement(child) && child.type - ? (child.type as any).displayName - : null - ); - const { backgroundColor, borderColor: themedBorderColor } = getCardColors({ theme, mode: cardMode, @@ -212,17 +207,11 @@ const Card = ({ }; const content = ( - - {React.Children.map(children, (child, index) => - React.isValidElement(child) - ? React.cloneElement(child as React.ReactElement, { - index, - total, - siblings, - borderRadiusStyles, - }) - : child - )} + + {children} ); @@ -288,6 +277,7 @@ Card.Title = CardTitle; const styles = StyleSheet.create({ innerContainer: { flexShrink: 1, + overflow: 'hidden', }, outline: { borderWidth: 1, diff --git a/src/components/Card/CardActions.tsx b/src/components/Card/CardActions.tsx index 689a97d1c1..e07a3034bc 100644 --- a/src/components/Card/CardActions.tsx +++ b/src/components/Card/CardActions.tsx @@ -2,7 +2,6 @@ import * as React from 'react'; import { StyleSheet, View } from 'react-native'; import type { StyleProp, ViewProps, ViewStyle } from 'react-native'; -import type { CardActionChildProps } from './utils'; import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../types'; @@ -17,6 +16,8 @@ export type Props = ViewProps & { /** * A component to show a list of actions inside a Card. + * Actions are rendered directly, so set button `mode`, `compact`, and custom + * spacing props explicitly on each action when needed. * * ## Usage * ```js @@ -26,8 +27,8 @@ export type Props = ViewProps & { * const MyComponent = () => ( * * - * - * + * + * * * * ); @@ -43,23 +44,7 @@ const CardActions = ({ theme, style, children, ...rest }: Props) => { return ( - {React.Children.map(children, (child, index) => { - if (!React.isValidElement(child)) { - return child; - } - - const compact = child.props.compact; - const mode = - child.props.mode ?? (index === 0 ? 'outlined' : 'contained'); - const childStyle = [styles.button, child.props.style]; - - return React.cloneElement(child, { - ...child.props, - compact, - mode, - style: childStyle, - }); - })} + {children} ); }; @@ -70,11 +55,9 @@ const styles = StyleSheet.create({ container: { flexDirection: 'row', alignItems: 'center', + gap: 8, padding: 8, }, - button: { - marginLeft: 8, - }, }); export default CardActions; diff --git a/src/components/Card/CardContent.tsx b/src/components/Card/CardContent.tsx index af69fd7459..5784273d1b 100644 --- a/src/components/Card/CardContent.tsx +++ b/src/components/Card/CardContent.tsx @@ -7,23 +7,13 @@ export type Props = ViewProps & { * Items inside the `Card.Content`. */ children: React.ReactNode; - /** - * @internal - */ - index?: number; - /** - * @internal - */ - total?: number; - /** - * @internal - */ - siblings?: Array; style?: StyleProp; }; /** * A component to show content inside a Card. + * Content uses uniform vertical padding and does not depend on neighboring + * card sections. * * ## Usage * ```js @@ -42,59 +32,18 @@ export type Props = ViewProps & { * export default MyComponent; * ``` */ -const CardContent = ({ index, total, siblings, style, ...rest }: Props) => { - const cover = 'withInternalTheme(CardCover)'; - const title = 'withInternalTheme(CardTitle)'; - - let contentStyle, prev, next; - - if (typeof index === 'number' && siblings) { - prev = siblings[index - 1]; - next = siblings[index + 1]; - } - - if ( - (prev === cover && next === cover) || - (prev === title && next === title) || - total === 1 - ) { - contentStyle = styles.only; - } else if (index === 0) { - if (next === cover || next === title) { - contentStyle = styles.only; - } else { - contentStyle = styles.first; - } - } else if (typeof total === 'number' && index === total - 1) { - if (prev === cover || prev === title) { - contentStyle = styles.only; - } else { - contentStyle = styles.last; - } - } else if (prev === cover || prev === title) { - contentStyle = styles.first; - } else if (next === cover || next === title) { - contentStyle = styles.last; - } - - return ; -}; +const CardContent = ({ style, ...rest }: Props) => ( + +); CardContent.displayName = 'Card.Content'; const styles = StyleSheet.create({ container: { paddingHorizontal: 16, - }, - first: { paddingTop: 16, - }, - last: { paddingBottom: 16, }, - only: { - paddingVertical: 16, - }, }); export default CardContent; diff --git a/src/components/Card/CardCover.tsx b/src/components/Card/CardCover.tsx index e68bfb4adb..873561bd82 100644 --- a/src/components/Card/CardCover.tsx +++ b/src/components/Card/CardCover.tsx @@ -8,14 +8,6 @@ import type { ThemeProp } from '../../types'; import { splitStyles } from '../../utils/splitStyles'; export type Props = ImageProps & { - /** - * @internal - */ - index?: number; - /** - * @internal - */ - total?: number; style?: StyleProp; /** * @optional @@ -42,13 +34,7 @@ export type Props = ImageProps & { * * @extends Image props https://reactnative.dev/docs/image#props */ -const CardCover = ({ - index, - total, - style, - theme: themeOverrides, - ...rest -}: Props) => { +const CardCover = ({ style, theme: themeOverrides, ...rest }: Props) => { const theme = useInternalTheme(themeOverrides); const flattenedStyles = (StyleSheet.flatten(style) || {}) as ViewStyle; @@ -59,8 +45,6 @@ const CardCover = ({ const coverStyle = getCardCoverStyle({ theme, - index, - total, borderRadiusStyles, }); diff --git a/src/components/Card/CardTitle.tsx b/src/components/Card/CardTitle.tsx index 2737fe6430..cf8beb20ef 100644 --- a/src/components/Card/CardTitle.tsx +++ b/src/components/Card/CardTitle.tsx @@ -81,14 +81,6 @@ export type Props = ViewProps & { * Style for the right element wrapper. */ rightStyle?: StyleProp; - /** - * @internal - */ - index?: number; - /** - * @internal - */ - total?: number; /** * Specifies the largest possible scale a title font can reach. */ diff --git a/src/components/Card/utils.tsx b/src/components/Card/utils.tsx index 785a23c820..ff4e117bab 100644 --- a/src/components/Card/utils.tsx +++ b/src/components/Card/utils.tsx @@ -17,14 +17,10 @@ export type CardActionChildProps = { export const getCardCoverStyle = ({ theme, - index: _index, - total: _total, borderRadiusStyles, }: { theme: InternalTheme; borderRadiusStyles: BorderRadiusStyles; - index?: number; - total?: number; }) => { if (Object.keys(borderRadiusStyles).length > 0) { return { diff --git a/src/components/__tests__/Card/Card.test.tsx b/src/components/__tests__/Card/Card.test.tsx index 61659c8a24..49a0c7a862 100644 --- a/src/components/__tests__/Card/Card.test.tsx +++ b/src/components/__tests__/Card/Card.test.tsx @@ -91,6 +91,19 @@ describe('Card', () => { expect(screen.getByTestId('card')).toHaveStyle(styles.contentStyle); }); + it('clips inner content to the card shape', async () => { + await render( + + + + ); + + expect(screen.getByTestId('card')).toHaveStyle({ + borderRadius: getTheme().shapes.corner.medium, + overflow: 'hidden', + }); + }); + it('does not render a disabled accessibility state', async () => { await render({null}); @@ -141,6 +154,43 @@ describe('CardActions', () => { ).toBe('contained'); }); + it('does not inject default button props', async () => { + await render( + + + + + + + ); + + const [cancelButton, agreeButton] = + // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion. + screen.getByTestId('card-actions').props.children; + + expect(cancelButton.props.mode).toBeUndefined(); + expect(cancelButton.props.compact).toBeUndefined(); + expect(agreeButton.props.mode).toBeUndefined(); + expect(agreeButton.props.compact).toBeUndefined(); + }); + + it('renders actions in a styled row', async () => { + await render( + + + + + + + ); + + expect(screen.getByTestId('card-actions')).toHaveStyle({ + flexDirection: 'row', + gap: 8, + justifyContent: 'flex-end', + }); + }); + it('renders button with custom styles', async () => { await render( @@ -224,6 +274,25 @@ describe('getCardCoverStyle - border radius', () => { }); }); +describe('CardContent', () => { + it('renders uniform vertical padding regardless of neighboring sections', async () => { + await render( + + + + + Card content + + + ); + + expect(screen.getByTestId('card-content')).toHaveStyle({ + paddingTop: 16, + paddingBottom: 16, + }); + }); +}); + it('animated value changes correctly', async () => { const value = new Animated.Value(1); await render( diff --git a/src/components/__tests__/Card/__snapshots__/Card.test.tsx.snap b/src/components/__tests__/Card/__snapshots__/Card.test.tsx.snap index d1492cc475..46c1359b6d 100644 --- a/src/components/__tests__/Card/__snapshots__/Card.test.tsx.snap +++ b/src/components/__tests__/Card/__snapshots__/Card.test.tsx.snap @@ -62,6 +62,10 @@ exports[`Card renders an outlined card 1`] = ` [ { "flexShrink": 1, + "overflow": "hidden", + }, + { + "borderRadius": 12, }, undefined, ] From 2d97fbc095b54be0f2f0d6eb9d3e7e86dc55dd4b Mon Sep 17 00:00:00 2001 From: matkoson Date: Tue, 23 Jun 2026 15:15:57 +0200 Subject: [PATCH 04/11] refactor(toggle-button): style row buttons via context ToggleButton.Row used React.Children.count/map + cloneElement to inject a first/middle/last border radius into each button by position, which only works when the buttons are direct children. Replace it with ToggleButtonRowContext: the row flags its descendants as segmented, each ToggleButton reads the flag and renders the flat segment shape itself, and the row clips a single rounded container with hairline dividers between segments. BREAKING CHANGE: ToggleButton.Row no longer clones its children to inject per-segment border radii. Segment styling now comes from ToggleButtonRowContext, so the buttons may be wrapped or conditionally rendered and still pick up the segmented appearance. Refs #4989 --- src/components/ToggleButton/ToggleButton.tsx | 7 ++ .../ToggleButton/ToggleButtonRow.tsx | 82 +++++++++---------- .../ToggleButton/ToggleButtonRowContext.tsx | 8 ++ .../__tests__/ToggleButton.test.tsx | 28 ++++++- 4 files changed, 80 insertions(+), 45 deletions(-) create mode 100644 src/components/ToggleButton/ToggleButtonRowContext.tsx diff --git a/src/components/ToggleButton/ToggleButton.tsx b/src/components/ToggleButton/ToggleButton.tsx index 22598c16b2..b21528cca3 100644 --- a/src/components/ToggleButton/ToggleButton.tsx +++ b/src/components/ToggleButton/ToggleButton.tsx @@ -3,6 +3,7 @@ import { StyleSheet, View, Animated } from 'react-native'; import type { GestureResponderEvent, StyleProp, ViewStyle } from 'react-native'; import { ToggleButtonGroupContext } from './ToggleButtonGroup'; +import { ToggleButtonRowContext } from './ToggleButtonRowContext'; import { getToggleButtonColor } from './utils'; import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../types'; @@ -99,6 +100,8 @@ const ToggleButton = ({ }: Props) => { const theme = useInternalTheme(themeOverrides); const borderRadius = theme.shapes.corner.extraSmall; + const rowContext = React.useContext(ToggleButtonRowContext); + const isSegmentedRow = !!rowContext?.segmented; return ( @@ -134,6 +137,7 @@ const ToggleButton = ({ borderRadius, borderColor, }, + isSegmentedRow && styles.segmentedContent, style, ]} ref={ref} @@ -152,6 +156,9 @@ const styles = StyleSheet.create({ height: 42, margin: 0, }, + segmentedContent: { + borderRadius: 0, + }, }); export default ToggleButton; diff --git a/src/components/ToggleButton/ToggleButtonRow.tsx b/src/components/ToggleButton/ToggleButtonRow.tsx index 46e6aee48f..121e8e3d2d 100644 --- a/src/components/ToggleButton/ToggleButtonRow.tsx +++ b/src/components/ToggleButton/ToggleButtonRow.tsx @@ -2,8 +2,10 @@ import * as React from 'react'; import { StyleSheet, View } from 'react-native'; import type { StyleProp, ViewStyle } from 'react-native'; -import ToggleButton from './ToggleButton'; import ToggleButtonGroup from './ToggleButtonGroup'; +import { ToggleButtonRowContext } from './ToggleButtonRowContext'; +import { useInternalTheme } from '../../core/theming'; +import type { ThemeProp } from '../../types'; export type Props = { /** @@ -19,8 +21,14 @@ export type Props = { */ children: React.ReactNode; style?: StyleProp; + /** + * @optional + */ + theme?: ThemeProp; }; +const SEGMENTED_ROW_CONTEXT = { segmented: true }; + /** * Toggle button row renders a group of toggle buttons in a row. * @@ -44,33 +52,34 @@ export type Props = { * *``` */ -const ToggleButtonRow = ({ value, onValueChange, children, style }: Props) => { - const count = React.Children.count(children); +const ToggleButtonRow = ({ + value, + onValueChange, + children, + style, + theme: themeOverrides, +}: Props) => { + const theme = useInternalTheme(themeOverrides); + const borderRadius = theme.shapes.corner.extraSmall; + const outlineColor = theme.colors.outline; return ( - - {React.Children.map(children, (child, i) => { - // @ts-expect-error: TypeScript complains about child.type but it doesn't matter - if (child && child.type === ToggleButton) { - // @ts-expect-error: We're sure that child is a React Element - return React.cloneElement(child, { - style: [ - styles.button, - i === 0 - ? styles.first - : i === count - 1 - ? styles.last - : styles.middle, - // @ts-expect-error: We're sure that child is a React Element - child.props.style, - ], - }); - } - - return child; - })} - + + + + {children} + + + ); }; @@ -80,25 +89,10 @@ ToggleButtonRow.displayName = 'ToggleButton.Row'; const styles = StyleSheet.create({ row: { flexDirection: 'row', - }, - button: { - borderWidth: StyleSheet.hairlineWidth, - }, - - first: { - borderTopRightRadius: 0, - borderBottomRightRadius: 0, - }, - - middle: { - borderRadius: 0, - borderLeftWidth: 0, - }, - - last: { - borderLeftWidth: 0, - borderTopLeftRadius: 0, - borderBottomLeftRadius: 0, + alignSelf: 'flex-start', + gap: StyleSheet.hairlineWidth, + overflow: 'hidden', + padding: StyleSheet.hairlineWidth, }, }); diff --git a/src/components/ToggleButton/ToggleButtonRowContext.tsx b/src/components/ToggleButton/ToggleButtonRowContext.tsx new file mode 100644 index 0000000000..ff37016102 --- /dev/null +++ b/src/components/ToggleButton/ToggleButtonRowContext.tsx @@ -0,0 +1,8 @@ +import * as React from 'react'; + +type ToggleButtonRowContextType = { + segmented: boolean; +}; + +export const ToggleButtonRowContext = + React.createContext(null); diff --git a/src/components/__tests__/ToggleButton.test.tsx b/src/components/__tests__/ToggleButton.test.tsx index 1ea9e20bae..0049c59371 100644 --- a/src/components/__tests__/ToggleButton.test.tsx +++ b/src/components/__tests__/ToggleButton.test.tsx @@ -1,4 +1,4 @@ -import { Animated } from 'react-native'; +import { Animated, View } from 'react-native'; import { describe, expect, it, jest } from '@jest/globals'; import { act } from '@testing-library/react-native'; @@ -36,6 +36,32 @@ it('renders unchecked toggle button', async () => { expect(tree).toMatchSnapshot(); }); +it('renders row buttons with segmented styling through context', async () => { + await render( + {}}> + + + + + + ); + + expect(screen.getByTestId('wrapped-toggle-container')).toHaveStyle({ + borderRadius: 0, + }); + expect(screen.getByTestId('direct-toggle-container')).toHaveStyle({ + borderRadius: 0, + }); +}); + describe('getToggleButtonColor', () => { it('should return correct color when checked and theme version 3', () => { expect(getToggleButtonColor({ theme: getTheme(), checked: true })).toBe( From 012f27f9817fa76152196cc86c8f86e67a7e23d3 Mon Sep 17 00:00:00 2001 From: matkoson Date: Mon, 29 Jun 2026 22:53:40 +0200 Subject: [PATCH 05/11] test: align composition tests with upstream utilities --- src/components/__tests__/Card/Card.test.tsx | 45 ++++++++++++------- src/components/__tests__/Dialog.test.tsx | 26 +++++++---- .../__tests__/ListAccordion.test.tsx | 18 +++++--- 3 files changed, 57 insertions(+), 32 deletions(-) diff --git a/src/components/__tests__/Card/Card.test.tsx b/src/components/__tests__/Card/Card.test.tsx index 49a0c7a862..c45132c0eb 100644 --- a/src/components/__tests__/Card/Card.test.tsx +++ b/src/components/__tests__/Card/Card.test.tsx @@ -1,3 +1,4 @@ +import type { ComponentProps } from 'react'; import { Animated, StyleSheet, Text } from 'react-native'; import { describe, expect, it, jest } from '@jest/globals'; @@ -140,38 +141,50 @@ describe('CardCover', () => { describe('CardActions', () => { it('renders button with passed mode', async () => { + const buttonProps = jest.fn(); + const ProbeButton = (props: ComponentProps) => { + buttonProps(props); + + return + + Agree ); - 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'); + expect(buttonProps).toHaveBeenCalledWith( + expect.objectContaining({ mode: 'contained' }) + ); }); it('does not inject default button props', async () => { + const buttonProps = jest.fn(); + const ProbeButton = (props: ComponentProps) => { + buttonProps(props); + + return - + + Cancel + Agree ); - const [cancelButton, agreeButton] = - // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion. - screen.getByTestId('card-actions').props.children; + const [cancelButtonProps] = buttonProps.mock.calls[0]; + const [agreeButtonProps] = buttonProps.mock.calls[1]; - expect(cancelButton.props.mode).toBeUndefined(); - expect(cancelButton.props.compact).toBeUndefined(); - expect(agreeButton.props.mode).toBeUndefined(); - expect(agreeButton.props.compact).toBeUndefined(); + expect(cancelButtonProps).not.toHaveProperty('mode'); + expect(cancelButtonProps).not.toHaveProperty('compact'); + expect(agreeButtonProps).not.toHaveProperty('mode'); + expect(agreeButtonProps).not.toHaveProperty('compact'); }); it('renders actions in a styled row', async () => { diff --git a/src/components/__tests__/Dialog.test.tsx b/src/components/__tests__/Dialog.test.tsx index a0c5fab39b..9ba64a82c3 100644 --- a/src/components/__tests__/Dialog.test.tsx +++ b/src/components/__tests__/Dialog.test.tsx @@ -1,3 +1,4 @@ +import type { ComponentProps } from 'react'; import { Text, StyleSheet, @@ -177,20 +178,27 @@ describe('DialogActions', () => { }); it('should not inject button props into actions', async () => { + const buttonProps = jest.fn(); + const ProbeButton = (props: ComponentProps) => { + buttonProps(props); + + return - + + Cancel + Ok ); - const dialogActionsContainer = screen.getByTestId('dialog-actions'); - const [cancelButton, okButton] = dialogActionsContainer.props.children; + const [cancelButtonProps] = buttonProps.mock.calls[0]; + const [okButtonProps] = buttonProps.mock.calls[1]; - expect(cancelButton.props.compact).toBeUndefined(); - expect(cancelButton.props.uppercase).toBeUndefined(); - expect(okButton.props.compact).toBeUndefined(); - expect(okButton.props.uppercase).toBeUndefined(); + expect(cancelButtonProps).not.toHaveProperty('compact'); + expect(cancelButtonProps).not.toHaveProperty('uppercase'); + expect(okButtonProps).not.toHaveProperty('compact'); + expect(okButtonProps).not.toHaveProperty('uppercase'); }); it('should apply custom styles', async () => { diff --git a/src/components/__tests__/ListAccordion.test.tsx b/src/components/__tests__/ListAccordion.test.tsx index 7733ce09f3..dddcb21de1 100644 --- a/src/components/__tests__/ListAccordion.test.tsx +++ b/src/components/__tests__/ListAccordion.test.tsx @@ -3,7 +3,7 @@ import { StyleSheet, View } from 'react-native'; import { describe, expect, it } from '@jest/globals'; import { getTheme } from '../../core/theming'; -import { render } from '../../test-utils'; +import { render, screen } from '../../test-utils'; import { red500 } from '../../theme/colors'; import ListAccordion from '../List/ListAccordion'; import ListAccordionGroup from '../List/ListAccordionGroup'; @@ -94,8 +94,8 @@ it('renders list accordion with custom title and description styles', async () = expect(tree).toMatchSnapshot(); }); -it('indents expanded accordion children without their own left/right when the accordion has a left icon', () => { - const { getByTestId } = render( +it('indents expanded accordion children without their own left/right when the accordion has a left icon', async () => { + await render( } title="Accordion with indented children" @@ -105,17 +105,21 @@ it('indents expanded accordion children without their own left/right when the ac ); - expect(getByTestId('accordion-child')).toHaveStyle({ paddingLeft: 40 }); + expect(screen.getByTestId('accordion-child')).toHaveStyle({ + paddingLeft: 40, + }); }); -it('does not indent expanded accordion children when the accordion has no left icon', () => { - const { getByTestId } = render( +it('does not indent expanded accordion children when the accordion has no left icon', async () => { + await render( ); - expect(getByTestId('accordion-child')).not.toHaveStyle({ paddingLeft: 40 }); + expect(screen.getByTestId('accordion-child')).not.toHaveStyle({ + paddingLeft: 40, + }); }); describe('ListAccordion', () => { From 426827fe4b39b0e721290f20e5a32e4bd5188150 Mon Sep 17 00:00:00 2001 From: matkoson Date: Tue, 30 Jun 2026 00:06:19 +0200 Subject: [PATCH 06/11] fix: preserve dialog icon title spacing --- src/components/Dialog/DialogIcon.tsx | 1 + src/components/__tests__/Dialog.test.tsx | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/components/Dialog/DialogIcon.tsx b/src/components/Dialog/DialogIcon.tsx index 62935052ff..46e70be39d 100644 --- a/src/components/Dialog/DialogIcon.tsx +++ b/src/components/Dialog/DialogIcon.tsx @@ -93,6 +93,7 @@ const styles = StyleSheet.create({ wrapper: { alignItems: 'center', justifyContent: 'center', + marginBottom: 16, paddingTop: 0, }, }); diff --git a/src/components/__tests__/Dialog.test.tsx b/src/components/__tests__/Dialog.test.tsx index 9ba64a82c3..c35d614469 100644 --- a/src/components/__tests__/Dialog.test.tsx +++ b/src/components/__tests__/Dialog.test.tsx @@ -142,6 +142,25 @@ describe('Dialog', () => { paddingTop: 0, }); }); + + it('should preserve the icon-to-title spacing for an icon dialog', async () => { + await render( + + + + Test Dialog Content + + + ); + + expect(screen.getByTestId('dialog-icon')).toHaveStyle({ + marginBottom: 16, + paddingTop: 0, + }); + expect(screen.getByTestId('dialog-title')).toHaveStyle({ + marginTop: 0, + }); + }); }); describe('DialogActions', () => { From d2f5f61de30d161a3d0602b6501b9ac3251ee0e6 Mon Sep 17 00:00:00 2001 From: matkoson Date: Tue, 30 Jun 2026 14:09:25 +0200 Subject: [PATCH 07/11] refactor(toggle-button): align segmented row to MD3 spec Use the MD3 segmented-button shape token (corner.largeIncreased) for the row container and secondaryContainer for the selected segment, matching the existing SegmentedButtons component. Full per-segment alignment (first/middle/last radius as in SegmentedButtons) is intentionally NOT done: it would require positional child inspection, the React.Children anti-pattern this refactor removes. The container-clip + divider approach keeps the segmented look composition-friendly. Refs #4989 --- src/components/ToggleButton/ToggleButton.tsx | 5 ++++- src/components/ToggleButton/ToggleButtonRow.tsx | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/ToggleButton/ToggleButton.tsx b/src/components/ToggleButton/ToggleButton.tsx index b21528cca3..15ea8866e5 100644 --- a/src/components/ToggleButton/ToggleButton.tsx +++ b/src/components/ToggleButton/ToggleButton.tsx @@ -109,7 +109,10 @@ const ToggleButton = ({ const checked: boolean | null = (context && context.value === value) || status === 'checked'; - const backgroundColor = getToggleButtonColor({ theme, checked }); + const backgroundColor = + isSegmentedRow && checked + ? theme.colors.secondaryContainer + : getToggleButtonColor({ theme, checked }); const borderColor = theme.colors.outline; return ( diff --git a/src/components/ToggleButton/ToggleButtonRow.tsx b/src/components/ToggleButton/ToggleButtonRow.tsx index 121e8e3d2d..8a9d9c313f 100644 --- a/src/components/ToggleButton/ToggleButtonRow.tsx +++ b/src/components/ToggleButton/ToggleButtonRow.tsx @@ -60,7 +60,7 @@ const ToggleButtonRow = ({ theme: themeOverrides, }: Props) => { const theme = useInternalTheme(themeOverrides); - const borderRadius = theme.shapes.corner.extraSmall; + const borderRadius = theme.shapes.corner.largeIncreased; const outlineColor = theme.colors.outline; return ( From 414bc1388b9e452e466fe257a544cb7a53e85ee2 Mon Sep 17 00:00:00 2001 From: matkoson Date: Tue, 30 Jun 2026 17:43:06 +0200 Subject: [PATCH 08/11] test(toggle-button): assert MD3 secondaryContainer selected fill in row --- src/components/__tests__/ToggleButton.test.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/components/__tests__/ToggleButton.test.tsx b/src/components/__tests__/ToggleButton.test.tsx index 0049c59371..9e3dd6dcb9 100644 --- a/src/components/__tests__/ToggleButton.test.tsx +++ b/src/components/__tests__/ToggleButton.test.tsx @@ -62,6 +62,22 @@ it('renders row buttons with segmented styling through context', async () => { }); }); +it('applies the MD3 secondaryContainer fill to the selected segment in a row', async () => { + await render( + {}}> + + + + ); + + expect(screen.getByTestId('selected-container')).toHaveStyle({ + backgroundColor: getTheme().colors.secondaryContainer, + }); + expect(screen.getByTestId('unselected-container')).toHaveStyle({ + backgroundColor: getTheme().colors.surfaceContainer, + }); +}); + describe('getToggleButtonColor', () => { it('should return correct color when checked and theme version 3', () => { expect(getToggleButtonColor({ theme: getTheme(), checked: true })).toBe( From 17199dfec43d4380c88dd997740e55dee14f933b Mon Sep 17 00:00:00 2001 From: matkoson Date: Tue, 30 Jun 2026 18:04:03 +0200 Subject: [PATCH 09/11] refactor(appbar): provide shared values via context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the `cloneElement` prop injection and `displayName`-based filtering in Appbar with an `AppbarContext` that exposes the shared `isDark` and `mode` values. `Appbar.Action`, `Appbar.BackAction` and `Appbar.Content` now read those values from context and derive their own foreground color, instead of having `color`/`mode`/`theme` spliced into them. - `small` and `center-aligned` render their children directly in author order; the title's `flex: 1` keeps trailing actions right-aligned and the center-aligned title centers itself. - `medium` and `large` keep the two-row layout (controls row above a full-width title) by partitioning children for placement only — no props are injected. - Drops the `React.Children.forEach` count heuristic that conditionally centered the title in `center-aligned`. BREAKING CHANGE: Appbar children are now rendered in the order they are written rather than being reordered to put the back action first. Place `Appbar.BackAction` before `Appbar.Content` and trailing `Appbar.Action`s after it, as shown in the docs. Children no longer receive injected `color`/`mode`/`theme` props; wrapping `Appbar.Content`/`Appbar.Action` in another element keeps working because the values come from context. --- src/components/Appbar/Appbar.tsx | 155 +++++------------- src/components/Appbar/AppbarAction.tsx | 11 +- src/components/Appbar/AppbarContent.tsx | 21 ++- src/components/Appbar/AppbarContext.tsx | 28 ++++ src/components/Appbar/utils.ts | 116 +------------ .../__tests__/Appbar/Appbar.test.tsx | 110 +------------ .../Appbar/__snapshots__/Appbar.test.tsx.snap | 9 +- 7 files changed, 100 insertions(+), 350 deletions(-) create mode 100644 src/components/Appbar/AppbarContext.tsx diff --git a/src/components/Appbar/Appbar.tsx b/src/components/Appbar/Appbar.tsx index 114ca59e20..ed047fae50 100644 --- a/src/components/Appbar/Appbar.tsx +++ b/src/components/Appbar/Appbar.tsx @@ -3,12 +3,8 @@ import { Animated, StyleSheet, View } from 'react-native'; import type { ColorValue, StyleProp, ViewProps, ViewStyle } from 'react-native'; import AppbarContent from './AppbarContent'; -import { - getAppbarBackgroundColor, - modeAppbarHeight, - renderAppbarContent, - filterAppbarActions, -} from './utils'; +import { AppbarContext } from './AppbarContext'; +import { getAppbarBackgroundColor, modeAppbarHeight } from './utils'; import type { AppbarModes, AppbarChildProps } from './utils'; import { useInternalTheme } from '../../core/theming'; import type { Elevation, ThemeProp } from '../../types'; @@ -174,38 +170,6 @@ const Appbar = ({ const isDark = typeof dark === 'boolean' ? dark : false; - const isCenterAlignedMode = isMode('center-aligned'); - - let shouldCenterContent = false; - let shouldAddLeftSpacing = false; - let shouldAddRightSpacing = false; - if (isCenterAlignedMode) { - let hasAppbarContent = false; - let leftItemsCount = 0; - let rightItemsCount = 0; - - React.Children.forEach(children, (child) => { - if (React.isValidElement(child)) { - const isLeading = child.props.isLeading === true; - - if (child.type === AppbarContent) { - hasAppbarContent = true; - } else if (isLeading || !hasAppbarContent) { - leftItemsCount++; - } else { - rightItemsCount++; - } - } - }); - - shouldCenterContent = - hasAppbarContent && leftItemsCount < 2 && rightItemsCount < 3; - shouldAddLeftSpacing = shouldCenterContent && leftItemsCount === 0; - shouldAddRightSpacing = shouldCenterContent && rightItemsCount === 0; - } - - const spacingStyle = styles.v3Spacing; - const insets = { paddingBottom: safeAreaInsets?.bottom, paddingTop: safeAreaInsets?.top, @@ -213,6 +177,41 @@ const Appbar = ({ paddingRight: safeAreaInsets?.right, }; + const appbarContextValue = React.useMemo( + () => ({ isDark, mode }), + [isDark, mode] + ); + + let content: React.ReactNode = children; + + if (isMode('medium') || isMode('large')) { + // Medium/large top app bars use a two-row layout: a controls row with the + // leading and trailing actions above a full-width title row. React Native + // flexbox has no `order`, so the title has to be separated from the actions + // structurally. We partition the children by element identity and the + // `isLeading` prop for layout only — nothing is injected into them; shared + // values flow through `AppbarContext`. + const items = React.Children.toArray(children).filter( + React.isValidElement + ) as React.ReactElement[]; + const titleItems = items.filter((child) => child.type === AppbarContent); + const actionItems = items.filter((child) => child.type !== AppbarContent); + const leadingActions = actionItems.filter((child) => child.props.isLeading); + const trailingActions = actionItems.filter( + (child) => !child.props.isLeading + ); + + content = ( + + + {leadingActions} + {trailingActions} + + {titleItems} + + ); + } + return ( - {shouldAddLeftSpacing ? : null} - {(isMode('small') || isMode('center-aligned')) && ( - <> - {/* Render only the back action at first place */} - {renderAppbarContent({ - children, - isDark, - theme, - renderOnly: ['Appbar.BackAction'], - shouldCenterContent: isCenterAlignedMode || shouldCenterContent, - })} - {/* Render the rest of the content except the back action */} - {renderAppbarContent({ - // Filter appbar actions - first leading icons, then trailing icons - children: [ - ...filterAppbarActions(children, true), - ...filterAppbarActions(children), - ], - isDark, - theme, - renderExcept: ['Appbar.BackAction'], - shouldCenterContent: isCenterAlignedMode || shouldCenterContent, - })} - - )} - {(isMode('medium') || isMode('large')) && ( - - {/* Appbar top row with controls */} - - {/* Left side of row container, can contain AppbarBackAction or AppbarAction if it's leading icon */} - {renderAppbarContent({ - children, - isDark, - renderOnly: ['Appbar.BackAction'], - mode, - })} - {renderAppbarContent({ - children: filterAppbarActions(children, true), - isDark, - renderOnly: ['Appbar.Action'], - mode, - })} - {/* Right side of row container, can contain other AppbarAction if they are not leading icons */} - - {renderAppbarContent({ - children: filterAppbarActions(children), - isDark, - renderExcept: [ - 'Appbar', - 'Appbar.BackAction', - 'Appbar.Content', - 'Appbar.Header', - ], - mode, - })} - - - {renderAppbarContent({ - children, - isDark, - renderOnly: ['Appbar.Content'], - mode, - })} - - )} - {shouldAddRightSpacing ? : null} + + {content} + ); }; @@ -309,9 +240,6 @@ const styles = StyleSheet.create({ alignItems: 'center', paddingHorizontal: 4, }, - v3Spacing: { - width: 52, - }, controlsRow: { flex: 1, flexDirection: 'row', @@ -328,9 +256,6 @@ const styles = StyleSheet.create({ flex: 1, paddingTop: 8, }, - centerAlignedContainer: { - paddingTop: 0, - }, }); export default Appbar; diff --git a/src/components/Appbar/AppbarAction.tsx b/src/components/Appbar/AppbarAction.tsx index 96e802a34f..944dcb5756 100644 --- a/src/components/Appbar/AppbarAction.tsx +++ b/src/components/Appbar/AppbarAction.tsx @@ -7,7 +7,9 @@ import type { ViewStyle, } from 'react-native'; +import { useAppbarContext } from './AppbarContext'; import { useInternalTheme } from '../../core/theming'; +import { white } from '../../theme/colors'; import type { Theme, ThemeProp } from '../../types'; import type { IconSource } from '../Icon'; import IconButton from '../IconButton/IconButton'; @@ -87,12 +89,15 @@ const AppbarAction = ({ }: Props) => { const theme = useInternalTheme(themeOverrides); const { colors } = theme as Theme; + const { isDark = false } = useAppbarContext() ?? {}; const actionIconColor = iconColor ? iconColor - : isLeading - ? colors.onSurface - : colors.onSurfaceVariant; + : isDark + ? white + : isLeading + ? colors.onSurface + : colors.onSurfaceVariant; return ( { const theme = useInternalTheme(themeOverrides); const { colors, fonts } = theme as Theme; + const { isDark = false, mode: contextMode } = useAppbarContext() ?? {}; + const mode = modeOverride ?? contextMode ?? 'small'; - const titleTextColor = titleColor ? titleColor : colors.onSurface; + const titleTextColor = titleColor + ? titleColor + : isDark + ? white + : colors.onSurface; const modeContainerStyles = { small: styles.v3DefaultContainer, medium: styles.v3MediumContainer, large: styles.v3LargeContainer, - 'center-aligned': styles.v3DefaultContainer, + 'center-aligned': styles.v3CenterAlignedContainer, }; const variant = modeTextVariant[mode] as TypescaleKey; @@ -177,17 +185,24 @@ const styles = StyleSheet.create({ }, v3DefaultContainer: { paddingHorizontal: 0, + marginLeft: 12, + }, + v3CenterAlignedContainer: { + paddingHorizontal: 0, + alignItems: 'center', }, v3MediumContainer: { paddingHorizontal: 0, justifyContent: 'flex-end', paddingBottom: 24, + marginLeft: 12, }, v3LargeContainer: { paddingHorizontal: 0, paddingTop: 36, justifyContent: 'flex-end', paddingBottom: 28, + marginLeft: 12, }, }); diff --git a/src/components/Appbar/AppbarContext.tsx b/src/components/Appbar/AppbarContext.tsx new file mode 100644 index 0000000000..dc09597a5c --- /dev/null +++ b/src/components/Appbar/AppbarContext.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; + +import type { AppbarModes } from './utils'; + +export type AppbarContextType = { + /** + * Whether the Appbar background is dark, so children can derive a + * contrasting default foreground color without it being injected. + */ + isDark: boolean; + /** + * The Appbar mode, consumed by `Appbar.Content` to pick the title text + * variant and container layout. + */ + mode: AppbarModes; +}; + +/** + * Shared Appbar values provided to `Appbar.Action`, `Appbar.BackAction` and + * `Appbar.Content` via context instead of being injected with `cloneElement`. + * This keeps composition intact: children can be wrapped, reordered or + * conditionally rendered without losing the values they need. + */ +export const AppbarContext = React.createContext( + null +); + +export const useAppbarContext = () => React.useContext(AppbarContext); diff --git a/src/components/Appbar/utils.ts b/src/components/Appbar/utils.ts index fbd2e7f461..9d61bc0f0e 100644 --- a/src/components/Appbar/utils.ts +++ b/src/components/Appbar/utils.ts @@ -1,16 +1,12 @@ -import React from 'react'; -import type { ColorValue, StyleProp, ViewStyle } from 'react-native'; -import { StyleSheet, Animated } from 'react-native'; +import { Animated } from 'react-native'; +import type { ColorValue, ViewStyle } from 'react-native'; -import { white } from '../../theme/colors'; -import type { InternalTheme, Theme, ThemeProp } from '../../types'; +import type { InternalTheme, Theme } from '../../types'; export type AppbarModes = 'small' | 'medium' | 'large' | 'center-aligned'; export type AppbarChildProps = { isLeading?: boolean; - color: string; - style?: StyleProp; }; const borderStyleProperties = [ @@ -39,21 +35,6 @@ export const getAppbarBackgroundColor = ( return colors.surface; }; -export const getAppbarColor = ({ - color, - isDark, -}: BaseProps & { color: string }) => { - if (typeof color !== 'undefined') { - return color; - } - - if (isDark) { - return white; - } - - return undefined; -}; - export const getAppbarBorders = ( style: | Animated.Value @@ -72,19 +53,6 @@ export const getAppbarBorders = ( return borders; }; -type BaseProps = { - isDark: boolean; -}; - -type RenderAppbarContentProps = BaseProps & { - children: React.ReactNode; - shouldCenterContent?: boolean; - renderOnly?: (string | boolean)[]; - renderExcept?: string[]; - mode?: AppbarModes; - theme?: ThemeProp; -}; - export const DEFAULT_APPBAR_HEIGHT = 56; const MD3_DEFAULT_APPBAR_HEIGHT = 64; @@ -101,81 +69,3 @@ export const modeTextVariant = { large: 'headlineMedium', 'center-aligned': 'titleLarge', } as const; - -export const filterAppbarActions = ( - children: React.ReactNode, - isLeading = false -) => { - return React.Children.toArray(children).filter((child) => { - if (!React.isValidElement(child)) return false; - return isLeading ? child.props.isLeading : !child.props.isLeading; - }); -}; - -export const renderAppbarContent = ({ - children, - isDark, - shouldCenterContent = false, - renderOnly, - renderExcept, - mode = 'small', - theme, -}: RenderAppbarContentProps) => { - return React.Children.toArray(children as React.ReactNode | React.ReactNode[]) - .filter((child) => child != null && typeof child !== 'boolean') - .filter((child) => - // @ts-expect-error: TypeScript complains about the type of type but it doesn't matter - renderExcept ? !renderExcept.includes(child.type.displayName) : child - ) - .filter((child) => - // @ts-expect-error: TypeScript complains about the type of type but it doesn't matter - renderOnly ? renderOnly.includes(child.type.displayName) : child - ) - .map((child, i) => { - if ( - !React.isValidElement(child) || - ![ - 'Appbar.Content', - 'Appbar.Action', - 'Appbar.BackAction', - 'Tooltip', - ].includes( - // @ts-expect-error: TypeScript complains about the type of type but it doesn't matter - child.type.displayName - ) - ) { - return child; - } - - const props: { - color?: string; - style?: StyleProp; - mode?: AppbarModes; - theme?: ThemeProp; - } = { - theme, - color: getAppbarColor({ color: child.props.color, isDark }), - }; - - // @ts-expect-error: TypeScript complains about the type of type but it doesn't matter - if (child.type.displayName === 'Appbar.Content') { - props.mode = mode; - props.style = [ - i === 0 && !shouldCenterContent && styles.v3Spacing, - shouldCenterContent && styles.centerAlignedContent, - child.props.style, - ]; - props.color; - } - return React.cloneElement(child, props); - }); -}; - -const styles = StyleSheet.create({ - centerAlignedContent: { - alignItems: 'center', - }, - v3Spacing: { - marginLeft: 12, - }, -}); diff --git a/src/components/__tests__/Appbar/Appbar.test.tsx b/src/components/__tests__/Appbar/Appbar.test.tsx index 8738f71e37..8f90ba201b 100644 --- a/src/components/__tests__/Appbar/Appbar.test.tsx +++ b/src/components/__tests__/Appbar/Appbar.test.tsx @@ -12,16 +12,10 @@ import { getAppbarBackgroundColor, getAppbarBorders, modeTextVariant, - renderAppbarContent as utilRenderAppbarContent, } from '../../Appbar/utils'; -import Menu from '../../Menu/Menu'; import Searchbar from '../../Searchbar'; import Text from '../../Typography/Text'; -const renderAppbarContent = utilRenderAppbarContent as ( - props: Parameters[0] -) => { props: any }[]; - describe('Appbar', () => { it('does not pass any additional props to Searchbar', async () => { const tree = ( @@ -50,109 +44,7 @@ describe('Appbar', () => { }); }); -describe('renderAppbarContent', () => { - const children = [ - {}} key={0} />, - , - {}} key={2} />, - {}} key={3} />, - ]; - - it('should render all children types if renderOnly is not specified', () => { - const result = renderAppbarContent({ - children, - isDark: false, - }); - - expect(result).toHaveLength(4); - }); - - it('should render all children types except specified in renderExcept', () => { - const result = renderAppbarContent({ - children: [ - ...children, - {}} />} - visible={false} - > - {null} - , - ], - isDark: false, - renderExcept: [ - 'Appbar', - 'Appbar.Header', - 'Appbar.BackAction', - 'Appbar.Content', - ], - }); - - expect(result).toHaveLength(3); - }); - - it('should render only children types specifed in renderOnly', () => { - const result = renderAppbarContent({ - children, - isDark: false, - renderOnly: ['Appbar.Action'], - }); - - expect(result).toHaveLength(2); - }); - - it('should render AppbarContent with correct mode', () => { - const result = renderAppbarContent({ - children, - isDark: false, - renderOnly: ['Appbar.Content'], - mode: 'large', - }); - - // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion. - expect(result[0].props.mode).toBe('large'); - }); - - it('should render centered AppbarContent', () => { - const result = renderAppbarContent({ - children, - isDark: false, - renderOnly: ['Appbar.Content'], - mode: 'center-aligned', - shouldCenterContent: true, - }); - - const centerAlignedContent = { - alignItems: 'center', - }; - - // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion. - expect(result[0].props.style).toEqual( - expect.arrayContaining([expect.objectContaining(centerAlignedContent)]) - ); - }); - - it('should render AppbarContent with correct spacings', () => { - const renderResult = (withAppbarBackAction = false) => - renderAppbarContent({ - children, - isDark: false, - renderOnly: [ - 'Appbar.Content', - withAppbarBackAction && 'Appbar.BackAction', - ], - }); - - const v3Spacing = { - marginLeft: 12, - }; - - // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion. - expect(renderResult()[0].props.style).toEqual( - expect.arrayContaining([expect.objectContaining(v3Spacing)]) - ); - }); - +describe('Appbar.Content accessibility', () => { it('Is recognized as a heading when no onPress callback has been passed', async () => { await render( diff --git a/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap b/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap index a5d9d95766..5d0be4d5e9 100644 --- a/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap +++ b/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap @@ -649,15 +649,10 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A "paddingHorizontal": 12, }, { + "marginLeft": 12, "paddingHorizontal": 0, }, - [ - { - "marginLeft": 12, - }, - false, - undefined, - ], + undefined, ] } testID="appbar-content" From b3d0b92c7ae0d4c17cbc70aacf4ad7dbd4878f36 Mon Sep 17 00:00:00 2001 From: matkoson Date: Wed, 1 Jul 2026 00:31:11 +0200 Subject: [PATCH 10/11] revert(toggle-button): drop MD3 redesign, keep legacy visual identity The composition refactor's job is to remove React.Children/cloneElement without changing what users see. A separate commit on this branch ("align segmented row to MD3 spec") went further: it changed ToggleButton.Row's corner radius (extraSmall -> largeIncreased) and the selected segment's fill (own color -> secondaryContainer), converging it toward the MD3 SegmentedButtons component. That redesign was scope creep. ToggleButton.Row was never styled to the MD3 SegmentedButtons spec upstream (it used surfaceContainerHighest / extraSmall corners, distinct from SegmentedButtons' secondaryContainer / largeIncreased) -- it's a separate, legacy component identity, not an under-spec implementation of SegmentedButtons. The acceptance criterion that triggered the MD3 pass ("double-check any affected component still follows the specs") is a non-regression check, not a redesign mandate. Revert ToggleButton.Row's corner back to theme.shapes.corner.extraSmall and ToggleButton's selected fill back to the unconditional getToggleButtonColor(...) (no row-specific override), matching the original upstream component exactly. The composition-safe technique from the prior commit (ToggleButtonRowContext, container-clip + gap divider) is untouched -- this only reverts the two MD3-redesign value changes on top of it, restoring a visually transparent composition refactor. Verified: tsc clean, eslint clean, ToggleButton suite 8/9 (the 1 failure is the pre-existing baseline animation test, unchanged). Pixel diff against the prior B+ screenshot confirms the change is real and scoped to exactly the Row control (4156px, corner + selected-fill only) with zero diff elsewhere on screen. --- src/components/ToggleButton/ToggleButton.tsx | 5 +---- src/components/ToggleButton/ToggleButtonRow.tsx | 2 +- src/components/__tests__/ToggleButton.test.tsx | 10 +++++++--- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/components/ToggleButton/ToggleButton.tsx b/src/components/ToggleButton/ToggleButton.tsx index 15ea8866e5..b21528cca3 100644 --- a/src/components/ToggleButton/ToggleButton.tsx +++ b/src/components/ToggleButton/ToggleButton.tsx @@ -109,10 +109,7 @@ const ToggleButton = ({ const checked: boolean | null = (context && context.value === value) || status === 'checked'; - const backgroundColor = - isSegmentedRow && checked - ? theme.colors.secondaryContainer - : getToggleButtonColor({ theme, checked }); + const backgroundColor = getToggleButtonColor({ theme, checked }); const borderColor = theme.colors.outline; return ( diff --git a/src/components/ToggleButton/ToggleButtonRow.tsx b/src/components/ToggleButton/ToggleButtonRow.tsx index 8a9d9c313f..121e8e3d2d 100644 --- a/src/components/ToggleButton/ToggleButtonRow.tsx +++ b/src/components/ToggleButton/ToggleButtonRow.tsx @@ -60,7 +60,7 @@ const ToggleButtonRow = ({ theme: themeOverrides, }: Props) => { const theme = useInternalTheme(themeOverrides); - const borderRadius = theme.shapes.corner.largeIncreased; + const borderRadius = theme.shapes.corner.extraSmall; const outlineColor = theme.colors.outline; return ( diff --git a/src/components/__tests__/ToggleButton.test.tsx b/src/components/__tests__/ToggleButton.test.tsx index 9e3dd6dcb9..0d8b6c1719 100644 --- a/src/components/__tests__/ToggleButton.test.tsx +++ b/src/components/__tests__/ToggleButton.test.tsx @@ -62,16 +62,20 @@ it('renders row buttons with segmented styling through context', async () => { }); }); -it('applies the MD3 secondaryContainer fill to the selected segment in a row', async () => { +it('applies the same selection color in a row as standalone (no row-specific override)', async () => { await render( {}}> - + ); expect(screen.getByTestId('selected-container')).toHaveStyle({ - backgroundColor: getTheme().colors.secondaryContainer, + backgroundColor: getTheme().colors.surfaceContainerHighest, }); expect(screen.getByTestId('unselected-container')).toHaveStyle({ backgroundColor: getTheme().colors.surfaceContainer, From 0d08f46d96f943fbde467e20dda7426934b7e656 Mon Sep 17 00:00:00 2001 From: matkoson Date: Thu, 2 Jul 2026 12:36:41 +0200 Subject: [PATCH 11/11] fix(toggle-button, dialog): address review feedback on composition refactor - DialogActions: destructure `theme`/`style`/`children` and forward only the remaining ViewProps to the native View, so the custom `theme` prop is not spread onto it (avoids react-native-web warnings and prop leakage). - ToggleButton.Row: apply the `style` prop to the segmented row container instead of an extra wrapper View, so callers can override the row's background, border radius and padding again. - ToggleButton.Row: replace the flexbox `gap` divider with a per-segment hairline `marginLeft` (dropping the row's left padding), so segment dividers render on React Native versions without `gap` support; the peer dependency is `react-native: "*"`. Refs #4989 --- src/components/Dialog/DialogActions.tsx | 13 +++++++--- src/components/ToggleButton/ToggleButton.tsx | 1 + .../ToggleButton/ToggleButtonRow.tsx | 25 +++++++++---------- 3 files changed, 22 insertions(+), 17 deletions(-) diff --git a/src/components/Dialog/DialogActions.tsx b/src/components/Dialog/DialogActions.tsx index c4eaa48c3f..457292ef2b 100644 --- a/src/components/Dialog/DialogActions.tsx +++ b/src/components/Dialog/DialogActions.tsx @@ -47,12 +47,17 @@ export type Props = ViewProps & { * export default MyComponent; * ``` */ -const DialogActions = (props: Props) => { - useInternalTheme(props.theme); +const DialogActions = ({ + theme: themeOverrides, + style, + children, + ...rest +}: Props) => { + useInternalTheme(themeOverrides); return ( - - {props.children} + + {children} ); }; diff --git a/src/components/ToggleButton/ToggleButton.tsx b/src/components/ToggleButton/ToggleButton.tsx index b21528cca3..7e1b040b2a 100644 --- a/src/components/ToggleButton/ToggleButton.tsx +++ b/src/components/ToggleButton/ToggleButton.tsx @@ -158,6 +158,7 @@ const styles = StyleSheet.create({ }, segmentedContent: { borderRadius: 0, + marginLeft: StyleSheet.hairlineWidth, }, }); diff --git a/src/components/ToggleButton/ToggleButtonRow.tsx b/src/components/ToggleButton/ToggleButtonRow.tsx index 121e8e3d2d..f4895f6fde 100644 --- a/src/components/ToggleButton/ToggleButtonRow.tsx +++ b/src/components/ToggleButton/ToggleButtonRow.tsx @@ -66,18 +66,17 @@ const ToggleButtonRow = ({ return ( - - - {children} - + + {children} @@ -90,9 +89,9 @@ const styles = StyleSheet.create({ row: { flexDirection: 'row', alignSelf: 'flex-start', - gap: StyleSheet.hairlineWidth, overflow: 'hidden', padding: StyleSheet.hairlineWidth, + paddingLeft: 0, }, });