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`] = `
+
+`;
+
+exports[`ConfigProvider components Statistic normal 1`] = `
+
+`;
+
+exports[`ConfigProvider components Statistic prefixCls 1`] = `
+
+`;
+
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";