フォームの日付入力欄は、ユーザーにとって入力が煩雑になりがちな要素です。
特に、年月日が別々のテキストフィールドになっている場合、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を改善できるという利点があります。
ぜひ、ご自身のプロジェクトで活用してみてください。