diff --git a/src/components/InlineMetrics/InlineMetrics.styles.ts b/src/components/InlineMetrics/InlineMetrics.styles.ts index 5210eff1..68040293 100644 --- a/src/components/InlineMetrics/InlineMetrics.styles.ts +++ b/src/components/InlineMetrics/InlineMetrics.styles.ts @@ -5,12 +5,13 @@ import { IconName } from '@/utility-types/IconName'; export type InlineMetricsConfig = { innerElements: { - label: BaseProps; - metric: BaseProps; - trendContainer: BaseProps; - trend: { trend: Partial> } & BaseProps; - icon: BaseProps; - trendValue: BaseProps; + label?: BaseProps; + metric?: BaseProps; + trendContainer?: BaseProps; + trend?: { trend?: Partial> } & BaseProps; + icon?: BaseProps; + trendValue?: BaseProps; + referenceDate?: BaseProps; }; } & BaseProps; @@ -19,6 +20,7 @@ export const defaultConfig = { h: '', display: 'flex', flexDirection: 'column', + gap: '$space-component-gap-medium', innerElements: { trendContainer: { display: 'flex', @@ -28,7 +30,6 @@ export const defaultConfig = { label: { color: '$color-content-secondary', text: '$typo-body-medium', - marginBottom: '$space-component-gap-medium', }, metric: { text: '$typo-header-4xLarge', @@ -39,7 +40,6 @@ export const defaultConfig = { padding: '$space-component-padding-xSmall 0', display: 'flex', alignItems: 'center', - alignSelf: 'flex-end', trend: { None: {}, Positive: { @@ -58,6 +58,9 @@ export const defaultConfig = { display: 'flex', alignItems: 'end', }, + referenceDate: { + display: 'none', + }, }, } satisfies InlineMetricsConfig; diff --git a/src/components/InlineMetrics/InlineMetrics.tsx b/src/components/InlineMetrics/InlineMetrics.tsx index aff774ac..f8c4023f 100644 --- a/src/components/InlineMetrics/InlineMetrics.tsx +++ b/src/components/InlineMetrics/InlineMetrics.tsx @@ -48,6 +48,9 @@ export const InlineMetrics: FC = ({ data-testid="inline-metrics-trend-value" > {trendValue} + + vs. last year + diff --git a/src/components/MetricsCard/MetricsCard.props.ts b/src/components/MetricsCard/MetricsCard.props.ts new file mode 100644 index 00000000..ac152e10 --- /dev/null +++ b/src/components/MetricsCard/MetricsCard.props.ts @@ -0,0 +1,13 @@ +import { MetricsCardConfig } from './MetricsCard.styles'; +import { InlineMetricsProps } from '../InlineMetrics'; + +export type IconPositionType = 'Top' | 'Left'; +export type IntentType = 'Neutral' | 'Positive' | 'Negative'; + +export type MetricsCardProps = { + iconPosition?: IconPositionType; + hasTrend?: boolean; + hasIcon?: boolean; + hasMoreIcon?: boolean; + custom?: MetricsCardConfig; +} & Omit; diff --git a/src/components/MetricsCard/MetricsCard.stories.tsx b/src/components/MetricsCard/MetricsCard.stories.tsx new file mode 100644 index 00000000..fa83d464 --- /dev/null +++ b/src/components/MetricsCard/MetricsCard.stories.tsx @@ -0,0 +1,56 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { MetricsCard } from './MetricsCard'; + +import { MetricsCardDocs } from '@/docs-components/MetricsCardDocs'; +import { TetDocs } from '@/docs-components/TetDocs'; + +const meta = { + title: 'Metrics / MetricsCard', + component: MetricsCard, + tags: ['autodocs'], + args: {}, + parameters: { + backgrounds: {}, + docs: { + description: { + component: + 'A set of several grouped components that displays numerical data, such as, for example, key performance indicators (KPIs). Metrics provide users with a clear, visual representation of essential statistics or progress.', + }, + page: () => ( + + + + ), + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + trend: 'Positive', + trendValue: '+24%', + metrics: '$123.12', + label: 'Total Earnings', + hasIcon: true, + hasMoreIcon: true, + hasTrend: true, + iconPosition: 'Top', + }, +}; + +export const IconPositionLeft: Story = { + args: { + trend: 'Negative', + trendValue: '-24%', + metrics: '$123.12', + label: 'Total Earnings', + hasIcon: true, + hasMoreIcon: true, + hasTrend: true, + iconPosition: 'Left', + }, +}; diff --git a/src/components/MetricsCard/MetricsCard.styles.ts b/src/components/MetricsCard/MetricsCard.styles.ts new file mode 100644 index 00000000..723441ad --- /dev/null +++ b/src/components/MetricsCard/MetricsCard.styles.ts @@ -0,0 +1,75 @@ +import type { IconPositionType } from './MetricsCard.props'; + +import type { BaseProps } from '@/types/BaseProps'; + +export type MetricsCardConfig = { + iconPosition?: Record; + innerElements: { + trendContainer?: BaseProps; + circle?: BaseProps; + referenceDate?: BaseProps; + trend?: BaseProps; + icon?: BaseProps; + trendValue?: BaseProps; + moreIcon?: BaseProps; + }; +} & BaseProps; + +export const defaultConfig = { + position: 'relative', + border: '1px solid', + borderColor: '$color-border-defaultA', + borderRadius: '$border-radius-xLarge', + padding: '$space-component-padding-2xLarge', + display: 'flex', + boxShadow: '$elevation-bottom-200', + w: '480px', + iconPosition: { + Top: { + flexDirection: 'column', + }, + Left: { + flexDirection: 'row', + }, + }, + innerElements: { + circle: { + w: '$size-large', + h: '$size-large', + padding: '$space-component-padding-medium', + border: '1px solid', + borderColor: '$color-border-neutral-subtle', + borderRadius: '24px', + }, + trend: {}, + icon: { + display: 'flex', + }, + trendValue: { + text: '$typo-body-strong-medium', + display: 'flex', + alignItems: 'end', + }, + referenceDate: { + display: 'block', + text: '$typo-body-medium', + color: '$color-content-secondary', + marginLeft: '$space-component-padding-xSmall', + }, + trendContainer: { + flexDirection: 'column', + alignSelf: 'flex-start', + gap: '$space-component-gap-xLarge', + }, + moreIcon: { + position: 'absolute', + color: '$color-action-neutral-normal', + top: '$space-component-padding-2xLarge', + right: '$space-component-padding-2xLarge', + }, + }, +} satisfies MetricsCardConfig; + +export const metricsCardStyles = { + defaultConfig, +}; diff --git a/src/components/MetricsCard/MetricsCard.test.tsx b/src/components/MetricsCard/MetricsCard.test.tsx new file mode 100644 index 00000000..5c3e87bb --- /dev/null +++ b/src/components/MetricsCard/MetricsCard.test.tsx @@ -0,0 +1,57 @@ +import { MetricsCard } from './MetricsCard'; +import { render } from '../../tests/render'; + +import { customPropTester } from '@/tests/customPropTester'; + +const getMetricsCard = (jsx: JSX.Element) => { + const { getByTestId, queryByTestId } = render(jsx); + return { + container: getByTestId('metrics-card'), + inlineMetrics: getByTestId('metrics-card-inline-metrics'), + moreIcon: queryByTestId('metrics-card-more-icon'), + walletIcon: queryByTestId('metrics-card-wallet-icon'), + }; +}; + +describe('Metrics Card', () => { + customPropTester( + , + { + containerId: 'metrics-card', + props: { + trend: ['Negative', 'None', 'Positive'], + }, + }, + ); + + it('should render the metrics card', () => { + const { container } = getMetricsCard(); + expect(container).toBeInTheDocument(); + }); + + it('should render the more icon', () => { + const { moreIcon } = getMetricsCard(); + expect(moreIcon).toBeInTheDocument(); + }); + + it('should not render the more icon', () => { + const { moreIcon } = getMetricsCard(); + expect(moreIcon).toBeNull(); + }); + + it('should render the wallet icon', () => { + const { walletIcon } = getMetricsCard(); + expect(walletIcon).toBeInTheDocument(); + }); + + it('should not render the wallet icon', () => { + const { walletIcon } = getMetricsCard(); + expect(walletIcon).toBeNull(); + }); +}); diff --git a/src/components/MetricsCard/MetricsCard.tsx b/src/components/MetricsCard/MetricsCard.tsx new file mode 100644 index 00000000..83fdfa74 --- /dev/null +++ b/src/components/MetricsCard/MetricsCard.tsx @@ -0,0 +1,69 @@ +import { Icon } from '@virtuslab/tetrisly-icons'; +import { MarginProps } from '@xstyled/styled-components'; +import { type FC, useMemo } from 'react'; + +import { MetricsCardProps } from './MetricsCard.props'; +import { stylesBuilder } from './stylesBuilder'; +import { InlineMetrics } from '../InlineMetrics'; +import { InlineMetricsConfig } from '../InlineMetrics/InlineMetrics.styles'; + +import { tet } from '@/tetrisly'; + +export const MetricsCard: FC = ({ + hasIcon = false, + hasMoreIcon = false, + hasTrend, + metrics, + label, + trend = 'None', + trendValue, + iconPosition = 'Top', + custom, + ...restProps +}) => { + const styles = useMemo( + () => stylesBuilder({ iconPosition, custom }), + [custom, iconPosition], + ); + const isLeftPosition = iconPosition === 'Left'; + + const customInlineMetrics: InlineMetricsConfig = { + gap: '$space-component-gap-null', + innerElements: { + trendContainer: { ...styles.trendContainer }, + referenceDate: { ...styles.referenceDate }, + trend: { ...styles.trend, display: hasTrend ? 'flex' : 'none' }, + }, + }; + + return ( + + {hasIcon && ( + + + + )} + + {hasMoreIcon && ( + + + + )} + + ); +}; diff --git a/src/components/MetricsCard/index.ts b/src/components/MetricsCard/index.ts new file mode 100644 index 00000000..ca6a9b2e --- /dev/null +++ b/src/components/MetricsCard/index.ts @@ -0,0 +1,3 @@ +export { MetricsCard } from './MetricsCard'; +export type { MetricsCardProps } from './MetricsCard.props'; +export { metricsCardStyles } from './MetricsCard.styles'; diff --git a/src/components/MetricsCard/stylesBuilder.ts b/src/components/MetricsCard/stylesBuilder.ts new file mode 100644 index 00000000..d8fb4a42 --- /dev/null +++ b/src/components/MetricsCard/stylesBuilder.ts @@ -0,0 +1,30 @@ +import type { IconPositionType, MetricsCardProps } from './MetricsCard.props'; +import { defaultConfig } from './MetricsCard.styles'; + +import { mergeConfigWithCustom } from '@/services'; + +type StylesBuilderParams = { + iconPosition: IconPositionType; + custom: MetricsCardProps['custom']; +}; + +export const stylesBuilder = ({ + iconPosition, + custom, +}: StylesBuilderParams) => { + const { + innerElements, + iconPosition: position, + ...restStyles + } = mergeConfigWithCustom({ + defaultConfig, + custom, + }); + + const containerStyles = { ...restStyles, ...position[iconPosition] }; + + return { + container: containerStyles, + ...innerElements, + }; +}; diff --git a/src/docs-components/MetricsCardDocs.tsx b/src/docs-components/MetricsCardDocs.tsx new file mode 100644 index 00000000..9f25a708 --- /dev/null +++ b/src/docs-components/MetricsCardDocs.tsx @@ -0,0 +1,68 @@ +import { SectionHeader } from './common/SectionHeader'; + +import { TrendType } from '@/components/InlineMetrics/InlineMetrics.props'; +import { MetricsCard } from '@/components/MetricsCard'; +import type { IconPositionType } from '@/components/MetricsCard/MetricsCard.props'; +import { tet } from '@/tetrisly'; + +const trends: TrendType[] = ['None', 'Positive', 'Negative']; +const iconPositions: IconPositionType[] = ['Top', 'Left']; +const intentNames: Record = { + None: 'Neutral', + Positive: 'Positive', + Negative: 'Negative', +}; + +export const MetricsCardDocs = () => ( + + {iconPositions.map((position) => ( + <> + + {position} Icon Position + + {trends.map((trend) => ( + + + Intent: {intentNames[trend]} + + + + + + ))} + + ))} + +); diff --git a/src/index.ts b/src/index.ts index 6440c3ae..e61dc8fa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,7 @@ export * from './components/InlineMetrics'; export * from './components/InlineSearchInput'; export * from './components/Label'; export * from './components/Loader'; +export * from './components/MetricsCard'; export * from './components/NewItemButton'; export * from './components/Popover'; export * from './components/RadioButton';