-
Notifications
You must be signed in to change notification settings - Fork 58
feat: MediaCard #242
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: adrien/card-refactor-base
Are you sure you want to change the base?
feat: MediaCard #242
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. */ | ||
| 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>; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 }; | ||
| 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'> & | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 & { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 } = {}, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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'; | ||
| 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; |
There was a problem hiding this comment.
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