Vue Datepicker を使って期間選択 UI を作成する【Vue3】

# 概要

[Vue Datepicker](https://vue3datepicker.com/) を使い `2022/1/1 〜 2022/6/1` のような期間を選択できる UI を作成してみました。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/488228/495c1c58-c4ed-363a-9cb5-0921630b5d8d.png" width="80%">

# Vue Datepicker とは？

公式サイト: https://vue3datepicker.com/

> Vue3 用の最も完全な datepicker の解決策  
> 強力、軽量、再利用可能な datepicker コンポーネントで、あらゆるプロジェクトに適合します。

- 年、月、日、秒まで指定可能
- 日本語対応可能
- デザイン & 操作性 :+1:

更に、オプションが100個近くあるので、どのようなユースケースにも対応できそうです。

## :rocket: 使用方法

公式の手順: https://vue3datepicker.com/installation/

:one: インストール

```sh
yarn add @vuepic/vue-datepicker
```

:two: 設置

```MyComp.vue
<script setup>
import { ref } from 'vue'
import Datepicker from '@vuepic/vue-datepicker'
import '@vuepic/vue-datepicker/dist/main.css'

const date = ref(new Date())
</script>

<template>
  <Datepicker v-model="date"></Datepicker>
</template>
```

こんな感じになる :arrow_down: 

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/488228/8579660b-b7a1-503f-b8c1-06bd34561b34.png" width="50%">

## :warning: 注意事項

以下のような CSS を定義していると、年と月の選択が正常に出来なくなるためご注意ください。

```css
* {
  position: relative;
}
```

※厳密には `.dp__month_year_row` クラスに `position: relative` が当たっている場合に発生します。


# (本題) 期間選択 UI を作る

Vue Datepicker の [range](https://vue3datepicker.com/api/props/#range) プロパティを使えば `9/1 〜 9/5` のような期間指定ができますが、

- 操作性が微妙 (個人的に)
- 月や年を跨ぐような広範囲だと更に使いづらい (個人的に)

という理由で Datepicker を開始日と終了日で2つ配置することにしました。
しかし、

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/488228/8e3efef3-5e82-3608-0744-d9205232a81e.png" width="70%">

こんな感じの UI にすると必要な操作が増えたりと微妙なので、テキストフィールド & テキストフィールドクリック時に表示するメニューを自前で用意し、メニューの中に `inline` オプションでカレンダーのみにした Datepicker を2つ配置する形にしたいと思います。

- `inline` オプション: https://vue3datepicker.com/api/props/#inline
    - 入力フィールドを削除し、カレンダーを親コンポーネントに配置する

**使用ライブラリとバージョン**

- vue `v3.2.38`
- @vuepic/vue-datepicker `v3.4.8`
- date-fns `v2.29.2`
    - 日付の計算のために使用。他ライブラリで代用可
- vuetify `v3.0.0-beta.10`
    - テキストフィールド、モーダル、ボタンなどの UI 設置のために使用。他ライブラリで代用可

**ソースコード**

<details>
<summary>長いので折りたたみ</summary>

```vue
<script setup lang="ts">
import { ref, computed } from 'vue'
import Datepicker from '@vuepic/vue-datepicker'
import '@vuepic/vue-datepicker/dist/main.css'
import { format, differenceInDays } from 'date-fns'

// 期間
const startDate = ref(new Date())
const endDate = ref(new Date())

// 入力したがまだ反映はされていない、仮状態の期間
const tmpStartDate = ref(startDate.value)
const tmpEndDate = ref(endDate.value)

// 仮状態の期間を変更した／していないのフラグ
const isTmpStartDateChanged = ref(false)
const isTmpEndDateChanged = ref(false)

// メニューが表示されているなら true
const isMenuOpened = ref(false)

// メニューを閉じる。合わせて各状態を初期値に戻す
const closeMenu = () => {
  isMenuOpened.value = false
  // メニューが閉じられてから (ユーザの目に見えないときに) 初期値に戻す
  setTimeout(() => {
    tmpStartDate.value = startDate.value
    tmpEndDate.value = endDate.value
    isTmpStartDateChanged.value = false
    isTmpEndDateChanged.value = false
  }, 300)
}

// 仮状態の期間を反映する
const updatePeriods = () => {
  startDate.value = tmpStartDate.value
  endDate.value = tmpEndDate.value
  closeMenu()
}

// 仮状態の期間を反映可能な状態なら true
const isUpdatable = computed<boolean>(() => {
  // 期間が変更されていないなら false
  if (!isTmpStartDateChanged.value && !isTmpEndDateChanged.value) {
    return false
  }
  // end が start より過去の値になっているなら false (同日は可)
  const diffDays = differenceInDays(tmpEndDate.value, tmpStartDate.value)
  if (diffDays < 0) {
    return false
  }
  // 上記以外は true
  return true
})

const formatDate = (date: Date): string => {
  return format(date, 'yyyy年M月d日')
}

// Vue Datepicker に渡すオプション
const datepickerOptions = {
  inline: true, // 入力フィールドを削除し、カレンダーを親コンポーネントに配置する
  format: formatDate,
  locale: 'jp',
  monthChangeOnScroll: false, // マウスホイールで月を切り替えない
  autoApply: true, // 日付をクリックした際、自動的にその値を選択する
  noToday: true, // カレンダーから今日のマークを隠す
  hideOffsetDates: true, // カレンダーの前月／翌月の日付を非表示にする
  preventMinMaxNavigation: true, // minDate または maxDate の後または前のナビゲーションを防止する
  enableTimePicker: false // タイムピッカーを無効化
}

const startDatepickerOptions = {
  ...datepickerOptions
}

const endDatepickerOptions = computed(() => ({
  ...datepickerOptions,
  minDate: tmpStartDate.value // 選択できる最小の日付は start と同日まで
}))

const handleUpdateStartDatepicker = () => {
  // 仮状態の期間を変更した／していないのフラグを更新
  isTmpStartDateChanged.value = true
  isTmpEndDateChanged.value = false
}

const handleUpdateEndDatepicker = () => {
  // 仮状態の期間を変更した／していないのフラグを更新
  isTmpEndDateChanged.value = true
  isTmpStartDateChanged.value = false
}
</script>

<template>
  <div>
    <v-menu
      v-model="isMenuOpened"
      transition="slide-y-transition"
      :close-on-content-click="false"
      @update:modelValue="(opened) => opened || closeMenu()"
    >
      <template v-slot:activator="{ props }">
        <v-text-field
          :model-value="`${formatDate(startDate)}〜${formatDate(endDate)}`"
          v-bind="props"
          label="期間"
          density="comfortable"
          hide-details
        />
      </template>

      <v-card class="pa-3">
        <v-card-title class="mb-3">
          <v-icon icon="mdi-calendar-check" class="mt-n1 mr-2" size="md" />期間
        </v-card-title>

        <v-card-text>
          <p class="tmpDate mb-3">
            <span class="tmpDate__item" :class="{'is-active': isTmpStartDateChanged}">
              {{ formatDate(tmpStartDate) }}
            </span>
            <span>&nbsp;-&nbsp;</span>
            <span class="tmpDate__item" :class="{'is-active': isTmpEndDateChanged}">
              {{ formatDate(tmpEndDate) }}
            </span>
          </p>

          <v-row class="mb-5">
            <v-col cols="auto">
              <p class="text-overline font-weight-bold">FROM</p>
              <Datepicker
                v-model="tmpStartDate"
                v-bind="startDatepickerOptions"
                @update:modelValue="handleUpdateStartDatepicker"
              />
            </v-col>

            <v-col cols="auto">
              <p class="text-overline font-weight-bold">TO</p>
              <Datepicker
                v-model="tmpEndDate"
                v-bind="endDatepickerOptions"
                @update:modelValue="handleUpdateEndDatepicker"
              />
            </v-col>
          </v-row>

          <v-btn
            color="primary"
            prepend-icon="mdi-update"
            :disabled="!isUpdatable"
            class="px-8 mr-4"
            @click="updatePeriods"
          >
            更新
          </v-btn>
          <v-btn
            prepend-icon="mdi-cancel"
            @click="closeMenu"
          >
            キャンセル
          </v-btn>
        </v-card-text>
      </v-card>
    </v-menu>
  </div>
</template>

<style scoped lang="scss">
::v-deep(.v-field__input) {
  font-size: 14px;
}

.tmpDate {
  margin: -6px -8px;

  &__item {
    transition: 0.3s;
    padding: 6px 8px;
    border-radius: 4px;

    &.is-active {
      background-color: rgba(var(--v-theme-primary), 0.15)
    }
  }
}
</style>
```
</details>

**出来たもの**
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/488228/689d07f0-4826-49ce-c089-d078b0c0a60c.gif" width="80%">

# 参考サイト

- [Vue Datepicker](https://vue3datepicker.com/)
- [Vue3 DatePickerを使ってみるついでにmonthPickerとtimePickerで詰まった話 – Amayz-技術ブログ](https://blog.amayz.co.jp/?p=696)
- [「Vue 3 Datepicker」やりたいことは何でもできるんじゃない？！  –  ちょいプラ素材](https://vitorec.co.jp/choi-plus-sozai/%E3%80%8Cvue-3-datepicker%E3%80%8D%E3%82%84%E3%82%8A%E3%81%9F%E3%81%84%E3%81%93%E3%81%A8%E3%81%AF%E4%BD%95%E3%81%A7%E3%82%82%E3%81%A7%E3%81%8D%E3%82%8B%E3%82%93%E3%81%98%E3%82%83%E3%81%AA%E3%81%84/)