JavaScript カレンダー


前回作成した[**祝日クラス**](https://qiita.com/akebi_mh/items/1e2f8ab623e90b9a12ad)を使って、サンプルとして[**カレンダー**](https://akebi.jp/calendar/)を作ってみました。
飾りっ気はありませんが、テーブルタグは使わずCSSでレイアウトしているのを活かして通常のグリッド表示のほかに１行表示やncalコマンド風の縦グリッド表示もできるようにしてみました。

![01.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/618708/393c5863-8f85-bcb0-b4ec-7a858621535b.jpeg)
![02.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/618708/14370af1-74e6-04ab-8100-77db51b99024.jpeg)

###HTML###

~~~html:index.html
<!DOCTYPE html>
<html lang='ja'>
    <head>
        <meta charset='utf-8'>
        <meta name='viewport' content='user-scalable=no,width=device-width,initial-scale=1'>
        <title>Calendar</title>
        <script src='./calendar.js'></script>
        <script src='./holiday_class.js'></script>
        <link rel='stylesheet' href='./calendar.css'>
    </head>

    <body>

        <div class='input'>
            <input id='year' type='text' value='' onchange='calendar(this.value)'>年
            <input id='prev' type='button' value='-' onclick='prev()'>
            <input id='reset' type='button' value='Reset' onclick='reset()'>
            <input id='next' type='button' value='+' onclick='next()'>
            <div class='selector'>
                開始曜日
                <select id='startWeek' onchange="calendar(document.querySelector('#year').value);checkUseStorage()"></select>
                表示タイプ
                <select id='displayType' onchange='displayType(this.value);checkUseStorage()'>
                    <option value='0'>Grid</option>
                    <option value='1'>Line</option>
                    <option value='2'>Vertical</option>
                    <option value='3'>VGrid</option>
                </select>
            </div>
        </div>

        <div class='year'></div>

        <div class='month m1'></div>
        <div class='month m2'></div>
        <div class='month m3'></div>
        <div class='month m4'></div>
        <div class='month m5'></div>
        <div class='month m6'></div>
        <div class='month m7'></div>
        <div class='month m8'></div>
        <div class='month m9'></div>
        <div class='month m10'></div>
        <div class='month m11'></div>
        <div class='month m12'></div>

        <div id='m'></div>
    </body>
</html>
~~~

###JavaScript###

~~~js:calendar.js
// Dateオブジェクト
const d = new Date();

// 今日の年月日
const today = {
    year: d.getFullYear(),
    month: d.getMonth() + 1,
    day: d.getDate(),
};

// 曜日ラベル
const weekLabel = ['日', '月', '火', '水', '木', '金', '土'];

// カレンダー表示
function calendar(year = 0) {

    if(isNaN(year) || year < 1 || year > 9999 || year != Math.floor(year))
        year = today.year;
    else
        year = Number(year);

    // 祝日リスト取得
    const holiday = new Holiday(year).getHolidayOfYear();

    // 年
    document.querySelector('.year').innerHTML = `<div class='yearName'>${year}年</div>`;
    document.querySelector('#year').value = year;

    // 週開始曜日
    const startWeek = document.querySelector('select#startWeek') ? 
        Number(document.querySelector('select#startWeek').value) : 0;

    // 月ループ
    for(let month = 1; month <= 12; month++) {
        // 月ラベル
        let monthBlock = `<div class='monthName'>${month}月</div>`;

        // 曜日ラベル
        monthBlock += "<div class='weekTop weekLabel'>";
        for(let i = 0; i < 7; i++) {
            const w = (startWeek + i) % 7;
            const weekClass = w === 0 ? ' sun' : w === 6 ? ' sat' : '';
            monthBlock += `<div class='d${weekClass}'><div class='t'>${weekLabel[w]}</div></div>`;
        }
        monthBlock += '</div>';

        // 当月1日の曜日
        d.setFullYear(year, month -1, 1);
        const firstDayWeek = d.getDay();

        // 月初余白
        if(firstDayWeek != startWeek) {
            monthBlock += "<div class='weekTop'>" +
                ("<div class='d'>&nbsp;</div>".repeat((firstDayWeek - startWeek + 7) % 7));
        }

        let wCount = 0;

        // 当月最終日
        d.setFullYear(year, month, 0);
        const lastDay = d.getDate();

        // 日ループ
        for(let day = 1; day <= lastDay; day++) {
            // 曜日取得
            d.setFullYear(year, month - 1, day);
            const w = d.getDay();

            // 土日クラス名
            let weekClass = w === 0 ? ' sun' : w === 6 ? ' sat' : '';
            // 祝日クラス名
            if(holiday[month][day] !== undefined) weekClass += ' holiday';

            // 週初め
            if(w === startWeek) monthBlock += "<div class='weekTop'>";

            // 祝日名表示用
            const holidayTag = (holiday[month][day] !== undefined) ?
                "<span class='holiday' " +
                    `onmousemove='p1("${month}/${day} ${holiday[month][day]}")' onmouseout='p0()'>` :
                '';
            monthBlock += holidayTag;

            // 当日クラス名
            const todayClass =
                (today.year === year && today.month === month && today.day === day) ?
                ' today' : '';

            // 当日日付
            monthBlock += `<div class='d${weekClass}${todayClass}'><div class='t'>${day}</div></div>`;

            // 祝日閉じ
            monthBlock += (holidayTag !== '') ? '</span>' : '';

            // weekTopの閉じ
            if(w === (startWeek + 6) % 7 || day === lastDay) {
                monthBlock += '</div>';
                wCount++;
            }
        }

        if(wCount < 6)
            monthBlock += "<div class='weekTop'><div class='d'>&nbsp;</div></div>".repeat(6 - wCount);

        // DOM更新
        document.querySelector(`.month.m${month}`).innerHTML = monthBlock;
    }

    displayType(document.querySelector('#displayType').value);
}

function p1 (str){
    const d = document.querySelector('#m');
    d.textContent = str;
    d.style.top = (y > 0 ? y : 0) + 'px';
    d.style.left = (x > 0 ? x : 0) + 'px';
    d.style.display = 'block';
}

function p0 (){
    const d = document.querySelector('#m');
    d.style.display = 'none';
}

let x, y;
const storage = getStorage('calendar_params');

onload = function() {
    let startWeekSelectOptions = '';

    for(let i = 0; i < 7; i++)
        startWeekSelectOptions += `<option value='${i}'>${weekLabel[i]}</option>`;

    if(document.querySelector('select#startWeek'))
        document.querySelector('select#startWeek').innerHTML = startWeekSelectOptions;

    if(storage['startWeek'] !== undefined)
        document.querySelector('select#startWeek').value = storage['startWeek'];

    if(storage['displayType'] !== undefined)
        document.querySelector('select#displayType').value = storage['displayType'];

    calendar();

    document.onmousemove = function(e) {
        x = e.pageX - 40;
        y = e.pageY - 40;
    }
    document.ontouchstart = function(e) {
        const ct = e.changedTouches[0];
        x = ct.pageX - 40;
        y = ct.pageY - 72;
    }

    checkUseStorage();
    setInterval(checkDayUpdate, 1000);
}

// 日付切替わり時更新
function checkDayUpdate() {
    const nd = new Date();
    if(today.day != nd.getDate()) {
        const prevDayYear = today.year;
        today.year = nd.getFullYear();
        today.month = nd.getMonth() + 1;
        today.day = nd.getDate();
        if(document.querySelector('#year').value == prevDayYear) {
            calendar(today.year);
        }
    }
}

function prev() {
    calendar(Number(document.querySelector('#year').value) - 1);
}
function next() {
    calendar(Number(document.querySelector('#year').value) + 1);
}
function reset() {
    calendar(today.year);
}

// レイアウト変更時 クラス名追加/削除
function displayType(n) {
    function classNameReplace(e, arr, n) {
        for(const i in arr) if(arr[i]) e.classList.remove(arr[i]);
        if(arr[n]) e.classList.add(arr[n]);
    }

    for(const i of document.querySelectorAll('.month'))
        classNameReplace(i, ['', 'line', 'v', 'gv'], n);

    for(const i of document.querySelectorAll('.weekLabel'))
        classNameReplace(i, ['', 'line', 'line', ''], n);

    for(const i of document.querySelectorAll('.monthName'))
        classNameReplace(i, ['', 'line', 'v', 'gv'], n);

    for(const i of document.querySelectorAll('div.d'))
        classNameReplace(i, ['', '', 'v', 'gv'], n);

    for(const i of document.querySelectorAll('.weekTop'))
        classNameReplace(i, ['', '', '', 'gv'], n);
}

// storage保存
function setStorage(name, value) {
    localStorage.setItem(name, JSON.stringify(value));
}

// storage取得
function getStorage(name) {
    const storage = localStorage.getItem(name);
    return storage ? JSON.parse(storage) : {};
}

// レイアウト保持状態チェック
function checkUseStorage() {
    if(+document.querySelector('select#startWeek').value || +document.querySelector('select#displayType').value)
        updateStorage();
    else
        deleteStorage();
}

// レイアウト保持storage更新
function updateStorage() {
    setStorage('calendar_params', {
            'startWeek': document.querySelector('select#startWeek').value,
            'displayType': document.querySelector('select#displayType').value,
        }
    );
}

// レイアウト保持storage削除
function deleteStorage() {
    localStorage.removeItem('calendar_params');
}
~~~

###CSS###

~~~css:calendar.css
input[type="text"] {
    width: 48px;
}
input[type="button"] {
    width: 25px;
}
input[type="button"]#reset {
    width: 52px;
}
select {
    font-size: 11px;
    height: 20px;
}
.input {
    padding-bottom: 10px;
}
.selector {
    display: inline-block;
    border: 1px #e0e0e0 inset;
    padding: 4px;
    border-radius: 3px;
    font-size: 12px;
    white-space: nowrap;
}
span.holiday {
    text-decoration: none;
    cursor: pointer;
}
.year {
    padding-bottom: 16px;
}
.yearName {
    margin: 0 auto 0 auto;
}
.month {
    line-height: 150%;
    padding-bottom: 16px;
    display: inline-grid;
    padding-right: 16px;
}
.monthName {
    margin: 0 auto 0 auto;
}
div.d {
    display: inline-grid;
    width: 26px;
}
.t {
    margin: 0 auto 0 auto;
    font-size: 14px;
}
.d.sat {
    color: #4444ff;
}
.d.sun {
    color: #ff4444;
}
.d.holiday {
    color: #ff4444;
    font-weight: bold;
}
.d.today {
    background: #ddddff;
}
#m {
    position: absolute;
    top: 0px;
    left: 0px;
    display: none;
    padding: 4px 12px 4px 12px;
    background-color: #ffffff;
    color: #882222;
    font-size: 14px;
    font-weight: bold;
    border: 1px #884444 solid;
}

/* 横一列レイアウト */
.month.line {
    display: -webkit-inline-box;
    width: fit-content;
    border-top: 1px #e0e0e0 solid;
}
.weekLabel.line {
    display: none;
}
.monthName.line {
    width: 48px;
    text-align: right;
    padding-right: 16px;
    display: inline-grid;
}

/* 縦一列レイアウト */
div.d.v {
    display: block;
    text-align: center;
}
.month.v {
    line-height: 140%;
    padding-right: 0px;
    width: 40px;
    border-right: 1px #e0e0e0 solid;
    margin-bottom: 24px;
}
.monthName.v {
    margin: 0;
}

/* 縦グリッドレイアウト */
div.d.gv {
    display: flex;
}
.month.gv {
    display: inline-flex;
    padding-right: 0px;
}
.monthName.gv {
    position: absolute;
    padding-left: 22px;
}
.weekTop.gv {
    padding-top: 25px;
}
~~~

他に[前回の祝日クラス](https://qiita.com/akebi_mh/items/1e2f8ab623e90b9a12ad)を**holiday_class.js**として保存したファイルを使います。