Vue.jsのカレンダーコンポーネント作ってみた

# 背景
私が以前、作成したWebアプリでカレンダー機能が必要となったが、

- 指定した日付に任意のhtmlを組み込む
- カレンダーのサイズや色などを自由に変更可能

という条件を満たすカレンダーコンポーネントを見つけることができなかった。
そのため、作ってみた。

（以前作成したWebアプリはこちら）


https://qiita.com/temmaru/items/a06264d35cefca62a689

# 作ったもの
以下が、ソースコードである。
`src/components/Calendar.vue`が該当のカレンダーコンポーネントである。

https://gitlab.com/teten927/vue.js-calendar

例えば、下記のようなカレンダーが作成できる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1526678/7fca0730-9f5c-0dad-9525-b9308df72661.png)


# 仕様
### プロパティ一覧
|  プロパティ名  |  型  |  デフォルト値  |  説明  |
| ---- | ---- | ---- | ---- |
|  year  |  Number  |  現在日時の年。  |  取得したいカレンダーの年。  |
|  month  |  Number  |  現在日時の月。  |  取得したいカレンダーの月。  |
|  weekHeight  |  String  |  "60px"  |  カレンダーの曜日部分の各マスの高さ。  |
|  itemMinHeight  |  String  |  "300px"  |  カレンダーの日付部分の各マスの最小の高さ。<br>※高さを固定にしたい場合、「itemMaxHeight」にも同じ値を入れる。  |
|  itemMaxHeight  |  String  |  "300px"  |  カレンダーの日付部分の各マスの最大の高さ。<br>※高さを固定にしたい場合、「itemMinHeight」にも同じ値を入れる。  |
|  itemMinWidth  |  String  |  "300px"  |  カレンダーの日付部分の各マスの最小の幅。<br>※幅を固定にしたい場合、「itemMaxWidth」にも同じ値を入れる。  |
|  itemMaxWidth  |  String  |  "300px"  |  カレンダーの日付部分の各マスの最大の幅。<br>※幅を固定にしたい場合、「itemMinWidth」にも同じ値を入れる。  |
|  outsideDateColor  |  String  |  "gray"  |  カレンダーの最初と最後の、該当月外の日付部分の色。  |
|  weekNames  |  Array  |  [<br> "SUN",<br> "MON",<br> "TUE",<br> "WED",<br> "THU",<br> "FRY",<br> "SAT"<br>]  |  カレンダーの曜日部分の表示内容。日曜日から順に配列に入れる。<br>null値を入れた箇所には、デフォルトの表示で置き換えられる。  |
|  saturdayColor  |  String  |  "blue"  |  カレンダーの曜日の「土曜日」に該当する文字のフォント色。  |
|  sundayColor  |  String  |  "red"  |  カレンダーの曜日の「日曜日」に該当する文字のフォント色。  |
|  weekdayColor  |  String  |  "black"  |  カレンダーの曜日の「土曜日」、「日曜日」以外に該当する文字のフォント色。  |
|  weekFontSize  |  String  |  "26px"  |  カレンダーの曜日の文字のフォントサイズ。  |
|  dateColor  |  String  |  "black"  |  カレンダーの日付の文字のフォント色。  |
|  dateFontSize  |  String  |  "16px"  |  カレンダーの日付の文字のフォントサイズ。  |
|  borderColor  |  String  |  "black"  |  カレンダーの線の色。  |
|  borderStyle  |  String  |  "double"  |  カレンダーの線のスタイル。  |
|  borderWidth  |  String  |  "4px"  |  カレンダーの線の幅。  |
|  todayBorderColor  |  String  |  -  |  現在の日付が表示月の中にあれば、指定した色で囲む。<br>本プロパティを未設定の場合、現在の日付があっても、囲まれない。  |
|  backgroundColor  |  String  |  "transparent"  |  カレンダー全体の背景色。  |

### templateの組み込み方
カレンダーの任意の日付のマスに、templateを組み込みたい場合、`v-slot`を使用する。

```Vue
<template v-slot:●●●>
  ※任意のテンプレート
</template>
```
上記の、●●●の部分に、テンプレートを組み込みたい日付（1日なら、1）を記載する。
またv-slotにて、指定されなかった日付全てを一括で設定したい場合、`default`を使用する。


```Vue
<template v-slot:default>
  ※任意のテンプレート
</template>
```

### コンポーネント呼び出し方の例
```Vue
    <Calendar
      outsideDateColor="rgb(70,70,70)"
      itemMinWidth="290px"
      itemMaxWidth="290px"
      itemMinHeight="200px"
      itemMaxHeight="200px"
      weekFontSize="40px"
      weekdayColor="white"
      dateColor="white"
      dateFontSize="24px"
      borderStyle="double"
      borderWidth="6px"
      borderColor="white"
      todayBorderColor="yellow"
      backgroundColor="black"
    >

        <!-- 休日だけ、「休暇です」と赤字で表示する -->
        <template v-for="element in [3,4,10,11,17,18,24,25,31]" v-slot:[element]>
          <div :key="element">
            <p style="color: white; color:red">休暇です。</p>
            </div>
        </template>

        <!-- 休日以外は、「スケジュール入力テキストボックス」と、「ボタン」を設置する -->
        <template v-slot:default style="color:white">
          <p style="color:white">スケジュールを入力してね！</p><input type="text" />
          <button>Click!</button>
          </template>

    </Calendar>
```

### 注意事項
itemMaxWidth、itemMinWidth、itemMaxHeight、itemMinHeightの設定値によっては、レイアウトが崩れることがあります。
調整してください。

# 最後に
私がWebアプリを作ったときには、上手くコンポーネント化できず、べた書きしていた。
Webアプリの仕様にバリバリ依存したカレンダーになっていた。
上手にコンポーネント化することは、再利用性の向上や可読性の向上など様々なメリットをうむ。
ちょっと面倒くさくても、将来性を考えて、どんどんコンポーネント化しよう！

templateの組み込みについては、このやり方で良かったのか気になっている。
v-htmlを使うことも考えていたが、XSSの危険性があるため避けた。
もっといいやり方があったら、教えていただけますと幸いです。

# Calendar.vue
コピペ用に記載しておきます。
長いのでスルーでおｋです。

```Vue:Calendar.vue
<template>
  <div>
    <div
      style="display: flex"
      :style="{
        'border-left-color': borderColor,
        'border-left-style': borderStyle,
        'border-left-width': borderWidth,
        height: weekHeight,
      }"
    >
      <div
        v-for="(day, index) in [
          weekNames[0] || 'SUN',
          weekNames[1] || 'MON',
          weekNames[2] || 'TUE',
          weekNames[3] || 'WED',
          weekNames[4] || 'THU',
          weekNames[5] || 'FRY',
          weekNames[6] || 'SAT',
        ]"
        :key="index"
        :style="{
          'min-width': itemMinWidth,
          'max-width': itemMaxWidth,
          'font-size': weekFontSize,
          'border-top-color': borderColor,
          'border-top-style': borderStyle,
          'border-top-width': borderWidth,
          'border-right-color': borderColor,
          'border-right-style': borderStyle,
          'border-right-width': borderWidth,
          'border-bottom-color': borderColor,
          'border-bottom-style': borderStyle,
          'border-bottom-width': borderWidth,
          'background-color': backgroundColor,
        }"
        class="days-style"
      >
        <div v-if="index === 0" :style="{ color: sundayColor }">
          {{ day }}
        </div>
        <div v-else-if="index === 6" :style="{ color: saturdayColor }">
          {{ day }}
        </div>
        <div v-else :style="{ color: weekdayColor }">
          {{ day }}
        </div>
      </div>
    </div>
    <div style="display: flex; flex-direction: column">
      <div
        v-for="(week, index) in calendars"
        :key="index"
        style="display: flex"
        :style="{
          'min-height': itemMinHeight,
          'max-height': itemMaxHeight,
          'border-left-color': borderColor,
          'border-left-style': borderStyle,
          'border-left-width': borderWidth,
        }"
      >
        <div
          v-for="(item, index) in week"
          :key="index"
          style="position: relative"
          :style="{
            'min-width': itemMinWidth,
            'max-width': itemMaxWidth,
            'border-right-color': borderColor,
            'border-right-style': borderStyle,
            'border-right-width': borderWidth,
            'border-bottom-color': borderColor,
            'border-bottom-style': borderStyle,
            'border-bottom-width': borderWidth,
            'background-color': backgroundColor,
          }"
        >
          <div
            v-if="todayBorderColor && item.isToday"
            class="today-style"
            :style="{
              'border-color': todayBorderColor,
              'border-width': borderWidth,
              width: 'calc(100% - (' + borderWidth + ' * 2))',
              height: 'calc(100% - (' + borderWidth + ' * 2))',
            }"
          />
          <div
            v-if="item.isOut"
            style="height: 100%; width: 100%; z-index: 1"
            :style="{
              'background-color': outsideDateColor || gray,
            }"
          >
            <div
              :style="{
                color: dateColor,
                'font-size': dateFontSize,
              }"
            >
              {{ item.date }}
            </div>
          </div>
          <div v-else style="height: 100%; width: 100%; z-index: 1">
            <div
              :style="{
                color: dateColor,
                'font-size': dateFontSize,
              }"
            >
              {{ item.date }}
            </div>
            <div
              style="width: 100%"
              :style="{
                height: 'calc(100% - ' + dateFontSize + ')',
              }"
            >
              <slot v-if="$scopedSlots[item.date]" :name="item.date"></slot>
              <slot v-else></slot>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import moment from "moment";

export default {
  props: {
    year: {
      type: Number,
      default: Number(moment().get("years")),
    },
    month: {
      type: Number,
      default: Number(moment().get("months")) + 1,
    },
    weekHeight: {
      type: String,
      default: "60px",
    },
    itemMinWidth: {
      type: String,
      default: "300px",
    },
    itemMaxWidth: {
      type: String,
      default: "300px",
    },
    itemMinHeight: {
      type: String,
      default: "300px",
    },
    itemMaxHeight: {
      type: String,
      default: "300px",
    },
    outsideDateColor: String,
    weekNames: {
      type: Array,
      default: () => [],
    },
    saturdayColor: {
      type: String,
      default: "blue",
    },
    sundayColor: {
      type: String,
      default: "red",
    },
    weekdayColor: {
      type: String,
      default: "black",
    },
    weekFontSize:  {
      type: String,
      default: "26px",
    },
    dateColor: {
      type: String,
      default: "black",
    },
    dateFontSize: {
      type: String,
      default: "16px",
    },
    borderColor: {
      type: String,
      default: "black",
    },
    borderStyle: {
      type: String,
      default: "double",
    },
    borderWidth: {
      type: String,
      default: "4px",
    },
    todayBorderColor: String,
    backgroundColor: {
      type: String,
      default: "transparent",
    },
  },
  data() {
    return {
      today: moment(),
      yearMonth: null,
    };
  },
  mounted() {
    this.yearMonth = moment({
      years: this.year,
      months: this.month - 1,
    });
  },
  methods: {
    getStartDate() {
      let date = moment(this.yearMonth);
      date.startOf("month");
      const youbiNum = date.day();
      return date.subtract(youbiNum, "days");
    },
    getEndDate() {
      let date = moment(this.yearMonth);
      date.endOf("month");
      const youbiNum = date.day();
      return date.add(6 - youbiNum, "days");
    },
  },
  computed: {
    calendars() {
      if (!this.yearMonth) return [];
      let startDate = this.getStartDate();
      const endDate = this.getEndDate();
      const weekNumber = Math.ceil(endDate.diff(startDate, "days") / 7);

      let calendars = [];
      for (let week = 0; week < weekNumber; week++) {
        let weekRow = [];
        for (let day = 0; day < 7; day++) {
          const isOut = Number(startDate.get("month")) !== this.month - 1;
          let item = {
            date: startDate.get("date"),
            isOut: isOut,
            isToday: !isOut && this.today.isSame(startDate, "day"),
          };
          weekRow.push(item);
          startDate.add(1, "days");
        }
        calendars.push(weekRow);
      }
      return calendars;
    },
  },
};
</script>

<style scoped>
.days-style {
  display: flex;
  align-items: center;
  justify-content: center;
  font-weight: bold;
}
.today-style {
  position: absolute;
  border-style: solid;
}
</style>
```