diff --git a/site/site.config.mjs b/site/site.config.mjs index c1e4e97aa3..d3fbf60cda 100644 --- a/site/site.config.mjs +++ b/site/site.config.mjs @@ -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', diff --git a/src/hooks/useIsFirstRender.ts b/src/hooks/useIsFirstRender.ts new file mode 100644 index 0000000000..d9870909ec --- /dev/null +++ b/src/hooks/useIsFirstRender.ts @@ -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; diff --git a/src/index.ts b/src/index.ts index 0a7ab6af08..f654956689 100644 --- a/src/index.ts +++ b/src/index.ts @@ -64,3 +64,4 @@ export * from './rate'; export * from './link'; export * from './guide'; export * from './back-top'; +export * from './statistic'; diff --git a/src/statistic/Statistic.tsx b/src/statistic/Statistic.tsx new file mode 100644 index 0000000000..24774f9291 --- /dev/null +++ b/src/statistic/Statistic.tsx @@ -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((props, ref) => { + const { + animation, + animationStart, + color, + decimalPlaces, + extra, + format, + loading, + prefix, + separator, + suffix, + title, + trend, + trendPlacement, + unit, + value, + } = useDefaultProps(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: , + decrease: , + }; + + const trendIcon = trend ? trendIcons[trend] : null; + + const prefixRender = prefix || (trendIcon && trendPlacement !== 'right' ? trendIcon : null); + const suffixRender = suffix || (trendIcon && trendPlacement === 'right' ? trendIcon : null); + + return ( +
+ {title &&
{title}
} + +
+ {prefixRender && {prefixRender}} + {formatValue} + {unit && {unit}} + {suffixRender && {suffixRender}} +
+
+ {extra &&
{extra}
} +
+ ); +}); + +Statistic.displayName = 'Statistic'; +export default Statistic; diff --git a/src/statistic/__tests__/statistic.test.tsx b/src/statistic/__tests__/statistic.test.tsx new file mode 100644 index 0000000000..928d23f92d --- /dev/null +++ b/src/statistic/__tests__/statistic.test.tsx @@ -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(); + + 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(); + + expect(document.querySelector('.t-statistic-content')).toHaveStyle(`color: ${COLOR_MAP[color]}`); + }); + }); + + /** + * trend + */ + + test('trend', () => { + render( +
+ + +
, + ); + + const { container: upIcon } = render(); + const { container: downIcon } = render(); + + expect(upIcon).toBeInTheDocument(); + expect(downIcon).toBeInTheDocument(); + }); + + test('trendPlacement left', () => { + render(); + + expect(document.querySelector('.t-statistic-content-prefix')).toBeInTheDocument(); + }); + + test('trendPlacement right', () => { + render(); + + expect(document.querySelector('.t-statistic-content-suffix')).toBeInTheDocument(); + }); + + /** + * loading + */ + + test('loading', () => { + render(); + + 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 ( + <> + + +value.toFixed(2)} + animationStart={start} + /> + + ); + }; + render(); + + 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( +value.toFixed(2)} />); + + expect(document.querySelector('.t-statistic-content-value')).toHaveTextContent('82.76'); + }); + + /** + * have animation config display valueFrom + */ + test('not animation', async () => { + render( + +value.toFixed(2)} + />, + ); + + expect(document.querySelector('.t-statistic-content-value')).toHaveTextContent('0'); + }); +}); diff --git a/src/statistic/_example/animation.jsx b/src/statistic/_example/animation.jsx new file mode 100644 index 0000000000..1d96cc47e8 --- /dev/null +++ b/src/statistic/_example/animation.jsx @@ -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 ( + + + + + + + + + ); +}; + +export default AnimationStatistic; diff --git a/src/statistic/_example/base.jsx b/src/statistic/_example/base.jsx new file mode 100644 index 0000000000..8c3572a2c5 --- /dev/null +++ b/src/statistic/_example/base.jsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { Space, Statistic } from 'tdesign-react'; + +const BaseStatistic = () => ( + + + + +); + +export default BaseStatistic; diff --git a/src/statistic/_example/color.jsx b/src/statistic/_example/color.jsx new file mode 100644 index 0000000000..87542efba7 --- /dev/null +++ b/src/statistic/_example/color.jsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { Space, Statistic } from 'tdesign-react'; + +const ColorStatistic = () => ( + + + + + + + +); + +export default ColorStatistic; diff --git a/src/statistic/_example/combination.jsx b/src/statistic/_example/combination.jsx new file mode 100644 index 0000000000..1153f7ebb7 --- /dev/null +++ b/src/statistic/_example/combination.jsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { Space, Statistic, Card, Divider } from 'tdesign-react'; +import { IconFont } from 'tdesign-icons-react'; + +const CombinationStatistic = () => { + const iconStyle = { + fontSize: '32px', + color: 'var(--td-brand-color)', + background: 'var(--td-brand-color-light)', + borderRadius: 'var(--td-radius-medium)', + padding: '12px', + }; + const separator = ; + return ( + + + + + + + + + + + + + + + + +
the day before
+ + 9% +
+ +
last week
+ + 9% +
+
+ } + >
+ + + + + ); +}; + +export default CombinationStatistic; diff --git a/src/statistic/_example/loading.jsx b/src/statistic/_example/loading.jsx new file mode 100644 index 0000000000..20a69b8556 --- /dev/null +++ b/src/statistic/_example/loading.jsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { Space, Switch, Statistic } from 'tdesign-react'; + +const LoadingStatistic = () => { + const [loading, setLoading] = React.useState(true); + return ( + + setLoading(value)} size="large" /> + + + ); +}; + +export default LoadingStatistic; diff --git a/src/statistic/_example/slot.jsx b/src/statistic/_example/slot.jsx new file mode 100644 index 0000000000..42affffcbc --- /dev/null +++ b/src/statistic/_example/slot.jsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { Space, Statistic } from 'tdesign-react'; +import { ControlPlatformIcon, ArrowTriangleDownFilledIcon } from 'tdesign-icons-react'; + +const SlotStatistic = () => ( + + }> + + + } + > + +); + +export default SlotStatistic; diff --git a/src/statistic/_example/trend.jsx b/src/statistic/_example/trend.jsx new file mode 100644 index 0000000000..38cdb600b3 --- /dev/null +++ b/src/statistic/_example/trend.jsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { Space, Statistic } from 'tdesign-react'; + +const TrendStatistic = () => ( + + + + + +); + +export default TrendStatistic; diff --git a/src/statistic/defaultProps.ts b/src/statistic/defaultProps.ts new file mode 100644 index 0000000000..f65d7a1076 --- /dev/null +++ b/src/statistic/defaultProps.ts @@ -0,0 +1,12 @@ +/** + * 该文件为脚本自动生成文件,请勿随意修改。如需修改请联系 PMC + * */ + +import { TdStatisticProps } from './type'; + +export const statisticDefaultProps: TdStatisticProps = { + animationStart: false, + loading: false, + separator: ',', + trendPlacement: 'left', +}; diff --git a/src/statistic/index.ts b/src/statistic/index.ts new file mode 100644 index 0000000000..c7cdfca0c1 --- /dev/null +++ b/src/statistic/index.ts @@ -0,0 +1,8 @@ +import _Statistic from './Statistic'; +import './style/index.js'; + +export type { StatisticProps, StatisticRef } from './Statistic'; +export * from './type'; + +export const Statistic = _Statistic; +export default Statistic; diff --git a/src/statistic/statistic.en-US.md b/src/statistic/statistic.en-US.md new file mode 100644 index 0000000000..0d53258f9a --- /dev/null +++ b/src/statistic/statistic.en-US.md @@ -0,0 +1,25 @@ +:: BASE_DOC :: + +## API + +### Statistic Props + +name | type | default | description | required +-- | -- | -- | -- | -- +className | String | - | 类名 | N +style | Object | - | 样式,Typescript:`React.CSSProperties` | N +animation | Object | - | Animation effect control, `duration` refers to the transition time of the animation `unit: millisecond`, `valueFrom` refers to the initial value of the animation. `{ duration, valueFrom }`。Typescript:`animation` `interface animation { duration: number; valueFrom: number; }`。[see more ts definition](https://github.com/Tencent/tdesign-react/blob/develop/src/statistic/type.ts) | N +animationStart | Boolean | false | Whether to start animation | N +color | String | - | Color style, followed by TDesign style black, blue, red, orange, green.Can also be any RGB equivalent supported by [CSS color](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value)。options: black/blue/red/orange/green | N +decimalPlaces | Number | - | Decimal places | N +extra | TNode | - | Additional display content。Typescript:`string \| TNode`。[see more ts definition](https://github.com/Tencent/tdesign-react/blob/develop/src/common.ts) | N +format | Function | - | Format numeric display value。Typescript:`(value: number) => number` | N +loading | Boolean | false | Loading | N +prefix | TNode | - | Prefix content, display priority is higher than trend。Typescript:`string \| TNode`。[see more ts definition](https://github.com/Tencent/tdesign-react/blob/develop/src/common.ts) | N +separator | String | , | The carry separator is displayed by default, and can be customized to other content. When `separator = ''` is set to an empty string/null/undefined, the separator is hidden | N +suffix | TNode | - | Suffix content, display priority is higher than trend。Typescript:`string \| TNode`。[see more ts definition](https://github.com/Tencent/tdesign-react/blob/develop/src/common.ts) | N +title | TNode | - | The title of Statistic。Typescript:`string \| TNode`。[see more ts definition](https://github.com/Tencent/tdesign-react/blob/develop/src/common.ts) | N +trend | String | - | trend。options: increase/decrease | N +trendPlacement | String | left | Position of trending placements。options: left/right | N +unit | TNode | - | Unit content。Typescript:`string \| TNode`。[see more ts definition](https://github.com/Tencent/tdesign-react/blob/develop/src/common.ts) | N +value | Number | - | The value of Statistic | N diff --git a/src/statistic/statistic.md b/src/statistic/statistic.md new file mode 100644 index 0000000000..79700d3e5b --- /dev/null +++ b/src/statistic/statistic.md @@ -0,0 +1,25 @@ +:: BASE_DOC :: + +## API + +### Statistic Props + +名称 | 类型 | 默认值 | 说明 | 必传 +-- | -- | -- | -- | -- +className | String | - | 类名 | N +style | Object | - | 样式,TS 类型:`React.CSSProperties` | N +animation | Object | - | 动画效果控制,`duration` 指动画的过渡时间`单位:毫秒`,`valueFrom` 指动画的起始数值。`{ duration, valueFrom }`。TS 类型:`animation` `interface animation { duration: number; valueFrom: number; }`。[详细类型定义](https://github.com/Tencent/tdesign-react/blob/develop/src/statistic/type.ts) | N +animationStart | Boolean | false | 是否开始动画 | N +color | String | - | 颜色风格,依次为 TDesign 风格的黑色、蓝色、红色、橙色、绿色。也可以为任何 [CSS color](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value) 支持的 RGB 等值。可选项:black/blue/red/orange/green | N +decimalPlaces | Number | - | 小数保留位数 | N +extra | TNode | - | 额外的显示内容。TS 类型:`string \| TNode`。[通用类型定义](https://github.com/Tencent/tdesign-react/blob/develop/src/common.ts) | N +format | Function | - | 格式化数值显示值。TS 类型:`(value: number) => number` | N +loading | Boolean | false | 是否加载中 | N +prefix | TNode | - | 前缀内容,展示优先级高于 trend。TS 类型:`string \| TNode`。[通用类型定义](https://github.com/Tencent/tdesign-react/blob/develop/src/common.ts) | N +separator | String | , | 默认展示进位分隔符,可以自定义为其他内容,`separator = ''` 设置为空字符串/null/undefined 时隐藏分隔符 | N +suffix | TNode | - | 后缀内容,展示优先级高于 trend。TS 类型:`string \| TNode`。[通用类型定义](https://github.com/Tencent/tdesign-react/blob/develop/src/common.ts) | N +title | TNode | - | 数值显示的标题。TS 类型:`string \| TNode`。[通用类型定义](https://github.com/Tencent/tdesign-react/blob/develop/src/common.ts) | N +trend | String | - | 趋势。可选项:increase/decrease | N +trendPlacement | String | left | 趋势展示位置。可选项:left/right | N +unit | TNode | - | 单位内容。TS 类型:`string \| TNode`。[通用类型定义](https://github.com/Tencent/tdesign-react/blob/develop/src/common.ts) | N +value | Number | - | 数值显示的值 | N diff --git a/src/statistic/style/css.js b/src/statistic/style/css.js new file mode 100644 index 0000000000..6a9a4b1328 --- /dev/null +++ b/src/statistic/style/css.js @@ -0,0 +1 @@ +import './index.css'; diff --git a/src/statistic/style/index.js b/src/statistic/style/index.js new file mode 100644 index 0000000000..4c0cc38c7d --- /dev/null +++ b/src/statistic/style/index.js @@ -0,0 +1 @@ +import '../../_common/style/web/components/statistic/_index.less'; diff --git a/src/statistic/type.ts b/src/statistic/type.ts new file mode 100644 index 0000000000..64f868a4a7 --- /dev/null +++ b/src/statistic/type.ts @@ -0,0 +1,79 @@ +/* eslint-disable */ + +/** + * 该文件为脚本自动生成文件,请勿随意修改。如需修改请联系 PMC + * */ + +import { TNode } from '../common'; + +export interface TdStatisticProps { + /** + * 动画效果控制,`duration` 指动画的过渡时间`单位:毫秒`,`valueFrom` 指动画的起始数值。`{ duration, valueFrom }` + */ + animation?: animation; + /** + * 是否开始动画 + * @default false + */ + animationStart?: boolean; + /** + * 颜色风格,依次为 TDesign 风格的黑色、蓝色、红色、橙色、绿色。也可以为任何 [CSS color](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value) 支持的 RGB 等值 + */ + color?: 'black' | 'blue' | 'red' | 'orange' | 'green'; + /** + * 小数保留位数 + */ + decimalPlaces?: number; + /** + * 额外的显示内容 + */ + extra?: TNode; + /** + * 格式化数值显示值 + */ + format?: (value: number) => number; + /** + * 是否加载中 + * @default false + */ + loading?: boolean; + /** + * 前缀内容,展示优先级高于 trend + */ + prefix?: TNode; + /** + * 默认展示进位分隔符,可以自定义为其他内容,`separator = ''` 设置为空字符串/null/undefined 时隐藏分隔符 + * @default , + */ + separator?: string; + /** + * 后缀内容,展示优先级高于 trend + */ + suffix?: TNode; + /** + * 数值显示的标题 + */ + title?: TNode; + /** + * 趋势 + */ + trend?: 'increase' | 'decrease'; + /** + * 趋势展示位置 + * @default left + */ + trendPlacement?: 'left' | 'right'; + /** + * 单位内容 + */ + unit?: TNode; + /** + * 数值显示的值 + */ + value?: number; +} + +export interface animation { + duration: number; + valueFrom: number; +} diff --git a/test/snap/__snapshots__/csr.test.jsx.snap b/test/snap/__snapshots__/csr.test.jsx.snap index 2b7eec7090..7027947e18 100644 --- a/test/snap/__snapshots__/csr.test.jsx.snap +++ b/test/snap/__snapshots__/csr.test.jsx.snap @@ -179768,6 +179768,2577 @@ exports[`csr snapshot test > csr test src/space/_example/vertical.jsx 1`] = ` } `; +exports[`csr snapshot test > csr test src/statistic/_example/animation.jsx 1`] = ` +{ + "asFragment": [Function], + "baseElement": +