Skip to content

Commit a5e53aa

Browse files
feat: DataCard
1 parent 5b77c22 commit a5e53aa

File tree

13 files changed

+1131
-12
lines changed

13 files changed

+1131
-12
lines changed

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,11 @@ export const routes = [
195195
getComponent: () =>
196196
require('@coinbase/cds-mobile/controls/__stories__/ControlGroup.stories').default,
197197
},
198+
{
199+
key: 'DataCard',
200+
getComponent: () =>
201+
require('@coinbase/cds-mobile/alpha/data-card/__stories__/DataCard.stories').default,
202+
},
198203
{
199204
key: 'DateInput',
200205
getComponent: () => require('@coinbase/cds-mobile/dates/__stories__/DateInput.stories').default,

apps/mobile-app/src/routes.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,11 @@ export const routes = [
200200
getComponent: () =>
201201
require('@coinbase/cds-mobile/controls/__stories__/ControlGroup.stories').default,
202202
},
203+
{
204+
key: 'DataCard',
205+
getComponent: () =>
206+
require('@coinbase/cds-mobile/alpha/data-card/__stories__/DataCard.stories').default,
207+
},
203208
{
204209
key: 'DateInput',
205210
getComponent: () => require('@coinbase/cds-mobile/dates/__stories__/DateInput.stories').default,

packages/mobile-visualization/src/chart/line/__stories__/LineChart.stories.tsx

Lines changed: 114 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,21 @@ import {
88
withTiming,
99
} from 'react-native-reanimated';
1010
import type { ThemeVars } from '@coinbase/cds-common/core/theme';
11-
import { assets } from '@coinbase/cds-common/internal/data/assets';
11+
import { assets, ethBackground } from '@coinbase/cds-common/internal/data/assets';
1212
import { candles as btcCandles } from '@coinbase/cds-common/internal/data/candles';
1313
import { prices } from '@coinbase/cds-common/internal/data/prices';
1414
import { sparklineInteractiveData } from '@coinbase/cds-common/internal/visualizations/SparklineInteractiveData';
1515
import { useTabsContext } from '@coinbase/cds-common/tabs/TabsContext';
1616
import type { TabValue } from '@coinbase/cds-common/tabs/useTabs';
17+
import { NoopFn } from '@coinbase/cds-common/utils/mockUtils';
1718
import { useTheme } from '@coinbase/cds-mobile';
19+
import { DataCard } from '@coinbase/cds-mobile/alpha/data-card/DataCard';
1820
import { Button, IconButton } from '@coinbase/cds-mobile/buttons';
1921
import { ListCell } from '@coinbase/cds-mobile/cells';
20-
import { Example, ExampleScreen } from '@coinbase/cds-mobile/examples/ExampleScreen';
22+
import { ExampleScreen } from '@coinbase/cds-mobile/examples/ExampleScreen';
2123
import { Box, type BoxBaseProps, HStack, VStack } from '@coinbase/cds-mobile/layout';
2224
import { Avatar, RemoteImage } from '@coinbase/cds-mobile/media';
25+
import { NavigationTitleSelect } from '@coinbase/cds-mobile/navigation';
2326
import { SectionHeader } from '@coinbase/cds-mobile/section-header/SectionHeader';
2427
import { Pressable } from '@coinbase/cds-mobile/system';
2528
import { type TabComponent, type TabsActiveIndicatorProps } from '@coinbase/cds-mobile/tabs';
@@ -38,7 +41,7 @@ import {
3841

3942
import { Area, DottedArea, type DottedAreaProps } from '../../area';
4043
import { DefaultAxisTickLabel, XAxis, YAxis } from '../../axis';
41-
import { BarChart, type BarComponentProps, BarPlot } from '../../bar';
44+
import { type BarComponentProps, BarPlot } from '../../bar';
4245
import { CartesianChart } from '../../CartesianChart';
4346
import { useCartesianChartContext } from '../../ChartProvider';
4447
import { PeriodSelector, PeriodSelectorActiveIndicator } from '../../PeriodSelector';
@@ -2281,6 +2284,106 @@ function TwoLineScrubberLabel() {
22812284
</VStack>
22822285
);
22832286
}
2287+
function DataCardWithLineChart() {
2288+
const exampleThumbnail = (
2289+
<RemoteImage accessibilityLabel="Ethereum" source={ethBackground} testID="thumbnail" />
2290+
);
2291+
2292+
const getLineChartSeries = () => [
2293+
{
2294+
id: 'price',
2295+
data: prices.slice(0, 30).map((price: string) => parseFloat(price)),
2296+
color: 'accentBoldBlue',
2297+
},
2298+
];
2299+
2300+
const lineChartSeries = useMemo(() => getLineChartSeries(), []);
2301+
const lineChartSeries2 = useMemo(() => getLineChartSeries(), []);
2302+
const ref = useRef<View>(null);
2303+
2304+
return (
2305+
<VStack gap={2}>
2306+
<DataCard
2307+
layout="vertical"
2308+
subtitle="Price trend"
2309+
thumbnail={exampleThumbnail}
2310+
title="Line Chart Card"
2311+
>
2312+
<LineChart
2313+
showArea
2314+
accessibilityLabel="Ethereum price chart"
2315+
areaType="dotted"
2316+
height={120}
2317+
inset={0}
2318+
series={lineChartSeries}
2319+
/>
2320+
</DataCard>
2321+
<DataCard
2322+
layout="vertical"
2323+
subtitle="Price trend"
2324+
tag="Trending"
2325+
thumbnail={exampleThumbnail}
2326+
title="Line Chart with Tag"
2327+
>
2328+
<LineChart
2329+
showArea
2330+
accessibilityLabel="Ethereum price chart"
2331+
areaType="dotted"
2332+
height={100}
2333+
inset={0}
2334+
series={lineChartSeries}
2335+
/>
2336+
</DataCard>
2337+
<DataCard
2338+
ref={ref}
2339+
renderAsPressable
2340+
layout="vertical"
2341+
onPress={NoopFn}
2342+
subtitle="Clickable line chart card"
2343+
tag="Interactive"
2344+
thumbnail={exampleThumbnail}
2345+
title="Actionable Line Chart"
2346+
>
2347+
<LineChart
2348+
showArea
2349+
accessibilityLabel="Ethereum price chart"
2350+
areaType="dotted"
2351+
height={120}
2352+
inset={0}
2353+
series={lineChartSeries}
2354+
showXAxis={false}
2355+
showYAxis={false}
2356+
/>
2357+
</DataCard>
2358+
<DataCard
2359+
layout="vertical"
2360+
subtitle="Price trend"
2361+
tag="Trending"
2362+
thumbnail={
2363+
<RemoteImage
2364+
accessibilityLabel="Bitcoin"
2365+
shape="circle"
2366+
size="l"
2367+
source={assets.btc.imageUrl}
2368+
testID="thumbnail"
2369+
/>
2370+
}
2371+
title="Card with Line Chart"
2372+
>
2373+
<LineChart
2374+
showArea
2375+
accessibilityLabel="Price chart"
2376+
areaType="dotted"
2377+
height={100}
2378+
inset={0}
2379+
series={lineChartSeries2}
2380+
showXAxis={false}
2381+
showYAxis={false}
2382+
/>
2383+
</DataCard>
2384+
</VStack>
2385+
);
2386+
}
22842387

22852388
type ExampleItem = {
22862389
title: string;
@@ -2542,20 +2645,22 @@ function ExampleNavigator() {
25422645
title: 'Two-Line Scrubber Label',
25432646
component: <TwoLineScrubberLabel />,
25442647
},
2648+
{
2649+
title: 'In DataCard',
2650+
component: <DataCardWithLineChart />,
2651+
},
25452652
],
25462653
[theme.color.fg, theme.color.fgPositive, theme.spectrum.gray50],
25472654
);
25482655

25492656
const currentExample = examples[currentIndex];
2550-
const isFirstExample = currentIndex === 0;
2551-
const isLastExample = currentIndex === examples.length - 1;
25522657

25532658
const handlePrevious = useCallback(() => {
2554-
setCurrentIndex((prev) => Math.max(0, prev - 1));
2555-
}, []);
2659+
setCurrentIndex((prev) => (prev - 1 + examples.length) % examples.length);
2660+
}, [examples.length]);
25562661

25572662
const handleNext = useCallback(() => {
2558-
setCurrentIndex((prev) => Math.min(examples.length - 1, prev + 1));
2663+
setCurrentIndex((prev) => (prev + 1 + examples.length) % examples.length);
25592664
}, [examples.length]);
25602665

25612666
return (
@@ -2565,12 +2670,11 @@ function ExampleNavigator() {
25652670
<IconButton
25662671
accessibilityHint="Navigate to previous example"
25672672
accessibilityLabel="Previous"
2568-
disabled={isFirstExample}
25692673
name="arrowLeft"
25702674
onPress={handlePrevious}
25712675
variant="secondary"
25722676
/>
2573-
<VStack alignItems="center" gap={1}>
2677+
<VStack alignItems="center">
25742678
<Text font="title3">{currentExample.title}</Text>
25752679
<Text color="fgMuted" font="label1">
25762680
{currentIndex + 1} / {examples.length}
@@ -2579,7 +2683,6 @@ function ExampleNavigator() {
25792683
<IconButton
25802684
accessibilityHint="Navigate to next example"
25812685
accessibilityLabel="Next"
2582-
disabled={isLastExample}
25832686
name="arrowRight"
25842687
onPress={handleNext}
25852688
variant="secondary"
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { forwardRef, memo } from 'react';
2+
import type { View } from 'react-native';
3+
import type { ThemeVars } from '@coinbase/cds-common';
4+
5+
import { CardRoot, type CardRootProps } from '../../cards/CardRoot';
6+
7+
import { DataCardLayout, type DataCardLayoutProps } from './DataCardLayout';
8+
9+
export type DataCardBaseProps = DataCardLayoutProps & {
10+
styles?: {
11+
root?: React.CSSProperties;
12+
};
13+
};
14+
15+
export type DataCardProps = Omit<CardRootProps, 'children'> & DataCardBaseProps;
16+
17+
const dataCardContainerProps = {
18+
borderRadius: 500 as ThemeVars.BorderRadius,
19+
background: 'bgAlternate' as ThemeVars.Color,
20+
overflow: 'hidden' as const,
21+
};
22+
23+
export const DataCard = memo(
24+
forwardRef<View, DataCardProps>(
25+
(
26+
{
27+
title,
28+
subtitle,
29+
tag,
30+
thumbnail,
31+
children,
32+
layout,
33+
renderAsPressable,
34+
styles: { root: rootStyle, ...layoutStyles } = {},
35+
...props
36+
},
37+
ref,
38+
) => (
39+
<CardRoot
40+
ref={ref}
41+
renderAsPressable={renderAsPressable}
42+
style={rootStyle}
43+
{...dataCardContainerProps}
44+
{...props}
45+
>
46+
<DataCardLayout
47+
layout={layout}
48+
styles={layoutStyles}
49+
subtitle={subtitle}
50+
tag={tag}
51+
thumbnail={thumbnail}
52+
title={title}
53+
>
54+
{children}
55+
</DataCardLayout>
56+
</CardRoot>
57+
),
58+
),
59+
);
60+
61+
DataCard.displayName = 'DataCard';
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import React, { memo, useMemo } from 'react';
2+
import type { StyleProp, ViewStyle } from 'react-native';
3+
4+
import { Box } from '../../layout/Box';
5+
import { HStack } from '../../layout/HStack';
6+
import { VStack } from '../../layout/VStack';
7+
import { Tag } from '../../tag/Tag';
8+
import { Text } from '../../typography';
9+
10+
export type DataCardLayoutBaseProps = {
11+
/** Text or React node to display as the card title. When a string is provided, it will be rendered in a CardTitle component. */
12+
title: React.ReactNode;
13+
/** Text or React node to display as the card subtitle. When a string is provided, it will be rendered in a CardSubtitle component. */
14+
subtitle?: React.ReactNode;
15+
/** Text or React node to display as a tag. When a string is provided, it will be rendered in a Tag component. */
16+
tag?: React.ReactNode;
17+
/** React node to display as a thumbnail in the header area. */
18+
thumbnail?: React.ReactNode;
19+
/** Layout orientation of the card. Horizontal places header and visualization side by side, vertical stacks them. */
20+
layout: 'horizontal' | 'vertical';
21+
/** child node to display as the visualization (e.g., ProgressBar or ProgressCircle). */
22+
children?: React.ReactNode;
23+
};
24+
25+
export type DataCardLayoutProps = DataCardLayoutBaseProps & {
26+
styles?: {
27+
layoutContainer?: StyleProp<ViewStyle>;
28+
headerContainer?: StyleProp<ViewStyle>;
29+
headerContent?: StyleProp<ViewStyle>;
30+
titleContainer?: StyleProp<ViewStyle>;
31+
};
32+
};
33+
export const DataCardLayout = memo(
34+
({
35+
title,
36+
subtitle,
37+
tag,
38+
thumbnail,
39+
layout = 'vertical',
40+
children,
41+
styles = {},
42+
}: DataCardLayoutProps) => {
43+
const titleNode = useMemo(() => {
44+
if (typeof title === 'string') {
45+
return (
46+
<Text font="headline" numberOfLines={2}>
47+
{title}
48+
</Text>
49+
);
50+
}
51+
return title;
52+
}, [title]);
53+
54+
const subtitleNode = useMemo(() => {
55+
if (typeof subtitle === 'string') {
56+
return (
57+
<Text color="fgMuted" font="label2" numberOfLines={1}>
58+
{subtitle}
59+
</Text>
60+
);
61+
}
62+
return subtitle;
63+
}, [subtitle]);
64+
65+
const tagNode = useMemo(() => {
66+
if (typeof tag === 'string') return <Tag>{tag}</Tag>;
67+
return tag;
68+
}, [tag]);
69+
70+
const layoutContainerSpacingProps = useMemo(() => {
71+
return {
72+
flexDirection: layout === 'horizontal' ? ('row' as const) : ('column' as const),
73+
gap: layout === 'horizontal' ? 2 : 1,
74+
padding: 2,
75+
} as const;
76+
}, [layout]);
77+
78+
const headerSpacingProps = useMemo(() => {
79+
return {
80+
flexDirection: layout === 'horizontal' ? ('column' as const) : ('row' as const),
81+
gap: layout === 'horizontal' ? 2 : 1,
82+
alignItems: layout === 'horizontal' ? ('flex-start' as const) : ('center' as const),
83+
} as const;
84+
}, [layout]);
85+
86+
return (
87+
<Box
88+
flexBasis="100%"
89+
flexGrow={1}
90+
flexShrink={1}
91+
style={styles?.layoutContainer}
92+
{...layoutContainerSpacingProps}
93+
>
94+
<Box flexGrow={1} flexShrink={1} style={styles?.headerContainer} {...headerSpacingProps}>
95+
{thumbnail}
96+
<VStack flexShrink={1} overflow="hidden" style={styles?.headerContent}>
97+
<HStack
98+
flexDirection="row-reverse"
99+
flexWrap="wrap"
100+
gap={0.5}
101+
justifyContent="flex-end"
102+
style={styles?.titleContainer}
103+
>
104+
{tagNode}
105+
{titleNode}
106+
</HStack>
107+
{subtitleNode}
108+
</VStack>
109+
</Box>
110+
{children}
111+
</Box>
112+
);
113+
},
114+
);
115+
116+
DataCardLayout.displayName = 'DataCardLayout';

0 commit comments

Comments
 (0)