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 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
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
14 changes: 14 additions & 0 deletions src/hooks/useIsFirstRender.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { useRef } from 'react';

const useIsFirstRender = () => {
const isFirstRenderRef = useRef(true);

if (isFirstRenderRef.current) {
isFirstRenderRef.current = false;
return true;
}

return isFirstRenderRef.current;
};

export default useIsFirstRender;
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';
159 changes: 159 additions & 0 deletions src/statistic/Statistic.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
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 useIsFirstRender from '../hooks/useIsFirstRender';

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 isFirstRender = useIsFirstRender();

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

const formatValue = useMemo(() => {
let formatInnerValue: number | string | undefined = innerValue;

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

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

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

useEffect(() => {
// 第一次渲染不执行,否则导致初始formValue失效
console.log('isFirstRender', isFirstRender);
if (isFirstRender) return;

setInnerValue(value);

animationStart && animation && start();

return () => {
if (tween.current) {
tween.current.stop();
}
};

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

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

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

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;
142 changes: 142 additions & 0 deletions src/statistic/__tests__/statistic.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import React from 'react';
import { render, fireEvent, mockDelay } from '@test/utils';
import { ArrowTriangleDownFilledIcon, ArrowTriangleUpFilledIcon } from 'tdesign-icons-react';
import Statistic from '../index';

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'));

await mockDelay(2000);

expect(document.querySelector('.t-statistic-content-value')).toHaveTextContent('82.76');
});

/**
* not animation config display value
*/
test('not animation', async () => {
render(<Statistic title="Total Assets" value={82.76} format={(value) => +value.toFixed(2)} />);

expect(document.querySelector('.t-statistic-content-value')).toHaveTextContent('82.76');
});

/**
* have animation config display valueFrom
*/
test('not animation', async () => {
render(
<Statistic
title="Total Assets"
value={82.76}
animation={{
valueFrom: 0,
duration: 2000,
}}
format={(value) => +value.toFixed(2)}
/>,
);

expect(document.querySelector('.t-statistic-content-value')).toHaveTextContent('0');
});
});
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