Skip to content

Commit 2974a71

Browse files
feat: MediaCard
1 parent ec9c499 commit 2974a71

File tree

12 files changed

+915
-0
lines changed

12 files changed

+915
-0
lines changed

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
@@ -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,
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import React, { memo, useMemo } from 'react';
2+
3+
import { type BoxBaseProps } from '../../layout';
4+
import { HStack } from '../../layout/HStack';
5+
import { VStack } from '../../layout/VStack';
6+
import { Tag, type TagBaseProps } from '../../tag/Tag';
7+
import { CardDescription, type CardDescriptionProps } from '../CardDescription';
8+
import { CardSubtitle, type CardSubtitleProps } from '../CardSubtitle';
9+
import { CardTitle, type CardTitleProps } from '../CardTitle';
10+
11+
export type MediaCardSlotProps = {
12+
/** Props to pass to the root layout container (HStack). */
13+
layoutContainer?: Omit<BoxBaseProps, 'children'>;
14+
/** Props to pass to the content container (VStack) that wraps thumbnail and text content. */
15+
contentContainer?: Omit<BoxBaseProps, 'children'>;
16+
/** Props to pass to the text container (VStack) that wraps header and description. */
17+
textContainer?: Omit<BoxBaseProps, 'children'>;
18+
/** Props to pass to the header container (VStack) that wraps subtitle and title. */
19+
headerContainer?: Omit<BoxBaseProps, 'children'>;
20+
/** Props to pass to the CardTitle component. */
21+
title?: Omit<CardTitleProps, 'children'>;
22+
/** Props to pass to the CardSubtitle component. */
23+
subtitle?: Omit<CardSubtitleProps, 'children'>;
24+
/** Props to pass to the CardDescription component. */
25+
description?: Omit<CardDescriptionProps, 'children'>;
26+
/** Props to pass to the Tag component. */
27+
tag?: Omit<TagBaseProps, 'children'>;
28+
/** Props to pass to the media container (HStack) that wraps the media content. */
29+
mediaContainer?: Omit<BoxBaseProps, 'children'>;
30+
};
31+
32+
export type MediaCardLayoutProps = {
33+
/** Text or React node to display as the card title. When a string is provided, it will be rendered in a CardTitle component. */
34+
title?: React.ReactNode;
35+
/** Text or React node to display as the card subtitle. When a string is provided, it will be rendered in a CardSubtitle component. */
36+
subtitle?: React.ReactNode;
37+
/** Text or React node to display as the card description. When a string is provided, it will be rendered in a CardDescription component. */
38+
description?: React.ReactNode;
39+
/** React node to display as a thumbnail in the content area. */
40+
thumbnail?: React.ReactNode;
41+
/** 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. */
42+
media?: React.ReactNode;
43+
/** Text or React node to display as a tag. When a string is provided, it will be rendered in a Tag component positioned absolutely in the top-right corner. */
44+
tag?: React.ReactNode;
45+
/** Props to customize sub-components and containers within the MediaCard layout. */
46+
slotProps?: MediaCardSlotProps;
47+
};
48+
49+
const MediaCardLayout = memo(
50+
({
51+
title,
52+
subtitle,
53+
description,
54+
thumbnail,
55+
media,
56+
tag,
57+
slotProps = {},
58+
}: MediaCardLayoutProps) => {
59+
const titleNode = useMemo(() => {
60+
if (typeof title === 'string') {
61+
return <CardTitle {...slotProps.title}>{title}</CardTitle>;
62+
}
63+
return title;
64+
}, [slotProps.title, title]);
65+
66+
const subtitleNode = useMemo(
67+
() =>
68+
typeof subtitle === 'string' ? (
69+
<CardSubtitle {...slotProps.subtitle}>{subtitle}</CardSubtitle>
70+
) : (
71+
subtitle
72+
),
73+
[slotProps?.subtitle, subtitle],
74+
);
75+
76+
const headerNode = useMemo(
77+
() => (
78+
<VStack {...slotProps.headerContainer}>
79+
{subtitleNode}
80+
{titleNode}
81+
</VStack>
82+
),
83+
[subtitleNode, titleNode, slotProps.headerContainer],
84+
);
85+
86+
const descriptionNode = useMemo(
87+
() =>
88+
typeof description === 'string' ? (
89+
<CardDescription {...slotProps.description}>{description}</CardDescription>
90+
) : (
91+
description
92+
),
93+
[slotProps.description, description],
94+
);
95+
96+
const tagNode = useMemo(
97+
() =>
98+
typeof tag === 'string' ? (
99+
<Tag position="absolute" right={20} top={16} {...slotProps.tag}>
100+
{tag}
101+
</Tag>
102+
) : (
103+
tag
104+
),
105+
[slotProps.tag, tag],
106+
);
107+
108+
return (
109+
<HStack flexGrow={1} position="relative" {...slotProps?.layoutContainer}>
110+
<VStack
111+
flexBasis="50%"
112+
gap={4}
113+
justifyContent="space-between"
114+
padding={2}
115+
{...slotProps?.contentContainer}
116+
>
117+
{thumbnail}
118+
<VStack {...slotProps?.textContainer}>
119+
{headerNode}
120+
{descriptionNode}
121+
</VStack>
122+
</VStack>
123+
{media && (
124+
<HStack flexBasis="50%" {...slotProps?.mediaContainer}>
125+
{media}
126+
</HStack>
127+
)}
128+
{tagNode}
129+
</HStack>
130+
);
131+
},
132+
);
133+
134+
export { MediaCardLayout };
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { forwardRef, memo } from 'react';
2+
import type { View } 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'> & MediaCardBaseProps;
12+
13+
const mediaCardContainerProps = {
14+
borderRadius: 500 as ThemeVars.BorderRadius,
15+
background: 'bgAlternate' as ThemeVars.Color,
16+
overflow: 'hidden' as const,
17+
};
18+
19+
export const MediaCard = memo(
20+
forwardRef<View, MediaCardProps>(
21+
(
22+
{ title, subtitle, description, thumbnail, media, tag, actionable, slotProps = {}, ...props },
23+
ref,
24+
) => (
25+
<CardRoot ref={ref} actionable={actionable} {...mediaCardContainerProps} {...props}>
26+
<MediaCardLayout
27+
description={description}
28+
media={media}
29+
slotProps={slotProps}
30+
subtitle={subtitle}
31+
tag={tag}
32+
thumbnail={thumbnail}
33+
title={title}
34+
/>
35+
</CardRoot>
36+
),
37+
),
38+
);
39+
40+
MediaCard.displayName = 'MediaCard';
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
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 { Example, ExampleScreen } from '../../examples/ExampleScreen';
7+
import { Carousel } from '../../media';
8+
import { RemoteImage } from '../../media/RemoteImage';
9+
import { TextHeadline, TextLabel2, TextTitle3 } from '../../typography';
10+
import { Text } from '../../typography/Text';
11+
import { CardThumbnail } from '../CardThumbnail';
12+
import type { MediaCardProps } from '../MediaCard';
13+
import { MediaCard } from '../MediaCard';
14+
15+
const exampleProps: MediaCardProps = {
16+
title: 'Title',
17+
subtitle: 'Subtitle',
18+
description: 'Description',
19+
width: 320,
20+
};
21+
22+
const exampleThumbnail = (
23+
<CardThumbnail accessibilityLabel="Ethereum" source={ethBackground} testID="thumbnail" />
24+
);
25+
26+
const exampleMedia = (
27+
<RemoteImage
28+
accessibilityLabel="Media"
29+
height="100%"
30+
resizeMode="cover"
31+
shape="rectangle"
32+
source={ethBackground}
33+
width="100%"
34+
/>
35+
);
36+
37+
const MediaCardScreen = () => {
38+
const ref = useRef<View>(null);
39+
return (
40+
<ExampleScreen>
41+
<Example title="Default">
42+
<MediaCard ref={ref} {...exampleProps} />
43+
</Example>
44+
45+
<Example title="With Thumbnail">
46+
<MediaCard {...exampleProps} thumbnail={exampleThumbnail} />
47+
</Example>
48+
49+
<Example title="With Media">
50+
<MediaCard {...exampleProps} media={exampleMedia} />
51+
</Example>
52+
53+
<Example title="With Thumbnail and Media">
54+
<MediaCard {...exampleProps} media={exampleMedia} thumbnail={exampleThumbnail} />
55+
</Example>
56+
57+
<Example title="With Tag">
58+
<MediaCard {...exampleProps} tag="Tag" />
59+
</Example>
60+
61+
<Example title="Complete">
62+
<MediaCard {...exampleProps} media={exampleMedia} tag="Tag" thumbnail={exampleThumbnail} />
63+
</Example>
64+
65+
<Example title="Long Text">
66+
<MediaCard
67+
actionable
68+
description="This is a very long description text that demonstrates how the card handles longer content"
69+
media={exampleMedia}
70+
onPress={NoopFn}
71+
subtitle="This is a very long subtitle text that will get truncated"
72+
thumbnail={exampleThumbnail}
73+
title="This is a very long title text that will get truncated"
74+
width={320}
75+
/>
76+
</Example>
77+
78+
<Example title="Custom Overrides">
79+
<MediaCard
80+
{...exampleProps}
81+
media={exampleMedia}
82+
slotProps={{
83+
title: { color: 'fgPositive' },
84+
subtitle: { font: 'label1' },
85+
description: { color: 'fgMuted' },
86+
tag: { colorScheme: 'blue' },
87+
}}
88+
tag="Custom Tag"
89+
thumbnail={exampleThumbnail}
90+
/>
91+
</Example>
92+
93+
<Example title="With Layout Overrides">
94+
<MediaCard
95+
{...exampleProps}
96+
media={exampleMedia}
97+
slotProps={{
98+
layoutContainer: { gap: 3 },
99+
contentContainer: { padding: 3, gap: 2 },
100+
textContainer: { gap: 1 },
101+
headerContainer: { gap: 1 },
102+
title: { color: 'fg', font: 'headline' },
103+
subtitle: { color: 'fgMuted', font: 'label1' },
104+
description: { color: 'fgMuted', font: 'body' },
105+
mediaContainer: { borderRadius: 300 },
106+
tag: { colorScheme: 'green' },
107+
}}
108+
tag="New"
109+
thumbnail={exampleThumbnail}
110+
/>
111+
</Example>
112+
113+
<Example title="Custom Content">
114+
<MediaCard
115+
description={
116+
<TextLabel2>
117+
Custom description with <Text font="headline">bold text</Text> and{' '}
118+
<Text font="label1">italic text</Text>
119+
</TextLabel2>
120+
}
121+
media={exampleMedia}
122+
subtitle={<TextHeadline color="fgPositive">Custom Subtitle</TextHeadline>}
123+
thumbnail={exampleThumbnail}
124+
title={<TextTitle3>Custom Title</TextTitle3>}
125+
width={320}
126+
/>
127+
</Example>
128+
129+
<Example title="Without Media">
130+
<MediaCard {...exampleProps} thumbnail={exampleThumbnail} />
131+
</Example>
132+
133+
<Example title="Interactive with onPress">
134+
<MediaCard
135+
actionable
136+
description="Clickable card with onPress handler"
137+
media={exampleMedia}
138+
onPress={() => console.log('Card clicked!')}
139+
subtitle="Button"
140+
tag="Action"
141+
thumbnail={exampleThumbnail}
142+
title="Interactive Card"
143+
width={320}
144+
/>
145+
</Example>
146+
147+
<Example title="Non-Interactive Explicit">
148+
<MediaCard
149+
{...exampleProps}
150+
actionable={false}
151+
media={exampleMedia}
152+
thumbnail={exampleThumbnail}
153+
/>
154+
</Example>
155+
156+
<Example title="Multiple Cards">
157+
<Carousel
158+
gap={1.5}
159+
items={[
160+
<MediaCard
161+
key="card1"
162+
{...exampleProps}
163+
media={exampleMedia}
164+
thumbnail={exampleThumbnail}
165+
/>,
166+
<MediaCard
167+
key="card2"
168+
actionable
169+
description="Another card with different content"
170+
media={exampleMedia}
171+
onPress={NoopFn}
172+
subtitle="BTC"
173+
tag="Hot"
174+
thumbnail={<CardThumbnail source={assets.btc.imageUrl} />}
175+
title="Bitcoin"
176+
width={320}
177+
/>,
178+
<MediaCard
179+
key="card3"
180+
actionable
181+
description="Card with onPress handler"
182+
onPress={NoopFn}
183+
subtitle="ETH"
184+
thumbnail={exampleThumbnail}
185+
title="Ethereum"
186+
width={320}
187+
/>,
188+
]}
189+
/>
190+
</Example>
191+
</ExampleScreen>
192+
);
193+
};
194+
195+
export default MediaCardScreen;

packages/mobile/src/cards/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,5 @@ export * from './FeatureEntryCard';
1515
export * from './FeedCard';
1616
// Phoenix cards
1717
export * from './ContentCard';
18+
// Media card
19+
export * from './MediaCard';

0 commit comments

Comments
 (0)