diff --git a/example/src/Examples/CardExample.tsx b/example/src/Examples/CardExample.tsx index ad3451e8b0..9de8b5cbbc 100644 --- a/example/src/Examples/CardExample.tsx +++ b/example/src/Examples/CardExample.tsx @@ -75,8 +75,12 @@ const CardExample = () => { - - + + @@ -104,10 +108,10 @@ const CardExample = () => { /> - - diff --git a/example/src/Examples/TeamDetails.tsx b/example/src/Examples/TeamDetails.tsx index 5970274f31..463e5148d8 100644 --- a/example/src/Examples/TeamDetails.tsx +++ b/example/src/Examples/TeamDetails.tsx @@ -93,8 +93,12 @@ const News = () => { - - + + @@ -110,8 +114,12 @@ const News = () => { - - + + diff --git a/src/components/Card/Card.tsx b/src/components/Card/Card.tsx index e1cc58c52a..fed3d55343 100644 --- a/src/components/Card/Card.tsx +++ b/src/components/Card/Card.tsx @@ -95,6 +95,8 @@ export type Props = $Omit, 'mode'> & { /** * A card is a sheet of material that serves as an entry point to more detailed information. + * Card clips its inner content to the card shape and renders children directly; + * section spacing is owned by the section components themselves. * * ## Usage * ```js @@ -112,8 +114,8 @@ export type Props = $Omit, 'mode'> & { * * * - * - * + * + * * * * ); @@ -185,13 +187,6 @@ const Card = ({ runElevationAnimation('out'); }); - const total = React.Children.count(children); - const siblings = React.Children.map(children, (child) => - React.isValidElement(child) && child.type - ? (child.type as any).displayName - : null - ); - const { backgroundColor, borderColor: themedBorderColor } = getCardColors({ theme, mode: cardMode, @@ -212,17 +207,11 @@ const Card = ({ }; const content = ( - - {React.Children.map(children, (child, index) => - React.isValidElement(child) - ? React.cloneElement(child as React.ReactElement, { - index, - total, - siblings, - borderRadiusStyles, - }) - : child - )} + + {children} ); @@ -288,6 +277,7 @@ Card.Title = CardTitle; const styles = StyleSheet.create({ innerContainer: { flexShrink: 1, + overflow: 'hidden', }, outline: { borderWidth: 1, diff --git a/src/components/Card/CardActions.tsx b/src/components/Card/CardActions.tsx index 689a97d1c1..e07a3034bc 100644 --- a/src/components/Card/CardActions.tsx +++ b/src/components/Card/CardActions.tsx @@ -2,7 +2,6 @@ import * as React from 'react'; import { StyleSheet, View } from 'react-native'; import type { StyleProp, ViewProps, ViewStyle } from 'react-native'; -import type { CardActionChildProps } from './utils'; import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../types'; @@ -17,6 +16,8 @@ export type Props = ViewProps & { /** * A component to show a list of actions inside a Card. + * Actions are rendered directly, so set button `mode`, `compact`, and custom + * spacing props explicitly on each action when needed. * * ## Usage * ```js @@ -26,8 +27,8 @@ export type Props = ViewProps & { * const MyComponent = () => ( * * - * - * + * + * * * * ); @@ -43,23 +44,7 @@ const CardActions = ({ theme, style, children, ...rest }: Props) => { return ( - {React.Children.map(children, (child, index) => { - if (!React.isValidElement(child)) { - return child; - } - - const compact = child.props.compact; - const mode = - child.props.mode ?? (index === 0 ? 'outlined' : 'contained'); - const childStyle = [styles.button, child.props.style]; - - return React.cloneElement(child, { - ...child.props, - compact, - mode, - style: childStyle, - }); - })} + {children} ); }; @@ -70,11 +55,9 @@ const styles = StyleSheet.create({ container: { flexDirection: 'row', alignItems: 'center', + gap: 8, padding: 8, }, - button: { - marginLeft: 8, - }, }); export default CardActions; diff --git a/src/components/Card/CardContent.tsx b/src/components/Card/CardContent.tsx index af69fd7459..5784273d1b 100644 --- a/src/components/Card/CardContent.tsx +++ b/src/components/Card/CardContent.tsx @@ -7,23 +7,13 @@ export type Props = ViewProps & { * Items inside the `Card.Content`. */ children: React.ReactNode; - /** - * @internal - */ - index?: number; - /** - * @internal - */ - total?: number; - /** - * @internal - */ - siblings?: Array; style?: StyleProp; }; /** * A component to show content inside a Card. + * Content uses uniform vertical padding and does not depend on neighboring + * card sections. * * ## Usage * ```js @@ -42,59 +32,18 @@ export type Props = ViewProps & { * export default MyComponent; * ``` */ -const CardContent = ({ index, total, siblings, style, ...rest }: Props) => { - const cover = 'withInternalTheme(CardCover)'; - const title = 'withInternalTheme(CardTitle)'; - - let contentStyle, prev, next; - - if (typeof index === 'number' && siblings) { - prev = siblings[index - 1]; - next = siblings[index + 1]; - } - - if ( - (prev === cover && next === cover) || - (prev === title && next === title) || - total === 1 - ) { - contentStyle = styles.only; - } else if (index === 0) { - if (next === cover || next === title) { - contentStyle = styles.only; - } else { - contentStyle = styles.first; - } - } else if (typeof total === 'number' && index === total - 1) { - if (prev === cover || prev === title) { - contentStyle = styles.only; - } else { - contentStyle = styles.last; - } - } else if (prev === cover || prev === title) { - contentStyle = styles.first; - } else if (next === cover || next === title) { - contentStyle = styles.last; - } - - return ; -}; +const CardContent = ({ style, ...rest }: Props) => ( + +); CardContent.displayName = 'Card.Content'; const styles = StyleSheet.create({ container: { paddingHorizontal: 16, - }, - first: { paddingTop: 16, - }, - last: { paddingBottom: 16, }, - only: { - paddingVertical: 16, - }, }); export default CardContent; diff --git a/src/components/Card/CardCover.tsx b/src/components/Card/CardCover.tsx index e68bfb4adb..873561bd82 100644 --- a/src/components/Card/CardCover.tsx +++ b/src/components/Card/CardCover.tsx @@ -8,14 +8,6 @@ import type { ThemeProp } from '../../types'; import { splitStyles } from '../../utils/splitStyles'; export type Props = ImageProps & { - /** - * @internal - */ - index?: number; - /** - * @internal - */ - total?: number; style?: StyleProp; /** * @optional @@ -42,13 +34,7 @@ export type Props = ImageProps & { * * @extends Image props https://reactnative.dev/docs/image#props */ -const CardCover = ({ - index, - total, - style, - theme: themeOverrides, - ...rest -}: Props) => { +const CardCover = ({ style, theme: themeOverrides, ...rest }: Props) => { const theme = useInternalTheme(themeOverrides); const flattenedStyles = (StyleSheet.flatten(style) || {}) as ViewStyle; @@ -59,8 +45,6 @@ const CardCover = ({ const coverStyle = getCardCoverStyle({ theme, - index, - total, borderRadiusStyles, }); diff --git a/src/components/Card/CardTitle.tsx b/src/components/Card/CardTitle.tsx index 2737fe6430..cf8beb20ef 100644 --- a/src/components/Card/CardTitle.tsx +++ b/src/components/Card/CardTitle.tsx @@ -81,14 +81,6 @@ export type Props = ViewProps & { * Style for the right element wrapper. */ rightStyle?: StyleProp; - /** - * @internal - */ - index?: number; - /** - * @internal - */ - total?: number; /** * Specifies the largest possible scale a title font can reach. */ diff --git a/src/components/Card/utils.tsx b/src/components/Card/utils.tsx index 785a23c820..ff4e117bab 100644 --- a/src/components/Card/utils.tsx +++ b/src/components/Card/utils.tsx @@ -17,14 +17,10 @@ export type CardActionChildProps = { export const getCardCoverStyle = ({ theme, - index: _index, - total: _total, borderRadiusStyles, }: { theme: InternalTheme; borderRadiusStyles: BorderRadiusStyles; - index?: number; - total?: number; }) => { if (Object.keys(borderRadiusStyles).length > 0) { return { diff --git a/src/components/Dialog/Dialog.tsx b/src/components/Dialog/Dialog.tsx index e79804d324..a41f29aa11 100644 --- a/src/components/Dialog/Dialog.tsx +++ b/src/components/Dialog/Dialog.tsx @@ -12,7 +12,6 @@ import DialogTitle from './DialogTitle'; import { useInternalTheme } from '../../core/theming'; import type { Theme, ThemeProp } from '../../types'; import Modal from '../Modal'; -import type { DialogChildProps } from './utils'; export type Props = { /** @@ -51,6 +50,8 @@ const DIALOG_ELEVATION: number = 24; /** * Dialogs inform users about a specific task and may contain critical information, require decisions, or involve multiple tasks. * To render the `Dialog` above other components, you'll need to wrap it with the [`Portal`](../Portal) component. + * Dialog owns the top content inset, so first-slot components render without + * adding their own top offset. * * ## Usage * ```js @@ -122,17 +123,7 @@ const Dialog = ({ theme={theme} testID={testID} > - {React.Children.toArray(children) - .filter((child) => child != null && typeof child !== 'boolean') - .map((child, i) => { - if (i === 0 && React.isValidElement(child)) { - return React.cloneElement(child, { - style: [{ marginTop: 24 }, child.props.style], - }); - } - - return child; - })} + {children} ); }; @@ -160,6 +151,7 @@ const styles = StyleSheet.create({ marginVertical: Platform.OS === 'android' ? 44 : 0, elevation: DIALOG_ELEVATION, justifyContent: 'flex-start', + paddingTop: 24, }, }); diff --git a/src/components/Dialog/DialogActions.tsx b/src/components/Dialog/DialogActions.tsx index 7e3799451e..c4eaa48c3f 100644 --- a/src/components/Dialog/DialogActions.tsx +++ b/src/components/Dialog/DialogActions.tsx @@ -2,7 +2,6 @@ import * as React from 'react'; import { StyleSheet, View } from 'react-native'; import type { StyleProp, ViewProps, ViewStyle } from 'react-native'; -import type { DialogActionChildProps } from './utils'; import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../types'; @@ -20,6 +19,8 @@ export type Props = ViewProps & { /** * A component to show a list of actions in a Dialog. + * Actions are rendered directly, so configure each action button's props + * explicitly when you need non-default behavior. * * ## Usage * ```js @@ -48,24 +49,10 @@ export type Props = ViewProps & { */ const DialogActions = (props: Props) => { useInternalTheme(props.theme); - const actionsLength = React.Children.toArray(props.children).length; return ( - {React.Children.map(props.children, (child, i) => - React.isValidElement(child) - ? React.cloneElement(child, { - compact: true, - uppercase: false, - style: [ - { - marginRight: i + 1 === actionsLength ? 0 : 8, - }, - child.props.style, - ], - }) - : child - )} + {props.children} ); }; @@ -78,6 +65,7 @@ const styles = StyleSheet.create({ flexGrow: 1, alignItems: 'center', justifyContent: 'flex-end', + gap: 8, paddingBottom: 24, paddingHorizontal: 24, }, diff --git a/src/components/Dialog/DialogIcon.tsx b/src/components/Dialog/DialogIcon.tsx index 199becd06c..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,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__/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 + + ); - 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( - - + + 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, + }); + }); + + 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, }); }); }); @@ -133,11 +188,36 @@ 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 () => { + const buttonProps = jest.fn(); + const ProbeButton = (props: ComponentProps) => { + buttonProps(props); + + return