Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(statistic): new component Statistic #2596

Merged
merged 9 commits into from
Nov 28, 2023
Merged
Show file tree
Hide file tree
Changes from 6 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
8 changes: 8 additions & 0 deletions site/site.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,14 @@ export const docs = [
component: () => import('tdesign-react/skeleton/skeleton.md'),
componentEn: () => import('tdesign-react/skeleton/skeleton.en-US.md'),
},
{
title: 'Statistic 统计数值',
titleEn: 'Statistic',
name: 'statistic',
path: '/react/components/statistic',
component: () => import('tdesign-react/statistic/statistic.md'),
componentEn: () => import('tdesign-react/statistic/statistic.en-US.md'),
},
{
title: 'Swiper 轮播框',
titleEn: 'Swiper',
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,4 @@ export * from './rate';
export * from './link';
export * from './guide';
export * from './back-top';
export * from './statistic';
168 changes: 168 additions & 0 deletions src/statistic/Statistic.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
import isNumber from 'lodash/isNumber';
import isFunction from 'lodash/isFunction';
import {
ArrowTriangleDownFilledIcon as TDArrowTriangleDownFilledIcon,
ArrowTriangleUpFilledIcon as TDArrowTriangleUpFilledIcon,
} from 'tdesign-icons-react';
import { TdStatisticProps } from './type';
import { statisticDefaultProps } from './defaultProps';
import { StyledProps } from '../common';
import useConfig from '../hooks/useConfig';
import useGlobalIcon from '../hooks/useGlobalIcon';
import useDefaultProps from '../hooks/useDefaultProps';

import Skeleton from '../skeleton';
import Tween from '../_common/js/statistic/tween';
import { COLOR_MAP } from '../_common/js/statistic/utils';

export interface StatisticProps extends TdStatisticProps, StyledProps {}

export interface StatisticRef {
start: (from?: number, to?: number) => void;
}

const Statistic = forwardRef<StatisticRef, StatisticProps>((props, ref) => {
const {
animation,
animationStart,
color,
decimalPlaces,
extra,
format,
loading,
prefix,
separator,
suffix,
title,
trend,
trendPlacement,
unit,
value,
} = useDefaultProps<StatisticProps>(props, statisticDefaultProps);
const { classPrefix } = useConfig();
const { ArrowTriangleUpFilledIcon } = useGlobalIcon({ ArrowTriangleUpFilledIcon: TDArrowTriangleUpFilledIcon });
const { ArrowTriangleDownFilledIcon } = useGlobalIcon({
ArrowTriangleDownFilledIcon: TDArrowTriangleDownFilledIcon,
});

/**
* init value
*/
const [innerValue, setInnerValue] = useState(animation?.valueFrom ?? value);
const numberValue = useMemo(() => (isNumber(value) ? value : 0), [value]);

const tween = useRef(null);

const start = (from: number = animation?.valueFrom ?? 0, to: number = numberValue) => {
if (from !== to) {
tween.current = new Tween({
from: {
value: from,
},
to: {
value: to,
},
duration: props.animation.duration,
onUpdate: (keys) => {
setInnerValue(keys.value);
},
onFinish: () => {
setInnerValue(to);
},
});
tween.current?.start();
}
};

const formatValue = useMemo(() => {
// eslint-disable-next-line no-underscore-dangle
let _value: number | undefined | string = innerValue;
HaixingOoO marked this conversation as resolved.
Show resolved Hide resolved

if (isFunction(format)) {
return format(_value);
}
const options = {
minimumFractionDigits: decimalPlaces || 0,
maximumFractionDigits: decimalPlaces || 20,
useGrouping: !!separator,
};
// replace的替换的方案仅能应对大部分地区
_value = _value.toLocaleString(undefined, options).replace(/,|,/g, separator);

return _value;
}, [innerValue, decimalPlaces, separator, format]);

const valueStyle = useMemo(
() => ({
color: COLOR_MAP[color] || color,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[color],
);

useEffect(() => {
animation && animationStart && start();
return () => {
if (tween.current) {
tween.current.stop();
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

useEffect(() => {
animationStart && animation && !tween.current && start();

return () => {
if (tween.current) {
tween.current.stop();
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [animationStart]);

useEffect(() => {
if (tween.current) {
tween.current?.stop();
tween.current = null;
}
setInnerValue(value);

animationStart && animation && start();

// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);

useImperativeHandle(ref, () => ({
start,
}));

const trendIcons = {
increase: <ArrowTriangleUpFilledIcon />,
decrease: <ArrowTriangleDownFilledIcon />,
};

const trendIcon = trend ? trendIcons[trend] : null;

const prefixRender = prefix || (trendIcon && trendPlacement !== 'right' ? trendIcon : null);
const suffixRender = suffix || (trendIcon && trendPlacement === 'right' ? trendIcon : null);

return (
<div className={`${classPrefix}-statistic`}>
{title && <div className={`${classPrefix}-statistic-title`}>{title}</div>}
<Skeleton animation="gradient" theme="text" loading={!!loading}>
<div className={`${classPrefix}-statistic-content`} style={valueStyle}>
{prefixRender && <span className={`${classPrefix}-statistic-content-prefix`}>{prefixRender}</span>}
<span className={`${classPrefix}-statistic-content-value`}>{formatValue}</span>
{unit && <span className={`${classPrefix}-statistic-content-unit`}>{unit}</span>}
{suffixRender && <span className={`${classPrefix}-statistic-content-suffix`}>{suffixRender}</span>}
</div>
</Skeleton>
{extra && <div className={`${classPrefix}-statistic-extra`}>{extra}</div>}
</div>
);
});

Statistic.displayName = 'Statistic';
export default Statistic;
126 changes: 126 additions & 0 deletions src/statistic/__tests__/statistic.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import React from 'react';
import { render, fireEvent, waitFor } from '@test/utils';
import { vi } from 'vitest';
import { ArrowTriangleDownFilledIcon, ArrowTriangleUpFilledIcon } from 'tdesign-icons-react';
import Statistic from '../index';

beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});

afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
});

describe('Statistic 组件测试', () => {
/**
* props
*/

test('props', () => {
render(<Statistic title="Total Assets" value={82.76} unit="%" trend="increase" color="black" />);

expect(document.querySelector('.t-statistic-title')).toHaveTextContent('Total Assets');

expect(document.querySelector('.t-statistic-content-unit')).toHaveTextContent('%');
});

/**
* color
*/

const COLOR_MAP = {
black: 'black',
blue: 'var(--td-brand-color)',
red: 'var(--td-error-color)',
orange: 'var(--td-warning-color)',
green: 'var(--td-success-color)',
};
const colors = ['black', 'blue', 'red', 'orange', 'green'] as const;
colors.forEach((color) => {
test('color', () => {
render(<Statistic title="Total Assets" value={82.76} unit="%" trend="increase" color={color} />);

expect(document.querySelector('.t-statistic-content')).toHaveStyle(`color: ${COLOR_MAP[color]}`);
});
});

/**
* trend
*/

test('trend', () => {
render(
<div>
<Statistic title="Total Assets" value={82.76} unit="%" trend="increase" />
<Statistic title="Total Assets" value={82.76} unit="%" trend="decrease" />
</div>,
);

const { container: upIcon } = render(<ArrowTriangleUpFilledIcon />);
const { container: downIcon } = render(<ArrowTriangleDownFilledIcon />);

expect(upIcon).toBeInTheDocument();
expect(downIcon).toBeInTheDocument();
});

test('trendPlacement left', () => {
render(<Statistic title="Total Assets" value={82.76} unit="%" trend="decrease" />);

expect(document.querySelector('.t-statistic-content-prefix')).toBeInTheDocument();
});

test('trendPlacement right', () => {
render(<Statistic title="Total Assets" value={82.76} unit="%" trend="decrease" trendPlacement="right" />);

expect(document.querySelector('.t-statistic-content-suffix')).toBeInTheDocument();
});

/**
* loading
*/

test('loading', () => {
render(<Statistic title="Total Assets" value={82.76} loading />);

expect(document.querySelector('.t-statistic-title')).toHaveTextContent('Total Assets');

expect(document.querySelector('.t-skeleton__row')).toBeInTheDocument();
});

/**
* Start
*/

test('Start Function', async () => {
const TestDom = () => {
const [start, setStart] = React.useState(false);

return (
<>
<button id="button" onClick={() => setStart(true)}></button>
<Statistic
title="Total Assets"
value={82.76}
animation={{
valueFrom: 0,
duration: 2000,
}}
format={(value) => +value.toFixed(2)}
animationStart={start}
/>
</>
);
};
render(<TestDom />);

fireEvent.click(document.querySelector('#button'));

vi.advanceTimersByTime(2000);

await waitFor(() => {
expect(document.querySelector('.t-statistic-content-value')).toHaveTextContent('82.76');
});
});
});
32 changes: 32 additions & 0 deletions src/statistic/_example/animation.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import React from 'react';
import { Space, Button, Statistic } from 'tdesign-react';

const AnimationStatistic = () => {
const [start, setStart] = React.useState(false);
const [value, setValue] = React.useState(56.32);
const statisticRef = React.useRef();

return (
<Space direction="vertical">
<Space>
<Button onClick={() => setStart(true)}>Start</Button>
<Button onClick={() => setValue(98.12)}>Update value</Button>
<Button onClick={() => statisticRef.current?.start()}>refs</Button>
</Space>
<Statistic
ref={statisticRef}
title="Total Assets"
suffix="%"
value={value}
animation={{
valueFrom: 0,
duration: 2000,
}}
decimalPlaces={2}
animationStart={start}
/>
</Space>
);
};

export default AnimationStatistic;
11 changes: 11 additions & 0 deletions src/statistic/_example/base.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import React from 'react';
import { Space, Statistic } from 'tdesign-react';

const BaseStatistic = () => (
<Space size={100}>
<Statistic title="Total Assets" value={82.76} unit="%" trend="increase" />
<Statistic title="Total Assets" value={82.76} unit="USD" trend="increase" />
</Space>
);

export default BaseStatistic;
14 changes: 14 additions & 0 deletions src/statistic/_example/color.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import React from 'react';
import { Space, Statistic } from 'tdesign-react';

const ColorStatistic = () => (
<Space>
<Statistic title="Total Assets" value={82.76} unit="%" trend="increase" color="black" />
<Statistic title="Total Assets" value={82.76} unit="%" trend="increase" color="blue" />
<Statistic title="Total Assets" value={82.76} unit="%" trend="increase" color="red" />
<Statistic title="Total Assets" value={82.76} unit="%" trend="increase" color="orange" />
<Statistic title="Total Assets" value={82.76} unit="%" trend="increase" color="green" />
</Space>
);

export default ColorStatistic;
Loading
Loading