diff --git a/src-next/count-down/README.md b/src-next/count-down/README.md
new file mode 100644
index 000000000..0ed8d66f8
--- /dev/null
+++ b/src-next/count-down/README.md
@@ -0,0 +1,165 @@
+# CountDown
+
+### Install
+
+```js
+import Vue from 'vue';
+import { CountDown } from 'vant';
+
+Vue.use(CountDown);
+```
+
+## Usage
+
+### Basic Usage
+
+```html
+
+```
+
+```js
+export default {
+ data() {
+ return {
+ time: 30 * 60 * 60 * 1000,
+ };
+ },
+};
+```
+
+### Custom Format
+
+```html
+
+```
+
+### Millisecond
+
+```html
+
+```
+
+### Custom Style
+
+```html
+
+
+ {{ timeData.hours }}
+ :
+ {{ timeData.minutes }}
+ :
+ {{ timeData.seconds }}
+
+
+
+
+```
+
+### Manual Control
+
+```html
+
+
+
+
+
+
+```
+
+```js
+import { Toast } from 'vant';
+
+export default {
+ methods: {
+ start() {
+ this.$refs.countDown.start();
+ },
+ pause() {
+ this.$refs.countDown.pause();
+ },
+ reset() {
+ this.$refs.countDown.reset();
+ },
+ finish() {
+ Toast('Finished');
+ },
+ },
+};
+```
+
+## API
+
+### Props
+
+| Attribute | Description | Type | Default |
+| --- | --- | --- | --- |
+| time | Total time | _number \| string_ | `0` |
+| format | Time format | _string_ | `HH:mm:ss` |
+| auto-start | Whether to auto start count down | _boolean_ | `true` |
+| millisecond | Whether to enable millisecond render | _boolean_ | `false` |
+
+### Available formats
+
+| Format | Description |
+| ------ | --------------------- |
+| DD | Day |
+| HH | Hour |
+| mm | Minute |
+| ss | Second |
+| S | Millisecond, 1-digit |
+| SS | Millisecond, 2-digits |
+| SSS | Millisecond, 3-digits |
+
+### Events
+
+| Event | Description | Arguments |
+| --------------- | ---------------------------------- | -------------------- |
+| finish | Triggered when count down finished | - |
+| change `v2.4.4` | Triggered when count down changed | _timeData: TimeData_ |
+
+### Slots
+
+| Name | Description | SlotProps |
+| ------- | -------------- | -------------------- |
+| default | Custom Content | _timeData: TimeData_ |
+
+### TimeData Structure
+
+| Name | Description | Type |
+| ------------ | ------------------- | -------- |
+| days | Remain days | _number_ |
+| hours | Remain hours | _number_ |
+| minutes | Remain minutes | _number_ |
+| seconds | Remain seconds | _number_ |
+| milliseconds | Remain milliseconds | _number_ |
+
+### Methods
+
+Use [ref](https://vuejs.org/v2/api/#ref) to get CountDown instance and call instance methods
+
+| Name | Description | Attribute | Return value |
+| ----- | ---------------- | --------- | ------------ |
+| start | Start count down | - | - |
+| pause | Pause count down | - | - |
+| reset | Reset count down | - | - |
diff --git a/src-next/count-down/README.zh-CN.md b/src-next/count-down/README.zh-CN.md
new file mode 100644
index 000000000..cfd1aada5
--- /dev/null
+++ b/src-next/count-down/README.zh-CN.md
@@ -0,0 +1,183 @@
+# CountDown 倒计时
+
+### 引入
+
+```js
+import Vue from 'vue';
+import { CountDown } from 'vant';
+
+Vue.use(CountDown);
+```
+
+## 代码演示
+
+### 基础用法
+
+`time`属性表示倒计时总时长,单位为毫秒
+
+```html
+
+```
+
+```js
+export default {
+ data() {
+ return {
+ time: 30 * 60 * 60 * 1000,
+ };
+ },
+};
+```
+
+### 自定义格式
+
+通过`format`属性设置倒计时文本的内容
+
+```html
+
+```
+
+### 毫秒级渲染
+
+倒计时默认每秒渲染一次,设置`millisecond`属性可以开启毫秒级渲染
+
+```html
+
+```
+
+### 自定义样式
+
+通过插槽自定义倒计时的样式,`timeData`对象格式见下方表格
+
+```html
+
+
+ {{ timeData.hours }}
+ :
+ {{ timeData.minutes }}
+ :
+ {{ timeData.seconds }}
+
+
+
+
+```
+
+### 手动控制
+
+通过 ref 获取到组件实例后,可以调用`start`、`pause`、`reset`方法
+
+```html
+
+
+
+
+
+
+```
+
+```js
+import { Toast } from 'vant';
+
+export default {
+ methods: {
+ start() {
+ this.$refs.countDown.start();
+ },
+ pause() {
+ this.$refs.countDown.pause();
+ },
+ reset() {
+ this.$refs.countDown.reset();
+ },
+ finish() {
+ Toast('倒计时结束');
+ },
+ },
+};
+```
+
+## API
+
+### Props
+
+| 参数 | 说明 | 类型 | 默认值 |
+| ----------- | -------------------- | ------------------ | ---------- |
+| time | 倒计时时长,单位毫秒 | _number \| string_ | `0` |
+| format | 时间格式 | _string_ | `HH:mm:ss` |
+| auto-start | 是否自动开始倒计时 | _boolean_ | `true` |
+| millisecond | 是否开启毫秒级渲染 | _boolean_ | `false` |
+
+### format 格式
+
+| 格式 | 说明 |
+| ---- | ------------ |
+| DD | 天数 |
+| HH | 小时 |
+| mm | 分钟 |
+| ss | 秒数 |
+| S | 毫秒(1 位) |
+| SS | 毫秒(2 位) |
+| SSS | 毫秒(3 位) |
+
+### Events
+
+| 事件名 | 说明 | 回调参数 |
+| --------------- | ---------------- | -------------------- |
+| finish | 倒计时结束时触发 | - |
+| change `v2.4.4` | 倒计时变化时触发 | _timeData: TimeData_ |
+
+### Slots
+
+| 名称 | 说明 | SlotProps |
+| ------- | ---------- | -------------------- |
+| default | 自定义内容 | _timeData: TimeData_ |
+
+### TimeData 格式
+
+| 名称 | 说明 | 类型 |
+| ------------ | -------- | -------- |
+| days | 剩余天数 | _number_ |
+| hours | 剩余小时 | _number_ |
+| minutes | 剩余分钟 | _number_ |
+| seconds | 剩余秒数 | _number_ |
+| milliseconds | 剩余毫秒 | _number_ |
+
+### 方法
+
+通过 ref 可以获取到 CountDown 实例并调用实例方法,详见[组件实例方法](#/zh-CN/quickstart#zu-jian-shi-li-fang-fa)
+
+| 方法名 | 说明 | 参数 | 返回值 |
+| --- | --- | --- | --- |
+| start | 开始倒计时 | - | - |
+| pause | 暂停倒计时 | - | - |
+| reset | 重设倒计时,若`auto-start`为`true`,重设后会自动开始倒计时 | - | - |
+
+## 常见问题
+
+### 在 iOS 系统上倒计时不生效?
+
+如果你遇到了在 iOS 上倒计时不生效的问题,请确认在创建 Date 对象时没有使用`new Date('2020-01-01')`这样的写法,iOS 不支持以中划线分隔的日期格式,正确写法是`new Date('2020/01/01')`。
+
+对此问题的详细解释:[stackoverflow](https://stackoverflow.com/questions/13363673/javascript-date-is-invalid-on-ios)。
diff --git a/src-next/count-down/demo/index.vue b/src-next/count-down/demo/index.vue
new file mode 100644
index 000000000..47619b927
--- /dev/null
+++ b/src-next/count-down/demo/index.vue
@@ -0,0 +1,130 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ currentTime.hours }}
+ :
+ {{ currentTime.minutes }}
+ :
+ {{ currentTime.seconds }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src-next/count-down/index.js b/src-next/count-down/index.js
new file mode 100644
index 000000000..705103305
--- /dev/null
+++ b/src-next/count-down/index.js
@@ -0,0 +1,164 @@
+import { createNamespace } from '../utils';
+import { raf, cancelRaf } from '../utils/dom/raf';
+import { isSameSecond, parseTimeData, parseFormat } from './utils';
+
+const [createComponent, bem] = createNamespace('count-down');
+
+export default createComponent({
+ props: {
+ millisecond: Boolean,
+ time: {
+ type: [Number, String],
+ default: 0,
+ },
+ format: {
+ type: String,
+ default: 'HH:mm:ss',
+ },
+ autoStart: {
+ type: Boolean,
+ default: true,
+ },
+ },
+
+ data() {
+ return {
+ remain: 0,
+ };
+ },
+
+ computed: {
+ timeData() {
+ return parseTimeData(this.remain);
+ },
+
+ formattedTime() {
+ return parseFormat(this.format, this.timeData);
+ },
+ },
+
+ watch: {
+ time: {
+ immediate: true,
+ handler() {
+ this.reset();
+ },
+ },
+ },
+
+ activated() {
+ if (this.keepAlivePaused) {
+ this.counting = true;
+ this.keepAlivePaused = false;
+ this.tick();
+ }
+ },
+
+ deactivated() {
+ if (this.counting) {
+ this.pause();
+ this.keepAlivePaused = true;
+ }
+ },
+
+ beforeDestroy() {
+ this.pause();
+ },
+
+ methods: {
+ // @exposed-api
+ start() {
+ if (this.counting) {
+ return;
+ }
+
+ this.counting = true;
+ this.endTime = Date.now() + this.remain;
+ this.tick();
+ },
+
+ // @exposed-api
+ pause() {
+ this.counting = false;
+ cancelRaf(this.rafId);
+ },
+
+ // @exposed-api
+ reset() {
+ this.pause();
+ this.remain = +this.time;
+
+ if (this.autoStart) {
+ this.start();
+ }
+ },
+
+ tick() {
+ if (this.millisecond) {
+ this.microTick();
+ } else {
+ this.macroTick();
+ }
+ },
+
+ microTick() {
+ this.rafId = raf(() => {
+ /* istanbul ignore if */
+ // in case of call reset immediately after finish
+ if (!this.counting) {
+ return;
+ }
+
+ this.setRemain(this.getRemain());
+
+ if (this.remain > 0) {
+ this.microTick();
+ }
+ });
+ },
+
+ macroTick() {
+ this.rafId = raf(() => {
+ /* istanbul ignore if */
+ // in case of call reset immediately after finish
+ if (!this.counting) {
+ return;
+ }
+
+ const remain = this.getRemain();
+
+ if (!isSameSecond(remain, this.remain) || remain === 0) {
+ this.setRemain(remain);
+ }
+
+ if (this.remain > 0) {
+ this.macroTick();
+ }
+ });
+ },
+
+ getRemain() {
+ return Math.max(this.endTime - Date.now(), 0);
+ },
+
+ setRemain(remain) {
+ this.remain = remain;
+ this.$emit('change', this.timeData);
+
+ if (remain === 0) {
+ this.pause();
+ this.$emit('finish');
+ }
+ },
+ },
+
+ render() {
+ return (
+
+ {this.$slots.default
+ ? this.$slots.default(this.timeData)
+ : this.formattedTime}
+
+ );
+ },
+});
diff --git a/src-next/count-down/index.less b/src-next/count-down/index.less
new file mode 100644
index 000000000..5a6206a0b
--- /dev/null
+++ b/src-next/count-down/index.less
@@ -0,0 +1,7 @@
+@import '../style/var';
+
+.van-count-down {
+ color: @count-down-text-color;
+ font-size: @count-down-font-size;
+ line-height: @count-down-line-height;
+}
diff --git a/src-next/count-down/test/__snapshots__/index.spec.js.snap b/src-next/count-down/test/__snapshots__/index.spec.js.snap
new file mode 100644
index 000000000..24d2499e0
--- /dev/null
+++ b/src-next/count-down/test/__snapshots__/index.spec.js.snap
@@ -0,0 +1,11 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`complete format prop 1`] = `01-05-59-59-999
`;
+
+exports[`disable auto-start prop 1`] = `100
`;
+
+exports[`incomplate format prop 1`] = `29-59-59-999
`;
+
+exports[`milliseconds format S 1`] = `01-5
`;
+
+exports[`milliseconds format SS 1`] = `01-50
`;
diff --git a/src-next/count-down/test/index.spec.js b/src-next/count-down/test/index.spec.js
new file mode 100644
index 000000000..1f0376509
--- /dev/null
+++ b/src-next/count-down/test/index.spec.js
@@ -0,0 +1,233 @@
+import CountDown from '..';
+import { mount, later } from '../../../test';
+
+test('macro task finish event', async () => {
+ const wrapper = mount(CountDown, {
+ propsData: {
+ time: 1,
+ },
+ });
+
+ expect(wrapper.emitted('finish')).toBeFalsy();
+ await later(50);
+ expect(wrapper.emitted('finish')).toBeTruthy();
+});
+
+test('micro task finish event', async () => {
+ const wrapper = mount(CountDown, {
+ propsData: {
+ time: 1,
+ millisecond: true,
+ },
+ });
+
+ expect(wrapper.emitted('finish')).toBeFalsy();
+ await later(50);
+ expect(wrapper.emitted('finish')).toBeTruthy();
+});
+
+test('macro task re-render', async () => {
+ const wrapper = mount(CountDown, {
+ propsData: {
+ time: 1000,
+ format: 'SSS',
+ },
+ });
+
+ const prevSnapShot = wrapper.html();
+ await later(50);
+ const laterSnapShot = wrapper.html();
+
+ expect(prevSnapShot !== laterSnapShot).toBeTruthy();
+});
+
+test('micro task re-render', async () => {
+ const wrapper = mount(CountDown, {
+ propsData: {
+ time: 100,
+ format: 'SSS',
+ millisecond: true,
+ },
+ });
+
+ const prevSnapShot = wrapper.html();
+ await later(50);
+ const laterSnapShot = wrapper.html();
+
+ expect(prevSnapShot !== laterSnapShot).toBeTruthy();
+});
+
+test('disable auto-start prop', async () => {
+ const wrapper = mount(CountDown, {
+ propsData: {
+ time: 100,
+ format: 'SSS',
+ autoStart: false,
+ },
+ });
+
+ await later(50);
+ expect(wrapper).toMatchSnapshot();
+});
+
+test('start method', async () => {
+ const wrapper = mount(CountDown, {
+ propsData: {
+ time: 100,
+ format: 'SSS',
+ autoStart: false,
+ millisecond: true,
+ },
+ });
+
+ const prevSnapShot = wrapper.html();
+
+ wrapper.vm.start();
+ wrapper.vm.start();
+
+ await later(50);
+
+ const laterShapShot = wrapper.html();
+
+ expect(prevSnapShot !== laterShapShot).toBeTruthy();
+});
+
+test('pause method', async () => {
+ const wrapper = mount(CountDown, {
+ propsData: {
+ time: 100,
+ format: 'SSS',
+ millisecond: true,
+ },
+ });
+
+ const prevSnapShot = wrapper.html();
+ wrapper.vm.pause();
+ await later(50);
+ const laterShapShot = wrapper.html();
+
+ expect(prevSnapShot === laterShapShot).toBeTruthy();
+});
+
+test('reset method', async () => {
+ const wrapper = mount(CountDown, {
+ propsData: {
+ time: 100,
+ format: 'SSS',
+ millisecond: true,
+ },
+ });
+
+ const prevSnapShot = wrapper.html();
+ await later(50);
+ wrapper.vm.reset();
+ const laterShapShot = wrapper.html();
+
+ expect(prevSnapShot === laterShapShot).toBeTruthy();
+});
+
+test('complete format prop', () => {
+ const wrapper = mount(CountDown, {
+ propsData: {
+ time: 30 * 60 * 60 * 1000 - 1,
+ autoStart: false,
+ format: 'DD-HH-mm-ss-SSS',
+ },
+ });
+
+ expect(wrapper).toMatchSnapshot();
+});
+
+test('milliseconds format SS', () => {
+ const wrapper = mount(CountDown, {
+ propsData: {
+ time: 1500,
+ autoStart: false,
+ format: 'ss-SS',
+ },
+ });
+
+ expect(wrapper).toMatchSnapshot();
+});
+
+test('milliseconds format S', () => {
+ const wrapper = mount(CountDown, {
+ propsData: {
+ time: 1500,
+ autoStart: false,
+ format: 'ss-S',
+ },
+ });
+
+ expect(wrapper).toMatchSnapshot();
+});
+
+test('incomplate format prop', () => {
+ const wrapper = mount(CountDown, {
+ propsData: {
+ time: 30 * 60 * 60 * 1000 - 1,
+ autoStart: false,
+ format: 'HH-mm-ss-SSS',
+ },
+ });
+
+ expect(wrapper).toMatchSnapshot();
+});
+
+test('pause when destroyed', () => {
+ const wrapper = mount(CountDown);
+ expect(wrapper.vm.counting).toBeTruthy();
+ wrapper.destroy();
+ expect(wrapper.vm.counting).toBeFalsy();
+});
+
+test('pause when deactivated', async () => {
+ const wrapper = mount({
+ template: `
+
+
+
+ `,
+ data() {
+ return {
+ render: true,
+ };
+ },
+ methods: {
+ getCountDown() {
+ return this.$refs.countDown;
+ },
+ },
+ });
+
+ const countDown = wrapper.vm.getCountDown();
+ expect(countDown.counting).toBeTruthy();
+
+ wrapper.setData({ render: false });
+ expect(countDown.counting).toBeFalsy();
+ wrapper.setData({ render: true });
+ expect(countDown.counting).toBeTruthy();
+
+ countDown.pause();
+ wrapper.setData({ render: false });
+ wrapper.setData({ render: true });
+ expect(countDown.counting).toBeFalsy();
+});
+
+test('change event', async () => {
+ const wrapper = mount(CountDown, {
+ propsData: {
+ time: 1,
+ },
+ });
+
+ expect(wrapper.emitted('change')).toBeFalsy();
+ await later(50);
+ expect(wrapper.emitted('change')[0][0]).toEqual({
+ days: 0,
+ hours: 0,
+ milliseconds: 0,
+ minutes: 0,
+ seconds: 0,
+ });
+});
diff --git a/src-next/count-down/utils.ts b/src-next/count-down/utils.ts
new file mode 100644
index 000000000..63c17264e
--- /dev/null
+++ b/src-next/count-down/utils.ts
@@ -0,0 +1,77 @@
+import { padZero } from '../utils/format/string';
+
+export type TimeData = {
+ days: number;
+ hours: number;
+ minutes: number;
+ seconds: number;
+ milliseconds: number;
+};
+
+const SECOND = 1000;
+const MINUTE = 60 * SECOND;
+const HOUR = 60 * MINUTE;
+const DAY = 24 * HOUR;
+
+export function parseTimeData(time: number): TimeData {
+ const days = Math.floor(time / DAY);
+ const hours = Math.floor((time % DAY) / HOUR);
+ const minutes = Math.floor((time % HOUR) / MINUTE);
+ const seconds = Math.floor((time % MINUTE) / SECOND);
+ const milliseconds = Math.floor(time % SECOND);
+
+ return {
+ days,
+ hours,
+ minutes,
+ seconds,
+ milliseconds,
+ };
+}
+
+export function parseFormat(format: string, timeData: TimeData): string {
+ const { days } = timeData;
+ let { hours, minutes, seconds, milliseconds } = timeData;
+
+ if (format.indexOf('DD') === -1) {
+ hours += days * 24;
+ } else {
+ format = format.replace('DD', padZero(days));
+ }
+
+ if (format.indexOf('HH') === -1) {
+ minutes += hours * 60;
+ } else {
+ format = format.replace('HH', padZero(hours));
+ }
+
+ if (format.indexOf('mm') === -1) {
+ seconds += minutes * 60;
+ } else {
+ format = format.replace('mm', padZero(minutes));
+ }
+
+ if (format.indexOf('ss') === -1) {
+ milliseconds += seconds * 1000;
+ } else {
+ format = format.replace('ss', padZero(seconds));
+ }
+
+ if (format.indexOf('S') !== -1) {
+ const ms = padZero(milliseconds, 3);
+
+ if (format.indexOf('SSS') !== -1) {
+ format = format.replace('SSS', ms);
+ } else if (format.indexOf('SS') !== -1) {
+ format = format.replace('SS', ms.slice(0, 2));
+ } else {
+ format = format.replace('S', ms.charAt(0));
+ }
+ }
+
+ return format;
+}
+
+export function isSameSecond(time1: number, time2: number): boolean {
+ return Math.floor(time1 / 1000) === Math.floor(time2 / 1000);
+}
diff --git a/vant.config.js b/vant.config.js
index 5a67a5bc6..bd5cc7bfe 100644
--- a/vant.config.js
+++ b/vant.config.js
@@ -229,10 +229,10 @@ module.exports = {
// path: 'collapse',
// title: 'Collapse 折叠面板',
// },
- // {
- // path: 'count-down',
- // title: 'CountDown 倒计时',
- // },
+ {
+ path: 'count-down',
+ title: 'CountDown 倒计时',
+ },
{
path: 'divider',
title: 'Divider 分割线',
@@ -563,10 +563,10 @@ module.exports = {
// path: 'collapse',
// title: 'Collapse',
// },
- // {
- // path: 'count-down',
- // title: 'CountDown',
- // },
+ {
+ path: 'count-down',
+ title: 'CountDown',
+ },
{
path: 'divider',
title: 'Divider',