フォームの日付入力欄は、ユーザーにとって入力が煩雑になりがちな要素です。
特に、年月日が別々のテキストフィールドになっている場合、UI/UXの低下を招くことがあります。
本記事では、既存の年月日入力欄を、
直感的に操作できるカレンダーピッカーに置き換えるJavaScriptの実装方法を紹介します。
既存のHTML構造を維持しつつ、UIを大幅に改善することが可能です。
この実装で実現できること
・ 既存の年月日入力欄を、モダンなカレンダーピッカーUIに置き換え。
・ 元の入力フィールドは非表示(hidden)で保持し、フォーム送信時のデータ互換性を維持。
・ 既存SPIRALデフォルトCSSと干渉しないよう、JavaScriptでスタイルを動的に適用。
・ 日付の妥当性チェック機能により、無効な日付(例: 6月31日)の入力を防止。
・ 日付(○年○月○日 ○時○分○秒)
・ 日付(○年○月○日 ○時○分)
・ 日付(○年○月○日 ○時)
・ 日付(○年○月○日)フィールドに対応しています。
実装の概要
このスクリプトは、指定されたIDを持つ年月日入力フィールド(li要素)をターゲットにし、
以下の処理を自動で行います。
- 元の年月日入力欄を非表示にし、代わりに日付表示用の新しい入力欄を設置します。
- 新しい入力欄をクリックすると、カレンダーがその場に表示されます。
- ユーザーがカレンダー上の日付を選択すると、表示用および元の非表示フィールドに日付が自動的に設定されます。
- カレンダーのスタイルは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 = '<'; 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 = '>'; 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 または オブジェクトカレンダーの初期表示日や、入力欄が空の場合のデフォルト日付を指定します。
|
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」形式で日付が入力されます。
まとめ
モダンなカレンダーピッカーに置き換える方法を紹介しました。
この実装は、ユーザーの入力体験を向上させるだけでなく、
既存のシステムとの互換性を保ちながらUIを改善できるという利点があります。
ぜひ、ご自身のプロジェクトで活用してみてください。