カレンダー予定管理画面(月表示+予定追加)

応用例 上級

この画面パターンについて

月表示カレンダーに予定の表示・追加・削除を組み合わせた、スケジュール管理画面のパターンです。ライブラリを使わず、Date オブジェクトから月のグリッド(月初の曜日合わせ・前後月の埋め草・今日のハイライト)を動的生成する方法を学べます。日付とデータの紐付けを 'YYYY-MM-DD' の文字列キーに統一する設計が実装の核心です。

こんな場面で使えます

  • 予約・スケジュール管理 — 空き状況と予定を月単位で見せる
  • シフト・当番の確認 — 誰がいつ担当かをカレンダーで共有する
  • イベント・締切カレンダー — 社内行事や提出期限を一覧する

この画面で使っているUIコンポーネント

#パーツこの画面での役割
1月表示カレンダーグリッドDate から6週×7日を動的生成
2月ナビゲーション前月・次月・「今日」ボタン
3予定チップカテゴリ色分け・4件以上は「+n件」
4今日のハイライト今日の日付を青丸で強調
5日付選択 → 予定リストセルクリックでその日の一覧を表示
6予定追加モーダルタイトル・時刻・カテゴリの入力
7予定削除(確認ダイアログ)対象名を明示して削除
8トースト通知追加・削除のフィードバック

実装のポイント・注意点

月グリッドは「月初の曜日 new Date(y, m, 1).getDay()」と「月末日 new Date(y, m + 1, 0).getDate()」の2つの定番イディオムから、6週×7日=42セル固定で生成します(週数を可変にすると月によって画面の高さが変わるため)。最大の落とし穴は日付比較で、Date オブジェクト同士の比較や toISOString() はタイムゾーンのずれで日付が1日ずれることがあります。予定と日付の紐付けは最初から 'YYYY-MM-DD' の文字列キーに統一し、Date は計算のときだけ使うのが安全です。42セルのクリックは1つずつ listener を張らず、tbody へのイベント委譲で処理します。

8個のUIコンポーネントをHTML・CSS・バニラJavaScriptのみで組み合わせており、 フレームワーク不要で画面ごとコピペして使えます。

動作サンプル

カレンダー予定管理画面(月表示+予定追加)のデモ画面 動作サンプルを別ウィンドウで確認 ↗

試してみる:

  • 前月・次月に移動して、月初の曜日合わせと前後月の薄色表示を確認
  • 日付をクリックして予定を追加し、セルにチップが増えることを確認
  • 同じ日に4件以上の予定を入れて、「+n件」の省略表示を確認

そのほかの操作も自由に試してみてください。

サンプルソース

3つのファイルを同じフォルダに保存し、ブラウザで index.html を開くと動作確認できます。
ファイル名:index.html / style.css / script.js
保存時の文字コードは UTF-8 を指定してください。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>カレンダー予定管理画面(月表示+予定追加)デモ</title>
  <link rel="stylesheet" href="./style.css">
</head>
<body>

<div class="calendar-screen">

  <!-- ===== 月ナビゲーション ===== -->
  <div class="calendar-nav">
    <button type="button" class="btn-secondary" id="prevBtn">◀ 前月</button>
    <h1 class="calendar-title" id="calendarTitle">2026年6月</h1>
    <button type="button" class="btn-secondary" id="nextBtn">次月 ▶</button>
    <button type="button" class="btn-today" id="todayBtn">今日</button>
  </div>

  <p class="demo-note">このデモは保存されません。リロードすると初期状態に戻ります。</p>

  <!-- ===== カレンダーグリッド ===== -->
  <div class="calendar-wrapper">
    <table class="calendar-grid">
      <thead>
        <tr>
          <th class="col-sun">日</th>
          <th>月</th>
          <th>火</th>
          <th>水</th>
          <th>木</th>
          <th>金</th>
          <th class="col-sat">土</th>
        </tr>
      </thead>
      <tbody id="calendarBody">
        <!--
          JSで生成:
          td.calendar-cell[data-date="YYYY-MM-DD"]
            .is-other-month / .is-today / .is-selected
            > .cell-date(日付数字)
            > .cell-events > .event-chip.chip-{category} / .more-count
        -->
      </tbody>
    </table>
  </div>

  <!-- ===== 選択日の予定パネル ===== -->
  <section class="day-panel">
    <div class="day-panel-header">
      <h2 class="day-panel-title" id="dayPanelTitle">日付を選択してください</h2>
      <button type="button" class="btn-primary" id="addEventBtn">+ 予定を追加</button>
    </div>
    <ul class="event-list" id="eventList"></ul>
    <p class="event-empty" id="eventEmpty">予定はありません</p>
  </section>

  <!-- ===== 予定追加モーダル ===== -->
  <div class="modal-overlay" id="addModal" hidden>
    <div class="modal-dialog" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
      <div class="modal-header">
        <h3 class="modal-title" id="modalTitle">予定を追加</h3>
        <button type="button" class="modal-close" id="modalClose" aria-label="閉じる">✕</button>
      </div>
      <div class="modal-body">
        <div class="form-field">
          <label class="form-label" for="eventTitle">タイトル <span class="required">必須</span></label>
          <input type="text" id="eventTitle" class="form-input" placeholder="例:定例ミーティング" maxlength="30">
          <p class="field-error" id="titleError" hidden>タイトルを入力してください</p>
        </div>
        <div class="form-field">
          <label class="form-label" for="eventTime">時刻</label>
          <input type="time" id="eventTime" class="form-input form-input-time">
        </div>
        <div class="form-field">
          <label class="form-label" for="eventCategory">カテゴリ</label>
          <select id="eventCategory" class="form-select">
            <option value="meeting">会議</option>
            <option value="deadline">締切</option>
            <option value="other">その他</option>
          </select>
        </div>
      </div>
      <div class="modal-footer">
        <button type="button" class="btn-secondary" id="modalCancel">キャンセル</button>
        <button type="button" class="btn-primary" id="modalSave">保存</button>
      </div>
    </div>
  </div>

  <!-- ===== 削除確認ダイアログ ===== -->
  <div class="modal-overlay" id="confirmDialog" hidden>
    <div class="modal-dialog confirm-dialog" role="dialog" aria-modal="true" aria-labelledby="confirmTitle">
      <div class="modal-header">
        <h3 class="modal-title" id="confirmTitle">削除の確認</h3>
      </div>
      <div class="modal-body">
        <p class="confirm-message" id="confirmMessage">この予定を削除しますか?</p>
      </div>
      <div class="modal-footer">
        <button type="button" class="btn-secondary" id="confirmCancel">キャンセル</button>
        <button type="button" class="btn-danger" id="confirmOk">削除する</button>
      </div>
    </div>
  </div>

  <!-- ===== トースト通知 ===== -->
  <div class="toast" id="toast" hidden></div>

</div><!-- /.calendar-screen -->

<script src="./script.js"></script>
</body>
</html>
/* ===== リセット・ベース ===== */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

:root {
  --color-primary: #2B7FE8;
  --color-primary-dark: #1A6DD6;
  --color-danger: #EF4444;
  --color-danger-dark: #DC2626;
  --color-secondary-bg: #F1F5F9;
  --color-secondary-border: #CBD5E1;
  --color-text: #1E293B;
  --color-text-muted: #64748B;
  --color-border: #E2E8F0;
  --color-today-bg: var(--color-primary);
  --color-selected-border: var(--color-primary);
  --color-other-month: #CBD5E1;
  --color-sun: #EF4444;
  --color-sat: #2B7FE8;
  --color-chip-meeting: #DBEAFE;
  --color-chip-meeting-text: #1D4ED8;
  --color-chip-deadline: #FEE2E2;
  --color-chip-deadline-text: #B91C1C;
  --color-chip-other: #F1F5F9;
  --color-chip-other-text: #475569;
  --radius-sm: 4px;
  --radius-md: 8px;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  font-size: 14px;
  color: var(--color-text);
  background: #F8FAFC;
  padding: 16px;
}

/* ===== 画面全体 ===== */
.calendar-screen {
  max-width: 960px;
  margin: 0 auto;
}

/* ===== 月ナビゲーション ===== */
.calendar-nav {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 12px;
}
.calendar-title {
  font-size: 18px;
  font-weight: 700;
  flex: 1;
  text-align: center;
}
.demo-note {
  font-size: 12px;
  color: var(--color-text-muted);
  margin-bottom: 12px;
}

/* ===== ボタン ===== */
.btn-primary {
  padding: 8px 16px;
  font-size: 13px;
  color: #fff;
  background: var(--color-primary);
  border: none;
  border-radius: var(--radius-sm);
  cursor: pointer;
  white-space: nowrap;
}
.btn-primary:hover { background: var(--color-primary-dark); }

.btn-secondary {
  padding: 8px 14px;
  font-size: 13px;
  color: var(--color-text);
  background: #fff;
  border: 1px solid var(--color-secondary-border);
  border-radius: var(--radius-sm);
  cursor: pointer;
  white-space: nowrap;
}
.btn-secondary:hover { background: var(--color-secondary-bg); }

.btn-today {
  padding: 8px 14px;
  font-size: 13px;
  color: var(--color-primary);
  background: #fff;
  border: 1px solid var(--color-primary);
  border-radius: var(--radius-sm);
  cursor: pointer;
}
.btn-today:hover { background: #EFF6FF; }

.btn-danger {
  padding: 8px 16px;
  font-size: 13px;
  color: #fff;
  background: var(--color-danger);
  border: none;
  border-radius: var(--radius-sm);
  cursor: pointer;
}
.btn-danger:hover { background: var(--color-danger-dark); }

/* ===== カレンダーグリッド ===== */
.calendar-wrapper {
  overflow-x: auto;
}
.calendar-grid {
  width: 100%;
  table-layout: fixed;
  border-collapse: collapse;
  background: #fff;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  overflow: hidden;
}
.calendar-grid th {
  padding: 8px 4px;
  text-align: center;
  font-size: 12px;
  font-weight: 600;
  background: var(--color-secondary-bg);
  border-bottom: 1px solid var(--color-border);
}
.col-sun { color: var(--color-sun); }
.col-sat { color: var(--color-sat); }

/* ===== カレンダーセル ===== */
.calendar-cell {
  height: 96px;
  vertical-align: top;
  padding: 4px;
  border: 1px solid var(--color-border);
  cursor: pointer;
  position: relative;
  transition: background 0.1s;
}
.calendar-cell:hover { background: #F8FAFC; }

/* 前後月セル */
.calendar-cell.is-other-month { background: #FAFAFA; }
.calendar-cell.is-other-month .cell-date { color: var(--color-other-month); }
.calendar-cell.is-other-month .event-chip { opacity: 0.45; }

/* 選択セル */
.calendar-cell.is-selected { outline: 2px solid var(--color-selected-border); outline-offset: -2px; }

/* 日付数字 */
.cell-date {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 24px;
  height: 24px;
  font-size: 12px;
  font-weight: 600;
  border-radius: 50%;
  margin-bottom: 2px;
}
/* 今日の青丸 */
.is-today .cell-date {
  background: var(--color-today-bg);
  color: #fff;
}
/* 曜日カラー(前後月を除く) */
.col-0:not(.is-other-month) .cell-date { color: var(--color-sun); }
.col-6:not(.is-other-month) .cell-date { color: var(--color-sat); }
/* 今日は色固定(白)*/
.is-today.col-0 .cell-date,
.is-today.col-6 .cell-date { color: #fff; }

/* ===== 予定チップ ===== */
.cell-events { display: flex; flex-direction: column; gap: 2px; }
.event-chip {
  font-size: 11px;
  padding: 1px 4px;
  border-radius: 3px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  line-height: 1.6;
}
.chip-meeting  { background: var(--color-chip-meeting);  color: var(--color-chip-meeting-text); }
.chip-deadline { background: var(--color-chip-deadline); color: var(--color-chip-deadline-text); }
.chip-other    { background: var(--color-chip-other);    color: var(--color-chip-other-text); }

.more-count {
  font-size: 11px;
  color: var(--color-text-muted);
  padding: 1px 4px;
}

/* ===== 予定パネル ===== */
.day-panel {
  margin-top: 16px;
  background: #fff;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  padding: 16px;
}
.day-panel-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 12px;
  gap: 8px;
}
.day-panel-title {
  font-size: 15px;
  font-weight: 600;
}

/* ===== 予定リスト ===== */
.event-list { list-style: none; display: flex; flex-direction: column; gap: 8px; }
.event-item {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 12px;
  background: var(--color-secondary-bg);
  border-radius: var(--radius-sm);
}
.event-time { font-size: 12px; color: var(--color-text-muted); min-width: 40px; }
.event-badge {
  font-size: 11px;
  padding: 1px 6px;
  border-radius: 3px;
  flex-shrink: 0;
}
.event-badge.chip-meeting  { background: var(--color-chip-meeting);  color: var(--color-chip-meeting-text); }
.event-badge.chip-deadline { background: var(--color-chip-deadline); color: var(--color-chip-deadline-text); }
.event-badge.chip-other    { background: var(--color-chip-other);    color: var(--color-chip-other-text); }

.event-title { flex: 1; font-size: 13px; }
.event-delete-btn {
  padding: 4px 10px;
  font-size: 12px;
  color: var(--color-danger);
  background: #fff;
  border: 1px solid var(--color-danger);
  border-radius: var(--radius-sm);
  cursor: pointer;
  flex-shrink: 0;
}
.event-delete-btn:hover { background: #FEF2F2; }

.event-empty { font-size: 13px; color: var(--color-text-muted); padding: 8px 0; }

/* ===== モーダル ===== */
.modal-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.4);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 100;
  padding: 16px;
}
.modal-overlay[hidden] { display: none; }

.modal-dialog {
  background: #fff;
  border-radius: var(--radius-md);
  width: 100%;
  max-width: 420px;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
}
.confirm-dialog { max-width: 360px; }

.modal-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 16px 20px;
  border-bottom: 1px solid var(--color-border);
}
.modal-title { font-size: 15px; font-weight: 600; }
.modal-close {
  background: none;
  border: none;
  font-size: 16px;
  color: var(--color-text-muted);
  cursor: pointer;
  padding: 4px;
}
.modal-close:hover { color: var(--color-text); }

.modal-body { padding: 20px; display: flex; flex-direction: column; gap: 16px; }
.modal-footer {
  display: flex;
  justify-content: flex-end;
  gap: 8px;
  padding: 16px 20px;
  border-top: 1px solid var(--color-border);
}

/* ===== フォームフィールド ===== */
.form-field { display: flex; flex-direction: column; gap: 4px; }
.form-label { font-size: 13px; font-weight: 600; }
.required { font-size: 11px; color: var(--color-danger); margin-left: 4px; }
.form-input, .form-select {
  padding: 8px 12px;
  font-size: 13px;
  border: 1px solid var(--color-secondary-border);
  border-radius: var(--radius-sm);
  width: 100%;
}
.form-input:focus, .form-select:focus {
  outline: none;
  border-color: var(--color-primary);
  box-shadow: 0 0 0 3px rgba(43, 127, 232, 0.15);
}
.form-input-time { max-width: 160px; }
.field-error { font-size: 12px; color: var(--color-danger); }
.field-error[hidden] { display: none; }

/* ===== 確認ダイアログ ===== */
.confirm-message { font-size: 14px; }

/* ===== トースト ===== */
.toast {
  position: fixed;
  bottom: 24px;
  left: 50%;
  transform: translateX(-50%);
  background: #1E293B;
  color: #fff;
  padding: 10px 20px;
  border-radius: 20px;
  font-size: 13px;
  z-index: 200;
  white-space: nowrap;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.toast[hidden] { display: none; }

/* ===== レスポンシブ(600px以下) ===== */
@media (max-width: 600px) {
  .calendar-cell { height: 64px; }
  .event-chip { display: none; }
  .more-count { display: none; }
  .cell-has-events::after {
    content: '';
    display: block;
    width: 6px;
    height: 6px;
    border-radius: 50%;
    background: var(--color-primary);
    margin: 2px auto 0;
  }
  .calendar-nav { flex-wrap: wrap; }
  .calendar-title { width: 100%; order: -1; text-align: center; }
}
/* =====================================================
   カレンダー予定管理画面のスクリプト

   仕組み:表示状態は viewYear / viewMonth / selectedDate の
   3変数で管理。すべての操作はこれらを書き換えて
   renderCalendar() / renderDayPanel() を呼ぶだけ。

   日付はすべて 'YYYY-MM-DD' 文字列で統一。
   Date オブジェクトは計算時のみ使い、比較には使わない。
   セルのクリックはイベント委譲(tbody に1つ)で処理。
   ===================================================== */

// ===== 設定値 =====
var MAX_CHIPS        = 3;     // セルに表示する最大チップ数
var GRID_CELLS       = 42;    // 6週×7日の固定セル数
var TOAST_DURATION   = 2500;  // トースト表示時間(ミリ秒)
var MAX_TITLE_LENGTH = 30;    // タイトルの最大文字数

// ===== 状態変数 =====
var today = toDateKey(new Date());
var viewYear, viewMonth;   // 現在表示中の年・月
var selectedDate = today;  // 選択中の日付(YYYY-MM-DD)
var events = [];           // 全予定データ
var pendingDeleteId = null; // 削除確認待ちの予定ID
var nextId = 1;            // 予定IDの採番カウンタ
var toastTimer = null;     // トーストのタイマー

// ===== ユーティリティ =====

// Date → 'YYYY-MM-DD' 文字列(タイムゾーンずれを避けるため自前で組む)
function toDateKey(date) {
  var y = date.getFullYear();
  var m = String(date.getMonth() + 1).padStart(2, '0');
  var d = String(date.getDate()).padStart(2, '0');
  return y + '-' + m + '-' + d;
}

// 曜日名(表示用)
var DAY_LABELS = ['日', '月', '火', '水', '木', '金', '土'];

// 'YYYY-MM-DD' → 表示文字列「M月D日(曜)」
function formatDateLabel(dateKey) {
  var parts = dateKey.split('-');
  var date = new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]));
  return parts[1].replace(/^0/, '') + '月' + parts[2].replace(/^0/, '') + '日(' + DAY_LABELS[date.getDay()] + ')';
}

// カテゴリ → 日本語ラベル
var CATEGORY_LABELS = { meeting: '会議', deadline: '締切', other: 'その他' };

// ===== 初期データ生成 =====

function initEvents() {
  var todayDate = new Date();
  // dayOffset: 今日からの日数差でサンプル予定を生成(いつ開いても自然なデモ)
  var seeds = [
    { dayOffset: 0,  time: '10:00', title: '定例ミーティング', category: 'meeting' },
    { dayOffset: 0,  time: '15:00', title: 'レポート提出',     category: 'deadline' },
    { dayOffset: 1,  time: '13:00', title: '打ち合わせ',       category: 'meeting' },
    { dayOffset: 1,  time: '',      title: 'プレゼン資料確認', category: 'other' },
    { dayOffset: 3,  time: '',      title: '資料準備',         category: 'other' },
    { dayOffset: 3,  time: '10:30', title: '部門会議',         category: 'meeting' },
    { dayOffset: 5,  time: '17:00', title: '月次報告書提出',   category: 'deadline' },
    { dayOffset: 7,  time: '14:00', title: 'チームレビュー',   category: 'meeting' },
    { dayOffset: -1, time: '09:00', title: '仕様確認',         category: 'meeting' },
    { dayOffset: -2, time: '',      title: 'バックアップ確認', category: 'other' }
  ];
  seeds.forEach(function (seed) {
    var d = new Date(todayDate);
    d.setDate(d.getDate() + seed.dayOffset);
    events.push({
      id:       nextId++,
      date:     toDateKey(d),
      time:     seed.time,
      title:    seed.title,
      category: seed.category
    });
  });
}

// ===== カレンダー描画 =====

function renderCalendar() {
  // 月タイトル更新
  document.getElementById('calendarTitle').textContent = viewYear + '年' + (viewMonth + 1) + '月';

  var tbody = document.getElementById('calendarBody');
  tbody.innerHTML = '';

  // 月初の曜日(0=日〜6=土)
  var firstDow = new Date(viewYear, viewMonth, 1).getDay();
  // new Date(y, m+1, 0) → 月末日(0日目 = 前月末日というイディオム)
  var lastDay = new Date(viewYear, viewMonth + 1, 0).getDate();

  // グリッド開始日(月初から firstDow 日分戻った日)
  var startDate = new Date(viewYear, viewMonth, 1 - firstDow);

  for (var week = 0; week < 6; week++) {
    var tr = document.createElement('tr');
    for (var dow = 0; dow < 7; dow++) {
      var cellIndex = week * 7 + dow;
      var cellDate = new Date(startDate);
      cellDate.setDate(startDate.getDate() + cellIndex);
      var dateKey = toDateKey(cellDate);

      var td = createCell(cellDate, dateKey, dow);
      tr.appendChild(td);
    }
    tbody.appendChild(tr);
  }
}

// セル1つを生成して返す
function createCell(cellDate, dateKey, dow) {
  var td = document.createElement('td');
  td.className = 'calendar-cell col-' + dow;
  td.dataset.date = dateKey;

  var cellMonth = cellDate.getMonth();
  if (cellMonth !== viewMonth) td.classList.add('is-other-month');
  if (dateKey === today)        td.classList.add('is-today');
  if (dateKey === selectedDate) td.classList.add('is-selected');

  // 日付数字
  var dateSpan = document.createElement('span');
  dateSpan.className = 'cell-date';
  dateSpan.textContent = cellDate.getDate();
  td.appendChild(dateSpan);

  // 予定チップ
  var dayEvents = getEventsByDate(dateKey);
  if (dayEvents.length > 0) {
    td.classList.add('cell-has-events');
    var eventsDiv = document.createElement('div');
    eventsDiv.className = 'cell-events';

    var showCount = Math.min(dayEvents.length, MAX_CHIPS);
    for (var i = 0; i < showCount; i++) {
      var chip = createChip(dayEvents[i]);
      eventsDiv.appendChild(chip);
    }
    // 4件以上は「+n件」を表示
    if (dayEvents.length > MAX_CHIPS) {
      var more = document.createElement('span');
      more.className = 'more-count';
      more.textContent = '+' + (dayEvents.length - MAX_CHIPS) + '件';
      eventsDiv.appendChild(more);
    }
    td.appendChild(eventsDiv);
  }
  return td;
}

// 予定チップ要素を生成
function createChip(event) {
  var chip = document.createElement('span');
  chip.className = 'event-chip chip-' + event.category;
  chip.textContent = (event.time ? event.time + ' ' : '') + event.title;
  return chip;
}

// ===== 予定パネル描画 =====

function renderDayPanel() {
  var title = document.getElementById('dayPanelTitle');
  var list  = document.getElementById('eventList');
  var empty = document.getElementById('eventEmpty');

  list.innerHTML = '';

  if (!selectedDate) {
    title.textContent = '日付を選択してください';
    empty.hidden = false;
    return;
  }

  title.textContent = formatDateLabel(selectedDate) + 'の予定';

  var dayEvents = getEventsByDate(selectedDate);
  // 時刻昇順ソート(時刻なしは末尾)
  dayEvents.sort(function (a, b) {
    if (!a.time && !b.time) return 0;
    if (!a.time) return 1;
    if (!b.time) return -1;
    return a.time.localeCompare(b.time);
  });

  if (dayEvents.length === 0) {
    empty.hidden = false;
    return;
  }
  empty.hidden = true;

  dayEvents.forEach(function (ev) {
    var li = createEventItem(ev);
    list.appendChild(li);
  });
}

// 予定リストアイテムを生成(createElement + textContent で XSS 対策)
function createEventItem(ev) {
  var li = document.createElement('li');
  li.className = 'event-item';

  var timeSpan = document.createElement('span');
  timeSpan.className = 'event-time';
  timeSpan.textContent = ev.time || '—';

  var badge = document.createElement('span');
  badge.className = 'event-badge chip-' + ev.category;
  badge.textContent = CATEGORY_LABELS[ev.category] || ev.category;

  var titleSpan = document.createElement('span');
  titleSpan.className = 'event-title';
  titleSpan.textContent = ev.title;

  // 削除ボタン → クリックで確認ダイアログ
  var delBtn = document.createElement('button');
  delBtn.type = 'button';
  delBtn.className = 'event-delete-btn';
  delBtn.textContent = '削除';
  delBtn.dataset.id = ev.id;

  li.appendChild(timeSpan);
  li.appendChild(badge);
  li.appendChild(titleSpan);
  li.appendChild(delBtn);
  return li;
}

// ===== データ操作 =====

function getEventsByDate(dateKey) {
  return events.filter(function (ev) { return ev.date === dateKey; });
}

function addEvent(dateKey, time, title, category) {
  events.push({ id: nextId++, date: dateKey, time: time, title: title, category: category });
}

function deleteEvent(id) {
  events = events.filter(function (ev) { return ev.id !== id; });
}

// カレンダーとパネルを同時に再描画
function refresh() {
  renderCalendar();
  renderDayPanel();
}

// ===== トースト =====

function showToast(message) {
  var toast = document.getElementById('toast');
  toast.textContent = message;
  toast.hidden = false;
  if (toastTimer) clearTimeout(toastTimer);
  toastTimer = setTimeout(function () {
    toast.hidden = true;
  }, TOAST_DURATION);
}

// ===== モーダル制御 =====

function openAddModal() {
  if (!selectedDate) return;
  document.getElementById('eventTitle').value = '';
  document.getElementById('eventTime').value = '';
  document.getElementById('eventCategory').value = 'meeting';
  document.getElementById('titleError').hidden = true;
  document.getElementById('addModal').hidden = false;
  document.getElementById('eventTitle').focus();
}

function closeAddModal() {
  document.getElementById('addModal').hidden = true;
}

function openConfirmDialog(id, title) {
  pendingDeleteId = id;
  var msg = document.getElementById('confirmMessage');
  msg.textContent = '「' + title + '」を削除しますか?';
  document.getElementById('confirmDialog').hidden = false;
}

function closeConfirmDialog() {
  pendingDeleteId = null;
  document.getElementById('confirmDialog').hidden = true;
}

// ===== イベントリスナー =====

// 月ナビゲーション:前月
document.getElementById('prevBtn').addEventListener('click', function () {
  viewMonth--;
  if (viewMonth < 0) { viewMonth = 11; viewYear--; }
  renderCalendar();
  renderDayPanel();
});

// 月ナビゲーション:次月
document.getElementById('nextBtn').addEventListener('click', function () {
  viewMonth++;
  if (viewMonth > 11) { viewMonth = 0; viewYear++; }
  renderCalendar();
  renderDayPanel();
});

// 今日ボタン → 当月に戻り今日を選択
document.getElementById('todayBtn').addEventListener('click', function () {
  var now = new Date();
  viewYear = now.getFullYear();
  viewMonth = now.getMonth();
  selectedDate = today;
  refresh();
});

// セルクリック → イベント委譲でまとめて処理
document.getElementById('calendarBody').addEventListener('click', function (e) {
  var cell = e.target.closest('.calendar-cell');
  if (!cell) return;

  var dateKey = cell.dataset.date;
  if (!dateKey) return;

  // 前後月のセルをクリックしたらその月へ移動
  var parts = dateKey.split('-');
  var clickedMonth = Number(parts[1]) - 1;
  var clickedYear  = Number(parts[0]);
  if (clickedMonth !== viewMonth || clickedYear !== viewYear) {
    viewYear  = clickedYear;
    viewMonth = clickedMonth;
  }

  selectedDate = dateKey;
  refresh();
});

// 「+ 予定を追加」ボタン
document.getElementById('addEventBtn').addEventListener('click', openAddModal);

// モーダル保存
document.getElementById('modalSave').addEventListener('click', function () {
  var titleInput = document.getElementById('eventTitle');
  var title = titleInput.value.trim();
  var titleError = document.getElementById('titleError');

  if (!title) {
    titleError.hidden = false;
    titleInput.focus();
    return;
  }
  titleError.hidden = true;

  var time     = document.getElementById('eventTime').value;
  var category = document.getElementById('eventCategory').value;

  addEvent(selectedDate, time, title, category);
  closeAddModal();
  refresh();
  showToast('予定を追加しました');
});

// モーダルキャンセル・閉じるボタン
document.getElementById('modalCancel').addEventListener('click', closeAddModal);
document.getElementById('modalClose').addEventListener('click', closeAddModal);

// 削除ボタン → イベント委譲でリストから処理
document.getElementById('eventList').addEventListener('click', function (e) {
  var btn = e.target.closest('.event-delete-btn');
  if (!btn) return;
  var id = Number(btn.dataset.id);
  var ev = events.find(function (ev) { return ev.id === id; });
  if (ev) openConfirmDialog(id, ev.title);
});

// 削除確認OK
document.getElementById('confirmOk').addEventListener('click', function () {
  if (pendingDeleteId !== null) {
    deleteEvent(pendingDeleteId);
    closeConfirmDialog();
    refresh();
    showToast('予定を削除しました');
  }
});

// 削除確認キャンセル
document.getElementById('confirmCancel').addEventListener('click', closeConfirmDialog);

// モーダルオーバーレイ外クリックで閉じる
document.getElementById('addModal').addEventListener('click', function (e) {
  if (e.target === this) closeAddModal();
});
document.getElementById('confirmDialog').addEventListener('click', function (e) {
  if (e.target === this) closeConfirmDialog();
});

// ===== 初期化 =====
(function init() {
  var now = new Date();
  viewYear  = now.getFullYear();
  viewMonth = now.getMonth();
  initEvents();
  refresh();
})();

AI用プロンプト

以下のプロンプトをコピーしてAIに渡すと、同様のコンポーネントを生成できます。

ChatGPTやClaudeにこのプロンプトを渡すと、同様のコンポーネントをゼロから生成・カスタマイズできます。ライブラリ指定や列数変更など、要件を追記して使うのがおすすめです。

※ このプロンプトを使ってもデモとまったく同じ動作にならない場合があります。AIの解釈や生成タイミングによって差が出ることをご了承ください。

💡 jQuery・Vue・React など特定のライブラリで実装したい場合は、プロンプトの末尾に「〇〇を使って実装してください」と追記してください。

# カレンダー予定管理画面 作成依頼

## 概要
月表示カレンダーに予定を表示し、日付を選んで予定の追加・削除ができる
スケジュール管理画面を、ライブラリを使わずに作成してください。

## 要件
- 月表示カレンダー(日曜始まり・6週×7日のグリッド)をDateオブジェクトから動的生成する。
  前後月の日付も薄い色で表示する
- 「◀ 前月」「次月 ▶」で月を移動し、「今日」ボタンで当月に戻る。年月表示も連動する
- 今日のセルは日付数字を青い丸でハイライトする。日曜の日付は赤、土曜は青にする
- 予定は日付セル内にカテゴリ色のチップ(会議=青/締切=赤/その他=グレー)で時刻順に表示する。
  1日4件以上は3件+「+n件」に省略する
- 日付セルをクリックすると選択状態(青枠)になり、カレンダー下部のパネルに
  その日の予定一覧(時刻・カテゴリバッジ・タイトル・削除ボタン)を表示する。
  予定がない日は「予定はありません」と表示する
- パネルの「+ 予定を追加」でモーダル(タイトル必須30文字以内・時刻 input[type="time"] 任意・
  カテゴリセレクト)を開き、保存で選択日に予定を追加してトーストを表示する
- 予定の「削除」はタイトル名入りの確認ダイアログを経て削除し、トーストを表示する
- 初期表示は当月で、今日が選択された状態にする。サンプル予定10件程度は
  「今日を基準にした相対日付」で生成し、いつ開いても自然に見えるようにする

## 技術仕様
- HTML / CSS / バニラJavaScript で実装
- 外部ライブラリ:なし
- 予定データはJavaScript内の配列で保持する(リロードで初期状態に戻る)
- レスポンシブ対応:必要(600px以下ではセルを低くし、予定はドット表示に省略してパネルで確認)

## 動作詳細
- 月初の曜日と月末日をDateから計算し、グリッド開始日から42日分のセルを生成する
- 日付は 'YYYY-MM-DD' 形式の文字列キーに統一し、予定の紐付け・今日判定・選択判定を
  文字列比較で行う
- セルのクリックはイベント委譲で処理する
- チップ・予定行の生成は createElement と textContent を使い、innerHTML に変数を結合しない

## 出力形式
HTML・CSS・JavaScriptを分けて出力してください。
各ファイルは単独でコピー&ペーストして使えるよう記述してください。