カンバンボード(D&Dでタスク移動)

応用例 上級

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

タスク管理ツールの定番UI「カンバンボード」を、ライブラリなしのHTML5 Drag and Drop APIだけで実装する画面パターンです。未着手・進行中・完了の3列の間でカードをドラッグ移動でき、件数バッジも連動して更新されます。dragstart / dragover / drop の基本イベントと視覚フィードバックの作り方を学ぶ実践教材です。

こんな場面で使えます

  • タスク・進捗管理 — チームの作業状況を列で見える化する
  • 申請・問い合わせのステータス管理 — 受付→対応中→完了を動かして管理する
  • 案件のフェーズ管理 — 商談や採用の段階をカードで追う

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

#パーツこの画面での役割
13カラムレイアウト未着手/進行中/完了の状態を列で表現
2タスクカードタイトル・ラベル・担当者イニシャル
3HTML5 Drag and Dropカードの列間移動
4ドラッグ中の視覚フィードバック半透明カード+ドロップ先ハイライト
5件数バッジ列ヘッダーの「未着手 (3)」表示
6カード追加(インライン入力)列ごとの「+追加」→その場で入力
7カード削除(確認つき)ホバーで×ボタン→確認ダイアログ
8空列プレースホルダー0枚でもドロップ先として機能する点線枠

実装のポイント・注意点

D&Dの肝は2つです。1つは dragovere.preventDefault() を呼ばない限り drop が発火しないというAPIの仕様で、これを知らないと「ドロップが効かない」で詰まります。もう1つはイベントの張り方で、カード1枚ずつに listener を付けるとカードの追加・削除のたびに付け直しが必要になるため、ボード全体へのイベント委譲e.target.closest() から対象を特定します。移動データは dataTransfer にカードIDだけを載せ、ドロップ側でデータ配列を書き換えてから再描画します。なお HTML5 D&D APIはタッチ操作(スマホ・タブレット)では動作しません。モバイル対応が必要な実案件ではSortableJS等の利用を検討してください。

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

動作サンプル

カンバンボード(D&Dでタスク移動)のデモ画面 動作サンプルを別ウィンドウで確認 ↗

試してみる:

  • カードをドラッグして、半透明表示とドロップ先列のハイライトを確認
  • カードを移動して、両方の列の件数バッジが連動することを確認
  • 1つの列を空にして、「カードをここにドロップ」の枠にもドロップできることを確認

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

サンプルソース

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="kanban-screen">
  <!-- ===== 画面タイトル ===== -->
  <h1 class="screen-title">タスクボード</h1>
  <p class="demo-note">このデモは保存されません。リロードすると初期状態に戻ります。※PCブラウザ向け(タッチ操作は非対応)</p>

  <!-- ===== カンバンボード ===== -->
  <div class="kanban-board" id="kanbanBoard">
    <!-- 列はJSで生成(COLUMNS 定義から) -->
  </div>

  <!-- ===== 削除確認ダイアログ ===== -->
  <div class="modal-overlay" id="confirmOverlay" hidden>
    <div class="modal modal-confirm" role="alertdialog" aria-modal="true" aria-labelledby="confirmMessage">
      <p class="confirm-message" id="confirmMessage"></p>
      <div class="modal-actions">
        <button type="button" class="btn-secondary" id="confirmCancelBtn">キャンセル</button>
        <button type="button" class="btn-danger" id="confirmOkBtn">削除する</button>
      </div>
    </div>
  </div>
</div>

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

:root {
  --color-bg: #F0F2F5;
  --color-column-bg: #F4F6F9;
  --color-card-bg: #fff;
  --color-border: #D0D7E0;
  --color-text: #1A202C;
  --color-text-sub: #6B7280;
  --color-primary: #2B7FE8;
  --color-danger: #EF4444;
  --color-drop-highlight: #E8F1FD;
  --color-drop-border: #2B7FE8;
  --label-work: #2B7FE8;
  --label-work-bg: #EBF4FF;
  --label-review: #F59E0B;
  --label-review-bg: #FEF3C7;
  --label-fix: #EF4444;
  --label-fix-bg: #FEE2E2;
  --radius: 8px;
  --shadow-card: 0 1px 4px rgba(0,0,0,0.08);
}

body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  background: var(--color-bg);
  color: var(--color-text);
  min-height: 100vh;
}

/* ===== 画面全体 ===== */
.kanban-screen {
  max-width: 1100px;
  margin: 0 auto;
  padding: 24px 16px 40px;
}

.screen-title {
  font-size: 22px;
  font-weight: 700;
  margin-bottom: 6px;
}

.demo-note {
  font-size: 12px;
  color: var(--color-text-sub);
  margin-bottom: 20px;
}

/* ===== カンバンボード ===== */
.kanban-board {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 16px;
  align-items: start;
}

/* ===== 列 ===== */
.kanban-column {
  background: var(--color-column-bg);
  border-radius: var(--radius);
  display: flex;
  flex-direction: column;
}

/* ===== 列ヘッダー ===== */
.column-header {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 12px 14px 10px;
  border-bottom: 1px solid var(--color-border);
}

.column-title {
  font-size: 14px;
  font-weight: 600;
}

.column-count {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 20px;
  height: 20px;
  padding: 0 6px;
  background: #E2E8F0;
  border-radius: 10px;
  font-size: 12px;
  font-weight: 600;
  color: var(--color-text-sub);
}

/* ===== 列ボディ(ドロップターゲット) ===== */
.column-body {
  min-height: 120px;
  padding: 10px 10px 4px;
  display: flex;
  flex-direction: column;
  gap: 8px;
  transition: background 0.1s;
}

.column-body.is-over {
  background: var(--color-drop-highlight);
  outline: 2px dashed var(--color-drop-border);
  outline-offset: -2px;
  border-radius: 0 0 var(--radius) var(--radius);
}

/* ===== 空列プレースホルダー ===== */
.column-empty {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 72px;
  border: 2px dashed var(--color-border);
  border-radius: 6px;
  color: var(--color-text-sub);
  font-size: 12px;
  text-align: center;
  padding: 12px;
  pointer-events: none;
}

/* ===== カード ===== */
.kanban-card {
  background: var(--color-card-bg);
  border-radius: 6px;
  padding: 10px 12px;
  box-shadow: var(--shadow-card);
  cursor: grab;
  position: relative;
  user-select: none;
  border: 1px solid transparent;
  transition: box-shadow 0.1s, opacity 0.1s;
}

.kanban-card:hover {
  border-color: var(--color-border);
  box-shadow: 0 2px 8px rgba(0,0,0,0.12);
}

.kanban-card:active { cursor: grabbing; }

.kanban-card.is-dragging {
  opacity: 0.4;
  cursor: grabbing;
}

/* ===== カードヘッダー行(ラベル+削除ボタン) ===== */
.card-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 6px;
}

/* ===== ラベルバッジ ===== */
.card-label {
  display: inline-block;
  padding: 2px 8px;
  border-radius: 10px;
  font-size: 11px;
  font-weight: 600;
}

.label-work   { background: var(--label-work-bg);   color: var(--label-work); }
.label-review { background: var(--label-review-bg); color: var(--label-review); }
.label-fix    { background: var(--label-fix-bg);    color: var(--label-fix); }

/* ===== 削除ボタン ===== */
.card-delete-btn {
  width: 20px;
  height: 20px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: none;
  border: none;
  border-radius: 4px;
  font-size: 14px;
  color: var(--color-text-sub);
  cursor: pointer;
  opacity: 0;
  transition: opacity 0.15s, background 0.15s;
  flex-shrink: 0;
}

.kanban-card:hover .card-delete-btn { opacity: 1; }
.card-delete-btn:hover { background: #FEE2E2; color: var(--color-danger); }

/* ===== カードタイトル ===== */
.card-title {
  font-size: 13px;
  font-weight: 500;
  line-height: 1.4;
  margin-bottom: 8px;
  word-break: break-all;
}

/* ===== 担当者アイコン ===== */
.card-assignee {
  width: 28px;
  height: 28px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 12px;
  font-weight: 700;
  color: #fff;
}

.assignee-unassigned {
  background: #CBD5E0;
  font-size: 10px;
  color: var(--color-text-sub);
}

/* 担当者カラーパレット */
.assignee-A { background: #4F46E5; }
.assignee-B { background: #059669; }
.assignee-C { background: #D97706; }
.assignee-D { background: #7C3AED; }
.assignee-E { background: #DC2626; }

/* ===== 列フッター ===== */
.column-footer {
  padding: 8px 10px 10px;
}

/* ===== カード追加ボタン ===== */
.add-card-btn {
  width: 100%;
  padding: 7px 12px;
  background: none;
  border: none;
  border-radius: 6px;
  font-size: 13px;
  color: var(--color-text-sub);
  cursor: pointer;
  text-align: left;
  transition: background 0.1s, color 0.1s;
}

.add-card-btn:hover {
  background: #E2E8F0;
  color: var(--color-text);
}

/* ===== カード追加フォーム ===== */
.add-card-form {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.add-card-form[hidden] { display: none; }

.add-card-input {
  width: 100%;
  padding: 7px 10px;
  font-size: 13px;
  border: 1.5px solid var(--color-border);
  border-radius: 6px;
  outline: none;
  transition: border-color 0.1s;
}

.add-card-input:focus { border-color: var(--color-primary); }
.add-card-input.is-error { border-color: var(--color-danger); }

.add-card-select {
  width: 100%;
  padding: 6px 10px;
  font-size: 13px;
  border: 1.5px solid var(--color-border);
  border-radius: 6px;
  background: #fff;
  outline: none;
  cursor: pointer;
}

.add-card-actions {
  display: flex;
  gap: 6px;
}

.btn-add-confirm {
  flex: 1;
  padding: 7px;
  font-size: 13px;
  background: var(--color-primary);
  color: #fff;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  transition: background 0.1s;
}

.btn-add-confirm:hover { background: #1a6ed4; }

.btn-add-cancel {
  padding: 7px 12px;
  font-size: 13px;
  background: none;
  border: 1.5px solid var(--color-border);
  border-radius: 6px;
  cursor: pointer;
  color: var(--color-text-sub);
  transition: background 0.1s;
}

.btn-add-cancel:hover { background: #E2E8F0; }

/* ===== 削除確認モーダル ===== */
.modal-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0,0,0,0.4);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 100;
}

.modal-overlay[hidden] { display: none; }

.modal-confirm {
  background: #fff;
  border-radius: var(--radius);
  padding: 24px 28px;
  max-width: 340px;
  width: 90%;
  box-shadow: 0 8px 32px rgba(0,0,0,0.16);
}

.confirm-message {
  font-size: 14px;
  line-height: 1.6;
  margin-bottom: 20px;
  text-align: center;
}

.modal-actions {
  display: flex;
  gap: 10px;
  justify-content: center;
}

.btn-secondary {
  padding: 9px 18px;
  font-size: 13px;
  background: #fff;
  border: 1.5px solid var(--color-border);
  border-radius: 6px;
  cursor: pointer;
  transition: background 0.1s;
}

.btn-secondary:hover { background: #F4F6F9; }

.btn-danger {
  padding: 9px 18px;
  font-size: 13px;
  background: var(--color-danger);
  color: #fff;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  transition: background 0.1s;
}

.btn-danger:hover { background: #dc2222; }

/* ===== レスポンシブ ===== */
@media (max-width: 768px) {
  .kanban-board {
    grid-template-columns: 1fr;
  }
}
/* =====================================================
   カンバンボードのスクリプト

   仕組み:cards 配列が唯一の情報源。
   移動・追加・削除はすべて cards を書き換えてから
   render() を呼ぶだけ。render() が毎回全列を再構築する。

   D&D はボード全体へのイベント委譲で実装。
   カードの再描画ごとにリスナーを付け直す必要がない。

   カード生成は createElement + textContent(XSS対策)。
   ===================================================== */

// ===== 設定値 =====
var NEXT_ID       = 9;       // 次に採番するカードID(追加のたびに増やす)
var ADDING_COLUMN = null;    // 現在入力フォームを開いている列ID(null = 閉じている)

// ===== 列定義 =====
var COLUMNS = [
  { id: 'todo',  label: '未着手' },
  { id: 'doing', label: '進行中' },
  { id: 'done',  label: '完了'   }
];

// ===== ラベル定義 =====
var LABELS = {
  work:   { text: '作業',   cls: 'label-work'   },
  review: { text: '確認',   cls: 'label-review' },
  fix:    { text: '修正',   cls: 'label-fix'    }
};

// ===== 初期データ =====
var cards = [
  { id: 1, column: 'todo',  title: 'サンプルタスクA', label: 'work',   assignee: 'A' },
  { id: 2, column: 'todo',  title: 'サンプルタスクB', label: 'fix',    assignee: 'B' },
  { id: 3, column: 'todo',  title: 'サンプルタスクC', label: 'review', assignee: 'C' },
  { id: 4, column: 'doing', title: 'サンプルタスクD', label: 'review', assignee: 'C' },
  { id: 5, column: 'doing', title: 'サンプルタスクE', label: 'work',   assignee: 'A' },
  { id: 6, column: 'done',  title: 'サンプルタスクF', label: 'work',   assignee: 'D' },
  { id: 7, column: 'done',  title: 'サンプルタスクG', label: 'fix',    assignee: 'E' },
  { id: 8, column: 'done',  title: 'サンプルタスクH', label: 'review', assignee: 'B' }
];

// ===== D&D 状態 =====
var draggingId   = null;  // ドラッグ中のカードID
var dragEnterCounts = {}; // dragleave 誤発火対策カウンター(列IDをキー)

// ===== 描画 =====

// 全列を再描画する(cards 変更後は必ずこれを呼ぶ)
function render() {
  var board = document.getElementById('kanbanBoard');
  board.innerHTML = '';

  COLUMNS.forEach(function (col) {
    var colCards = cards.filter(function (c) { return c.column === col.id; });
    board.appendChild(buildColumn(col, colCards));
  });
}

// 1列分のDOM要素を組み立てる
function buildColumn(col, colCards) {
  var wrap = document.createElement('div');
  wrap.className = 'kanban-column';
  wrap.dataset.column = col.id;

  // ヘッダー
  var header = document.createElement('div');
  header.className = 'column-header';

  var title = document.createElement('span');
  title.className = 'column-title';
  title.textContent = col.label;

  var count = document.createElement('span');
  count.className = 'column-count';
  count.textContent = colCards.length;

  header.appendChild(title);
  header.appendChild(count);

  // ボディ(ドロップターゲット)
  var body = document.createElement('div');
  body.className = 'column-body';
  body.dataset.columnBody = col.id;

  if (colCards.length === 0) {
    body.appendChild(buildEmptyPlaceholder());
  } else {
    colCards.forEach(function (card) {
      body.appendChild(buildCard(card));
    });
  }

  // フッター(追加ボタン or 追加フォーム)
  var footer = document.createElement('div');
  footer.className = 'column-footer';

  if (ADDING_COLUMN === col.id) {
    footer.appendChild(buildAddForm(col.id));
  } else {
    footer.appendChild(buildAddButton(col.id));
  }

  wrap.appendChild(header);
  wrap.appendChild(body);
  wrap.appendChild(footer);
  return wrap;
}

// カードDOM要素を組み立てる(innerHTML禁止・textContentで設定)
function buildCard(card) {
  var el = document.createElement('div');
  el.className = 'kanban-card';
  el.draggable = true;
  el.dataset.id = card.id;

  // ヘッダー行(ラベル+削除ボタン)
  var cardHeader = document.createElement('div');
  cardHeader.className = 'card-header';

  var label = document.createElement('span');
  var labelDef = LABELS[card.label] || LABELS.work;
  label.className = 'card-label ' + labelDef.cls;
  label.textContent = labelDef.text;

  var delBtn = document.createElement('button');
  delBtn.type = 'button';
  delBtn.className = 'card-delete-btn';
  delBtn.dataset.deleteId = card.id;
  delBtn.setAttribute('aria-label', '削除');
  delBtn.textContent = '×';

  cardHeader.appendChild(label);
  cardHeader.appendChild(delBtn);

  // タイトル
  var titleEl = document.createElement('p');
  titleEl.className = 'card-title';
  titleEl.textContent = card.title;

  // 担当者アイコン
  var assignee = document.createElement('div');
  if (card.assignee) {
    assignee.className = 'card-assignee assignee-' + card.assignee;
    assignee.textContent = card.assignee;
  } else {
    assignee.className = 'card-assignee assignee-unassigned';
    assignee.textContent = '未';
  }

  el.appendChild(cardHeader);
  el.appendChild(titleEl);
  el.appendChild(assignee);
  return el;
}

// 空列プレースホルダー
function buildEmptyPlaceholder() {
  var el = document.createElement('div');
  el.className = 'column-empty';
  el.textContent = 'カードをここにドロップ';
  return el;
}

// 「+ カードを追加」ボタン
function buildAddButton(columnId) {
  var btn = document.createElement('button');
  btn.type = 'button';
  btn.className = 'add-card-btn';
  btn.dataset.addColumn = columnId;
  btn.textContent = '+ カードを追加';
  return btn;
}

// インライン追加フォーム
function buildAddForm(columnId) {
  var form = document.createElement('div');
  form.className = 'add-card-form';

  var input = document.createElement('input');
  input.type = 'text';
  input.className = 'add-card-input';
  input.placeholder = 'タイトルを入力…';
  input.dataset.addInput = columnId;
  input.maxLength = 60;

  var select = document.createElement('select');
  select.className = 'add-card-select';
  select.dataset.addSelect = columnId;
  Object.keys(LABELS).forEach(function (key) {
    var opt = document.createElement('option');
    opt.value = key;
    opt.textContent = LABELS[key].text;
    select.appendChild(opt);
  });

  var actions = document.createElement('div');
  actions.className = 'add-card-actions';

  var confirmBtn = document.createElement('button');
  confirmBtn.type = 'button';
  confirmBtn.className = 'btn-add-confirm';
  confirmBtn.dataset.addConfirm = columnId;
  confirmBtn.textContent = '追加';

  var cancelBtn = document.createElement('button');
  cancelBtn.type = 'button';
  cancelBtn.className = 'btn-add-cancel';
  cancelBtn.dataset.addCancel = columnId;
  cancelBtn.textContent = 'キャンセル';

  actions.appendChild(confirmBtn);
  actions.appendChild(cancelBtn);

  form.appendChild(input);
  form.appendChild(select);
  form.appendChild(actions);

  // 描画直後にフォーカスを当てる
  setTimeout(function () {
    var el = document.querySelector('[data-add-input="' + columnId + '"]');
    if (el) el.focus();
  }, 0);

  return form;
}

// ===== カード追加 =====

// 確定:入力値をバリデートしてカードを追加する
function confirmAddCard(columnId) {
  var inputEl  = document.querySelector('[data-add-input="' + columnId + '"]');
  var selectEl = document.querySelector('[data-add-select="' + columnId + '"]');
  if (!inputEl || !selectEl) return;

  var title = inputEl.value.trim();
  if (!title) {
    inputEl.classList.add('is-error');
    inputEl.focus();
    return;
  }

  cards.push({
    id:       NEXT_ID++,
    column:   columnId,
    title:    title,
    label:    selectEl.value,
    assignee: null
  });

  ADDING_COLUMN = null;
  render();
}

// ===== D&D イベント(イベント委譲) =====

var board = document.getElementById('kanbanBoard');

// dragstart:ドラッグ開始時にカードIDをセット
board.addEventListener('dragstart', function (e) {
  var card = e.target.closest('.kanban-card');
  if (!card) return;

  draggingId = Number(card.dataset.id);
  e.dataTransfer.setData('text/plain', draggingId);
  e.dataTransfer.effectAllowed = 'move';

  // 半透明はsetTimeoutで付ける(即時だとゴースト画像に反映されてしまう)
  setTimeout(function () { card.classList.add('is-dragging'); }, 0);
});

// dragover:ドロップ可能にする(preventDefault が必須)
board.addEventListener('dragover', function (e) {
  var body = e.target.closest('[data-column-body]');
  if (!body) return;
  e.preventDefault();
  e.dataTransfer.dropEffect = 'move';
  body.classList.add('is-over');
});

// dragenter:カウンターを増やす(子要素への移動で dragleave が誤発火するのを防ぐ)
board.addEventListener('dragenter', function (e) {
  var body = e.target.closest('[data-column-body]');
  if (!body) return;
  var colId = body.dataset.columnBody;
  dragEnterCounts[colId] = (dragEnterCounts[colId] || 0) + 1;
  body.classList.add('is-over');
});

// dragleave:カウンターが0になったときだけハイライトを外す
board.addEventListener('dragleave', function (e) {
  var body = e.target.closest('[data-column-body]');
  if (!body) return;
  var colId = body.dataset.columnBody;
  dragEnterCounts[colId] = (dragEnterCounts[colId] || 1) - 1;
  if (dragEnterCounts[colId] <= 0) {
    body.classList.remove('is-over');
    dragEnterCounts[colId] = 0;
  }
});

// drop:カードの所属列を書き換えて再描画
board.addEventListener('drop', function (e) {
  var body = e.target.closest('[data-column-body]');
  if (!body) return;
  e.preventDefault();

  var targetColumn = body.dataset.columnBody;
  var card = cards.find(function (c) { return c.id === draggingId; });
  if (card && card.column !== targetColumn) {
    card.column = targetColumn;
  }

  dragEnterCounts = {};
  draggingId = null;
  render();
});

// dragend:ドロップ失敗時(列外ドロップ)のクリーンアップ
board.addEventListener('dragend', function () {
  document.querySelectorAll('.is-dragging').forEach(function (el) {
    el.classList.remove('is-dragging');
  });
  document.querySelectorAll('.is-over').forEach(function (el) {
    el.classList.remove('is-over');
  });
  dragEnterCounts = {};
  draggingId = null;
});

// ===== クリックイベント(イベント委譲) =====

board.addEventListener('click', function (e) {
  // 「+ カードを追加」ボタン
  var addBtn = e.target.closest('[data-add-column]');
  if (addBtn) {
    ADDING_COLUMN = addBtn.dataset.addColumn;
    render();
    return;
  }

  // 追加「確定」ボタン
  var confirmBtn = e.target.closest('[data-add-confirm]');
  if (confirmBtn) {
    confirmAddCard(confirmBtn.dataset.addConfirm);
    return;
  }

  // 追加「キャンセル」ボタン
  var cancelBtn = e.target.closest('[data-add-cancel]');
  if (cancelBtn) {
    ADDING_COLUMN = null;
    render();
    return;
  }

  // カード削除ボタン(×)→ 確認ダイアログを開く
  var delBtn = e.target.closest('[data-delete-id]');
  if (delBtn) {
    openConfirmDialog(Number(delBtn.dataset.deleteId));
    return;
  }
});

// キーボード操作:フォーム上でのEnter/ESC
board.addEventListener('keydown', function (e) {
  var input = e.target.closest('[data-add-input]');
  if (!input) return;

  if (e.key === 'Enter') {
    confirmAddCard(input.dataset.addInput);
  } else if (e.key === 'Escape') {
    ADDING_COLUMN = null;
    render();
  }
});

// ===== 削除確認ダイアログ =====

var pendingDeleteId = null;

function openConfirmDialog(cardId) {
  var card = cards.find(function (c) { return c.id === cardId; });
  if (!card) return;

  pendingDeleteId = cardId;

  var msg = document.getElementById('confirmMessage');
  msg.textContent = '「' + card.title + '」を削除しますか?';

  document.getElementById('confirmOverlay').removeAttribute('hidden');
  document.getElementById('confirmOkBtn').focus();
}

document.getElementById('confirmOkBtn').addEventListener('click', function () {
  if (pendingDeleteId !== null) {
    cards = cards.filter(function (c) { return c.id !== pendingDeleteId; });
    pendingDeleteId = null;
  }
  document.getElementById('confirmOverlay').setAttribute('hidden', '');
  render();
});

document.getElementById('confirmCancelBtn').addEventListener('click', function () {
  pendingDeleteId = null;
  document.getElementById('confirmOverlay').setAttribute('hidden', '');
});

// オーバーレイ背景クリックでもキャンセル
document.getElementById('confirmOverlay').addEventListener('click', function (e) {
  if (e.target === this) {
    pendingDeleteId = null;
    this.setAttribute('hidden', '');
  }
});

// ===== 初期描画 =====
render();

AI用プロンプト

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

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

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

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

# カンバンボード 作成依頼

## 概要
未着手・進行中・完了の3列の間でタスクカードをドラッグ&ドロップで移動できる
カンバンボードを、ライブラリを使わずHTML5 Drag and Drop APIで作成してください。

## 要件
- 3列構成(未着手/進行中/完了)。各列ヘッダーに「未着手 (3)」のような件数バッジを表示し、
  カードの移動・追加・削除と連動して更新する
- カードには 種別ラベルバッジ(作業=青/確認=黄/修正=赤)・タイトル・
  担当者イニシャルの丸アイコン を表示する。初期データは8枚
- カードはドラッグで別の列に移動できる。ドラッグ中はカードを半透明にし、
  ドロップ可能な列の背景をハイライトする。列の外でドロップしたら移動しない
- 各列の下部に「+ カードを追加」ボタンを置き、クリックでインライン入力欄
  (タイトル入力+ラベル選択+追加/キャンセル)に切り替える。
  Enterで追加、ESCでキャンセル。空のままは追加しない
- カードにホバーすると×ボタンが現れ、確認ダイアログ(タイトル名入り)を経て削除できる
- カードが0枚の列には「カードをここにドロップ」の点線プレースホルダーを表示し、
  ドロップ先として機能させる

## 技術仕様
- HTML / CSS / バニラJavaScript で実装
- 外部ライブラリ:なし(HTML5 Drag and Drop API を使用)
- データはJavaScript内の配列で保持する(リロードで初期状態に戻る)
- レスポンシブ対応:必要(768px以下で列を縦積みにする)

## 動作詳細
- dragstart で dataTransfer にカードIDをセットし、drop 側で取り出して
  データ配列の所属列を書き換えてから全列を再描画する
- dragover では必ず preventDefault を呼ぶ(drop を有効にするため)
- イベントはボード要素へのイベント委譲で登録し、再描画のたびに付け直さない
- カードの生成は createElement と textContent を使い、innerHTML に変数を結合しない

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