-
Notifications
You must be signed in to change notification settings - Fork 329
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(statistic): new component Statistic
- Loading branch information
1 parent
55bddb0
commit 3b77b11
Showing
19 changed files
with
729 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,175 @@ | ||
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 './tween'; | ||
|
||
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; | ||
|
||
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 COLOR_MAP = { | ||
blue: 'var(--td-brand-color)', | ||
red: 'var(--td-error-color)', | ||
orange: 'var(--td-warning-color)', | ||
green: 'var(--td-success-color)', | ||
}; | ||
|
||
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(() => { | ||
if (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); | ||
if (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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
import React from 'react'; | ||
import { Space, Button, Statistic } from 'tdesign-react'; | ||
import type { StatisticRef } from '../index'; | ||
|
||
const AnimationStatistic = () => { | ||
const [start, setStart] = React.useState(false); | ||
const [value, setValue] = React.useState(56.32); | ||
const statisticRef = React.useRef<StatisticRef>(); | ||
|
||
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
Oops, something went wrong.