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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/green-kids-cheer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tiny-design/react': patch
---

Redesign the Statistic component with a product-grade metric API, richer states, improved docs guidance, and updated dashboard demos.
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,67 @@
exports[`<Statistic /> should match the snapshot 1`] = `
<DocumentFragment>
<div
class="ty-statistic"
class="ty-statistic ty-statistic_md ty-statistic_align-start ty-statistic_emphasis-strong ty-statistic_monospace"
>
<div
class="ty-statistic__title"
class="ty-statistic__header"
>
Active Users
<div
class="ty-statistic__title"
>
Monthly Revenue
</div>
</div>
<div
aria-label="112,893"
class="ty-statistic__description"
>
Booked revenue across all active subscriptions.
</div>
<div
aria-label="Monthly Revenue, $128,430.50, up +12.4% vs last month, success Healthy growth"
class="ty-statistic__content"
>
<span
class="ty-statistic__value"
>
112,893
$128,430.50
</span>
</div>
<div
class="ty-statistic__aux"
>
<div
class="ty-statistic__trend ty-statistic__trend_positive"
>
<span
aria-hidden="true"
class="ty-statistic__trend-icon ty-statistic__trend-icon_up"
/>
<span
class="ty-statistic__trend-value"
>
+12.4%
</span>
<span
class="ty-statistic__trend-label"
>
vs last month
</span>
</div>
<div
class="ty-statistic__status ty-statistic__status_success"
>
<span
aria-hidden="true"
class="ty-statistic__status-dot"
/>
<span
class="ty-statistic__status-text"
>
Healthy growth
</span>
</div>
</div>
</div>
</DocumentFragment>
`;
120 changes: 65 additions & 55 deletions packages/react/src/statistic/__tests__/statistic.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,84 +4,94 @@ import Statistic from '../index';

describe('<Statistic />', () => {
it('should match the snapshot', () => {
const { asFragment } = render(<Statistic title="Active Users" value={112893} />);
const { asFragment } = render(
<Statistic
title="Monthly Revenue"
description="Booked revenue across all active subscriptions."
value={128430.5}
format={{ type: 'currency', currency: 'USD', maximumFractionDigits: 2 }}
trend={{ direction: 'up', value: '+12.4%', label: 'vs last month', sentiment: 'positive' }}
status={{ type: 'success', text: 'Healthy growth' }}
/>
);
expect(asFragment()).toMatchSnapshot();
});

it('should render correctly', () => {
const { container } = render(<Statistic title="Score" value={95} />);
expect(container.firstChild).toHaveClass('ty-statistic');
});

it('should render title', () => {
const { getByText } = render(<Statistic title="Total Sales" value={1000} />);
expect(getByText('Total Sales')).toBeInTheDocument();
});

it('should format value with group separator', () => {
const { getByText } = render(<Statistic value={112893} />);
expect(getByText('112,893')).toBeInTheDocument();
});

it('should format value with precision', () => {
const { getByText } = render(<Statistic value={11.28} precision={2} />);
expect(getByText('11.28')).toBeInTheDocument();
});

it('should support custom decimal separator', () => {
it('should render currency formatting', () => {
const { getByText } = render(
<Statistic value={112893.5} precision={1} groupSeparator="." decimalSeparator="," />
<Statistic value={128430.5} format={{ type: 'currency', currency: 'USD', maximumFractionDigits: 2 }} />
);
expect(getByText('112.893,5')).toBeInTheDocument();
expect(getByText('$128,430.50')).toBeInTheDocument();
});

it('should render prefix and suffix', () => {
it('should render percent formatting', () => {
const { getByText } = render(
<Statistic value={100} prefix="$" suffix="USD" />
<Statistic value={0.2386} format={{ type: 'percent', maximumFractionDigits: 2 }} />
);
expect(getByText('$')).toBeInTheDocument();
expect(getByText('USD')).toBeInTheDocument();
});

it('should use custom formatter', () => {
const formatter = (val: number | string) => `${val}%`;
const { getByText } = render(<Statistic value={95} formatter={formatter} />);
expect(getByText('95%')).toBeInTheDocument();
expect(getByText('23.86%')).toBeInTheDocument();
});

it('should pass formatting info to formatter', () => {
const formatter = jest.fn((_: number | string, info) => info.formattedValue);
const { getByText } = render(<Statistic value={2386.4} precision={1} formatter={formatter} />);
expect(getByText('2,386.4')).toBeInTheDocument();
it('should support custom formatter', () => {
const formatter = jest.fn((_, info) => `${info.formattedValue} live`);
const { getByText } = render(
<Statistic value={112893} format={{ type: 'compact', maximumFractionDigits: 1 }} formatter={formatter} />
);
expect(getByText('112.9K live')).toBeInTheDocument();
expect(formatter).toHaveBeenCalledWith(
2386.4,
112893,
expect.objectContaining({
formattedValue: '2,386.4',
groupSeparator: ',',
decimalSeparator: '.',
precision: 1,
formattedValue: '112.9K',
isNumeric: true,
})
);
});

it('should render string value', () => {
const { getByText } = render(<Statistic value="N/A" />);
expect(getByText('N/A')).toBeInTheDocument();
it('should render loading skeleton', () => {
const { container } = render(<Statistic title="Net Retention" loading />);
expect(container.querySelector('.ty-skeleton')).toBeInTheDocument();
});

it('should render empty placeholder for null value', () => {
const { getByText } = render(<Statistic value={null} empty="No data" />);
expect(getByText('No data')).toBeInTheDocument();
});

it('should render empty placeholder for invalid number', () => {
const { getByText } = render(<Statistic value={Number.NaN} />);
expect(getByText('--')).toBeInTheDocument();
it('should render error state before value', () => {
const { getByText, queryByText } = render(
<Statistic value={95} suffix="%" error="Feed unavailable" />
);
expect(getByText('Feed unavailable')).toBeInTheDocument();
expect(queryByText('95')).not.toBeInTheDocument();
});

it('should support custom empty placeholder', () => {
const { getByText } = render(<Statistic empty="Pending" />);
expect(getByText('Pending')).toBeInTheDocument();
it('should render trend and status', () => {
const { container, getByText } = render(
<Statistic
value={95}
trend={{ direction: 'down', value: '-2%', label: 'vs yesterday', sentiment: 'negative' }}
status={{ type: 'warning', text: 'Below target' }}
/>
);
expect(getByText('-2%')).toBeInTheDocument();
expect(getByText('Below target')).toBeInTheDocument();
expect(container.querySelector('.ty-statistic__trend_negative')).toBeInTheDocument();
expect(container.querySelector('.ty-statistic__status_warning')).toBeInTheDocument();
});

it('should apply value class name', () => {
const { container } = render(<Statistic value={95} valueClassName="custom-value" />);
expect(container.querySelector('.ty-statistic__content')).toHaveClass('custom-value');
it('should build aria label from the rendered metric content', () => {
const { container } = render(
<Statistic
title="Revenue"
value={128430.5}
format={{ type: 'currency', currency: 'USD', maximumFractionDigits: 2 }}
trend={{ direction: 'up', value: '+12.4%', label: 'vs last month' }}
status={{ type: 'success', text: 'Healthy growth' }}
/>
);

expect(container.querySelector('.ty-statistic__content')).toHaveAttribute(
'aria-label',
'Revenue, $128,430.50, up +12.4% vs last month, success Healthy growth'
);
});
});
43 changes: 38 additions & 5 deletions packages/react/src/statistic/demo/Basic.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,45 @@
import React from 'react';
import { Statistic, Flex } from '@tiny-design/react';
import { Card, Flex, Statistic } from '@tiny-design/react';

export default function BasicDemo() {
return (
<Flex gap="lg">
<Statistic title="Active Users" value={112893} />
<Statistic title="Account Balance" value={112893.4} precision={2} prefix="$" />
<Statistic title="Growth Rate" value={93.12} suffix="%" precision={2} />
<Flex gap="md" wrap="wrap">
<Card style={{ minWidth: 260, flex: '1 1 260px' }}>
<Card.Content>
<Statistic
title="Monthly Revenue"
description="Booked revenue across all active subscriptions."
value={128430.5}
format={{ type: 'currency', currency: 'USD', maximumFractionDigits: 2 }}
trend={{ direction: 'up', value: '+12.4%', label: 'vs last month', sentiment: 'positive' }}
status={{ type: 'success', text: 'Healthy growth' }}
/>
</Card.Content>
</Card>
<Card style={{ minWidth: 260, flex: '1 1 260px' }}>
<Card.Content>
<Statistic
title="Conversion Rate"
description="Signup to paid conversion in the last 7 days."
value={0.2386}
format={{ type: 'percent', maximumFractionDigits: 2 }}
trend={{ direction: 'flat', value: 'Stable', label: 'within target band', sentiment: 'neutral' }}
status={{ type: 'info', text: 'Watching experiment B' }}
/>
</Card.Content>
</Card>
<Card style={{ minWidth: 260, flex: '1 1 260px' }}>
<Card.Content>
<Statistic
title="Daily Active Users"
description="Unique users active in the last 24 hours."
value={112893}
format={{ type: 'compact', maximumFractionDigits: 1 }}
trend={{ direction: 'down', value: '-2.1%', label: 'after campaign cooldown', sentiment: 'negative' }}
extra="Last updated 2 minutes ago"
/>
</Card.Content>
</Card>
</Flex>
);
}
32 changes: 20 additions & 12 deletions packages/react/src/statistic/demo/Formatter.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,30 @@
import React from 'react';
import { Statistic, Flex } from '@tiny-design/react';
import { Flex, Statistic } from '@tiny-design/react';

export default function FormatterDemo() {
return (
<Flex gap="lg">
<Flex gap="lg" wrap="wrap">
<Statistic
title="Countdown"
value={Date.now() + 86400000}
formatter={(val) => {
const diff = Math.max(0, Math.floor((Number(val) - Date.now()) / 3600000));
return `${diff}h remaining`;
}}
title="Compact Number"
value={3498200}
format={{ type: 'compact', maximumFractionDigits: 1 }}
size="sm"
/>
<Statistic
title="Conversion"
value={0.2386}
precision={2}
formatter={(_, info) => <span style={{ color: '#1677ff' }}>{info.formattedValue}%</span>}
title="German Revenue"
value={1128930.5}
format={{ type: 'currency', currency: 'EUR', locale: 'de-DE', maximumFractionDigits: 2 }}
/>
<Statistic
title="API Latency"
value={184}
format={{ type: 'duration', durationUnit: 'ms' }}
/>
<Statistic
title="Fulfillment Rate"
value={0.9962}
format={{ type: 'percent', signDisplay: 'exceptZero', maximumFractionDigits: 2 }}
suffix=" SLA"
/>
</Flex>
);
Expand Down
55 changes: 55 additions & 0 deletions packages/react/src/statistic/demo/States.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import React from 'react';
import { Card, Flex, Statistic } from '@tiny-design/react';

export default function StatesDemo() {
return (
<Flex gap="md" wrap="wrap">
<Card style={{ minWidth: 260, flex: '1 1 260px' }}>
<Card.Content>
<Statistic
title="Net Retention"
description="Syncing finance data from the billing warehouse."
loading
footer="Loading has the highest display priority."
/>
</Card.Content>
</Card>
<Card style={{ minWidth: 260, flex: '1 1 260px' }}>
<Card.Content>
<Statistic
title="Refund Rate"
description="Last 30 days"
value={null}
empty="No data yet"
status={{ type: 'info', text: 'Awaiting first billing cycle' }}
footer="Use concise empty states that do not overpower normal values."
/>
</Card.Content>
</Card>
<Card style={{ minWidth: 260, flex: '1 1 260px' }}>
<Card.Content>
<Statistic
title="Warehouse Feed"
description="Most recent ETL job"
error="Unavailable"
status={{ type: 'danger', text: 'Connection timeout while reading the warehouse feed' }}
footer="Keep the main error state short, and move details into supporting copy."
/>
</Card.Content>
</Card>
<Card style={{ minWidth: 260, flex: '1 1 260px' }}>
<Card.Content>
<Statistic
title="Forecast Confidence"
description="This card shows the normal value state after status fallbacks are resolved."
value={82}
suffix="/100"
status={{ type: 'success', text: 'Model calibrated' }}
extra="Updated after pipeline clean-up"
footer="Value renders when loading, error, and empty conditions are absent."
/>
</Card.Content>
</Card>
</Flex>
);
}
Loading
Loading