vue 手寫一個時間選擇器

最近研究了 DatePicker 的實現原理後作了一個 vue 的 DatePicker 組件,今天帶你們一步一步實現 DatePicker 的 vue 組件。javascript

原理

DatePicker 的原理是——計算日曆面板中當月或選中月份的總天數及先後月份相近的日子,根據點擊事件計算日曆面板顯示內容,以及將所選值賦值給<input/>標籤。html

實現

  • CSS 代碼於文章末尾處

1. 構思頁面結構

DatePicker 組件由輸入框和日曆面板組成,寫好頁面主體結構。vue

<div class="date-picker">
  <input class="input" v-model="dateValue" @click="openPanel"/>
  <transition name="fadeDownBig">
    <div class="date-panel" v-show="panelState"></div>
  </transiton>
</div>
複製代碼

輸入框<input>點擊顯示或隱藏日曆面板,openPanel()方法改變 panelState 布爾值控制日曆面板的顯示隱藏。java

日曆面板由頂部條和麪板兩部分組成,而面板則由年份選擇面板,月份選擇面板,日期選擇面板所組成,結構以下:git

<div class="date-panel" v-show="panelState">
  <!-- 頂部按鈕及年月顯示條 -->
  <div class="topbar">
    <span @click="leftBig">&lt;&lt;</span>
    <span @click="left">&lt;</span>
    <span class="year" @click="panelType = 'year'">{{tmpYear}}</span>
    <span class="month" @click="panelType = 'month'">{{changeTmpMonth}}</span>
    <span @click="right">&gt;</span>
    <span @click="rightBig">&gt;&gt;</span>
  </div>
  <!-- 年面板 -->
  <div class="type-year" v-show="panelType === 'year'">
    <ul class="year-list">
      <li v-for="(item, index) in yearList" :key="index" @click="selectYear(item)" >
        <span :class="{selected: item === tmpYear}" >{{item}}</span>
      </li>
    </ul>
  </div>
  <!-- 月面板 -->
  <div class="type-year" v-show="panelType === 'month'">
    <ul class="year-list">
      <li v-for="(item, index) in monthList" :key="index" @click="selectMonth(item)" >
        <span :class="{selected: item.value === tmpMonth}" >{{item.label}}</span>
      </li>
    </ul>
  </div>
  <!-- 日期面板 -->
  <div class="date-group" v-show="panelType === 'date'">
    <span v-for="(item, index) in weekList" :key="index" class="weekday">{{item.label}}</span>
    <ul class="date-list">
      <li v-for="(item, index) in dateList" v-text="item.value" :class="{preMonth: item.previousMonth, nextMonth: item.nextMonth, selected: date === item.value && month === tmpMonth && item.currentMonth, invalid: validateDate(item)}" :key="index" @click="selectDate(item)">
      </li>
    </ul>
  </div>
</div>
複製代碼

2. 頁面數據實現

DatePicker 所對應的 data 代碼github

data() {
  return {
    dateValue: "", // 輸入框顯示日期
    date: new Date().getDate(), // 當前日期
    panelState: false, // 初始值,默認panel關閉
    tmpMonth: new Date().getMonth(), // 臨時月份,可修改
    month: new Date().getMonth(),
    tmpYear: new Date().getFullYear(), // 臨時年份,可修改
    weekList: [
      { label: "Sun", value: 0 },
      { label: "Mon", value: 1 },
      { label: "Tue", value: 2 },
      { label: "Wed", value: 3 },
      { label: "Thu", value: 4 },
      { label: "Fri", value: 5 },
      { label: "Sat", value: 6 }
    ], // 周
    monthList: [
      { label: "Jan", value: 0 },
      { label: "Feb", value: 1 },
      { label: "Mar", value: 2 },
      { label: "Apr", value: 3 },
      { label: "May", value: 4 },
      { label: "Jun", value: 5 },
      { label: "Jul", value: 6 },
      { label: "Aug", value: 7 },
      { label: "Sept", value: 8 },
      { label: "Oct", value: 9 },
      { label: "Nov", value: 10 },
      { label: "Dec", value: 11 }
    ], // 月
    nowValue: 0, // 當前選中日期值
    panelType: "date" // 面板狀態
  };
},
複製代碼

DatePicker 的核心在於日期面板的數據。咱們知道,一個月最多31天,最少28天。面板按週日至週六設計,最極端的狀況以下:web

最多的極端狀況:數組

* * * * * * 1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30 31 1 2 3 4 5

最少的極端狀況:函數

1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
1 2 3 4 5 6 7
8 9 10 11 12 13 14

根據上表咱們能夠得知一個月最多佔六個星期,最少四個星期,因此日曆面板必須設計爲 6 行,剩餘的用下個月的日期補上,最多補14天。所以日期數組能夠這麼設計:動畫

computed: {
  dateList() {
    //獲取當月的天數
    let currentMonthLength = new Date(
      this.tmpYear,
      this.tmpMonth + 1,
      0
    ).getDate();
    //先將當月的日期塞入dateList
    let dateList = Array.from(
      { length: currentMonthLength },
      (val, index) => {
        return {
          currentMonth: true,
          value: index + 1
        };
      }
    );
    // 獲取當月1號的星期是爲了肯定在1號前須要插多少天
    let startDay = new Date(this.tmpYear, this.tmpMonth, 1).getDay();
    // 確認上個月一共多少天
    let previousMongthLength = new Date(
      this.tmpYear,
      this.tmpMonth,
      0
    ).getDate();
    // 在1號前插入上個月日期
    for (let i = 0, len = startDay; i < len; i++) {
      dateList = [
        { previousMonth: true, value: previousMongthLength - i }
      ].concat(dateList);
    }
    // 補全剩餘位置,至少14天,則 i < 15
    for (let i = 1, item = 1; i < 15; i++, item++) {
      dateList[dateList.length] = { nextMonth: true, value: i };
    }
    return dateList;
  },
}
複製代碼

changeTmpMonth 爲選擇月份後顯示的文案,yearList 爲年份列表,爲了與月份數量保持一致,咱們也設長度爲12.

computed: {
  changeTmpMonth() {
    return this.monthList[this.tmpMonth].label;
  },
  // 經過改變this.tmpYear則能夠改變年份數組
  yearList() {
    return Array.from({ length: 12 }, (value, index) => this.tmpYear + index);
  }
}

複製代碼

3. 實現頁面功能

(1)面板切換功能

面板圖

  • 點擊輸入框,除了打開日曆面板,同時也默認爲日期面板
openPanel() {
  this.panelState = !this.panelState;
  this.panelType = "date";
},
複製代碼
  • 點擊 2018 年份進入年份面板,點擊相對應年份顯示該年份並進入月份選擇面板
<span class="year" @click="panelType = 'year'">{{tmpYear}}</span>
複製代碼
selectYear(item) {
  this.tmpYear = item;
  this.panelType = "month";
},
複製代碼
  • 點擊 Aug 月份進入月份面板,點擊相對應月份顯示該月份並進入日期選擇面板
<span class="month" @click="panelType = 'month'">{{changeTmpMonth}}</span>
複製代碼
selectMonth(item) {
  this.tmpMonth = item.value;
  this.panelType = "date";
},
複製代碼

點擊日期選擇日期,關閉面板同時賦值給輸入框

// methods
selectDate(item) {
  // 賦值 當前 nowValue,用於控制樣式突出顯示當前月份日期
  this.nowValue = item.value;
  // 選擇了上個月
  if (item.previousMonth) this.tmpMonth--;
  // 選擇了下個月
  if (item.nextMonth) this.tmpMonth++;
  // 獲取選中日期的 date
  let selectDay = new Date(this.tmpYear, this.tmpMonth, this.nowValue);
  // 格式日期爲字符串後,賦值給 input
  this.dateValue = this.formatDate(selectDay.getTime());
  // 關閉面板
  this.panelState = !this.panelState;
},
// 日期格式方法
formatDate(date, fmt = this.format) {
  if (date === null || date === "null") {
    return "--";
  }
  date = new Date(Number(date));
  var o = {
    "M+": date.getMonth() + 1, // 月份
    "d+": date.getDate(), // 日
    "h+": date.getHours(), // 小時
    "m+": date.getMinutes(), // 分
    "s+": date.getSeconds(), // 秒
    "q+": Math.floor((date.getMonth() + 3) / 3), // 季度
    S: date.getMilliseconds() // 毫秒
  };
  if (/(y+)/.test(fmt))
    fmt = fmt.replace(
      RegExp.$1,
      (date.getFullYear() + "").substr(4 - RegExp.$1.length)
    );
  for (var k in o) {
    if (new RegExp("(" + k + ")").test(fmt))
      fmt = fmt.replace(
        RegExp.$1,
        RegExp.$1.length === 1
          ? o[k]
          : ("00" + o[k]).substr(("" + o[k]).length)
      );
  }
  return fmt;
},
// 確認是否爲當前月份
validateDate(item) {
  if (this.nowValue === item.value && item.currentMonth) return true;
},
複製代碼

(2)topbar 中左右箭頭功能,具體詳看下面方法

// <
left() {
  if (this.panelType === "year") this.tmpYear--;
  else {
    if (this.tmpMonth === 0) {
      this.tmpYear--;
      this.tmpMonth = 11;
    } else this.tmpMonth--;
  }
},
// <<
leftBig() {
  if (this.panelType === "year") this.tmpYear -= 12;
  else this.tmpYear--;
},
// >
right() {
  if (this.panelType === "year") this.tmpYear++;
  else {
    if (this.tmpMonth === 11) {
      this.tmpYear++;
      this.tmpMonth = 0;
    } else this.tmpMonth++;
  }
},
// >>
rightBig() {
  if (this.panelType === "year") this.tmpYear += 12;
  else this.tmpYear++;
},
複製代碼

(3) 實現輸入框的雙向綁定及格式規定

props

props: {
  value: {
    type: [Date, String],
    default: ""
  },
  format: {
    type: String,
    default: "yyyy-MM-dd"
  }
},
複製代碼

其中 value 支持日期格式和字符串,當設置了props時,則需在monted鉤子函數中初始化input 值。format 默認值爲 "yyyy-MM-dd", 固然你也能夠設置爲 "dd-MM-yyyy"等。

mounted() {
  if (this.value) {
    this.dateValue = this.formatDate(new Date(this.value).getTime());
  }
},
複製代碼

雙向綁定父組件賦值 props 爲 value, 子組件傳遞的事件爲input, 所以需在 selectDate 方法中 emit 事件及數據給父組件

selectDate(item) {
  ...
  this.$emit("input", selectDay);
},
複製代碼

這樣,父組件即可以進行雙向綁定了

<Datepicker v-model="time" format="dd-MM-yyyy"/>
複製代碼

(4)點擊頁面其餘位置收起日曆面板

原理

監聽頁面的點擊事件,檢測到有點擊事件時關閉面板,但點擊組件內容時也會觸發點擊事件,所以須要在組件內部阻止冒泡。同時,當組件銷燬時,也要及時清除該監聽器。

組件最外層阻止冒泡

<div class="date-picker" @click.stop></div>
複製代碼

頁面建立設置監聽

mounted() {
  ...
  window.addEventListener("click", this.eventListener);
}
複製代碼

頁面銷燬清除監聽

destroyed() {
  window.removeEventListener("click", this.eventListener);
}
複製代碼

公共方法

eventListener() {
  this.panelState = false;
},
複製代碼

項目Demo

項目源碼

有用就點個讚唄~

最後,貼上 CSS 代碼...

  • fadeDownBig 後面的樣式爲 vue <transiton> 的動畫特效.
.topbar {
  padding-top: 8px;
}
.topbar span {
  display: inline-block;
  width: 20px;
  height: 30px;
  line-height: 30px;
  color: #515a6e;
  cursor: pointer;
}
.topbar span:hover {
  color: #2d8cf0;
}
.topbar .year,
.topbar .month {
  width: 60px;
}
.year-list {
  height: 200px;
  width: 210px;
}
.year-list .selected {
  background: #2d8cf0;
  border-radius: 4px;
  color: #fff;
}
.year-list li {
  display: inline-block;
  width: 70px;
  height: 50px;
  line-height: 50px;
  border-radius: 10px;
  cursor: pointer;
}
.year-list span {
  display: inline-block;
  line-height: 16px;
  padding: 8px;
}
.year-list span:hover {
  background: #e1f0fe;
}
.weekday {
  display: inline-block;
  font-size: 13px;
  width: 30px;
  color: #c5c8ce;
  text-align: center;
}
.date-picker {
  width: 210px;
  text-align: center;
  font-family: "Avenir", Helvetica, Arial, sans-serif;
}
.date-panel {
  width: 210px;
  box-shadow: 0 0 8px #ccc;
  background: #fff;
}
ul {
  list-style: none;
  padding: 0;
  margin: 0;
}
.date-list {
  width: 210px;
  text-align: left;
  height: 180px;
  overflow: hidden;
  margin-top: 4px;
}
.date-list li {
  display: inline-block;
  width: 28px;
  height: 28px;
  line-height: 30px;
  text-align: center;
  cursor: pointer;
  color: #000;
  border: 1px solid #fff;
  border-radius: 4px;
}
.date-list .selected {
  border: 1px solid #2d8cf0;
}
.date-list .invalid {
  background: #2d8cf0;
  color: #fff;
}
.date-list .preMonth,
.date-list .nextMonth {
  color: #c5c8ce;
}
.date-list li:hover {
  background: #e1f0fe;
}
input {
  display: inline-block;
  box-sizing: border-box;
  width: 100%;
  height: 32px;
  line-height: 1.5;
  padding: 4px 7px;
  font-size: 12px;
  border: 1px solid #dcdee2;
  border-radius: 4px;
  color: #515a6e;
  background-color: #fff;
  background-image: none;
  position: relative;
  cursor: text;
  transition: border 0.2s ease-in-out, background 0.2s ease-in-out,
    box-shadow 0.2s ease-in-out;
  margin-bottom: 6px;
}
.fadeDownBig-enter-active,
.fadeDownBig-leave-active,
.fadeInDownBig {
  -webkit-animation-duration: 0.5s;
  animation-duration: 0.5s;
  -webkit-animation-fill-mode: both;
  animation-fill-mode: both;
}
.fadeDownBig-enter-active {
  -webkit-animation-name: fadeInDownBig;
  animation-name: fadeInDownBig;
}
.fadeDownBig-leave-active {
  -webkit-animation-name: fadeOutDownBig;
  animation-name: fadeOutDownBig;
}
@-webkit-keyframes fadeInDownBig {
  from {
    opacity: 0.8;
    -webkit-transform: translate3d(0, -4px, 0);
    transform: translate3d(0, -4px, 0);
  }
  to {
    opacity: 1;
    -webkit-transform: none;
    transform: none;
  }
}
@keyframes fadeInDownBig {
  from {
    opacity: 0.8;
    -webkit-transform: translate3d(0, -4px, 0);
    transform: translate3d(0, -4px, 0);
  }
  to {
    opacity: 1;
    -webkit-transform: none;
    transform: none;
  }
}
@-webkit-keyframes fadeOutDownBig {
  from {
    opacity: 1;
  }
  to {
    opacity: 0.8;
    -webkit-transform: translate3d(0, -4px, 0);
    transform: translate3d(0, -4px, 0);
  }
}
@keyframes fadeOutDownBig {
  from {
    opacity: 1;
  }
  to {
    opacity: 0;
  }
}
複製代碼
相關文章
相關標籤/搜索