From a681e7c5c24fac825de74be073c13c3d5ab86a33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Kata?= Date: Fri, 4 Sep 2026 12:26:07 +0200 Subject: [PATCH 01/11] refactor(card): make layout helpers parent-independent BREAKING CHANGE: Card.Content now always applies fixed padding, and Card.Actions no longer assigns button modes or child margins. --- src/components/Card/Card.tsx | 23 +--- src/components/Card/CardActions.tsx | 43 +------ src/components/Card/CardContent.tsx | 60 +-------- src/components/Card/utils.tsx | 8 +- src/components/__tests__/Card/Card.test.tsx | 136 ++++++++++++++++++-- 5 files changed, 141 insertions(+), 129 deletions(-) diff --git a/src/components/Card/Card.tsx b/src/components/Card/Card.tsx index 712110883b..c99b4f4840 100644 --- a/src/components/Card/Card.tsx +++ b/src/components/Card/Card.tsx @@ -141,7 +141,7 @@ const Card = ({ style, contentStyle, theme: themeOverrides, - testID, + testID = 'card', accessible, disabled, ref, @@ -182,15 +182,6 @@ const Card = ({ } }); - 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, @@ -204,16 +195,8 @@ const Card = ({ const borderRadius = theme.shapes.corner.medium; const content = ( - - {React.Children.map(children, (child, index) => - React.isValidElement(child) - ? React.cloneElement(child as React.ReactElement, { - index, - total, - siblings, - }) - : child - )} + + {children} ); diff --git a/src/components/Card/CardActions.tsx b/src/components/Card/CardActions.tsx index d541c691bc..03ce9d96f4 100644 --- a/src/components/Card/CardActions.tsx +++ b/src/components/Card/CardActions.tsx @@ -2,8 +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 '../../theme/types'; export type Props = ViewProps & { @@ -35,37 +33,11 @@ export type Props = ViewProps & { * export default MyComponent; * ``` */ -const CardActions = ({ theme, style, children, ...rest }: Props) => { - useInternalTheme(theme); - - const containerStyle = [ - styles.container, - { justifyContent: 'flex-end' } satisfies ViewStyle, - style, - ]; - - 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, - }); - })} - - ); -}; +const CardActions = ({ style, children, theme: _theme, ...rest }: Props) => ( + + {children} + +); CardActions.displayName = 'Card.Actions'; @@ -73,10 +45,9 @@ const styles = StyleSheet.create({ container: { flexDirection: 'row', alignItems: 'center', + justifyContent: 'flex-end', padding: 8, - }, - button: { - marginLeft: 8, + gap: 8, }, }); diff --git a/src/components/Card/CardContent.tsx b/src/components/Card/CardContent.tsx index bde25ddb25..4e0208469f 100644 --- a/src/components/Card/CardContent.tsx +++ b/src/components/Card/CardContent.tsx @@ -7,18 +7,6 @@ export type Props = ViewProps & { * Items inside the `Card.Content`. */ children: React.ReactNode; - /** - * @internal - */ - index?: number; - /** - * @internal - */ - total?: number; - /** - * @internal - */ - siblings?: Array; style?: StyleProp; }; @@ -42,57 +30,15 @@ export type Props = ViewProps & { * export default MyComponent; * ``` */ -const CardContent = ({ index, total, siblings, style, ...rest }: Props) => { - const cover = 'Card.Cover'; - const title = 'Card.Title'; - - 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, }, }); diff --git a/src/components/Card/utils.tsx b/src/components/Card/utils.tsx index fc0faa945a..9a1d4dd687 100644 --- a/src/components/Card/utils.tsx +++ b/src/components/Card/utils.tsx @@ -1,4 +1,4 @@ -import type { StyleProp, ViewStyle } from 'react-native'; +import type { ViewStyle } from 'react-native'; import type { InternalTheme } from '../../theme/types'; @@ -9,12 +9,6 @@ type BorderRadiusStyles = Pick< Extract >; -export type CardActionChildProps = { - compact?: boolean; - mode?: string; - style?: StyleProp; -}; - export const getCardCoverStyle = ({ theme, index: _index, diff --git a/src/components/__tests__/Card/Card.test.tsx b/src/components/__tests__/Card/Card.test.tsx index 7d84f0e44f..051db81eef 100644 --- a/src/components/__tests__/Card/Card.test.tsx +++ b/src/components/__tests__/Card/Card.test.tsx @@ -1,4 +1,5 @@ -import { Platform, StyleSheet, Text } from 'react-native'; +import { Platform, StyleSheet, Text, View } from 'react-native'; +import type { StyleProp, ViewStyle } from 'react-native'; import { afterEach, describe, expect, it, jest } from '@jest/globals'; @@ -19,6 +20,9 @@ const styles = StyleSheet.create({ contentStyle: { flexDirection: 'column-reverse', }, + customAction: { + marginRight: 12, + }, }); afterEach(() => { @@ -122,20 +126,134 @@ describe('CardCover', () => { }); }); -describe('CardActions', () => { - it('renders button with passed mode', async () => { +describe('CardContent', () => { + it('uses fixed padding when rendered standalone', async () => { + await render( + + Content + + ); + + expect(screen.getByTestId('card-content')).toHaveStyle({ + paddingHorizontal: 16, + paddingVertical: 16, + }); + }); + + it('uses fixed padding regardless of neighboring card elements', async () => { await render( - - + + <> + + + Content + + + + + ); - 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-content')).toHaveStyle({ + paddingHorizontal: 16, + paddingVertical: 16, + }); + }); + + it('lets consumer styles override the default padding', async () => { + await render( + + Content + + ); + + expect(screen.getByTestId('card-content')).toHaveStyle({ + paddingHorizontal: 24, + paddingVertical: 12, + }); + }); +}); + +describe('CardActions', () => { + it('lays out heterogeneous nodes with container-owned spacing', async () => { + await render( + + + + Details + + ); + + expect(screen.getByTestId('card-actions')).toHaveStyle({ + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'flex-end', + padding: 8, + gap: 8, + }); + expect(screen.getByTestId('custom-action')).toBeOnTheScreen(); + expect(screen.getByText('Details')).toBeOnTheScreen(); + }); + + it('lets consumer styles override the default layout', async () => { + await render( + + Action + + ); + + expect(screen.getByTestId('card-actions')).toHaveStyle({ + justifyContent: 'flex-start', + padding: 4, + gap: 12, + }); + }); + + it('preserves consumer-configured child props', async () => { + const Action = ({ + compact, + mode, + style, + }: { + compact?: boolean; + mode?: string; + style?: StyleProp; + }) => ( + + ); + + await render( + + + + + + ); + + expect(screen.getByLabelText('unset:unset')).toHaveStyle( + styles.customAction + ); + expect(screen.getByLabelText('contained:true')).toHaveStyle( + styles.customAction + ); + expect(screen.getByLabelText('unset:unset')).not.toHaveStyle({ + marginLeft: 8, + }); + expect(screen.getByTestId('custom-action')).not.toHaveStyle({ + marginLeft: 8, + }); }); }); From f20883ee7d4eae0272246e0365747f03e4b104e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Kata?= Date: Fri, 4 Sep 2026 13:08:03 +0200 Subject: [PATCH 02/11] feat(card)!: introduce slot-first filled card BREAKING CHANGE: Card now accepts media, header, content, and actions slots; arbitrary children composition and the mode prop are removed. --- example/src/Examples/CardExample.tsx | 327 ++++++++------- example/src/Examples/DataTableExample.tsx | 74 ++-- .../SegmentedButtonMultiselectRealCase.tsx | 27 +- .../SegmentedButtonRealCase.tsx | 27 +- example/src/Examples/TeamDetails.tsx | 90 +++-- example/src/Examples/TooltipExample.tsx | 17 +- src/components/Card/Card.tsx | 204 +++++----- src/components/Card/CardActions.tsx | 8 +- src/components/Card/CardContent.tsx | 8 +- src/components/Card/CardCover.tsx | 6 +- src/components/Card/utils.tsx | 43 -- src/components/__tests__/Card/Card.test.tsx | 208 +++++----- .../Card/__snapshots__/Card.test.tsx.snap | 371 ------------------ 13 files changed, 498 insertions(+), 912 deletions(-) delete mode 100644 src/components/__tests__/Card/__snapshots__/Card.test.tsx.snap diff --git a/example/src/Examples/CardExample.tsx b/example/src/Examples/CardExample.tsx index 016bef3e21..a5fa6ee49d 100644 --- a/example/src/Examples/CardExample.tsx +++ b/example/src/Examples/CardExample.tsx @@ -1,11 +1,10 @@ import * as React from 'react'; -import { Alert, Platform, ScrollView, StyleSheet, View } from 'react-native'; +import { Alert, Platform, ScrollView, StyleSheet } from 'react-native'; import { Avatar, Button, Card, - Chip, IconButton, Text, useTheme, @@ -14,139 +13,143 @@ import { import { PreferencesContext } from '../PreferencesContext'; import ScreenWrapper from '../ScreenWrapper'; -type Mode = 'elevated' | 'outlined' | 'contained'; - const CardExample = () => { const { colors } = useTheme(); - const [selectedMode, setSelectedMode] = React.useState('elevated'); const [isSelected, setIsSelected] = React.useState(false); const preferences = React.useContext(PreferencesContext); - const modes: Mode[] = ['elevated', 'outlined', 'contained']; - return ( - - {modes.map((mode) => ( - setSelectedMode(mode)} - style={styles.chip} - > - {mode} - - ))} - - - - - - - The Abandoned Ship is a wrecked ship located on Route 108 in - Hoenn, originally being a ship named the S.S. Cactus. The second - part of the ship can only be accessed by using Dive and contains - the Scanner. - - - - - - - - - This is a card using title and subtitle with specified variants. - - - - - - - - - - - - } - right={(props: any) => ( - {}} /> - )} - /> - - - Dotted around the Hoenn region, you will find loamy soil, many of - which are housing berries. Once you have picked the berries, then - you have the ability to use that loamy soil to grow your own - berries. These can be any berry and will require attention to get - the best crop. - - - - - - - - - - - + } + title="Abandoned Ship" + content={ + + + The Abandoned Ship is a wrecked ship located on Route 108 in + Hoenn, originally being a ship named the S.S. Cactus. The second + part of the ship can only be accessed by using Dive and contains + the Scanner. + + + } + /> + + } + header={ + + } + content={ + + + This is a card using title and subtitle with specified variants. + + + } + /> + + } + actions={ + + + + + } + /> + } + trailing={(props) => ( + {}} /> + )} + content={ + + + Dotted around the Hoenn region, you will find loamy soil, many + of which are housing berries. Once you have picked the berries, + then you have the ability to use that loamy soil to grow your + own berries. These can be any berry and will require attention + to get the best crop. + + + } + /> + + } + title="Custom Button styles" + actions={ + + + + + } + /> + - - - - - - ( - setIsSelected(!isSelected)} - /> - )} - /> - + media={ + + } + title="Custom border radius" + subtitle="... for card and cover" + /> + + } + title="Just Strawberries" + subtitle="... and only Strawberries" + trailing={(props) => ( + setIsSelected(!isSelected)} + /> + )} + /> { @@ -154,16 +157,18 @@ const CardExample = () => { ? alert('The Chameleon is Pressed') : Alert.alert('The Chameleon is Pressed'); }} - mode={selectedMode} - > - - - - - This is a pressable chameleon. If you press me, I will alert. - - - + media={ + + } + title="Pressable Chameleon" + content={ + + + This is a pressable chameleon. If you press me, I will alert. + + + } + /> { @@ -171,37 +176,36 @@ const CardExample = () => { ? alert('The City is Long Pressed') : Alert.alert('The City is Long Pressed'); }} - mode={selectedMode} - > - - } - /> - - - This is a long press only city. If you long press me, I will - alert. - - - + media={ + + } + title="Long Pressable City" + leading={(props) => } + content={ + + + This is a long press only city. If you long press me, I will + alert. + + + } + /> { preferences?.toggleTheme(); }} - mode={selectedMode} - > - } - /> - - - This is pressable card. If you press me, I will switch the theme. - - - + title="Pressable Theme Change" + leading={(props) => } + content={ + + + This is pressable card. If you press me, I will switch the + theme. + + + } + /> ); @@ -219,15 +223,6 @@ const styles = StyleSheet.create({ card: { margin: 4, }, - chip: { - margin: 4, - }, - preference: { - alignItems: 'center', - flexDirection: 'row', - paddingVertical: 12, - paddingHorizontal: 8, - }, customCoverRadius: { borderTopLeftRadius: 0, borderTopRightRadius: 0, diff --git a/example/src/Examples/DataTableExample.tsx b/example/src/Examples/DataTableExample.tsx index 69858746c2..43147cef16 100644 --- a/example/src/Examples/DataTableExample.tsx +++ b/example/src/Examples/DataTableExample.tsx @@ -73,43 +73,47 @@ const DataTableExample = () => { return ( - - - - setSortAscending(!sortAscending)} - style={styles.first} - > - Dessert - - - Calories per piece - - Fat (g) - + + + setSortAscending(!sortAscending)} + style={styles.first} + > + Dessert + + + Calories per piece + + Fat (g) + - {sortedItems.slice(from, to).map((item) => ( - - {item.name} - {item.calories} - {item.fat} - - ))} + {sortedItems.slice(from, to).map((item) => ( + + + {item.name} + + {item.calories} + {item.fat} + + ))} - setPage(page)} - label={`${from + 1}-${to} of ${sortedItems.length}`} - numberOfItemsPerPageList={numberOfItemsPerPageList} - numberOfItemsPerPage={itemsPerPage} - onItemsPerPageChange={onItemsPerPageChange} - showFastPaginationControls - selectPageDropdownLabel={'Rows per page'} - /> - - + setPage(page)} + label={`${from + 1}-${to} of ${sortedItems.length}`} + numberOfItemsPerPageList={numberOfItemsPerPageList} + numberOfItemsPerPage={itemsPerPage} + onItemsPerPageChange={onItemsPerPageChange} + showFastPaginationControls + selectPageDropdownLabel={'Rows per page'} + /> + + } + /> ); }; diff --git a/example/src/Examples/SegmentedButtons/SegmentedButtonMultiselectRealCase.tsx b/example/src/Examples/SegmentedButtons/SegmentedButtonMultiselectRealCase.tsx index 28475a32da..8bfdf35c2d 100644 --- a/example/src/Examples/SegmentedButtons/SegmentedButtonMultiselectRealCase.tsx +++ b/example/src/Examples/SegmentedButtons/SegmentedButtonMultiselectRealCase.tsx @@ -59,18 +59,21 @@ const SegmentedButtonMultiselectRealCase = () => { contentContainerStyle={styles.contentContainer} renderItem={({ item }) => { return ( - - - - } - /> - - + + + } + /> + + } + /> ); }} /> diff --git a/example/src/Examples/SegmentedButtons/SegmentedButtonRealCase.tsx b/example/src/Examples/SegmentedButtons/SegmentedButtonRealCase.tsx index 172c80a5f6..b1882a4b43 100644 --- a/example/src/Examples/SegmentedButtons/SegmentedButtonRealCase.tsx +++ b/example/src/Examples/SegmentedButtons/SegmentedButtonRealCase.tsx @@ -37,18 +37,21 @@ const SegmentedButtonRealCase = () => { contentContainerStyle={styles.contentContainer} renderItem={({ item }) => { return ( - - - - } - /> - - + + + } + /> + + } + /> ); }} /> diff --git a/example/src/Examples/TeamDetails.tsx b/example/src/Examples/TeamDetails.tsx index 5970274f31..85ccd2aa92 100644 --- a/example/src/Examples/TeamDetails.tsx +++ b/example/src/Examples/TeamDetails.tsx @@ -79,41 +79,61 @@ const News = () => { - - - - - - Which soccer players are switching teams? From the Premier - League, La Liga and beyond, here is a list of players on the - move this summer. - - - - - - - - - - - - - Medical tests show that Doe has injured the tendon in his left - hamstring, and in the next few days will... - - - - - - - + + } + header={ + + } + content={ + + + Which soccer players are switching teams? From the Premier + League, La Liga and beyond, here is a list of players on the + move this summer. + + + } + actions={ + + + + + } + /> + + } + header={ + + } + content={ + + + Medical tests show that Doe has injured the tendon in his left + hamstring, and in the next few days will... + + + } + actions={ + + + + + } + /> {}} visible style={styles.fab} /> diff --git a/example/src/Examples/TooltipExample.tsx b/example/src/Examples/TooltipExample.tsx index 325e0f0113..6eba9597a2 100644 --- a/example/src/Examples/TooltipExample.tsx +++ b/example/src/Examples/TooltipExample.tsx @@ -134,15 +134,14 @@ const TooltipExample = () => { - - ( - - )} - /> - + ( + + )} + /> diff --git a/src/components/Card/Card.tsx b/src/components/Card/Card.tsx index c99b4f4840..787cd5e374 100644 --- a/src/components/Card/Card.tsx +++ b/src/components/Card/Card.tsx @@ -7,48 +7,76 @@ import type { ViewStyle, } from 'react-native'; -import useLatestCallback from 'use-latest-callback'; - import CardActions from './CardActions'; import CardContent from './CardContent'; import CardCover from './CardCover'; import CardTitle from './CardTitle'; -import { getCardColors } from './utils'; +import type { Props as CardTitleProps } from './CardTitle'; import { useInternalTheme } from '../../core/theming'; -import type { Elevation, ThemeProp } from '../../theme/types'; +import type { ThemeProp } from '../../theme/types'; import hasTouchHandler from '../../utils/hasTouchHandler'; import Surface from '../Surface'; import type { SurfaceStyle } from '../Surface'; -type OutlinedCardProps = { - mode: 'outlined'; - elevation?: never; -}; - -type ElevatedCardProps = { - mode?: 'elevated'; - elevation?: Elevation; +type ConvenienceHeaderProps = { + /** + * Header title. + */ + title?: React.ReactNode; + /** + * Header subtitle. + */ + subtitle?: React.ReactNode; + /** + * Render slot displayed before the title and subtitle. + */ + leading?: CardTitleProps['left']; + /** + * Render slot displayed after the title and subtitle. + */ + trailing?: CardTitleProps['right']; + /** + * A fully custom header cannot be combined with convenience header props. + */ + header?: never; }; -type ContainedCardProps = { - mode?: 'contained'; - elevation?: never; +type CustomHeaderProps = { + /** + * Fully custom header content. + */ + header: React.ReactNode; + /** + * Unavailable when a custom header is supplied. + */ + title?: never; + /** + * Unavailable when a custom header is supplied. + */ + subtitle?: never; + /** + * Unavailable when a custom header is supplied. + */ + leading?: never; + /** + * Unavailable when a custom header is supplied. + */ + trailing?: never; }; -type Mode = 'elevated' | 'outlined' | 'contained'; - -export type Props = Omit & { +type CardBaseProps = Omit & { + /** + * Media rendered at the start of the Card. + */ + media?: React.ReactNode; /** - * Mode of the Card. - * - `elevated` - Card with elevation. - * - `contained` - Card without outline and elevation @supported Available in v5.x with theme version 3 - * - `outlined` - Card with an outline. + * Main Card content. */ - mode?: Mode; + content?: React.ReactNode; /** - * Content of the `Card`. + * Actions rendered at the end of the Card. */ - children: React.ReactNode; + actions?: React.ReactNode; /** * Function to execute on long press. */ @@ -73,10 +101,6 @@ export type Props = Omit & { * If true, disable all interactions for this component. */ disabled?: boolean; - /** - * Changes Card shadow and background on iOS and Android. - */ - elevation?: Elevation; /** * Style of card's inner content. */ @@ -100,29 +124,34 @@ export type Props = Omit & { ref?: React.Ref; }; +export type Props = CardBaseProps & + (ConvenienceHeaderProps | CustomHeaderProps); + /** - * A card is a sheet of material that serves as an entry point to more detailed information. + * A filled Card groups related media, header content, body content, and actions. * * ## Usage * ```js * import * as React from 'react'; * import { Avatar, Button, Card, Text } from 'react-native-paper'; * - * const LeftContent = props => + * const Leading = props => * * const MyComponent = () => ( - * - * - * + * } + * title="Card Title" + * subtitle="Card Subtitle" + * leading={Leading} + * content={ * Card title * Card content - * - * - * + * } + * actions={ * * - * - * + * } + * /> * ); * * export default MyComponent; @@ -130,14 +159,19 @@ export type Props = Omit & { */ const Card = ({ - elevation: cardElevation = 1, delayLongPress, onPress, onLongPress, onPressOut, onPressIn, - mode: cardMode = 'elevated', - children, + media, + header, + title, + subtitle, + leading, + trailing, + content: cardContent, + actions, style, contentStyle, theme: themeOverrides, @@ -146,16 +180,9 @@ const Card = ({ disabled, ref, ...rest -}: (OutlinedCardProps | ElevatedCardProps | ContainedCardProps) & Props) => { +}: Props) => { const theme = useInternalTheme(themeOverrides); - const isMode = React.useCallback( - (modeToCompare: Mode) => { - return cardMode === modeToCompare; - }, - [cardMode] - ); - const hasPassedTouchHandler = hasTouchHandler({ onPress, onLongPress, @@ -163,40 +190,24 @@ const Card = ({ onPressOut, }); - const [pressed, setPressed] = React.useState(false); - const elevation = isMode('elevated') ? (pressed ? 2 : cardElevation) : 0; - - const handlePressIn = useLatestCallback((e: GestureResponderEvent) => { - onPressIn?.(e); - - if (isMode('elevated')) { - setPressed(true); - } - }); - - const handlePressOut = useLatestCallback((e: GestureResponderEvent) => { - onPressOut?.(e); - - if (isMode('elevated')) { - setPressed(false); - } - }); - - const { backgroundColor, borderColor: themedBorderColor } = getCardColors({ - theme, - mode: cardMode, - }); - - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const flattenedStyles = (StyleSheet.flatten(style) || {}) as ViewStyle; - - const { borderColor = themedBorderColor } = flattenedStyles; - const borderRadius = theme.shapes.corner.medium; + const hasConvenienceHeader = + title != null || subtitle != null || leading != null || trailing != null; const content = ( - {children} + {media} + {header ?? + (hasConvenienceHeader ? ( + + ) : null)} + {cardContent} + {actions} ); @@ -204,26 +215,13 @@ const Card = ({ - {isMode('outlined') && ( - - )} - {hasPassedTouchHandler ? ( {content} @@ -260,13 +257,6 @@ const styles = StyleSheet.create({ innerContainer: { flexShrink: 1, }, - outline: { - borderWidth: 1, - position: 'absolute', - width: '100%', - height: '100%', - zIndex: 2, - }, }); export default Card; diff --git a/src/components/Card/CardActions.tsx b/src/components/Card/CardActions.tsx index 03ce9d96f4..1d26a6f70d 100644 --- a/src/components/Card/CardActions.tsx +++ b/src/components/Card/CardActions.tsx @@ -22,12 +22,12 @@ export type Props = ViewProps & { * import { Card, Button } from 'react-native-paper'; * * const MyComponent = () => ( - * - * + * * * - * - * + * } + * /> * ); * * export default MyComponent; diff --git a/src/components/Card/CardContent.tsx b/src/components/Card/CardContent.tsx index 4e0208469f..9281390f87 100644 --- a/src/components/Card/CardContent.tsx +++ b/src/components/Card/CardContent.tsx @@ -19,12 +19,12 @@ export type Props = ViewProps & { * import { Card, Text } from 'react-native-paper'; * * const MyComponent = () => ( - * - * + * * Card title * Card content - * - * + * } + * /> * ); * * export default MyComponent; diff --git a/src/components/Card/CardCover.tsx b/src/components/Card/CardCover.tsx index 4542aa7c99..99345cea64 100644 --- a/src/components/Card/CardCover.tsx +++ b/src/components/Card/CardCover.tsx @@ -32,9 +32,9 @@ export type Props = ImageProps & { * import { Card } from 'react-native-paper'; * * const MyComponent = () => ( - * - * - * + * } + * /> * ); * * export default MyComponent; diff --git a/src/components/Card/utils.tsx b/src/components/Card/utils.tsx index 9a1d4dd687..f7fff786a6 100644 --- a/src/components/Card/utils.tsx +++ b/src/components/Card/utils.tsx @@ -2,8 +2,6 @@ import type { ViewStyle } from 'react-native'; import type { InternalTheme } from '../../theme/types'; -type CardMode = 'elevated' | 'outlined' | 'contained'; - type BorderRadiusStyles = Pick< ViewStyle, Extract @@ -31,44 +29,3 @@ export const getCardCoverStyle = ({ borderRadius: theme.shapes.corner.medium, }; }; - -const getBorderColor = ({ theme }: { theme: InternalTheme }) => { - return theme.colors.outline; -}; - -const getBackgroundColor = ({ - theme, - isMode, -}: { - theme: InternalTheme; - isMode: (mode: CardMode) => boolean; -}) => { - const { colors } = theme; - if (isMode('contained')) { - return colors.surfaceVariant; - } - if (isMode('outlined')) { - return colors.surface; - } - return undefined; -}; - -export const getCardColors = ({ - theme, - mode, -}: { - theme: InternalTheme; - mode: CardMode; -}) => { - const isMode = (modeToCompare: CardMode) => { - return mode === modeToCompare; - }; - - return { - backgroundColor: getBackgroundColor({ - theme, - isMode, - }), - borderColor: getBorderColor({ theme }), - }; -}; diff --git a/src/components/__tests__/Card/Card.test.tsx b/src/components/__tests__/Card/Card.test.tsx index 051db81eef..35925d7dd7 100644 --- a/src/components/__tests__/Card/Card.test.tsx +++ b/src/components/__tests__/Card/Card.test.tsx @@ -1,14 +1,14 @@ -import { Platform, StyleSheet, Text, View } from 'react-native'; +import * as React from 'react'; +import { StyleSheet, Text, View } from 'react-native'; import type { StyleProp, ViewStyle } from 'react-native'; import { afterEach, describe, expect, it, jest } from '@jest/globals'; import { render, screen } from '../../../test-utils'; import { LightTheme } from '../../../theme/schemes'; -import { Palette } from '../../../theme/tokens'; import Button from '../../Button/Button'; import Card from '../../Card/Card'; -import { getCardColors, getCardCoverStyle } from '../../Card/utils'; +import { getCardCoverStyle } from '../../Card/utils'; const styles = StyleSheet.create({ customCoverRadius: { @@ -30,94 +30,125 @@ afterEach(() => { }); describe('Card', () => { - it('renders an outlined card', async () => { - const tree = (await render({null})).toJSON(); + it('renders populated slots in deterministic order without rewriting nodes', async () => { + const CustomContent = React.memo(() => ( + + Custom content + + )); - expect(tree).toMatchSnapshot(); - }); - - it('renders an outlined card with a custom outline color', async () => { - const { toJSON } = await render( + await render( - {null} - + media={} + header={} + content={[ + , + null, + , + ]} + actions={ + <> + {null} + + + } + /> ); - expect(toJSON()).toMatchSnapshot(); + expect(screen.getAllByTestId(/^(region-|content-custom-wrapper)/)).toEqual([ + screen.getByTestId('region-media'), + screen.getByTestId('region-header'), + screen.getByTestId('region-content-array'), + screen.getByTestId('content-custom-wrapper'), + screen.getByTestId('region-actions'), + ]); }); - it('renders an outlined card with custom border color', async () => { - const { toJSON } = await render( - - {null} - - ); + it('renders omitted slots as a neutral filled grouping container', async () => { + await render(); - expect(toJSON()).toMatchSnapshot(); + expect(screen.getByTestId('card-container')).toHaveStyle({ + backgroundColor: getTheme().colors.surfaceVariant, + }); + expect(screen.queryByRole('button')).not.toBeOnTheScreen(); }); - it('renders with a custom theme background color', async () => { - jest.replaceProperty(Platform, 'OS', 'web'); - + it('renders the convenience header inputs', async () => { await render( - {null} - + title="Card title" + subtitle="Card subtitle" + leading={({ size }) => Leading {size}} + trailing={({ size }) => Trailing {size}} + /> ); - expect(screen.getByLabelText('card')).toHaveStyle({ - backgroundColor: '#0000FF', - }); + expect(screen.getByText('Card title')).toBeOnTheScreen(); + expect(screen.getByText('Card subtitle')).toBeOnTheScreen(); + expect(screen.getByText('Leading 40')).toBeOnTheScreen(); + expect(screen.getByText('Trailing 24')).toBeOnTheScreen(); }); it('renders with a content style', async () => { await render( - - Content - + Content} contentStyle={styles.contentStyle} /> ); expect(screen.getByText('Content').parent).toHaveStyle(styles.contentStyle); }); - it('does not render a disabled accessibility state', async () => { - await render({null}); + it('does render a disabled accessibility state', async () => { + await render( {}} disabled />); - expect(screen.getByTestId('card')).toBeEnabled(); + expect(screen.getByTestId('card')).toBeDisabled(); }); - it('does render a disabled accessibility state', async () => { - await render( - {}} disabled> - {null} - +}); + +describe('Card types', () => { + it('rejects the removed API and mixed header forms', () => { + const typeCases = ( + <> + + } + trailing={({ size }) => } + /> + } /> + Content} + actions={[]} + /> + + {/* @ts-expect-error: Arbitrary children composition was removed. */} + + + + + {/* @ts-expect-error: The old mode prop was removed. */} + + + {/* @ts-expect-error: Custom and convenience headers are mutually exclusive. */} + } title="Title" /> + + {/* @ts-expect-error: Custom and convenience headers are mutually exclusive. */} + } leading={() => } /> + ); - expect(screen.getByTestId('card')).toBeDisabled(); + expect(typeCases).toBeDefined(); }); }); describe('CardCover', () => { it('renders with custom border radius', async () => { await render( - - - + ); expect(screen.getByTestId('card-cover')).toHaveStyle( @@ -142,19 +173,17 @@ describe('CardContent', () => { it('uses fixed padding regardless of neighboring card elements', async () => { await render( - + <> - <> - - - Content - - - + + + Content + + - + ); expect(screen.getByTestId('card-content')).toHaveStyle({ @@ -257,49 +286,6 @@ describe('CardActions', () => { }); }); -describe('getCardColors - background color', () => { - it('should return correct theme color, for theme version 3, contained mode', () => { - expect( - getCardColors({ - theme: LightTheme, - mode: 'contained', - }) - ).toMatchObject({ - backgroundColor: LightTheme.colors.surfaceVariant, - }); - }); - - it('should return correct theme color, for theme version 3, outlined mode', () => { - expect( - getCardColors({ - theme: LightTheme, - mode: 'outlined', - }) - ).toMatchObject({ backgroundColor: LightTheme.colors.surface }); - }); - - it('should return undefined, for theme version 3, elevated mode', () => { - expect( - getCardColors({ - theme: LightTheme, - mode: 'elevated', - }) - ).toMatchObject({ backgroundColor: undefined }); - }); -}); - -describe('getCardColors - border color', () => { - it('should return correct theme color, for theme version 3', () => { - expect( - getCardColors({ - theme: LightTheme, - // @ts-expect-error: Verify the runtime fallback when mode is omitted. - mode: undefined, - }) - ).toMatchObject({ borderColor: LightTheme.colors.outline }); - }); -}); - describe('getCardCoverStyle - border radius', () => { it('should return custom border radius', () => { expect( diff --git a/src/components/__tests__/Card/__snapshots__/Card.test.tsx.snap b/src/components/__tests__/Card/__snapshots__/Card.test.tsx.snap deleted file mode 100644 index 216e5b5d08..0000000000 --- a/src/components/__tests__/Card/__snapshots__/Card.test.tsx.snap +++ /dev/null @@ -1,371 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Card renders an outlined card 1`] = ` - - - - - -`; - -exports[`Card renders an outlined card with a custom outline color 1`] = ` - - - - - -`; - -exports[`Card renders an outlined card with custom border color 1`] = ` - - - - - -`; From 9362353d5bd91657b55a9da326296aefb200b432 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Kata?= Date: Fri, 4 Sep 2026 13:22:31 +0200 Subject: [PATCH 03/11] feat(card): add Material variant token contract --- src/components/Card/Card.tsx | 303 ++++++++++++++---- src/components/Card/tokens.ts | 280 ++++++++++++++++ src/components/__tests__/Card/Card.test.tsx | 201 +++++++++++- .../__tests__/Card/Card.tokens.test.ts | 248 ++++++++++++++ 4 files changed, 974 insertions(+), 58 deletions(-) create mode 100644 src/components/Card/tokens.ts create mode 100644 src/components/__tests__/Card/Card.tokens.test.ts diff --git a/src/components/Card/Card.tsx b/src/components/Card/Card.tsx index 787cd5e374..9785b4e2c4 100644 --- a/src/components/Card/Card.tsx +++ b/src/components/Card/Card.tsx @@ -12,8 +12,9 @@ import CardContent from './CardContent'; import CardCover from './CardCover'; import CardTitle from './CardTitle'; import type { Props as CardTitleProps } from './CardTitle'; +import { resolveCardVisuals } from './tokens'; import { useInternalTheme } from '../../core/theming'; -import type { ThemeProp } from '../../theme/types'; +import type { Elevation, ThemeProp } from '../../theme/types'; import hasTouchHandler from '../../utils/hasTouchHandler'; import Surface from '../Surface'; import type { SurfaceStyle } from '../Surface'; @@ -64,71 +65,169 @@ type CustomHeaderProps = { trailing?: never; }; -type CardBaseProps = Omit & { +type CardShapeProps = { /** - * Media rendered at the start of the Card. + * Radius of every Card corner. */ - media?: React.ReactNode; + borderRadius?: ViewStyle['borderRadius']; /** - * Main Card content. + * Radius of the Card's bottom-end corner. */ - content?: React.ReactNode; + borderBottomEndRadius?: ViewStyle['borderBottomEndRadius']; /** - * Actions rendered at the end of the Card. + * Radius of the Card's bottom-left corner. */ - actions?: React.ReactNode; + borderBottomLeftRadius?: ViewStyle['borderBottomLeftRadius']; /** - * Function to execute on long press. + * Radius of the Card's bottom-right corner. */ - onLongPress?: () => void; + borderBottomRightRadius?: ViewStyle['borderBottomRightRadius']; /** - * Function to execute on press. + * Radius of the Card's bottom-start corner. */ - onPress?: (e: GestureResponderEvent) => void; + borderBottomStartRadius?: ViewStyle['borderBottomStartRadius']; /** - * Function to execute as soon as the touchable element is pressed and invoked even before onPress. + * Radius of the Card's end-end corner. */ - onPressIn?: (e: GestureResponderEvent) => void; + borderEndEndRadius?: ViewStyle['borderEndEndRadius']; /** - * Function to execute as soon as the touch is released even before onPress. + * Radius of the Card's end-start corner. */ - onPressOut?: (e: GestureResponderEvent) => void; + borderEndStartRadius?: ViewStyle['borderEndStartRadius']; /** - * The number of milliseconds a user must touch the element before executing `onLongPress`. + * Radius of the Card's start-end corner. */ - delayLongPress?: number; + borderStartEndRadius?: ViewStyle['borderStartEndRadius']; /** - * If true, disable all interactions for this component. + * Radius of the Card's start-start corner. */ - disabled?: boolean; + borderStartStartRadius?: ViewStyle['borderStartStartRadius']; /** - * Style of card's inner content. + * Radius of the Card's top-end corner. */ - contentStyle?: StyleProp; - style?: StyleProp; + borderTopEndRadius?: ViewStyle['borderTopEndRadius']; /** - * @optional + * Radius of the Card's top-left corner. */ - theme?: ThemeProp; + borderTopLeftRadius?: ViewStyle['borderTopLeftRadius']; /** - * Pass down testID from card props to touchable + * Radius of the Card's top-right corner. */ - testID?: string; + borderTopRightRadius?: ViewStyle['borderTopRightRadius']; /** - * Pass down accessible from card props to touchable + * Radius of the Card's top-start corner. */ - accessible?: boolean; + borderTopStartRadius?: ViewStyle['borderTopStartRadius']; /** - * Reference to the card container. + * Corner curve used by the Card on iOS. */ - ref?: React.Ref; + borderCurve?: ViewStyle['borderCurve']; }; +type FilledCardProps = { + /** + * Filled Card variant (default). + */ + variant?: 'filled'; + /** + * Filled Cards do not support custom elevation. + */ + elevation?: never; +}; + +type ElevatedCardProps = { + /** + * Elevated Card variant. + */ + variant: 'elevated'; + /** + * Resting shadow elevation for an elevated Card. + */ + elevation?: Elevation; +}; + +type OutlinedCardProps = { + /** + * Outlined Card variant. + */ + variant: 'outlined'; + /** + * Outlined Cards do not support custom elevation. + */ + elevation?: never; +}; + +type CardVariantProps = FilledCardProps | ElevatedCardProps | OutlinedCardProps; + +type CardBaseProps = Omit & + CardShapeProps & { + /** + * Media rendered at the start of the Card. + */ + media?: React.ReactNode; + /** + * Main Card content. + */ + content?: React.ReactNode; + /** + * Actions rendered at the end of the Card. + */ + actions?: React.ReactNode; + /** + * Function to execute on long press. + */ + onLongPress?: () => void; + /** + * Function to execute on press. + */ + onPress?: (e: GestureResponderEvent) => void; + /** + * Function to execute as soon as the touchable element is pressed and invoked even before onPress. + */ + onPressIn?: (e: GestureResponderEvent) => void; + /** + * Function to execute as soon as the touch is released even before onPress. + */ + onPressOut?: (e: GestureResponderEvent) => void; + /** + * The number of milliseconds a user must touch the element before executing `onLongPress`. + */ + delayLongPress?: number; + /** + * If true, disable all interactions for this component. + */ + disabled?: boolean; + /** + * Style of card's inner content. + */ + contentStyle?: StyleProp; + style?: StyleProp; + /** + * @optional + */ + theme?: ThemeProp; + /** + * Pass down testID from card props to touchable + */ + testID?: string; + /** + * Pass down accessible from card props to touchable + */ + accessible?: boolean; + /** + * Reference to the card container. + */ + ref?: React.Ref; + }; + export type Props = CardBaseProps & - (ConvenienceHeaderProps | CustomHeaderProps); + (ConvenienceHeaderProps | CustomHeaderProps) & + CardVariantProps; /** - * A filled Card groups related media, header content, body content, and actions. + * A Card groups related media, header content, body content, and actions. + * Use the `filled` (default), `elevated`, or `outlined` variant to select its + * Material 3 emphasis. * * ## Usage * ```js @@ -139,6 +238,7 @@ export type Props = CardBaseProps & * * const MyComponent = () => ( * } * title="Card Title" * subtitle="Card Subtitle" @@ -159,6 +259,8 @@ export type Props = CardBaseProps & */ const Card = ({ + variant: cardVariant = 'filled', + elevation: customElevation, delayLongPress, onPress, onLongPress, @@ -178,11 +280,32 @@ const Card = ({ testID = 'card', accessible, disabled, + borderRadius, + borderBottomEndRadius, + borderBottomLeftRadius, + borderBottomRightRadius, + borderBottomStartRadius, + borderEndEndRadius, + borderEndStartRadius, + borderStartEndRadius, + borderStartStartRadius, + borderTopEndRadius, + borderTopLeftRadius, + borderTopRightRadius, + borderTopStartRadius, + borderCurve = 'continuous', ref, ...rest }: Props) => { const theme = useInternalTheme(themeOverrides); + const visuals = resolveCardVisuals({ + theme, + variant: cardVariant, + elevation: customElevation, + disabled, + }); + const hasPassedTouchHandler = hasTouchHandler({ onPress, onLongPress, @@ -190,12 +313,27 @@ const Card = ({ onPressOut, }); - const borderRadius = theme.shapes.corner.medium; + const shapeStyle = { + borderRadius: borderRadius ?? visuals.shape, + borderBottomEndRadius, + borderBottomLeftRadius, + borderBottomRightRadius, + borderBottomStartRadius, + borderEndEndRadius, + borderEndStartRadius, + borderStartEndRadius, + borderStartStartRadius, + borderTopEndRadius, + borderTopLeftRadius, + borderTopRightRadius, + borderTopStartRadius, + borderCurve, + }; const hasConvenienceHeader = title != null || subtitle != null || leading != null || trailing != null; const content = ( - + {media} {header ?? (hasConvenienceHeader ? ( @@ -214,30 +352,80 @@ const Card = ({ return ( - {hasPassedTouchHandler ? ( - - {content} - - ) : ( - content - )} + + + + {hasPassedTouchHandler ? ( + + {content} + + ) : ( + content + )} + {visuals.outlineWidth > 0 ? ( + + ) : null} + ); }; @@ -254,8 +442,13 @@ Card.Cover = CardCover; Card.Title = CardTitle; const styles = StyleSheet.create({ - innerContainer: { + visual: { + flexShrink: 1, + overflow: 'hidden', + }, + content: { flexShrink: 1, + position: 'relative', }, }); diff --git a/src/components/Card/tokens.ts b/src/components/Card/tokens.ts new file mode 100644 index 0000000000..aaa00a2f11 --- /dev/null +++ b/src/components/Card/tokens.ts @@ -0,0 +1,280 @@ +import { tokens as systemTokens } from '../../theme/tokens'; +import type { Elevation, InternalTheme } from '../../theme/types'; + +export const cardVariants = ['filled', 'elevated', 'outlined'] as const; + +export type CardVariant = (typeof cardVariants)[number]; + +export const cardStates = [ + 'enabled', + 'hovered', + 'focused', + 'pressed', + 'dragged', + 'disabled', +] as const; + +export type CardState = (typeof cardStates)[number]; + +type ContainerColorRole = + | 'surfaceContainerLow' + | 'surfaceContainerHighest' + | 'surfaceVariant' + | 'surface'; + +type OutlineColorRole = 'outlineVariant' | 'onSurface' | 'outline'; + +type CardStateTokens = { + containerColor: ContainerColorRole; + containerOpacity: number; + outlineColor: OutlineColorRole; + outlineOpacity: number; + outlineWidth: 0 | 1; + elevation: Elevation; + stateLayerOpacity: number; +}; + +type CardTokenMatrix = Record>; + +const { opacity } = systemTokens.md.sys.state; + +/** + * Material 3 Card variant-by-state tokens. + * + * Rechecked 2026-09-04 against the Material 3 Card specification and the + * current AndroidX generated Card tokens at commit + * 160825094a81825468a95b115bfb1b541e549856: + * https://m3.material.io/components/cards/specs + * - FilledCardTokens v0_210 + * - ElevatedCardTokens v0_210 + * - OutlinedCardTokens v0_192 + * https://github.com/androidx/androidx/tree/160825094a81825468a95b115bfb1b541e549856/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/tokens + * + * State opacities were rechecked against Material Components Android generated + * token set 34.0.0 at commit 4d3710682140722f48a5965b68109b240e1fe79e. + * https://github.com/material-components/material-components-android/tree/4d3710682140722f48a5965b68109b240e1fe79e/lib/java/com/google/android/material + */ +const cardTokenMatrix = { + filled: { + enabled: { + containerColor: 'surfaceContainerHighest', + containerOpacity: 1, + outlineColor: 'outlineVariant', + outlineOpacity: 0, + outlineWidth: 0, + elevation: 0, + stateLayerOpacity: 0, + }, + hovered: { + containerColor: 'surfaceContainerHighest', + containerOpacity: 1, + outlineColor: 'outlineVariant', + outlineOpacity: 0, + outlineWidth: 0, + elevation: 1, + stateLayerOpacity: opacity.hovered, + }, + focused: { + containerColor: 'surfaceContainerHighest', + containerOpacity: 1, + outlineColor: 'outlineVariant', + outlineOpacity: 0, + outlineWidth: 0, + elevation: 0, + stateLayerOpacity: opacity.focused, + }, + pressed: { + containerColor: 'surfaceContainerHighest', + containerOpacity: 1, + outlineColor: 'outlineVariant', + outlineOpacity: 0, + outlineWidth: 0, + elevation: 0, + stateLayerOpacity: opacity.pressed, + }, + dragged: { + containerColor: 'surfaceContainerHighest', + containerOpacity: 1, + outlineColor: 'outlineVariant', + outlineOpacity: 0, + outlineWidth: 0, + elevation: 3, + stateLayerOpacity: opacity.dragged, + }, + disabled: { + containerColor: 'surfaceVariant', + containerOpacity: opacity.disabled, + outlineColor: 'outlineVariant', + outlineOpacity: 0, + outlineWidth: 0, + elevation: 0, + stateLayerOpacity: 0, + }, + }, + elevated: { + enabled: { + containerColor: 'surfaceContainerLow', + containerOpacity: 1, + outlineColor: 'outlineVariant', + outlineOpacity: 0, + outlineWidth: 0, + elevation: 1, + stateLayerOpacity: 0, + }, + hovered: { + containerColor: 'surfaceContainerLow', + containerOpacity: 1, + outlineColor: 'outlineVariant', + outlineOpacity: 0, + outlineWidth: 0, + elevation: 2, + stateLayerOpacity: opacity.hovered, + }, + focused: { + containerColor: 'surfaceContainerLow', + containerOpacity: 1, + outlineColor: 'outlineVariant', + outlineOpacity: 0, + outlineWidth: 0, + elevation: 1, + stateLayerOpacity: opacity.focused, + }, + pressed: { + containerColor: 'surfaceContainerLow', + containerOpacity: 1, + outlineColor: 'outlineVariant', + outlineOpacity: 0, + outlineWidth: 0, + elevation: 1, + stateLayerOpacity: opacity.pressed, + }, + dragged: { + containerColor: 'surfaceContainerLow', + containerOpacity: 1, + outlineColor: 'outlineVariant', + outlineOpacity: 0, + outlineWidth: 0, + elevation: 4, + stateLayerOpacity: opacity.dragged, + }, + disabled: { + containerColor: 'surface', + containerOpacity: opacity.disabled, + outlineColor: 'outlineVariant', + outlineOpacity: 0, + outlineWidth: 0, + elevation: 1, + stateLayerOpacity: 0, + }, + }, + outlined: { + enabled: { + containerColor: 'surface', + containerOpacity: 1, + outlineColor: 'outlineVariant', + outlineOpacity: 1, + outlineWidth: 1, + elevation: 0, + stateLayerOpacity: 0, + }, + hovered: { + containerColor: 'surface', + containerOpacity: 1, + outlineColor: 'outlineVariant', + outlineOpacity: 1, + outlineWidth: 1, + elevation: 1, + stateLayerOpacity: opacity.hovered, + }, + focused: { + containerColor: 'surface', + containerOpacity: 1, + outlineColor: 'onSurface', + outlineOpacity: 1, + outlineWidth: 1, + elevation: 0, + stateLayerOpacity: opacity.focused, + }, + pressed: { + containerColor: 'surface', + containerOpacity: 1, + outlineColor: 'outlineVariant', + outlineOpacity: 1, + outlineWidth: 1, + elevation: 0, + stateLayerOpacity: opacity.pressed, + }, + dragged: { + containerColor: 'surface', + containerOpacity: 1, + outlineColor: 'outlineVariant', + outlineOpacity: 1, + outlineWidth: 1, + elevation: 3, + stateLayerOpacity: opacity.dragged, + }, + disabled: { + containerColor: 'surface', + containerOpacity: 1, + outlineColor: 'outline', + outlineOpacity: 0.12, + outlineWidth: 1, + elevation: 0, + stateLayerOpacity: 0, + }, + }, +} as const satisfies CardTokenMatrix; + +export type CardStateFlags = { + disabled?: boolean; + dragged?: boolean; + pressed?: boolean; + focused?: boolean; + hovered?: boolean; +}; + +export type ResolveCardVisualsOptions = CardStateFlags & { + theme: InternalTheme; + variant: CardVariant; + elevation?: Elevation; +}; + +export const resolveCardVisuals = ({ + theme, + variant, + elevation: customElevation, + disabled = false, + dragged = false, + pressed = false, + focused = false, + hovered = false, +}: ResolveCardVisualsOptions) => { + const state: CardState = disabled + ? 'disabled' + : dragged + ? 'dragged' + : pressed + ? 'pressed' + : focused + ? 'focused' + : hovered + ? 'hovered' + : 'enabled'; + const stateTokens = cardTokenMatrix[variant][state]; + + return { + state, + containerColor: theme.colors[stateTokens.containerColor], + containerOpacity: stateTokens.containerOpacity, + outlineColor: theme.colors[stateTokens.outlineColor], + outlineOpacity: stateTokens.outlineOpacity, + outlineWidth: stateTokens.outlineWidth, + elevation: + variant === 'elevated' && state === 'enabled' && customElevation != null + ? customElevation + : stateTokens.elevation, + shape: theme.shapes.corner.medium, + stateLayerColor: theme.colors.onSurface, + stateLayerOpacity: stateTokens.stateLayerOpacity, + }; +}; diff --git a/src/components/__tests__/Card/Card.test.tsx b/src/components/__tests__/Card/Card.test.tsx index 35925d7dd7..d9f336e76e 100644 --- a/src/components/__tests__/Card/Card.test.tsx +++ b/src/components/__tests__/Card/Card.test.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { StyleSheet, Text, View } from 'react-native'; +import { Platform, StyleSheet, Text, View } from 'react-native'; import type { StyleProp, ViewStyle } from 'react-native'; import { afterEach, describe, expect, it, jest } from '@jest/globals'; @@ -30,6 +30,185 @@ afterEach(() => { }); describe('Card', () => { + it.each([ + { + variant: 'filled' as const, + colorRole: 'surfaceContainerHighest' as const, + }, + { + variant: 'elevated' as const, + colorRole: 'surfaceContainerLow' as const, + }, + { variant: 'outlined' as const, colorRole: 'surface' as const }, + ])( + 'renders the enabled $variant appearance in light and dark themes', + async ({ variant, colorRole }) => { + for (const isDark of [false, true] as const) { + const theme = getTheme(isDark); + const card = + variant === 'elevated' ? ( + + ) : variant === 'outlined' ? ( + + ) : ( + + ); + const { unmount } = await render(card); + + expect(screen.getByTestId('card-visual')).toHaveStyle({ + backgroundColor: theme.colors[colorRole], + }); + + await unmount(); + } + } + ); + + it('renders the enabled outlined role in light and dark themes', async () => { + for (const isDark of [false, true] as const) { + const theme = getTheme(isDark); + const { unmount } = await render( + + ); + + expect(screen.getByTestId('card-outline')).toHaveStyle({ + borderColor: theme.colors.outlineVariant, + borderWidth: 1, + opacity: 1, + }); + + await unmount(); + } + }); + + it.each(['filled', 'elevated'] as const)( + 'does not render an outline for the %s variant', + async (variant) => { + const card = + variant === 'elevated' ? ( + + ) : ( + + ); + + await render(card); + + expect(screen.queryByTestId('card-outline')).not.toBeOnTheScreen(); + } + ); + + it('uses filled as the default and resolves deeply merged custom colors', async () => { + await render( + + ); + + expect(screen.getByTestId('card-visual')).toHaveStyle({ + backgroundColor: '#111111', + }); + expect(screen.getByTestId('card-state-layer')).toHaveStyle({ + backgroundColor: '#222222', + opacity: 0, + }); + }); + + it('uses custom theme roles for elevated and outlined variants', async () => { + const { unmount } = await render( + + ); + + expect(screen.getByTestId('card-visual')).toHaveStyle({ + backgroundColor: '#123456', + }); + await unmount(); + + await render( + + ); + + expect(screen.getByTestId('card-visual')).toHaveStyle({ + backgroundColor: '#abcdef', + }); + expect(screen.getByTestId('card-outline')).toHaveStyle({ + borderColor: '#654321', + }); + }); + + it('lets only elevated Cards customize their resting elevation', async () => { + jest.replaceProperty(Platform, 'OS', 'android'); + + await render(); + + expect(screen.getByTestId('card-container')).toHaveStyle({ elevation: 12 }); + }); + + it.each([ + { variant: 'filled' as const, elevation: 0 }, + { variant: 'elevated' as const, elevation: 1 }, + { variant: 'outlined' as const, elevation: 0 }, + ])( + 'renders the enabled $variant elevation', + async ({ variant, elevation }) => { + jest.replaceProperty(Platform, 'OS', 'android'); + const card = + variant === 'elevated' ? ( + + ) : variant === 'outlined' ? ( + + ) : ( + + ); + + await render(card); + + expect(screen.getByTestId('card-container')).toHaveStyle({ elevation }); + expect(screen.getByTestId('card-container')).toHaveStyle({ + backgroundColor: 'transparent', + }); + } + ); + + it('applies the medium shape and asymmetric overrides across the shell', async () => { + await render( + + ); + + const expectedShape = { + borderRadius: getTheme().shapes.corner.medium, + borderTopLeftRadius: 4, + borderTopRightRadius: 8, + borderBottomRightRadius: 16, + borderBottomLeftRadius: 20, + borderCurve: 'continuous', + }; + + expect(screen.getByTestId('card-container')).toHaveStyle(expectedShape); + expect(screen.getByTestId('card-visual')).toHaveStyle(expectedShape); + expect(screen.getByTestId('card-background')).toHaveStyle(expectedShape); + expect(screen.getByTestId('card-state-layer')).toHaveStyle(expectedShape); + expect(screen.getByTestId('card-outline')).toHaveStyle(expectedShape); + }); + it('renders populated slots in deterministic order without rewriting nodes', async () => { const CustomContent = React.memo(() => ( @@ -67,8 +246,8 @@ describe('Card', () => { it('renders omitted slots as a neutral filled grouping container', async () => { await render(); - expect(screen.getByTestId('card-container')).toHaveStyle({ - backgroundColor: getTheme().colors.surfaceVariant, + expect(screen.getByTestId('card-visual')).toHaveStyle({ + backgroundColor: getTheme().colors.surfaceContainerHighest, }); expect(screen.queryByRole('button')).not.toBeOnTheScreen(); }); @@ -120,6 +299,10 @@ describe('Card types', () => { content={Content} actions={[]} /> + + + + {/* @ts-expect-error: Arbitrary children composition was removed. */} @@ -129,6 +312,18 @@ describe('Card types', () => { {/* @ts-expect-error: The old mode prop was removed. */} + {/* @ts-expect-error: Contained is not a Card variant. */} + + + {/* @ts-expect-error: The default filled Card cannot be elevated. */} + + + {/* @ts-expect-error: Filled Cards cannot be elevated. */} + + + {/* @ts-expect-error: Outlined Cards cannot be elevated. */} + + {/* @ts-expect-error: Custom and convenience headers are mutually exclusive. */} } title="Title" /> diff --git a/src/components/__tests__/Card/Card.tokens.test.ts b/src/components/__tests__/Card/Card.tokens.test.ts new file mode 100644 index 0000000000..c89a1d93a5 --- /dev/null +++ b/src/components/__tests__/Card/Card.tokens.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, it } from '@jest/globals'; + +import { LightTheme } from '../../../theme/schemes'; +import { + cardStates, + cardVariants, + resolveCardVisuals, +} from '../../Card/tokens'; +import type { CardState } from '../../Card/tokens'; + +const stateFlags: Record< + CardState, + Partial< + Record<'disabled' | 'dragged' | 'pressed' | 'focused' | 'hovered', boolean> + > +> = { + enabled: {}, + hovered: { hovered: true }, + focused: { focused: true }, + pressed: { pressed: true }, + dragged: { dragged: true }, + disabled: { disabled: true }, +}; + +type ExpectedVisualTokens = { + containerRole: + | 'surfaceContainerLow' + | 'surfaceContainerHighest' + | 'surfaceVariant' + | 'surface'; + containerOpacity: number; + outlineRole: 'outlineVariant' | 'onSurface' | 'outline'; + outlineOpacity: number; + outlineWidth: 0 | 1; + elevation: 0 | 1 | 2 | 3 | 4 | 5; + stateLayerOpacity: number; +}; + +const expectedVisual = ( + tokens: Pick< + ExpectedVisualTokens, + 'containerRole' | 'elevation' | 'stateLayerOpacity' + > & + Partial +): ExpectedVisualTokens => ({ + containerOpacity: 1, + outlineRole: 'outlineVariant', + outlineOpacity: 0, + outlineWidth: 0, + ...tokens, +}); + +const expectedOutlinedVisual = ( + tokens: Pick & + Partial +): ExpectedVisualTokens => + expectedVisual({ + containerRole: 'surface', + outlineOpacity: 1, + outlineWidth: 1, + ...tokens, + }); + +const expectedVisuals: Record< + (typeof cardVariants)[number], + ExpectedVisualTokens[] +> = { + elevated: [ + expectedVisual({ + containerRole: 'surfaceContainerLow', + elevation: 1, + stateLayerOpacity: 0, + }), + expectedVisual({ + containerRole: 'surfaceContainerLow', + elevation: 2, + stateLayerOpacity: 0.08, + }), + expectedVisual({ + containerRole: 'surfaceContainerLow', + elevation: 1, + stateLayerOpacity: 0.1, + }), + expectedVisual({ + containerRole: 'surfaceContainerLow', + elevation: 1, + stateLayerOpacity: 0.1, + }), + expectedVisual({ + containerRole: 'surfaceContainerLow', + elevation: 4, + stateLayerOpacity: 0.16, + }), + expectedVisual({ + containerRole: 'surface', + containerOpacity: 0.38, + elevation: 1, + stateLayerOpacity: 0, + }), + ], + filled: [ + expectedVisual({ + containerRole: 'surfaceContainerHighest', + elevation: 0, + stateLayerOpacity: 0, + }), + expectedVisual({ + containerRole: 'surfaceContainerHighest', + elevation: 1, + stateLayerOpacity: 0.08, + }), + expectedVisual({ + containerRole: 'surfaceContainerHighest', + elevation: 0, + stateLayerOpacity: 0.1, + }), + expectedVisual({ + containerRole: 'surfaceContainerHighest', + elevation: 0, + stateLayerOpacity: 0.1, + }), + expectedVisual({ + containerRole: 'surfaceContainerHighest', + elevation: 3, + stateLayerOpacity: 0.16, + }), + expectedVisual({ + containerRole: 'surfaceVariant', + containerOpacity: 0.38, + elevation: 0, + stateLayerOpacity: 0, + }), + ], + outlined: [ + expectedOutlinedVisual({ elevation: 0, stateLayerOpacity: 0 }), + expectedOutlinedVisual({ elevation: 1, stateLayerOpacity: 0.08 }), + expectedOutlinedVisual({ + elevation: 0, + outlineRole: 'onSurface', + stateLayerOpacity: 0.1, + }), + expectedOutlinedVisual({ elevation: 0, stateLayerOpacity: 0.1 }), + expectedOutlinedVisual({ elevation: 3, stateLayerOpacity: 0.16 }), + expectedOutlinedVisual({ + elevation: 0, + outlineRole: 'outline', + outlineOpacity: 0.12, + stateLayerOpacity: 0, + }), + ], +}; + +describe('resolveCardVisuals', () => { + it.each(cardVariants)( + 'resolves every Material state for the %s variant', + (variant) => { + const theme = LightTheme; + + expect( + cardStates.map((state) => { + return resolveCardVisuals({ + theme, + variant, + ...stateFlags[state], + }); + }) + ).toEqual( + expectedVisuals[variant].map( + ( + { + containerRole, + containerOpacity, + outlineRole, + outlineOpacity, + outlineWidth, + elevation, + stateLayerOpacity, + }, + index + ) => ({ + state: cardStates[index], + containerColor: theme.colors[containerRole], + containerOpacity, + outlineColor: theme.colors[outlineRole], + outlineOpacity, + outlineWidth, + elevation, + shape: theme.shapes.corner.medium, + stateLayerColor: theme.colors.onSurface, + stateLayerOpacity, + }) + ) + ); + } + ); + + it('uses disabled, dragged, pressed, focused, hovered, then enabled precedence', () => { + const theme = LightTheme; + const common = { theme, variant: 'filled' as const }; + + expect([ + resolveCardVisuals({ + ...common, + disabled: true, + dragged: true, + pressed: true, + focused: true, + hovered: true, + }).state, + resolveCardVisuals({ + ...common, + dragged: true, + pressed: true, + focused: true, + hovered: true, + }).state, + resolveCardVisuals({ + ...common, + pressed: true, + focused: true, + hovered: true, + }).state, + resolveCardVisuals({ + ...common, + focused: true, + hovered: true, + }).state, + resolveCardVisuals({ ...common, hovered: true }).state, + resolveCardVisuals(common).state, + ]).toEqual(cardStates.toReversed()); + }); + + it('uses a custom resting elevation only for the enabled elevated state', () => { + const theme = LightTheme; + + expect( + resolveCardVisuals({ theme, variant: 'elevated', elevation: 5 }).elevation + ).toBe(5); + expect( + resolveCardVisuals({ + theme, + variant: 'elevated', + elevation: 5, + hovered: true, + }).elevation + ).toBe(2); + }); +}); From 4e37e955ee86742ca68985f0c4e62dd2ae527159 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Kata?= Date: Fri, 4 Sep 2026 13:36:47 +0200 Subject: [PATCH 04/11] feat(card): modernize responsive cover media --- src/components/Card/CardCover.tsx | 84 +++++------- src/components/Card/utils.tsx | 31 ----- src/components/__tests__/Card/Card.test.tsx | 137 +++++++++++++++----- 3 files changed, 140 insertions(+), 112 deletions(-) delete mode 100644 src/components/Card/utils.tsx diff --git a/src/components/Card/CardCover.tsx b/src/components/Card/CardCover.tsx index 99345cea64..aefb625a9d 100644 --- a/src/components/Card/CardCover.tsx +++ b/src/components/Card/CardCover.tsx @@ -1,22 +1,16 @@ -import { Image, StyleSheet, View } from 'react-native'; -import type { ImageProps, StyleProp, ViewStyle } from 'react-native'; +import { Image, StyleSheet } from 'react-native'; +import type { ImageProps, ImageStyle, StyleProp } from 'react-native'; -import { getCardCoverStyle } from './utils'; -import { useInternalTheme } from '../../core/theming'; import { grey200 } from '../../theme/colors'; import type { ThemeProp } from '../../theme/types'; -import { splitStyles } from '../../utils/splitStyles'; -export type Props = ImageProps & { +export type Props = Omit & { /** - * @internal + * Style for the cover image. The default size is full width by 195. Consumer + * styles are applied after these defaults. Supplying an `aspectRatio` + * removes the default height so the cover can resize responsively. */ - index?: number; - /** - * @internal - */ - total?: number; - style?: StyleProp; + style?: StyleProp; /** * @optional */ @@ -26,6 +20,12 @@ export type Props = ImageProps & { /** * A component to show a cover image inside a Card. * + * Card owns clipping when the cover is used in its `media` slot, so the image + * follows the Card's default or custom shape without adding another radius. + * Hide decorative covers from screen readers with `accessible={false}` and + * `aria-hidden`. For informative covers, provide `accessible`, + * `accessibilityRole="image"`, and a useful `accessibilityLabel`. + * * ## Usage * ```js * import * as React from 'react'; @@ -33,7 +33,15 @@ export type Props = ImageProps & { * * const MyComponent = () => ( * } + * media={ + * + * } * /> * ); * @@ -42,52 +50,28 @@ export type Props = ImageProps & { * * @extends Image props https://reactnative.dev/docs/image#props */ -const CardCover = ({ - index, - total, - style, - theme: themeOverrides, - ...rest -}: Props) => { - const theme = useInternalTheme(themeOverrides); - - const flattenedStyles = StyleSheet.flatten(style) || {}; - const [, borderRadiusStyles] = splitStyles( - flattenedStyles, - (style) => style.startsWith('border') && style.endsWith('Radius') - ); - - const coverStyle = getCardCoverStyle({ - theme, - index, - total, - borderRadiusStyles, - }); +const CardCover = ({ style, theme: _theme, ...rest }: Props) => { + const usesAspectRatio = StyleSheet.flatten(style)?.aspectRatio !== undefined; return ( - - - + ); }; CardCover.displayName = 'Card.Cover'; const styles = StyleSheet.create({ - container: { - height: 195, - backgroundColor: grey200, - overflow: 'hidden', - }, image: { - flex: 1, - height: undefined, - width: undefined, + width: '100%', + backgroundColor: grey200, justifyContent: 'flex-end', }, + defaultHeight: { + height: 195, + }, }); export default CardCover; diff --git a/src/components/Card/utils.tsx b/src/components/Card/utils.tsx deleted file mode 100644 index f7fff786a6..0000000000 --- a/src/components/Card/utils.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import type { ViewStyle } from 'react-native'; - -import type { InternalTheme } from '../../theme/types'; - -type BorderRadiusStyles = Pick< - ViewStyle, - Extract ->; - -export const getCardCoverStyle = ({ - theme, - index: _index, - total: _total, - borderRadiusStyles, -}: { - theme: InternalTheme; - borderRadiusStyles: BorderRadiusStyles; - index?: number; - total?: number; -}) => { - if (Object.keys(borderRadiusStyles).length > 0) { - return { - borderRadius: theme.shapes.corner.medium, - ...borderRadiusStyles, - }; - } - - return { - borderRadius: theme.shapes.corner.medium, - }; -}; diff --git a/src/components/__tests__/Card/Card.test.tsx b/src/components/__tests__/Card/Card.test.tsx index d9f336e76e..2de4a9f286 100644 --- a/src/components/__tests__/Card/Card.test.tsx +++ b/src/components/__tests__/Card/Card.test.tsx @@ -8,15 +8,8 @@ import { render, screen } from '../../../test-utils'; import { LightTheme } from '../../../theme/schemes'; import Button from '../../Button/Button'; import Card from '../../Card/Card'; -import { getCardCoverStyle } from '../../Card/utils'; const styles = StyleSheet.create({ - customCoverRadius: { - borderTopLeftRadius: 4, - borderTopRightRadius: 8, - borderBottomLeftRadius: 0, - borderBottomRightRadius: 2, - }, contentStyle: { flexDirection: 'column-reverse', }, @@ -329,6 +322,9 @@ describe('Card types', () => { {/* @ts-expect-error: Custom and convenience headers are mutually exclusive. */} } leading={() => } /> + + {/* @ts-expect-error: Cover placement metadata is not public. */} + ); @@ -337,18 +333,117 @@ describe('Card types', () => { }); describe('CardCover', () => { - it('renders with custom border radius', async () => { + it('uses the documented full-width default size', async () => { await render( ); - expect(screen.getByTestId('card-cover')).toHaveStyle( - styles.customCoverRadius + expect(screen.getByTestId('card-cover')).toHaveStyle({ + width: '100%', + height: 195, + }); + }); + + it('uses an aspect ratio instead of the default height', async () => { + await render( + + ); + + const cover = screen.getByTestId('responsive-cover'); + + expect(cover).toHaveStyle({ width: '100%', aspectRatio: 16 / 9 }); + expect(cover).not.toHaveStyle({ height: 195 }); + }); + + it('exposes supplied semantics for an informative image', async () => { + await render( + + ); + + expect(screen.getByRole('image')).toBe( + screen.getByLabelText('Snow-covered mountains') + ); + }); + + it('preserves explicit decorative image semantics', async () => { + await render( + + ); + + const cover = screen.getByTestId('decorative-cover', { + includeHiddenElements: true, + }); + + expect(cover).toHaveProp('accessible', false); + expect(cover).toHaveProp('aria-hidden', true); + expect(screen.queryByRole('image')).not.toBeOnTheScreen(); + }); + + it('applies consumer image styles after the defaults', async () => { + await render( + + ); + + expect(screen.getByTestId('styled-cover')).toHaveStyle({ + width: 320, + height: 200, + opacity: 0.8, + borderRadius: 6, + }); + }); + + it('uses the Card clipping shape for edge media without double rounding', async () => { + await render( + + } + /> ); + + expect(screen.getByTestId('shaped-card-visual')).toHaveStyle({ + overflow: 'hidden', + borderTopLeftRadius: 4, + borderTopRightRadius: 8, + borderBottomRightRadius: 16, + borderBottomLeftRadius: 20, + }); + expect(screen.getByTestId('edge-cover')).not.toHaveStyle({ + borderRadius: getTheme().shapes.corner.medium, + }); }); }); @@ -480,23 +575,3 @@ describe('CardActions', () => { }); }); }); - -describe('getCardCoverStyle - border radius', () => { - it('should return custom border radius', () => { - expect( - getCardCoverStyle({ - theme: LightTheme, - borderRadiusStyles: styles.customCoverRadius, - }) - ).toMatchObject(styles.customCoverRadius); - }); - - it('should return correct border radius based on roundness, for theme version 3', () => { - expect( - getCardCoverStyle({ - theme: LightTheme, - borderRadiusStyles: {}, - }) - ).toMatchObject({ borderRadius: LightTheme.shapes.corner.medium }); - }); -}); From 63868282a789c430089f2986faa892edbf9c4db7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Kata?= Date: Fri, 4 Sep 2026 13:48:21 +0200 Subject: [PATCH 05/11] feat(card)!: expose one actionable target --- src/components/Card/Card.tsx | 190 +++++++++++- src/components/__tests__/Card/Card.test.tsx | 312 +++++++++++++++++++- 2 files changed, 483 insertions(+), 19 deletions(-) diff --git a/src/components/Card/Card.tsx b/src/components/Card/Card.tsx index 9785b4e2c4..3fcf462c6f 100644 --- a/src/components/Card/Card.tsx +++ b/src/components/Card/Card.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { StyleSheet, Pressable, View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; import type { GestureResponderEvent, StyleProp, @@ -18,6 +18,8 @@ import type { Elevation, ThemeProp } from '../../theme/types'; import hasTouchHandler from '../../utils/hasTouchHandler'; import Surface from '../Surface'; import type { SurfaceStyle } from '../Surface'; +import TouchableRipple from '../TouchableRipple/TouchableRipple'; +import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple'; type ConvenienceHeaderProps = { /** @@ -189,6 +191,14 @@ type CardBaseProps = Omit & * Function to execute as soon as the touch is released even before onPress. */ onPressOut?: (e: GestureResponderEvent) => void; + /** + * Function called when the pointer starts hovering over an actionable Card. + */ + onHoverIn?: TouchableRippleProps['onHoverIn']; + /** + * Function called when the pointer stops hovering over an actionable Card. + */ + onHoverOut?: TouchableRippleProps['onHoverOut']; /** * The number of milliseconds a user must touch the element before executing `onLongPress`. */ @@ -207,15 +217,21 @@ type CardBaseProps = Omit & */ theme?: ThemeProp; /** - * Pass down testID from card props to touchable + * Test ID for the interaction node when the Card is actionable, or the + * content node when it is neutral. The shell and clipped visual region use + * `${testID}-container` and `${testID}-visual` respectively. */ testID?: string; /** - * Pass down accessible from card props to touchable + * Whether the Card's semantic target is an accessibility element. */ accessible?: boolean; /** - * Reference to the card container. + * Reference to the actionable Card interaction node. + */ + touchableRef?: React.Ref; + /** + * Reference to the outer Card shell. */ ref?: React.Ref; }; @@ -229,6 +245,10 @@ export type Props = CardBaseProps & * Use the `filled` (default), `elevated`, or `outlined` variant to select its * Material 3 emphasis. * + * A Card with an interaction handler represents one action. It receives button + * semantics by default and must not contain independent controls in `actions`. + * Use a neutral Card when its actions provide their own interaction targets. + * * ## Usage * ```js * import * as React from 'react'; @@ -280,6 +300,50 @@ const Card = ({ testID = 'card', accessible, disabled, + accessibilityActions, + role, + accessibilityRole, + 'aria-label': ariaLabel, + accessibilityLabel, + accessibilityHint, + accessibilityState, + accessibilityValue, + 'aria-busy': ariaBusy, + 'aria-checked': ariaChecked, + 'aria-disabled': ariaDisabled, + 'aria-expanded': ariaExpanded, + 'aria-hidden': ariaHidden, + 'aria-labelledby': ariaLabelledBy, + 'aria-live': ariaLive, + 'aria-modal': ariaModal, + 'aria-selected': ariaSelected, + 'aria-valuemax': ariaValueMax, + 'aria-valuemin': ariaValueMin, + 'aria-valuenow': ariaValueNow, + 'aria-valuetext': ariaValueText, + accessibilityLabelledBy, + accessibilityLiveRegion, + accessibilityElementsHidden, + accessibilityViewIsModal, + accessibilityIgnoresInvertColors, + accessibilityLanguage, + accessibilityShowsLargeContentViewer, + accessibilityLargeContentTitle, + accessibilityRespondsToUserInteraction, + importantForAccessibility, + screenReaderFocusable, + onAccessibilityAction, + onAccessibilityEscape, + onAccessibilityTap, + onMagicTap, + focusable, + tabIndex, + hitSlop, + onFocus, + onBlur, + onHoverIn, + onHoverOut, + touchableRef, borderRadius, borderBottomEndRadius, borderBottomLeftRadius, @@ -299,11 +363,15 @@ const Card = ({ }: Props) => { const theme = useInternalTheme(themeOverrides); + const isDisabled = Boolean( + disabled || ariaDisabled || accessibilityState?.disabled + ); + const visuals = resolveCardVisuals({ theme, variant: cardVariant, elevation: customElevation, - disabled, + disabled: isDisabled, }); const hasPassedTouchHandler = hasTouchHandler({ @@ -312,6 +380,23 @@ const Card = ({ onPressIn, onPressOut, }); + const hasWarnedAboutActions = React.useRef(false); + const hasActions = + actions !== null && actions !== undefined && actions !== false; + + React.useEffect(() => { + if ( + process.env.NODE_ENV !== 'production' && + hasPassedTouchHandler && + hasActions && + !hasWarnedAboutActions.current + ) { + console.warn( + 'An actionable Card cannot contain actions. Remove the Card interaction handlers or move the independent actions outside the Card.' + ); + hasWarnedAboutActions.current = true; + } + }, [hasActions, hasPassedTouchHandler]); const shapeStyle = { borderRadius: borderRadius ?? visuals.shape, @@ -333,7 +418,10 @@ const Card = ({ title != null || subtitle != null || leading != null || trailing != null; const content = ( - + {media} {header ?? (hasConvenienceHeader ? ( @@ -349,6 +437,62 @@ const Card = ({ ); + const actionableRole = + role ?? (accessibilityRole === undefined ? 'button' : undefined); + const accessibilityProps = { + accessible, + accessibilityActions, + role, + accessibilityRole, + 'aria-label': ariaLabel, + accessibilityLabel, + accessibilityHint, + accessibilityState, + accessibilityValue, + 'aria-busy': ariaBusy, + 'aria-checked': ariaChecked, + 'aria-disabled': ariaDisabled, + 'aria-expanded': ariaExpanded, + 'aria-hidden': ariaHidden, + 'aria-labelledby': ariaLabelledBy, + 'aria-live': ariaLive, + 'aria-modal': ariaModal, + 'aria-selected': ariaSelected, + 'aria-valuemax': ariaValueMax, + 'aria-valuemin': ariaValueMin, + 'aria-valuenow': ariaValueNow, + 'aria-valuetext': ariaValueText, + accessibilityLabelledBy, + accessibilityLiveRegion, + accessibilityElementsHidden, + accessibilityViewIsModal, + accessibilityIgnoresInvertColors, + accessibilityLanguage, + accessibilityShowsLargeContentViewer, + accessibilityLargeContentTitle, + accessibilityRespondsToUserInteraction, + importantForAccessibility, + screenReaderFocusable, + onAccessibilityAction, + onAccessibilityEscape, + onAccessibilityTap, + onMagicTap, + }; + const actionableAccessibilityProps = { + ...accessibilityProps, + role: actionableRole, + 'aria-disabled': isDisabled, + accessibilityActions: isDisabled ? undefined : accessibilityActions, + onAccessibilityAction: isDisabled ? undefined : onAccessibilityAction, + onAccessibilityEscape: isDisabled ? undefined : onAccessibilityEscape, + onAccessibilityTap: isDisabled ? undefined : onAccessibilityTap, + onMagicTap: isDisabled ? undefined : onMagicTap, + }; + const neutralAccessibilityProps = { + ...accessibilityProps, + 'aria-disabled': isDisabled || ariaDisabled, + }; + return ( {hasPassedTouchHandler ? ( - {content} - + ) : ( content )} diff --git a/src/components/__tests__/Card/Card.test.tsx b/src/components/__tests__/Card/Card.test.tsx index 2de4a9f286..2e8eccd00f 100644 --- a/src/components/__tests__/Card/Card.test.tsx +++ b/src/components/__tests__/Card/Card.test.tsx @@ -4,8 +4,8 @@ import type { StyleProp, ViewStyle } from 'react-native'; import { afterEach, describe, expect, it, jest } from '@jest/globals'; -import { render, screen } from '../../../test-utils'; -import { LightTheme } from '../../../theme/schemes'; +import { getTheme } from '../../../core/theming'; +import { fireEvent, render, screen, userEvent } from '../../../test-utils'; import Button from '../../Button/Button'; import Card from '../../Card/Card'; @@ -242,9 +242,238 @@ describe('Card', () => { expect(screen.getByTestId('card-visual')).toHaveStyle({ backgroundColor: getTheme().colors.surfaceContainerHighest, }); + expect(screen.getByTestId('card')).not.toHaveProp('focusable'); + expect(screen.getByTestId('card-container')).not.toHaveProp('focusable'); expect(screen.queryByRole('button')).not.toBeOnTheScreen(); }); + it('preserves explicit semantics on a neutral Card shell', async () => { + await render( + + ); + + const shell = screen.getByRole('summary', { name: 'Product summary' }); + + expect(shell).toBe(screen.getByTestId('product-card-container')); + expect(shell).toHaveProp( + 'accessibilityHint', + 'Contains product information' + ); + expect(shell).toHaveProp('focusable', false); + expect(screen.getByTestId('product-card')).not.toHaveProp('role'); + }); + + it('creates one target for whole-Card interaction callbacks', async () => { + const onPress = jest.fn(); + const onLongPress = jest.fn(); + const onPressIn = jest.fn(); + const onPressOut = jest.fn(); + const onFocus = jest.fn(); + const onBlur = jest.fn(); + const onHoverIn = jest.fn(); + const onHoverOut = jest.fn(); + const hitSlop = { top: 4, right: 8, bottom: 12, left: 16 }; + await render( + + ); + + const [target] = screen.getAllByRole('button'); + const events = { + press: { nativeEvent: { target: 'press' } }, + longPress: { nativeEvent: { target: 'long-press' } }, + pressIn: { nativeEvent: { target: 'press-in' } }, + pressOut: { nativeEvent: { target: 'press-out' } }, + focus: { nativeEvent: { target: 'focus' } }, + blur: { nativeEvent: { target: 'blur' } }, + hoverIn: { nativeEvent: { target: 'hover-in' } }, + hoverOut: { nativeEvent: { target: 'hover-out' } }, + }; + + expect(screen.getAllByRole('button')).toHaveLength(1); + expect(target).toBe(screen.getByTestId('card')); + expect(target).toHaveProp('hitSlop', hitSlop); + expect(target).toHaveProp('focusable', true); + + await fireEvent(target, 'press', events.press); + await fireEvent(target, 'longPress', events.longPress); + await fireEvent(target, 'pressIn', events.pressIn); + await fireEvent(target, 'pressOut', events.pressOut); + await fireEvent(target, 'focus', events.focus); + await fireEvent(target, 'blur', events.blur); + await fireEvent(target, 'hoverIn', events.hoverIn); + await fireEvent(target, 'hoverOut', events.hoverOut); + + expect(onPress).toHaveBeenCalledWith(events.press); + expect(onLongPress).toHaveBeenCalledWith(events.longPress); + expect(onPressIn).toHaveBeenCalledWith(events.pressIn); + expect(onPressOut).toHaveBeenCalledWith(events.pressOut); + expect(onFocus).toHaveBeenCalledWith(events.focus); + expect(onBlur).toHaveBeenCalledWith(events.blur); + expect(onHoverIn).toHaveBeenCalledWith(events.hoverIn); + expect(onHoverOut).toHaveBeenCalledWith(events.hoverOut); + }); + + it('routes whole-Card accessibility semantics and callbacks to its target', async () => { + const onAccessibilityAction = jest.fn(); + const onAccessibilityEscape = jest.fn(); + const onAccessibilityTap = jest.fn(); + const onMagicTap = jest.fn(); + const accessibilityActionEvent = { + nativeEvent: { actionName: 'activate' }, + }; + await render( + {}} + role="link" + accessibilityLabel="Open product" + accessibilityHint="Shows product details" + accessibilityState={{ selected: true }} + accessibilityValue={{ text: 'In stock' }} + accessibilityActions={[{ name: 'activate', label: 'Open product' }]} + onAccessibilityAction={onAccessibilityAction} + onAccessibilityEscape={onAccessibilityEscape} + onAccessibilityTap={onAccessibilityTap} + onMagicTap={onMagicTap} + /> + ); + + const target = screen.getByRole('link', { name: 'Open product' }); + const shell = screen.getByTestId('product-card-container'); + + expect(target).toBe(screen.getByTestId('product-card')); + expect(target).toHaveProp('accessibilityHint', 'Shows product details'); + expect(target).toHaveProp( + 'accessibilityState', + expect.objectContaining({ selected: true }) + ); + expect(target).toHaveAccessibilityValue({ text: 'In stock' }); + expect(target).toHaveProp('accessibilityActions', [ + { name: 'activate', label: 'Open product' }, + ]); + expect(shell).not.toHaveProp('accessibilityLabel'); + expect(shell).not.toHaveProp('accessibilityActions'); + + await fireEvent(target, 'accessibilityAction', accessibilityActionEvent); + await fireEvent(target, 'accessibilityEscape'); + await fireEvent(target, 'accessibilityTap'); + await fireEvent(target, 'magicTap'); + + expect(onAccessibilityAction).toHaveBeenCalledWith( + accessibilityActionEvent + ); + expect(onAccessibilityEscape).toHaveBeenCalledTimes(1); + expect(onAccessibilityTap).toHaveBeenCalledTimes(1); + expect(onMagicTap).toHaveBeenCalledTimes(1); + }); + + it('targets documented shell, visual, and shaped interaction nodes', async () => { + const shellRef = React.createRef>(); + const touchableRef = React.createRef>(); + const shape = { + borderTopLeftRadius: 4, + borderTopRightRadius: 8, + borderBottomRightRadius: 16, + borderBottomLeftRadius: 20, + }; + await render( + {}} + /> + ); + + const interaction = screen.getByTestId('product-card'); + const shell = screen.getByTestId('product-card-container'); + const visual = screen.getByTestId('product-card-visual'); + + expect(interaction).toHaveStyle(shape); + expect(visual).toHaveStyle({ overflow: 'hidden', ...shape }); + expect(shell).toBeOnTheScreen(); + expect(shellRef.current).not.toBeNull(); + expect(touchableRef.current).not.toBeNull(); + expect(touchableRef.current).not.toBe(shellRef.current); + }); + + it('warns once when whole-Card interaction is combined with populated actions', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const { rerender } = await render( + {}} actions={null} /> + ); + + expect(warn).not.toHaveBeenCalled(); + + await rerender( + {}} + actions={ + + + + } + /> + ); + await rerender( + {}} + actions={ + + + + } + /> + ); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + 'An actionable Card cannot contain actions. Remove the Card interaction handlers or move the independent actions outside the Card.' + ); + }); + + it('does not warn about Card actions in production', async () => { + const environment = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + try { + await render( + {}} + actions={ + + + + } + /> + ); + + expect(warn).not.toHaveBeenCalled(); + } finally { + process.env.NODE_ENV = environment; + } + }); + it('renders the convenience header inputs', async () => { await render( { expect(screen.getByText('Content').parent).toHaveStyle(styles.contentStyle); }); - it('does render a disabled accessibility state', async () => { - await render( {}} disabled />); + it('exposes disabled semantics and suppresses every activation callback', async () => { + const onAccessibilityAction = jest.fn(); + const callbacks = { + onPress: jest.fn(), + onLongPress: jest.fn(), + onPressIn: jest.fn(), + onPressOut: jest.fn(), + onFocus: jest.fn(), + onBlur: jest.fn(), + onHoverIn: jest.fn(), + onHoverOut: jest.fn(), + onAccessibilityEscape: jest.fn(), + onAccessibilityTap: jest.fn(), + onMagicTap: jest.fn(), + }; + await render( + + ); + + const target = screen.getByTestId('card'); + + expect(target).toBeDisabled(); + expect(target).toHaveProp( + 'accessibilityState', + expect.objectContaining({ disabled: true }) + ); + expect(target).not.toHaveProp('accessibilityActions'); + expect(target).toHaveProp('focusable', false); + expect(target).toHaveProp('tabIndex', -1); + + await userEvent.press(target); + await userEvent.longPress(target); + await fireEvent(target, 'focus'); + await fireEvent(target, 'blur'); + await fireEvent(target, 'hoverIn'); + await fireEvent(target, 'hoverOut'); + await fireEvent(target, 'accessibilityAction', { + nativeEvent: { actionName: 'activate' }, + }); + await fireEvent(target, 'accessibilityEscape'); + await fireEvent(target, 'accessibilityTap'); + await fireEvent(target, 'magicTap'); + + Object.values(callbacks).forEach((callback) => { + expect(callback).not.toHaveBeenCalled(); + }); + expect(onAccessibilityAction).not.toHaveBeenCalled(); + }); + + it('exposes disabled state on an explicitly semantic neutral Card', async () => { + await render(); + + expect(screen.getByRole('summary')).toBeDisabled(); + }); + + it.each([ + { name: 'aria-disabled', props: { 'aria-disabled': true } }, + { + name: 'accessibilityState.disabled', + props: { accessibilityState: { disabled: true } }, + }, + ] as const)('honors $name as a disabled Card state', async ({ props }) => { + const onPress = jest.fn(); + await render(); + + const target = screen.getByTestId('card'); - expect(screen.getByTestId('card')).toBeDisabled(); + expect(target).toBeDisabled(); + await userEvent.press(target); + expect(onPress).not.toHaveBeenCalled(); }); }); From 84189cf1268274fc5e22a4a0b1f1d67145ea4f4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Kata?= Date: Fri, 4 Sep 2026 14:07:25 +0200 Subject: [PATCH 06/11] feat(card): add actionable state feedback --- src/components/Card/Card.tsx | 227 +++++++++++++++--- src/components/Surface.tsx | 85 ++++++- .../TouchableRipple.native.tsx | 3 + .../TouchableRipple/TouchableRipple.tsx | 11 +- src/components/__tests__/Card/Card.test.tsx | 199 ++++++++++++++- src/components/__tests__/Surface.test.tsx | 37 +++ 6 files changed, 523 insertions(+), 39 deletions(-) diff --git a/src/components/Card/Card.tsx b/src/components/Card/Card.tsx index 3fcf462c6f..8109b8967f 100644 --- a/src/components/Card/Card.tsx +++ b/src/components/Card/Card.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { StyleSheet, View } from 'react-native'; +import { Platform, StyleSheet, View } from 'react-native'; import type { GestureResponderEvent, StyleProp, @@ -7,6 +7,12 @@ import type { ViewStyle, } from 'react-native'; +import Animated, { + useAnimatedStyle, + useDerivedValue, + useSharedValue, +} from 'react-native-reanimated'; + import CardActions from './CardActions'; import CardContent from './CardContent'; import CardCover from './CardCover'; @@ -14,8 +20,10 @@ import CardTitle from './CardTitle'; import type { Props as CardTitleProps } from './CardTitle'; import { resolveCardVisuals } from './tokens'; import { useInternalTheme } from '../../core/theming'; +import { tokens as systemTokens } from '../../theme/tokens'; import type { Elevation, ThemeProp } from '../../theme/types'; import hasTouchHandler from '../../utils/hasTouchHandler'; +import { isKeyboardFocusEvent } from '../../utils/isKeyboardFocusEvent'; import Surface from '../Surface'; import type { SurfaceStyle } from '../Surface'; import TouchableRipple from '../TouchableRipple/TouchableRipple'; @@ -374,12 +382,66 @@ const Card = ({ disabled: isDisabled, }); + const enabledVisuals = resolveCardVisuals({ + theme, + variant: cardVariant, + elevation: customElevation, + }); + const hoveredVisuals = resolveCardVisuals({ + theme, + variant: cardVariant, + elevation: customElevation, + hovered: true, + }); + const focusedVisuals = resolveCardVisuals({ + theme, + variant: cardVariant, + elevation: customElevation, + focused: true, + }); + const pressedVisuals = resolveCardVisuals({ + theme, + variant: cardVariant, + elevation: customElevation, + pressed: true, + }); + + const hovered = useSharedValue(false); + const focused = useSharedValue(false); + const pressed = useSharedValue(false); + const currentInteractiveVisuals = useDerivedValue(() => { + if (pressed.value) { + return pressedVisuals; + } + if (focused.value) { + return focusedVisuals; + } + if (hovered.value) { + return hoveredVisuals; + } + return enabledVisuals; + }); + const interactiveElevation = useDerivedValue(() => { + return currentInteractiveVisuals.value.elevation; + }); + const stateLayerAnimatedStyle = useAnimatedStyle(() => ({ + opacity: currentInteractiveVisuals.value.stateLayerOpacity, + })); + const outlineAnimatedStyle = useAnimatedStyle(() => ({ + borderColor: currentInteractiveVisuals.value.outlineColor, + opacity: currentInteractiveVisuals.value.outlineOpacity, + })); + const focusIndicatorAnimatedStyle = useAnimatedStyle(() => ({ + opacity: focused.value ? 1 : 0, + })); + const hasPassedTouchHandler = hasTouchHandler({ onPress, onLongPress, onPressIn, onPressOut, }); + const isInteractive = hasPassedTouchHandler && !isDisabled; const hasWarnedAboutActions = React.useRef(false); const hasActions = actions !== null && actions !== undefined && actions !== false; @@ -398,22 +460,35 @@ const Card = ({ } }, [hasActions, hasPassedTouchHandler]); - const shapeStyle = { - borderRadius: borderRadius ?? visuals.shape, - borderBottomEndRadius, - borderBottomLeftRadius, - borderBottomRightRadius, - borderBottomStartRadius, - borderEndEndRadius, - borderEndStartRadius, - borderStartEndRadius, - borderStartStartRadius, - borderTopEndRadius, - borderTopLeftRadius, - borderTopRightRadius, - borderTopStartRadius, - borderCurve, - }; + const shapeStyle = Object.fromEntries( + Object.entries({ + borderRadius: borderRadius ?? visuals.shape, + borderBottomEndRadius, + borderBottomLeftRadius, + borderBottomRightRadius, + borderBottomStartRadius, + borderEndEndRadius, + borderEndStartRadius, + borderStartEndRadius, + borderStartStartRadius, + borderTopEndRadius, + borderTopLeftRadius, + borderTopRightRadius, + borderTopStartRadius, + borderCurve, + }).filter(([, value]) => value !== undefined) + ); + const focusIndicatorInset = + systemTokens.md.sys.state.focusIndicator.outerOffset + + systemTokens.md.sys.state.focusIndicator.thickness; + const focusIndicatorShapeStyle = Object.fromEntries( + Object.entries(shapeStyle).map(([property, value]) => [ + property, + property !== 'borderCurve' && typeof value === 'number' + ? value + focusIndicatorInset + : value, + ]) + ); const hasConvenienceHeader = title != null || subtitle != null || leading != null || trailing != null; @@ -492,6 +567,53 @@ const Card = ({ ...accessibilityProps, 'aria-disabled': isDisabled || ariaDisabled, }; + const handlePressIn = React.useCallback( + (event: GestureResponderEvent) => { + pressed.value = true; + onPressIn?.(event); + }, + [onPressIn, pressed] + ); + const handlePressOut = React.useCallback( + (event: GestureResponderEvent) => { + pressed.value = false; + onPressOut?.(event); + }, + [onPressOut, pressed] + ); + const handleFocus: NonNullable = + React.useCallback( + (event) => { + focused.value = isKeyboardFocusEvent(event); + onFocus?.(event); + }, + [focused, onFocus] + ); + const handleBlur: NonNullable = + React.useCallback( + (event) => { + focused.value = false; + pressed.value = false; + onBlur?.(event); + }, + [focused, onBlur, pressed] + ); + const handleHoverIn: NonNullable = + React.useCallback( + (event) => { + hovered.value = true; + onHoverIn?.(event); + }, + [hovered, onHoverIn] + ); + const handleHoverOut: NonNullable = + React.useCallback( + (event) => { + hovered.value = false; + onHoverOut?.(event); + }, + [hovered, onHoverOut] + ); return ( - {hasPassedTouchHandler ? ( @@ -550,7 +674,11 @@ const Card = ({ ref={touchableRef} testID={testID} borderless={false} - style={shapeStyle} + hoverColor="transparent" + style={[ + shapeStyle, + Platform.OS === 'web' ? webNoOutline : undefined, + ]} theme={theme} focusable={isDisabled ? false : focusable} tabIndex={isDisabled ? -1 : tabIndex} @@ -560,12 +688,12 @@ const Card = ({ delayLongPress={delayLongPress} onLongPress={isDisabled ? undefined : onLongPress} onPress={isDisabled ? undefined : onPress} - onPressIn={isDisabled ? undefined : onPressIn} - onPressOut={isDisabled ? undefined : onPressOut} - onFocus={isDisabled ? undefined : onFocus} - onBlur={isDisabled ? undefined : onBlur} - onHoverIn={isDisabled ? undefined : onHoverIn} - onHoverOut={isDisabled ? undefined : onHoverOut} + onPressIn={isDisabled ? undefined : handlePressIn} + onPressOut={isDisabled ? undefined : handlePressOut} + onFocus={isDisabled ? undefined : handleFocus} + onBlur={isDisabled ? undefined : handleBlur} + onHoverIn={isDisabled ? undefined : handleHoverIn} + onHoverOut={isDisabled ? undefined : handleHoverOut} > {content} @@ -573,21 +701,42 @@ const Card = ({ content )} {visuals.outlineWidth > 0 ? ( - ) : null} + {hasPassedTouchHandler ? ( + + ) : null} ); }; @@ -612,6 +761,18 @@ const styles = StyleSheet.create({ flexShrink: 1, position: 'relative', }, + focusIndicator: { + position: 'absolute', + pointerEvents: 'none', + }, + hidden: { + opacity: 0, + }, }); +// React Native Web otherwise draws its browser-default outline in addition to +// the Material focus indicator. +// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion +const webNoOutline = { outline: 'none' } as unknown as ViewStyle; + export default Card; diff --git a/src/components/Surface.tsx b/src/components/Surface.tsx index 48df1a4d4a..e04fbe7b5e 100644 --- a/src/components/Surface.tsx +++ b/src/components/Surface.tsx @@ -5,6 +5,7 @@ import type { ColorValue, StyleProp, ViewProps, ViewStyle } from 'react-native'; import Animated, { cubicBezier, isSharedValue, + type SharedValue, type AnimatedStyle, useAnimatedStyle, } from 'react-native-reanimated'; @@ -113,8 +114,9 @@ export type Props = Omit & * * Note: In version 2 the `elevation` prop was accepted via `style` prop i.e. `style={{ elevation: 4 }}`. * It's no longer supported with theme version 3 and you should use `elevation` property instead. + * A Reanimated shared value can drive elevation without a React render. */ - elevation?: Elevation; + elevation?: Elevation | SharedValue; /** * @supported Available in v5.x with theme version 3 * Mode of the Surface. @@ -137,6 +139,12 @@ export type Props = Omit & ref?: React.Ref; }; +type StaticSurfaceProps = Omit & { + elevation?: Elevation; + animatedSurfaceStyle?: AnimatedStyle; + animatedAmbientStyle?: AnimatedStyle; +}; + /** * Surface is a basic container that can give depth to an element with elevation shadow. * @@ -168,7 +176,7 @@ export type Props = Omit & * }); * ``` */ -const Surface = ({ +const StaticSurface = ({ elevation = 1, children, theme: overridenTheme, @@ -191,9 +199,11 @@ const Surface = ({ testID, mode = 'elevated', transitionDuration: customTransitionDuration, + animatedSurfaceStyle, + animatedAmbientStyle, ref, ...rest -}: Props) => { +}: StaticSurfaceProps) => { const theme = useInternalTheme(overridenTheme); const { colors } = theme; @@ -259,6 +269,7 @@ const Surface = ({ backgroundStyle, visualStyle, isElevated ? elevationShadow : null, + ...(animatedSurfaceStyle ? [animatedSurfaceStyle] : []), ]} > {children} @@ -286,6 +297,7 @@ const Surface = ({ backgroundStyle, visualStyle, isElevated && { elevation: elevationAndroid }, + ...(animatedSurfaceStyle ? [animatedSurfaceStyle] : []), ]} > {children} @@ -316,6 +328,7 @@ const Surface = ({ backgroundStyle, visualStyle, isElevated && spotShadow, + ...(animatedSurfaceStyle ? [animatedSurfaceStyle] : []), ]} testID={testID} > @@ -329,6 +342,7 @@ const Surface = ({ backgroundStyle, shadowVisualStyle, ambientShadow, + ...(animatedAmbientStyle ? [animatedAmbientStyle] : []), ]} /> ) : null} @@ -337,6 +351,71 @@ const Surface = ({ ); }; +const AnimatedElevationSurface = ({ + elevation, + theme: themeOverrides, + backgroundColor: customBackgroundColor, + mode = 'elevated', + ...rest +}: Props & { elevation: SharedValue }) => { + const theme = useInternalTheme(themeOverrides); + const elevationShadows = React.useMemo( + () => + ([0, 1, 2, 3, 4, 5] as const).map((level) => + shadow(level, theme.colors.shadow) + ), + [theme.colors.shadow] + ); + const animatedSurfaceStyle = useAnimatedStyle(() => { + const level = elevation.value; + const backgroundStyle = + customBackgroundColor == null + ? { backgroundColor: theme.colors.elevation?.[`level${level}`] } + : {}; + + if (mode === 'flat') { + return backgroundStyle; + } + if (Platform.OS === 'android') { + return { + ...backgroundStyle, + elevation: androidElevationLevels[level], + }; + } + + return { ...backgroundStyle, ...elevationShadows[level][0] }; + }, [customBackgroundColor, elevation, elevationShadows, mode, theme.colors]); + const animatedAmbientStyle = useAnimatedStyle(() => { + if (mode === 'flat') { + return {}; + } + + return elevationShadows[elevation.value][1] ?? {}; + }, [elevation, elevationShadows, mode]); + + return ( + + ); +}; + +const Surface = (props: Props) => { + const elevation = props.elevation ?? 1; + + return isSharedValue(elevation) ? ( + + ) : ( + + ); +}; + const useSurfaceVisualStyle = ({ borderRadius, borderBottomEndRadius, diff --git a/src/components/TouchableRipple/TouchableRipple.native.tsx b/src/components/TouchableRipple/TouchableRipple.native.tsx index ec7b13dd91..ff2e85d93a 100644 --- a/src/components/TouchableRipple/TouchableRipple.native.tsx +++ b/src/components/TouchableRipple/TouchableRipple.native.tsx @@ -31,6 +31,8 @@ export type Props = PressableProps & { onPressOut?: (e: GestureResponderEvent) => void; rippleColor?: ColorValue; underlayColor?: string; + /** Web-only hover feedback color. */ + hoverColor?: ColorValue; children: React.ReactNode; style?: StyleProp; ref?: React.Ref; @@ -44,6 +46,7 @@ const TouchableRipple = ({ disabled: disabledProp, rippleColor, underlayColor, + hoverColor: _hoverColor, children, theme: themeOverrides, ref, diff --git a/src/components/TouchableRipple/TouchableRipple.tsx b/src/components/TouchableRipple/TouchableRipple.tsx index 128e2c017e..f74593ba3b 100644 --- a/src/components/TouchableRipple/TouchableRipple.tsx +++ b/src/components/TouchableRipple/TouchableRipple.tsx @@ -60,6 +60,11 @@ export type Props = PressableProps & { * Color of the underlay for the highlight effect (Android < 5.0 and iOS). */ underlayColor?: string; + /** + * Color of the hover feedback on web. Set this to `transparent` when the + * caller renders its own state layer. + */ + hoverColor?: ColorValue; /** * Content of the `TouchableRipple`. */ @@ -109,6 +114,7 @@ const TouchableRipple = ({ disabled: disabledProp, rippleColor, underlayColor: _underlayColor, + hoverColor: customHoverColor, children, theme: themeOverrides, ref, @@ -122,9 +128,10 @@ const TouchableRipple = ({ // Web-only style. PlatformColor doesn't exist on web, so the calculated // ripple color is effectively always a string here. const hoverColor = - typeof calculatedRippleColor === 'string' + customHoverColor ?? + (typeof calculatedRippleColor === 'string' ? color(calculatedRippleColor).fade(0.5).rgb().string() - : calculatedRippleColor; + : calculatedRippleColor); const { rippleEffectEnabled } = React.useContext(SettingsContext); const { onPress, onLongPress, onPressIn, onPressOut } = rest; diff --git a/src/components/__tests__/Card/Card.test.tsx b/src/components/__tests__/Card/Card.test.tsx index 2e8eccd00f..12ccec5fd2 100644 --- a/src/components/__tests__/Card/Card.test.tsx +++ b/src/components/__tests__/Card/Card.test.tsx @@ -3,6 +3,8 @@ import { Platform, StyleSheet, Text, View } from 'react-native'; import type { StyleProp, ViewStyle } from 'react-native'; import { afterEach, describe, expect, it, jest } from '@jest/globals'; +import { act } from '@testing-library/react-native'; +import { getAnimatedStyle } from 'react-native-reanimated'; import { getTheme } from '../../../core/theming'; import { fireEvent, render, screen, userEvent } from '../../../test-utils'; @@ -18,6 +20,15 @@ const styles = StyleSheet.create({ }, }); +const expectAnimatedStyle = ( + testID: string, + expectedStyle: Record +) => { + expect(getAnimatedStyle(screen.getByTestId(testID))).toEqual( + expect.objectContaining(expectedStyle) + ); +}; + afterEach(() => { jest.restoreAllMocks(); }); @@ -385,7 +396,7 @@ describe('Card', () => { expect(onMagicTap).toHaveBeenCalledTimes(1); }); - it('targets documented shell, visual, and shaped interaction nodes', async () => { + it('keeps the ripple, visual layers, and focus indicator on the Card shape', async () => { const shellRef = React.createRef>(); const touchableRef = React.createRef>(); const shape = { @@ -410,12 +421,198 @@ describe('Card', () => { expect(interaction).toHaveStyle(shape); expect(visual).toHaveStyle({ overflow: 'hidden', ...shape }); + expect(interaction.parent).toBe(visual); + expect(screen.getByTestId('product-card-focus-indicator')).toHaveStyle({ + top: -5, + right: -5, + bottom: -5, + left: -5, + borderColor: getTheme().colors.secondary, + borderWidth: 3, + borderTopLeftRadius: 9, + borderTopRightRadius: 13, + borderBottomRightRadius: 21, + borderBottomLeftRadius: 25, + }); expect(shell).toBeOnTheScreen(); expect(shellRef.current).not.toBeNull(); expect(touchableRef.current).not.toBeNull(); expect(touchableRef.current).not.toBe(shellRef.current); }); + it.each([ + { variant: 'filled' as const, hoveredElevation: 1 }, + { variant: 'elevated' as const, hoveredElevation: 2 }, + { variant: 'outlined' as const, hoveredElevation: 1 }, + ])( + 'shows and clears the $variant hover feedback', + async ({ variant, hoveredElevation }) => { + expect.hasAssertions(); + jest.replaceProperty(Platform, 'OS', 'android'); + const card = + variant === 'elevated' ? ( + {}} /> + ) : variant === 'outlined' ? ( + {}} /> + ) : ( + {}} /> + ); + await render(card); + + const target = screen.getByTestId('card'); + + await fireEvent(target, 'hoverIn'); + await act(() => { + jest.runOnlyPendingTimers(); + }); + + expectAnimatedStyle('card-state-layer', { opacity: 0.08 }); + expectAnimatedStyle('card-container', { + elevation: hoveredElevation === 1 ? 1 : 3, + }); + + await fireEvent(target, 'hoverOut'); + + expectAnimatedStyle('card-state-layer', { opacity: 0 }); + } + ); + + it.each([ + { variant: 'filled' as const, pressedElevation: 0 }, + { variant: 'elevated' as const, pressedElevation: 1 }, + { variant: 'outlined' as const, pressedElevation: 0 }, + ])( + 'shows and clears the $variant pressed feedback', + async ({ variant, pressedElevation }) => { + expect.hasAssertions(); + jest.replaceProperty(Platform, 'OS', 'android'); + const card = + variant === 'elevated' ? ( + {}} /> + ) : variant === 'outlined' ? ( + {}} /> + ) : ( + {}} /> + ); + await render(card); + + const target = screen.getByTestId('card'); + + await fireEvent(target, 'pressIn'); + await act(() => { + jest.runOnlyPendingTimers(); + }); + + expectAnimatedStyle('card-state-layer', { opacity: 0.1 }); + expectAnimatedStyle('card-container', { elevation: pressedElevation }); + + await fireEvent(target, 'pressOut'); + await act(() => { + jest.runOnlyPendingTimers(); + }); + + expectAnimatedStyle('card-state-layer', { opacity: 0 }); + } + ); + + it('shows focus feedback only for keyboard-visible focus and clears it on blur', async () => { + expect.hasAssertions(); + jest.replaceProperty(Platform, 'OS', 'web'); + const theme = getTheme(); + await render( {}} theme={theme} />); + const target = screen.getByTestId('card'); + const pointerTarget = { matches: () => false }; + const keyboardTarget = { matches: () => true }; + + await fireEvent(target, 'focus', { currentTarget: pointerTarget }); + await act(() => { + jest.runOnlyPendingTimers(); + }); + + expectAnimatedStyle('card-focus-indicator', { opacity: 0 }); + expectAnimatedStyle('card-state-layer', { opacity: 0 }); + + await fireEvent(target, 'focus', { currentTarget: keyboardTarget }); + await act(() => { + jest.runOnlyPendingTimers(); + }); + + expectAnimatedStyle('card-focus-indicator', { opacity: 1 }); + expectAnimatedStyle('card-state-layer', { opacity: 0.1 }); + expectAnimatedStyle('card-outline', { + borderColor: theme.colors.onSurface, + opacity: 1, + }); + + await fireEvent(target, 'blur'); + await act(() => { + jest.runOnlyPendingTimers(); + }); + + expectAnimatedStyle('card-focus-indicator', { opacity: 0 }); + expectAnimatedStyle('card-state-layer', { opacity: 0 }); + }); + + it('uses pressed, focused, then hovered precedence and settles at the latest state', async () => { + expect.hasAssertions(); + const theme = getTheme(); + await render( {}} theme={theme} />); + const target = screen.getByTestId('card'); + + await fireEvent(target, 'hoverIn'); + await fireEvent(target, 'focus'); + await fireEvent(target, 'pressIn'); + await fireEvent(target, 'pressOut'); + await act(() => { + jest.runOnlyPendingTimers(); + }); + + expectAnimatedStyle('card-outline', { + borderColor: theme.colors.onSurface, + opacity: 1, + }); + + await fireEvent(target, 'blur'); + await act(() => { + jest.runOnlyPendingTimers(); + }); + + expectAnimatedStyle('card-state-layer', { opacity: 0.08 }); + expectAnimatedStyle('card-outline', { + borderColor: theme.colors.outlineVariant, + opacity: 1, + }); + + await fireEvent(target, 'hoverOut'); + await act(() => { + jest.runOnlyPendingTimers(); + }); + + expectAnimatedStyle('card-state-layer', { opacity: 0 }); + }); + + it('does not rerender stable memoized slot content for transient feedback', async () => { + const renderCount = jest.fn(); + const StableContent = React.memo(() => { + renderCount(); + return Stable content; + }); + await render( {}} content={} />); + const target = screen.getByTestId('card'); + + await fireEvent(target, 'hoverIn'); + await fireEvent(target, 'focus'); + await fireEvent(target, 'pressIn'); + await fireEvent(target, 'pressOut'); + await fireEvent(target, 'blur'); + await fireEvent(target, 'hoverOut'); + await act(() => { + jest.runOnlyPendingTimers(); + }); + + expect(renderCount).toHaveBeenCalledTimes(1); + }); + it('warns once when whole-Card interaction is combined with populated actions', async () => { const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); const { rerender } = await render( diff --git a/src/components/__tests__/Surface.test.tsx b/src/components/__tests__/Surface.test.tsx index 96ed707ae1..2638de9492 100644 --- a/src/components/__tests__/Surface.test.tsx +++ b/src/components/__tests__/Surface.test.tsx @@ -22,6 +22,7 @@ import { import { render, screen } from '../../test-utils'; import { LightTheme } from '../../theme/schemes'; +import type { Elevation } from '../../theme/types'; import Surface from '../Surface'; const SPOT_SHADOW_OPACITY = 0.19; @@ -69,6 +70,24 @@ const AnimatedVisualSurface = () => { ); }; +const SharedElevationSurface = () => { + const elevation = useSharedValue(0); + + return ( + <> + { + elevation.value = 2; + }} + /> + + {null} + + + ); +}; + afterEach(() => { jest.restoreAllMocks(); }); @@ -208,6 +227,24 @@ describe('Surface', () => { }); }); + it('updates shared elevation without rerendering the Surface', async () => { + await render(); + const surface = screen.getByTestId('shared-elevation-surface'); + + expect(getAnimatedStyle(surface)).toMatchObject({ + backgroundColor: LightTheme.colors.elevation.level0, + elevation: 0, + }); + + await userEvent.press(screen.getByTestId('raise-surface')); + await jest.runAllTimersAsync(); + + expect(getAnimatedStyle(surface)).toMatchObject({ + backgroundColor: LightTheme.colors.elevation.level2, + elevation: 3, + }); + }); + it('does not transition a PlatformColor background', async () => { await render( Date: Fri, 4 Sep 2026 14:32:48 +0200 Subject: [PATCH 07/11] feat(card): complete state and motion behavior --- src/components/Card/Card.tsx | 127 +++++-- src/components/__tests__/Card/Card.test.tsx | 356 +++++++++++++++++++- 2 files changed, 447 insertions(+), 36 deletions(-) diff --git a/src/components/Card/Card.tsx b/src/components/Card/Card.tsx index 8109b8967f..7992ec31f8 100644 --- a/src/components/Card/Card.tsx +++ b/src/components/Card/Card.tsx @@ -8,6 +8,7 @@ import type { } from 'react-native'; import Animated, { + cubicBezier, useAnimatedStyle, useDerivedValue, useSharedValue, @@ -20,6 +21,7 @@ import CardTitle from './CardTitle'; import type { Props as CardTitleProps } from './CardTitle'; import { resolveCardVisuals } from './tokens'; import { useInternalTheme } from '../../core/theming'; +import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; import { tokens as systemTokens } from '../../theme/tokens'; import type { Elevation, ThemeProp } from '../../theme/types'; import hasTouchHandler from '../../utils/hasTouchHandler'; @@ -215,6 +217,11 @@ type CardBaseProps = Omit & * If true, disable all interactions for this component. */ disabled?: boolean; + /** + * Whether to show the Card's controlled Material dragged presentation. + * Gesture recognition and drag lifecycle remain the consumer's responsibility. + */ + dragged?: boolean; /** * Style of card's inner content. */ @@ -308,6 +315,7 @@ const Card = ({ testID = 'card', accessible, disabled, + dragged = false, accessibilityActions, role, accessibilityRole, @@ -370,6 +378,7 @@ const Card = ({ ...rest }: Props) => { const theme = useInternalTheme(themeOverrides); + const reduceMotion = useReduceMotion(); const isDisabled = Boolean( disabled || ariaDisabled || accessibilityState?.disabled @@ -380,6 +389,7 @@ const Card = ({ variant: cardVariant, elevation: customElevation, disabled: isDisabled, + dragged, }); const enabledVisuals = resolveCardVisuals({ @@ -405,11 +415,30 @@ const Card = ({ elevation: customElevation, pressed: true, }); - + const draggedVisuals = resolveCardVisuals({ + theme, + variant: cardVariant, + elevation: customElevation, + dragged: true, + }); + const disabledVisuals = resolveCardVisuals({ + theme, + variant: cardVariant, + elevation: customElevation, + disabled: true, + }); + const disabledState = useSharedValue(isDisabled); + const draggedState = useSharedValue(dragged); const hovered = useSharedValue(false); const focused = useSharedValue(false); const pressed = useSharedValue(false); const currentInteractiveVisuals = useDerivedValue(() => { + if (disabledState.value) { + return disabledVisuals; + } + if (draggedState.value) { + return draggedVisuals; + } if (pressed.value) { return pressedVisuals; } @@ -420,20 +449,74 @@ const Card = ({ return hoveredVisuals; } return enabledVisuals; - }); + }, [ + disabledState, + disabledVisuals, + draggedState, + draggedVisuals, + enabledVisuals, + focusedVisuals, + hoveredVisuals, + pressedVisuals, + ]); const interactiveElevation = useDerivedValue(() => { return currentInteractiveVisuals.value.elevation; }); - const stateLayerAnimatedStyle = useAnimatedStyle(() => ({ - opacity: currentInteractiveVisuals.value.stateLayerOpacity, - })); - const outlineAnimatedStyle = useAnimatedStyle(() => ({ - borderColor: currentInteractiveVisuals.value.outlineColor, - opacity: currentInteractiveVisuals.value.outlineOpacity, - })); - const focusIndicatorAnimatedStyle = useAnimatedStyle(() => ({ - opacity: focused.value ? 1 : 0, - })); + const transitionDuration = reduceMotion + ? 0 + : theme.motion.duration.short3 * theme.animation.scale; + const transitionTimingFunction = cubicBezier(...theme.motion.easing.standard); + const stateLayerAnimatedStyle = useAnimatedStyle( + () => ({ + opacity: currentInteractiveVisuals.value.stateLayerOpacity, + transitionDuration, + transitionProperty: ['opacity'], + transitionTimingFunction, + }), + [currentInteractiveVisuals, transitionDuration, transitionTimingFunction] + ); + const outlineAnimatedStyle = useAnimatedStyle(() => { + const outlineColor = currentInteractiveVisuals.value.outlineColor; + + return { + borderColor: outlineColor, + opacity: currentInteractiveVisuals.value.outlineOpacity, + transitionDuration, + transitionProperty: + typeof outlineColor === 'string' + ? ['borderColor', 'opacity'] + : ['opacity'], + transitionTimingFunction, + }; + }, [currentInteractiveVisuals, transitionDuration, transitionTimingFunction]); + const focusIndicatorAnimatedStyle = useAnimatedStyle( + () => ({ + opacity: disabledState.value ? 0 : focused.value ? 1 : 0, + transitionDuration, + transitionProperty: ['opacity'], + transitionTimingFunction, + }), + [disabledState, transitionDuration, transitionTimingFunction] + ); + + React.useEffect(() => { + disabledState.value = isDisabled; + draggedState.value = dragged; + + if (isDisabled) { + hovered.value = false; + focused.value = false; + pressed.value = false; + } + }, [ + disabledState, + dragged, + draggedState, + focused, + hovered, + isDisabled, + pressed, + ]); const hasPassedTouchHandler = hasTouchHandler({ onPress, @@ -441,7 +524,6 @@ const Card = ({ onPressIn, onPressOut, }); - const isInteractive = hasPassedTouchHandler && !isDisabled; const hasWarnedAboutActions = React.useRef(false); const hasActions = actions !== null && actions !== undefined && actions !== false; @@ -622,7 +704,8 @@ const Card = ({ backgroundColor="transparent" style={style} theme={theme} - elevation={isInteractive ? interactiveElevation : visuals.elevation} + elevation={interactiveElevation} + transitionDuration={transitionDuration} testID={`${testID}-container`} {...(!hasPassedTouchHandler && neutralAccessibilityProps)} onFocus={!hasPassedTouchHandler ? onFocus : undefined} @@ -663,9 +746,7 @@ const Card = ({ { backgroundColor: visuals.stateLayerColor, }, - isInteractive - ? stateLayerAnimatedStyle - : { opacity: visuals.stateLayerOpacity }, + stateLayerAnimatedStyle, ]} /> {hasPassedTouchHandler ? ( @@ -708,12 +789,7 @@ const Card = ({ StyleSheet.absoluteFill, shapeStyle, { borderWidth: visuals.outlineWidth }, - isInteractive - ? outlineAnimatedStyle - : { - borderColor: visuals.outlineColor, - opacity: visuals.outlineOpacity, - }, + outlineAnimatedStyle, ]} /> ) : null} @@ -733,7 +809,7 @@ const Card = ({ borderWidth: systemTokens.md.sys.state.focusIndicator.thickness, }, focusIndicatorShapeStyle, - isInteractive ? focusIndicatorAnimatedStyle : styles.hidden, + focusIndicatorAnimatedStyle, ]} /> ) : null} @@ -765,9 +841,6 @@ const styles = StyleSheet.create({ position: 'absolute', pointerEvents: 'none', }, - hidden: { - opacity: 0, - }, }); // React Native Web otherwise draws its browser-default outline in addition to diff --git a/src/components/__tests__/Card/Card.test.tsx b/src/components/__tests__/Card/Card.test.tsx index 12ccec5fd2..8942b916dc 100644 --- a/src/components/__tests__/Card/Card.test.tsx +++ b/src/components/__tests__/Card/Card.test.tsx @@ -4,12 +4,16 @@ import type { StyleProp, ViewStyle } from 'react-native'; import { afterEach, describe, expect, it, jest } from '@jest/globals'; import { act } from '@testing-library/react-native'; +import * as Reanimated from 'react-native-reanimated'; import { getAnimatedStyle } from 'react-native-reanimated'; -import { getTheme } from '../../../core/theming'; import { fireEvent, render, screen, userEvent } from '../../../test-utils'; +import { ReduceMotionContext } from '../../../theme/accessibility/ReduceMotionContext'; +import { DarkTheme, LightTheme } from '../../../theme/schemes'; +import { tokens as systemTokens } from '../../../theme/tokens'; import Button from '../../Button/Button'; import Card from '../../Card/Card'; +import type { Props as CardProps } from '../../Card/Card'; const styles = StyleSheet.create({ contentStyle: { @@ -29,6 +33,28 @@ const expectAnimatedStyle = ( ); }; +const getVariantCard = ( + variant: 'filled' | 'elevated' | 'outlined', + props: Pick = {} +) => { + if (variant === 'elevated') { + return ; + } + if (variant === 'outlined') { + return ; + } + return ; +}; + +const expectOutlineStyle = (expectedStyle?: Record) => { + const outline = screen.queryByTestId('card-outline'); + + expect(Boolean(outline)).toBe(Boolean(expectedStyle)); + expect(outline ? getAnimatedStyle(outline) : {}).toEqual( + expect.objectContaining(expectedStyle ?? {}) + ); +}; + afterEach(() => { jest.restoreAllMocks(); }); @@ -48,7 +74,7 @@ describe('Card', () => { 'renders the enabled $variant appearance in light and dark themes', async ({ variant, colorRole }) => { for (const isDark of [false, true] as const) { - const theme = getTheme(isDark); + const theme = isDark ? DarkTheme : LightTheme; const card = variant === 'elevated' ? ( @@ -70,7 +96,7 @@ describe('Card', () => { it('renders the enabled outlined role in light and dark themes', async () => { for (const isDark of [false, true] as const) { - const theme = getTheme(isDark); + const theme = isDark ? DarkTheme : LightTheme; const { unmount } = await render( ); @@ -198,7 +224,7 @@ describe('Card', () => { ); const expectedShape = { - borderRadius: getTheme().shapes.corner.medium, + borderRadius: LightTheme.shapes.corner.medium, borderTopLeftRadius: 4, borderTopRightRadius: 8, borderBottomRightRadius: 16, @@ -251,7 +277,7 @@ describe('Card', () => { await render(); expect(screen.getByTestId('card-visual')).toHaveStyle({ - backgroundColor: getTheme().colors.surfaceContainerHighest, + backgroundColor: LightTheme.colors.surfaceContainerHighest, }); expect(screen.getByTestId('card')).not.toHaveProp('focusable'); expect(screen.getByTestId('card-container')).not.toHaveProp('focusable'); @@ -427,7 +453,7 @@ describe('Card', () => { right: -5, bottom: -5, left: -5, - borderColor: getTheme().colors.secondary, + borderColor: LightTheme.colors.secondary, borderWidth: 3, borderTopLeftRadius: 9, borderTopRightRadius: 13, @@ -515,10 +541,237 @@ describe('Card', () => { } ); + it.each([ + { + variant: 'filled' as const, + containerRole: 'surfaceContainerHighest' as const, + draggedElevation: 3, + outlineRole: undefined, + }, + { + variant: 'elevated' as const, + containerRole: 'surfaceContainerLow' as const, + draggedElevation: 4, + outlineRole: undefined, + }, + { + variant: 'outlined' as const, + containerRole: 'surface' as const, + draggedElevation: 3, + outlineRole: 'outlineVariant' as const, + }, + ])( + 'renders the consumer-controlled $variant dragged presentation', + async ({ variant, containerRole, draggedElevation, outlineRole }) => { + jest.replaceProperty(Platform, 'OS', 'android'); + const theme = LightTheme; + const card = getVariantCard(variant, { dragged: true, theme }); + + await render(card); + + expect(screen.getByTestId('card-background')).toHaveStyle({ + backgroundColor: theme.colors[containerRole], + opacity: 1, + }); + expect(screen.getByTestId('card-state-layer')).toHaveStyle({ + backgroundColor: theme.colors.onSurface, + opacity: systemTokens.md.sys.state.opacity.dragged, + }); + expect(screen.getByTestId('card-container')).toHaveStyle({ + elevation: draggedElevation === 3 ? 6 : 8, + }); + + const expectedOutlineStyle = outlineRole + ? { + borderColor: theme.colors[outlineRole], + borderWidth: 1, + opacity: 1, + } + : undefined; + expectOutlineStyle(expectedOutlineStyle); + } + ); + + it.each([ + { + variant: 'filled' as const, + containerRole: 'surfaceVariant' as const, + containerOpacity: systemTokens.md.sys.state.opacity.disabled, + elevation: 0, + outlineRole: undefined, + outlineOpacity: undefined, + }, + { + variant: 'elevated' as const, + containerRole: 'surface' as const, + containerOpacity: systemTokens.md.sys.state.opacity.disabled, + elevation: 1, + outlineRole: undefined, + outlineOpacity: undefined, + }, + { + variant: 'outlined' as const, + containerRole: 'surface' as const, + containerOpacity: 1, + elevation: 0, + outlineRole: 'outline' as const, + outlineOpacity: 0.12, + }, + ])( + 'renders the Material disabled treatment for $variant Cards', + async ({ + variant, + containerRole, + containerOpacity, + elevation, + outlineRole, + outlineOpacity, + }) => { + jest.replaceProperty(Platform, 'OS', 'android'); + const theme = LightTheme; + const card = getVariantCard(variant, { + disabled: true, + onPress: () => {}, + theme, + }); + + await render(card); + + expect(screen.getByTestId('card-background')).toHaveStyle({ + backgroundColor: theme.colors[containerRole], + opacity: containerOpacity, + }); + expect(screen.getByTestId('card-state-layer')).toHaveStyle({ + opacity: 0, + }); + expect(screen.getByTestId('card-container')).toHaveStyle({ elevation }); + expect(screen.getByTestId('card-focus-indicator')).toHaveStyle({ + opacity: 0, + }); + + const expectedOutlineStyle = outlineRole + ? { + borderColor: theme.colors[outlineRole], + borderWidth: 1, + opacity: outlineOpacity, + } + : undefined; + expectOutlineStyle(expectedOutlineStyle); + } + ); + + it('resolves disabled and dragged before pressed, focused, and hovered visuals', async () => { + expect.hasAssertions(); + jest.replaceProperty(Platform, 'OS', 'android'); + const theme = LightTheme; + const { rerender } = await render( + {}} theme={theme} /> + ); + const target = screen.getByTestId('card'); + + await fireEvent(target, 'hoverIn'); + await fireEvent(target, 'focus'); + await fireEvent(target, 'pressIn'); + await act(() => { + jest.runOnlyPendingTimers(); + }); + + expectAnimatedStyle('card-state-layer', { + opacity: systemTokens.md.sys.state.opacity.dragged, + }); + expectAnimatedStyle('card-container', { elevation: 6 }); + expectAnimatedStyle('card-outline', { + borderColor: theme.colors.outlineVariant, + opacity: 1, + }); + expectAnimatedStyle('card-focus-indicator', { opacity: 1 }); + + await rerender( + {}} + theme={theme} + /> + ); + await act(() => { + jest.runOnlyPendingTimers(); + }); + + expectAnimatedStyle('card-state-layer', { opacity: 0 }); + expectAnimatedStyle('card-container', { elevation: 0 }); + expectAnimatedStyle('card-outline', { + borderColor: theme.colors.outline, + opacity: 0.12, + }); + expectAnimatedStyle('card-focus-indicator', { opacity: 0 }); + }); + + it('updates the controlled dragged presentation in both directions', async () => { + expect.hasAssertions(); + jest.replaceProperty(Platform, 'OS', 'android'); + const theme = LightTheme; + const props = { + variant: 'outlined' as const, + onPress: () => {}, + theme, + }; + const { rerender } = await render(); + + await rerender(); + await act(() => { + jest.runOnlyPendingTimers(); + }); + + expectAnimatedStyle('card-state-layer', { + opacity: systemTokens.md.sys.state.opacity.dragged, + }); + expectAnimatedStyle('card-container', { elevation: 6 }); + expectAnimatedStyle('card-outline', { + borderColor: theme.colors.outlineVariant, + opacity: 1, + }); + + await rerender(); + await act(() => { + jest.runOnlyPendingTimers(); + }); + + expectAnimatedStyle('card-state-layer', { opacity: 0 }); + expectAnimatedStyle('card-container', { elevation: 0 }); + expectAnimatedStyle('card-outline', { + borderColor: theme.colors.outlineVariant, + opacity: 1, + }); + }); + + it.each(['filled', 'elevated', 'outlined'] as const)( + 'renders keyboard focus feedback for the %s variant', + async (variant) => { + expect.hasAssertions(); + jest.replaceProperty(Platform, 'OS', 'web'); + const card = getVariantCard(variant, { onPress: () => {} }); + await render(card); + + await fireEvent(screen.getByTestId('card'), 'focus', { + currentTarget: { matches: () => true }, + }); + await act(() => { + jest.runOnlyPendingTimers(); + }); + + expectAnimatedStyle('card-state-layer', { + opacity: systemTokens.md.sys.state.opacity.focused, + }); + expectAnimatedStyle('card-focus-indicator', { opacity: 1 }); + } + ); + it('shows focus feedback only for keyboard-visible focus and clears it on blur', async () => { expect.hasAssertions(); jest.replaceProperty(Platform, 'OS', 'web'); - const theme = getTheme(); + const theme = LightTheme; await render( {}} theme={theme} />); const target = screen.getByTestId('card'); const pointerTarget = { matches: () => false }; @@ -555,7 +808,7 @@ describe('Card', () => { it('uses pressed, focused, then hovered precedence and settles at the latest state', async () => { expect.hasAssertions(); - const theme = getTheme(); + const theme = LightTheme; await render( {}} theme={theme} />); const target = screen.getByTestId('card'); @@ -591,6 +844,91 @@ describe('Card', () => { expectAnimatedStyle('card-state-layer', { opacity: 0 }); }); + it('uses scaled theme motion duration and easing for visual transitions', async () => { + expect.hasAssertions(); + const easing = [0.1, 0.2, 0.3, 0.4] as const; + const theme = { + animation: { scale: 0.5 }, + motion: { + duration: { short3: 320 }, + easing: { standard: easing }, + }, + }; + const { rerender } = await render( + {}} theme={theme} /> + ); + + expectAnimatedStyle('card-state-layer', { + transitionDuration: 160, + transitionProperty: ['opacity'], + transitionTimingFunction: Reanimated.cubicBezier(...easing), + }); + expectAnimatedStyle('card-container', { transitionDuration: 160 }); + + await rerender( {}} theme={theme} />); + + expectAnimatedStyle('card-state-layer', { transitionDuration: 160 }); + expectAnimatedStyle('card-container', { transitionDuration: 160 }); + }); + + it('settles transitions immediately when reduced motion is enabled', async () => { + expect.hasAssertions(); + await render( + + {}} /> + + ); + + await fireEvent(screen.getByTestId('card'), 'pressIn'); + await act(() => { + jest.runOnlyPendingTimers(); + }); + + expectAnimatedStyle('card-state-layer', { + opacity: systemTokens.md.sys.state.opacity.pressed, + transitionDuration: 0, + }); + expectAnimatedStyle('card-container', { transitionDuration: 0 }); + expectAnimatedStyle('card-outline', { transitionDuration: 0 }); + expectAnimatedStyle('card-focus-indicator', { transitionDuration: 0 }); + }); + + it('settles rapid changes at the latest complete visual state', async () => { + expect.hasAssertions(); + jest.replaceProperty(Platform, 'OS', 'android'); + const theme = LightTheme; + const props = { + variant: 'outlined' as const, + onPress: () => {}, + theme, + }; + const { rerender } = await render(); + const target = screen.getByTestId('card'); + + await fireEvent(target, 'hoverIn'); + await fireEvent(target, 'focus'); + await fireEvent(target, 'pressIn'); + await rerender(); + await rerender(); + await fireEvent(target, 'pressOut'); + await fireEvent(target, 'blur'); + await fireEvent(target, 'hoverOut'); + await fireEvent(target, 'hoverIn'); + await act(() => { + jest.runOnlyPendingTimers(); + }); + + expectAnimatedStyle('card-state-layer', { + opacity: systemTokens.md.sys.state.opacity.hovered, + }); + expectAnimatedStyle('card-container', { elevation: 1 }); + expectAnimatedStyle('card-outline', { + borderColor: theme.colors.outlineVariant, + opacity: 1, + }); + expectAnimatedStyle('card-focus-indicator', { opacity: 0 }); + }); + it('does not rerender stable memoized slot content for transient feedback', async () => { const renderCount = jest.fn(); const StableContent = React.memo(() => { @@ -941,7 +1279,7 @@ describe('CardCover', () => { borderBottomLeftRadius: 20, }); expect(screen.getByTestId('edge-cover')).not.toHaveStyle({ - borderRadius: getTheme().shapes.corner.medium, + borderRadius: LightTheme.shapes.corner.medium, }); }); }); From 335a05d20a8613e642c7415734498748c32bc6e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Kata?= Date: Fri, 4 Sep 2026 14:58:59 +0200 Subject: [PATCH 08/11] fix(card): fix worklet related complaints --- src/components/Card/Card.tsx | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/components/Card/Card.tsx b/src/components/Card/Card.tsx index 7992ec31f8..2eaf7fb5f3 100644 --- a/src/components/Card/Card.tsx +++ b/src/components/Card/Card.tsx @@ -9,6 +9,7 @@ import type { import Animated, { cubicBezier, + type AnimatedStyle, useAnimatedStyle, useDerivedValue, useSharedValue, @@ -466,14 +467,24 @@ const Card = ({ ? 0 : theme.motion.duration.short3 * theme.animation.scale; const transitionTimingFunction = cubicBezier(...theme.motion.easing.standard); + // Parametrized timing functions are class instances and cannot cross the + // worklet boundary, so keep them in regular styles. + const stateLayerTransitionStyle: AnimatedStyle = { + transitionTimingFunction, + }; + const outlineTransitionStyle: AnimatedStyle = { + transitionTimingFunction, + }; + const focusIndicatorTransitionStyle: AnimatedStyle = { + transitionTimingFunction, + }; const stateLayerAnimatedStyle = useAnimatedStyle( () => ({ opacity: currentInteractiveVisuals.value.stateLayerOpacity, transitionDuration, transitionProperty: ['opacity'], - transitionTimingFunction, }), - [currentInteractiveVisuals, transitionDuration, transitionTimingFunction] + [currentInteractiveVisuals, transitionDuration] ); const outlineAnimatedStyle = useAnimatedStyle(() => { const outlineColor = currentInteractiveVisuals.value.outlineColor; @@ -486,17 +497,15 @@ const Card = ({ typeof outlineColor === 'string' ? ['borderColor', 'opacity'] : ['opacity'], - transitionTimingFunction, }; - }, [currentInteractiveVisuals, transitionDuration, transitionTimingFunction]); + }, [currentInteractiveVisuals, transitionDuration]); const focusIndicatorAnimatedStyle = useAnimatedStyle( () => ({ opacity: disabledState.value ? 0 : focused.value ? 1 : 0, transitionDuration, transitionProperty: ['opacity'], - transitionTimingFunction, }), - [disabledState, transitionDuration, transitionTimingFunction] + [disabledState, transitionDuration] ); React.useEffect(() => { @@ -746,6 +755,7 @@ const Card = ({ { backgroundColor: visuals.stateLayerColor, }, + stateLayerTransitionStyle, stateLayerAnimatedStyle, ]} /> @@ -789,6 +799,7 @@ const Card = ({ StyleSheet.absoluteFill, shapeStyle, { borderWidth: visuals.outlineWidth }, + outlineTransitionStyle, outlineAnimatedStyle, ]} /> @@ -809,6 +820,7 @@ const Card = ({ borderWidth: systemTokens.md.sys.state.focusIndicator.thickness, }, focusIndicatorShapeStyle, + focusIndicatorTransitionStyle, focusIndicatorAnimatedStyle, ]} /> From da09098491b3f2d4c5a6e6b780953350cf4b0da7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Kata?= Date: Mon, 7 Sep 2026 08:31:15 +0200 Subject: [PATCH 09/11] feat(card): add expressive example gallery --- example/src/Examples/CardExample.tsx | 537 +++++++++++------- .../src/Examples/CardRenderCountExample.tsx | 114 ++++ .../__tests__/CardRenderCountExample.test.tsx | 76 +++ 3 files changed, 524 insertions(+), 203 deletions(-) create mode 100644 example/src/Examples/CardRenderCountExample.tsx create mode 100644 example/src/Examples/__tests__/CardRenderCountExample.test.tsx diff --git a/example/src/Examples/CardExample.tsx b/example/src/Examples/CardExample.tsx index a5fa6ee49d..c7665a699f 100644 --- a/example/src/Examples/CardExample.tsx +++ b/example/src/Examples/CardExample.tsx @@ -1,212 +1,301 @@ -import * as React from 'react'; -import { Alert, Platform, ScrollView, StyleSheet } from 'react-native'; +import { Alert, Platform, StyleSheet, View } from 'react-native'; import { Avatar, Button, Card, + DarkTheme, IconButton, + LightTheme, Text, + ThemeProvider, useTheme, } from 'react-native-paper'; +import type { CardCoverProps, Theme } from 'react-native-paper'; -import { PreferencesContext } from '../PreferencesContext'; +import CardRenderCountExample from './CardRenderCountExample'; import ScreenWrapper from '../ScreenWrapper'; +const showMessage = (message: string) => { + if (Platform.OS === 'web') { + alert(message); + } else { + Alert.alert(message); + } +}; + +const CustomHeader = () => ( + + + + Custom header + Neutral container, independent actions + + +); + +const ResponsiveCover = ({ + aspectRatio = 16 / 9, + ...props +}: Omit & { aspectRatio?: number }) => ( + + + +); + +const ThemePreview = ({ name }: { name: string }) => { + const theme = useTheme(); + + return ( + + {name} + showMessage(`${name} filled Card pressed`)} + onLongPress={() => showMessage(`${name} filled Card long pressed`)} + title="Filled interaction" + subtitle="Tab, hover, press, or long press" + content={ + + + State layers and keyboard focus stay inside the shape. + + + } + /> + + Compare the raised surface. + + } + /> + + } + title="Outlined clipping" + /> + + ); +}; + +const ThemedPreview = ({ name, theme }: { name: string; theme: Theme }) => ( + + + +); + const CardExample = () => { - const { colors } = useTheme(); - const [isSelected, setIsSelected] = React.useState(false); - const preferences = React.useContext(PreferencesContext); + const theme = useTheme(); return ( - - + + Expressive Card gallery + + Filled is the default. Whole-Card actions are shown without nested + controls; neutral Cards own any independent actions. + + + + + Variants and composition + + showMessage('Default filled Card pressed')} + onLongPress={() => showMessage('Default filled Card long pressed')} + media={ + + } + title="Filled (default)" + subtitle="Actionable Card" + content={ + + + One coherent target with direct title and content slots. + + + } + /> + + } + content={ + + + Card.Actions preserves each control's own presentation. + + + } + actions={ + + showMessage('Saved')} + /> + + + } + /> + + + } + title="Outlined" + subtitle="Responsive informative media" + /> + + + + + States, shapes, and omitted slots + + showMessage('Disabled Card pressed')} + title="Disabled action" + content={ + + Not focusable or pressable. + + } + /> + + Controlled visual state. + + } + /> + + } + content={ + + Asymmetric shape, no header. + + } + /> + } + right={(props) => ( + showMessage('More options')} + /> + )} + /> + } + /> + + + + + Light and dark verification + + Compare surfaces, outlines, elevation, clipping, and interaction + feedback without changing the application theme. + + + + + + + + - - } - title="Abandoned Ship" - content={ - - - The Abandoned Ship is a wrecked ship located on Route 108 in - Hoenn, originally being a ship named the S.S. Cactus. The second - part of the ship can only be accessed by using Dive and contains - the Scanner. - - - } - /> - - } - header={ - - } - content={ - - - This is a card using title and subtitle with specified variants. - - - } - /> - - } - actions={ - - - - - } - /> - } - trailing={(props) => ( - {}} /> - )} - content={ - - - Dotted around the Hoenn region, you will find loamy soil, many - of which are housing berries. Once you have picked the berries, - then you have the ability to use that loamy soil to grow your - own berries. These can be any berry and will require attention - to get the best crop. - - - } - /> - - } - title="Custom Button styles" - actions={ - - - - - } - /> - - } - title="Custom border radius" - subtitle="... for card and cover" - /> - - } - title="Just Strawberries" - subtitle="... and only Strawberries" - trailing={(props) => ( - setIsSelected(!isSelected)} - /> - )} - /> - { - Platform.OS === 'web' - ? alert('The Chameleon is Pressed') - : Alert.alert('The Chameleon is Pressed'); - }} - media={ - - } - title="Pressable Chameleon" - content={ - - - This is a pressable chameleon. If you press me, I will alert. - - - } - /> - { - Platform.OS === 'web' - ? alert('The City is Long Pressed') - : Alert.alert('The City is Long Pressed'); - }} - media={ - - } - title="Long Pressable City" - leading={(props) => } - content={ - - - This is a long press only city. If you long press me, I will - alert. - - - } - /> - { - preferences?.toggleTheme(); - }} - title="Pressable Theme Change" - leading={(props) => } - content={ - - - This is pressable card. If you press me, I will switch the - theme. - - - } - /> - + Platform verification + + Android · iOS · web: compare layout, both themes, surface roles, + outlines, clipping, and elevation. + + + Web: use Tab and hover on actionable Cards to inspect focus and state + layers. + + + Native: touch actionable Cards to inspect bounded ripple, clipping, + and elevation. Current platform: {Platform.OS}. + + + + + + ); }; @@ -214,19 +303,61 @@ const CardExample = () => { CardExample.title = 'Card'; const styles = StyleSheet.create({ - container: { - flex: 1, + screen: { + gap: 28, + padding: 16, + }, + intro: { + gap: 8, + }, + section: { + gap: 12, + }, + gallery: { + alignItems: 'flex-start', + flexDirection: 'row', + flexWrap: 'wrap', + gap: 16, }, - content: { - padding: 4, + galleryCard: { + minWidth: 260, + flexBasis: 300, + flexGrow: 1, + }, + compactCard: { + minWidth: 220, + flexBasis: 250, + flexGrow: 1, + }, + mediaFrame: { + overflow: 'hidden', + width: '100%', + }, + mediaFill: { + height: '100%', + }, + customHeader: { + alignItems: 'center', + flexDirection: 'row', + gap: 12, + paddingHorizontal: 16, + paddingTop: 16, + }, + customHeaderText: { + flex: 1, }, - card: { - margin: 4, + themePreview: { + borderWidth: 1, + flexBasis: 300, + flexGrow: 1, + gap: 12, + minWidth: 260, + padding: 16, }, - customCoverRadius: { - borderTopLeftRadius: 0, - borderTopRightRadius: 0, - borderBottomRightRadius: 24, + verification: { + borderLeftWidth: 4, + gap: 6, + paddingLeft: 12, }, }); diff --git a/example/src/Examples/CardRenderCountExample.tsx b/example/src/Examples/CardRenderCountExample.tsx new file mode 100644 index 0000000000..368883f0f1 --- /dev/null +++ b/example/src/Examples/CardRenderCountExample.tsx @@ -0,0 +1,114 @@ +import * as React from 'react'; +import { FlatList, StyleSheet, View } from 'react-native'; + +import { Button, Card, Text } from 'react-native-paper'; + +const benchmarkItems = Array.from({ length: 30 }, (_, index) => ({ + id: index + 1, + title: `List item ${String(index + 1).padStart(2, '0')}`, +})); + +type BenchmarkItem = (typeof benchmarkItems)[number]; + +const handleCardPress = () => {}; + +const StableCardContent = React.memo(({ item }: { item: BenchmarkItem }) => { + const renderCount = React.useRef(0); + renderCount.current += 1; + + return ( + + Referentially stable slot content + + {`Stable content renders: ${renderCount.current}`} + + + ); +}); + +StableCardContent.displayName = 'StableCardContent'; + +const BenchmarkCard = ({ item }: { item: BenchmarkItem }) => ( + } + /> +); + +const CardRenderCountExample = () => { + const [listRevision, setListRevision] = React.useState(0); + const parentRenderCount = React.useRef(0); + parentRenderCount.current += 1; + + const renderItem = React.useCallback( + ({ item }: { item: BenchmarkItem }) => , + [] + ); + + return ( + + Large-list render boundary + + Interact with a Card using touch, hover, or keyboard focus. Its stable + content counter should remain at 1. + + + {`Parent render passes: ${parentRenderCount.current}`} + + + String(item.id)} + renderItem={renderItem} + showsHorizontalScrollIndicator + contentContainerStyle={styles.list} + /> + + 30 actionable Cards · counters are recorded by memoized content + subtrees, not by the Card shell. + + + ); +}; + +const styles = StyleSheet.create({ + container: { + gap: 12, + }, + metrics: { + alignItems: 'center', + flexDirection: 'row', + flexWrap: 'wrap', + gap: 12, + justifyContent: 'space-between', + }, + list: { + gap: 12, + padding: 4, + }, + card: { + marginVertical: 4, + width: 240, + }, +}); + +export default CardRenderCountExample; diff --git a/example/src/Examples/__tests__/CardRenderCountExample.test.tsx b/example/src/Examples/__tests__/CardRenderCountExample.test.tsx new file mode 100644 index 0000000000..e34e33c32c --- /dev/null +++ b/example/src/Examples/__tests__/CardRenderCountExample.test.tsx @@ -0,0 +1,76 @@ +import { Platform } from 'react-native'; + +import { describe, expect, it, jest } from '@jest/globals'; +import { getAnimatedStyle } from 'react-native-reanimated'; + +import { + act, + fireEvent, + render, + screen, + userEvent, +} from '../../../../src/test-utils'; +import CardRenderCountExample from '../CardRenderCountExample'; + +jest.mock('react', () => jest.requireActual('../../../../node_modules/react')); +jest.mock('react-native-reanimated', () => + jest.requireActual('../../../../node_modules/react-native-reanimated') +); +jest.mock('react-native-paper', () => + jest.requireActual('../../../../src/index') +); + +describe('CardRenderCountExample', () => { + it('keeps stable content at one render through feedback and a parent rerender', async () => { + jest.replaceProperty(Platform, 'OS', 'web'); + const user = userEvent.setup(); + await render(); + + const firstCard = screen.getByTestId('card-benchmark-item-1'); + + await fireEvent(firstCard, 'hoverIn'); + await act(() => { + jest.runOnlyPendingTimers(); + }); + expect( + getAnimatedStyle(screen.getByTestId('card-benchmark-item-1-state-layer')) + ).toEqual(expect.objectContaining({ opacity: 0.08 })); + + await fireEvent(firstCard, 'focus', { + currentTarget: { matches: () => true }, + }); + await act(() => { + jest.runOnlyPendingTimers(); + }); + expect( + getAnimatedStyle( + screen.getByTestId('card-benchmark-item-1-focus-indicator') + ) + ).toEqual(expect.objectContaining({ opacity: 1 })); + + await fireEvent(firstCard, 'pressIn'); + await act(() => { + jest.runOnlyPendingTimers(); + }); + expect( + getAnimatedStyle(screen.getByTestId('card-benchmark-item-1-state-layer')) + ).toEqual(expect.objectContaining({ opacity: 0.1 })); + + await fireEvent(firstCard, 'pressOut'); + await fireEvent(firstCard, 'blur'); + await fireEvent(firstCard, 'hoverOut'); + + expect( + screen.getByTestId('card-benchmark-render-count-1') + ).toHaveTextContent('Stable content renders: 1'); + + await user.press(screen.getByTestId('card-benchmark-rerender')); + + expect(screen.getByTestId('card-benchmark-parent-count')).toHaveTextContent( + 'Parent render passes: 2' + ); + expect( + screen.getByTestId('card-benchmark-render-count-1') + ).toHaveTextContent('Stable content renders: 1'); + }); +}); From dd5916b977a1d2cf1c6418912c6d7ab264a09eca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Kata?= Date: Mon, 7 Sep 2026 09:08:08 +0200 Subject: [PATCH 10/11] docs(card): publish v6 contract and migration --- docs/6.x/docs/guides/migration.md | 87 +++++++++++- .../guides/theming-with-react-navigation.md | 12 +- docs/component-docs.config.ts | 6 +- .../component-docs/__tests__/parser.test.ts | 25 ++++ docs/plugins/component-docs/parser.ts | 16 +-- docs/src/components/ThemeColorsTable.tsx | 15 +- docs/src/data/screenshots.ts | 2 +- docs/src/data/themeColors.ts | 8 +- src/components/Card/Card.tsx | 128 +++++++++++++----- src/components/Card/tokens.ts | 6 +- src/components/__tests__/Card/Card.test.tsx | 30 +++- 11 files changed, 262 insertions(+), 73 deletions(-) create mode 100644 docs/plugins/component-docs/__tests__/parser.test.ts diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index 35a8f74830..ca8d4b7b2b 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -38,7 +38,7 @@ const MyComponent = () => { opacity: opacity.value, })); - return Button; + return ; }; ``` @@ -47,7 +47,7 @@ const MyComponent = () => { The `elevation` prop no longer accepts a React Native `Animated.Value` in the following components: - `Banner` -- `Card` +- `Card` (`variant="elevated"` only) - `Searchbar` - `Snackbar` - `Surface` @@ -115,6 +115,89 @@ The `style` props for `Appbar` and `Appbar.Header` no longer accept `Animated.Va The `style.elevation` property is no longer supported. Use the `elevated` prop to control Appbar elevation. +### Card + +Paper 6 replaces the Card's `mode` and arbitrary-children interfaces with Material 3 variants and explicit slots. These interfaces were removed; they are not deprecated APIs. Migrate each Card directly to the new contract. + +#### Variants and default + +Replace `mode` with `variant`: + +| Paper 5 | Paper 6 | +| --- | --- | +| `mode="contained"` | `variant="filled"` | +| `mode="elevated"` | `variant="elevated"` | +| `mode="outlined"` | `variant="outlined"` | + +The default also changed. A Paper 5 Card without `mode` was elevated; a Paper 6 Card without `variant` is filled and has no resting shadow. Add `variant="elevated"` if you need to preserve the old default emphasis. The `elevation` prop is accepted only with `variant="elevated"`. + +#### Replace nested composition with slots + +Arbitrary Card children were removed. Paper 6 renders the explicit regions in the deterministic order `media`, header, `content`, and `actions`, regardless of the order in which props are written. Arrays, fragments, conditional values, and custom wrappers can be passed inside a slot without changing region placement. + +For the common header form, move `Card.Title` values to `title`, `subtitle`, `leading`, and `trailing`. Move the remaining regions to their corresponding slots: + +```tsx +// Before (v5) + + + + + View the itinerary. + + + +// After (v6) +} + title="Weekend trip" + subtitle="2 days" + content={ + + View the itinerary. + + } +/> +``` + +Use `header` when the complete header is custom. It is mutually exclusive with `title`, `subtitle`, `leading`, and `trailing`: + +```tsx +} + content={{trip.summary}} +/> +``` + +`Card.Content`, `Card.Cover`, `Card.Title`, and `Card.Actions` remain available as layout helpers inside the new slots. They are not arbitrary Card children. + +#### Choose one interaction model + +Give the Card an interaction handler when the whole Card represents one action. It becomes one accessibility target with button semantics by default, so do not place independent controls in its `actions` slot. + +When buttons or other controls perform independent actions, keep the Card itself neutral and put those controls in `actions`: + +```tsx +Review before saving.} + actions={ + + + + + } +/> +``` + +Paper 6 warns in development if whole-Card interaction handlers and a populated `actions` slot are combined. A neutral Card remains a grouping container unless you provide accessibility semantics explicitly. The `dragged` prop controls the Material dragged presentation only; drag gestures and lifecycle remain application responsibilities. + +Card refs and test IDs now target documented nodes: `ref` targets the outer shell, `touchableRef` targets the actionable interaction node, and `testID` targets the interaction node for actionable Cards or the slot-content node for neutral Cards. `${testID}-container` and `${testID}-visual` identify the outer shell and clipped visual region. + ### Surface - The `elevation` prop no longer accepts a React Native `Animated.Value`. Any `elevation` changes are animated automatically. diff --git a/docs/6.x/docs/guides/theming-with-react-navigation.md b/docs/6.x/docs/guides/theming-with-react-navigation.md index ada86a611c..43d0b3201e 100644 --- a/docs/6.x/docs/guides/theming-with-react-navigation.md +++ b/docs/6.x/docs/guides/theming-with-react-navigation.md @@ -45,27 +45,27 @@ For React Native Paper theme to work, we need to use `PaperProvider` also at the ```js import { NavigationContainer } from '@react-navigation/native'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; -import { TouchableOpacity } from 'react-native'; import { Card, Text, List, PaperProvider } from 'react-native-paper'; const Stack = createNativeStackNavigator(); const HomeScreen = ({ navigation }) => ( - navigation?.push('Details', { title, content, }) } - > - + title={title} + content={ {title} {content} - - + } + /> ); const DetailsScreen = (props) => { diff --git a/docs/component-docs.config.ts b/docs/component-docs.config.ts index 4196e8e7bd..3cb6482918 100644 --- a/docs/component-docs.config.ts +++ b/docs/component-docs.config.ts @@ -135,7 +135,11 @@ const pages = { SegmentedButtons: 'SegmentedButtons/SegmentedButtons', }, Snackbar: 'Snackbar', - Surface: 'Surface', + Surface: { + source: 'Surface', + component: 'StaticSurface', + title: 'Surface', + }, Switch: { Switch: 'Switch/Switch', }, diff --git a/docs/plugins/component-docs/__tests__/parser.test.ts b/docs/plugins/component-docs/__tests__/parser.test.ts new file mode 100644 index 0000000000..6b3ff8c68b --- /dev/null +++ b/docs/plugins/component-docs/__tests__/parser.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from '@jest/globals'; +import path from 'node:path'; + +import { createComponentParser } from '../parser'; + +describe('component docs parser', () => { + it('documents every branch of the Card public prop unions', () => { + const repositoryRoot = process.cwd(); + const parse = createComponentParser( + path.join(repositoryRoot, 'tsconfig.source.json') + ); + const { props: documentedProps } = parse( + path.join(repositoryRoot, 'src', 'components'), + { source: 'Card/Card' } + ); + const props = new Map(documentedProps.map((prop) => [prop.name, prop])); + + expect(props.get('variant')?.type).toContain('"filled"'); + expect(props.get('variant')?.type).toContain('"elevated"'); + expect(props.get('variant')?.type).toContain('"outlined"'); + expect(props.get('elevation')?.type).toContain('Elevation'); + expect(props.get('header')?.type).toBe('React.ReactNode'); + expect(props.get('title')?.type).toBe('React.ReactNode'); + }); +}); diff --git a/docs/plugins/component-docs/parser.ts b/docs/plugins/component-docs/parser.ts index 800bd8f2f3..ed03c20bb8 100644 --- a/docs/plugins/component-docs/parser.ts +++ b/docs/plugins/component-docs/parser.ts @@ -206,22 +206,8 @@ const getProps = ( ? [item.type.getText(item.getSourceFile())] : [] ); - const hasUnionDeclarations = declarations.some((item) => { - let parent: ts.Node | undefined = item.parent; - - while (parent && !ts.isTypeAliasDeclaration(parent)) { - if (ts.isUnionTypeNode(parent)) { - return true; - } - - parent = parent.parent; - } - - return false; - }); const type = - (hasUnionDeclarations && new Set(declarationTypes).size > 1) || - !declarations.includes(property) + new Set(declarationTypes).size > 1 || !declarations.includes(property) ? checker.typeToString( checker.getTypeOfSymbolAtLocation(symbol, property), property, diff --git a/docs/src/components/ThemeColorsTable.tsx b/docs/src/components/ThemeColorsTable.tsx index d9c5cec724..ba6f5cfc2e 100644 --- a/docs/src/components/ThemeColorsTable.tsx +++ b/docs/src/components/ThemeColorsTable.tsx @@ -24,9 +24,11 @@ const isDataObject = (value: DataObject[string]): value is DataObject => typeof value === 'object'; const FlatTable = ({ + firstColumnLabel, themeColorsData, uniqueKeys, }: { + firstColumnLabel: string; themeColorsData: DataObject; uniqueKeys: string[]; }): ReactNode => { @@ -50,7 +52,7 @@ const FlatTable = ({ - + {getTableHeader(uniqueKeys)} @@ -61,9 +63,11 @@ const FlatTable = ({ }; const TabbedTable = ({ + firstColumnLabel, themeColorsData, uniqueKeys, }: { + firstColumnLabel: string; themeColorsData: DataObject; uniqueKeys: string[]; }): ReactNode => { @@ -88,7 +92,7 @@ const TabbedTable = ({
mode{firstColumnLabel}
- + {getTableHeader(uniqueKeys)} @@ -120,12 +124,17 @@ const ThemeColorsTable = ({ const uniqueKeys = getUniqueNestedKeys(themeColorsData); const nestingLevel = getMaxNestedLevel(themeColorsData); const isFlatTable = nestingLevel === 1; + const firstColumnLabel = componentName === 'Card' ? 'variant' : 'mode'; const Table = isFlatTable ? FlatTable : TabbedTable; return ( <> -
mode{firstColumnLabel}
+

If a dedicated prop for a specific color is not available or the{' '} diff --git a/docs/src/data/screenshots.ts b/docs/src/data/screenshots.ts index 92bf8f2788..d520402f57 100644 --- a/docs/src/data/screenshots.ts +++ b/docs/src/data/screenshots.ts @@ -29,9 +29,9 @@ export const screenshots = { 'contained-tonal': 'screenshots/button-5.png', }, Card: { + filled: 'screenshots/card-3.png', elevated: 'screenshots/card-1.png', outlined: 'screenshots/card-2.png', - contained: 'screenshots/card-3.png', }, 'Card.Actions': 'screenshots/card-actions.png', 'Card.Content': 'screenshots/card-content-example.png', diff --git a/docs/src/data/themeColors.ts b/docs/src/data/themeColors.ts index 20f16962f7..0cbc6ec27a 100644 --- a/docs/src/data/themeColors.ts +++ b/docs/src/data/themeColors.ts @@ -90,15 +90,15 @@ export const themeColors = { }, }, Card: { - contained: { - backgroundColor: 'theme.colors.surfaceVariant', + filled: { + backgroundColor: 'theme.colors.surfaceContainerHighest', }, elevated: { - backgroundColor: 'theme.colors.elevation.level1', + backgroundColor: 'theme.colors.surfaceContainerLow', }, outlined: { backgroundColor: 'theme.colors.surface', - borderColor: 'theme.colors.outline', + borderColor: 'theme.colors.outlineVariant', }, }, Dialog: { diff --git a/src/components/Card/Card.tsx b/src/components/Card/Card.tsx index 2eaf7fb5f3..05578e5ff1 100644 --- a/src/components/Card/Card.tsx +++ b/src/components/Card/Card.tsx @@ -34,30 +34,32 @@ import type { Props as TouchableRippleProps } from '../TouchableRipple/Touchable type ConvenienceHeaderProps = { /** - * Header title. + * Title rendered in the Card's header region. */ title?: React.ReactNode; /** - * Header subtitle. + * Subtitle rendered below `title` in the Card's header region. */ subtitle?: React.ReactNode; /** - * Render slot displayed before the title and subtitle. + * Render slot displayed before `title` and `subtitle`. */ leading?: CardTitleProps['left']; /** - * Render slot displayed after the title and subtitle. + * Render slot displayed after `title` and `subtitle`. */ trailing?: CardTitleProps['right']; /** - * A fully custom header cannot be combined with convenience header props. + * Fully custom header region. This cannot be combined with `title`, + * `subtitle`, `leading`, or `trailing`. */ header?: never; }; type CustomHeaderProps = { /** - * Fully custom header content. + * Fully custom header region. This cannot be combined with `title`, + * `subtitle`, `leading`, or `trailing`. */ header: React.ReactNode; /** @@ -139,11 +141,12 @@ type CardShapeProps = { type FilledCardProps = { /** - * Filled Card variant (default). + * Material Card variant. `filled` is the default, `elevated` adds hierarchy + * with a shadow, and `outlined` adds a visible boundary. */ variant?: 'filled'; /** - * Filled Cards do not support custom elevation. + * Resting elevation. Available only when `variant="elevated"`. */ elevation?: never; }; @@ -172,18 +175,22 @@ type OutlinedCardProps = { type CardVariantProps = FilledCardProps | ElevatedCardProps | OutlinedCardProps; -type CardBaseProps = Omit & +export type Props = Omit & CardShapeProps & { /** - * Media rendered at the start of the Card. + * Media rendered as the first Card region. Use `Card.Cover` for responsive + * edge-to-edge image media, or pass any React node, array, or fragment. */ media?: React.ReactNode; /** - * Main Card content. + * Main content rendered after the header. Use `Card.Content` when the + * standard Card padding is desired. */ content?: React.ReactNode; /** - * Actions rendered at the end of the Card. + * Actions rendered as the final Card region. Independent controls belong + * here only when the Card itself is neutral, without interaction handlers. + * `Card.Actions` provides the standard action-row layout. */ actions?: React.ReactNode; /** @@ -220,13 +227,18 @@ type CardBaseProps = Omit & disabled?: boolean; /** * Whether to show the Card's controlled Material dragged presentation. - * Gesture recognition and drag lifecycle remain the consumer's responsibility. + * This controls visuals only; gesture recognition, drag lifecycle, list + * reordering, and drop behavior remain the consumer's responsibility. */ dragged?: boolean; /** - * Style of card's inner content. + * Style of the inner region that contains all Card slots. */ contentStyle?: StyleProp; + /** + * Layout style for the outer Card shell. Use the dedicated shape props and + * `variant` or `elevation` for Card visuals. + */ style?: StyleProp; /** * @optional @@ -239,7 +251,9 @@ type CardBaseProps = Omit & */ testID?: string; /** - * Whether the Card's semantic target is an accessibility element. + * Whether the Card's semantic target is an accessibility element. For an + * actionable Card this applies to its single interaction target; otherwise + * it applies to the neutral outer shell. */ accessible?: boolean; /** @@ -250,47 +264,87 @@ type CardBaseProps = Omit & * Reference to the outer Card shell. */ ref?: React.Ref; - }; - -export type Props = CardBaseProps & - (ConvenienceHeaderProps | CustomHeaderProps) & + } & (ConvenienceHeaderProps | CustomHeaderProps) & CardVariantProps; /** - * A Card groups related media, header content, body content, and actions. - * Use the `filled` (default), `elevated`, or `outlined` variant to select its - * Material 3 emphasis. + * A Card groups related media, header content, body content, and actions. It + * renders populated regions in the fixed order `media`, header, `content`, and + * `actions`, regardless of prop order. Slots accept React nodes, including + * arrays and fragments, and Card does not clone or rewrite them. + * + * The header region can be created directly with `title`, `subtitle`, `leading`, + * and `trailing`, or replaced completely with `header`; the two forms are + * mutually exclusive. `Card.Title`, `Card.Content`, `Card.Cover`, and + * `Card.Actions` remain optional layout helpers for their corresponding slots. * - * A Card with an interaction handler represents one action. It receives button - * semantics by default and must not contain independent controls in `actions`. - * Use a neutral Card when its actions provide their own interaction targets. + * Use `filled` (the default), `elevated`, or `outlined` for Material 3 emphasis. + * Only an elevated Card accepts `elevation`. Every variant uses the theme's + * medium shape by default; the dedicated corner props consistently shape the + * shadow shell, clipped visual region, outline, state layer, ripple, focus + * indicator, and edge media. + * + * Supplying `onPress`, `onLongPress`, `onPressIn`, or `onPressOut` makes the + * whole Card one actionable target. It receives button semantics by default, + * routes accessibility props and `touchableRef` to that target, and must not + * contain independent controls in `actions`. Keep the Card neutral when the + * controls in `actions` are the interaction targets. A neutral Card remains a + * grouping container unless accessibility semantics are supplied explicitly. + * Disabled Cards expose disabled semantics and suppress interaction callbacks. + * + * `ref` targets the outer shadow shell. On an actionable Card, `testID` targets + * the interaction node; on a neutral Card it targets the slot-content node. + * `${testID}-container` and `${testID}-visual` target the outer shell and the + * clipped visual region. `dragged` controls Material dragged visuals only; the + * consumer remains responsible for gesture recognition and drag lifecycle. * * ## Usage - * ```js + * + * An actionable filled Card represents one action and contains no independent + * controls: + * + * ```tsx * import * as React from 'react'; * import { Avatar, Button, Card, Text } from 'react-native-paper'; + * import { View } from 'react-native'; * - * const Leading = props => + * const CardExamples = () => ( + * + * console.log('Open trip details')} + * media={} + * title="Weekend trip" + * subtitle="Actionable filled Card" + * leading={(props) => } + * content={ + * View the itinerary. + * } + * /> * - * const MyComponent = () => ( * } - * title="Card Title" - * subtitle="Card Subtitle" - * leading={Leading} + * title="Draft itinerary" * content={ - * Card title - * Card content + * Review before saving. * } * actions={ - * - * + * + * * } * /> + * + * } + * content={ + * Supply any React node as the header. + * } + * /> + * * ); * - * export default MyComponent; + * export default CardExamples; * ``` */ diff --git a/src/components/Card/tokens.ts b/src/components/Card/tokens.ts index aaa00a2f11..70dc3322e5 100644 --- a/src/components/Card/tokens.ts +++ b/src/components/Card/tokens.ts @@ -41,14 +41,14 @@ const { opacity } = systemTokens.md.sys.state; /** * Material 3 Card variant-by-state tokens. * - * Rechecked 2026-09-04 against the Material 3 Card specification and the + * Rechecked 2026-09-07 against the Material 3 Card specification and the * current AndroidX generated Card tokens at commit - * 160825094a81825468a95b115bfb1b541e549856: + * 8c85cbb3ccccbaf5ca40c45527e2028ced01e472: * https://m3.material.io/components/cards/specs * - FilledCardTokens v0_210 * - ElevatedCardTokens v0_210 * - OutlinedCardTokens v0_192 - * https://github.com/androidx/androidx/tree/160825094a81825468a95b115bfb1b541e549856/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/tokens + * https://android.googlesource.com/platform/frameworks/support/+/8c85cbb3ccccbaf5ca40c45527e2028ced01e472/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/tokens * * State opacities were rechecked against Material Components Android generated * token set 34.0.0 at commit 4d3710682140722f48a5965b68109b240e1fe79e. diff --git a/src/components/__tests__/Card/Card.test.tsx b/src/components/__tests__/Card/Card.test.tsx index 8942b916dc..ac2df12c1d 100644 --- a/src/components/__tests__/Card/Card.test.tsx +++ b/src/components/__tests__/Card/Card.test.tsx @@ -1114,10 +1114,38 @@ describe('Card', () => { }); describe('Card types', () => { - it('rejects the removed API and mixed header forms', () => { + it('accepts documented examples and rejects removed or mixed forms', () => { const typeCases = ( <> + {}} + media={} + title="Weekend trip" + subtitle="Actionable filled Card" + content={View the itinerary.} + /> + Review before saving.} + actions={ + + + + + } + /> + } + content={ + Supply any React node as the header. + } + /> Date: Mon, 7 Sep 2026 09:10:56 +0200 Subject: [PATCH 11/11] fix(docs): correct card migration examples --- docs/6.x/docs/guides/migration.md | 15 ++++++++++++--- docs/src/components/ThemeColorsTable.tsx | 18 ++++++++---------- src/components/__tests__/Card/Card.test.tsx | 16 +++++++++++++--- 3 files changed, 33 insertions(+), 16 deletions(-) diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index ca8d4b7b2b..82f2114142 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -31,6 +31,7 @@ So you can use Reanimated's `useSharedValue` and `useAnimatedStyle` to animate t ```tsx import { useAnimatedStyle, useSharedValue } from 'react-native-reanimated'; +import { Card, Text } from 'react-native-paper'; const MyComponent = () => { const opacity = useSharedValue(1); @@ -38,7 +39,7 @@ const MyComponent = () => { opacity: opacity.value, })); - return ; + return Animated Card} style={animatedStyle} />; }; ``` @@ -168,7 +169,11 @@ Use `header` when the complete header is custom. It is mutually exclusive with ` } - content={{trip.summary}} + content={ + + {trip.summary} + + } /> ``` @@ -184,7 +189,11 @@ When buttons or other controls perform independent actions, keep the Card itself Review before saving.} + content={ + + Review before saving. + + } actions={ diff --git a/docs/src/components/ThemeColorsTable.tsx b/docs/src/components/ThemeColorsTable.tsx index ba6f5cfc2e..c26155b3e6 100644 --- a/docs/src/components/ThemeColorsTable.tsx +++ b/docs/src/components/ThemeColorsTable.tsx @@ -23,15 +23,17 @@ const getTableCell = (keys: string[], modes: DataObject): ReactNode[] => { const isDataObject = (value: DataObject[string]): value is DataObject => typeof value === 'object'; +type TableProps = { + firstColumnLabel: string; + themeColorsData: DataObject; + uniqueKeys: string[]; +}; + const FlatTable = ({ firstColumnLabel, themeColorsData, uniqueKeys, -}: { - firstColumnLabel: string; - themeColorsData: DataObject; - uniqueKeys: string[]; -}): ReactNode => { +}: TableProps): ReactNode => { const rows = Object.keys(themeColorsData).map((mode) => { const value = themeColorsData[mode]; @@ -66,11 +68,7 @@ const TabbedTable = ({ firstColumnLabel, themeColorsData, uniqueKeys, -}: { - firstColumnLabel: string; - themeColorsData: DataObject; - uniqueKeys: string[]; -}): ReactNode => { +}: TableProps): ReactNode => { const tabTableContent = Object.entries(themeColorsData).map( ([key, modes]) => { if (!isDataObject(modes)) { diff --git a/src/components/__tests__/Card/Card.test.tsx b/src/components/__tests__/Card/Card.test.tsx index ac2df12c1d..7b0002ae16 100644 --- a/src/components/__tests__/Card/Card.test.tsx +++ b/src/components/__tests__/Card/Card.test.tsx @@ -1124,12 +1124,20 @@ describe('Card types', () => { media={} title="Weekend trip" subtitle="Actionable filled Card" - content={View the itinerary.} + content={ + + View the itinerary. + + } /> Review before saving.} + content={ + + Review before saving. + + } actions={ @@ -1143,7 +1151,9 @@ describe('Card types', () => { variant="outlined" header={} content={ - Supply any React node as the header. + + Supply any React node as the header. + } />