diff --git a/components/__tests__/__snapshots__/index.test.js.snap b/components/__tests__/__snapshots__/index.test.js.snap index 6bfaa07821..211cf437e4 100644 --- a/components/__tests__/__snapshots__/index.test.js.snap +++ b/components/__tests__/__snapshots__/index.test.js.snap @@ -35,6 +35,7 @@ Array [ "message", "Menu", "Modal", + "Statistic", "notification", "Pagination", "Popconfirm", diff --git a/components/config-provider/__tests__/__snapshots__/index.test.js.snap b/components/config-provider/__tests__/__snapshots__/index.test.js.snap index 4c52148da1..b0aed66e56 100644 --- a/components/config-provider/__tests__/__snapshots__/index.test.js.snap +++ b/components/config-provider/__tests__/__snapshots__/index.test.js.snap @@ -11043,6 +11043,66 @@ exports[`ConfigProvider components Spin prefixCls 1`] = ` `; +exports[`ConfigProvider components Statistic configProvider 1`] = ` +
+
+ + + 0 + + +
+
+`; + +exports[`ConfigProvider components Statistic normal 1`] = ` +
+
+ + + 0 + + +
+
+`; + +exports[`ConfigProvider components Statistic prefixCls 1`] = ` +
+
+ + + 0 + + +
+
+`; + exports[`ConfigProvider components Steps configProvider 1`] = `
{ // Spin testPair('Spin', props => ); + // Statistic + testPair('Statistic', props => ); + // Steps testPair('Steps', props => { const myProps = { ...props }; diff --git a/components/config-provider/index.tsx b/components/config-provider/index.tsx index 73aaebbb88..45d9ec7067 100644 --- a/components/config-provider/index.tsx +++ b/components/config-provider/index.tsx @@ -87,9 +87,9 @@ interface ConsumerConfig { } export function withConfigConsumer(config: ConsumerConfig) { - return function(Component: IReactComponent): React.SFC { + return function (Component: IReactComponent): React.SFC & ComponentDef { // Wrap with ConfigConsumer. Since we need compatible with react 15, be care when using ref methods - return (props: ExportProps) => ( + return ((props: ExportProps) => ( {(configProps: ConfigConsumerProps) => { const { prefixCls: basicPrefixCls } = config; @@ -99,7 +99,7 @@ export function withConfigConsumer(config: return ; }} - ); + )) as React.SFC & ComponentDef; }; } diff --git a/components/index.tsx b/components/index.tsx index 6b176d30c2..69d5726d1e 100644 --- a/components/index.tsx +++ b/components/index.tsx @@ -81,6 +81,8 @@ export { default as Menu } from './menu'; export { default as Modal } from './modal'; +export { default as Statistic } from './statistic'; + export { default as notification } from './notification'; export { default as Pagination } from './pagination'; diff --git a/components/statistic/Countdown.tsx b/components/statistic/Countdown.tsx new file mode 100644 index 0000000000..0c48fc0959 --- /dev/null +++ b/components/statistic/Countdown.tsx @@ -0,0 +1,78 @@ +import * as React from 'react'; +import { polyfill } from 'react-lifecycles-compat'; +import * as moment from 'moment'; +import interopDefault from '../_util/interopDefault'; +import Statistic, { StatisticProps } from './Statistic'; +import { formatCountdown, countdownValueType, FormatConfig } from './utils'; + +const REFRESH_INTERVAL = 1000 / 30; + +interface CountdownProps extends StatisticProps { + value?: countdownValueType; + format?: string; +} + +class Countdown extends React.Component { + static defaultProps: Partial = { + format: 'HH:mm:ss', + }; + + countdownId?: number = undefined; + + componentDidMount() { + this.syncTimer(); + } + + componentDidUpdate() { + this.syncTimer(); + } + + componentWillUnmount() { + this.stopTimer(); + } + + syncTimer = () => { + const { value } = this.props; + + const timestamp = interopDefault(moment)(value).valueOf(); + if (timestamp >= Date.now()) { + this.startTimer(); + } else { + this.stopTimer(); + } + }; + + startTimer = () => { + if (this.countdownId !== undefined) return; + + this.countdownId = window.setInterval(() => { + this.forceUpdate(); + }, REFRESH_INTERVAL); + }; + + stopTimer = () => { + clearInterval(this.countdownId); + this.countdownId = undefined; + }; + + formatCountdown = (value: countdownValueType, config: FormatConfig) => { + const { format } = this.props; + return formatCountdown(value, { ...config, format }); + }; + + // Countdown do not need display the timestamp + valueRender = (node: React.ReactElement) => + React.cloneElement(node, { + title: undefined, + }); + + render() { + return ( + + ); + } +} + +polyfill(Countdown); + +export default Countdown; diff --git a/components/statistic/Number.tsx b/components/statistic/Number.tsx new file mode 100644 index 0000000000..2e8c61f0bb --- /dev/null +++ b/components/statistic/Number.tsx @@ -0,0 +1,55 @@ +import * as React from 'react'; +import padEnd from 'lodash/padEnd'; +import { valueType, FormatConfig } from './utils'; + +interface NumberProps extends FormatConfig { + value: valueType; +} + +const StatisticNumber: React.SFC = props => { + const { value, formatter, precision, decimalSeparator, prefixCls } = props; + + let valueNode: React.ReactNode; + + if (typeof formatter === 'function') { + // Customize formatter + valueNode = formatter(value); + } else { + // Internal formatter + const val: string = String(value); + const cells = val.match(/^(\d*)(\.(\d+))?$/); + + // Process if illegal number + if (!cells) { + valueNode = val; + } else { + let int = cells[1] || '0'; + let decimal = cells[3] || ''; + + int = int.replace(/\B(?=(\d{3})+(?!\d))/g, ','); + + if (typeof precision === 'number') { + decimal = padEnd(decimal, precision, '0').slice(0, precision); + } + + if (decimal) { + decimal = `${decimalSeparator}${decimal}`; + } + + valueNode = [ + + {int} + , + decimal && ( + + {decimal} + + ), + ]; + } + } + + return {valueNode}; +}; + +export default StatisticNumber; diff --git a/components/statistic/Statistic.tsx b/components/statistic/Statistic.tsx new file mode 100644 index 0000000000..c425f1e1f8 --- /dev/null +++ b/components/statistic/Statistic.tsx @@ -0,0 +1,64 @@ +import * as React from 'react'; +import classNames from 'classnames'; + +import { withConfigConsumer, ConfigConsumerProps } from '../config-provider'; +import StatisticNumber from './Number'; +import Countdown from './Countdown'; +import { valueType, FormatConfig } from './utils'; + +interface StatisticComponent { + Countdown: typeof Countdown; +} + +export interface StatisticProps extends FormatConfig { + prefixCls?: string; + className?: string; + style?: React.CSSProperties; + value?: valueType; + valueStyle?: React.CSSProperties; + valueRender?: (node: React.ReactNode) => React.ReactNode; + title?: React.ReactNode; + prefix?: React.ReactNode; + suffix?: React.ReactNode; +} + +const Statistic: React.SFC = props => { + const { + prefixCls, + className, + style, + valueStyle, + value = 0, + title, + valueRender, + prefix, + suffix, + } = props; + + let valueNode: React.ReactNode = ; + + if (valueRender) { + valueNode = valueRender(valueNode); + } + + return ( +
+ {title &&
{title}
} +
+ {prefix && {prefix}} + {valueNode} + {suffix && {suffix}} +
+
+ ); +}; + +Statistic.defaultProps = { + decimalSeparator: '.', +}; + +const WrapperStatistic = withConfigConsumer({ + prefixCls: 'statistic', +})(Statistic); + +export default WrapperStatistic; diff --git a/components/statistic/__tests__/__snapshots__/demo.test.js.snap b/components/statistic/__tests__/__snapshots__/demo.test.js.snap new file mode 100644 index 0000000000..0977607393 --- /dev/null +++ b/components/statistic/__tests__/__snapshots__/demo.test.js.snap @@ -0,0 +1,388 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders ./components/statistic/demo/basic.md correctly 1`] = ` +
+
+
+
+ Active Users +
+
+ + + 112,893 + + +
+
+
+
+
+
+ Account Balance (CNY) +
+
+ + + 112,893 + + + .00 + + +
+
+ +
+
+`; + +exports[`renders ./components/statistic/demo/card.md correctly 1`] = ` +
+
+
+
+
+
+
+ Active +
+
+ + + + + + + + 11 + + + .28 + + + + % + +
+
+
+
+
+
+
+
+
+
+ Idle +
+
+ + + + + + + + 9 + + + .30 + + + + % + +
+
+
+
+
+
+
+`; + +exports[`renders ./components/statistic/demo/countdown.md correctly 1`] = ` +
+
+
+
+ Countdown +
+
+ + 48:00:30 + +
+
+
+
+
+
+ Million Seconds +
+
+ + 48:00:30:000 + +
+
+
+
+
+
+ Day Level +
+
+ + 2 天 0 时 0 分 30 秒 + +
+
+
+
+`; + +exports[`renders ./components/statistic/demo/unit.md correctly 1`] = ` +
+
+
+
+ Feedback +
+
+ + + + + + + + 1,128 + + +
+
+
+
+
+
+ Unmerged +
+
+ + + 93 + + + + / 100 + +
+
+
+
+`; diff --git a/components/statistic/__tests__/demo.test.js b/components/statistic/__tests__/demo.test.js new file mode 100644 index 0000000000..2cc48661ee --- /dev/null +++ b/components/statistic/__tests__/demo.test.js @@ -0,0 +1,3 @@ +import demoTest from '../../../tests/shared/demoTest'; + +demoTest('statistic'); diff --git a/components/statistic/__tests__/index.test.js b/components/statistic/__tests__/index.test.js new file mode 100644 index 0000000000..e7e5a09f5f --- /dev/null +++ b/components/statistic/__tests__/index.test.js @@ -0,0 +1,65 @@ +import React from 'react'; +import MockDate from 'mockdate'; +import moment from 'moment'; +import { mount } from 'enzyme'; +import Statistic from '..'; + +const delay = timeout => new Promise(resolve => setTimeout(resolve, timeout)); + +describe('Statistic', () => { + beforeAll(() => { + MockDate.set(moment('2018-11-28 00:00:00')); + }); + + afterAll(() => { + MockDate.reset(); + }); + + it('customize formatter', () => { + const formatter = jest.fn(() => 93); + const wrapper = mount(); + expect(formatter).toBeCalledWith(1128); + expect(wrapper.find('.ant-statistic-content-value').text()).toEqual('93'); + }); + + it('not a number', () => { + const wrapper = mount(); + expect(wrapper.find('.ant-statistic-content-value').text()).toEqual('bamboo'); + }); + + describe('Countdown', () => { + it('render correctly', () => { + const now = moment() + .add(2, 'd') + .add(11, 'h') + .add(28, 'm') + .add(9, 's') + .add(3, 'ms'); + + [ + ['H:m:s', '59:28:9'], + ['HH:mm:ss', '59:28:09'], + ['HH:mm:ss:SSS', '59:28:09:003'], + ['DD-HH:mm:ss', '02-11:28:09'], + ].forEach(([format, value]) => { + const wrapper = mount(); + expect(wrapper.find('.ant-statistic-content-value').text()).toEqual(value); + }); + }); + + it('time going', async () => { + const now = Date.now() + 1000; + const wrapper = mount(); + wrapper.update(); + + // setInterval should work + const instance = wrapper.instance(); + expect(instance.countdownId).not.toBe(undefined); + + await delay(50); + + wrapper.unmount(); + expect(instance.countdownId).toBe(undefined); + }); + }); +}); diff --git a/components/statistic/demo/basic.md b/components/statistic/demo/basic.md new file mode 100644 index 0000000000..ca1a29e122 --- /dev/null +++ b/components/statistic/demo/basic.md @@ -0,0 +1,31 @@ +--- +order: 0 +title: + zh-CN: 基本 + en-US: Basic +--- + +## zh-CN + +简单的展示。 + +## en-US + +Simplest Usage. + +```jsx +import { Statistic, Row, Col, Button } from 'antd'; + +ReactDOM.render( + + + + + + + + + , + mountNode +); +``` diff --git a/components/statistic/demo/card.md b/components/statistic/demo/card.md new file mode 100644 index 0000000000..66768824d0 --- /dev/null +++ b/components/statistic/demo/card.md @@ -0,0 +1,50 @@ +--- +order: 2 +title: + zh-CN: 在卡片中使用 + en-US: In Card +--- + +## zh-CN + +在卡片中展示统计数值。 + +## en-US + +Display statistic data in Card. + +```jsx +import { Statistic, Card, Row, Col, Icon } from 'antd'; + +ReactDOM.render( +
+ + + + } + suffix="%" + /> + + + + + } + suffix="%" + /> + + + +
, + mountNode +); +``` diff --git a/components/statistic/demo/countdown.md b/components/statistic/demo/countdown.md new file mode 100644 index 0000000000..0bfc1feb12 --- /dev/null +++ b/components/statistic/demo/countdown.md @@ -0,0 +1,36 @@ +--- +order: 3 +title: + zh-CN: 倒计时 + en-US: Countdown +--- + +## zh-CN + +倒计时组件。 + +## en-US + +Countdown component. + +```jsx +import { Statistic, Row, Col } from 'antd'; + +const Countdown = Statistic.Countdown; +const deadline = Date.now() + 1000 * 60 * 60 * 24 * 2 + 1000 * 30; // Moment is also OK + +ReactDOM.render( + + + + + + + + + + + , + mountNode +); +``` diff --git a/components/statistic/demo/unit.md b/components/statistic/demo/unit.md new file mode 100644 index 0000000000..eb94db72dc --- /dev/null +++ b/components/statistic/demo/unit.md @@ -0,0 +1,30 @@ +--- +order: 1 +title: + zh-CN: 单位 + en-US: Unit +--- + +## zh-CN + +通过前缀和后缀添加单位。 + +## en-US + +Add unit through `prefix` and `suffix`. + +```jsx +import { Statistic, Row, Col, Icon } from 'antd'; + +ReactDOM.render( + + + } /> + + + + + , + mountNode +); +``` diff --git a/components/statistic/index.en-US.md b/components/statistic/index.en-US.md new file mode 100644 index 0000000000..97b41ea7dc --- /dev/null +++ b/components/statistic/index.en-US.md @@ -0,0 +1,38 @@ +--- +category: Components +type: Data Display +title: Statistic +--- + +Display statistic number. + +## When To Use + +* When want to highlight some data. +* When want to display statistic data with description. + +## API + +#### Statistic + +| Property | Description | Type | Default | +| -------- | ----------- | ---- | ------- | +| decimalSeparator | decimal separator | string | - | +| formatter | customize value display logic | (value) => ReactNode | - | +| precision | precision of input value | number | - | +| prefix | prefix node of value | string \| ReactNode | - | +| suffix | suffix node of value | string \| ReactNode | - | +| title | Display title | string \| ReactNode | - | +| value | Display value | string \| number | - | +| valueStyle | Set value css style | style | - | + +#### Statistic.Countdown + +| Property | Description | Type | Default | +| -------- | ----------- | ---- | ------- | +| format | Format as [moment](http://momentjs.com/) | string | 'HH:mm:ss' | +| prefix | prefix node of value | string \| ReactNode | - | +| suffix | suffix node of value | string \| ReactNode | - | +| title | Display title | string \| ReactNode | - | +| value | Set target countdown time | number \| moment | - | +| valueStyle | Set value css style | style | - | diff --git a/components/statistic/index.tsx b/components/statistic/index.tsx new file mode 100644 index 0000000000..3f60608f64 --- /dev/null +++ b/components/statistic/index.tsx @@ -0,0 +1,6 @@ +import Statistic from './Statistic'; +import Countdown from './Countdown'; + +Statistic.Countdown = Countdown; + +export default Statistic; diff --git a/components/statistic/index.zh-CN.md b/components/statistic/index.zh-CN.md new file mode 100644 index 0000000000..2525621cd8 --- /dev/null +++ b/components/statistic/index.zh-CN.md @@ -0,0 +1,39 @@ +--- +category: Components +subtitle: 统计数值 +type: 数据展示 +title: Statistic +--- + +展示统计数值。 + +## 何时使用 + +* 当需要突出某个或某组数字时。 +* 当需要展示带描述的统计类数据时使用。 + +## API + +#### Statistic + +| 参数 | 说明 | 类型 | 默认值 | +| -------- | ----------- | ---- | ------- | +| decimalSeparator | 设置小数点 | string | - | +| formatter | 自定义数值展示 | (value) => ReactNode | - | +| precision | 数值精度 | number | - | +| prefix | 设置数值的前缀 | string \| ReactNode | - | +| suffix | 设置数值的后缀 | string \| ReactNode | - | +| title | 数值的标题 | string \| ReactNode | - | +| value | 数值内容 | string \| number | - | +| valueStyle | 设置数值的样式 | style | - | + +#### Statistic.Countdown + +| 参数 | 说明 | 类型 | 默认值 | +| -------- | ----------- | ---- | ------- | +| format | 格式化倒计时展示,参考 [moment](http://momentjs.com/) | string | 'HH:mm:ss' | +| prefix | 设置数值的前缀 | string \| ReactNode | - | +| suffix | 设置数值的后缀 | string \| ReactNode | - | +| title | 数值的标题 | string \| ReactNode | - | +| value | 数值内容 | number \| moment | - | +| valueStyle | 设置数值的样式 | style | - | diff --git a/components/statistic/style/index.less b/components/statistic/style/index.less new file mode 100644 index 0000000000..541457a852 --- /dev/null +++ b/components/statistic/style/index.less @@ -0,0 +1,38 @@ +@import '../../style/themes/default'; +@import '../../style/mixins/index'; + +@statistic-prefix-cls: ~'@{ant-prefix}-statistic'; + +.@{statistic-prefix-cls} { + .reset-component; + + &-title { + font-size: @statistic-title-font-size; + margin-bottom: 4px; + } + + &-content { + font-size: @statistic-content-font-size; + font-family: @statistic-font-family; + + &-value { + &-decimal { + font-size: @statistic-unit-font-size; + } + } + + &-prefix, + &-suffix { + display: inline-block; + } + + &-prefix { + margin-right: 4px; + } + + &-suffix { + margin-left: 4px; + font-size: @statistic-unit-font-size; + } + } +} diff --git a/components/statistic/style/index.tsx b/components/statistic/style/index.tsx new file mode 100644 index 0000000000..3a3ab0de59 --- /dev/null +++ b/components/statistic/style/index.tsx @@ -0,0 +1,2 @@ +import '../../style/index.less'; +import './index.less'; diff --git a/components/statistic/utils.tsx b/components/statistic/utils.tsx new file mode 100644 index 0000000000..eb0f8d55c4 --- /dev/null +++ b/components/statistic/utils.tsx @@ -0,0 +1,60 @@ +import * as React from 'react'; +import * as moment from 'moment'; +import padStart from 'lodash/padStart'; +import interopDefault from '../_util/interopDefault'; + +export type valueType = number | string; +export type countdownValueType = valueType | string; + +export type Formatter = + | false + | 'number' + | 'countdown' + | ((value: valueType, config?: FormatConfig) => React.ReactNode); + +export interface FormatConfig { + formatter?: Formatter; + decimalSeparator?: string; + precision?: number; + prefixCls?: string; +} + +export interface CountdownFormatConfig extends FormatConfig { + format?: string; +} + +// Countdown +const timeUnits: [string, number][] = [ + ['Y', 1000 * 60 * 60 * 24 * 365], // years + ['M', 1000 * 60 * 60 * 24 * 30], // months + ['D', 1000 * 60 * 60 * 24], // days + ['H', 1000 * 60 * 60], // hours + ['m', 1000 * 60], // minutes + ['s', 1000], // seconds + ['S', 1], // million seconds +]; + +function formatTimeStr(duration: number, format: string) { + let leftDuration: number = duration; + + return timeUnits.reduce((current, [name, unit]) => { + if (current.indexOf(name) !== -1) { + const value = Math.floor(leftDuration / unit); + leftDuration -= value * unit; + return current.replace(new RegExp(`${name}+`, 'g'), (match: string) => { + const len = match.length; + return padStart(value.toString(), len, '0'); + }); + } + return current; + }, format); +} + +export function formatCountdown(value: countdownValueType, config: CountdownFormatConfig) { + const { format = '' } = config; + const target = interopDefault(moment)(value).valueOf(); + const current = interopDefault(moment)().valueOf(); + const diff = Math.max(target - current, 0); + + return formatTimeStr(diff, format); +} diff --git a/components/style/themes/default.less b/components/style/themes/default.less index c06dfb2dd8..bb2ea0b755 100644 --- a/components/style/themes/default.less +++ b/components/style/themes/default.less @@ -562,3 +562,10 @@ @list-item-meta-margin-bottom: @padding-md; @list-item-meta-avatar-margin-right: @padding-md; @list-item-meta-title-margin-bottom: @padding-sm; + +// Statistic +// --- +@statistic-title-font-size: @font-size-base; +@statistic-content-font-size: 24px; +@statistic-unit-font-size: 16px; +@statistic-font-family: Tahoma, 'Helvetica Neue', @font-family; diff --git a/tests/__snapshots__/index.test.js.snap b/tests/__snapshots__/index.test.js.snap index b0dc6f3b84..83abea77f6 100644 --- a/tests/__snapshots__/index.test.js.snap +++ b/tests/__snapshots__/index.test.js.snap @@ -35,6 +35,7 @@ Array [ "message", "Menu", "Modal", + "Statistic", "notification", "Pagination", "Popconfirm", diff --git a/typings/custom-typings.d.ts b/typings/custom-typings.d.ts index 8449ff9c8f..0af56db4d0 100644 --- a/typings/custom-typings.d.ts +++ b/typings/custom-typings.d.ts @@ -93,6 +93,10 @@ declare module '*.json' { declare module 'lodash/debounce'; +declare module 'lodash/padStart'; + +declare module 'lodash/padEnd'; + declare module 'lodash/uniqBy'; declare module "raf";