From e111fd4208c3f68531486374e2e7db7dc3f2c7a8 Mon Sep 17 00:00:00 2001 From: chenjiahan Date: Sun, 12 Jul 2020 15:14:07 +0800 Subject: [PATCH] feat: migrate CountDown component --- src-next/count-down/README.md | 165 +++++++++++++ src-next/count-down/README.zh-CN.md | 183 ++++++++++++++ src-next/count-down/demo/index.vue | 130 ++++++++++ src-next/count-down/index.js | 164 ++++++++++++ src-next/count-down/index.less | 7 + .../test/__snapshots__/index.spec.js.snap | 11 + src-next/count-down/test/index.spec.js | 233 ++++++++++++++++++ src-next/count-down/utils.ts | 77 ++++++ vant.config.js | 16 +- 9 files changed, 978 insertions(+), 8 deletions(-) create mode 100644 src-next/count-down/README.md create mode 100644 src-next/count-down/README.zh-CN.md create mode 100644 src-next/count-down/demo/index.vue create mode 100644 src-next/count-down/index.js create mode 100644 src-next/count-down/index.less create mode 100644 src-next/count-down/test/__snapshots__/index.spec.js.snap create mode 100644 src-next/count-down/test/index.spec.js create mode 100644 src-next/count-down/utils.ts 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 + + + + + +``` + +### 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 + + + + + +``` + +### 手动控制 + +通过 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 @@ + + + + + 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',