JS: 簡易 Date Picker

有 N 人看过

如何使用 JavaScript 實作一個 date picker。參考教學: Custom Date Picker in JavaScript & CSS

這邊做一下筆記如何完成以及哪邊該注意的。

起始結構

- index.html
- reset.css
- style.css
- app.js
- simple-date-picker
        - simple-date-picker.css
        -    simple-date-picker.js

index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Simple Date Picker</title>
  <link rel="stylesheet" href="reset.css">
  <link rel="stylesheet" href="style.css">
  <link rel="stylesheet" href="./simple-date-picker/simple-date-picker.css">
</head>
<body>
  <div class="container">
    <div id="date-picker"></div>
  </div>

  <script src="./simple-date-picker/simple-date-picker.js"></script>
  <script src="app.js"></script>
</body>
</html>

style.css

.container {
  width: 100vw;
  height: 100vh;
  background: rgb(249,242,206);
  background: linear-gradient(156deg, rgba(249,242,206,1) 2%, rgba(184,230,246,1) 96%);
  display: flex;
  justify-content: center;
  align-items: center;
}

#date-picker {
  width: 350px;
}

simple-date-picker.css

@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@100;300;400;500;700&display=swap');

.simple-date-picker {
  font-family: 'Roboto', sans-serif;
  position: relative;
  width: 350px;
  height: 70px;
  background-color: mintcream;
  margin: 10px auto;
  box-shadow: 0px 2px 6px rgba(0,0,0,.2);
  cursor: pointer;
  user-select: none;
}

.simple-date-picker:hover {
  background-color: #e9f9ff;
}

.simple-date-picker .selected-date {
  width: 100%;
  height: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
  color: #313131;
  font-size: 30px;
}

.simple-date-picker .dates {
  display: none;
  position: absolute;
  top: 100%;
  left: 0;
  right: 0;
  background-color: #fff;
}

// active 展開
.simple-date-picker .dates.active {
  display: block;
}

simple-date-picker.js

const datePicker = ({root}) => {
  root.innerHTML = `
    <div class="simple-date-picker">
      <div class="selected-date">08-25-2020</div>
      <div class="dates">
        <div class="month">
          <div class="btns prev-month"><<</div>
          <div class="month-label">八月</div>
          <div class="btns next-month">>></div>          
        </div>
          <div class="days"></div>
      </div>
    </div>
  `;

    const datepickerElement = root.querySelector('.simple-date-picker');
  const selectedDateElement = root.querySelector('.simple-date-picker .selected-date');
  const datesElement = root.querySelector('.simple-date-picker .dates');

  datepickerElement.addEventListener('click', toggleDatepicker);

  function toggleDatepicker(e){
    console.log('展開 datepicker')
  }
}

這邊的先用一個函式打包,方便以後可以在其他地方使用,也可以一次使用多個,不會互相打架。

app.js

datePicker({
  root: document.querySelector('#date-picker'),
});

在 app.js 裡呼叫,生成一個 date picker,順便指定要載入的元素 #date-picker。

展開/關閉日期選擇器

toggle 開關

現在日期選擇器可以展開,但是如果滑鼠點擊『月份選擇』區塊時,它會關閉。這時候需要檢查滑鼠所點擊的位置有沒有在 .dates 區塊。

在 simple-date-picker.js 裡面新增一個 helper function。

...(略)

datepickerElement.addEventListener('click', toggleDatepicker);

function toggleDatepicker(e){
  console.log(e.path);
  if (!checkEventPathForClass(e.path, 'dates')) {
    datesElement.classList.toggle('active');
  }
}

function checkEventPathForClass (path, selector) {
  for (let i = 0; i < path.length; i++) {
    if (path[i].classList && path[i].classList.contains(selector)) {
      console.log('path[i].classList', path[i].classList)
      console.log('contains selector? ', path[i].classList.contains(selector))
      return true;
    }
  }
  return false;
}

當 date picker 展開時,滑鼠點擊下方『月份選擇』區塊時,e.path 是有包含 selector dates,所以會 return true

if (!checkEventPathForClass(e.path, 'dates')) {
  datesElement.classList.toggle('active');
}

而我們要 checkEventPathForClass 非 true 時,才關閉日期選擇器。

點擊以外元素&關閉

如果要點擊 #date-picker (root) 以外的地方就關閉日期選擇棄,也可以用類似方法達到。

document.addEventListener('click', function(e){
  if (!root.contains(e.target)){
    datesElement.classList.remove('active');
  }
});

判斷 root element (date picker 所有元素)有沒有包含滑鼠點擊的元素(e.target)。頭兩次的 e.target 都沒有包含所以選擇器不會關閉,到了第三個才會 remove class active

月份 & 日期選擇器

CSS Style

/* 月份選擇 */
.simple-date-picker .dates .month {
  display: flex;
  justify-content: space-between;
  align-items: center;
  border-bottom: 3px solid #eee;
  padding: 5px;
}

/* 月份選擇 左右按鈕 */
.simple-date-picker .dates .month .btns {
  width: 35px;
  height: 35px;
  display: flex;
  justify-content: center;
  align-items: center;
  color: #313131;
  font-size: 20px;
  border-radius: 50%;
}

.simple-date-picker .dates .month .btns:hover {
  background-color: whitesmoke;
  color: #999;
}

.simple-date-picker .dates .month .btns:active {
  background-color: azure;
  color: #bbb;
}

在月份選擇區塊加入樣式。

/* 日期選擇器 */
.simple-date-picker .dates .days {
  height: 240px;
  display: grid;
  grid-template-columns: repeat(7, 1fr);
  border-radius: 5px;
}

.simple-date-picker .dates .days .day {
  display: flex;
  justify-content: center;
  align-items: center;
  color: #313131;
  font-size: 15px;
  font-weight: 300;
  border-radius: 50%;
  border: 1px solid #eee;
  background-color: #eee;
  margin: 5px;
}

.simple-date-picker .dates .days .day:hover {
  font-weight: 700;
  background-color: #fff;
  border: 1px solid #eee;
}

.simple-date-picker .dates .days .day:active,
.simple-date-picker .dates .days .day.selected  {
  font-weight: 700;
  background-color: lightsalmon;
  border: 1px solid lightsalmon;
  color: #fff;
}

在日期區塊加入樣式。

const datePicker = ({root}) => {
  root.innerHTML = `
    <div class="simple-date-picker">
      <div class="selected-date">08-25-2020</div>
      <div class="dates">
        <div class="month">
          <div class="btns prev-month"><<</div>
          <div class="month-label">八月</div>
          <div class="btns next-month">>></div>          
        </div>
        <div class="days">

                    <!-- 這部分先加入日期,測試一下樣式 -->
          <div class="day">1</div>
          <div class="day">2</div>
          <div class="day">3</div>
          <div class="day">4</div>
          <div class="day">5</div>
          <div class="day">6</div>
          <div class="day">7</div>
          <div class="day">8</div>
          <div class="day">9</div>
          <div class="day">10</div>
          <div class="day">11</div>
          <div class="day">12</div>
          <div class="day">13</div>
          <div class="day">14</div>
          <div class="day">15</div>
          <div class="day">16</div>
          <div class="day">17</div>
          <div class="day">18</div>
          <div class="day">19</div>
          <div class="day">20</div>
          <div class="day">21</div>
          <div class="day">22</div>
          <div class="day">23</div>
          <div class="day">24</div>
          <div class="day">25</div>
          <div class="day">26</div>
          <div class="day">27</div>
          <div class="day">28</div>
          <div class="day">29</div>
          <div class="day">30</div>
          <div class="day">31</div>
        </div>
      </div>

    </div>
  `;

.... 略

Date Picker 預設日期

const datePicker = ({root}) => {
  root.innerHTML = `
    <div class="simple-date-picker">
            <!-- 填入的日期先清空,要讓 JS 自動填入當下的日期 -->
      <div class="selected-date"></div>
      <div class="dates">
        <div class="month">
          <div class="btns prev-month"><<</div>
          <div class="month-label"></div>
          <div class="btns next-month">>></div>          
        </div>
        <div class="days"></div>
      </div>

    </div>
  `;

... 略

selectedDateElement.textContent = formatDate(date);

function formatDate(d) {
  let day = d.getDate();
  let month = d.getMonth();
  let year = d.getFullYear();

    if (day < 10) {
    day = `0${day}`;
  }

  if (month < 10){
    month = `0${month+1}`
  }
  return `${year}年 ${month}月 ${day}日`;
}

展開時顯示月份年份

const monthElement = root.querySelector('.simple-date-picker .dates .month .month-label');
const prevBtn = root.querySelector('.simple-date-picker .dates .month .prev-month');
const nextBtn = root.querySelector('.simple-date-picker .dates .month .next-month');
const daysElement = root.querySelector('.simple-date-picker .dates .days');

const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];

// 當下日期
let date = new Date();
let day = date.getDate();
let month = date.getMonth();
let year = date.getFullYear();

// 選擇的日期
let selectedDate = date;
let selectedDay = day;
let selectedMonth = month;
let selectedYear = year;

monthElement.textContent = `${ month+1 }月  ${year}年`;  // 展開時顯示的月份&年份

月份往前/後移動&更新畫面

prevBtn.addEventListener('click', goPrevMonth); // 往前一個月事件監聽
nextBtn.addEventListener('click', goNextMonth); // 往後一個月事件監聽

// 當月份往前到 -1 時,要換成 11 (十二月),年也要跟著減1
function goPrevMonth() {
  month--;
  if (month < 0) {
    month = 11;
    year--;
  }
  monthElement.textContent = `${ month+1 }月  ${year}年`;  // 展開時顯示的月份&年份
}

// 當月份走到12時 (即十三月),要換成 0 (一月),年也要跟著加1
function goNextMonth() {
  month++;
  if (month > 11) {
    month = 0;
    year++;
  }
  monthElement.textContent = `${ month+1 }月  ${year}年`;  // 展開時顯示的月份&年份
}

加入對應日期

populateDates(month, year);

function goPrevMonth() {
    ... 略
  populateDates(month, year);
}

function goNextMonth() {
  ... 略
  populateDates(month, year);
}

function populateDates(mth, yr) {
  daysElement.innerHTML = '';
  let amountDays = getTotalDays(mth, yr); // 取得月份共幾天

  for (let i = 0; i < amountDays; i++) {
    const dayElement = document.createElement('div');
    dayElement.classList.add('day');
    dayElement.textContent = i + 1;

   daysElement.appendChild(dayElement);
  }
}

function getTotalDays(month, year) {
  return new Date(year, month+1, 0).getDate();
}

當要加入對應的日期數量需要先知道被選擇的月份有幾天,所以下面的 getTotalDays() 函式會回傳月份日數,再用這個去跑回圈。

日期事件監聽

當進入 populateDates() 回圈時,需要幫 dayElement 元素個別加入事件監聽,這樣才知道到底選擇了哪一天。

function populateDates(mth, yr) {
  daysElement.innerHTML = '';
  let amountDays = getTotalDays(mth, yr); // 取得月份共幾天

  for (let i = 0; i < amountDays; i++) {
    const dayElement = document.createElement('div');
    dayElement.classList.add('day');
    dayElement.textContent = i + 1;

    ++ // 遇到當下日期/被選擇的日期,加上 css style
    ++ if (selectedDay == (i + 1) && selectedYear == year && selectedMonth == month) {
    ++   dayElement.classList.add('selected');
    ++ }

    ++ dayElement.addEventListener('click', function() {
    ++   selectedDate = new Date(year + '-' + (month+1) + '-' + (i+1));
    ++   selectedDay = (i + 1);
    ++   selectedMonth = month;
    ++   selectedYear = year;

      ++ selectedDateElement.textContent = formatDate(selectedDate);
      ++ selectedDateElement.dataset.value = dataSetFormat(selectedDate); // 在元素上加入 dataset,之後要取得日期比較容易。

      ++ populateDates(selectedMonth, selectedYear); // 當選擇了某一天,日期區塊需要重新更新。
    ++ });

   daysElement.appendChild(dayElement);
  }
}

function dataSetFormat(d) {
  const year = d.getFullYear();
  const month = d.getMonth()+1;
  const day = d.getDate();
  return [year, month, day].join("-");
}

之後要取得選擇的日期可以直接從 data-value 取得。

加入週

/* WEEK */
.simple-date-picker .dates .weekday {
  display: grid;
  grid-template-columns: repeat(7, 1fr);
  border-radius: 5px;
  padding: 8px;
}

.simple-date-picker .dates .weekday .week {
  display: flex;
  justify-content: center;
  align-items: center;
  color: #313131;
  font-size: 13px;
  font-weight: 600;
  margin: 15px 0 0 0;
}

.simple-date-picker .dates .days .space {
  display: flex;
  justify-content: center;
  align-items: center;
  margin: 5px;
  height: 36px;
}

function populateDates(mth, yr) {
  daysElement.innerHTML = '';

  let amountDays = getTotalDays(mth, yr); // 取得月份共幾天

++ let firstDay = getStartedWeekDay(mth, yr);
++   let spacer = ``;

++   for (let i = 0; i < firstDay; i++) {
++     spacer += `<div class="space"></div>`;
++   }

++   daysElement.innerHTML = spacer;

... 略
}

function getStartedWeekDay(month, year) {
  return new Date(year, month, 1).getDay();
}

使用 getStartedWeekDay() 取得月份起始日是週幾。 getDay() 會回傳本地時間星期中的日子(0-6,從星期日開始)。之後在 daysElement 加入幾個空格,把月份的 1 號撐開至對應的星期日子。


Sample
Code

Reference:
Custom Date Picker in JavaScript & CSS

本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可。