開発情報・ナレッジ

投稿者: ShiningStar株式会社 2025年6月20日 (金)

テキストをカレンダー形式で表示させるサンプルプログラム

フォームの日付入力欄は、ユーザーにとって入力が煩雑になりがちな要素です。
特に、年月日が別々のテキストフィールドになっている場合、UI/UXの低下を招くことがあります。
本記事では、既存の年月日入力欄を、
直感的に操作できるカレンダーピッカーに置き換えるJavaScriptの実装方法を紹介します。
既存のHTML構造を維持しつつ、UIを大幅に改善することが可能です。

この実装で実現できること

既存の年月日入力欄を、モダンなカレンダーピッカーUIに置き換え。
元の入力フィールドは非表示(hidden)で保持し、フォーム送信時のデータ互換性を維持。
既存SPIRALデフォルトCSSと干渉しないよう、JavaScriptでスタイルを動的に適用。
日付の妥当性チェック機能により、無効な日付(例: 6月31日)の入力を防止。
日付(○年○月○日 ○時○分○秒)
日付(○年○月○日 ○時○分)
日付(○年○月○日 ○時)
日付(○年○月○日)フィールドに対応しています。

実装の概要

このスクリプトは、指定されたIDを持つ年月日入力フィールド(li要素)をターゲットにし、
以下の処理を自動で行います。

  1. 元の年月日入力欄を非表示にし、代わりに日付表示用の新しい入力欄を設置します。
  2. 新しい入力欄をクリックすると、カレンダーがその場に表示されます。
  3. ユーザーがカレンダー上の日付を選択すると、表示用および元の非表示フィールドに日付が自動的に設定されます。
  4. カレンダーのスタイルはJavaScriptによって直接要素に適用されるため、
    デフォルトのCSSファイルとの競合を最小限に抑えます。

導入方法:JavaScriptおよびCSSコード

以下のJavaScriptコードをページの任意の場所に設置します。
bodyタグを閉じる直前がおすすめです。
このコードには、カレンダーのUIを生成するためのCSSも含まれています。

<script>
document.addEventListener('DOMContentLoaded', function() {
    // --- 設定値 ---
    const config = {
        minDays: null, // 本日を「0」として何日後から選択可能にするか (例: 1 は明日から)
        maxDays: null, // 本日を「0」として何日後まで選択可能にするか (例: 30 は30日後まで)
        defaultDate: null, // 初期表示される日付 { year: YYYY, month: M, day: D } または { year: 0, month: 1, day: 0 } (1ヶ月後)
        disabledDates: "", // 選択不可にする特定の日付 (カンマ区切り "YYYY/MM/DD,YYYY/MM/DD")
        disabledDaysOfWeek: "" // 選択不可にする曜日 (カンマ区切り 0:日,1:月,2:火,3:水,4:木,5:金,6:土)
    };
    // --- 設定値ここまで ---

    const style = document.createElement('style');
    style.textContent = `
        .calendar-wrapper { position: relative; display: inline-block; vertical-align: middle; font-size: 14px; /* 基本フォントサイズ */}
        .calendar-container { display: none; position: absolute; top: 100%; left: 0; z-index: 1000; border: 1px solid #ccc; background: #fff; padding: 15px; box-shadow: 0 4px 8px rgba(0,0,0,0.15); margin-top: 2px; border-radius: 4px; min-width: 280px; }
        .calendar-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; }
        .calendar-header span { font-size: 1.1em; font-weight: bold; }
        .calendar-header button { border: 1px solid #ccc; background-color: #f0f0f0; cursor: pointer; font-size: 1.2em; padding: 5px 10px; border-radius: 3px; line-height: 1; }
        .calendar-header button:hover { background-color: #e0e0e0; }
        .calendar-grid { width: 100%; border-collapse: collapse; }
        .calendar-grid th, .calendar-grid td { text-align: center; padding: 8px; width: 38px; height: 38px; font-size: 0.95em; }
        .calendar-grid th { font-weight: normal; color: #666; }
        .calendar-grid td { cursor: pointer; border-radius: 4px; /* 四角に近い形状にするため50%から変更 */ width: 32px; /* 明示的な幅 */ height: 32px; /* 明示的な高さ */ line-height: 32px; /* テキストを垂直方向に中央揃え */ text-align: center; /* テキストを水平方向に中央揃え */ box-sizing: border-box; }
        .calendar-grid td:hover { background: #f0f0f0; }
        .calendar-grid .day-disabled { color: #ccc; cursor: not-allowed; background: #f9f9f9 !important; text-decoration: line-through; }
        .calendar-grid .day-today { font-weight: bold; border: 1px solid #007bff; }
        .calendar-grid .day-selected { background-color: #007bff; color: #fff; }
        .calendar-grid .day-selected:hover { background-color: #0056b3; color: #fff; }
        .calendar-display { border: 1px solid #ccc !important; padding: 8px 10px !important; width: 180px !important; background: #fff url('data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22%23333%22%3E%3Cpath%20d%3D%22M19%204h-1V2h-2v2H8V2H6v2H5c-1.11%200-1.99.9-1.99%202L3%2020c0%201.1.89%202%202%202h14c1.1%200%202-.9%202-2V6c0-1.1-.9-2-2-2zm0%2016H5V9h14v11zM7%2011h5v5H7z%22%2F%3E%3C%2Fsvg%3E') no-repeat right 8px center !important; background-size: 16px 16px !important; cursor: pointer !important; border-radius: 4px !important; text-align: left !important; font-size: inherit !important; box-sizing: border-box !important; height: auto !important; margin-right: 10px !important; }
    `;
    document.head.appendChild(style);

    const parsedDisabledDates = config.disabledDates.split(',').map(d => d.trim()).filter(d => d && /\d{4}\/\d{1,2}\/\d{1,2}/.test(d));
    const parsedDisabledDaysOfWeek = config.disabledDaysOfWeek.split(',').map(d => parseInt(d.trim(), 10)).filter(d => !isNaN(d) && d >= 0 && d <= 6);

    const datePickers = document.querySelectorAll('dd.data.time');

    datePickers.forEach((picker, index) => {
        const yearInput = picker.querySelector("input[name$=':y']");
        const monthInput = picker.querySelector("input[name$=':m']");
        const dayInput = picker.querySelector("input[name$=':d']");

        if (!yearInput || !monthInput || !dayInput) return; // 年月日全て揃っていない場合はスキップ

        // 時分秒フィールドを表示したままにするため、元のul要素は非表示にしない

        const wrapperId = `cal-wrapper-${index}`;
        if (document.getElementById(wrapperId)) return;

        const wrapper = document.createElement('div');
        wrapper.id = wrapperId;
        wrapper.className = 'calendar-wrapper';

        const displayInput = document.createElement('input');
        displayInput.type = 'text';
        displayInput.className = 'calendar-display';
        displayInput.readOnly = true;
        displayInput.placeholder = '日付を選択';

        const calendarContainer = document.createElement('div');
        calendarContainer.className = 'calendar-container';

        wrapper.appendChild(displayInput);
        wrapper.appendChild(calendarContainer);
        
        // カレンダーピッカーを他の入力要素(時分秒など)より前に表示するため、prepend()を使用
        picker.prepend(wrapper);

            // 元のinputを削除せず、hidden属性で残す
            if (yearInput) yearInput.type = 'hidden';
            if (monthInput) monthInput.type = 'hidden';
            if (dayInput) dayInput.type = 'hidden';
            
            // 親のli要素は非表示にする
            if (yearInput && yearInput.closest('li')) yearInput.closest('li').style.display = 'none';
            if (monthInput && monthInput.closest('li')) monthInput.closest('li').style.display = 'none';
            if (dayInput && dayInput.closest('li')) dayInput.closest('li').style.display = 'none';

        let minDateObj = null;
        if (config.minDays !== null && !isNaN(parseInt(config.minDays))) {
            minDateObj = new Date();
            minDateObj.setDate(minDateObj.getDate() + parseInt(config.minDays));
            minDateObj.setHours(0, 0, 0, 0);
        }

        let maxDateObj = null;
        if (config.maxDays !== null && !isNaN(parseInt(config.maxDays))) {
            maxDateObj = new Date();
            maxDateObj.setDate(maxDateObj.getDate() + parseInt(config.maxDays));
            maxDateObj.setHours(23, 59, 59, 999);
        }

        function updateDisplay() {
            if (yearInput.value && monthInput.value && dayInput.value) {
                // 日付の妥当性チェック
                const y = parseInt(yearInput.value, 10);
                const m = parseInt(monthInput.value, 10) - 1; // JavaScriptの月は0-11
                const d = parseInt(dayInput.value, 10);
                
                // 正しい日付オブジェクトを作成
                const validDate = new Date(y, m, d);
                
                // 日付が正しく設定されたか確認
                if (validDate.getFullYear() !== y || validDate.getMonth() !== m || validDate.getDate() !== d) {
                    // 無効な日付の場合は修正(例:2025/6/31 → 2025/7/1)
                    yearInput.value = validDate.getFullYear();
                    monthInput.value = String(validDate.getMonth() + 1).padStart(2, '0');
                    dayInput.value = String(validDate.getDate()).padStart(2, '0');
                }
                
                displayInput.value = `${yearInput.value}/${String(monthInput.value).padStart(2, '0')}/${String(dayInput.value).padStart(2, '0')}`;
            } else {
                displayInput.value = '';
            }
        }

        function renderCalendar(year, month) { // 月は0-11(0=1月、11=12月)
            calendarContainer.innerHTML = '';

            const header = document.createElement('div');
            header.className = 'calendar-header';
            
            const prevButton = document.createElement('button');
            prevButton.type = 'button';
            prevButton.innerHTML = '&lt;';
            prevButton.onclick = (e) => { e.stopPropagation(); renderCalendar(month === 0 ? year - 1 : year, month === 0 ? 11 : month - 1); };

            const monthYearLabel = document.createElement('span');
            monthYearLabel.textContent = `${year}年 ${month + 1}月`;

            const nextButton = document.createElement('button');
            nextButton.type = 'button';
            nextButton.innerHTML = '&gt;';
            nextButton.onclick = (e) => { e.stopPropagation(); renderCalendar(month === 11 ? year + 1 : year, month === 11 ? 0 : month + 1); };

            header.appendChild(prevButton);
            header.appendChild(monthYearLabel);
            header.appendChild(nextButton);
            calendarContainer.appendChild(header);

            const grid = document.createElement('table');
            grid.className = 'calendar-grid';
            const thead = grid.createTHead();
            const trHead = thead.insertRow();
            ['日', '月', '火', '水', '木', '金', '土'].forEach(day => {
                const th = document.createElement('th');
                th.textContent = day;
                trHead.appendChild(th);
            });

            const tbody = grid.createTBody();
            const firstDayOfMonth = new Date(year, month, 1).getDay();
            const daysInMonth = new Date(year, month + 1, 0).getDate();
            const today = new Date();
            today.setHours(0,0,0,0);

            let dateCounter = 1;
            for (let i = 0; i < 6; i++) {
                const tr = tbody.insertRow();
                for (let j = 0; j < 7; j++) {
                    const td = tr.insertCell();
                    if (i === 0 && j < firstDayOfMonth || dateCounter > daysInMonth) {
                        // 空のセル
                    } else {
                        const currentDate = new Date(year, month, dateCounter);
                        const currentDateStr = `${currentDate.getFullYear()}/${String(currentDate.getMonth() + 1).padStart(2, '0')}/${String(currentDate.getDate()).padStart(2, '0')}`;
                        const dayOfWeek = currentDate.getDay();
                        td.textContent = dateCounter;
                        
                        let isDisabled = false;
                        if ((minDateObj && currentDate < minDateObj) || (maxDateObj && currentDate > maxDateObj)) {
                            isDisabled = true;
                        }
                        if (parsedDisabledDates.includes(currentDateStr)) {
                            isDisabled = true;
                        }
                        if (parsedDisabledDaysOfWeek.includes(dayOfWeek)) {
                            isDisabled = true;
                        }

                        if (isDisabled) {
                            td.classList.add('day-disabled');
                        } else {
                            // クロージャの問題を解決するため、現在の日付を変数に保存
                            const currentDay = dateCounter;
                            td.setAttribute('data-day', currentDay); // データ属性に日付を保存
                            
                            td.onclick = (e) => {
                                e.stopPropagation();
                                // データ属性から日付を取得
                                const clickedDay = parseInt(td.getAttribute('data-day'), 10);
                                
                                // 正確な日付オブジェクトを作成
                                const selectedDate = new Date(year, month, clickedDay);
                                
                                // 元のinputに値を設定
                                yearInput.value = selectedDate.getFullYear();
                                monthInput.value = String(selectedDate.getMonth() + 1).padStart(2, '0'); // 月を2桁ゼロ埋め
                                dayInput.value = String(selectedDate.getDate()).padStart(2, '0'); // 日を2桁ゼロ埋め
                                
                                // デバッグ用(必要に応じてコメントアウト)
                                // console.log(`選択された日付: ${selectedDate.getFullYear()}/${selectedDate.getMonth() + 1}/${selectedDate.getDate()}`);
                                // console.log(`クリックされた日: ${clickedDay}, 月: ${month + 1}, 年: ${year}`);
                                
                                updateDisplay();
                                calendarContainer.style.display = 'none';
                            };
                        }
                        
                        if (currentDate.getTime() === today.getTime() && !isDisabled) td.classList.add('day-today');
                        if (yearInput.value == year && monthInput.value == (month + 1) && dayInput.value == dateCounter && !isDisabled) {
                            td.classList.add('day-selected');
                        }
                        dateCounter++;
                    }
                }
                if (dateCounter > daysInMonth) break;
            }
            calendarContainer.appendChild(grid);
        }

        const toggleCalendar = (e) => {
            e.stopPropagation();
            const isVisible = calendarContainer.style.display === 'block';
            document.querySelectorAll('.calendar-container').forEach(c => c.style.display = 'none'); // Close other calendars
            if (!isVisible) {
                calendarContainer.style.display = 'block';
                let y = parseInt(yearInput.value, 10);
                let m = parseInt(monthInput.value, 10) - 1; // JavaScriptの月は0-11
                const initialDate = new Date();
                
                // カレンダーに最初に表示する日付を決定
                // 優先順位: 1. 既存の入力値、2. defaultDateの設定、3. 今日
                if (!isNaN(y) && !isNaN(m) && y > 0 && m >=0 && m <=11) {
                    // Use existing valid input values
                } else if (config.defaultDate) {
                    let effYear = initialDate.getFullYear();
                    let effMonth = initialDate.getMonth();
                    let effDay = initialDate.getDate();

                    if (typeof config.defaultDate.year === 'number' && config.defaultDate.year > 1000) { // 絶対日付
                        effYear = config.defaultDate.year;
                        effMonth = config.defaultDate.month - 1;
                        effDay = config.defaultDate.day;
                    } else { // 相対日付
                        if (config.defaultDate.year !== undefined) effYear += config.defaultDate.year;
                        if (config.defaultDate.month !== undefined) effMonth += config.defaultDate.month;
                        if (config.defaultDate.day !== undefined) effDay += config.defaultDate.day;
                    }
                    const tempDate = new Date(effYear, effMonth, effDay);
                    y = tempDate.getFullYear();
                    m = tempDate.getMonth();
                } else {
                     y = initialDate.getFullYear();
                     m = initialDate.getMonth();
                }
                renderCalendar(y, m);
            }
        };
        
        displayInput.onclick = toggleCalendar;
        
        // 入力が空の場合、初期表示の更新とデフォルト日付の適用
        if (!yearInput.value && !monthInput.value && !dayInput.value && config.defaultDate) {
            const d = new Date();
            let effYear = d.getFullYear();
            let effMonth = d.getMonth(); // 0-11
            let effDay = d.getDate();

            if (typeof config.defaultDate.year === 'number' && config.defaultDate.year > 1000) { // 絶対日付
                 effYear = config.defaultDate.year;
                 effMonth = config.defaultDate.month - 1; // ユーザー入力の月は1-12
                 effDay = config.defaultDate.day;
            } else { // 相対日付
                if (config.defaultDate.year !== undefined) effYear += config.defaultDate.year;
                if (config.defaultDate.month !== undefined) effMonth += config.defaultDate.month;
                if (config.defaultDate.day !== undefined) effDay += config.defaultDate.day;
            }
            const tempDate = new Date(effYear, effMonth, effDay);
            // 日付の妥当性を確保
            const validDate = new Date(tempDate.getFullYear(), tempDate.getMonth(), tempDate.getDate());
            yearInput.value = validDate.getFullYear();
            monthInput.value = String(validDate.getMonth() + 1).padStart(2, '0'); // 月を2桁ゼロ埋め
            dayInput.value = String(validDate.getDate()).padStart(2, '0'); // 日を2桁ゼロ埋め
        }
        updateDisplay(); // 初期値またはデフォルト値に基づいて表示を更新
    });

    document.addEventListener('click', (e) => {
        // カレンダーラッパー外のクリックでカレンダーを閉じる
        if (!e.target.closest('.calendar-wrapper')) {
            document.querySelectorAll('.calendar-container').forEach(c => c.style.display = 'none');
        }
    });
});
</script>
            

設定値に対する説明

このカレンダーピッカースクリプトは、ページ内の年月日入力欄
(具体的には <dd class="data time"> というクラスを持つ要素内の年月日入力フィールド)
を自動的に検出し、カレンダー機能を付与します。
スクリプト内の config オブジェクトを編集することで、動作を細かくカスタマイズできます。

主な設定項目 (config オブジェクト内):
設定項目 説明
minDays null または 数値
本日を「0」として、何日後から選択可能にするかを指定します。
例: 1 を設定すると明日から選択可能になります。null の場合は制限なしです。
maxDays null または 数値
本日を「0」として、何日後まで選択可能にするかを指定します。
例: 30 を設定すると30日後まで選択可能です。null の場合は制限なしです。
yearRange 数値
カレンダーの年選択ドロップダウンで、表示されている年から前後何年分を表示するかを指定します。
デフォルトは 10 です。
defaultDate null または オブジェクト
カレンダーの初期表示日や、入力欄が空の場合のデフォルト日付を指定します。
  • 絶対指定: { year: 2025, month: 1, day: 1 } (2025年1月1日)
  • 相対指定: { year: 0, month: 1, day: 0 } (来月の今日) や
    { year: 0, month: 0, day: -7 } (7日前) なども可能です。
  • null の場合: 入力欄に既存の値があればそれを使用し、なければ今日の日付を基準にします。
disabledDates 文字列
選択不可にする特定の日付をカンマ区切りで指定します。
例: "2024/12/25,2025/01/01"
書式は "YYYY/MM/DD" です。
disabledDaysOfWeek 文字列
選択不可にする曜日を数値でカンマ区切りで指定します。
(0:日曜日, 1:月曜日, ..., 6:土曜日)
例: "0,6" で土日を選択不可にします。
その他のカスタマイズ:
項目 説明
カレンダーの表示テキスト 曜日(「日」「月」など)や月のナビゲーションボタンのテキスト(「<」「>」)は、
JavaScriptコード内の renderCalendar 関数で直接定義されています。
これらの値を変更することで、表示言語の変更や文言の調整が可能です。
スタイルの調整 カレンダーの見た目(背景色、文字色、枠線、フォントサイズなど)は、
スクリプト冒頭の style.textContent = \`...\`; 部分でCSSとして定義されています。
これらのCSSルールを直接編集することで、デザインを細かく調整できます。

これらの設定値を調整することで、ウェブサイトの要件に合わせたカレンダーピッカーを実装できます。
変更を加える際は、元のコードをバックアップしておくことをお勧めします。

実行結果

上記コードを設置すると、指定した年月日入力欄がカレンダーピッカーに置き換わります。
入力欄をクリックするとカレンダーが表示され、
日付を選択すると「YYYY/MM/DD」形式で日付が入力されます。

まとめ

本記事では、JavaScriptを用いて既存のフォーム部品を、
モダンなカレンダーピッカーに置き換える方法を紹介しました。
この実装は、ユーザーの入力体験を向上させるだけでなく、
既存のシステムとの互換性を保ちながらUIを改善できるという利点があります。
ぜひ、ご自身のプロジェクトで活用してみてください。
解決しない場合はこちら コンテンツに関しての
要望はこちら