diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index 35a8f74830..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 Button; + return Animated Card} style={animatedStyle} />; }; ``` @@ -47,7 +48,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 +116,97 @@ 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..c26155b3e6 100644 --- a/docs/src/components/ThemeColorsTable.tsx +++ b/docs/src/components/ThemeColorsTable.tsx @@ -23,13 +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, -}: { - themeColorsData: DataObject; - uniqueKeys: string[]; -}): ReactNode => { +}: TableProps): ReactNode => { const rows = Object.keys(themeColorsData).map((mode) => { const value = themeColorsData[mode]; @@ -50,7 +54,7 @@ const FlatTable = ({ - + {getTableHeader(uniqueKeys)} @@ -61,12 +65,10 @@ const FlatTable = ({ }; const TabbedTable = ({ + firstColumnLabel, themeColorsData, uniqueKeys, -}: { - themeColorsData: DataObject; - uniqueKeys: string[]; -}): ReactNode => { +}: TableProps): ReactNode => { const tabTableContent = Object.entries(themeColorsData).map( ([key, modes]) => { if (!isDataObject(modes)) { @@ -88,7 +90,7 @@ const TabbedTable = ({
mode{firstColumnLabel}
- + {getTableHeader(uniqueKeys)} @@ -120,12 +122,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/example/src/Examples/CardExample.tsx b/example/src/Examples/CardExample.tsx index 016bef3e21..c7665a699f 100644 --- a/example/src/Examples/CardExample.tsx +++ b/example/src/Examples/CardExample.tsx @@ -1,208 +1,301 @@ -import * as React from 'react'; -import { Alert, Platform, ScrollView, StyleSheet, View } from 'react-native'; +import { Alert, Platform, StyleSheet, View } from 'react-native'; import { Avatar, Button, Card, - Chip, + 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'; -type Mode = 'elevated' | 'outlined' | 'contained'; +const showMessage = (message: string) => { + if (Platform.OS === 'web') { + alert(message); + } else { + Alert.alert(message); + } +}; -const CardExample = () => { - const { colors } = useTheme(); - const [selectedMode, setSelectedMode] = React.useState('elevated'); - const [isSelected, setIsSelected] = React.useState(false); - const preferences = React.useContext(PreferencesContext); +const CustomHeader = () => ( + + + + Custom header + Neutral container, independent actions + + +); - const modes: Mode[] = ['elevated', 'outlined', 'contained']; +const ResponsiveCover = ({ + aspectRatio = 16 / 9, + ...props +}: Omit & { aspectRatio?: number }) => ( + + + +); + +const ThemePreview = ({ name }: { name: string }) => { + const theme = useTheme(); return ( - - - {modes.map((mode) => ( - setSelectedMode(mode)} - style={styles.chip} - > - {mode} - - ))} - - - - - + + {name} + showMessage(`${name} filled Card pressed`)} + onLongPress={() => showMessage(`${name} filled Card long pressed`)} + title="Filled interaction" + subtitle="Tab, hover, press, or long press" + 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. + + State layers and keyboard focus stay inside the shape. - - - - + } + /> + - - This is a card using title and subtitle with specified variants. - + Compare the raised surface. - - - - - - - - - - } - 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. - - - - - + + ); +}; + +const ThemedPreview = ({ name, theme }: { name: string; theme: Theme }) => ( + + + +); + +const CardExample = () => { + 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. + + } /> - ( - setIsSelected(!isSelected)} - /> - )} + + Controlled visual state. + + } /> - - { - Platform.OS === 'web' - ? 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. - - - - { - Platform.OS === 'web' - ? alert('The City is Long Pressed') - : Alert.alert('The City is Long Pressed'); - }} - mode={selectedMode} - > - - } + + } + content={ + + Asymmetric shape, no header. + + } /> - - - This is a long press only city. If you long press me, I will - alert. - - - - { - preferences?.toggleTheme(); - }} - mode={selectedMode} - > - } + } + right={(props) => ( + showMessage('More options')} + /> + )} + /> + } /> - - - This is pressable card. If you press me, I will switch the theme. - - - - + + + + + Light and dark verification + + Compare surfaces, outlines, elevation, clipping, and interaction + feedback without changing the application 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}. + + + + + + ); }; @@ -210,28 +303,61 @@ const CardExample = () => { CardExample.title = 'Card'; const styles = StyleSheet.create({ - container: { - flex: 1, + screen: { + gap: 28, + padding: 16, + }, + intro: { + gap: 8, }, - content: { - padding: 4, + section: { + gap: 12, }, - card: { - margin: 4, + gallery: { + alignItems: 'flex-start', + flexDirection: 'row', + flexWrap: 'wrap', + gap: 16, }, - chip: { - margin: 4, + galleryCard: { + minWidth: 260, + flexBasis: 300, + flexGrow: 1, }, - preference: { + compactCard: { + minWidth: 220, + flexBasis: 250, + flexGrow: 1, + }, + mediaFrame: { + overflow: 'hidden', + width: '100%', + }, + mediaFill: { + height: '100%', + }, + customHeader: { alignItems: 'center', flexDirection: 'row', - paddingVertical: 12, - paddingHorizontal: 8, + gap: 12, + paddingHorizontal: 16, + paddingTop: 16, + }, + customHeaderText: { + flex: 1, + }, + 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/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/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'); + }); +}); diff --git a/src/components/Card/Card.tsx b/src/components/Card/Card.tsx index 712110883b..05578e5ff1 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 { Platform, StyleSheet, View } from 'react-native'; import type { GestureResponderEvent, StyleProp, @@ -7,257 +7,878 @@ import type { ViewStyle, } from 'react-native'; -import useLatestCallback from 'use-latest-callback'; +import Animated, { + cubicBezier, + type AnimatedStyle, + useAnimatedStyle, + useDerivedValue, + useSharedValue, +} from 'react-native-reanimated'; 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 { 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'; +import { isKeyboardFocusEvent } from '../../utils/isKeyboardFocusEvent'; import Surface from '../Surface'; import type { SurfaceStyle } from '../Surface'; +import TouchableRipple from '../TouchableRipple/TouchableRipple'; +import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple'; -type OutlinedCardProps = { - mode: 'outlined'; - elevation?: never; -}; - -type ElevatedCardProps = { - mode?: 'elevated'; - elevation?: Elevation; +type ConvenienceHeaderProps = { + /** + * Title rendered in the Card's header region. + */ + title?: React.ReactNode; + /** + * Subtitle rendered below `title` in the Card's header region. + */ + subtitle?: React.ReactNode; + /** + * Render slot displayed before `title` and `subtitle`. + */ + leading?: CardTitleProps['left']; + /** + * Render slot displayed after `title` and `subtitle`. + */ + trailing?: CardTitleProps['right']; + /** + * Fully custom header region. This cannot be combined with `title`, + * `subtitle`, `leading`, or `trailing`. + */ + header?: never; }; -type ContainedCardProps = { - mode?: 'contained'; - elevation?: never; +type CustomHeaderProps = { + /** + * Fully custom header region. This cannot be combined with `title`, + * `subtitle`, `leading`, or `trailing`. + */ + 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 CardShapeProps = { /** - * 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. + * Radius of every Card corner. */ - mode?: Mode; + borderRadius?: ViewStyle['borderRadius']; /** - * Content of the `Card`. + * Radius of the Card's bottom-end corner. */ - children: React.ReactNode; + borderBottomEndRadius?: ViewStyle['borderBottomEndRadius']; /** - * Function to execute on long press. + * Radius of the Card's bottom-left corner. */ - onLongPress?: () => void; + borderBottomLeftRadius?: ViewStyle['borderBottomLeftRadius']; /** - * Function to execute on press. + * Radius of the Card's bottom-right corner. */ - onPress?: (e: GestureResponderEvent) => void; + borderBottomRightRadius?: ViewStyle['borderBottomRightRadius']; /** - * Function to execute as soon as the touchable element is pressed and invoked even before onPress. + * Radius of the Card's bottom-start corner. */ - onPressIn?: (e: GestureResponderEvent) => void; + borderBottomStartRadius?: ViewStyle['borderBottomStartRadius']; /** - * Function to execute as soon as the touch is released even before onPress. + * Radius of the Card's end-end corner. */ - onPressOut?: (e: GestureResponderEvent) => void; + borderEndEndRadius?: ViewStyle['borderEndEndRadius']; /** - * The number of milliseconds a user must touch the element before executing `onLongPress`. + * Radius of the Card's end-start corner. */ - delayLongPress?: number; + borderEndStartRadius?: ViewStyle['borderEndStartRadius']; /** - * If true, disable all interactions for this component. + * Radius of the Card's start-end corner. */ - disabled?: boolean; + borderStartEndRadius?: ViewStyle['borderStartEndRadius']; /** - * Changes Card shadow and background on iOS and Android. + * Radius of the Card's start-start corner. */ - elevation?: Elevation; + 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 = { + /** + * Material Card variant. `filled` is the default, `elevated` adds hierarchy + * with a shadow, and `outlined` adds a visible boundary. + */ + variant?: 'filled'; + /** + * Resting elevation. Available only when `variant="elevated"`. + */ + 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; + +export type Props = Omit & + CardShapeProps & { + /** + * 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 content rendered after the header. Use `Card.Content` when the + * standard Card padding is desired. + */ + content?: React.ReactNode; + /** + * 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; + /** + * 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; + /** + * 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`. + */ + delayLongPress?: number; + /** + * If true, disable all interactions for this component. + */ + disabled?: boolean; + /** + * Whether to show the Card's controlled Material dragged presentation. + * This controls visuals only; gesture recognition, drag lifecycle, list + * reordering, and drop behavior remain the consumer's responsibility. + */ + dragged?: boolean; + /** + * 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 + */ + theme?: ThemeProp; + /** + * 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; + /** + * 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; + /** + * Reference to the actionable Card interaction node. + */ + touchableRef?: React.Ref; + /** + * Reference to the outer Card shell. + */ + ref?: React.Ref; + } & (ConvenienceHeaderProps | CustomHeaderProps) & + CardVariantProps; + /** - * A card is a sheet of material that serves as an entry point to more detailed information. + * 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. + * + * 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 LeftContent = props => + * const CardExamples = () => ( + * + * console.log('Open trip details')} + * media={} + * title="Weekend trip" + * subtitle="Actionable filled Card" + * leading={(props) => } + * content={ + * View the itinerary. + * } + * /> * - * const MyComponent = () => ( - * - * - * - * Card title - * Card content - * - * - * - * - * - * - * + * + * Review before saving. + * } + * actions={ + * + * + * } + * /> + * + * } + * content={ + * Supply any React node as the header. + * } + * /> + * * ); * - * export default MyComponent; + * export default CardExamples; * ``` */ const Card = ({ - elevation: cardElevation = 1, + variant: cardVariant = 'filled', + elevation: customElevation, delayLongPress, onPress, onLongPress, onPressOut, onPressIn, - mode: cardMode = 'elevated', - children, + media, + header, + title, + subtitle, + leading, + trailing, + content: cardContent, + actions, style, contentStyle, theme: themeOverrides, - testID, + testID = 'card', accessible, disabled, + dragged = false, + 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, + borderBottomRightRadius, + borderBottomStartRadius, + borderEndEndRadius, + borderEndStartRadius, + borderStartEndRadius, + borderStartStartRadius, + borderTopEndRadius, + borderTopLeftRadius, + borderTopRightRadius, + borderTopStartRadius, + borderCurve = 'continuous', ref, ...rest -}: (OutlinedCardProps | ElevatedCardProps | ContainedCardProps) & Props) => { +}: Props) => { const theme = useInternalTheme(themeOverrides); + const reduceMotion = useReduceMotion(); - const isMode = React.useCallback( - (modeToCompare: Mode) => { - return cardMode === modeToCompare; - }, - [cardMode] + const isDisabled = Boolean( + disabled || ariaDisabled || accessibilityState?.disabled ); - const hasPassedTouchHandler = hasTouchHandler({ - onPress, - onLongPress, - onPressIn, - onPressOut, + const visuals = resolveCardVisuals({ + theme, + variant: cardVariant, + elevation: customElevation, + disabled: isDisabled, + dragged, }); - 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 enabledVisuals = resolveCardVisuals({ + theme, + variant: cardVariant, + elevation: customElevation, }); - - const handlePressOut = useLatestCallback((e: GestureResponderEvent) => { - onPressOut?.(e); - - if (isMode('elevated')) { - setPressed(false); + 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 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; + } + if (focused.value) { + return focusedVisuals; } + if (hovered.value) { + return hoveredVisuals; + } + return enabledVisuals; + }, [ + disabledState, + disabledVisuals, + draggedState, + draggedVisuals, + enabledVisuals, + focusedVisuals, + hoveredVisuals, + pressedVisuals, + ]); + const interactiveElevation = useDerivedValue(() => { + return currentInteractiveVisuals.value.elevation; }); + const transitionDuration = reduceMotion + ? 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'], + }), + [currentInteractiveVisuals, transitionDuration] + ); + const outlineAnimatedStyle = useAnimatedStyle(() => { + const outlineColor = currentInteractiveVisuals.value.outlineColor; - 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 + return { + borderColor: outlineColor, + opacity: currentInteractiveVisuals.value.outlineOpacity, + transitionDuration, + transitionProperty: + typeof outlineColor === 'string' + ? ['borderColor', 'opacity'] + : ['opacity'], + }; + }, [currentInteractiveVisuals, transitionDuration]); + const focusIndicatorAnimatedStyle = useAnimatedStyle( + () => ({ + opacity: disabledState.value ? 0 : focused.value ? 1 : 0, + transitionDuration, + transitionProperty: ['opacity'], + }), + [disabledState, transitionDuration] ); - const { backgroundColor, borderColor: themedBorderColor } = getCardColors({ - theme, - mode: cardMode, - }); + React.useEffect(() => { + disabledState.value = isDisabled; + draggedState.value = dragged; - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const flattenedStyles = (StyleSheet.flatten(style) || {}) as ViewStyle; + if (isDisabled) { + hovered.value = false; + focused.value = false; + pressed.value = false; + } + }, [ + disabledState, + dragged, + draggedState, + focused, + hovered, + isDisabled, + pressed, + ]); - const { borderColor = themedBorderColor } = flattenedStyles; + const hasPassedTouchHandler = hasTouchHandler({ + onPress, + onLongPress, + 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 borderRadius = theme.shapes.corner.medium; + 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; const content = ( - - {React.Children.map(children, (child, index) => - React.isValidElement(child) - ? React.cloneElement(child as React.ReactElement, { - index, - total, - siblings, - }) - : child - )} + + {media} + {header ?? + (hasConvenienceHeader ? ( + + ) : null)} + {cardContent} + {actions} ); + 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, + }; + 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 ( - {isMode('outlined') && ( + - )} - + + {hasPassedTouchHandler ? ( + + {content} + + ) : ( + content + )} + {visuals.outlineWidth > 0 ? ( + + ) : null} + {hasPassedTouchHandler ? ( - - {content} - - ) : ( - content - )} + + ) : null} ); }; @@ -274,16 +895,23 @@ Card.Cover = CardCover; Card.Title = CardTitle; const styles = StyleSheet.create({ - innerContainer: { + visual: { flexShrink: 1, + overflow: 'hidden', }, - outline: { - borderWidth: 1, + content: { + flexShrink: 1, + position: 'relative', + }, + focusIndicator: { position: 'absolute', - width: '100%', - height: '100%', - zIndex: 2, + pointerEvents: 'none', }, }); +// 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/Card/CardActions.tsx b/src/components/Card/CardActions.tsx index d541c691bc..1d26a6f70d 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 & { @@ -24,48 +22,22 @@ export type Props = ViewProps & { * import { Card, Button } from 'react-native-paper'; * * const MyComponent = () => ( - * - * + * * * - * - * + * } + * /> * ); * * 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..9281390f87 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; }; @@ -31,68 +19,26 @@ export type Props = ViewProps & { * import { Card, Text } from 'react-native-paper'; * * const MyComponent = () => ( - * - * + * * Card title * Card content - * - * + * } + * /> * ); * * 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/CardCover.tsx b/src/components/Card/CardCover.tsx index 4542aa7c99..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,15 +20,29 @@ 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'; * import { Card } from 'react-native-paper'; * * const MyComponent = () => ( - * - * - * + * + * } + * /> * ); * * export default MyComponent; @@ -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/tokens.ts b/src/components/Card/tokens.ts new file mode 100644 index 0000000000..70dc3322e5 --- /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-07 against the Material 3 Card specification and the + * current AndroidX generated Card tokens at commit + * 8c85cbb3ccccbaf5ca40c45527e2028ced01e472: + * https://m3.material.io/components/cards/specs + * - FilledCardTokens v0_210 + * - ElevatedCardTokens v0_210 + * - OutlinedCardTokens v0_192 + * 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. + * 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/Card/utils.tsx b/src/components/Card/utils.tsx deleted file mode 100644 index fc0faa945a..0000000000 --- a/src/components/Card/utils.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import type { StyleProp, ViewStyle } from 'react-native'; - -import type { InternalTheme } from '../../theme/types'; - -type CardMode = 'elevated' | 'outlined' | 'contained'; - -type BorderRadiusStyles = Pick< - ViewStyle, - Extract ->; - -export type CardActionChildProps = { - compact?: boolean; - mode?: string; - style?: StyleProp; -}; - -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, - }; -}; - -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/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 7d84f0e44f..7b0002ae16 100644 --- a/src/components/__tests__/Card/Card.test.tsx +++ b/src/components/__tests__/Card/Card.test.tsx @@ -1,203 +1,1452 @@ -import { Platform, StyleSheet, Text } from 'react-native'; +import * as React from 'react'; +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 * as Reanimated from 'react-native-reanimated'; +import { getAnimatedStyle } from 'react-native-reanimated'; -import { render, screen } from '../../../test-utils'; -import { LightTheme } from '../../../theme/schemes'; -import { Palette } from '../../../theme/tokens'; +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 { getCardColors, getCardCoverStyle } from '../../Card/utils'; +import type { Props as CardProps } from '../../Card/Card'; const styles = StyleSheet.create({ - customCoverRadius: { - borderTopLeftRadius: 4, - borderTopRightRadius: 8, - borderBottomLeftRadius: 0, - borderBottomRightRadius: 2, - }, contentStyle: { flexDirection: 'column-reverse', }, + customAction: { + marginRight: 12, + }, }); +const expectAnimatedStyle = ( + testID: string, + expectedStyle: Record +) => { + expect(getAnimatedStyle(screen.getByTestId(testID))).toEqual( + expect.objectContaining(expectedStyle) + ); +}; + +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(); }); describe('Card', () => { - it('renders an outlined card', async () => { - const tree = (await render({null})).toJSON(); + 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 = isDark ? DarkTheme : LightTheme; + 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 = isDark ? DarkTheme : LightTheme; + const { unmount } = await render( + + ); + + expect(screen.getByTestId('card-outline')).toHaveStyle({ + borderColor: theme.colors.outlineVariant, + borderWidth: 1, + opacity: 1, + }); - expect(tree).toMatchSnapshot(); + await unmount(); + } }); - it('renders an outlined card with a custom outline color', async () => { - const { toJSON } = await render( + 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( - {null} - + theme={{ + colors: { + surfaceContainerHighest: '#111111', + onSurface: '#222222', + }, + }} + /> ); - expect(toJSON()).toMatchSnapshot(); + expect(screen.getByTestId('card-visual')).toHaveStyle({ + backgroundColor: '#111111', + }); + expect(screen.getByTestId('card-state-layer')).toHaveStyle({ + backgroundColor: '#222222', + opacity: 0, + }); }); - it('renders an outlined card with custom border color', async () => { - const { toJSON } = await render( + it('uses custom theme roles for elevated and outlined variants', async () => { + const { unmount } = await render( - {null} - + variant="elevated" + theme={{ colors: { surfaceContainerLow: '#123456' } }} + /> ); - expect(toJSON()).toMatchSnapshot(); + 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('renders with a custom theme background color', async () => { - jest.replaceProperty(Platform, 'OS', 'web'); + 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( - {null} - + variant="outlined" + borderTopLeftRadius={4} + borderTopRightRadius={8} + borderBottomRightRadius={16} + borderBottomLeftRadius={20} + /> + ); + + const expectedShape = { + borderRadius: LightTheme.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(() => ( + + Custom content + + )); + + await render( + } + header={} + content={[ + , + null, + , + ]} + actions={ + <> + {null} + + + } + /> + ); + + 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 omitted slots as a neutral filled grouping container', async () => { + await render(); + + expect(screen.getByTestId('card-visual')).toHaveStyle({ + backgroundColor: LightTheme.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('keeps the ripple, visual layers, and focus indicator on the Card shape', 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(interaction.parent).toBe(visual); + expect(screen.getByTestId('product-card-focus-indicator')).toHaveStyle({ + top: -5, + right: -5, + bottom: -5, + left: -5, + borderColor: LightTheme.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.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 = LightTheme; + 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 = LightTheme; + 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('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( + + {}} /> + ); - expect(screen.getByLabelText('card')).toHaveStyle({ - backgroundColor: '#0000FF', + 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(() => { + 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( + {}} 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( + Leading {size}} + trailing={({ size }) => Trailing {size}} + /> + ); + + 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('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.getByTestId('card')).toBeEnabled(); + expect(screen.getByRole('summary')).toBeDisabled(); }); - it('does render a disabled accessibility state', async () => { - await render( - {}} disabled> - {null} - + + 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(target).toBeDisabled(); + await userEvent.press(target); + expect(onPress).not.toHaveBeenCalled(); + }); +}); + +describe('Card types', () => { + 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. + + } + /> + } + trailing={({ size }) => } + /> + } /> + Content} + actions={[]} + /> + + + + + + {/* @ts-expect-error: Arbitrary children composition was removed. */} + + + + + {/* @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" /> + + {/* @ts-expect-error: Custom and convenience headers are mutually exclusive. */} + } leading={() => } /> + + {/* @ts-expect-error: Cover placement metadata is not public. */} + + ); - expect(screen.getByTestId('card')).toBeDisabled(); + expect(typeCases).toBeDefined(); }); }); 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 }); }); -}); -describe('CardActions', () => { - it('renders button with passed mode', async () => { + it('exposes supplied semantics for an informative image', async () => { await render( - - - - - + ); - 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.getByRole('image')).toBe( + screen.getByLabelText('Snow-covered mountains') + ); }); -}); -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('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('should return correct theme color, for theme version 3, outlined mode', () => { - expect( - getCardColors({ - theme: LightTheme, - mode: 'outlined', - }) - ).toMatchObject({ backgroundColor: LightTheme.colors.surface }); + 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('should return undefined, for theme version 3, elevated mode', () => { - expect( - getCardColors({ - theme: LightTheme, - mode: 'elevated', - }) - ).toMatchObject({ backgroundColor: undefined }); + 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: LightTheme.shapes.corner.medium, + }); }); }); -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('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(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('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 }); +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, + }); }); }); 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); + }); +}); 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`] = ` - - - - - -`; 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(