Skip to content

Commit b8ba62a

Browse files
feat: MediaCard
1 parent 634007d commit b8ba62a

12 files changed

Lines changed: 848 additions & 0 deletions

File tree

apps/mobile-app/scripts/utils/routes.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,10 @@ export const routes = [
341341
getComponent: () =>
342342
require('@coinbase/cds-mobile/animation/__stories__/LottieStatusAnimation.stories').default,
343343
},
344+
{
345+
key: 'MediaCard',
346+
getComponent: () => require('@coinbase/cds-mobile/cards/__stories__/MediaCard.stories').default,
347+
},
344348
{
345349
key: 'MediaChip',
346350
getComponent: () => require('@coinbase/cds-mobile/chips/__stories__/MediaChip.stories').default,

apps/mobile-app/src/routes.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,10 @@ export const routes = [
346346
getComponent: () =>
347347
require('@coinbase/cds-mobile/animation/__stories__/LottieStatusAnimation.stories').default,
348348
},
349+
{
350+
key: 'MediaCard',
351+
getComponent: () => require('@coinbase/cds-mobile/cards/__stories__/MediaCard.stories').default,
352+
},
349353
{
350354
key: 'MediaChip',
351355
getComponent: () => require('@coinbase/cds-mobile/chips/__stories__/MediaChip.stories').default,
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import React, { memo, useMemo } from 'react';
2+
import type { StyleProp, ViewStyle } from 'react-native';
3+
4+
import { HStack } from '../../layout/HStack';
5+
import { VStack } from '../../layout/VStack';
6+
import { Text } from '../../typography/Text';
7+
8+
export type MediaCardLayoutBaseProps = {
9+
/** Text or React node to display as the card title. When a string is provided, it will be rendered in a CardTitle component. */
10+
title?: React.ReactNode;
11+
/** Text or React node to display as the card subtitle. When a string is provided, it will be rendered in a CardSubtitle component. */
12+
subtitle?: React.ReactNode;
13+
/** Text or React node to display as the card description. When a string is provided, it will be rendered in a CardDescription component. */
14+
description?: React.ReactNode;
15+
/** React node to display as a thumbnail in the content area. */
16+
thumbnail: React.ReactNode;
17+
/** React node to display as the main media content. When provided, it will be rendered in an HStack container taking up 50% of the card width. */
18+
media?: React.ReactNode;
19+
/** The position of the media within the card. */
20+
mediaPlacement?: 'start' | 'end';
21+
};
22+
23+
export type MediaCardLayoutProps = MediaCardLayoutBaseProps & {
24+
styles?: {
25+
layoutContainer?: StyleProp<ViewStyle>;
26+
contentContainer?: StyleProp<ViewStyle>;
27+
textContainer?: StyleProp<ViewStyle>;
28+
headerContainer?: StyleProp<ViewStyle>;
29+
mediaContainer?: StyleProp<ViewStyle>;
30+
};
31+
};
32+
33+
const MediaCardLayout = memo(
34+
({
35+
title,
36+
subtitle,
37+
description,
38+
thumbnail,
39+
media,
40+
mediaPlacement = 'end',
41+
styles = {},
42+
}: MediaCardLayoutProps) => {
43+
const titleNode = useMemo(() => {
44+
if (typeof title === 'string') {
45+
return (
46+
<Text font="headline" numberOfLines={1}>
47+
{title}
48+
</Text>
49+
);
50+
}
51+
return title;
52+
}, [title]);
53+
54+
const subtitleNode = useMemo(
55+
() =>
56+
typeof subtitle === 'string' ? (
57+
<Text color="fgMuted" font="legal" numberOfLines={1}>
58+
{subtitle}
59+
</Text>
60+
) : (
61+
subtitle
62+
),
63+
[subtitle],
64+
);
65+
66+
const headerNode = useMemo(
67+
() => (
68+
<VStack style={styles?.headerContainer}>
69+
{subtitleNode}
70+
{titleNode}
71+
</VStack>
72+
),
73+
[subtitleNode, titleNode, styles?.headerContainer],
74+
);
75+
76+
const descriptionNode = useMemo(
77+
() =>
78+
typeof description === 'string' ? (
79+
<Text color="fgMuted" font="label2" numberOfLines={2}>
80+
{description}
81+
</Text>
82+
) : (
83+
description
84+
),
85+
[description],
86+
);
87+
88+
const contentNode = useMemo(
89+
() => (
90+
<VStack
91+
flexBasis="50%"
92+
gap={4}
93+
justifyContent="space-between"
94+
padding={2}
95+
style={styles?.contentContainer}
96+
>
97+
{thumbnail}
98+
<VStack style={styles?.textContainer}>
99+
{headerNode}
100+
{descriptionNode}
101+
</VStack>
102+
</VStack>
103+
),
104+
[styles?.contentContainer, styles?.textContainer, thumbnail, headerNode, descriptionNode],
105+
);
106+
107+
const mediaNode = useMemo(() => {
108+
if (media) {
109+
return (
110+
<HStack flexBasis="50%" style={styles?.mediaContainer}>
111+
{media}
112+
</HStack>
113+
);
114+
}
115+
}, [media, styles?.mediaContainer]);
116+
117+
return (
118+
<HStack flexGrow={1} style={styles?.layoutContainer}>
119+
{mediaPlacement === 'start' ? mediaNode : contentNode}
120+
{mediaPlacement === 'end' ? mediaNode : contentNode}
121+
</HStack>
122+
);
123+
},
124+
);
125+
126+
export { MediaCardLayout };
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { forwardRef, memo, useCallback, useMemo } from 'react';
2+
import type { PressableStateCallbackType, StyleProp, View, ViewStyle } from 'react-native';
3+
import type { ThemeVars } from '@coinbase/cds-common';
4+
5+
import { CardRoot, type CardRootProps } from '../CardRoot';
6+
7+
import { MediaCardLayout, type MediaCardLayoutProps } from './MediaCardLayout';
8+
9+
export type MediaCardBaseProps = MediaCardLayoutProps;
10+
11+
export type MediaCardProps = Omit<CardRootProps, 'children'> &
12+
MediaCardBaseProps & {
13+
styles?: {
14+
root?: StyleProp<ViewStyle>;
15+
};
16+
};
17+
18+
const mediaCardContainerProps = {
19+
borderRadius: 500 as ThemeVars.BorderRadius,
20+
background: 'bgAlternate' as ThemeVars.Color,
21+
overflow: 'hidden' as const,
22+
};
23+
24+
export const MediaCard = memo(
25+
forwardRef<View, MediaCardProps>(
26+
(
27+
{
28+
title,
29+
subtitle,
30+
description,
31+
thumbnail,
32+
media,
33+
mediaPlacement = 'end',
34+
style,
35+
styles: { root: rootStyle, ...layoutStyles } = {},
36+
...props
37+
},
38+
ref,
39+
) => {
40+
return (
41+
<CardRoot ref={ref} {...mediaCardContainerProps} style={[style, rootStyle]} {...props}>
42+
<MediaCardLayout
43+
description={description}
44+
media={media}
45+
mediaPlacement={mediaPlacement}
46+
styles={layoutStyles}
47+
subtitle={subtitle}
48+
thumbnail={thumbnail}
49+
title={title}
50+
/>
51+
</CardRoot>
52+
);
53+
},
54+
),
55+
);
56+
57+
MediaCard.displayName = 'MediaCard';
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import { useRef } from 'react';
2+
import { type View } from 'react-native';
3+
import { assets, ethBackground } from '@coinbase/cds-common/internal/data/assets';
4+
import { NoopFn } from '@coinbase/cds-common/utils/mockUtils';
5+
6+
import { Carousel } from '../../carousel/Carousel';
7+
import { CarouselItem } from '../../carousel/CarouselItem';
8+
import { Example, ExampleScreen } from '../../examples/ExampleScreen';
9+
import { RemoteImage } from '../../media/RemoteImage';
10+
import { TextHeadline, TextLabel2, TextTitle3 } from '../../typography';
11+
import { Text } from '../../typography/Text';
12+
import type { MediaCardProps } from '../MediaCard';
13+
import { MediaCard } from '../MediaCard';
14+
15+
const exampleProps: Omit<MediaCardProps, 'thumbnail'> = {
16+
title: 'Title',
17+
subtitle: 'Subtitle',
18+
description: 'Description',
19+
width: 320,
20+
};
21+
22+
const exampleThumbnail = (
23+
<RemoteImage
24+
accessibilityLabel="Ethereum"
25+
shape="circle"
26+
size="l"
27+
source={ethBackground}
28+
testID="thumbnail"
29+
/>
30+
);
31+
32+
const exampleMedia = (
33+
<RemoteImage
34+
accessibilityLabel="Media"
35+
height="100%"
36+
resizeMode="cover"
37+
shape="rectangle"
38+
source={ethBackground}
39+
width="100%"
40+
/>
41+
);
42+
43+
const MediaCardScreen = () => {
44+
const ref = useRef<View>(null);
45+
return (
46+
<ExampleScreen>
47+
{/* Basic Examples */}
48+
<Example title="Default">
49+
<MediaCard ref={ref} {...exampleProps} thumbnail={exampleThumbnail} />
50+
</Example>
51+
52+
<Example title="With Media">
53+
<MediaCard {...exampleProps} media={exampleMedia} thumbnail={exampleThumbnail} />
54+
</Example>
55+
56+
{/* Media Placement */}
57+
<Example title="Media Placement Start">
58+
<MediaCard
59+
{...exampleProps}
60+
media={exampleMedia}
61+
mediaPlacement="start"
62+
thumbnail={exampleThumbnail}
63+
/>
64+
</Example>
65+
66+
<Example title="Media Placement End">
67+
<MediaCard
68+
{...exampleProps}
69+
media={exampleMedia}
70+
mediaPlacement="end"
71+
thumbnail={exampleThumbnail}
72+
/>
73+
</Example>
74+
75+
{/* Text Content */}
76+
<Example title="Long Text">
77+
<MediaCard
78+
description="This is a very long description text that demonstrates how the card handles longer content"
79+
media={exampleMedia}
80+
onPress={NoopFn}
81+
subtitle="This is a very long subtitle text that will get truncated"
82+
thumbnail={exampleThumbnail}
83+
title="This is a very long title text that will get truncated"
84+
width={320}
85+
/>
86+
</Example>
87+
88+
<Example title="Custom Content">
89+
<MediaCard
90+
description={
91+
<TextLabel2>
92+
Custom description with <Text font="headline">bold text</Text> and{' '}
93+
<Text font="label1">italic text</Text>
94+
</TextLabel2>
95+
}
96+
media={exampleMedia}
97+
subtitle={<TextHeadline color="fgPositive">Custom Subtitle</TextHeadline>}
98+
thumbnail={exampleThumbnail}
99+
title={<TextTitle3>Custom Title</TextTitle3>}
100+
width={320}
101+
/>
102+
</Example>
103+
104+
{/* Styling */}
105+
<Example title="With Layout Overrides">
106+
<MediaCard
107+
{...exampleProps}
108+
media={exampleMedia}
109+
styles={{
110+
layoutContainer: { gap: 3 },
111+
contentContainer: { padding: 3, gap: 2 },
112+
textContainer: { gap: 1 },
113+
headerContainer: { gap: 1 },
114+
mediaContainer: { borderRadius: 300 },
115+
}}
116+
thumbnail={exampleThumbnail}
117+
/>
118+
</Example>
119+
120+
<Example title="With Root Style Override">
121+
<MediaCard
122+
{...exampleProps}
123+
media={exampleMedia}
124+
styles={{
125+
root: { borderWidth: 2, borderColor: 'blue' },
126+
}}
127+
thumbnail={exampleThumbnail}
128+
/>
129+
</Example>
130+
131+
{/* Interactive */}
132+
<Example title="Interactive with onPress">
133+
<MediaCard
134+
description="Clickable card with onPress handler"
135+
media={exampleMedia}
136+
onPress={() => console.log('Card clicked!')}
137+
subtitle="Button"
138+
thumbnail={exampleThumbnail}
139+
title="Interactive Card"
140+
width={320}
141+
/>
142+
</Example>
143+
144+
{/* Multiple Cards */}
145+
<Example title="Multiple Cards">
146+
<Carousel styles={{ carousel: { gap: 16 } }}>
147+
<CarouselItem id="card1">
148+
<MediaCard {...exampleProps} media={exampleMedia} thumbnail={exampleThumbnail} />
149+
</CarouselItem>
150+
<CarouselItem id="card2">
151+
<MediaCard
152+
description="Another card with different content"
153+
media={exampleMedia}
154+
onPress={NoopFn}
155+
subtitle="BTC"
156+
thumbnail={
157+
<RemoteImage
158+
accessibilityLabel="Bitcoin"
159+
shape="square"
160+
size="l"
161+
source={assets.btc.imageUrl}
162+
/>
163+
}
164+
title="Bitcoin"
165+
width={320}
166+
/>
167+
</CarouselItem>
168+
<CarouselItem id="card3">
169+
<MediaCard
170+
description="Card with onPress handler"
171+
onPress={NoopFn}
172+
subtitle="ETH"
173+
thumbnail={exampleThumbnail}
174+
title="Ethereum"
175+
width={320}
176+
/>
177+
</CarouselItem>
178+
</Carousel>
179+
</Example>
180+
</ExampleScreen>
181+
);
182+
};
183+
184+
export default MediaCardScreen;

packages/mobile/src/cards/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,5 @@ export * from './FeatureEntryCard';
1111
export * from './FeedCard';
1212
// Phoenix cards
1313
export * from './ContentCard';
14+
// Media card
15+
export * from './MediaCard';

0 commit comments

Comments
 (0)