From 8d5158c992975e0083a26d831aaf7b7f803e7687 Mon Sep 17 00:00:00 2001 From: matkoson Date: Thu, 18 Jun 2026 13:34:40 +0200 Subject: [PATCH 01/18] 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 1d2ab26559c3cd6a31b26a9babbd9d571fb41e71 Mon Sep 17 00:00:00 2001 From: matkoson Date: Tue, 23 Jun 2026 15:15:15 +0200 Subject: [PATCH 02/18] 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 a7b082fe89..16b14a371e 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 { 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 3ced9e12b0..dce9c16c23 100644 --- a/src/components/Dialog/DialogIcon.tsx +++ b/src/components/Dialog/DialogIcon.tsx @@ -23,6 +23,10 @@ export type Props = { * @optional */ theme?: ThemeProp; + /** + * testID to be used on tests. + */ + testID?: string; }; /** @@ -67,6 +71,7 @@ const DialogIcon = ({ color, icon, theme: themeOverrides, + testID, }: Props) => { const theme = useInternalTheme(themeOverrides); const { colors } = theme; @@ -75,7 +80,7 @@ const DialogIcon = ({ const iconColor = color || colors.secondary; return ( - + ); @@ -87,7 +92,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 ae553daee2..d997cadc32 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 32477e5fe1..ce2935929a 100644 --- a/src/components/__tests__/Dialog.test.tsx +++ b/src/components/__tests__/Dialog.test.tsx @@ -94,17 +94,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, }); }); }); @@ -134,11 +169,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 cd944b1c64eddc131f569720d642bb0da16934dc Mon Sep 17 00:00:00 2001 From: matkoson Date: Tue, 23 Jun 2026 15:15:42 +0200 Subject: [PATCH 03/18] 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 | 32 +++------ 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(+), 138 deletions(-) diff --git a/example/src/Examples/CardExample.tsx b/example/src/Examples/CardExample.tsx index 61c7ebe822..42ce70d91a 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 f0fdc89407..8cec1489eb 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,15 +187,6 @@ const Card = ({ runElevationAnimation('out'); }); - const total = React.Children.count(children); - const siblings = React.Children.map(children, (child) => - React.isValidElement(child) && child.type - ? typeof child.type !== 'string' && 'displayName' in child.type - ? child.type.displayName - : null - : null - ); - const { backgroundColor, borderColor: themedBorderColor } = getCardColors({ theme, mode: cardMode, @@ -215,17 +208,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} ); @@ -291,6 +278,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 529db00f52..aacbfe534c 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 = () => ( * * - * - * + * + * * * * ); @@ -46,23 +47,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} ); }; @@ -73,11 +58,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 f53ff034f5..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 dc348dbdd8..a84aa1e814 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) || {}; @@ -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 644c8f4c27..1703249401 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 d9e91d9d73..7a4c4c7a4e 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( @@ -225,6 +275,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 07cc50ea040961925d29654c7dcf5a0f6399170c Mon Sep 17 00:00:00 2001 From: matkoson Date: Tue, 23 Jun 2026 15:15:57 +0200 Subject: [PATCH 04/18] 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 7bca1e1267e48a21d11b37cef8c4692767669fec Mon Sep 17 00:00:00 2001 From: matkoson Date: Mon, 29 Jun 2026 22:53:40 +0200 Subject: [PATCH 05/18] 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 7a4c4c7a4e..0ef4ef0a58 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 ce2935929a..7a9c389618 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, @@ -178,20 +179,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 4aebe0c159023d3a55767fd3fa08b60845aa3ad4 Mon Sep 17 00:00:00 2001 From: matkoson Date: Tue, 30 Jun 2026 00:06:19 +0200 Subject: [PATCH 06/18] 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 dce9c16c23..e8ebc5b751 100644 --- a/src/components/Dialog/DialogIcon.tsx +++ b/src/components/Dialog/DialogIcon.tsx @@ -92,6 +92,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 7a9c389618..caff6488b1 100644 --- a/src/components/__tests__/Dialog.test.tsx +++ b/src/components/__tests__/Dialog.test.tsx @@ -143,6 +143,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 eec56a651b3f962cb431af899c7c7e8b4b527f60 Mon Sep 17 00:00:00 2001 From: matkoson Date: Tue, 30 Jun 2026 14:09:25 +0200 Subject: [PATCH 07/18] 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 62bb66302107389fe59b310e605769e4470deaf4 Mon Sep 17 00:00:00 2001 From: matkoson Date: Tue, 30 Jun 2026 17:43:06 +0200 Subject: [PATCH 08/18] 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 53906e3f29eaa5819db1f01b990d6a2846f93126 Mon Sep 17 00:00:00 2001 From: matkoson Date: Tue, 30 Jun 2026 18:04:03 +0200 Subject: [PATCH 09/18] 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 | 111 +------------ .../Appbar/__snapshots__/Appbar.test.tsx.snap | 9 +- 7 files changed, 100 insertions(+), 351 deletions(-) create mode 100644 src/components/Appbar/AppbarContext.tsx diff --git a/src/components/Appbar/Appbar.tsx b/src/components/Appbar/Appbar.tsx index 8ef9965819..8e58d4a812 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'; @@ -175,38 +171,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, @@ -214,6 +178,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} + ); }; @@ -310,9 +241,6 @@ const styles = StyleSheet.create({ alignItems: 'center', paddingHorizontal: 4, }, - v3Spacing: { - width: 52, - }, controlsRow: { flex: 1, flexDirection: 'row', @@ -329,9 +257,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 400271a988..4049e9f5e3 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 { 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; + 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; + 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]; @@ -172,17 +180,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 a6ea546b34..ffa5bb1f7a 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, ThemeProp } from '../../types'; +import type { InternalTheme } 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 @@ -73,19 +54,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; @@ -102,81 +70,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) - .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 27cd573f9d..8f90ba201b 100644 --- a/src/components/__tests__/Appbar/Appbar.test.tsx +++ b/src/components/__tests__/Appbar/Appbar.test.tsx @@ -12,17 +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'; -// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -const renderAppbarContent = utilRenderAppbarContent as ( - props: Parameters[0] -) => { props: any }[]; - describe('Appbar', () => { it('does not pass any additional props to Searchbar', async () => { const tree = ( @@ -51,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 0ee29f6515658b5dc76ebcd841192f3873c5db16 Mon Sep 17 00:00:00 2001 From: matkoson Date: Wed, 1 Jul 2026 00:31:11 +0200 Subject: [PATCH 10/18] 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 08c27f14692efb92411fd09630a4cfe01ebcf2b4 Mon Sep 17 00:00:00 2001 From: matkoson Date: Thu, 2 Jul 2026 12:36:41 +0200 Subject: [PATCH 11/18] 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, }, }); From 31d02310d3942f164b83a16d65097b54be1c5e85 Mon Sep 17 00:00:00 2001 From: matkoson Date: Tue, 7 Jul 2026 01:38:59 +0200 Subject: [PATCH 12/18] fix(card, dialog, appbar): resolve remaining Copilot review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CardActions/DialogActions still used flexbox `gap` for inter-action spacing, the same RN-version-unsafe pattern already flagged and fixed in ToggleButtonRow. Since react-native is declared as a `*` peer dependency, `gap` silently drops on older RN versions, removing spacing between actions. Replace with explicit layout-only spacer Views rendered between children — no gap dependency, no prop injection into the action elements. Appbar's medium/large title-row partition compared `child.type === AppbarContent` directly, so a React.memo-wrapped Appbar.Content was never recognized and landed in the controls row instead of the title row — contradicting the composition goal that wrapped children keep working. Unwrap memo's `.type` before comparing. Also fixes two existing Card/Dialog tests that asserted the styled `gap` value directly and indexed action children positionally; they now assert via testID, decoupled from spacer siblings changing child count. --- src/components/Appbar/Appbar.tsx | 15 +++++++-- src/components/Card/CardActions.tsx | 12 +++++-- src/components/Dialog/DialogActions.tsx | 13 ++++++-- .../__tests__/Appbar/Appbar.test.tsx | 15 +++++++++ src/components/__tests__/Card/Card.test.tsx | 1 - src/components/__tests__/Dialog.test.tsx | 33 +++++++++++-------- 6 files changed, 69 insertions(+), 20 deletions(-) diff --git a/src/components/Appbar/Appbar.tsx b/src/components/Appbar/Appbar.tsx index 8e58d4a812..3fa3f67fe4 100644 --- a/src/components/Appbar/Appbar.tsx +++ b/src/components/Appbar/Appbar.tsx @@ -195,8 +195,19 @@ const Appbar = ({ 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 isAppbarContent = (child: React.ReactElement) => { + const { type } = child; + // React.memo(AppbarContent) wraps the component in an object whose + // `.type` holds the original component — unwrap it so memoized + // Content still lands in the title row. + const innerType = + typeof type === 'object' && type !== null && 'type' in type + ? (type as { type: unknown }).type + : type; + return innerType === AppbarContent; + }; + const titleItems = items.filter(isAppbarContent); + const actionItems = items.filter((child) => !isAppbarContent(child)); const leadingActions = actionItems.filter((child) => child.props.isLeading); const trailingActions = actionItems.filter( (child) => !child.props.isLeading diff --git a/src/components/Card/CardActions.tsx b/src/components/Card/CardActions.tsx index aacbfe534c..4ec2145f47 100644 --- a/src/components/Card/CardActions.tsx +++ b/src/components/Card/CardActions.tsx @@ -44,10 +44,16 @@ const CardActions = ({ theme, style, children, ...rest }: Props) => { { justifyContent: 'flex-end' } satisfies ViewStyle, style, ]; + const items = React.Children.toArray(children); return ( - {children} + {items.map((child, index) => ( + + {index > 0 && } + {child} + + ))} ); }; @@ -58,9 +64,11 @@ const styles = StyleSheet.create({ container: { flexDirection: 'row', alignItems: 'center', - gap: 8, padding: 8, }, + spacer: { + width: 8, + }, }); export default CardActions; diff --git a/src/components/Dialog/DialogActions.tsx b/src/components/Dialog/DialogActions.tsx index 457292ef2b..5a91f0293a 100644 --- a/src/components/Dialog/DialogActions.tsx +++ b/src/components/Dialog/DialogActions.tsx @@ -55,9 +55,16 @@ const DialogActions = ({ }: Props) => { useInternalTheme(themeOverrides); + const items = React.Children.toArray(children); + return ( - {children} + {items.map((child, index) => ( + + {index > 0 && } + {child} + + ))} ); }; @@ -70,10 +77,12 @@ const styles = StyleSheet.create({ flexGrow: 1, alignItems: 'center', justifyContent: 'flex-end', - gap: 8, paddingBottom: 24, paddingHorizontal: 24, }, + spacer: { + width: 8, + }, }); export default DialogActions; diff --git a/src/components/__tests__/Appbar/Appbar.test.tsx b/src/components/__tests__/Appbar/Appbar.test.tsx index 8f90ba201b..564f76bd5e 100644 --- a/src/components/__tests__/Appbar/Appbar.test.tsx +++ b/src/components/__tests__/Appbar/Appbar.test.tsx @@ -1,3 +1,4 @@ +import * as React from 'react'; import { Animated } from 'react-native'; import { describe, expect, it, jest } from '@jest/globals'; @@ -42,6 +43,20 @@ describe('Appbar', () => { expect(tree).toMatchSnapshot(); }); + + it('places a memoized Appbar.Content in the title row in medium/large mode', async () => { + const MemoContent = React.memo(Appbar.Content); + + await render( + + {}} /> + + {}} /> + + ); + + expect(screen.getByRole('heading')).toHaveTextContent('Memoized title'); + }); }); describe('Appbar.Content accessibility', () => { diff --git a/src/components/__tests__/Card/Card.test.tsx b/src/components/__tests__/Card/Card.test.tsx index 0ef4ef0a58..c1384d2cd0 100644 --- a/src/components/__tests__/Card/Card.test.tsx +++ b/src/components/__tests__/Card/Card.test.tsx @@ -199,7 +199,6 @@ describe('CardActions', () => { expect(screen.getByTestId('card-actions')).toHaveStyle({ flexDirection: 'row', - gap: 8, justifyContent: 'flex-end', }); }); diff --git a/src/components/__tests__/Dialog.test.tsx b/src/components/__tests__/Dialog.test.tsx index caff6488b1..6fd8b12615 100644 --- a/src/components/__tests__/Dialog.test.tsx +++ b/src/components/__tests__/Dialog.test.tsx @@ -180,21 +180,23 @@ describe('DialogActions', () => { it('should apply default styles', async () => { await render( - - + + ); const dialogActionsContainer = screen.getByTestId('dialog-actions'); - const dialogActionButtons = dialogActionsContainer.children; expect(dialogActionsContainer).toHaveStyle({ - gap: 8, paddingBottom: 24, paddingHorizontal: 24, }); - expect(dialogActionButtons[0]).not.toHaveStyle({ marginRight: 8 }); - expect(dialogActionButtons[1]).not.toHaveStyle({ marginRight: 0 }); + expect(screen.getByTestId('button-cancel-container')).not.toHaveStyle({ + marginRight: 8, + }); + expect(screen.getByTestId('button-ok-container')).not.toHaveStyle({ + marginRight: 0, + }); }); it('should not inject button props into actions', async () => { @@ -224,16 +226,21 @@ describe('DialogActions', () => { it('should apply custom styles', async () => { await render( - - + + ); - const dialogActionsContainer = screen.getByTestId('dialog-actions'); - const dialogActionButtons = dialogActionsContainer.children; - - expect(dialogActionButtons[0]).toHaveStyle({ margin: 10 }); - expect(dialogActionButtons[1]).toHaveStyle({ margin: 0 }); + expect(screen.getByTestId('button-cancel-container')).toHaveStyle({ + margin: 10, + }); + expect(screen.getByTestId('button-ok-container')).toHaveStyle({ + margin: 0, + }); }); }); From a922bed14f38fd8b4c8fdc4b37238796af8c5653 Mon Sep 17 00:00:00 2001 From: Adam Sajko Date: Tue, 25 Aug 2026 14:02:40 +0200 Subject: [PATCH 13/18] test: stabilise the Tooltip pressOut timing --- src/components/__tests__/Tooltip.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/__tests__/Tooltip.test.tsx b/src/components/__tests__/Tooltip.test.tsx index 75b4a18cf7..43904e5bd5 100644 --- a/src/components/__tests__/Tooltip.test.tsx +++ b/src/components/__tests__/Tooltip.test.tsx @@ -163,7 +163,7 @@ describe('Tooltip', () => { it('hides the tooltip when the user stop pressing the component', async () => { const { wrapper: { queryByText, getByText, findByText }, - } = await setup({ enterTouchDelay: 50, leaveTouchDelay: 0 }); + } = await setup({ enterTouchDelay: 50, leaveTouchDelay: 100 }); await userEvent.longPress(getTrigger(getByText)); From 33e5141a236c1d2b8b507a7580b24dceace8dbf6 Mon Sep 17 00:00:00 2001 From: Adam Sajko Date: Tue, 25 Aug 2026 14:02:59 +0200 Subject: [PATCH 14/18] chore: avoid unnecessary type assertion in Appbar --- src/components/Appbar/Appbar.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/Appbar/Appbar.tsx b/src/components/Appbar/Appbar.tsx index 3fa3f67fe4..c0c806eb93 100644 --- a/src/components/Appbar/Appbar.tsx +++ b/src/components/Appbar/Appbar.tsx @@ -193,8 +193,9 @@ const Appbar = ({ // `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[]; + (child): child is React.ReactElement => + React.isValidElement(child) + ); const isAppbarContent = (child: React.ReactElement) => { const { type } = child; // React.memo(AppbarContent) wraps the component in an object whose From 04aca35cb7faed7a89cfea360c9734ef346dff93 Mon Sep 17 00:00:00 2001 From: Adam Sajko Date: Tue, 25 Aug 2026 14:03:12 +0200 Subject: [PATCH 15/18] fix(card, dialog): preserve child keys in action rows --- src/components/Card/CardActions.tsx | 6 +++++- src/components/Dialog/DialogActions.tsx | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/components/Card/CardActions.tsx b/src/components/Card/CardActions.tsx index 4ec2145f47..05d136ee3b 100644 --- a/src/components/Card/CardActions.tsx +++ b/src/components/Card/CardActions.tsx @@ -49,7 +49,11 @@ const CardActions = ({ theme, style, children, ...rest }: Props) => { return ( {items.map((child, index) => ( - + {index > 0 && } {child} diff --git a/src/components/Dialog/DialogActions.tsx b/src/components/Dialog/DialogActions.tsx index 5a91f0293a..3cc9f73714 100644 --- a/src/components/Dialog/DialogActions.tsx +++ b/src/components/Dialog/DialogActions.tsx @@ -60,7 +60,11 @@ const DialogActions = ({ return ( {items.map((child, index) => ( - + {index > 0 && } {child} From f7b86a0c428a4e1018c4a7cca6bda28aff1de59c Mon Sep 17 00:00:00 2001 From: Adam Sajko Date: Tue, 25 Aug 2026 14:03:24 +0200 Subject: [PATCH 16/18] docs: document Appbar, Card and Dialog breaking changes --- docs/6.x/docs/guides/migration.md | 86 ++++++++++++++++++++++++++ src/components/Appbar/AppbarAction.tsx | 2 +- 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index 1fcd32bd25..8313aa0d69 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -126,3 +126,89 @@ const theme = { style={{ fontSize: 16, color: '#1C1B1F' }} /> ``` + +### Appbar + +#### Child order + +Children render in the order they are written. `Appbar.BackAction` is no longer moved to the front for you. + +```tsx +// Before (v5) + + + + + +// After (v6) + + + + +``` + +#### Injected props + +`Appbar` no longer injects `color`, `mode` and `theme` into its children. Those values come from context instead, so wrapping `Appbar.Content` or `Appbar.Action` in your own component keeps working. + +`theme` set on `Appbar` no longer reaches the children either. Pass it to the child that needs the override. + +```tsx +// Before (v5) + + + + +// After (v6) + + + +``` + +#### `isLeading` + +`isLeading` on `Appbar.Action` sets the icon color, and in `medium` and `large` it places the action on the leading side of the controls row. It no longer changes the order in `small` and `center-aligned`. + +### Card + +#### `Card.Actions` + +`Card.Actions` no longer assigns `mode` to its buttons, previously `outlined` for the first one and `contained` for the rest, and no longer injects `compact`. Set both on the buttons. + +```tsx +// Before (v5) + + + + + +// After (v6) + + + + +``` + +#### `Card.Content` + +`Card.Content` uses the same vertical padding everywhere. Before it changed depending on the sections next to it. + +### Dialog + +#### `Dialog.Actions` + +`Dialog.Actions` no longer injects `compact` and `uppercase` into the action buttons. Set them yourself if you want the old look. + +```tsx +// Before (v5) + + + + +// After (v6) + + + +``` diff --git a/src/components/Appbar/AppbarAction.tsx b/src/components/Appbar/AppbarAction.tsx index 4049e9f5e3..a50680eaaf 100644 --- a/src/components/Appbar/AppbarAction.tsx +++ b/src/components/Appbar/AppbarAction.tsx @@ -42,7 +42,7 @@ export type Props = React.ComponentPropsWithoutRef & { /** * @supported Available in v5.x with theme version 3 * - * Whether it's the leading button. Note: If `Appbar.BackAction` is present, it will be rendered before any `isLeading` icons. + * Whether it's the leading button. Sets the icon color, and in `medium` and `large` modes places the action on the leading side of the controls row. In `small` and `center-aligned` modes children render in the order they are written. */ isLeading?: boolean; style?: Animated.WithAnimatedValue>; From dd22197f55caba6562a7d5cda4274c64ac31e4ea Mon Sep 17 00:00:00 2001 From: Adam Sajko Date: Thu, 27 Aug 2026 13:25:17 +0200 Subject: [PATCH 17/18] revert(appbar): drop Appbar from this PR #4934 proposes an `actions` prop API for Appbar and #5075 implements it, so the children refactor here is superseded. Appbar sources, tests and snapshots go back to their state on main. --- src/components/Appbar/Appbar.tsx | 167 ++++++++++++------ src/components/Appbar/AppbarAction.tsx | 13 +- src/components/Appbar/AppbarContent.tsx | 21 +-- src/components/Appbar/AppbarContext.tsx | 28 --- src/components/Appbar/utils.ts | 116 +++++++++++- .../__tests__/Appbar/Appbar.test.tsx | 118 +++++++++++-- .../Appbar/__snapshots__/Appbar.test.tsx.snap | 9 +- 7 files changed, 348 insertions(+), 124 deletions(-) delete mode 100644 src/components/Appbar/AppbarContext.tsx diff --git a/src/components/Appbar/Appbar.tsx b/src/components/Appbar/Appbar.tsx index c0c806eb93..8ef9965819 100644 --- a/src/components/Appbar/Appbar.tsx +++ b/src/components/Appbar/Appbar.tsx @@ -3,8 +3,12 @@ import { Animated, StyleSheet, View } from 'react-native'; import type { ColorValue, StyleProp, ViewProps, ViewStyle } from 'react-native'; import AppbarContent from './AppbarContent'; -import { AppbarContext } from './AppbarContext'; -import { getAppbarBackgroundColor, modeAppbarHeight } from './utils'; +import { + getAppbarBackgroundColor, + modeAppbarHeight, + renderAppbarContent, + filterAppbarActions, +} from './utils'; import type { AppbarModes, AppbarChildProps } from './utils'; import { useInternalTheme } from '../../core/theming'; import type { Elevation, ThemeProp } from '../../types'; @@ -171,6 +175,38 @@ 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, @@ -178,53 +214,6 @@ 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( - (child): child is React.ReactElement => - React.isValidElement(child) - ); - const isAppbarContent = (child: React.ReactElement) => { - const { type } = child; - // React.memo(AppbarContent) wraps the component in an object whose - // `.type` holds the original component — unwrap it so memoized - // Content still lands in the title row. - const innerType = - typeof type === 'object' && type !== null && 'type' in type - ? (type as { type: unknown }).type - : type; - return innerType === AppbarContent; - }; - const titleItems = items.filter(isAppbarContent); - const actionItems = items.filter((child) => !isAppbarContent(child)); - const leadingActions = actionItems.filter((child) => child.props.isLeading); - const trailingActions = actionItems.filter( - (child) => !child.props.isLeading - ); - - content = ( - - - {leadingActions} - {trailingActions} - - {titleItems} - - ); - } - return ( - - {content} - + {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} ); }; @@ -253,6 +310,9 @@ const styles = StyleSheet.create({ alignItems: 'center', paddingHorizontal: 4, }, + v3Spacing: { + width: 52, + }, controlsRow: { flex: 1, flexDirection: 'row', @@ -269,6 +329,9 @@ 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 a50680eaaf..400271a988 100644 --- a/src/components/Appbar/AppbarAction.tsx +++ b/src/components/Appbar/AppbarAction.tsx @@ -7,9 +7,7 @@ import type { ViewStyle, } from 'react-native'; -import { useAppbarContext } from './AppbarContext'; import { useInternalTheme } from '../../core/theming'; -import { white } from '../../theme/colors'; import type { ThemeProp } from '../../types'; import type { IconSource } from '../Icon'; import IconButton from '../IconButton/IconButton'; @@ -42,7 +40,7 @@ export type Props = React.ComponentPropsWithoutRef & { /** * @supported Available in v5.x with theme version 3 * - * Whether it's the leading button. Sets the icon color, and in `medium` and `large` modes places the action on the leading side of the controls row. In `small` and `center-aligned` modes children render in the order they are written. + * Whether it's the leading button. Note: If `Appbar.BackAction` is present, it will be rendered before any `isLeading` icons. */ isLeading?: boolean; style?: Animated.WithAnimatedValue>; @@ -89,15 +87,12 @@ const AppbarAction = ({ }: Props) => { const theme = useInternalTheme(themeOverrides); const { colors } = theme; - const { isDark = false } = useAppbarContext() ?? {}; const actionIconColor = iconColor ? iconColor - : isDark - ? white - : isLeading - ? colors.onSurface - : colors.onSurfaceVariant; + : isLeading + ? colors.onSurface + : colors.onSurfaceVariant; return ( { const theme = useInternalTheme(themeOverrides); const { colors, fonts } = theme; - const { isDark = false, mode: contextMode } = useAppbarContext() ?? {}; - const mode = modeOverride ?? contextMode ?? 'small'; - const titleTextColor = titleColor - ? titleColor - : isDark - ? white - : colors.onSurface; + const titleTextColor = titleColor ? titleColor : colors.onSurface; const modeContainerStyles = { small: styles.v3DefaultContainer, medium: styles.v3MediumContainer, large: styles.v3LargeContainer, - 'center-aligned': styles.v3CenterAlignedContainer, + 'center-aligned': styles.v3DefaultContainer, }; const variant = modeTextVariant[mode]; @@ -180,24 +172,17 @@ 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 deleted file mode 100644 index dc09597a5c..0000000000 --- a/src/components/Appbar/AppbarContext.tsx +++ /dev/null @@ -1,28 +0,0 @@ -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 ffa5bb1f7a..a6ea546b34 100644 --- a/src/components/Appbar/utils.ts +++ b/src/components/Appbar/utils.ts @@ -1,12 +1,16 @@ -import { Animated } from 'react-native'; -import type { ColorValue, ViewStyle } from 'react-native'; +import React from 'react'; +import type { ColorValue, StyleProp, ViewStyle } from 'react-native'; +import { StyleSheet, Animated } from 'react-native'; -import type { InternalTheme } from '../../types'; +import { white } from '../../theme/colors'; +import type { InternalTheme, ThemeProp } from '../../types'; export type AppbarModes = 'small' | 'medium' | 'large' | 'center-aligned'; export type AppbarChildProps = { isLeading?: boolean; + color: string; + style?: StyleProp; }; const borderStyleProperties = [ @@ -35,6 +39,21 @@ 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 @@ -54,6 +73,19 @@ 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; @@ -70,3 +102,81 @@ 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) + .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 564f76bd5e..27cd573f9d 100644 --- a/src/components/__tests__/Appbar/Appbar.test.tsx +++ b/src/components/__tests__/Appbar/Appbar.test.tsx @@ -1,4 +1,3 @@ -import * as React from 'react'; import { Animated } from 'react-native'; import { describe, expect, it, jest } from '@jest/globals'; @@ -13,10 +12,17 @@ import { getAppbarBackgroundColor, getAppbarBorders, modeTextVariant, + renderAppbarContent as utilRenderAppbarContent, } from '../../Appbar/utils'; +import Menu from '../../Menu/Menu'; import Searchbar from '../../Searchbar'; import Text from '../../Typography/Text'; +// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion +const renderAppbarContent = utilRenderAppbarContent as ( + props: Parameters[0] +) => { props: any }[]; + describe('Appbar', () => { it('does not pass any additional props to Searchbar', async () => { const tree = ( @@ -43,23 +49,111 @@ describe('Appbar', () => { expect(tree).toMatchSnapshot(); }); +}); - it('places a memoized Appbar.Content in the title row in medium/large mode', async () => { - const MemoContent = React.memo(Appbar.Content); +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, + }); - await render( - - {}} /> - - {}} /> - + 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, + }; - expect(screen.getByRole('heading')).toHaveTextContent('Memoized title'); + // 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 5d0be4d5e9..a5d9d95766 100644 --- a/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap +++ b/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap @@ -649,10 +649,15 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A "paddingHorizontal": 12, }, { - "marginLeft": 12, "paddingHorizontal": 0, }, - undefined, + [ + { + "marginLeft": 12, + }, + false, + undefined, + ], ] } testID="appbar-content" From 4f718bb114774cb531ab618dffadb98d6f5fd682 Mon Sep 17 00:00:00 2001 From: Adam Sajko Date: Thu, 27 Aug 2026 13:25:35 +0200 Subject: [PATCH 18/18] docs: swap the Appbar migration notes for List.Accordion Appbar is out of this PR. List.Accordion used to indent any child without its own left or right element, that indent now comes from context and only List.Item reads it. --- docs/6.x/docs/guides/migration.md | 66 +++++++++++-------------------- 1 file changed, 24 insertions(+), 42 deletions(-) diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index 8313aa0d69..88ecc6fafe 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -127,48 +127,6 @@ const theme = { /> ``` -### Appbar - -#### Child order - -Children render in the order they are written. `Appbar.BackAction` is no longer moved to the front for you. - -```tsx -// Before (v5) - - - - - -// After (v6) - - - - -``` - -#### Injected props - -`Appbar` no longer injects `color`, `mode` and `theme` into its children. Those values come from context instead, so wrapping `Appbar.Content` or `Appbar.Action` in your own component keeps working. - -`theme` set on `Appbar` no longer reaches the children either. Pass it to the child that needs the override. - -```tsx -// Before (v5) - - - - -// After (v6) - - - -``` - -#### `isLeading` - -`isLeading` on `Appbar.Action` sets the icon color, and in `medium` and `large` it places the action on the leading side of the controls row. It no longer changes the order in `small` and `center-aligned`. - ### Card #### `Card.Actions` @@ -212,3 +170,27 @@ Children render in the order they are written. `Appbar.BackAction` is no longer ``` + +### List + +#### `List.Accordion` + +When `List.Accordion` has a `left` element, it used to indent every child that rendered no `left` or `right` of its own, whatever the child was. The indent now comes from context and only `List.Item` reads it, so a custom child keeps its own padding. Indent it yourself if you need the old alignment. + +```tsx +// Before (v5) + }> + + Custom row + + + +// After (v6) + }> + + Custom row + + +``` + +`theme` set on `List.Accordion` no longer reaches its children either. Pass it to the child that needs the override.