feat(Calendar): support select date range

This commit is contained in:
陈嘉涵 2019-12-25 10:56:53 +08:00 committed by neverland
parent c447152be9
commit 0db1a03996
8 changed files with 365 additions and 125 deletions

View File

@ -1,7 +1,7 @@
import { createNamespace } from '../utils'; import { createNamespace } from '../utils';
import { t, formatMonthTitle } from './utils'; import { t, bem, formatMonthTitle } from './utils';
const [createComponent, bem] = createNamespace('calendar-header'); const [createComponent] = createNamespace('calendar-header');
export default createComponent({ export default createComponent({
props: { props: {
@ -11,14 +11,16 @@ export default createComponent({
methods: { methods: {
genTitle() { genTitle() {
if (this.title) { const title = this.slots('title') || this.title || t('title');
return <div class={bem('title')}>{this.title}</div>;
if (title) {
return <div class={bem('header-title')}>{title}</div>;
} }
}, },
genMonth() { genMonth() {
return ( return (
<div class={bem('month')}> <div class={bem('month-title')}>
{formatMonthTitle(this.currentMonth)} {formatMonthTitle(this.currentMonth)}
</div> </div>
); );
@ -39,7 +41,7 @@ export default createComponent({
render() { render() {
return ( return (
<div class={bem()}> <div class={bem('header')}>
{this.genTitle()} {this.genTitle()}
{this.genMonth()} {this.genMonth()}
{this.genWeekDays()} {this.genWeekDays()}

74
src/calendar/Month.js Normal file
View File

@ -0,0 +1,74 @@
import { createNamespace } from '../utils';
import { t, bem } from './utils';
const [createComponent] = createNamespace('calendar-month');
export default createComponent({
props: {
date: Date,
days: Array,
title: String
},
mounted() {
this.height = this.$el.getBoundingClientRect().height;
},
methods: {
getLabel(item) {
if (item.type === 'start') {
return t('start');
}
if (item.type === 'end') {
return t('end');
}
},
genTitle() {
if (this.title) {
return <div class={bem('month-title')}>{this.title}</div>;
}
},
genDay(item) {
const { type } = item;
const onClick = () => {
if (type !== 'disabled') {
this.$emit('click', item);
}
};
if (type === 'selected') {
return (
<div class={bem('day')} onClick={onClick}>
<div class={bem('selected-day')}>{item.day}</div>
</div>
);
}
const label = this.getLabel(item);
const Label = label && <div class={bem('day-label')}>{label}</div>;
return (
<div class={bem('day', [type])} onClick={onClick}>
{item.day}
{Label}
</div>
);
}
},
render() {
return (
<div class={bem('month')}>
{this.genTitle()}
<div class={bem('days')}>
<div class={bem('month-mark')}>{this.date.getMonth() + 1}</div>
{this.days.map(this.genDay)}
</div>
</div>
);
}
});

View File

@ -1,5 +1,9 @@
# Calendar 日历 # Calendar 日历
### 介绍
日历组件可以用于选择日期或日期区间,通常与 [弹出层](#/zh-CN/popup) 组件配合使用
### 引入 ### 引入
``` javascript ``` javascript
@ -11,7 +15,7 @@ Vue.use(Calendar);
## 代码演示 ## 代码演示
### 基础用法 ### 选择单个日期
```html ```html
<van-calendar /> <van-calendar />
@ -26,8 +30,10 @@ Vue.use(Calendar);
| v-model | 选中的日期 | `Date` | - | - | | v-model | 选中的日期 | `Date` | - | - |
| type | 选择类型,`single`为选择单日,`range`为选择日期区间 | `string` | `single` | - | | type | 选择类型,`single`为选择单日,`range`为选择日期区间 | `string` | `single` | - |
| title | 日历标题 | `string` | - | - | | title | 日历标题 | `string` | - | - |
| min-date | 最小日期 | `Date` | 当前时间 | - | | min-date | 最小日期 | `Date` | 当前日期 | - |
| max-date | 最大日期 | `Date` | 当前时间的六个月后 | - | | max-date | 最大日期 | `Date` | 当前日期的六个月后 | - |
| confirm-text | 选择日期区间时,确认按钮的文字 | `string` | `确定` | - |
| confirm-disabled-text | 选择日期区间时,确认按钮处于禁用状态时的文字 | `string` | `确定` | - |
### Events ### Events
@ -39,6 +45,7 @@ Vue.use(Calendar);
| 名称 | 说明 | | 名称 | 说明 |
|------|------| |------|------|
| title | 自定义标题 |
### 方法 ### 方法

View File

@ -2,20 +2,46 @@
<demo-section> <demo-section>
<demo-block :title="$t('basicUsage')"> <demo-block :title="$t('basicUsage')">
<van-cell <van-cell
:title="$t('selectDate')"
:value="formatDate(date.selectDate)"
is-link is-link
@click="toggle('selectDate', true)" :title="$t('selectSingleDate')"
:value="formatDate(date.selectSingleDate)"
@click="toggle('selectSingleDate', true)"
/> />
</demo-block>
<van-popup v-model="show.selectDate" round closeable position="bottom"> <van-popup
<van-calendar v-model="show.selectSingleDate"
v-model="date.selectDate" round
:title="$t('title')" closeable
@select="show.selectDate = false" position="bottom"
style="height: 80vh;"
>
<van-calendar
v-model="date.selectSingleDate"
@select="show.selectSingleDate = false"
/>
</van-popup>
<van-cell
is-link
:title="$t('selectDateRange')"
:value="formatDateRange(date.selectDateRange)"
@click="toggle('selectDateRange', true)"
/> />
</van-popup>
<van-popup
v-model="show.selectDateRange"
round
closeable
position="bottom"
style="height: 80vh;"
>
<van-calendar
v-model="date.selectDateRange"
type="range"
@select="show.selectDateRange = false"
/>
</van-popup>
</demo-block>
</demo-section> </demo-section>
</template> </template>
@ -23,22 +49,24 @@
export default { export default {
i18n: { i18n: {
'zh-CN': { 'zh-CN': {
title: '日期选择', selectSingleDate: '选择单个日期',
selectDate: '选择日期' selectDateRange: '选择日期区间'
}, },
'en-US': { 'en-US': {
title: 'Select Date', selectSingleDate: 'Select Single Date',
selectDate: 'Select Date' selectDateRange: 'Select Date Range'
} }
}, },
data() { data() {
return { return {
date: { date: {
selectDate: null selectSingleDate: null,
selectDateRange: []
}, },
show: { show: {
selectDate: false selectSingleDate: false,
selectDateRange: false
} }
}; };
}, },
@ -52,6 +80,14 @@ export default {
if (date) { if (date) {
return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`; return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`;
} }
},
formatDateRange(dateRange) {
if (dateRange.length) {
return `${this.formatDate(dateRange[0])} - ${this.formatDate(
dateRange[1]
)}`;
}
} }
} }
}; };

View File

@ -1,12 +1,24 @@
import { isDate } from '../utils/validate/date'; import { isDate } from '../utils/validate/date';
import { getScrollTop } from '../utils/dom/scroll'; import { getScrollTop } from '../utils/dom/scroll';
import { createComponent, bem, compareMonth, formatMonthTitle } from './utils'; import {
t,
bem,
getNextDay,
compareDay,
compareMonth,
createComponent,
formatMonthTitle
} from './utils';
import Month from './Month';
import Header from './Header'; import Header from './Header';
import Button from '../button';
export default createComponent({ export default createComponent({
props: { props: {
value: Date,
title: String, title: String,
value: [Date, Array],
confirmText: String,
confirmDisabledText: String,
type: { type: {
type: String, type: String,
default: 'single' default: 'single'
@ -28,7 +40,8 @@ export default createComponent({
data() { data() {
return { return {
currentMonth: this.minDate currentMonth: this.minDate,
currentValue: this.getDefaultValue()
}; };
}, },
@ -54,42 +67,64 @@ export default createComponent({
} }
}, },
mounted() { watch: {
this.initRects(); value(val) {
this.currentValue = val;
}
}, },
methods: { methods: {
initRects() { getDefaultValue() {
this.monthsHeight = this.$refs.month.map( const { type, value, minDate } = this;
month => month.getBoundingClientRect().height
); if (type === 'single') {
return value || minDate;
}
if (type === 'range') {
const range = value || [];
return [range[0] || minDate, range[1] || getNextDay(minDate)];
}
},
getDayType(day) {
const { type, minDate, maxDate, currentValue } = this;
if (compareDay(day, minDate) < 0 || compareDay(day, maxDate) > 0) {
return 'disabled';
}
if (type === 'single') {
return compareDay(day, currentValue) === 0 ? 'selected' : '';
}
if (type === 'range') {
if (!currentValue[0]) {
return;
}
const compareWithStart = compareDay(day, currentValue[0]);
if (compareWithStart === 0) {
return 'start';
}
if (!currentValue[1]) {
return;
}
const compareWithEnd = compareDay(day, currentValue[1]);
if (compareWithEnd === 0) {
return 'end';
}
if (compareWithStart > 0 && compareWithEnd < 0) {
return 'middle';
}
}
}, },
getDays(date) { getDays(date) {
const days = []; const days = [];
const { minDate, maxDate } = this;
const checkMinDate = compareMonth(date, minDate) === 0;
const checkMaxDate = compareMonth(date, maxDate) === 0;
const checkSelected =
this.value &&
this.type === 'single' &&
compareMonth(date, this.value) === 0;
const isDisabled = date => {
if (checkMaxDate && date.getDate() > maxDate.getDate()) {
return true;
}
if (checkMinDate && date.getDate() < minDate.getDate()) {
return true;
}
return false;
};
const isSelected = date =>
checkSelected && date.getDate() === this.value.getDate();
const placeholderCount = date.getDay() === 0 ? 6 : date.getDay() - 1; const placeholderCount = date.getDay() === 0 ? 6 : date.getDay() - 1;
for (let i = 1; i <= placeholderCount; i++) { for (let i = 1; i <= placeholderCount; i++) {
@ -102,8 +137,7 @@ export default createComponent({
days.push({ days.push({
day: cursor.getDate(), day: cursor.getDate(),
date: new Date(cursor), date: new Date(cursor),
disabled: isDisabled(cursor), type: this.getDayType(cursor)
selected: isSelected(cursor)
}); });
cursor.setDate(cursor.getDate() + 1); cursor.setDate(cursor.getDate() + 1);
@ -113,46 +147,21 @@ export default createComponent({
}, },
genMonth(month, index) { genMonth(month, index) {
const Title = index !== 0 && (
<div class={bem('month-title')}>{month.title}</div>
);
const Days = month.days.map(item => {
const onClick = () => {
this.onClickDay(item);
};
if (item.selected) {
return (
<div class={bem('day')} onClick={onClick}>
<div class={bem('selected-day')}>{item.day}</div>
</div>
);
}
return (
<div
class={bem('day', { disabled: item.disabled })}
onClick={onClick}
>
{item.day}
</div>
);
});
return ( return (
<div class={bem('month')} ref="month" refInFor> <Month
{Title} ref="month"
<div class={bem('days')}> refInFor
<div class={bem('month-mark')}>{month.date.getMonth() + 1}</div> days={month.days}
{Days} date={month.date}
</div> title={index !== 0 ? month.title : ''}
</div> onClick={this.onClickDay}
/>
); );
}, },
onScroll() { onScroll() {
const scrollTop = getScrollTop(this.$refs.body); const scrollTop = getScrollTop(this.$refs.body);
const monthsHeight = this.$refs.month.map(item => item.height);
let height = 0; let height = 0;
for (let i = 0; i < this.months.length; i++) { for (let i = 0; i < this.months.length; i++) {
@ -161,18 +170,65 @@ export default createComponent({
return; return;
} }
height += this.monthsHeight[i]; height += monthsHeight[i];
} }
}, },
onClickDay(item) { onClickDay(item) {
if (item.disabled) { const { date } = item;
return;
}
if (this.type === 'single') { if (this.type === 'single') {
this.$emit('input', item.date); this.$emit('input', date);
this.$emit('select', item.date); this.$emit('select', date);
}
if (this.type === 'range') {
const startDay = this.currentValue[0];
const endDay = this.currentValue[1];
if (startDay && endDay) {
this.$emit('input', [date, null]);
return;
}
if (startDay) {
const compareWithStart = compareDay(date, startDay);
if (compareWithStart === 1) {
this.$emit('input', [startDay, date]);
}
if (compareWithStart === -1) {
this.$emit('input', [date, null]);
}
}
}
},
onConfirmRange() {
this.$emit('input', this.currentValue);
this.$emit('select', this.currentValue);
},
genFooter() {
if (this.type === 'range') {
const disabled = !this.currentValue[1];
const text = disabled ? this.confirmDisabledText : this.confirmText;
return (
<div class={bem('footer')}>
<Button
round
block
type="danger"
disabled={disabled}
class={bem('confirm')}
onClick={this.onConfirmRange}
>
{text || t('confirm')}
</Button>
</div>
);
} }
} }
}, },
@ -180,10 +236,17 @@ export default createComponent({
render() { render() {
return ( return (
<div class={bem()}> <div class={bem()}>
<Header title={this.title} currentMonth={this.currentMonth} /> <Header
title={this.title}
currentMonth={this.currentMonth}
scopedSlots={{
title: () => this.slots('title')
}}
/>
<div ref="body" class={bem('body')} onScroll={this.onScroll}> <div ref="body" class={bem('body')} onScroll={this.onScroll}>
{this.months.map(this.genMonth)} {this.months.map(this.genMonth)}
</div> </div>
{this.genFooter()}
</div> </div>
); );
} }

View File

@ -3,10 +3,14 @@
.van-calendar { .van-calendar {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 80vh; height: 100%;
&-header__title, &__header {
&-header__month, flex-shrink: 0;
box-shadow: 0 2px 10px rgba(125, 126, 128, 0.16);
}
&__header-title,
&__month-title { &__month-title {
height: 44px; height: 44px;
font-weight: @font-weight-bold; font-weight: @font-weight-bold;
@ -14,34 +18,25 @@
text-align: center; text-align: center;
} }
&-header { &__header-title {
flex-shrink: 0; font-size: @font-size-lg;
box-shadow: 0 2px 10px rgba(125, 126, 128, 0.16);
&__title {
font-size: @font-size-lg;
}
&__month {
font-size: @font-size-md;
}
&__weekdays {
display: flex;
}
&__weekday {
flex: 1;
font-size: @font-size-sm;
line-height: 30px;
text-align: center;
}
} }
&__month-title { &__month-title {
font-size: @font-size-md; font-size: @font-size-md;
} }
&__weekdays {
display: flex;
}
&__weekday {
flex: 1;
font-size: @font-size-sm;
line-height: 30px;
text-align: center;
}
&__body { &__body {
flex: 1; flex: 1;
overflow: auto; overflow: auto;
@ -73,17 +68,47 @@
} }
&__day { &__day {
position: relative;
width: 14.285%; width: 14.285%;
height: 64px; height: 64px;
font-size: @font-size-lg; font-size: @font-size-lg;
cursor: pointer; cursor: pointer;
&--end,
&--start {
color: @white;
background-color: @red;
}
&--start {
border-radius: @border-radius-md 0 0 @border-radius-md;
}
&--end {
border-radius: 0 @border-radius-md @border-radius-md 0;
}
&--middle {
color: @red;
background-color: fade(@red, 10%);
}
&--disabled { &--disabled {
color: @gray-5; color: @gray-5;
cursor: default; cursor: default;
} }
} }
&__day-label {
position: absolute;
right: 0;
bottom: 6px;
left: 0;
font-size: @font-size-xs;
line-height: 14px;
text-align: center;
}
&__selected-day { &__selected-day {
width: 54px; width: 54px;
height: 54px; height: 54px;
@ -91,4 +116,13 @@
background: @red; background: @red;
border-radius: @border-radius-md; border-radius: @border-radius-md;
} }
&__footer {
padding: 7px @padding-md;
}
&__confirm {
height: 36px;
line-height: 34px;
}
} }

View File

@ -21,3 +21,23 @@ export function compareMonth(date1: Date, date2: Date) {
return year1 > year2 ? 1 : -1; return year1 > year2 ? 1 : -1;
} }
export function compareDay(day1: Date, day2: Date) {
const compareMonthResult = compareMonth(day1, day2);
if (compareMonthResult === 0) {
const date1 = day1.getDate();
const date2 = day2.getDate();
return date1 === date2 ? 0 : date1 > date2 ? 1 : -1;
}
return compareMonthResult;
}
export function getNextDay(date: Date) {
date = new Date(date);
date.setDate(date.getDate() + 1);
return date;
}

View File

@ -12,6 +12,10 @@ export default {
confirmDelete: '确定要删除么', confirmDelete: '确定要删除么',
telInvalid: '请填写正确的电话', telInvalid: '请填写正确的电话',
vanCalendar: { vanCalendar: {
end: '结束',
start: '开始',
title: '日期选择',
confirm: '确定',
weekdays: ['日', '一', '二', '三', '四', '五', '六'], weekdays: ['日', '一', '二', '三', '四', '五', '六'],
monthTitle: (year: number, month: number) => `${year}${month}` monthTitle: (year: number, month: number) => `${year}${month}`
}, },