diff --git a/apps/mobile-app/scripts/utils/routes.mjs b/apps/mobile-app/scripts/utils/routes.mjs index d5d4c026b..b0ca56a90 100644 --- a/apps/mobile-app/scripts/utils/routes.mjs +++ b/apps/mobile-app/scripts/utils/routes.mjs @@ -38,6 +38,12 @@ export const routes = [ getComponent: () => require('@coinbase/cds-mobile/overlays/__stories__/AlertVerticalActions.stories').default, }, + { + key: 'AlphaContentCard', + getComponent: () => + require('@coinbase/cds-mobile/alpha/content-card/__stories__/AlphaContentCard.stories') + .default, + }, { key: 'AlphaSelect', getComponent: () => @@ -195,6 +201,11 @@ export const routes = [ getComponent: () => require('@coinbase/cds-mobile/controls/__stories__/ControlGroup.stories').default, }, + { + key: 'DataCard', + getComponent: () => + require('@coinbase/cds-mobile/alpha/data-card/__stories__/DataCard.stories').default, + }, { key: 'DateInput', getComponent: () => require('@coinbase/cds-mobile/dates/__stories__/DateInput.stories').default, @@ -341,10 +352,19 @@ 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, }, + { + key: 'MessagingCard', + getComponent: () => + require('@coinbase/cds-mobile/cards/__stories__/MessagingCard.stories').default, + }, { key: 'ModalBackButton', getComponent: () => diff --git a/apps/mobile-app/src/routes.ts b/apps/mobile-app/src/routes.ts index 24168f023..8be0ccc74 100644 --- a/apps/mobile-app/src/routes.ts +++ b/apps/mobile-app/src/routes.ts @@ -38,6 +38,12 @@ export const routes = [ getComponent: () => require('@coinbase/cds-mobile/overlays/__stories__/AlertVerticalActions.stories').default, }, + { + key: 'AlphaContentCard', + getComponent: () => + require('@coinbase/cds-mobile/alpha/content-card/__stories__/AlphaContentCard.stories') + .default, + }, { key: 'AlphaSelect', getComponent: () => @@ -200,6 +206,11 @@ export const routes = [ getComponent: () => require('@coinbase/cds-mobile/controls/__stories__/ControlGroup.stories').default, }, + { + key: 'DataCard', + getComponent: () => + require('@coinbase/cds-mobile/alpha/data-card/__stories__/DataCard.stories').default, + }, { key: 'DateInput', getComponent: () => require('@coinbase/cds-mobile/dates/__stories__/DateInput.stories').default, @@ -346,10 +357,19 @@ 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, }, + { + key: 'MessagingCard', + getComponent: () => + require('@coinbase/cds-mobile/cards/__stories__/MessagingCard.stories').default, + }, { key: 'ModalBackButton', getComponent: () => diff --git a/packages/mobile-visualization/src/chart/line/__stories__/LineChart.stories.tsx b/packages/mobile-visualization/src/chart/line/__stories__/LineChart.stories.tsx index 21f6f2176..168db338f 100644 --- a/packages/mobile-visualization/src/chart/line/__stories__/LineChart.stories.tsx +++ b/packages/mobile-visualization/src/chart/line/__stories__/LineChart.stories.tsx @@ -8,18 +8,21 @@ import { withTiming, } from 'react-native-reanimated'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; -import { assets } from '@coinbase/cds-common/internal/data/assets'; +import { assets, ethBackground } from '@coinbase/cds-common/internal/data/assets'; import { candles as btcCandles } from '@coinbase/cds-common/internal/data/candles'; import { prices } from '@coinbase/cds-common/internal/data/prices'; import { sparklineInteractiveData } from '@coinbase/cds-common/internal/visualizations/SparklineInteractiveData'; import { useTabsContext } from '@coinbase/cds-common/tabs/TabsContext'; import type { TabValue } from '@coinbase/cds-common/tabs/useTabs'; +import { NoopFn } from '@coinbase/cds-common/utils/mockUtils'; import { useTheme } from '@coinbase/cds-mobile'; +import { DataCard } from '@coinbase/cds-mobile/alpha/data-card/DataCard'; import { Button, IconButton } from '@coinbase/cds-mobile/buttons'; import { ListCell } from '@coinbase/cds-mobile/cells'; -import { Example, ExampleScreen } from '@coinbase/cds-mobile/examples/ExampleScreen'; +import { ExampleScreen } from '@coinbase/cds-mobile/examples/ExampleScreen'; import { Box, type BoxBaseProps, HStack, VStack } from '@coinbase/cds-mobile/layout'; import { Avatar, RemoteImage } from '@coinbase/cds-mobile/media'; +import { NavigationTitleSelect } from '@coinbase/cds-mobile/navigation'; import { SectionHeader } from '@coinbase/cds-mobile/section-header/SectionHeader'; import { Pressable } from '@coinbase/cds-mobile/system'; import { type TabComponent, type TabsActiveIndicatorProps } from '@coinbase/cds-mobile/tabs'; @@ -38,7 +41,7 @@ import { import { Area, DottedArea, type DottedAreaProps } from '../../area'; import { DefaultAxisTickLabel, XAxis, YAxis } from '../../axis'; -import { BarChart, type BarComponentProps, BarPlot } from '../../bar'; +import { type BarComponentProps, BarPlot } from '../../bar'; import { CartesianChart } from '../../CartesianChart'; import { useCartesianChartContext } from '../../ChartProvider'; import { PeriodSelector, PeriodSelectorActiveIndicator } from '../../PeriodSelector'; @@ -2281,6 +2284,106 @@ function TwoLineScrubberLabel() { ); } +function DataCardWithLineChart() { + const exampleThumbnail = ( + + ); + + const getLineChartSeries = () => [ + { + id: 'price', + data: prices.slice(0, 30).map((price: string) => parseFloat(price)), + color: 'accentBoldBlue', + }, + ]; + + const lineChartSeries = useMemo(() => getLineChartSeries(), []); + const lineChartSeries2 = useMemo(() => getLineChartSeries(), []); + const ref = useRef(null); + + return ( + + + + + + + + + + + + } + title="Card with Line Chart" + > + + + + ); +} type ExampleItem = { title: string; @@ -2542,20 +2645,22 @@ function ExampleNavigator() { title: 'Two-Line Scrubber Label', component: , }, + { + title: 'In DataCard', + component: , + }, ], [theme.color.fg, theme.color.fgPositive, theme.spectrum.gray50], ); const currentExample = examples[currentIndex]; - const isFirstExample = currentIndex === 0; - const isLastExample = currentIndex === examples.length - 1; const handlePrevious = useCallback(() => { - setCurrentIndex((prev) => Math.max(0, prev - 1)); - }, []); + setCurrentIndex((prev) => (prev - 1 + examples.length) % examples.length); + }, [examples.length]); const handleNext = useCallback(() => { - setCurrentIndex((prev) => Math.min(examples.length - 1, prev + 1)); + setCurrentIndex((prev) => (prev + 1 + examples.length) % examples.length); }, [examples.length]); return ( @@ -2565,12 +2670,11 @@ function ExampleNavigator() { - + {currentExample.title} {currentIndex + 1} / {examples.length} @@ -2579,7 +2683,6 @@ function ExampleNavigator() { (({ children, renderAsPressable, ...props }, ref) => { + const Component = renderAsPressable ? Pressable : VStack; + return ( + + {children} + + ); + }), +); + +ContentCard.displayName = 'ContentCard'; diff --git a/packages/mobile/src/alpha/content-card/ContentCardBody.tsx b/packages/mobile/src/alpha/content-card/ContentCardBody.tsx new file mode 100644 index 000000000..0a642a9a8 --- /dev/null +++ b/packages/mobile/src/alpha/content-card/ContentCardBody.tsx @@ -0,0 +1,101 @@ +import React, { forwardRef, memo, useMemo } from 'react'; +import type { StyleProp, View, ViewStyle } from 'react-native'; + +import { HStack } from '../../layout'; +import { Box, type BoxBaseProps } from '../../layout/Box'; +import { VStack, type VStackProps } from '../../layout/VStack'; +import { Text } from '../../typography'; + +export type ContentCardBodyBaseProps = BoxBaseProps & { + title: React.ReactNode; + description?: React.ReactNode; + media?: React.ReactNode; + mediaPlacement?: 'top' | 'bottom' | 'start' | 'end'; + styles?: { + root: StyleProp; + contentContainer: StyleProp; + mediaContainer: StyleProp; + }; +}; + +export type ContentCardBodyProps = ContentCardBodyBaseProps; + +export const ContentCardBody = memo( + forwardRef( + ( + { title, description, media, mediaPlacement = 'top', style, styles, padding = 2, ...props }, + ref, + ) => { + const hasMedia = !!media; + const isHorizontal = hasMedia && (mediaPlacement === 'start' || mediaPlacement === 'end'); + const isMediaFirst = hasMedia && (mediaPlacement === 'top' || mediaPlacement === 'start'); + + const titleNode = useMemo(() => { + if (typeof title === 'string') { + return ( + + {title} + + ); + } + return title; + }, [title]); + + const descriptionNode = useMemo(() => { + if (typeof description === 'string') { + return ( + + {description} + + ); + } + return description; + }, [description]); + + const contentNode = useMemo(() => { + return ( + + {titleNode} + {descriptionNode} + + ); + }, [isHorizontal, styles?.contentContainer, titleNode, descriptionNode]); + + const mediaNode = useMemo(() => { + if (!hasMedia) return null; + return ( + + {media} + + ); + }, [hasMedia, media, styles?.mediaContainer]); + + return ( + + {isMediaFirst ? mediaNode : contentNode} + {isMediaFirst ? contentNode : mediaNode} + + ); + }, + ), +); + +ContentCardBody.displayName = 'ContentCardBody'; diff --git a/packages/mobile/src/alpha/content-card/ContentCardFooter.tsx b/packages/mobile/src/alpha/content-card/ContentCardFooter.tsx new file mode 100644 index 000000000..db5f6e980 --- /dev/null +++ b/packages/mobile/src/alpha/content-card/ContentCardFooter.tsx @@ -0,0 +1,16 @@ +import React, { forwardRef, memo } from 'react'; +import type { View } from 'react-native'; + +import { HStack, type HStackProps } from '../../layout/HStack'; + +export type ContentCardFooterBaseProps = HStackProps; + +export type ContentCardFooterProps = ContentCardFooterBaseProps; + +export const ContentCardFooter = memo( + forwardRef(({ paddingX = 2, paddingBottom = 2, ...props }, ref) => { + return ; + }), +); + +ContentCardFooter.displayName = 'ContentCardFooter'; diff --git a/packages/mobile/src/alpha/content-card/ContentCardHeader.tsx b/packages/mobile/src/alpha/content-card/ContentCardHeader.tsx new file mode 100644 index 000000000..798f7c960 --- /dev/null +++ b/packages/mobile/src/alpha/content-card/ContentCardHeader.tsx @@ -0,0 +1,88 @@ +import React, { forwardRef, memo, useMemo } from 'react'; +import type { StyleProp, View, ViewStyle } from 'react-native'; + +import { type BoxBaseProps } from '../../layout/Box'; +import { HStack } from '../../layout/HStack'; +import { VStack } from '../../layout/VStack'; +import { Text } from '../../typography'; + +export type ContentCardHeaderBaseProps = BoxBaseProps & { + title: React.ReactNode; + subtitle: React.ReactNode; + thumbnail: React.ReactNode; + action: React.ReactNode; + styles?: { + root: StyleProp; + contentContainer: StyleProp; + }; +}; + +export type ContentCardHeaderProps = ContentCardHeaderBaseProps; + +export const ContentCardHeader = memo( + forwardRef( + ( + { + title, + subtitle, + thumbnail, + styles, + action, + gap = 1.5, + paddingX = 2, + paddingTop = 2, + style, + ...props + }, + ref, + ) => { + const titleNode = useMemo(() => { + if (typeof title === 'string') { + return ( + + {title} + + ); + } + return title; + }, [title]); + + const subtitleNode = useMemo(() => { + if (typeof subtitle === 'string') { + return ( + + {subtitle} + + ); + } + return subtitle; + }, [subtitle]); + + return ( + + {thumbnail} + + {titleNode} + {subtitleNode} + + {action} + + ); + }, + ), +); + +ContentCardHeader.displayName = 'ContentCardHeader'; diff --git a/packages/mobile/src/alpha/content-card/__stories__/AlphaContentCard.stories.tsx b/packages/mobile/src/alpha/content-card/__stories__/AlphaContentCard.stories.tsx new file mode 100644 index 000000000..acc86a93d --- /dev/null +++ b/packages/mobile/src/alpha/content-card/__stories__/AlphaContentCard.stories.tsx @@ -0,0 +1,395 @@ +import { Alert } from 'react-native'; +import { ethBackground } from '@coinbase/cds-common/internal/data/assets'; + +import { Button, IconButton } from '../../../buttons'; +import { Example, ExampleScreen } from '../../../examples/ExampleScreen'; +import { HStack, VStack } from '../../../layout'; +import { RemoteImage } from '../../../media'; +import { ContentCard, ContentCardBody, ContentCardFooter, ContentCardHeader } from '../index'; + +const exampleThumbnail = ( + +); + +const exampleMedia = ( + +); + +const exampleMediaSquare = ( + +); + +const ContentCardScreen = () => { + return ( + + + + + + + + + } + subtitle="transparent" + thumbnail={exampleThumbnail} + title="Content Card" + /> + + + + + + + + + + + } + subtitle="bgAlternate" + thumbnail={exampleThumbnail} + title="Content Card" + /> + + + + + + + + + + + Alert.alert('Card clicked!')} width={327}> + + + + + } + subtitle="Subtitle" + thumbnail={exampleThumbnail} + title="Title" + /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + } + subtitle="Subtitle" + thumbnail={exampleThumbnail} + title="Title" + /> + + + + + + + + + + + + + } + subtitle="Subtitle" + thumbnail={exampleThumbnail} + title="Title" + /> + } + mediaPlacement="top" + title="No Media Card" + /> + + + + + + + + + + + + + + + } + subtitle={ + + } + thumbnail={exampleThumbnail} + title={ + + } + /> + + + + + } + media={exampleMedia} + mediaPlacement="top" + title={ + + } + /> + + + + + + + + + + + + + + + } + subtitle="Subtitle" + thumbnail={exampleThumbnail} + title="Title" + /> + + + + + + + + + + + } + subtitle="Subtitle" + thumbnail={exampleThumbnail} + title="Title" + /> + + + + + + Alert.alert('Card 3 clicked!')} width={327}> + + + + + } + subtitle="Subtitle" + thumbnail={exampleThumbnail} + title="Title" + /> + + + + + + + + + + + + + + + + } + subtitle="This is a very long subtitle text that demonstrates how the header handles longer content" + thumbnail={exampleThumbnail} + title="This is a very long title text that demonstrates how the header handles longer content" + /> + + + + + + + + + ); +}; + +export default ContentCardScreen; diff --git a/packages/mobile/src/alpha/content-card/index.tsx b/packages/mobile/src/alpha/content-card/index.tsx new file mode 100644 index 000000000..4f5303cb0 --- /dev/null +++ b/packages/mobile/src/alpha/content-card/index.tsx @@ -0,0 +1,4 @@ +export * from './ContentCard'; +export * from './ContentCardBody'; +export * from './ContentCardFooter'; +export * from './ContentCardHeader'; diff --git a/packages/mobile/src/alpha/data-card/DataCard.tsx b/packages/mobile/src/alpha/data-card/DataCard.tsx new file mode 100644 index 000000000..baa86edb7 --- /dev/null +++ b/packages/mobile/src/alpha/data-card/DataCard.tsx @@ -0,0 +1,61 @@ +import { forwardRef, memo } from 'react'; +import type { View } from 'react-native'; +import type { ThemeVars } from '@coinbase/cds-common'; + +import { CardRoot, type CardRootProps } from '../../cards/CardRoot'; + +import { DataCardLayout, type DataCardLayoutProps } from './DataCardLayout'; + +export type DataCardBaseProps = DataCardLayoutProps & { + styles?: { + root?: React.CSSProperties; + }; +}; + +export type DataCardProps = Omit & DataCardBaseProps; + +const dataCardContainerProps = { + borderRadius: 500 as ThemeVars.BorderRadius, + background: 'bgAlternate' as ThemeVars.Color, + overflow: 'hidden' as const, +}; + +export const DataCard = memo( + forwardRef( + ( + { + title, + subtitle, + tag, + thumbnail, + children, + layout, + renderAsPressable, + styles: { root: rootStyle, ...layoutStyles } = {}, + ...props + }, + ref, + ) => ( + + + {children} + + + ), + ), +); + +DataCard.displayName = 'DataCard'; diff --git a/packages/mobile/src/alpha/data-card/DataCardLayout.tsx b/packages/mobile/src/alpha/data-card/DataCardLayout.tsx new file mode 100644 index 000000000..1d6a16bf7 --- /dev/null +++ b/packages/mobile/src/alpha/data-card/DataCardLayout.tsx @@ -0,0 +1,116 @@ +import React, { memo, useMemo } from 'react'; +import type { StyleProp, ViewStyle } from 'react-native'; + +import { Box } from '../../layout/Box'; +import { HStack } from '../../layout/HStack'; +import { VStack } from '../../layout/VStack'; +import { Tag } from '../../tag/Tag'; +import { Text } from '../../typography'; + +export type DataCardLayoutBaseProps = { + /** 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 a tag. When a string is provided, it will be rendered in a Tag component. */ + tag?: React.ReactNode; + /** React node to display as a thumbnail in the header area. */ + thumbnail?: React.ReactNode; + /** Layout orientation of the card. Horizontal places header and visualization side by side, vertical stacks them. */ + layout: 'horizontal' | 'vertical'; + /** child node to display as the visualization (e.g., ProgressBar or ProgressCircle). */ + children?: React.ReactNode; +}; + +export type DataCardLayoutProps = DataCardLayoutBaseProps & { + styles?: { + layoutContainer?: StyleProp; + headerContainer?: StyleProp; + headerContent?: StyleProp; + titleContainer?: StyleProp; + }; +}; +export const DataCardLayout = memo( + ({ + title, + subtitle, + tag, + thumbnail, + layout = 'vertical', + children, + styles = {}, + }: DataCardLayoutProps) => { + const titleNode = useMemo(() => { + if (typeof title === 'string') { + return ( + + {title} + + ); + } + return title; + }, [title]); + + const subtitleNode = useMemo(() => { + if (typeof subtitle === 'string') { + return ( + + {subtitle} + + ); + } + return subtitle; + }, [subtitle]); + + const tagNode = useMemo(() => { + if (typeof tag === 'string') return {tag}; + return tag; + }, [tag]); + + const layoutContainerSpacingProps = useMemo(() => { + return { + flexDirection: layout === 'horizontal' ? ('row' as const) : ('column' as const), + gap: layout === 'horizontal' ? 2 : 1, + padding: 2, + } as const; + }, [layout]); + + const headerSpacingProps = useMemo(() => { + return { + flexDirection: layout === 'horizontal' ? ('column' as const) : ('row' as const), + gap: layout === 'horizontal' ? 2 : 1, + alignItems: layout === 'horizontal' ? ('flex-start' as const) : ('center' as const), + } as const; + }, [layout]); + + return ( + + + {thumbnail} + + + {tagNode} + {titleNode} + + {subtitleNode} + + + {children} + + ); + }, +); + +DataCardLayout.displayName = 'DataCardLayout'; diff --git a/packages/mobile/src/alpha/data-card/__stories__/DataCard.stories.tsx b/packages/mobile/src/alpha/data-card/__stories__/DataCard.stories.tsx new file mode 100644 index 000000000..88fbcf66f --- /dev/null +++ b/packages/mobile/src/alpha/data-card/__stories__/DataCard.stories.tsx @@ -0,0 +1,238 @@ +import { useRef } from 'react'; +import { Alert, type View } from 'react-native'; +import { ethBackground } from '@coinbase/cds-common/internal/data/assets'; +import { NoopFn } from '@coinbase/cds-common/utils/mockUtils'; + +import { Example, ExampleScreen } from '../../../examples/ExampleScreen'; +import { HStack } from '../../../layout'; +import { Box } from '../../../layout/Box'; +import { VStack } from '../../../layout/VStack'; +import { RemoteImage } from '../../../media'; +import { ProgressBar, ProgressBarWithFixedLabels, ProgressCircle } from '../../../visualizations'; +import { DataCard } from '../DataCard'; + +const exampleThumbnail = ( + +); + +const DataCardScreen = () => { + const ref1 = useRef(null); + const ref2 = useRef(null); + + return ( + + {/* Basic Examples */} + + + + + + + + + + + + + + + + + + {/* Features */} + + + + + + + + + + + + + + + + + + + + + + + {/* Interactive */} + + + Alert.alert('Progress bar card clicked!')} + subtitle="Clickable progress card" + tag="Action" + thumbnail={exampleThumbnail} + title="Actionable Progress Bar" + > + + + + + + + + + + + + + + + {/* Style Overrides */} + + + + + + + + + + + + + + + + + + + + + + + + + {/* Multiple Cards */} + + + + + + + + + + + + + ); +}; + +export default DataCardScreen; diff --git a/packages/mobile/src/cards/CardMedia.tsx b/packages/mobile/src/cards/CardMedia.tsx index 08865306a..d6da9f053 100644 --- a/packages/mobile/src/cards/CardMedia.tsx +++ b/packages/mobile/src/cards/CardMedia.tsx @@ -1,4 +1,4 @@ -import React, { memo } from 'react'; +import { memo } from 'react'; import { defaultMediaDimension, defaultMediaSize, @@ -11,8 +11,7 @@ import type { } from '@coinbase/cds-common/types'; import { Pictogram, SpotSquare } from '../illustrations'; - -import { CardRemoteImage } from './CardRemoteImage'; +import { getSource, RemoteImage } from '../media/RemoteImage'; export type CardMediaProps = CommonCardMediaProps; @@ -50,8 +49,10 @@ export const CardMedia = memo(function CardMedia({ placement = 'end', ...props } ); case 'image': return ( - getSource(src), [src]); - return ; -}); - -CardRemoteImage.displayName = 'CardRemoteImage'; diff --git a/packages/mobile/src/cards/CardRoot.tsx b/packages/mobile/src/cards/CardRoot.tsx new file mode 100644 index 000000000..32105f357 --- /dev/null +++ b/packages/mobile/src/cards/CardRoot.tsx @@ -0,0 +1,25 @@ +import React, { forwardRef, memo } from 'react'; +import type { View } from 'react-native'; + +import { HStack } from '../layout/HStack'; +import { Pressable, type PressableProps } from '../system/Pressable'; + +export type CardRootBaseProps = { + children: React.ReactNode; + renderAsPressable?: boolean; +}; + +export type CardRootProps = CardRootBaseProps & PressableProps; + +export const CardRoot = memo( + forwardRef(({ children, renderAsPressable, ...props }, ref) => { + const Component = renderAsPressable ? Pressable : HStack; + return ( + + {children} + + ); + }), +); + +CardRoot.displayName = 'CardRoot'; diff --git a/packages/mobile/src/cards/MediaCard/MediaCardLayout.tsx b/packages/mobile/src/cards/MediaCard/MediaCardLayout.tsx new file mode 100644 index 000000000..a6584e286 --- /dev/null +++ b/packages/mobile/src/cards/MediaCard/MediaCardLayout.tsx @@ -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; + contentContainer?: StyleProp; + textContainer?: StyleProp; + headerContainer?: StyleProp; + mediaContainer?: StyleProp; + }; +}; + +const MediaCardLayout = memo( + ({ + title, + subtitle, + description, + thumbnail, + media, + mediaPlacement = 'end', + styles = {}, + }: MediaCardLayoutProps) => { + const titleNode = useMemo(() => { + if (typeof title === 'string') { + return ( + + {title} + + ); + } + return title; + }, [title]); + + const subtitleNode = useMemo( + () => + typeof subtitle === 'string' ? ( + + {subtitle} + + ) : ( + subtitle + ), + [subtitle], + ); + + const headerNode = useMemo( + () => ( + + {subtitleNode} + {titleNode} + + ), + [subtitleNode, titleNode, styles?.headerContainer], + ); + + const descriptionNode = useMemo( + () => + typeof description === 'string' ? ( + + {description} + + ) : ( + description + ), + [description], + ); + + const contentNode = useMemo( + () => ( + + {thumbnail} + + {headerNode} + {descriptionNode} + + + ), + [styles?.contentContainer, styles?.textContainer, thumbnail, headerNode, descriptionNode], + ); + + const mediaNode = useMemo(() => { + if (media) { + return ( + + {media} + + ); + } + }, [media, styles?.mediaContainer]); + + return ( + + {mediaPlacement === 'start' ? mediaNode : contentNode} + {mediaPlacement === 'end' ? mediaNode : contentNode} + + ); + }, +); + +export { MediaCardLayout }; diff --git a/packages/mobile/src/cards/MediaCard/index.tsx b/packages/mobile/src/cards/MediaCard/index.tsx new file mode 100644 index 000000000..5af40e169 --- /dev/null +++ b/packages/mobile/src/cards/MediaCard/index.tsx @@ -0,0 +1,56 @@ +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 & + MediaCardBaseProps & { + styles?: { + root?: StyleProp | ((state: PressableStateCallbackType) => StyleProp); + }; + }; + +const mediaCardContainerProps = { + borderRadius: 500 as ThemeVars.BorderRadius, + background: 'bgAlternate' as ThemeVars.Color, + overflow: 'hidden' as const, +}; + +export const MediaCard = memo( + forwardRef( + ( + { + title, + subtitle, + description, + thumbnail, + media, + mediaPlacement = 'end', + styles: { root: rootStyle, ...layoutStyles } = {}, + ...props + }, + ref, + ) => { + return ( + + + + ); + }, + ), +); + +MediaCard.displayName = 'MediaCard'; diff --git a/packages/mobile/src/cards/MesssagingCard/MessagingCardLayout.tsx b/packages/mobile/src/cards/MesssagingCard/MessagingCardLayout.tsx new file mode 100644 index 000000000..52164bf6d --- /dev/null +++ b/packages/mobile/src/cards/MesssagingCard/MessagingCardLayout.tsx @@ -0,0 +1,187 @@ +import React, { memo, useMemo } from 'react'; +import type { GestureResponderEvent, StyleProp, ViewStyle } from 'react-native'; + +import { IconButton } from '../../buttons/IconButton'; +import { HStack } from '../../layout/HStack'; +import { VStack } from '../../layout/VStack'; +import { Tag } from '../../tag/Tag'; +import { Text } from '../../typography/Text'; + +export type MessagingCardLayoutProps = { + /** Type of messaging card. Determines background color and text color. */ + type: 'upsell' | 'nudge'; + /** Text or React node to display as the card title. When a string is provided, it will be rendered in a CardTitle component with appropriate color based on type. */ + title?: React.ReactNode; + /** Text or React node to display as the card subtitle. */ + 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 with appropriate color based on type. */ + description?: React.ReactNode; + /** Text or React node to display as a tag. When a string is provided, it will be rendered in a Tag component. */ + tag?: React.ReactNode; + /** React node to display as actions (typically buttons) at the bottom of the content area. */ + actions?: React.ReactNode; + /** React node to display as the dismiss button. When provided, a dismiss button will be rendered in the top-right corner. */ + dismissButton?: React.ReactNode; + /** Callback fired when the dismiss button is pressed. When provided, a dismiss button will be rendered in the top-right corner. */ + onDismiss?: (event: GestureResponderEvent) => void; + /** Accessibility label for the dismiss button. + * @default 'Dismiss card' + */ + dismissButtonAccessibilityLabel?: string; + /** Placement of the media content relative to the text content. */ + mediaPlacement: 'start' | 'end'; + /** React node to display as the main media content. When provided, it will be rendered in an HStack container. */ + media?: React.ReactNode; + styles?: { + layoutContainer?: StyleProp; + contentContainer?: StyleProp; + textContainer?: StyleProp; + mediaContainer?: StyleProp; + dismissButtonContainer?: StyleProp; + }; +}; + +export const MessagingCardLayout = memo( + ({ + type, + title, + description, + tag, + actions, + onDismiss, + dismissButtonAccessibilityLabel = 'Dismiss card', + mediaPlacement = 'end', + media, + styles = {}, + dismissButton, + }: MessagingCardLayoutProps) => { + const titleNode = useMemo(() => { + if (typeof title === 'string') { + return ( + + {title} + + ); + } + return title; + }, [title, type]); + + const descriptionNode = useMemo(() => { + if (typeof description === 'string') { + return ( + + {description} + + ); + } + return description; + }, [description, type]); + + const tagNode = useMemo(() => { + if (typeof tag === 'string') { + return {tag}; + } + return tag; + }, [tag]); + + const dismissButtonNode = useMemo(() => { + if (dismissButton) { + return dismissButton; + } + if (onDismiss) { + const handleDismiss = (event: GestureResponderEvent) => { + event.preventDefault(); + event.stopPropagation(); + onDismiss(event); + }; + + return ( + + + + ); + } + return null; + }, [dismissButton, dismissButtonAccessibilityLabel, onDismiss, styles?.dismissButtonContainer]); + + const contentContainerPaddingProps = useMemo(() => { + if (mediaPlacement === 'start' && onDismiss) { + // needs to add additional padding to the end of the content area when media is placed at the start and there is a dismiss button + // this is to avoid dismiss button from overlapping with the content area + return { + paddingY: 2, + paddingStart: 2, + paddingEnd: 6, + } as const; + } + return { + padding: 2, + } as const; + }, [mediaPlacement, onDismiss]); + + const mediaContainerPaddingProps = useMemo(() => { + if (type === 'upsell') return; + if (mediaPlacement === 'start') { + return { paddingStart: 3, paddingEnd: 1 } as const; + } + // when media is placed at the end, we need to add additional padding to the end of the media container + // this is to avoid the dismiss button from overlapping with the media + return onDismiss + ? ({ paddingStart: 1, paddingEnd: 6 } as const) + : ({ paddingStart: 1, paddingEnd: 3 } as const); + }, [mediaPlacement, onDismiss, type]); + + return ( + + + + {tagNode} + {titleNode} + {descriptionNode} + + {actions} + + {media && ( + + {media} + + )} + {dismissButtonNode} + + ); + }, +); + +MessagingCardLayout.displayName = 'MessagingCardLayout'; diff --git a/packages/mobile/src/cards/MesssagingCard/index.tsx b/packages/mobile/src/cards/MesssagingCard/index.tsx new file mode 100644 index 000000000..1ba982966 --- /dev/null +++ b/packages/mobile/src/cards/MesssagingCard/index.tsx @@ -0,0 +1,71 @@ +import { forwardRef, memo } from 'react'; +import type { StyleProp, View, ViewStyle } from 'react-native'; +import type { ThemeVars } from '@coinbase/cds-common'; + +import { CardRoot, type CardRootProps } from '../CardRoot'; + +import { MessagingCardLayout, type MessagingCardLayoutProps } from './MessagingCardLayout'; + +export type MessagingCardBaseProps = MessagingCardLayoutProps; + +export type MessagingCardProps = Omit & + MessagingCardBaseProps & { + styles?: { + root?: StyleProp; + }; + }; + +const messagingCardContainerProps = { + borderRadius: 500 as ThemeVars.BorderRadius, + overflow: 'hidden' as const, +}; + +export const MessagingCard = memo( + forwardRef( + ( + { + type, + title, + description, + tag, + actions, + dismissButton, + onDismiss, + dismissButtonAccessibilityLabel, + mediaPlacement, + media, + styles: { root: rootStyle, ...layoutStyles } = {}, + ...props + }, + ref, + ) => { + const background = type === 'upsell' ? 'bgPrimary' : 'bgAlternate'; + return ( + + + + ); + }, + ), +); + +MessagingCard.displayName = 'MessagingCard'; diff --git a/packages/mobile/src/cards/__stories__/MediaCard.stories.tsx b/packages/mobile/src/cards/__stories__/MediaCard.stories.tsx new file mode 100644 index 000000000..4a78cebe8 --- /dev/null +++ b/packages/mobile/src/cards/__stories__/MediaCard.stories.tsx @@ -0,0 +1,184 @@ +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 = { + title: 'Title', + subtitle: 'Subtitle', + description: 'Description', + width: 320, +}; + +const exampleThumbnail = ( + +); + +const exampleMedia = ( + +); + +const MediaCardScreen = () => { + const ref = useRef(null); + return ( + + {/* Basic Examples */} + + + + + + + + + {/* Media Placement */} + + + + + + + + + {/* Text Content */} + + + + + + + Custom description with bold text and{' '} + italic text + + } + media={exampleMedia} + subtitle={Custom Subtitle} + thumbnail={exampleThumbnail} + title={Custom Title} + width={320} + /> + + + {/* Styling */} + + + + + + + + + {/* Interactive */} + + console.log('Card clicked!')} + subtitle="Button" + thumbnail={exampleThumbnail} + title="Interactive Card" + width={320} + /> + + + {/* Multiple Cards */} + + + + + + + + } + title="Bitcoin" + width={320} + /> + + + + + + + + ); +}; + +export default MediaCardScreen; diff --git a/packages/mobile/src/cards/__stories__/MessagingCard.stories.tsx b/packages/mobile/src/cards/__stories__/MessagingCard.stories.tsx new file mode 100644 index 000000000..e0c917f64 --- /dev/null +++ b/packages/mobile/src/cards/__stories__/MessagingCard.stories.tsx @@ -0,0 +1,347 @@ +import { useRef } from 'react'; +import { Alert, type View } from 'react-native'; +import { StyleSheet } from 'react-native'; +import { coinbaseOneLogo, svgs } from '@coinbase/cds-common/internal/data/assets'; +import { NoopFn } from '@coinbase/cds-common/utils/mockUtils'; + +import { Button } from '../../buttons/Button'; +import { IconButton } from '../../buttons/IconButton'; +import { Carousel } from '../../carousel/Carousel'; +import { CarouselItem } from '../../carousel/CarouselItem'; +import { Example, ExampleScreen } from '../../examples/ExampleScreen'; +import { Pictogram } from '../../illustrations'; +import { HStack } from '../../layout/HStack'; +import { VStack } from '../../layout/VStack'; +import { RemoteImage } from '../../media/RemoteImage'; +import { Text } from '../../typography/Text'; +import type { MessagingCardProps } from '../MesssagingCard'; +import { MessagingCard } from '../MesssagingCard'; + +const exampleProps: MessagingCardProps = { + title: 'Title', + description: 'Description', + mediaPlacement: 'end', + type: 'nudge', +} as const; + +const styles = StyleSheet.create({ + image: { + width: 130, + height: 174, + position: 'relative', + }, +}); + +const MessagingCardScreen = () => { + const ref = useRef(null); + return ( + + {/* Basic Types */} + + + + } + mediaPlacement="end" + title="Upsell Card" + type="upsell" + /> + + } + mediaPlacement="start" + title="Upsell Card" + type="upsell" + /> + } + mediaPlacement="end" + title="Nudge Card" + type="nudge" + /> + } + mediaPlacement="start" + title="Nudge Card" + type="nudge" + /> + + + + {/* Features */} + + + + } + mediaPlacement="end" + onDismiss={() => Alert.alert('Card dismissed!')} + title="Dismissible Card" + type="upsell" + /> + + } + mediaPlacement="end" + tag="New" + title="Tagged Card" + type="upsell" + /> + + Action + + } + description="Upsell card with action button" + media={ + + } + mediaPlacement="end" + title="Upsell with Action" + type="upsell" + /> + + Get Started + + } + description="Complete upsell card with all features" + dismissButtonAccessibilityLabel="Dismiss" + media={ + + } + mediaPlacement="end" + onDismiss={() => Alert.alert('Dismissed')} + tag="New" + title="Complete Upsell Card" + type="upsell" + /> + + Alert.alert('Custom dismiss pressed!')} + variant="secondary" + /> + + } + media={ + + } + mediaPlacement="end" + title="Custom Dismiss Button" + type="upsell" + /> + + + + {/* Interactive */} + + + } + mediaPlacement="end" + onPress={NoopFn} + title="Interactive Card" + type="upsell" + /> + + + {/* Text Content */} + + + + } + mediaPlacement="end" + title="This is a very long title text that demonstrates text wrapping" + type="upsell" + /> + + Custom description with bold text and{' '} + italic text + + } + media={ + + } + mediaPlacement="end" + tag={Custom Tag} + title={Custom Title} + type="upsell" + /> + + + + + + + + } + mediaPlacement="end" + title="Card 1" + type="upsell" + width={320} + /> + + + } + mediaPlacement="end" + onPress={NoopFn} + tag="Link" + title="Card 2" + type="nudge" + width={320} + /> + + + + } + mediaPlacement="end" + onPress={() => console.log('clicked')} + tag="Action" + title="Card 3" + type="upsell" + width={320} + /> + + + + + ); +}; + +export default MessagingCardScreen; diff --git a/packages/mobile/src/cards/index.ts b/packages/mobile/src/cards/index.ts index bfa55a19f..df1a81b90 100644 --- a/packages/mobile/src/cards/index.ts +++ b/packages/mobile/src/cards/index.ts @@ -4,9 +4,16 @@ export * from './CardFooter'; export * from './CardGroup'; export * from './CardHeader'; export * from './CardMedia'; +export * from './CardRoot'; // Card variants export * from './AnnouncementCard'; export * from './FeatureEntryCard'; export * from './FeedCard'; // Phoenix cards export * from './ContentCard'; +// Media card +export * from './MediaCard'; +// Messaging card +export * from './MesssagingCard'; +// Data card +export * from './DataCard'; diff --git a/packages/ui-mobile-playground/src/routes.ts b/packages/ui-mobile-playground/src/routes.ts index d5d4c026b..b0ca56a90 100644 --- a/packages/ui-mobile-playground/src/routes.ts +++ b/packages/ui-mobile-playground/src/routes.ts @@ -38,6 +38,12 @@ export const routes = [ getComponent: () => require('@coinbase/cds-mobile/overlays/__stories__/AlertVerticalActions.stories').default, }, + { + key: 'AlphaContentCard', + getComponent: () => + require('@coinbase/cds-mobile/alpha/content-card/__stories__/AlphaContentCard.stories') + .default, + }, { key: 'AlphaSelect', getComponent: () => @@ -195,6 +201,11 @@ export const routes = [ getComponent: () => require('@coinbase/cds-mobile/controls/__stories__/ControlGroup.stories').default, }, + { + key: 'DataCard', + getComponent: () => + require('@coinbase/cds-mobile/alpha/data-card/__stories__/DataCard.stories').default, + }, { key: 'DateInput', getComponent: () => require('@coinbase/cds-mobile/dates/__stories__/DateInput.stories').default, @@ -341,10 +352,19 @@ 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, }, + { + key: 'MessagingCard', + getComponent: () => + require('@coinbase/cds-mobile/cards/__stories__/MessagingCard.stories').default, + }, { key: 'ModalBackButton', getComponent: () => diff --git a/packages/ui-mobile-visreg/src/routes.ts b/packages/ui-mobile-visreg/src/routes.ts index d5d4c026b..b0ca56a90 100644 --- a/packages/ui-mobile-visreg/src/routes.ts +++ b/packages/ui-mobile-visreg/src/routes.ts @@ -38,6 +38,12 @@ export const routes = [ getComponent: () => require('@coinbase/cds-mobile/overlays/__stories__/AlertVerticalActions.stories').default, }, + { + key: 'AlphaContentCard', + getComponent: () => + require('@coinbase/cds-mobile/alpha/content-card/__stories__/AlphaContentCard.stories') + .default, + }, { key: 'AlphaSelect', getComponent: () => @@ -195,6 +201,11 @@ export const routes = [ getComponent: () => require('@coinbase/cds-mobile/controls/__stories__/ControlGroup.stories').default, }, + { + key: 'DataCard', + getComponent: () => + require('@coinbase/cds-mobile/alpha/data-card/__stories__/DataCard.stories').default, + }, { key: 'DateInput', getComponent: () => require('@coinbase/cds-mobile/dates/__stories__/DateInput.stories').default, @@ -341,10 +352,19 @@ 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, }, + { + key: 'MessagingCard', + getComponent: () => + require('@coinbase/cds-mobile/cards/__stories__/MessagingCard.stories').default, + }, { key: 'ModalBackButton', getComponent: () => diff --git a/packages/web-visualization/src/chart/line/__stories__/LineChart.stories.tsx b/packages/web-visualization/src/chart/line/__stories__/LineChart.stories.tsx index 12ded1762..bc4670b7c 100644 --- a/packages/web-visualization/src/chart/line/__stories__/LineChart.stories.tsx +++ b/packages/web-visualization/src/chart/line/__stories__/LineChart.stories.tsx @@ -1,10 +1,11 @@ import { forwardRef, memo, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; -import { assets } from '@coinbase/cds-common/internal/data/assets'; +import { assets, ethBackground } from '@coinbase/cds-common/internal/data/assets'; import { candles as btcCandles } from '@coinbase/cds-common/internal/data/candles'; import { prices } from '@coinbase/cds-common/internal/data/prices'; import { sparklineInteractiveData } from '@coinbase/cds-common/internal/visualizations/SparklineInteractiveData'; import { useTabsContext } from '@coinbase/cds-common/tabs/TabsContext'; import type { TabValue } from '@coinbase/cds-common/tabs/useTabs'; +import { DataCard } from '@coinbase/cds-web/alpha/data-card/DataCard'; import { ListCell } from '@coinbase/cds-web/cells'; import { useBreakpoints } from '@coinbase/cds-web/hooks/useBreakpoints'; import { Box, HStack, VStack } from '@coinbase/cds-web/layout'; @@ -1934,6 +1935,115 @@ export const All = () => { + + + ); }; + +function DataCardWithLineChart() { + const exampleThumbnail = ( + + ); + + const getLineChartSeries = () => [ + { + id: 'price', + data: prices.slice(0, 30).map((price: string) => parseFloat(price)), + color: 'var(--color-accentBoldBlue)', + }, + ]; + + const lineChartSeries = useMemo(() => getLineChartSeries(), []); + const lineChartSeries2 = useMemo(() => getLineChartSeries(), []); + const ref = useRef(null); + + return ( + + + + + + + + + + + + + } + title="Card with Line Chart" + > + + + + ); +} diff --git a/packages/web/src/alpha/content-card/ContentCard.tsx b/packages/web/src/alpha/content-card/ContentCard.tsx new file mode 100644 index 000000000..ae8201c9f --- /dev/null +++ b/packages/web/src/alpha/content-card/ContentCard.tsx @@ -0,0 +1,69 @@ +import React, { forwardRef, memo } from 'react'; + +import type { Polymorphic } from '../../core/polymorphism'; +import { VStack } from '../../layout/VStack'; +import { Pressable, type PressableBaseProps } from '../../system/Pressable'; + +export type ContentCardBaseProps = Polymorphic.ExtendableProps< + PressableBaseProps, + { + children?: React.ReactNode; + renderAsPressable?: boolean; + } +>; + +export type ContentCardProps = Polymorphic.Props< + AsComponent, + ContentCardBaseProps +>; + +type ContentCardComponent = (( + props: Polymorphic.Props, +) => Polymorphic.ReactReturn) & + Polymorphic.ReactNamed; + +export const ContentCard: ContentCardComponent = memo( + forwardRef, ContentCardBaseProps>( + ( + { + renderAsPressable, + children, + background = 'bg', + borderRadius = 500, + ...props + }: ContentCardProps, + ref?: Polymorphic.Ref, + ) => { + if (renderAsPressable) { + const { as, ...pressableRestProps } = props; + return ( + + {children} + + ); + } else { + const { as, ...vstackRestProps } = props; + return ( + + {children} + + ); + } + }, + ), +); + +ContentCard.displayName = 'ContentCard'; diff --git a/packages/web/src/alpha/content-card/ContentCardBody.tsx b/packages/web/src/alpha/content-card/ContentCardBody.tsx new file mode 100644 index 000000000..f575a80e8 --- /dev/null +++ b/packages/web/src/alpha/content-card/ContentCardBody.tsx @@ -0,0 +1,137 @@ +import { forwardRef, memo, useMemo } from 'react'; + +import type { Polymorphic } from '../../core/polymorphism'; +import { cx } from '../../cx'; +import { Box, type BoxBaseProps, VStack } from '../../layout'; +import { Text } from '../../typography'; + +export type ContentCardBodyBaseProps = Polymorphic.ExtendableProps< + BoxBaseProps, + { + title: React.ReactNode; + description?: React.ReactNode; + media?: React.ReactNode; + mediaPlacement?: 'top' | 'bottom' | 'start' | 'end'; + styles?: { + root: React.CSSProperties; + contentContainer: React.CSSProperties; + mediaContainer: React.CSSProperties; + }; + classNames?: { + root: string; + contentContainer: string; + mediaContainer: string; + }; + } +>; + +export type ContentCardBodyProps = Polymorphic.Props< + AsComponent, + ContentCardBodyBaseProps +>; + +type ContentCardBodyComponent = (( + props: ContentCardBodyProps, +) => Polymorphic.ReactReturn) & + Polymorphic.ReactNamed; + +export const ContentCardBody: ContentCardBodyComponent = memo( + forwardRef, ContentCardBodyBaseProps>( + ( + { + as, + title, + description, + media, + mediaPlacement = 'top', + style, + styles, + classNames, + className, + padding = 2, + ...props + }: ContentCardBodyProps, + ref?: Polymorphic.Ref, + ) => { + const Component = (as ?? 'div') satisfies React.ElementType; + const hasMedia = !!media; + const isHorizontal = hasMedia && (mediaPlacement === 'start' || mediaPlacement === 'end'); + const isMediaFirst = hasMedia && (mediaPlacement === 'top' || mediaPlacement === 'start'); + + const titleNode = useMemo(() => { + if (typeof title === 'string') { + return ( + + {title} + + ); + } + return title; + }, [title]); + + const descriptionNode = useMemo(() => { + if (typeof description === 'string') { + return ( + + {description} + + ); + } + return description; + }, [description]); + + const contentNode = useMemo(() => { + return ( + + {titleNode} + {descriptionNode} + + ); + }, [ + isHorizontal, + classNames?.contentContainer, + styles?.contentContainer, + titleNode, + descriptionNode, + ]); + + const mediaNode = useMemo(() => { + if (!hasMedia) return null; + return ( + + {media} + + ); + }, [hasMedia, media, classNames?.mediaContainer, styles?.mediaContainer]); + + return ( + + {isMediaFirst ? mediaNode : contentNode} + {isMediaFirst ? contentNode : mediaNode} + + ); + }, + ), +); + +ContentCardBody.displayName = 'ContentCardBody'; diff --git a/packages/web/src/alpha/content-card/ContentCardFooter.tsx b/packages/web/src/alpha/content-card/ContentCardFooter.tsx new file mode 100644 index 000000000..f4bc7b9cc --- /dev/null +++ b/packages/web/src/alpha/content-card/ContentCardFooter.tsx @@ -0,0 +1,36 @@ +import { forwardRef, memo } from 'react'; + +import type { Polymorphic } from '../../core/polymorphism'; +import { HStack, type HStackBaseProps } from '../../layout'; + +export type ContentCardFooterBaseProps = HStackBaseProps; + +export type ContentCardFooterProps = Polymorphic.Props< + AsComponent, + ContentCardFooterBaseProps +>; + +type ContentCardFooterComponent = (( + props: ContentCardFooterProps, +) => Polymorphic.ReactReturn) & + Polymorphic.ReactNamed; + +export const ContentCardFooter: ContentCardFooterComponent = memo( + forwardRef, ContentCardFooterBaseProps>( + ( + { as, paddingX = 2, paddingBottom = 2, ...props }: ContentCardFooterProps, + ref?: Polymorphic.Ref, + ) => { + const Component = (as ?? 'footer') satisfies React.ElementType; + return ( + + ); + }, + ), +); diff --git a/packages/web/src/alpha/content-card/ContentCardHeader.tsx b/packages/web/src/alpha/content-card/ContentCardHeader.tsx new file mode 100644 index 000000000..0b2616cf7 --- /dev/null +++ b/packages/web/src/alpha/content-card/ContentCardHeader.tsx @@ -0,0 +1,107 @@ +import React, { forwardRef, memo, useMemo } from 'react'; + +import type { Polymorphic } from '../../core/polymorphism'; +import { cx } from '../../cx'; +import { type BoxBaseProps, HStack, VStack, type VStackBaseProps } from '../../layout'; +import { Text } from '../../typography'; + +export type ContentCardHeaderBaseProps = Polymorphic.ExtendableProps< + BoxBaseProps, + { + title: React.ReactNode; + subtitle: React.ReactNode; + thumbnail: React.ReactNode; + action: React.ReactNode; + styles?: { + root: React.CSSProperties; + contentContainer: React.CSSProperties; + }; + classNames?: { + root: string; + contentContainer: string; + }; + } +>; + +export type ContentCardHeaderProps = Polymorphic.Props< + AsComponent, + ContentCardHeaderBaseProps +>; + +type ContentCardHeaderComponent = (( + props: ContentCardHeaderProps, +) => Polymorphic.ReactReturn) & + Polymorphic.ReactNamed; + +export const ContentCardHeader: ContentCardHeaderComponent = memo( + forwardRef, ContentCardHeaderBaseProps>( + ( + { + as, + title, + subtitle, + thumbnail, + action, + gap = 1.5, + paddingX = 2, + paddingTop = 2, + styles, + style, + classNames, + className, + ...props + }: ContentCardHeaderProps, + ref?: Polymorphic.Ref, + ) => { + const titleNode = useMemo(() => { + if (typeof title === 'string') { + return ( + + {title} + + ); + } + return title; + }, [title]); + + const subtitleNode = useMemo(() => { + if (typeof subtitle === 'string') { + return ( + + {subtitle} + + ); + } + return subtitle; + }, [subtitle]); + + const Component = (as ?? 'header') satisfies React.ElementType; + + return ( + + {thumbnail} + + {titleNode} + {subtitleNode} + + {action} + + ); + }, + ), +); diff --git a/packages/web/src/alpha/content-card/__stories__/ContentCard.stories.tsx b/packages/web/src/alpha/content-card/__stories__/ContentCard.stories.tsx new file mode 100644 index 000000000..49698f57c --- /dev/null +++ b/packages/web/src/alpha/content-card/__stories__/ContentCard.stories.tsx @@ -0,0 +1,477 @@ +import React from 'react'; +import { ethBackground } from '@coinbase/cds-common/internal/data/assets'; + +import { Button, IconButton } from '../../../buttons'; +import { HStack, VStack } from '../../../layout'; +import { RemoteImage } from '../../../media'; +import { ContentCard, ContentCardBody, ContentCardFooter, ContentCardHeader } from '../index'; + +const exampleThumbnail = ( + +); + +const exampleMedia = ( + + } + subtitle="bgAlternate" + thumbnail={exampleThumbnail} + title="Content Card" + /> + + + + + + + ); +}; + +export const Interactive = (): JSX.Element => { + return ( + + alert('Card clicked!')} width={327}> + + + + + } + subtitle="Subtitle" + thumbnail={exampleThumbnail} + title="Title" + /> + + + + + + + + + + + } + subtitle="Subtitle" + thumbnail={exampleThumbnail} + title="Title" + /> + + + + + + + ); +}; + +export const MediaPositions = (): JSX.Element => { + return ( + + + + + + + + + + + + + + + ); +}; + +export const WithoutHeader = (): JSX.Element => { + return ( + + + + + + + + + ); +}; + +export const WithoutFooter = (): JSX.Element => { + return ( + + + + + + + } + subtitle="Subtitle" + thumbnail={exampleThumbnail} + title="Title" + /> + + + + ); +}; + +export const WithoutMedia = (): JSX.Element => { + return ( + + + + + + + } + subtitle="Subtitle" + thumbnail={exampleThumbnail} + title="Title" + /> + } + mediaPlacement="top" + title="No Media Card" + /> + + + + + + ); +}; + +export const CustomContent = (): JSX.Element => { + return ( + + + + + + + } + subtitle={Custom Subtitle} + thumbnail={exampleThumbnail} + title={Custom Title} + /> + + Custom description with bold text and italic text + + } + media={exampleMedia} + mediaPlacement="top" + title={Custom Title Content} + /> + + + + + + ); +}; + +export const MultipleCards = (): JSX.Element => { + return ( + + + + + + + } + subtitle="Subtitle" + thumbnail={exampleThumbnail} + title="Title" + /> + + + + + + + + + + + } + subtitle="Subtitle" + thumbnail={exampleThumbnail} + title="Title" + /> + + + + + + alert('Card 3 clicked!')} width={327}> + + + + + } + subtitle="Subtitle" + thumbnail={exampleThumbnail} + title="Title" + /> + + + + + + + + + + + } + subtitle="Subtitle" + thumbnail={exampleThumbnail} + title="Title" + /> + + + + + + + + + + + } + subtitle="Subtitle" + thumbnail={exampleThumbnail} + title="Title" + /> + + + + + + + ); +}; + +export const LongContent = (): JSX.Element => { + return ( + + + + + + + } + subtitle="This is a very long subtitle text that demonstrates how the header handles longer content" + thumbnail={exampleThumbnail} + title="This is a very long title text that demonstrates how the header handles longer content" + /> + + + + + + + ); +}; + +export default { + title: 'Components/Alpha/ContentCard', + component: ContentCard, +}; diff --git a/packages/web/src/alpha/content-card/index.tsx b/packages/web/src/alpha/content-card/index.tsx new file mode 100644 index 000000000..4f5303cb0 --- /dev/null +++ b/packages/web/src/alpha/content-card/index.tsx @@ -0,0 +1,4 @@ +export * from './ContentCard'; +export * from './ContentCardBody'; +export * from './ContentCardFooter'; +export * from './ContentCardHeader'; diff --git a/packages/web/src/alpha/data-card/DataCard.tsx b/packages/web/src/alpha/data-card/DataCard.tsx new file mode 100644 index 000000000..76a11fbb2 --- /dev/null +++ b/packages/web/src/alpha/data-card/DataCard.tsx @@ -0,0 +1,82 @@ +import { forwardRef, memo } from 'react'; +import type { ThemeVars } from '@coinbase/cds-common'; + +import { CardRoot, type CardRootBaseProps } from '../../cards/CardRoot'; +import type { Polymorphic } from '../../core/polymorphism'; +import { cx } from '../../cx'; + +import { DataCardLayout, type DataCardLayoutProps } from './DataCardLayout'; + +export type DataCardBaseProps = Polymorphic.ExtendableProps< + Omit, + DataCardLayoutProps & { + classNames?: { + root?: string; + }; + styles?: { + root?: React.CSSProperties; + }; + } +>; + +export type DataCardProps = Polymorphic.Props< + AsComponent, + DataCardBaseProps +>; + +type DataCardComponent = (( + props: DataCardProps, +) => Polymorphic.ReactReturn) & + Polymorphic.ReactNamed; + +const dataCardContainerProps = { + borderRadius: 500 as ThemeVars.BorderRadius, + flexDirection: 'row' as const, + background: 'bgAlternate' as ThemeVars.Color, + overflow: 'hidden' as const, +}; + +export const DataCard: DataCardComponent = memo( + forwardRef, DataCardBaseProps>( + ( + { + title, + subtitle, + tag, + thumbnail, + visualization, + layout, + slotProps, + as, + children, + className, + style, + classNames: { root: rootClassName, ...layoutClassNames } = {}, + styles: { root: rootStyle, ...layoutStyles } = {}, + ...props + }: DataCardProps, + ref?: Polymorphic.Ref, + ) => ( + + + {children} + + + ), + ), +); diff --git a/packages/web/src/alpha/data-card/DataCardLayout.tsx b/packages/web/src/alpha/data-card/DataCardLayout.tsx new file mode 100644 index 000000000..03dd693cb --- /dev/null +++ b/packages/web/src/alpha/data-card/DataCardLayout.tsx @@ -0,0 +1,125 @@ +import React, { memo, useMemo } from 'react'; + +import { Box, HStack, VStack } from '../../layout'; +import { Tag } from '../../tag/Tag'; +import { Text } from '../../typography'; + +export type DataCardLayoutBaseProps = { + /** 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 a tag. When a string is provided, it will be rendered in a Tag component. */ + tag?: React.ReactNode; + /** React node to display as a thumbnail in the header area. */ + thumbnail?: React.ReactNode; + /** Layout orientation of the card. Horizontal places header and visualization side by side, vertical stacks them. */ + layout: 'horizontal' | 'vertical'; + /** child node to display as the visualization (e.g., ProgressBar or ProgressCircle). */ + children?: React.ReactNode; +}; + +export type DataCardLayoutProps = DataCardLayoutBaseProps & { + classNames?: { + layoutContainer?: string; + headerContainer?: string; + headerContent?: string; + titleContainer?: string; + }; + styles?: { + layoutContainer?: React.CSSProperties; + headerContainer?: React.CSSProperties; + headerContent?: React.CSSProperties; + titleContainer?: React.CSSProperties; + }; +}; + +export const DataCardLayout = memo( + ({ + title, + subtitle, + tag, + thumbnail, + layout = 'vertical', + classNames = {}, + styles = {}, + children, + }: DataCardLayoutProps) => { + const titleNode = useMemo(() => { + if (typeof title === 'string') { + return ( + + {title} + + ); + } + return title; + }, [title]); + + const subtitleNode = useMemo(() => { + if (typeof subtitle === 'string') { + return ( + + {subtitle} + + ); + } + return subtitle; + }, [subtitle]); + + const tagNode = useMemo(() => { + if (typeof tag === 'string') return {tag}; + return tag; + }, [tag]); + + const layoutContainerSpacingProps = useMemo(() => { + return { + flexDirection: layout === 'horizontal' ? 'row' : 'column', + gap: layout === 'horizontal' ? 2 : 1, + padding: 2, + } as const; + }, [layout]); + + const headerSpacingProps = useMemo(() => { + return { + flexDirection: layout === 'horizontal' ? 'column' : 'row', + gap: layout === 'horizontal' ? 1.5 : 1, + alignItems: layout === 'horizontal' ? 'flex-start' : 'center', + } as const; + }, [layout]); + + return ( + + + {thumbnail} + + + {tagNode} + {titleNode} + + {subtitleNode} + + + {children} + + ); + }, +); diff --git a/packages/web/src/alpha/data-card/__stories__/DataCard.stories.tsx b/packages/web/src/alpha/data-card/__stories__/DataCard.stories.tsx new file mode 100644 index 000000000..ac01bf871 --- /dev/null +++ b/packages/web/src/alpha/data-card/__stories__/DataCard.stories.tsx @@ -0,0 +1,262 @@ +import React, { useRef } from 'react'; +import { ethBackground } from '@coinbase/cds-common/internal/data/assets'; +import { + ProgressBar, + ProgressBarWithFixedLabels, + ProgressCircle, +} from '@coinbase/cds-web/visualizations'; + +import { Box } from '../../../layout/Box'; +import { VStack } from '../../../layout/VStack'; +import { RemoteImage } from '../../../media'; +import { DataCard } from '../DataCard'; + +const exampleThumbnail = ( + +); + +// Basic Examples +export const BasicExamples = (): JSX.Element => { + return ( + + + + + + + + + + + + + + + ); +}; + +// Features +export const Features = (): JSX.Element => { + return ( + + + + + + + + + + + + + + + + + + + + ); +}; + +// Interactive +export const Interactive = (): JSX.Element => { + const ref1 = useRef(null); + const ref2 = useRef(null); + return ( + + alert('Progress bar card clicked!')} + subtitle="Clickable progress card" + tag="Action" + thumbnail={exampleThumbnail} + title="Actionable Progress Bar" + > + + + + + + + + + + + + + ); +}; + +// Style Overrides +export const StyleOverrides = (): JSX.Element => { + return ( + + + + + + + + + + + + + + + + + + + + + + ); +}; + +// Multiple Cards +export const MultipleCards = (): JSX.Element => { + return ( + + + + + + + + + + + + + + + ); +}; + +export default { + title: 'Components/Alpha/DataCard', + component: DataCard, +}; diff --git a/packages/web/src/cards/CardRemoteImage.tsx b/packages/web/src/cards/CardRemoteImage.tsx deleted file mode 100644 index b4cf7edb0..000000000 --- a/packages/web/src/cards/CardRemoteImage.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import React, { memo } from 'react'; -import type { CardRemoteImageProps } from '@coinbase/cds-common/types'; - -import { RemoteImage } from '../media/RemoteImage'; - -export type { CardRemoteImageProps }; - -/** - * @deprecated render a instead - */ -export const CardRemoteImage = memo(function CardRemoteImage({ - src, - alt = '', - ...props -}: CardRemoteImageProps) { - return ; -}); diff --git a/packages/web/src/cards/CardRoot.tsx b/packages/web/src/cards/CardRoot.tsx new file mode 100644 index 000000000..303f33b8c --- /dev/null +++ b/packages/web/src/cards/CardRoot.tsx @@ -0,0 +1,50 @@ +import React, { forwardRef, memo } from 'react'; + +import type { Polymorphic } from '../core/polymorphism'; +import { HStack } from '../layout/HStack'; +import { Pressable, type PressableBaseProps } from '../system/Pressable'; + +export type CardRootBaseProps = Polymorphic.ExtendableProps< + PressableBaseProps, + { + children?: React.ReactNode; + renderAsPressable?: boolean; + } +>; + +export type CardRootProps = Polymorphic.Props< + AsComponent, + CardRootBaseProps +>; + +type CardRootComponent = (( + props: Polymorphic.Props, +) => Polymorphic.ReactReturn) & + Polymorphic.ReactNamed; + +export const CardRoot: CardRootComponent = memo( + forwardRef, CardRootBaseProps>( + ( + { renderAsPressable, children, ...props }: CardRootProps, + ref?: Polymorphic.Ref, + ) => { + if (renderAsPressable) { + const { as, ...pressableRestProps } = props; + return ( + + {children} + + ); + } else { + const { as, ...hstackRestProps } = props; + return ( + + {children} + + ); + } + }, + ), +); + +CardRoot.displayName = 'CardRoot'; diff --git a/packages/web/src/cards/MediaCard/MediaCardLayout.tsx b/packages/web/src/cards/MediaCard/MediaCardLayout.tsx new file mode 100644 index 000000000..833aece57 --- /dev/null +++ b/packages/web/src/cards/MediaCard/MediaCardLayout.tsx @@ -0,0 +1,145 @@ +import React, { memo, useMemo } from 'react'; + +import { Box } from '../../layout'; +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 a Box 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 & { + classNames?: { + layoutContainer?: string; + contentContainer?: string; + textContainer?: string; + headerContainer?: string; + mediaContainer?: string; + }; + styles?: { + layoutContainer?: React.CSSProperties; + contentContainer?: React.CSSProperties; + textContainer?: React.CSSProperties; + headerContainer?: React.CSSProperties; + mediaContainer?: React.CSSProperties; + }; +}; + +export const MediaCardLayout = memo( + ({ + title, + subtitle, + description, + thumbnail, + media, + mediaPlacement = 'end', + classNames = {}, + styles = {}, + }: MediaCardLayoutProps) => { + const titleNode = useMemo(() => { + if (typeof title === 'string') { + return ( + + {title} + + ); + } + return title; + }, [title]); + + const subtitleNode = useMemo( + () => + typeof subtitle === 'string' ? ( + + {subtitle} + + ) : ( + subtitle + ), + [subtitle], + ); + + const headerNode = useMemo( + () => ( + + {subtitleNode} + {titleNode} + + ), + [subtitleNode, titleNode, styles?.headerContainer, classNames?.headerContainer], + ); + + const descriptionNode = useMemo( + () => + typeof description === 'string' ? ( + + {description} + + ) : ( + description + ), + [description], + ); + + const contentNode = useMemo( + () => ( + + {thumbnail} + + {headerNode} + {descriptionNode} + + + ), + [ + thumbnail, + headerNode, + descriptionNode, + styles?.contentContainer, + classNames?.contentContainer, + classNames?.textContainer, + styles?.textContainer, + ], + ); + + const mediaNode = useMemo(() => { + if (media) { + return ( + + {media} + + ); + } + }, [media, styles?.mediaContainer, classNames?.mediaContainer]); + + return ( + + {mediaPlacement === 'start' ? mediaNode : contentNode} + {mediaPlacement === 'end' ? mediaNode : contentNode} + + ); + }, +); diff --git a/packages/web/src/cards/MediaCard/index.tsx b/packages/web/src/cards/MediaCard/index.tsx new file mode 100644 index 000000000..13770e09d --- /dev/null +++ b/packages/web/src/cards/MediaCard/index.tsx @@ -0,0 +1,82 @@ +import React, { forwardRef, memo } from 'react'; +import type { ThemeVars } from '@coinbase/cds-common'; + +import type { Polymorphic } from '../../core/polymorphism'; +import { CardRoot, type CardRootBaseProps } from '../CardRoot'; + +import { MediaCardLayout, type MediaCardLayoutProps } from './MediaCardLayout'; +import { cx } from '../../cx'; + +export type MediaCardBaseProps = Polymorphic.ExtendableProps< + Omit, + MediaCardLayoutProps & { + classNames?: { + root?: string; + }; + styles?: { + root?: React.CSSProperties; + }; + } +>; + +export type MediaCardProps = Polymorphic.Props< + AsComponent, + MediaCardBaseProps +>; + +const mediaCardContainerProps = { + borderRadius: 500 as ThemeVars.BorderRadius, + flexDirection: 'row' as const, + background: 'bgAlternate' as ThemeVars.Color, + overflow: 'hidden' as const, +}; + +type MediaCardComponent = (( + props: MediaCardProps, +) => Polymorphic.ReactReturn) & + Polymorphic.ReactNamed; + +export const MediaCard: MediaCardComponent = memo( + forwardRef, MediaCardBaseProps>( + ( + { + title, + subtitle, + description, + thumbnail, + media, + children, + mediaPlacement = 'end', + as, + classNames: { root: rootClassName, ...layoutClassNames } = {}, + styles: { root: rootStyle, ...layoutStyles } = {}, + className, + style, + ...props + }: MediaCardProps, + ref?: Polymorphic.Ref, + ) => ( + + + + ), + ), +); + +export { MediaCardLayout }; diff --git a/packages/web/src/cards/MessagingCard/MessagingCardLayout.tsx b/packages/web/src/cards/MessagingCard/MessagingCardLayout.tsx new file mode 100644 index 000000000..ccdeec180 --- /dev/null +++ b/packages/web/src/cards/MessagingCard/MessagingCardLayout.tsx @@ -0,0 +1,207 @@ +import { memo, useMemo } from 'react'; + +import { IconButton } from '../../buttons/IconButton'; +import { Box, VStack } from '../../layout'; +import { HStack } from '../../layout/HStack'; +import { Tag } from '../../tag/Tag'; +import { Text } from '../../typography/Text'; + +export type MessagingCardLayoutProps = { + /** Type of messaging card. Determines background color and text color. */ + type: 'upsell' | 'nudge'; + /** Text or React node to display as the card title. When a string is provided, it will be rendered in a CardTitle component with appropriate color based on type. */ + title?: React.ReactNode; + /** Text or React node to display as the card subtitle. */ + 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 with appropriate color based on type. */ + description?: React.ReactNode; + /** Text or React node to display as a tag. When a string is provided, it will be rendered in a Tag component. */ + tag?: React.ReactNode; + /** React node to display as actions (typically buttons) at the bottom of the content area. */ + actions?: React.ReactNode; + /** React node to display as the dismiss button. When provided, a dismiss button will be rendered in the top-right corner. */ + dismissButton?: React.ReactNode; + /** Callback fired when the dismiss button is pressed. When provided, a dismiss button will be rendered in the top-right corner. */ + onDismiss?: (event: React.MouseEvent) => void; + /** Accessibility label for the dismiss button. + * @default 'Dismiss card' + */ + dismissButtonAccessibilityLabel?: string; + /** Placement of the media content relative to the text content. */ + mediaPlacement: 'start' | 'end'; + /** React node to display as the main media content. When provided, it will be rendered in a Box container. */ + media?: React.ReactNode; + styles?: { + layoutContainer?: React.CSSProperties; + contentContainer?: React.CSSProperties; + textContainer?: React.CSSProperties; + mediaContainer?: React.CSSProperties; + dismissButtonContainer?: React.CSSProperties; + }; + classNames?: { + layoutContainer?: string; + contentContainer?: string; + textContainer?: string; + mediaContainer?: string; + dismissButtonContainer?: string; + }; +}; + +export const MessagingCardLayout = memo( + ({ + type, + title, + description, + tag, + actions, + onDismiss, + dismissButtonAccessibilityLabel = 'Dismiss card', + mediaPlacement = 'end', + media, + styles = {}, + classNames = {}, + dismissButton, + }: MessagingCardLayoutProps) => { + const titleNode = useMemo(() => { + if (typeof title === 'string') { + return ( + + {title} + + ); + } + return title; + }, [title, type]); + + const descriptionNode = useMemo(() => { + if (typeof description === 'string') { + return ( + + {description} + + ); + } + return description; + }, [description, type]); + + const tagNode = useMemo(() => { + if (typeof tag === 'string') { + return {tag}; + } + return tag; + }, [tag]); + + const dismissButtonNode = useMemo(() => { + if (dismissButton) { + return dismissButton; + } + if (onDismiss) { + const handleDismiss = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onDismiss(event); + }; + + return ( + + + + ); + } + return null; + }, [ + classNames?.dismissButtonContainer, + dismissButton, + dismissButtonAccessibilityLabel, + onDismiss, + styles?.dismissButtonContainer, + ]); + + const contentContainerPaddingProps = useMemo(() => { + if (mediaPlacement === 'start' && onDismiss) { + // needs to add additional padding to the end of the content area when media is placed at the start and there is a dismiss button + // this is to avoid dismiss button from overlapping with the content area + return { + paddingY: 2, + paddingStart: 2, + paddingEnd: 6, + } as const; + } + return { + padding: 2, + } as const; + }, [mediaPlacement, onDismiss]); + + const mediaContainerPaddingProps = useMemo(() => { + if (type === 'upsell') return; + if (mediaPlacement === 'start') { + return { paddingStart: 3, paddingEnd: 1 } as const; + } + // when media is placed at the end, we need to add additional padding to the end of the media container + // this is to avoid the dismiss button from overlapping with the media + return onDismiss + ? ({ paddingStart: 1, paddingEnd: 6 } as const) + : ({ paddingStart: 1, paddingEnd: 3 } as const); + }, [mediaPlacement, onDismiss, type]); + + return ( + + + + {tagNode} + {titleNode} + {descriptionNode} + + {actions} + + + {media} + + {dismissButtonNode} + + ); + }, +); diff --git a/packages/web/src/cards/MessagingCard/index.tsx b/packages/web/src/cards/MessagingCard/index.tsx new file mode 100644 index 000000000..cf2e2d7ba --- /dev/null +++ b/packages/web/src/cards/MessagingCard/index.tsx @@ -0,0 +1,83 @@ +import { forwardRef, memo } from 'react'; + +import type { Polymorphic } from '../../core/polymorphism'; +import { cx } from '../../cx'; +import { CardRoot, type CardRootBaseProps } from '../CardRoot'; + +import { MessagingCardLayout, type MessagingCardLayoutProps } from './MessagingCardLayout'; + +export * from './MessagingCardLayout'; + +export type MessagingCardBaseProps = Polymorphic.ExtendableProps< + Omit, + MessagingCardLayoutProps & { + classNames?: { + root?: string; + }; + styles?: { + root?: React.CSSProperties; + }; + } +>; + +export type MessagingCardProps = + Polymorphic.Props; + +type MessagingCardComponent = (( + props: MessagingCardProps, +) => Polymorphic.ReactReturn) & + Polymorphic.ReactNamed; + +export const MessagingCard: MessagingCardComponent = memo( + forwardRef, MessagingCardBaseProps>( + ( + { + as, + type, + title, + description, + tag, + actions, + onDismiss, + dismissButtonAccessibilityLabel, + mediaPlacement, + media, + slotProps, + dismissButton, + styles: { root: rootStyle, ...layoutStyles } = {}, + classNames: { root: rootClassName, ...layoutClassNames } = {}, + className, + style, + ...props + }: MessagingCardProps, + ref?: Polymorphic.Ref, + ) => ( + + + + ), + ), +); diff --git a/packages/web/src/cards/__stories__/MediaCard.stories.tsx b/packages/web/src/cards/__stories__/MediaCard.stories.tsx new file mode 100644 index 000000000..1bd3e7895 --- /dev/null +++ b/packages/web/src/cards/__stories__/MediaCard.stories.tsx @@ -0,0 +1,234 @@ +import React, { useRef } from 'react'; +import { assets, ethBackground } from '@coinbase/cds-common/internal/data/assets'; + +import { Carousel } from '../../carousel/Carousel'; +import { CarouselItem } from '../../carousel/CarouselItem'; +import { VStack } from '../../layout/VStack'; +import { RemoteImage } from '../../media/RemoteImage'; +import { TextHeadline, TextLabel2, TextTitle3 } from '../../typography'; +import { MediaCard } from '../MediaCard'; + +const exampleProps = { + title: 'Title', + subtitle: 'Subtitle', + description: 'Description', + width: 320, +} as const; + +const exampleThumbnail = ( +