Forráskód Böngészése

Calendar: add Calendar component (#14908)

hetech 6 éve
szülő
commit
dc8bdc021e

+ 2 - 1
components.json

@@ -72,5 +72,6 @@
   "timeline-item": "./packages/timeline-item/index.js",
   "link": "./packages/link/index.js",
   "divider": "./packages/divider/index.js",
-  "image": "./packages/image/index.js"
+  "image": "./packages/image/index.js",
+  "calendar": "./packages/calendar/index.js"
 }

+ 5 - 0
examples/demo-styles/calendar.scss

@@ -0,0 +1,5 @@
+.demo-calendar.demo-block {
+  .is-selected {
+    color: #1989FA;
+  } 
+}

+ 1 - 0
examples/demo-styles/index.scss

@@ -2,6 +2,7 @@
 @import "./badge.scss";
 @import "./border.scss";
 @import "./button.scss";
+@import "./calendar.scss";
 @import "./card.scss";
 @import "./carousel.scss";
 @import "./cascader.scss";

+ 65 - 0
examples/docs/en-US/calendar.md

@@ -0,0 +1,65 @@
+## Calendar
+
+Display date.
+
+### Basic
+
+:::demo Set `value` to specify the currently displayed month. If `value` is not specified, current month is displayed. `value` supports two-way binding.
+```html
+<el-calendar v-model="value">
+</el-calendar>
+
+<script>
+  export default {
+    data() {
+      return {
+        value: new Date()
+      }
+    }
+  }
+</script>
+```
+:::
+
+### Custom Content
+
+:::demo Customize what is displayed in the calendar cell by setting `scoped-slot` named `dateCell`. In `scoped-slot` you can get the date (the date of the current cell), data (including the type, isSelected, day attribute). For details, please refer to the API documentation below.
+```html
+<el-calendar>
+  <!-- Use 2.5 slot syntax. If you use Vue 2.6, please use new slot syntax-->
+  <template
+    slot="dateCell"
+    slot-scope="{date, data}">
+    <p :class="data.isSelected ? 'is-selected' : ''">
+      {{ data.day.split('-').slice(1).join('-') }} {{ data.isSelected ? '✔️' : ''}}
+    </p>
+  </template>
+</el-calendar>
+<style>
+  .is-selected {
+    color: #1989FA;
+  }
+</style>
+```
+:::
+
+### Range
+
+:::demo Set the `range` attribute to specify the display range of the calendar. Start time must be Monday, end time must be Sunday, and the time span cannot exceed two months.
+```html
+<el-calendar :range="['2019-03-04', '2019-03-24']">
+</el-calendar>
+```
+:::
+
+### Attributes
+| Attribute       | Description   | Type      | Accepted Values       | Default  |
+|-----------------|-------------- |---------- |---------------------- |--------- |
+| value / v-model | binding value | Date/string/number | —            | —        |
+| range           | time range, including start time and end time. Start time must be Monday, end time must be Sunday, the time span cannot exceed two months | Array     | —           | —      |
+
+### dateCell scoped slot 参数
+| Attribute       | Description   | Type      | Accepted Values       | Default  |
+|-----------------|-------------- |---------- |---------------------- |--------- |
+| date            | date the cell represents  | Date      | —                     | —        |
+| data            | { type, isSelected, day}. `type` indicates which month the date belongs, optional values are prev-month, current-month, next-month; `isSelected` indicates whether the date is selected; `day` is the formatted date in the format yyyy-MM-dd    | Object      | —           | —      |

+ 65 - 0
examples/docs/es/calendar.md

@@ -0,0 +1,65 @@
+## Calendar
+
+Display date.
+
+### Basic
+
+:::demo Set `value` to specify the currently displayed month. If `value` is not specified, current month is displayed. `value` supports two-way binding.
+```html
+<el-calendar v-model="value">
+</el-calendar>
+
+<script>
+  export default {
+    data() {
+      return {
+        value: new Date()
+      }
+    }
+  }
+</script>
+```
+:::
+
+### Custom Content
+
+:::demo Customize what is displayed in the calendar cell by setting `scoped-slot` named `dateCell`. In `scoped-slot` you can get the date (the date of the current cell), data (including the type, isSelected, day attribute). For details, please refer to the API documentation below.
+```html
+<el-calendar>
+  <!-- Use 2.5 slot syntax. If you use Vue 2.6, please use new slot syntax-->
+  <template
+    slot="dateCell"
+    slot-scope="{date, data}">
+    <p :class="data.isSelected ? 'is-selected' : ''">
+      {{ data.day.split('-').slice(1).join('-') }} {{ data.isSelected ? '✔️' : ''}}
+    </p>
+  </template>
+</el-calendar>
+<style>
+  .is-selected {
+    color: #1989FA;
+  }
+</style>
+```
+:::
+
+### Range
+
+:::demo Set the `range` attribute to specify the display range of the calendar. Start time must be Monday, end time must be Sunday, and the time span cannot exceed two months.
+```html
+<el-calendar :range="['2019-03-04', '2019-03-24']">
+</el-calendar>
+```
+:::
+
+### Attributes
+| Attribute       | Description   | Type      | Accepted Values       | Default  |
+|-----------------|-------------- |---------- |---------------------- |--------- |
+| value / v-model | binding value | Date/string/number | —            | —        |
+| range           | time range, including start time and end time. Start time must be Monday, end time must be Sunday, the time span cannot exceed two months | Array     | —           | —      |
+
+### dateCell scoped slot 参数
+| Attribute       | Description   | Type      | Accepted Values       | Default  |
+|-----------------|-------------- |---------- |---------------------- |--------- |
+| date            | date the cell represents  | Date      | —                     | —        |
+| data            | { type, isSelected, day}. `type` indicates which month the date belongs, optional values are prev-month, current-month, next-month; `isSelected` indicates whether the date is selected; `day` is the formatted date in the format yyyy-MM-dd    | Object      | —           | —      |

+ 65 - 0
examples/docs/fr-FR/calendar.md

@@ -0,0 +1,65 @@
+## Calendar
+
+Display date.
+
+### Basic
+
+:::demo Set `value` to specify the currently displayed month. If `value` is not specified, current month is displayed. `value` supports two-way binding.
+```html
+<el-calendar v-model="value">
+</el-calendar>
+
+<script>
+  export default {
+    data() {
+      return {
+        value: new Date()
+      }
+    }
+  }
+</script>
+```
+:::
+
+### Custom Content
+
+:::demo Customize what is displayed in the calendar cell by setting `scoped-slot` named `dateCell`. In `scoped-slot` you can get the date (the date of the current cell), data (including the type, isSelected, day attribute). For details, please refer to the API documentation below.
+```html
+<el-calendar>
+  <!-- Use 2.5 slot syntax. If you use Vue 2.6, please use new slot syntax-->
+  <template
+    slot="dateCell"
+    slot-scope="{date, data}">
+    <p :class="data.isSelected ? 'is-selected' : ''">
+      {{ data.day.split('-').slice(1).join('-') }} {{ data.isSelected ? '✔️' : ''}}
+    </p>
+  </template>
+</el-calendar>
+<style>
+  .is-selected {
+    color: #1989FA;
+  }
+</style>
+```
+:::
+
+### Range
+
+:::demo Set the `range` attribute to specify the display range of the calendar. Start time must be Monday, end time must be Sunday, and the time span cannot exceed two months.
+```html
+<el-calendar :range="['2019-03-04', '2019-03-24']">
+</el-calendar>
+```
+:::
+
+### Attributes
+| Attribute       | Description   | Type      | Accepted Values       | Default  |
+|-----------------|-------------- |---------- |---------------------- |--------- |
+| value / v-model | binding value | Date/string/number | —            | —        |
+| range           | time range, including start time and end time. Start time must be Monday, end time must be Sunday, the time span cannot exceed two months | Array     | —           | —      |
+
+### dateCell scoped slot 参数
+| Attribute       | Description   | Type      | Accepted Values       | Default  |
+|-----------------|-------------- |---------- |---------------------- |--------- |
+| date            | date the cell represents  | Date      | —                     | —        |
+| data            | { type, isSelected, day}. `type` indicates which month the date belongs, optional values are prev-month, current-month, next-month; `isSelected` indicates whether the date is selected; `day` is the formatted date in the format yyyy-MM-dd    | Object      | —           | —      |

+ 65 - 0
examples/docs/zh-CN/calendar.md

@@ -0,0 +1,65 @@
+## Calendar calendar
+
+显示日期
+
+### 基本
+
+:::demo 设置 `value` 来指定当前显示的月份。如果 `value` 未指定,则显示当月。`value` 支持 `v-model` 双向绑定。
+```html
+<el-calendar v-model="value">
+</el-calendar>
+
+<script>
+  export default {
+    data() {
+      return {
+        value: new Date()
+      }
+    }
+  }
+</script>
+```
+:::
+
+### 自定义内容
+
+:::demo 通过设置名为 `dateCell` 的 `scoped-slot` 来自定义日历单元格中显示的内容。在 `scoped-slot` 可以获取到 date(当前单元格的日期), data(包括 type,isSelected,day 属性)。详情解释参考下方的 API 文档。
+```html
+<el-calendar>
+  <!-- 这里使用的是 2.5 slot 语法,对于新项目请使用 2.6 slot 语法-->
+  <template
+    slot="dateCell"
+    slot-scope="{date, data}">
+    <p :class="data.isSelected ? 'is-selected' : ''">
+      {{ data.day.split('-').slice(1).join('-') }} {{ data.isSelected ? '✔️' : ''}}
+    </p>
+  </template>
+</el-calendar>
+<style>
+  .is-selected {
+    color: #1989FA;
+  }
+</style>
+```
+:::
+
+### 自定义范围
+
+:::demo 设置 `range` 属性指定日历的显示范围。开始时间必须是周一,结束时间必须是周日,且时间跨度不能超过两个月。
+```html
+<el-calendar :range="['2019-03-04', '2019-03-24']">
+</el-calendar>
+```
+:::
+
+### Attributes
+| 参数             | 说明          | 类型      | 可选值        | 默认值  |
+|-----------------|-------------- |---------- |------------ |-------- |
+| value / v-model | 绑定值         | Date/string/number | —  | —      |
+| range           | 时间范围,包括开始时间与结束时间。开始时间必须是周一,结束时间必须是周日,且时间跨度不能超过两个月。 | Array     | —           | —      |
+
+### dateCell scoped slot 参数
+| 参数             | 说明          | 类型      | 可选值        | 默认值  |
+|-----------------|-------------- |---------- |------------ |-------- |
+| date            | 单元格代表的日期 | Date      | —           | —      |
+| data            | { type, isSelected, day},`type` 表示该日期的所属月份,可选值有 prev-month,current-month,next-month;`isSelected` 标明该日期是否被选中;`day` 是格式化的日期,格式为 yyyy-MM-dd    | Object      | —           | —      |

+ 16 - 0
examples/nav.config.json

@@ -260,6 +260,10 @@
               "path": "/divider",
               "title": "Divider 分割线"
             },
+            {
+              "path": "/calendar",
+              "title": "Calendar 日历"
+            },
             {
               "path": "/image",
               "title": "Image 图片"
@@ -530,6 +534,10 @@
               "path": "/divider",
               "title": "Divider"
             },
+            {
+              "path": "/calendar",
+              "title": "Calendar"
+            },
             {
               "path": "/image",
               "title": "Image"
@@ -800,6 +808,10 @@
               "path": "/divider",
               "title": "Divider"
             },
+            {
+              "path": "/calendar",
+              "title": "Calendar"
+            },
             {
               "path": "/image",
               "title": "Image"
@@ -1070,6 +1082,10 @@
               "path": "/divider",
               "title": "Divider"
             },
+            {
+              "path": "/calendar",
+              "title": "Calendar"
+            },
             {
               "path": "/image",
               "title": "Image"

+ 8 - 0
packages/calendar/index.js

@@ -0,0 +1,8 @@
+import Calendar from './src/main';
+
+/* istanbul ignore next */
+Calendar.install = function(Vue) {
+  Vue.component(Calendar.name, Calendar);
+};
+
+export default Calendar;

+ 189 - 0
packages/calendar/src/date-table.vue

@@ -0,0 +1,189 @@
+<script>
+import fecha from 'element-ui/src/utils/date';
+import { range as rangeArr, getFirstDayOfMonth, getPrevMonthLastDays, getMonthDays, getI18nSettings, validateRangeInOneMonth } from 'element-ui/src/utils/date-util';
+export default {
+
+  props: {
+    selectedDay: String, // formated date yyyy-MM-dd
+    range: {
+      type: Array,
+      validator(val) {
+        if (!(val && val.length)) return true;
+        const [start, end] = val;
+        return validateRangeInOneMonth(start, end);
+      }
+    },
+    date: Date,
+    hideHeader: Boolean
+  },
+
+  inject: ['elCalendar'],
+
+  methods: {
+    toNestedArr(days) {
+      return rangeArr(days.length / 7).map((_, index) => {
+        const start = index * 7;
+        return days.slice(start, start + 7);
+      });
+    },
+
+    getFormateDate(day, type) {
+      if (!day || ['prev', 'current', 'next'].indexOf(type) === -1) {
+        throw new Error('invalid day or type');
+      }
+      let prefix = this.curMonthDatePrefix;
+      if (type === 'prev') {
+        prefix = this.prevMonthDatePrefix;
+      } else if (type === 'next') {
+        prefix = this.nextMonthDatePrefix;
+      }
+      day = `00${day}`.slice(-2);
+      return `${prefix}-${day}`;
+    },
+
+    getCellClass({ text, type}) {
+      const classes = [type];
+      if (type === 'current') {
+        const date = this.getFormateDate(text, type);
+        if (date === this.selectedDay) {
+          classes.push('is-selected');
+        }
+        if (date === this.formatedToday) {
+          classes.push('is-today');
+        }
+      }
+      return classes;
+    },
+
+    pickDay({ text, type }) {
+      const date = this.getFormateDate(text, type);
+      this.$emit('pick', date);
+    },
+
+    cellRenderProxy({ text, type }) {
+      let render = this.elCalendar.$scopedSlots.dateCell;
+      if (!render) return <span>{ text }</span>;
+
+      const day = this.getFormateDate(text, type);
+      const date = new Date(day);
+      const data = {
+        isSelected: this.selectedDay === day,
+        type: `${type}-month`,
+        day
+      };
+      return render({ date, data });
+    }
+  },
+
+  computed: {
+    prevMonthDatePrefix() {
+      const temp = new Date(this.date.getTime());
+      temp.setDate(0);
+      return fecha.format(temp, 'yyyy-MM');
+    },
+
+    curMonthDatePrefix() {
+      return fecha.format(this.date, 'yyyy-MM');
+    },
+
+    nextMonthDatePrefix() {
+      const temp = new Date(this.date.getFullYear(), this.date.getMonth() + 1, 1);
+      return fecha.format(temp, 'yyyy-MM');
+    },
+
+    formatedToday() {
+      return this.elCalendar.formatedToday;
+    },
+
+    isInRange() {
+      return this.range && this.range.length;
+    },
+
+    rows() {
+      let days = [];
+      // if range exists, should render days in range.
+      if (this.isInRange) {
+        const [start, end] = this.range;
+        const currentMonthRange = rangeArr(end.getDate() - start.getDate() + 1).map((_, index) => ({
+          text: start.getDate() + index,
+          type: 'current'
+        }));
+        let remaining = currentMonthRange.length % 7;
+        remaining = remaining === 0 ? 0 : 7 - remaining;
+        const nextMonthRange = rangeArr(remaining).map((_, index) => ({
+          text: index + 1,
+          type: 'next'
+        }));
+        days = currentMonthRange.concat(nextMonthRange);
+      } else {
+        const date = this.date;
+        const firstDay = getFirstDayOfMonth(date);
+        const prevMonthDays = getPrevMonthLastDays(date, firstDay - 1).map(day => ({
+          text: day,
+          type: 'prev'
+        }));
+        const currentMonthDays = getMonthDays(date).map(day => ({
+          text: day,
+          type: 'current'
+        }));
+        days = [...prevMonthDays, ...currentMonthDays];
+        const nextMonthDays = rangeArr(42 - days.length).map((_, index) => ({
+          text: index + 1,
+          type: 'next'
+        }));
+        days = days.concat(nextMonthDays);
+      }
+      return this.toNestedArr(days);
+    }
+  },
+
+  data() {
+    const dayNames = getI18nSettings().dayNames;
+    return {
+      DAYS: dayNames.slice(1).concat(dayNames[0])
+    };
+  },
+
+  render() {
+    const thead = this.hideHeader ? null : (<thead>
+      {
+        this.DAYS.map(day => <th key={day}>{ day }</th>)
+      }
+    </thead>);
+    return (
+      <table
+        class={{
+          'el-calendar-table': true,
+          'is-range': this.isInRange
+        }}
+        cellspacing="0"
+        cellpadding="0">
+        {
+          thead
+        }
+        <tbody>
+          {
+            this.rows.map((row, index) => <tr
+              class={{
+                'el-calendar-table__row': true,
+                'el-calendar-table__row--hide-border': index === 0 && this.hideHeader
+              }}
+              key={index}>
+              {
+                row.map((cell, key) => <td key={key}
+                  class={ this.getCellClass(cell) }
+                  onClick={this.pickDay.bind(this, cell)}>
+                  <div class="el-calendar-day">
+                    {
+                      this.cellRenderProxy(cell)
+                    }
+                  </div>
+                </td>)
+              }
+            </tr>)
+          }
+        </tbody>
+      </table>);
+  }
+};
+</script>

+ 251 - 0
packages/calendar/src/main.vue

@@ -0,0 +1,251 @@
+<template>
+  <div class="el-calendar">
+    <div class="el-calendar__header">
+      <div class="el-calendar__title">
+        {{ i18nDate }}
+      </div>
+      <div
+        class="el-calendar__button-group"
+        v-if="validatedRange.length === 0">
+        <el-button-group>
+          <el-button
+            type="plain"
+            size="mini"
+            @click="selectDate('prev-month')">
+            {{ t('el.datepicker.prevMonth') }}
+          </el-button>
+          <el-button
+            type="plain"
+            size="mini"
+            @click="selectDate('today')">
+            {{ t('el.datepicker.today') }}
+          </el-button>
+          <el-button
+            type="plain"
+            size="mini"
+            @click="selectDate('next-month')">
+            {{ t('el.datepicker.nextMonth') }}
+          </el-button>
+        </el-button-group>
+      </div>
+    </div>
+    <div
+      class="el-calendar__body"
+      v-if="validatedRange.length === 0"
+      key="no-range">
+      <date-table
+        :date="date"
+        :selected-day="realSelectedDay"
+        @pick="pickDay" />
+    </div>
+    <div
+      v-else
+      class="el-calendar__body"
+      key="has-range">
+      <date-table
+        v-for="(range, index) in validatedRange"
+        :key="index"
+        :date="range[0]"
+        :selected-day="realSelectedDay"
+        :range="range"
+        :hide-header="index !== 0"
+        @pick="pickDay" />
+    </div>
+  </div>
+</template>
+
+<script>
+import Locale from 'element-ui/src/mixins/locale';
+import fecha from 'element-ui/src/utils/date';
+import DateTable from './date-table';
+import { validateRangeInOneMonth } from 'element-ui/src/utils/date-util';
+
+const validTypes = ['prev-month', 'today', 'next-month'];
+const oneDay = 86400000;
+
+export default {
+  name: 'ElCalendar',
+
+  mixins: [Locale],
+
+  components: {
+    DateTable
+  },
+
+  props: {
+    value: [Date, String, Number],
+    range: {
+      type: Array,
+      validator(range) {
+        if (Array.isArray(range)) {
+          return range.length === 2 && range.every(
+            item => typeof item === 'string' ||
+            typeof item === 'number' ||
+            item instanceof Date);
+        } else {
+          return true;
+        }
+      }
+    }
+  },
+
+  provide() {
+    return {
+      elCalendar: this
+    };
+  },
+
+  methods: {
+    pickDay(day) {
+      this.realSelectedDay = day;
+    },
+
+    selectDate(type) {
+      if (validTypes.indexOf(type) === -1) {
+        throw new Error(`invalid type ${type}`);
+      }
+      let day = '';
+      if (type === 'prev-month') {
+        day = `${this.prevMonthDatePrefix}-01`;
+      } else if (type === 'next-month') {
+        day = `${this.nextMonthDatePrefix}-01`;
+      } else {
+        day = this.formatedToday;
+      }
+
+      if (day === this.formatedDate) return;
+      this.pickDay(day);
+    },
+
+    toDate(val) {
+      if (!val) {
+        throw new Error('invalid val');
+      }
+      return val instanceof Date ? val : new Date(val);
+    }
+  },
+
+  computed: {
+    prevMonthDatePrefix() {
+      const temp = new Date(this.date.getTime());
+      temp.setDate(0);
+      return fecha.format(temp, 'yyyy-MM');
+    },
+
+    curMonthDatePrefix() {
+      return fecha.format(this.date, 'yyyy-MM');
+    },
+
+    nextMonthDatePrefix() {
+      const temp = new Date(this.date.getFullYear(), this.date.getMonth() + 1, 1);
+      return fecha.format(temp, 'yyyy-MM');
+    },
+
+    formatedDate() {
+      return fecha.format(this.date, 'yyyy-MM-dd');
+    },
+
+    i18nDate() {
+      const year = this.formatedDate.slice(0, 4);
+      const month = this.formatedDate.slice(5, 7).replace('0', '');
+      return `${year} ${this.t('el.datepicker.year')} ${this.t('el.datepicker.month' + month)}`;
+    },
+
+    formatedToday() {
+      return fecha.format(this.now, 'yyyy-MM-dd');
+    },
+
+    realSelectedDay: {
+      get() {
+        if (!this.value) return this.selectedDay;
+        return this.formatedDate;
+      },
+      set(val) {
+        this.selectedDay = val;
+        const date = new Date(val);
+        this.$emit('input', date);
+      }
+    },
+
+    date() {
+      if (!this.value) {
+        if (this.realSelectedDay) {
+          return new Date(this.selectedDay);
+        } else if (this.validatedRange.length) {
+          return this.validatedRange[0][0];
+        }
+        return this.now;
+      } else {
+        return this.toDate(this.value);
+      }
+    },
+
+    // if range is valid, we get a two-digit array
+    validatedRange() {
+      let range = this.range;
+      if (!range) return [];
+      const expetedMap = {
+        0: {
+          value: 1,
+          message: 'start of range should be Monday.'
+        },
+        1: {
+          value: 0,
+          message: 'end of range should be Sunday.'
+        }
+      };
+      range = range.reduce((prev, val, index) => {
+        const date = this.toDate(val);
+        if (date.getDay() !== expetedMap[index].value) {
+          console.warn('[ElementCalendar]', expetedMap[index].message, ' invalid range will be ignored');
+        } else {
+          prev = prev.concat(date);
+        }
+        return prev;
+      }, []);
+      if (range.length === 2) {
+        const [start, end] = range;
+        if (start > end) {
+          console.warn('[ElementCalendar]end time should be greater than start time');
+          return [];
+        }
+        // start time and end time in one month
+        if (validateRangeInOneMonth(start, end)) {
+          return [
+            [start, end]
+          ];
+        }
+        const data = [];
+        let startDay = new Date(start.getFullYear(), start.getMonth() + 1, 1);
+        const lastDay = this.toDate(startDay.getTime() - oneDay);
+        if (!validateRangeInOneMonth(startDay, end)) {
+          console.warn('[ElementCalendar]start time and end time interval must not exceed two months');
+          return [];
+        }
+        data.push([
+          start,
+          lastDay
+        ]);
+        let interval = startDay.getDay();
+        interval = interval <= 1 ? Math.abs(interval - 1) : (8 - interval);
+        startDay = this.toDate(startDay.getTime() + interval * oneDay);
+        if (startDay.getDate() < end.getDate()) {
+          data.push([
+            startDay,
+            end
+          ]);
+        }
+        return data;
+      }
+      return [];
+    }
+  },
+
+  data() {
+    return {
+      selectedDay: '',
+      now: new Date()
+    };
+  }
+};
+</script>

+ 1 - 1
packages/date-picker/src/basic/date-table.vue

@@ -32,7 +32,7 @@
 </template>
 
 <script>
-  import { getFirstDayOfMonth, getDayCountOfMonth, getWeekNumber, getStartDateOfMonth, prevDate, nextDate, isDate, clearTime as _clearTime} from '../util';
+  import { getFirstDayOfMonth, getDayCountOfMonth, getWeekNumber, getStartDateOfMonth, prevDate, nextDate, isDate, clearTime as _clearTime} from 'element-ui/src/utils/date-util';
   import Locale from 'element-ui/src/mixins/locale';
   import { arrayFindIndex, arrayFind, coerceTruthyValueToArray } from 'element-ui/src/utils/util';
 

+ 1 - 1
packages/date-picker/src/basic/month-table.vue

@@ -14,7 +14,7 @@
 
 <script type="text/babel">
   import Locale from 'element-ui/src/mixins/locale';
-  import { isDate, range, getDayCountOfMonth, nextDate } from '../util';
+  import { isDate, range, getDayCountOfMonth, nextDate } from 'element-ui/src/utils/date-util';
   import { hasClass } from 'element-ui/src/utils/dom';
   import { arrayFindIndex, coerceTruthyValueToArray, arrayFind } from 'element-ui/src/utils/util';
 

+ 1 - 1
packages/date-picker/src/basic/time-spinner.vue

@@ -101,7 +101,7 @@
 </template>
 
 <script type="text/babel">
-  import { getRangeHours, getRangeMinutes, modifyTime } from '../util';
+  import { getRangeHours, getRangeMinutes, modifyTime } from 'element-ui/src/utils/date-util';
   import ElScrollbar from 'element-ui/packages/scrollbar';
   import RepeatClick from 'element-ui/src/directives/repeat-click';
 

+ 1 - 1
packages/date-picker/src/basic/year-table.vue

@@ -45,7 +45,7 @@
 
 <script type="text/babel">
   import { hasClass } from 'element-ui/src/utils/dom';
-  import { isDate, range, nextDate, getDayCountOfYear } from '../util';
+  import { isDate, range, nextDate, getDayCountOfYear } from 'element-ui/src/utils/date-util';
   import { arrayFindIndex, coerceTruthyValueToArray } from 'element-ui/src/utils/util';
 
   const datesInYear = year => {

+ 1 - 1
packages/date-picker/src/panel/date-range.vue

@@ -200,7 +200,7 @@
     nextDate,
     extractDateFormat,
     extractTimeFormat
-  } from '../util';
+  } from 'element-ui/src/utils/date-util';
   import Clickoutside from 'element-ui/src/utils/clickoutside';
   import Locale from 'element-ui/src/mixins/locale';
   import TimePicker from './time';

+ 1 - 1
packages/date-picker/src/panel/date.vue

@@ -160,7 +160,7 @@
     extractDateFormat,
     extractTimeFormat,
     timeWithinRange
-  } from '../util';
+  } from 'element-ui/src/utils/date-util';
   import Clickoutside from 'element-ui/src/utils/clickoutside';
   import Locale from 'element-ui/src/mixins/locale';
   import ElInput from 'element-ui/packages/input';

+ 1 - 1
packages/date-picker/src/panel/month-range.vue

@@ -84,7 +84,7 @@
     prevYear,
     nextYear,
     nextMonth
-  } from '../util';
+  } from 'element-ui/src/utils/date-util';
   import Clickoutside from 'element-ui/src/utils/clickoutside';
   import Locale from 'element-ui/src/mixins/locale';
   import MonthTable from '../basic/month-table';

+ 1 - 1
packages/date-picker/src/panel/time-range.vue

@@ -62,7 +62,7 @@
     modifyDate,
     clearMilliseconds,
     timeWithinRange
-  } from '../util';
+  } from 'element-ui/src/utils/date-util';
   import Locale from 'element-ui/src/mixins/locale';
   import TimeSpinner from '../basic/time-spinner';
 

+ 1 - 1
packages/date-picker/src/panel/time.vue

@@ -31,7 +31,7 @@
 </template>
 
 <script type="text/babel">
-  import { limitTimeRange, isDate, clearMilliseconds, timeWithinRange } from '../util';
+  import { limitTimeRange, isDate, clearMilliseconds, timeWithinRange } from 'element-ui/src/utils/date-util';
   import Locale from 'element-ui/src/mixins/locale';
   import TimeSpinner from '../basic/time-spinner';
 

+ 1 - 1
packages/date-picker/src/picker.vue

@@ -86,7 +86,7 @@
 <script>
 import Vue from 'vue';
 import Clickoutside from 'element-ui/src/utils/clickoutside';
-import { formatDate, parseDate, isDateObject, getWeekNumber } from './util';
+import { formatDate, parseDate, isDateObject, getWeekNumber } from 'element-ui/src/utils/date-util';
 import Popper from 'element-ui/src/utils/vue-popper';
 import Emitter from 'element-ui/src/mixins/emitter';
 import ElInput from 'element-ui/packages/input';

+ 77 - 0
packages/theme-chalk/src/calendar.scss

@@ -0,0 +1,77 @@
+@import "mixins/mixins";
+@import "common/var";
+
+@include b(calendar) {
+  background-color:#fff;
+
+  @include e(header) {
+    display: flex;
+    justify-content: space-between;
+    padding: 12px 20px;
+    border-bottom: $--table-border;
+  }
+
+  @include e(title) {
+    color: #000000;
+    align-self: center;
+  }
+
+  @include e(body) {
+    padding: 12px 20px 35px;
+  }
+}
+
+@include b(calendar-table) {
+  table-layout: fixed;
+  width: 100%;
+
+  thead th {
+    padding: 12px 0;
+    color: $--color-text-regular;
+    font-weight: normal;
+  }
+
+  &:not(.is-range) {
+    td.prev,
+    td.next {
+      color: $--color-text-placeholder;
+    }
+  }
+
+  td {
+    border-bottom: $--calendar-border;
+    border-right: $--calendar-border;
+    vertical-align: top;
+    transition: background-color 0.2s ease;
+
+    @include when(selected) {
+      background-color: $--calendar-selected-background-color;
+    }
+
+    @include when(today) {
+      color: $--color-primary;
+    }
+  }
+
+  tr:first-child td {
+    border-top: $--calendar-border;
+  }
+
+  tr td:first-child {
+    border-left: $--calendar-border;
+  }
+
+  tr.el-calendar-table__row--hide-border td {
+    border-top: none;
+  }
+
+  @include b(calendar-day) {
+    box-sizing: border-box;
+    padding: 8px;
+    height: $--calendar-cell-width;
+    &:hover {
+      cursor: pointer;
+      background-color: $--calendar-selected-background-color;
+    }
+  }
+}

+ 7 - 0
packages/theme-chalk/src/common/var.scss

@@ -934,6 +934,13 @@ $--link-warning-font-color: $--color-warning !default;
 $--link-danger-font-color: $--color-danger !default;
 /// color||Color|0
 $--link-info-font-color: $--color-info !default;
+/* Calendar
+--------------------------*/
+/// border||Other|4
+$--calendar-border: $--table-border !default;
+/// color||Other|4
+$--calendar-selected-background-color: #F2F8FE !default;
+$--calendar-cell-width: 85px !default;
 
 /* Break-point
 --------------------------*/

+ 1 - 0
packages/theme-chalk/src/index.scss

@@ -70,3 +70,4 @@
 @import "./link.scss";
 @import "./divider.scss";
 @import "./image.scss";
+@import "./calendar.scss";

+ 4 - 1
src/index.js

@@ -74,6 +74,7 @@ import TimelineItem from '../packages/timeline-item/index.js';
 import Link from '../packages/link/index.js';
 import Divider from '../packages/divider/index.js';
 import Image from '../packages/image/index.js';
+import Calendar from '../packages/calendar/index.js';
 import locale from 'element-ui/src/locale';
 import CollapseTransition from 'element-ui/src/transitions/collapse-transition';
 
@@ -148,6 +149,7 @@ const components = [
   Link,
   Divider,
   Image,
+  Calendar,
   CollapseTransition
 ];
 
@@ -260,5 +262,6 @@ export default {
   TimelineItem,
   Link,
   Divider,
-  Image
+  Image,
+  Calendar
 };

+ 32 - 13
packages/date-picker/src/util/index.js → src/utils/date-util.js

@@ -1,17 +1,8 @@
-import dateUtil from 'element-ui/src/utils/date';
+import fecha from 'element-ui/src/utils/date';
 import { t } from 'element-ui/src/locale';
 
 const weeks = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
 const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
-const getI18nSettings = () => {
-  return {
-    dayNamesShort: weeks.map(week => t(`el.datepicker.weeks.${ week }`)),
-    dayNames: weeks.map(week => t(`el.datepicker.weeks.${ week }`)),
-    monthNamesShort: months.map(month => t(`el.datepicker.months.${ month }`)),
-    monthNames: months.map((month, index) => t(`el.datepicker.month${ index + 1 }`)),
-    amPm: ['am', 'pm']
-  };
-};
 
 const newArray = function(start, end) {
   let result = [];
@@ -21,6 +12,16 @@ const newArray = function(start, end) {
   return result;
 };
 
+export const getI18nSettings = () => {
+  return {
+    dayNamesShort: weeks.map(week => t(`el.datepicker.weeks.${ week }`)),
+    dayNames: weeks.map(week => t(`el.datepicker.weeks.${ week }`)),
+    monthNamesShort: months.map(month => t(`el.datepicker.months.${ month }`)),
+    monthNames: months.map((month, index) => t(`el.datepicker.month${ index + 1 }`)),
+    amPm: ['am', 'pm']
+  };
+};
+
 export const toDate = function(date) {
   return isDate(date) ? new Date(date) : null;
 };
@@ -39,11 +40,11 @@ export const isDateObject = function(val) {
 export const formatDate = function(date, format) {
   date = toDate(date);
   if (!date) return '';
-  return dateUtil.format(date, format || 'yyyy-MM-dd', getI18nSettings());
+  return fecha.format(date, format || 'yyyy-MM-dd', getI18nSettings());
 };
 
 export const parseDate = function(string, format) {
-  return dateUtil.parse(string, format || 'yyyy-MM-dd', getI18nSettings());
+  return fecha.parse(string, format || 'yyyy-MM-dd', getI18nSettings());
 };
 
 export const getDayCountOfMonth = function(year, month) {
@@ -131,6 +132,20 @@ export const getRangeHours = function(ranges) {
   return hours;
 };
 
+export const getPrevMonthLastDays = (date, amount) => {
+  if (amount <= 0) return [];
+  const temp = new Date(date.getTime());
+  temp.setDate(0);
+  const lastDay = temp.getDate();
+  return range(amount).map((_, index) => lastDay - (amount - index - 1));
+};
+
+export const getMonthDays = (date) => {
+  const temp = new Date(date.getFullYear(), date.getMonth() + 1, 0);
+  const days = temp.getDate();
+  return range(days).map((_, index) => index + 1);
+};
+
 function setRangeData(arr, start, end, value) {
   for (let i = start; i < end; i++) {
     arr[i] = value;
@@ -196,7 +211,7 @@ export const clearMilliseconds = function(date) {
 export const limitTimeRange = function(date, ranges, format = 'HH:mm:ss') {
   // TODO: refactory a more elegant solution
   if (ranges.length === 0) return date;
-  const normalizeDate = date => dateUtil.parse(dateUtil.format(date, format), format);
+  const normalizeDate = date => fecha.parse(fecha.format(date, format), format);
   const ndate = normalizeDate(date);
   const nranges = ranges.map(range => range.map(normalizeDate));
   if (nranges.some(nrange => ndate >= nrange[0] && ndate <= nrange[1])) return date;
@@ -271,3 +286,7 @@ export const extractTimeFormat = function(format) {
     .replace(/\W?D{1,2}|\W?Do|\W?d{1,4}|\W?M{1,4}|\W?y{2,4}/g, '')
     .trim();
 };
+
+export const validateRangeInOneMonth = function(start, end) {
+  return (start.getMonth() === end.getMonth()) && (start.getFullYear() === end.getFullYear());
+};

+ 70 - 0
test/unit/specs/calendar.spec.js

@@ -0,0 +1,70 @@
+import { createVue, destroyVM, waitImmediate } from '../util';
+
+describe('Calendar', () => {
+  let vm;
+  afterEach(() => {
+    destroyVM(vm);
+  });
+
+  it('create', async() => {
+    vm = createVue({
+      template: `
+      <el-calendar v-model="value"></el-calendar>
+      `,
+      data() {
+        return {
+          value: new Date('2019-04-01')
+        };
+      }
+    }, true);
+    const titleEl = vm.$el.querySelector('.el-calendar__title');
+    expect(/2019.*4/.test(titleEl.innerText)).to.be.true;
+    expect(vm.$el.querySelectorAll('thead th').length).to.equal(7);
+    const rows = vm.$el.querySelectorAll('.el-calendar-table__row');
+    expect(rows.length).to.equal(6);
+    rows[5].firstElementChild.click();
+
+    await waitImmediate();
+
+    expect(/2019.*5/.test(titleEl.innerText)).to.be.true;
+    const value = vm.value;
+    expect(value.getFullYear()).to.be.equal(2019);
+    expect(value.getMonth()).to.be.equal(4);
+    expect(vm.$el.querySelector('.is-selected span').innerText).to.be.equal('6');
+  });
+
+  it('range', () => {
+    vm = createVue({
+      template: `
+      <el-calendar :range="['2019-03-04', '2019-03-24']"></el-calendar>
+      `
+    }, true);
+    const titleEl = vm.$el.querySelector('.el-calendar__title');
+    expect(/2019.*3/.test(titleEl.innerText)).to.be.true;
+    const rows = vm.$el.querySelectorAll('.el-calendar-table__row');
+    expect(rows.length).to.equal(3);
+    expect(vm.$el.querySelector('.el-calendar__button-group')).to.be.a('null');
+  });
+
+  it('range tow monthes', async() => {
+    vm = createVue({
+      template: `
+      <el-calendar :range="['2019-04-15', '2019-05-19']"></el-calendar>
+      `
+    }, true);
+    const titleEl = vm.$el.querySelector('.el-calendar__title');
+    expect(/2019.*4/.test(titleEl.innerText)).to.be.true;
+    const dateTables = vm.$el.querySelectorAll('.el-calendar-table.is-range');
+    expect(dateTables.length).to.be.equal(2);
+    const rows = vm.$el.querySelectorAll('.el-calendar-table__row');
+    expect(rows.length).to.equal(5);
+    const cell = rows[rows.length - 1].firstElementChild;
+    cell.click();
+
+    await waitImmediate();
+
+    expect(/2019.*5/.test(titleEl.innerText)).to.be.true;
+    expect(cell.classList.contains('is-selected')).to.be.true;
+  });
+});
+

+ 12 - 0
types/calendar.d.ts

@@ -0,0 +1,12 @@
+import { ElementUIComponent } from './component'
+
+export type DateType = Date | String | Number
+
+/** Calendar Component */
+export declare class ElCalendar extends ElementUIComponent {
+  /** Binding value */
+  value: DateType
+
+  /** Specify the display range of the calendar */
+  range: DateType[]
+}

+ 2 - 0
types/element-ui.d.ts

@@ -70,6 +70,8 @@ import { ElTransfer } from './transfer'
 import { ElTree } from './tree'
 import { ElUpload } from './upload'
 import { ElDivider } from './divider'
+import { ElCalendar } from './calendar'
+import { ElImage } from './image'
 
 export interface InstallationOptions {
   locale: any,