diff --git a/example/src/Examples/CardExample.tsx b/example/src/Examples/CardExample.tsx
index ad3451e8b0..9de8b5cbbc 100644
--- a/example/src/Examples/CardExample.tsx
+++ b/example/src/Examples/CardExample.tsx
@@ -75,8 +75,12 @@ const CardExample = () => {
-
-
+
+
@@ -104,10 +108,10 @@ const CardExample = () => {
/>
-
diff --git a/example/src/Examples/TeamDetails.tsx b/example/src/Examples/TeamDetails.tsx
index 5970274f31..463e5148d8 100644
--- a/example/src/Examples/TeamDetails.tsx
+++ b/example/src/Examples/TeamDetails.tsx
@@ -93,8 +93,12 @@ const News = () => {
- {}}>Share
- {}}>Read more
+ {}}>
+ Share
+
+ {}}>
+ Read more
+
@@ -110,8 +114,12 @@ const News = () => {
- {}}>Share
- {}}>Read more
+ {}}>
+ Share
+
+ {}}>
+ Read more
+
diff --git a/src/components/Appbar/Appbar.tsx b/src/components/Appbar/Appbar.tsx
index 114ca59e20..ed047fae50 100644
--- a/src/components/Appbar/Appbar.tsx
+++ b/src/components/Appbar/Appbar.tsx
@@ -3,12 +3,8 @@ import { Animated, StyleSheet, View } from 'react-native';
import type { ColorValue, StyleProp, ViewProps, ViewStyle } from 'react-native';
import AppbarContent from './AppbarContent';
-import {
- getAppbarBackgroundColor,
- modeAppbarHeight,
- renderAppbarContent,
- filterAppbarActions,
-} from './utils';
+import { AppbarContext } from './AppbarContext';
+import { getAppbarBackgroundColor, modeAppbarHeight } from './utils';
import type { AppbarModes, AppbarChildProps } from './utils';
import { useInternalTheme } from '../../core/theming';
import type { Elevation, ThemeProp } from '../../types';
@@ -174,38 +170,6 @@ const Appbar = ({
const isDark = typeof dark === 'boolean' ? dark : false;
- const isCenterAlignedMode = isMode('center-aligned');
-
- let shouldCenterContent = false;
- let shouldAddLeftSpacing = false;
- let shouldAddRightSpacing = false;
- if (isCenterAlignedMode) {
- let hasAppbarContent = false;
- let leftItemsCount = 0;
- let rightItemsCount = 0;
-
- React.Children.forEach(children, (child) => {
- if (React.isValidElement(child)) {
- const isLeading = child.props.isLeading === true;
-
- if (child.type === AppbarContent) {
- hasAppbarContent = true;
- } else if (isLeading || !hasAppbarContent) {
- leftItemsCount++;
- } else {
- rightItemsCount++;
- }
- }
- });
-
- shouldCenterContent =
- hasAppbarContent && leftItemsCount < 2 && rightItemsCount < 3;
- shouldAddLeftSpacing = shouldCenterContent && leftItemsCount === 0;
- shouldAddRightSpacing = shouldCenterContent && rightItemsCount === 0;
- }
-
- const spacingStyle = styles.v3Spacing;
-
const insets = {
paddingBottom: safeAreaInsets?.bottom,
paddingTop: safeAreaInsets?.top,
@@ -213,6 +177,41 @@ const Appbar = ({
paddingRight: safeAreaInsets?.right,
};
+ const appbarContextValue = React.useMemo(
+ () => ({ isDark, mode }),
+ [isDark, mode]
+ );
+
+ let content: React.ReactNode = children;
+
+ if (isMode('medium') || isMode('large')) {
+ // Medium/large top app bars use a two-row layout: a controls row with the
+ // leading and trailing actions above a full-width title row. React Native
+ // flexbox has no `order`, so the title has to be separated from the actions
+ // structurally. We partition the children by element identity and the
+ // `isLeading` prop for layout only — nothing is injected into them; shared
+ // values flow through `AppbarContext`.
+ const items = React.Children.toArray(children).filter(
+ React.isValidElement
+ ) as React.ReactElement[];
+ const titleItems = items.filter((child) => child.type === AppbarContent);
+ const actionItems = items.filter((child) => child.type !== AppbarContent);
+ const leadingActions = actionItems.filter((child) => child.props.isLeading);
+ const trailingActions = actionItems.filter(
+ (child) => !child.props.isLeading
+ );
+
+ content = (
+
+
+ {leadingActions}
+ {trailingActions}
+
+ {titleItems}
+
+ );
+ }
+
return (
- {shouldAddLeftSpacing ? : null}
- {(isMode('small') || isMode('center-aligned')) && (
- <>
- {/* Render only the back action at first place */}
- {renderAppbarContent({
- children,
- isDark,
- theme,
- renderOnly: ['Appbar.BackAction'],
- shouldCenterContent: isCenterAlignedMode || shouldCenterContent,
- })}
- {/* Render the rest of the content except the back action */}
- {renderAppbarContent({
- // Filter appbar actions - first leading icons, then trailing icons
- children: [
- ...filterAppbarActions(children, true),
- ...filterAppbarActions(children),
- ],
- isDark,
- theme,
- renderExcept: ['Appbar.BackAction'],
- shouldCenterContent: isCenterAlignedMode || shouldCenterContent,
- })}
- >
- )}
- {(isMode('medium') || isMode('large')) && (
-
- {/* Appbar top row with controls */}
-
- {/* Left side of row container, can contain AppbarBackAction or AppbarAction if it's leading icon */}
- {renderAppbarContent({
- children,
- isDark,
- renderOnly: ['Appbar.BackAction'],
- mode,
- })}
- {renderAppbarContent({
- children: filterAppbarActions(children, true),
- isDark,
- renderOnly: ['Appbar.Action'],
- mode,
- })}
- {/* Right side of row container, can contain other AppbarAction if they are not leading icons */}
-
- {renderAppbarContent({
- children: filterAppbarActions(children),
- isDark,
- renderExcept: [
- 'Appbar',
- 'Appbar.BackAction',
- 'Appbar.Content',
- 'Appbar.Header',
- ],
- mode,
- })}
-
-
- {renderAppbarContent({
- children,
- isDark,
- renderOnly: ['Appbar.Content'],
- mode,
- })}
-
- )}
- {shouldAddRightSpacing ? : null}
+
+ {content}
+
);
};
@@ -309,9 +240,6 @@ const styles = StyleSheet.create({
alignItems: 'center',
paddingHorizontal: 4,
},
- v3Spacing: {
- width: 52,
- },
controlsRow: {
flex: 1,
flexDirection: 'row',
@@ -328,9 +256,6 @@ const styles = StyleSheet.create({
flex: 1,
paddingTop: 8,
},
- centerAlignedContainer: {
- paddingTop: 0,
- },
});
export default Appbar;
diff --git a/src/components/Appbar/AppbarAction.tsx b/src/components/Appbar/AppbarAction.tsx
index 96e802a34f..944dcb5756 100644
--- a/src/components/Appbar/AppbarAction.tsx
+++ b/src/components/Appbar/AppbarAction.tsx
@@ -7,7 +7,9 @@ import type {
ViewStyle,
} from 'react-native';
+import { useAppbarContext } from './AppbarContext';
import { useInternalTheme } from '../../core/theming';
+import { white } from '../../theme/colors';
import type { Theme, ThemeProp } from '../../types';
import type { IconSource } from '../Icon';
import IconButton from '../IconButton/IconButton';
@@ -87,12 +89,15 @@ const AppbarAction = ({
}: Props) => {
const theme = useInternalTheme(themeOverrides);
const { colors } = theme as Theme;
+ const { isDark = false } = useAppbarContext() ?? {};
const actionIconColor = iconColor
? iconColor
- : isLeading
- ? colors.onSurface
- : colors.onSurfaceVariant;
+ : isDark
+ ? white
+ : isLeading
+ ? colors.onSurface
+ : colors.onSurfaceVariant;
return (
{
const theme = useInternalTheme(themeOverrides);
const { colors, fonts } = theme as Theme;
+ const { isDark = false, mode: contextMode } = useAppbarContext() ?? {};
+ const mode = modeOverride ?? contextMode ?? 'small';
- const titleTextColor = titleColor ? titleColor : colors.onSurface;
+ const titleTextColor = titleColor
+ ? titleColor
+ : isDark
+ ? white
+ : colors.onSurface;
const modeContainerStyles = {
small: styles.v3DefaultContainer,
medium: styles.v3MediumContainer,
large: styles.v3LargeContainer,
- 'center-aligned': styles.v3DefaultContainer,
+ 'center-aligned': styles.v3CenterAlignedContainer,
};
const variant = modeTextVariant[mode] as TypescaleKey;
@@ -177,17 +185,24 @@ const styles = StyleSheet.create({
},
v3DefaultContainer: {
paddingHorizontal: 0,
+ marginLeft: 12,
+ },
+ v3CenterAlignedContainer: {
+ paddingHorizontal: 0,
+ alignItems: 'center',
},
v3MediumContainer: {
paddingHorizontal: 0,
justifyContent: 'flex-end',
paddingBottom: 24,
+ marginLeft: 12,
},
v3LargeContainer: {
paddingHorizontal: 0,
paddingTop: 36,
justifyContent: 'flex-end',
paddingBottom: 28,
+ marginLeft: 12,
},
});
diff --git a/src/components/Appbar/AppbarContext.tsx b/src/components/Appbar/AppbarContext.tsx
new file mode 100644
index 0000000000..dc09597a5c
--- /dev/null
+++ b/src/components/Appbar/AppbarContext.tsx
@@ -0,0 +1,28 @@
+import * as React from 'react';
+
+import type { AppbarModes } from './utils';
+
+export type AppbarContextType = {
+ /**
+ * Whether the Appbar background is dark, so children can derive a
+ * contrasting default foreground color without it being injected.
+ */
+ isDark: boolean;
+ /**
+ * The Appbar mode, consumed by `Appbar.Content` to pick the title text
+ * variant and container layout.
+ */
+ mode: AppbarModes;
+};
+
+/**
+ * Shared Appbar values provided to `Appbar.Action`, `Appbar.BackAction` and
+ * `Appbar.Content` via context instead of being injected with `cloneElement`.
+ * This keeps composition intact: children can be wrapped, reordered or
+ * conditionally rendered without losing the values they need.
+ */
+export const AppbarContext = React.createContext(
+ null
+);
+
+export const useAppbarContext = () => React.useContext(AppbarContext);
diff --git a/src/components/Appbar/utils.ts b/src/components/Appbar/utils.ts
index fbd2e7f461..9d61bc0f0e 100644
--- a/src/components/Appbar/utils.ts
+++ b/src/components/Appbar/utils.ts
@@ -1,16 +1,12 @@
-import React from 'react';
-import type { ColorValue, StyleProp, ViewStyle } from 'react-native';
-import { StyleSheet, Animated } from 'react-native';
+import { Animated } from 'react-native';
+import type { ColorValue, ViewStyle } from 'react-native';
-import { white } from '../../theme/colors';
-import type { InternalTheme, Theme, ThemeProp } from '../../types';
+import type { InternalTheme, Theme } from '../../types';
export type AppbarModes = 'small' | 'medium' | 'large' | 'center-aligned';
export type AppbarChildProps = {
isLeading?: boolean;
- color: string;
- style?: StyleProp;
};
const borderStyleProperties = [
@@ -39,21 +35,6 @@ export const getAppbarBackgroundColor = (
return colors.surface;
};
-export const getAppbarColor = ({
- color,
- isDark,
-}: BaseProps & { color: string }) => {
- if (typeof color !== 'undefined') {
- return color;
- }
-
- if (isDark) {
- return white;
- }
-
- return undefined;
-};
-
export const getAppbarBorders = (
style:
| Animated.Value
@@ -72,19 +53,6 @@ export const getAppbarBorders = (
return borders;
};
-type BaseProps = {
- isDark: boolean;
-};
-
-type RenderAppbarContentProps = BaseProps & {
- children: React.ReactNode;
- shouldCenterContent?: boolean;
- renderOnly?: (string | boolean)[];
- renderExcept?: string[];
- mode?: AppbarModes;
- theme?: ThemeProp;
-};
-
export const DEFAULT_APPBAR_HEIGHT = 56;
const MD3_DEFAULT_APPBAR_HEIGHT = 64;
@@ -101,81 +69,3 @@ export const modeTextVariant = {
large: 'headlineMedium',
'center-aligned': 'titleLarge',
} as const;
-
-export const filterAppbarActions = (
- children: React.ReactNode,
- isLeading = false
-) => {
- return React.Children.toArray(children).filter((child) => {
- if (!React.isValidElement(child)) return false;
- return isLeading ? child.props.isLeading : !child.props.isLeading;
- });
-};
-
-export const renderAppbarContent = ({
- children,
- isDark,
- shouldCenterContent = false,
- renderOnly,
- renderExcept,
- mode = 'small',
- theme,
-}: RenderAppbarContentProps) => {
- return React.Children.toArray(children as React.ReactNode | React.ReactNode[])
- .filter((child) => child != null && typeof child !== 'boolean')
- .filter((child) =>
- // @ts-expect-error: TypeScript complains about the type of type but it doesn't matter
- renderExcept ? !renderExcept.includes(child.type.displayName) : child
- )
- .filter((child) =>
- // @ts-expect-error: TypeScript complains about the type of type but it doesn't matter
- renderOnly ? renderOnly.includes(child.type.displayName) : child
- )
- .map((child, i) => {
- if (
- !React.isValidElement(child) ||
- ![
- 'Appbar.Content',
- 'Appbar.Action',
- 'Appbar.BackAction',
- 'Tooltip',
- ].includes(
- // @ts-expect-error: TypeScript complains about the type of type but it doesn't matter
- child.type.displayName
- )
- ) {
- return child;
- }
-
- const props: {
- color?: string;
- style?: StyleProp;
- mode?: AppbarModes;
- theme?: ThemeProp;
- } = {
- theme,
- color: getAppbarColor({ color: child.props.color, isDark }),
- };
-
- // @ts-expect-error: TypeScript complains about the type of type but it doesn't matter
- if (child.type.displayName === 'Appbar.Content') {
- props.mode = mode;
- props.style = [
- i === 0 && !shouldCenterContent && styles.v3Spacing,
- shouldCenterContent && styles.centerAlignedContent,
- child.props.style,
- ];
- props.color;
- }
- return React.cloneElement(child, props);
- });
-};
-
-const styles = StyleSheet.create({
- centerAlignedContent: {
- alignItems: 'center',
- },
- v3Spacing: {
- marginLeft: 12,
- },
-});
diff --git a/src/components/Card/Card.tsx b/src/components/Card/Card.tsx
index e1cc58c52a..fed3d55343 100644
--- a/src/components/Card/Card.tsx
+++ b/src/components/Card/Card.tsx
@@ -95,6 +95,8 @@ export type Props = $Omit, 'mode'> & {
/**
* A card is a sheet of material that serves as an entry point to more detailed information.
+ * Card clips its inner content to the card shape and renders children directly;
+ * section spacing is owned by the section components themselves.
*
* ## Usage
* ```js
@@ -112,8 +114,8 @@ export type Props = $Omit, 'mode'> & {
*
*
*
- * Cancel
- * Ok
+ * Cancel
+ * Ok
*
*
* );
@@ -185,13 +187,6 @@ const Card = ({
runElevationAnimation('out');
});
- const total = React.Children.count(children);
- const siblings = React.Children.map(children, (child) =>
- React.isValidElement(child) && child.type
- ? (child.type as any).displayName
- : null
- );
-
const { backgroundColor, borderColor: themedBorderColor } = getCardColors({
theme,
mode: cardMode,
@@ -212,17 +207,11 @@ const Card = ({
};
const content = (
-
- {React.Children.map(children, (child, index) =>
- React.isValidElement(child)
- ? React.cloneElement(child as React.ReactElement, {
- index,
- total,
- siblings,
- borderRadiusStyles,
- })
- : child
- )}
+
+ {children}
);
@@ -288,6 +277,7 @@ Card.Title = CardTitle;
const styles = StyleSheet.create({
innerContainer: {
flexShrink: 1,
+ overflow: 'hidden',
},
outline: {
borderWidth: 1,
diff --git a/src/components/Card/CardActions.tsx b/src/components/Card/CardActions.tsx
index 689a97d1c1..e07a3034bc 100644
--- a/src/components/Card/CardActions.tsx
+++ b/src/components/Card/CardActions.tsx
@@ -2,7 +2,6 @@ import * as React from 'react';
import { StyleSheet, View } from 'react-native';
import type { StyleProp, ViewProps, ViewStyle } from 'react-native';
-import type { CardActionChildProps } from './utils';
import { useInternalTheme } from '../../core/theming';
import type { ThemeProp } from '../../types';
@@ -17,6 +16,8 @@ export type Props = ViewProps & {
/**
* A component to show a list of actions inside a Card.
+ * Actions are rendered directly, so set button `mode`, `compact`, and custom
+ * spacing props explicitly on each action when needed.
*
* ## Usage
* ```js
@@ -26,8 +27,8 @@ export type Props = ViewProps & {
* const MyComponent = () => (
*
*
- * Cancel
- * Ok
+ * Cancel
+ * Ok
*
*
* );
@@ -43,23 +44,7 @@ const CardActions = ({ theme, style, children, ...rest }: Props) => {
return (
- {React.Children.map(children, (child, index) => {
- if (!React.isValidElement(child)) {
- return child;
- }
-
- const compact = child.props.compact;
- const mode =
- child.props.mode ?? (index === 0 ? 'outlined' : 'contained');
- const childStyle = [styles.button, child.props.style];
-
- return React.cloneElement(child, {
- ...child.props,
- compact,
- mode,
- style: childStyle,
- });
- })}
+ {children}
);
};
@@ -70,11 +55,9 @@ const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
+ gap: 8,
padding: 8,
},
- button: {
- marginLeft: 8,
- },
});
export default CardActions;
diff --git a/src/components/Card/CardContent.tsx b/src/components/Card/CardContent.tsx
index af69fd7459..5784273d1b 100644
--- a/src/components/Card/CardContent.tsx
+++ b/src/components/Card/CardContent.tsx
@@ -7,23 +7,13 @@ export type Props = ViewProps & {
* Items inside the `Card.Content`.
*/
children: React.ReactNode;
- /**
- * @internal
- */
- index?: number;
- /**
- * @internal
- */
- total?: number;
- /**
- * @internal
- */
- siblings?: Array;
style?: StyleProp;
};
/**
* A component to show content inside a Card.
+ * Content uses uniform vertical padding and does not depend on neighboring
+ * card sections.
*
* ## Usage
* ```js
@@ -42,59 +32,18 @@ export type Props = ViewProps & {
* export default MyComponent;
* ```
*/
-const CardContent = ({ index, total, siblings, style, ...rest }: Props) => {
- const cover = 'withInternalTheme(CardCover)';
- const title = 'withInternalTheme(CardTitle)';
-
- let contentStyle, prev, next;
-
- if (typeof index === 'number' && siblings) {
- prev = siblings[index - 1];
- next = siblings[index + 1];
- }
-
- if (
- (prev === cover && next === cover) ||
- (prev === title && next === title) ||
- total === 1
- ) {
- contentStyle = styles.only;
- } else if (index === 0) {
- if (next === cover || next === title) {
- contentStyle = styles.only;
- } else {
- contentStyle = styles.first;
- }
- } else if (typeof total === 'number' && index === total - 1) {
- if (prev === cover || prev === title) {
- contentStyle = styles.only;
- } else {
- contentStyle = styles.last;
- }
- } else if (prev === cover || prev === title) {
- contentStyle = styles.first;
- } else if (next === cover || next === title) {
- contentStyle = styles.last;
- }
-
- return ;
-};
+const CardContent = ({ style, ...rest }: Props) => (
+
+);
CardContent.displayName = 'Card.Content';
const styles = StyleSheet.create({
container: {
paddingHorizontal: 16,
- },
- first: {
paddingTop: 16,
- },
- last: {
paddingBottom: 16,
},
- only: {
- paddingVertical: 16,
- },
});
export default CardContent;
diff --git a/src/components/Card/CardCover.tsx b/src/components/Card/CardCover.tsx
index e68bfb4adb..873561bd82 100644
--- a/src/components/Card/CardCover.tsx
+++ b/src/components/Card/CardCover.tsx
@@ -8,14 +8,6 @@ import type { ThemeProp } from '../../types';
import { splitStyles } from '../../utils/splitStyles';
export type Props = ImageProps & {
- /**
- * @internal
- */
- index?: number;
- /**
- * @internal
- */
- total?: number;
style?: StyleProp;
/**
* @optional
@@ -42,13 +34,7 @@ export type Props = ImageProps & {
*
* @extends Image props https://reactnative.dev/docs/image#props
*/
-const CardCover = ({
- index,
- total,
- style,
- theme: themeOverrides,
- ...rest
-}: Props) => {
+const CardCover = ({ style, theme: themeOverrides, ...rest }: Props) => {
const theme = useInternalTheme(themeOverrides);
const flattenedStyles = (StyleSheet.flatten(style) || {}) as ViewStyle;
@@ -59,8 +45,6 @@ const CardCover = ({
const coverStyle = getCardCoverStyle({
theme,
- index,
- total,
borderRadiusStyles,
});
diff --git a/src/components/Card/CardTitle.tsx b/src/components/Card/CardTitle.tsx
index 2737fe6430..cf8beb20ef 100644
--- a/src/components/Card/CardTitle.tsx
+++ b/src/components/Card/CardTitle.tsx
@@ -81,14 +81,6 @@ export type Props = ViewProps & {
* Style for the right element wrapper.
*/
rightStyle?: StyleProp;
- /**
- * @internal
- */
- index?: number;
- /**
- * @internal
- */
- total?: number;
/**
* Specifies the largest possible scale a title font can reach.
*/
diff --git a/src/components/Card/utils.tsx b/src/components/Card/utils.tsx
index 785a23c820..ff4e117bab 100644
--- a/src/components/Card/utils.tsx
+++ b/src/components/Card/utils.tsx
@@ -17,14 +17,10 @@ export type CardActionChildProps = {
export const getCardCoverStyle = ({
theme,
- index: _index,
- total: _total,
borderRadiusStyles,
}: {
theme: InternalTheme;
borderRadiusStyles: BorderRadiusStyles;
- index?: number;
- total?: number;
}) => {
if (Object.keys(borderRadiusStyles).length > 0) {
return {
diff --git a/src/components/Dialog/Dialog.tsx b/src/components/Dialog/Dialog.tsx
index e79804d324..a41f29aa11 100644
--- a/src/components/Dialog/Dialog.tsx
+++ b/src/components/Dialog/Dialog.tsx
@@ -12,7 +12,6 @@ import DialogTitle from './DialogTitle';
import { useInternalTheme } from '../../core/theming';
import type { Theme, ThemeProp } from '../../types';
import Modal from '../Modal';
-import type { DialogChildProps } from './utils';
export type Props = {
/**
@@ -51,6 +50,8 @@ const DIALOG_ELEVATION: number = 24;
/**
* Dialogs inform users about a specific task and may contain critical information, require decisions, or involve multiple tasks.
* To render the `Dialog` above other components, you'll need to wrap it with the [`Portal`](../Portal) component.
+ * Dialog owns the top content inset, so first-slot components render without
+ * adding their own top offset.
*
* ## Usage
* ```js
@@ -122,17 +123,7 @@ const Dialog = ({
theme={theme}
testID={testID}
>
- {React.Children.toArray(children)
- .filter((child) => child != null && typeof child !== 'boolean')
- .map((child, i) => {
- if (i === 0 && React.isValidElement(child)) {
- return React.cloneElement(child, {
- style: [{ marginTop: 24 }, child.props.style],
- });
- }
-
- return child;
- })}
+ {children}
);
};
@@ -160,6 +151,7 @@ const styles = StyleSheet.create({
marginVertical: Platform.OS === 'android' ? 44 : 0,
elevation: DIALOG_ELEVATION,
justifyContent: 'flex-start',
+ paddingTop: 24,
},
});
diff --git a/src/components/Dialog/DialogActions.tsx b/src/components/Dialog/DialogActions.tsx
index 7e3799451e..457292ef2b 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
@@ -46,26 +47,17 @@ export type Props = ViewProps & {
* export default MyComponent;
* ```
*/
-const DialogActions = (props: Props) => {
- useInternalTheme(props.theme);
- const actionsLength = React.Children.toArray(props.children).length;
+const DialogActions = ({
+ theme: themeOverrides,
+ style,
+ children,
+ ...rest
+}: Props) => {
+ useInternalTheme(themeOverrides);
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
- )}
+
+ {children}
);
};
@@ -78,6 +70,7 @@ const styles = StyleSheet.create({
flexGrow: 1,
alignItems: 'center',
justifyContent: 'flex-end',
+ gap: 8,
paddingBottom: 24,
paddingHorizontal: 24,
},
diff --git a/src/components/Dialog/DialogIcon.tsx b/src/components/Dialog/DialogIcon.tsx
index 199becd06c..46e70be39d 100644
--- a/src/components/Dialog/DialogIcon.tsx
+++ b/src/components/Dialog/DialogIcon.tsx
@@ -24,6 +24,10 @@ export type Props = {
* @optional
*/
theme?: ThemeProp;
+ /**
+ * testID to be used on tests.
+ */
+ testID?: string;
};
/**
@@ -68,6 +72,7 @@ const DialogIcon = ({
color,
icon,
theme: themeOverrides,
+ testID,
}: Props) => {
const theme = useInternalTheme(themeOverrides);
const { colors } = theme as Theme;
@@ -76,7 +81,7 @@ const DialogIcon = ({
const iconColor = color || colors.secondary;
return (
-
+
);
@@ -88,7 +93,8 @@ const styles = StyleSheet.create({
wrapper: {
alignItems: 'center',
justifyContent: 'center',
- paddingTop: 24,
+ marginBottom: 16,
+ paddingTop: 0,
},
});
diff --git a/src/components/Dialog/DialogTitle.tsx b/src/components/Dialog/DialogTitle.tsx
index bdf5021379..f361f911ab 100644
--- a/src/components/Dialog/DialogTitle.tsx
+++ b/src/components/Dialog/DialogTitle.tsx
@@ -81,7 +81,7 @@ const styles = StyleSheet.create({
marginHorizontal: 24,
},
v3Text: {
- marginTop: 16,
+ marginTop: 0,
marginBottom: 16,
},
});
diff --git a/src/components/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 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,10 @@ const styles = StyleSheet.create({
height: 42,
margin: 0,
},
+ segmentedContent: {
+ borderRadius: 0,
+ marginLeft: StyleSheet.hairlineWidth,
+ },
});
export default ToggleButton;
diff --git a/src/components/ToggleButton/ToggleButtonRow.tsx b/src/components/ToggleButton/ToggleButtonRow.tsx
index 46e6aee48f..f4895f6fde 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,33 @@ 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 +88,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',
+ overflow: 'hidden',
+ padding: StyleSheet.hairlineWidth,
+ paddingLeft: 0,
},
});
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__/Appbar/Appbar.test.tsx b/src/components/__tests__/Appbar/Appbar.test.tsx
index 8738f71e37..8f90ba201b 100644
--- a/src/components/__tests__/Appbar/Appbar.test.tsx
+++ b/src/components/__tests__/Appbar/Appbar.test.tsx
@@ -12,16 +12,10 @@ import {
getAppbarBackgroundColor,
getAppbarBorders,
modeTextVariant,
- renderAppbarContent as utilRenderAppbarContent,
} from '../../Appbar/utils';
-import Menu from '../../Menu/Menu';
import Searchbar from '../../Searchbar';
import Text from '../../Typography/Text';
-const renderAppbarContent = utilRenderAppbarContent as (
- props: Parameters[0]
-) => { props: any }[];
-
describe('Appbar', () => {
it('does not pass any additional props to Searchbar', async () => {
const tree = (
@@ -50,109 +44,7 @@ describe('Appbar', () => {
});
});
-describe('renderAppbarContent', () => {
- const children = [
- {}} key={0} />,
- ,
- {}} key={2} />,
- {}} key={3} />,
- ];
-
- it('should render all children types if renderOnly is not specified', () => {
- const result = renderAppbarContent({
- children,
- isDark: false,
- });
-
- expect(result).toHaveLength(4);
- });
-
- it('should render all children types except specified in renderExcept', () => {
- const result = renderAppbarContent({
- children: [
- ...children,
- ,
- ],
- 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"
diff --git a/src/components/__tests__/Card/Card.test.tsx b/src/components/__tests__/Card/Card.test.tsx
index 61659c8a24..c45132c0eb 100644
--- a/src/components/__tests__/Card/Card.test.tsx
+++ b/src/components/__tests__/Card/Card.test.tsx
@@ -1,3 +1,4 @@
+import type { ComponentProps } from 'react';
import { Animated, StyleSheet, Text } from 'react-native';
import { describe, expect, it, jest } from '@jest/globals';
@@ -91,6 +92,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});
@@ -127,18 +141,67 @@ describe('CardCover', () => {
describe('CardActions', () => {
it('renders button with passed mode', async () => {
+ const buttonProps = jest.fn();
+ const ProbeButton = (props: ComponentProps) => {
+ buttonProps(props);
+
+ return ;
+ };
+
+ await render(
+
+
+ Agree
+
+
+ );
+
+ 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 ;
+ };
+
+ await render(
+
+
+ Cancel
+ Agree
+
+
+ );
+
+ const [cancelButtonProps] = buttonProps.mock.calls[0];
+ const [agreeButtonProps] = buttonProps.mock.calls[1];
+
+ 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 () => {
await render(
- Agree
+ Cancel
+ 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(screen.getByTestId('card-actions')).toHaveStyle({
+ flexDirection: 'row',
+ gap: 8,
+ justifyContent: 'flex-end',
+ });
});
it('renders button with custom styles', async () => {
@@ -224,6 +287,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,
]
diff --git a/src/components/__tests__/Dialog.test.tsx b/src/components/__tests__/Dialog.test.tsx
index 59785c52cf..c35d614469 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,
@@ -93,17 +94,71 @@ 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(
-