Skip to content

Commit 83a8994

Browse files
authored
feat(statistic): redesign metric api and docs (#118)
* feat(statistic): redesign metric api and docs * chore: lint
1 parent e781241 commit 83a8994

12 files changed

Lines changed: 1372 additions & 237 deletions

File tree

.changeset/green-kids-cheer.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tiny-design/react': patch
3+
---
4+
5+
Redesign the Statistic component with a product-grade metric API, richer states, improved docs guidance, and updated dashboard demos.

packages/react/src/statistic/__tests__/__snapshots__/statistic.test.tsx.snap

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,67 @@
33
exports[`<Statistic /> should match the snapshot 1`] = `
44
<DocumentFragment>
55
<div
6-
class="ty-statistic"
6+
class="ty-statistic ty-statistic_md ty-statistic_align-start ty-statistic_emphasis-strong ty-statistic_monospace"
77
>
88
<div
9-
class="ty-statistic__title"
9+
class="ty-statistic__header"
1010
>
11-
Active Users
11+
<div
12+
class="ty-statistic__title"
13+
>
14+
Monthly Revenue
15+
</div>
1216
</div>
1317
<div
14-
aria-label="112,893"
18+
class="ty-statistic__description"
19+
>
20+
Booked revenue across all active subscriptions.
21+
</div>
22+
<div
23+
aria-label="Monthly Revenue, $128,430.50, up +12.4% vs last month, success Healthy growth"
1524
class="ty-statistic__content"
1625
>
1726
<span
1827
class="ty-statistic__value"
1928
>
20-
112,893
29+
$128,430.50
2130
</span>
2231
</div>
32+
<div
33+
class="ty-statistic__aux"
34+
>
35+
<div
36+
class="ty-statistic__trend ty-statistic__trend_positive"
37+
>
38+
<span
39+
aria-hidden="true"
40+
class="ty-statistic__trend-icon ty-statistic__trend-icon_up"
41+
/>
42+
<span
43+
class="ty-statistic__trend-value"
44+
>
45+
+12.4%
46+
</span>
47+
<span
48+
class="ty-statistic__trend-label"
49+
>
50+
vs last month
51+
</span>
52+
</div>
53+
<div
54+
class="ty-statistic__status ty-statistic__status_success"
55+
>
56+
<span
57+
aria-hidden="true"
58+
class="ty-statistic__status-dot"
59+
/>
60+
<span
61+
class="ty-statistic__status-text"
62+
>
63+
Healthy growth
64+
</span>
65+
</div>
66+
</div>
2367
</div>
2468
</DocumentFragment>
2569
`;

packages/react/src/statistic/__tests__/statistic.test.tsx

Lines changed: 65 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -4,84 +4,94 @@ import Statistic from '../index';
44

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

11-
it('should render correctly', () => {
12-
const { container } = render(<Statistic title="Score" value={95} />);
13-
expect(container.firstChild).toHaveClass('ty-statistic');
14-
});
15-
16-
it('should render title', () => {
17-
const { getByText } = render(<Statistic title="Total Sales" value={1000} />);
18-
expect(getByText('Total Sales')).toBeInTheDocument();
19-
});
20-
21-
it('should format value with group separator', () => {
22-
const { getByText } = render(<Statistic value={112893} />);
23-
expect(getByText('112,893')).toBeInTheDocument();
24-
});
25-
26-
it('should format value with precision', () => {
27-
const { getByText } = render(<Statistic value={11.28} precision={2} />);
28-
expect(getByText('11.28')).toBeInTheDocument();
29-
});
30-
31-
it('should support custom decimal separator', () => {
20+
it('should render currency formatting', () => {
3221
const { getByText } = render(
33-
<Statistic value={112893.5} precision={1} groupSeparator="." decimalSeparator="," />
22+
<Statistic value={128430.5} format={{ type: 'currency', currency: 'USD', maximumFractionDigits: 2 }} />
3423
);
35-
expect(getByText('112.893,5')).toBeInTheDocument();
24+
expect(getByText('$128,430.50')).toBeInTheDocument();
3625
});
3726

38-
it('should render prefix and suffix', () => {
27+
it('should render percent formatting', () => {
3928
const { getByText } = render(
40-
<Statistic value={100} prefix="$" suffix="USD" />
29+
<Statistic value={0.2386} format={{ type: 'percent', maximumFractionDigits: 2 }} />
4130
);
42-
expect(getByText('$')).toBeInTheDocument();
43-
expect(getByText('USD')).toBeInTheDocument();
44-
});
45-
46-
it('should use custom formatter', () => {
47-
const formatter = (val: number | string) => `${val}%`;
48-
const { getByText } = render(<Statistic value={95} formatter={formatter} />);
49-
expect(getByText('95%')).toBeInTheDocument();
31+
expect(getByText('23.86%')).toBeInTheDocument();
5032
});
5133

52-
it('should pass formatting info to formatter', () => {
53-
const formatter = jest.fn((_: number | string, info) => info.formattedValue);
54-
const { getByText } = render(<Statistic value={2386.4} precision={1} formatter={formatter} />);
55-
expect(getByText('2,386.4')).toBeInTheDocument();
34+
it('should support custom formatter', () => {
35+
const formatter = jest.fn((_, info) => `${info.formattedValue} live`);
36+
const { getByText } = render(
37+
<Statistic value={112893} format={{ type: 'compact', maximumFractionDigits: 1 }} formatter={formatter} />
38+
);
39+
expect(getByText('112.9K live')).toBeInTheDocument();
5640
expect(formatter).toHaveBeenCalledWith(
57-
2386.4,
41+
112893,
5842
expect.objectContaining({
59-
formattedValue: '2,386.4',
60-
groupSeparator: ',',
61-
decimalSeparator: '.',
62-
precision: 1,
43+
formattedValue: '112.9K',
6344
isNumeric: true,
6445
})
6546
);
6647
});
6748

68-
it('should render string value', () => {
69-
const { getByText } = render(<Statistic value="N/A" />);
70-
expect(getByText('N/A')).toBeInTheDocument();
49+
it('should render loading skeleton', () => {
50+
const { container } = render(<Statistic title="Net Retention" loading />);
51+
expect(container.querySelector('.ty-skeleton')).toBeInTheDocument();
52+
});
53+
54+
it('should render empty placeholder for null value', () => {
55+
const { getByText } = render(<Statistic value={null} empty="No data" />);
56+
expect(getByText('No data')).toBeInTheDocument();
7157
});
7258

73-
it('should render empty placeholder for invalid number', () => {
74-
const { getByText } = render(<Statistic value={Number.NaN} />);
75-
expect(getByText('--')).toBeInTheDocument();
59+
it('should render error state before value', () => {
60+
const { getByText, queryByText } = render(
61+
<Statistic value={95} suffix="%" error="Feed unavailable" />
62+
);
63+
expect(getByText('Feed unavailable')).toBeInTheDocument();
64+
expect(queryByText('95')).not.toBeInTheDocument();
7665
});
7766

78-
it('should support custom empty placeholder', () => {
79-
const { getByText } = render(<Statistic empty="Pending" />);
80-
expect(getByText('Pending')).toBeInTheDocument();
67+
it('should render trend and status', () => {
68+
const { container, getByText } = render(
69+
<Statistic
70+
value={95}
71+
trend={{ direction: 'down', value: '-2%', label: 'vs yesterday', sentiment: 'negative' }}
72+
status={{ type: 'warning', text: 'Below target' }}
73+
/>
74+
);
75+
expect(getByText('-2%')).toBeInTheDocument();
76+
expect(getByText('Below target')).toBeInTheDocument();
77+
expect(container.querySelector('.ty-statistic__trend_negative')).toBeInTheDocument();
78+
expect(container.querySelector('.ty-statistic__status_warning')).toBeInTheDocument();
8179
});
8280

83-
it('should apply value class name', () => {
84-
const { container } = render(<Statistic value={95} valueClassName="custom-value" />);
85-
expect(container.querySelector('.ty-statistic__content')).toHaveClass('custom-value');
81+
it('should build aria label from the rendered metric content', () => {
82+
const { container } = render(
83+
<Statistic
84+
title="Revenue"
85+
value={128430.5}
86+
format={{ type: 'currency', currency: 'USD', maximumFractionDigits: 2 }}
87+
trend={{ direction: 'up', value: '+12.4%', label: 'vs last month' }}
88+
status={{ type: 'success', text: 'Healthy growth' }}
89+
/>
90+
);
91+
92+
expect(container.querySelector('.ty-statistic__content')).toHaveAttribute(
93+
'aria-label',
94+
'Revenue, $128,430.50, up +12.4% vs last month, success Healthy growth'
95+
);
8696
});
8797
});
Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,45 @@
11
import React from 'react';
2-
import { Statistic, Flex } from '@tiny-design/react';
2+
import { Card, Flex, Statistic } from '@tiny-design/react';
33

44
export default function BasicDemo() {
55
return (
6-
<Flex gap="lg">
7-
<Statistic title="Active Users" value={112893} />
8-
<Statistic title="Account Balance" value={112893.4} precision={2} prefix="$" />
9-
<Statistic title="Growth Rate" value={93.12} suffix="%" precision={2} />
6+
<Flex gap="md" wrap="wrap">
7+
<Card style={{ minWidth: 260, flex: '1 1 260px' }}>
8+
<Card.Content>
9+
<Statistic
10+
title="Monthly Revenue"
11+
description="Booked revenue across all active subscriptions."
12+
value={128430.5}
13+
format={{ type: 'currency', currency: 'USD', maximumFractionDigits: 2 }}
14+
trend={{ direction: 'up', value: '+12.4%', label: 'vs last month', sentiment: 'positive' }}
15+
status={{ type: 'success', text: 'Healthy growth' }}
16+
/>
17+
</Card.Content>
18+
</Card>
19+
<Card style={{ minWidth: 260, flex: '1 1 260px' }}>
20+
<Card.Content>
21+
<Statistic
22+
title="Conversion Rate"
23+
description="Signup to paid conversion in the last 7 days."
24+
value={0.2386}
25+
format={{ type: 'percent', maximumFractionDigits: 2 }}
26+
trend={{ direction: 'flat', value: 'Stable', label: 'within target band', sentiment: 'neutral' }}
27+
status={{ type: 'info', text: 'Watching experiment B' }}
28+
/>
29+
</Card.Content>
30+
</Card>
31+
<Card style={{ minWidth: 260, flex: '1 1 260px' }}>
32+
<Card.Content>
33+
<Statistic
34+
title="Daily Active Users"
35+
description="Unique users active in the last 24 hours."
36+
value={112893}
37+
format={{ type: 'compact', maximumFractionDigits: 1 }}
38+
trend={{ direction: 'down', value: '-2.1%', label: 'after campaign cooldown', sentiment: 'negative' }}
39+
extra="Last updated 2 minutes ago"
40+
/>
41+
</Card.Content>
42+
</Card>
1043
</Flex>
1144
);
1245
}

packages/react/src/statistic/demo/Formatter.tsx

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,30 @@
11
import React from 'react';
2-
import { Statistic, Flex } from '@tiny-design/react';
2+
import { Flex, Statistic } from '@tiny-design/react';
33

44
export default function FormatterDemo() {
55
return (
6-
<Flex gap="lg">
6+
<Flex gap="lg" wrap="wrap">
77
<Statistic
8-
title="Countdown"
9-
value={Date.now() + 86400000}
10-
formatter={(val) => {
11-
const diff = Math.max(0, Math.floor((Number(val) - Date.now()) / 3600000));
12-
return `${diff}h remaining`;
13-
}}
8+
title="Compact Number"
9+
value={3498200}
10+
format={{ type: 'compact', maximumFractionDigits: 1 }}
11+
size="sm"
1412
/>
1513
<Statistic
16-
title="Conversion"
17-
value={0.2386}
18-
precision={2}
19-
formatter={(_, info) => <span style={{ color: '#1677ff' }}>{info.formattedValue}%</span>}
14+
title="German Revenue"
15+
value={1128930.5}
16+
format={{ type: 'currency', currency: 'EUR', locale: 'de-DE', maximumFractionDigits: 2 }}
17+
/>
18+
<Statistic
19+
title="API Latency"
20+
value={184}
21+
format={{ type: 'duration', durationUnit: 'ms' }}
22+
/>
23+
<Statistic
24+
title="Fulfillment Rate"
25+
value={0.9962}
26+
format={{ type: 'percent', signDisplay: 'exceptZero', maximumFractionDigits: 2 }}
27+
suffix=" SLA"
2028
/>
2129
</Flex>
2230
);
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import React from 'react';
2+
import { Card, Flex, Statistic } from '@tiny-design/react';
3+
4+
export default function StatesDemo() {
5+
return (
6+
<Flex gap="md" wrap="wrap">
7+
<Card style={{ minWidth: 260, flex: '1 1 260px' }}>
8+
<Card.Content>
9+
<Statistic
10+
title="Net Retention"
11+
description="Syncing finance data from the billing warehouse."
12+
loading
13+
footer="Loading has the highest display priority."
14+
/>
15+
</Card.Content>
16+
</Card>
17+
<Card style={{ minWidth: 260, flex: '1 1 260px' }}>
18+
<Card.Content>
19+
<Statistic
20+
title="Refund Rate"
21+
description="Last 30 days"
22+
value={null}
23+
empty="No data yet"
24+
status={{ type: 'info', text: 'Awaiting first billing cycle' }}
25+
footer="Use concise empty states that do not overpower normal values."
26+
/>
27+
</Card.Content>
28+
</Card>
29+
<Card style={{ minWidth: 260, flex: '1 1 260px' }}>
30+
<Card.Content>
31+
<Statistic
32+
title="Warehouse Feed"
33+
description="Most recent ETL job"
34+
error="Unavailable"
35+
status={{ type: 'danger', text: 'Connection timeout while reading the warehouse feed' }}
36+
footer="Keep the main error state short, and move details into supporting copy."
37+
/>
38+
</Card.Content>
39+
</Card>
40+
<Card style={{ minWidth: 260, flex: '1 1 260px' }}>
41+
<Card.Content>
42+
<Statistic
43+
title="Forecast Confidence"
44+
description="This card shows the normal value state after status fallbacks are resolved."
45+
value={82}
46+
suffix="/100"
47+
status={{ type: 'success', text: 'Model calibrated' }}
48+
extra="Updated after pipeline clean-up"
49+
footer="Value renders when loading, error, and empty conditions are absent."
50+
/>
51+
</Card.Content>
52+
</Card>
53+
</Flex>
54+
);
55+
}

0 commit comments

Comments
 (0)