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(