Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/mobile-app/scripts/utils/routes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,10 @@ export const routes = [
getComponent: () =>
require('@coinbase/cds-mobile/animation/__stories__/LottieStatusAnimation.stories').default,
},
{
key: 'MediaCard',
getComponent: () => require('@coinbase/cds-mobile/cards/__stories__/MediaCard.stories').default,
},
{
key: 'MediaChip',
getComponent: () => require('@coinbase/cds-mobile/chips/__stories__/MediaChip.stories').default,
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile-app/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,10 @@ export const routes = [
getComponent: () =>
require('@coinbase/cds-mobile/animation/__stories__/LottieStatusAnimation.stories').default,
},
{
key: 'MediaCard',
getComponent: () => require('@coinbase/cds-mobile/cards/__stories__/MediaCard.stories').default,
},
{
key: 'MediaChip',
getComponent: () => require('@coinbase/cds-mobile/chips/__stories__/MediaChip.stories').default,
Expand Down
126 changes: 126 additions & 0 deletions packages/mobile/src/cards/MediaCard/MediaCardLayout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import React, { memo, useMemo } from 'react';
import type { StyleProp, ViewStyle } from 'react-native';

import { HStack } from '../../layout/HStack';
import { VStack } from '../../layout/VStack';
import { Text } from '../../typography/Text';

export type MediaCardLayoutBaseProps = {
/** Text or React node to display as the card title. When a string is provided, it will be rendered in a CardTitle component. */
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: CardTitle doesn't really exist from the customer's perspective. I would mention the font instead

title?: React.ReactNode;
/** Text or React node to display as the card subtitle. When a string is provided, it will be rendered in a CardSubtitle component. */
subtitle?: React.ReactNode;
/** Text or React node to display as the card description. When a string is provided, it will be rendered in a CardDescription component. */
description?: React.ReactNode;
/** React node to display as a thumbnail in the content area. */
thumbnail: React.ReactNode;
/** 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. */
media?: React.ReactNode;
/** The position of the media within the card. */
mediaPlacement?: 'start' | 'end';
};

export type MediaCardLayoutProps = MediaCardLayoutBaseProps & {
styles?: {
layoutContainer?: StyleProp<ViewStyle>;
contentContainer?: StyleProp<ViewStyle>;
textContainer?: StyleProp<ViewStyle>;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sometimes it feels unnecessary to have 2 levels deep of custom CSS - how does it feel to you? Imo they could always provide more targetted CSS or a ReactNode to customize further if needed (latter for mobile as no CSS there)

headerContainer?: StyleProp<ViewStyle>;
mediaContainer?: StyleProp<ViewStyle>;
};
};

const MediaCardLayout = memo(
({
title,
subtitle,
description,
thumbnail,
media,
mediaPlacement = 'end',
styles = {},
}: MediaCardLayoutProps) => {
const titleNode = useMemo(() => {
if (typeof title === 'string') {
return (
<Text font="headline" numberOfLines={1}>
{title}
</Text>
);
}
return title;
}, [title]);

const subtitleNode = useMemo(
() =>
typeof subtitle === 'string' ? (
<Text color="fgMuted" font="legal" numberOfLines={1}>
{subtitle}
</Text>
) : (
subtitle
),
[subtitle],
);

const headerNode = useMemo(
() => (
<VStack style={styles?.headerContainer}>
{subtitleNode}
{titleNode}
</VStack>
),
[subtitleNode, titleNode, styles?.headerContainer],
);

const descriptionNode = useMemo(
() =>
typeof description === 'string' ? (
<Text color="fgMuted" font="label2" numberOfLines={2}>
{description}
</Text>
) : (
description
),
[description],
);

const contentNode = useMemo(
() => (
<VStack
flexBasis="50%"
gap={4}
justifyContent="space-between"
padding={2}
style={styles?.contentContainer}
>
{thumbnail}
<VStack style={styles?.textContainer}>
{headerNode}
{descriptionNode}
</VStack>
</VStack>
),
[styles?.contentContainer, styles?.textContainer, thumbnail, headerNode, descriptionNode],
);

const mediaNode = useMemo(() => {
if (media) {
return (
<HStack flexBasis="50%" style={styles?.mediaContainer}>
{media}
</HStack>
);
}
}, [media, styles?.mediaContainer]);

return (
<HStack flexGrow={1} style={styles?.layoutContainer}>
{mediaPlacement === 'start' ? mediaNode : contentNode}
{mediaPlacement === 'end' ? mediaNode : contentNode}
</HStack>
);
},
);

export { MediaCardLayout };
57 changes: 57 additions & 0 deletions packages/mobile/src/cards/MediaCard/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { forwardRef, memo, useCallback, useMemo } from 'react';
import type { PressableStateCallbackType, StyleProp, View, ViewStyle } from 'react-native';
import type { ThemeVars } from '@coinbase/cds-common';

import { CardRoot, type CardRootProps } from '../CardRoot';

import { MediaCardLayout, type MediaCardLayoutProps } from './MediaCardLayout';

export type MediaCardBaseProps = MediaCardLayoutProps;

export type MediaCardProps = Omit<CardRootProps, 'children'> &
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a reason you are composing the RootProps and not the RootBaseProps on the base props type? I trust your decision, I'm just trying to strengthen my own understanding of the distinction when I can. The benefit I see of doing it this way is you now make MediaCard polymorphic the same way as the CardRoot component

MediaCardBaseProps & {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: full types before others

styles?: {
root?: StyleProp<ViewStyle>;
};
};

const mediaCardContainerProps = {
borderRadius: 500 as ThemeVars.BorderRadius,
background: 'bgAlternate' as ThemeVars.Color,
overflow: 'hidden' as const,
};

export const MediaCard = memo(
forwardRef<View, MediaCardProps>(
(
{
title,
subtitle,
description,
thumbnail,
media,
mediaPlacement = 'end',
style,
styles: { root: rootStyle, ...layoutStyles } = {},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice way to separate out the layout styles into their own component!

...props
},
ref,
) => {
return (
<CardRoot ref={ref} {...mediaCardContainerProps} style={[style, rootStyle]} {...props}>
<MediaCardLayout
description={description}
media={media}
mediaPlacement={mediaPlacement}
styles={layoutStyles}
subtitle={subtitle}
thumbnail={thumbnail}
title={title}
/>
</CardRoot>
);
},
),
);

MediaCard.displayName = 'MediaCard';
187 changes: 187 additions & 0 deletions packages/mobile/src/cards/__stories__/MediaCard.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { useRef } from 'react';
import { type View } from 'react-native';
import { assets, ethBackground } from '@coinbase/cds-common/internal/data/assets';
import { NoopFn } from '@coinbase/cds-common/utils/mockUtils';

import { Carousel } from '../../carousel/Carousel';
import { CarouselItem } from '../../carousel/CarouselItem';
import { Example, ExampleScreen } from '../../examples/ExampleScreen';
import { RemoteImage } from '../../media/RemoteImage';
import { TextHeadline, TextLabel2, TextTitle3 } from '../../typography';
import { Text } from '../../typography/Text';
import type { MediaCardProps } from '../MediaCard';
import { MediaCard } from '../MediaCard';

const exampleProps: Omit<MediaCardProps, 'thumbnail'> = {
title: 'Title',
subtitle: 'Subtitle',
description: 'Description',
width: 320,
};

const exampleThumbnail = (
<RemoteImage
accessibilityLabel="Ethereum"
shape="circle"
size="l"
source={ethBackground}
testID="thumbnail"
/>
);

const exampleMedia = (
<RemoteImage
accessibilityLabel="Media"
height="100%"
resizeMode="cover"
shape="rectangle"
source={ethBackground}
width="100%"
/>
);

const MediaCardScreen = () => {
const ref = useRef<View>(null);
return (
<ExampleScreen>
{/* Basic Examples */}
<Example title="Default">
<MediaCard ref={ref} {...exampleProps} thumbnail={exampleThumbnail} />
</Example>

<Example title="With Media">
<MediaCard {...exampleProps} media={exampleMedia} thumbnail={exampleThumbnail} />
</Example>

{/* Media Placement */}
<Example title="Media Placement Start">
<MediaCard
{...exampleProps}
media={exampleMedia}
mediaPlacement="start"
thumbnail={exampleThumbnail}
/>
</Example>

<Example title="Media Placement End">
<MediaCard
{...exampleProps}
media={exampleMedia}
mediaPlacement="end"
thumbnail={exampleThumbnail}
/>
</Example>

{/* Text Content */}
<Example title="Long Text">
<MediaCard
description="This is a very long description text that demonstrates how the card handles longer content"
media={exampleMedia}
onPress={NoopFn}
subtitle="This is a very long subtitle text that will get truncated"
thumbnail={exampleThumbnail}
title="This is a very long title text that will get truncated"
width={320}
/>
</Example>

<Example title="Custom Content">
<MediaCard
description={
<TextLabel2>
Custom description with <Text font="headline">bold text</Text> and{' '}
<Text font="label1">italic text</Text>
</TextLabel2>
}
media={exampleMedia}
subtitle={<TextHeadline color="fgPositive">Custom Subtitle</TextHeadline>}
thumbnail={exampleThumbnail}
title={<TextTitle3>Custom Title</TextTitle3>}
width={320}
/>
</Example>

{/* Styling */}
<Example title="With Layout Overrides">
<MediaCard
{...exampleProps}
media={exampleMedia}
styles={{
layoutContainer: { gap: 3 },
contentContainer: { padding: 3, gap: 2 },
textContainer: { gap: 1 },
headerContainer: { gap: 1 },
mediaContainer: { borderRadius: 300 },
}}
thumbnail={exampleThumbnail}
/>
</Example>

<Example title="With Root Style Override">
<MediaCard
{...exampleProps}
media={exampleMedia}
styles={{
root: { borderWidth: 2, borderColor: 'blue' },
}}
thumbnail={exampleThumbnail}
/>
</Example>

{/* Interactive */}
<Example title="Interactive with onPress">
<MediaCard
renderAsPressable
description="Clickable card with onPress handler"
media={exampleMedia}
onPress={() => console.log('Card clicked!')}
subtitle="Button"
thumbnail={exampleThumbnail}
title="Interactive Card"
width={320}
/>
</Example>

{/* Multiple Cards */}
<Example title="Multiple Cards">
<Carousel styles={{ carousel: { gap: 16 } }}>
<CarouselItem id="card1">
<MediaCard {...exampleProps} media={exampleMedia} thumbnail={exampleThumbnail} />
</CarouselItem>
<CarouselItem id="card2">
<MediaCard
renderAsPressable
description="Another card with different content"
media={exampleMedia}
onPress={NoopFn}
subtitle="BTC"
thumbnail={
<RemoteImage
accessibilityLabel="Bitcoin"
shape="square"
size="l"
source={assets.btc.imageUrl}
/>
}
title="Bitcoin"
width={320}
/>
</CarouselItem>
<CarouselItem id="card3">
<MediaCard
renderAsPressable
description="Card with onPress handler"
onPress={NoopFn}
subtitle="ETH"
thumbnail={exampleThumbnail}
title="Ethereum"
width={320}
/>
</CarouselItem>
</Carousel>
</Example>
</ExampleScreen>
);
};

export default MediaCardScreen;
Loading