ツリー階層メンテナンス(ノード追加・編集・移動・削除)

応用例 上級

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

カテゴリ管理・組織図・メニュー構造など、階層データをGUIで維持管理する画面パターンです。JSONをfetchで取得してツリーを動的に構築し、ノードの追加・編集・削除・並べ替え・別階層への移動を1画面で行えます。モーダルを使った編集・移動UIにより、SQLやJSONを直接触らずに階層構造を視覚的にメンテナンスできる仕組みを実装します。

こんな場面で使えます

  • カテゴリ管理 — ECサイトやCMSの商品カテゴリ(大分類→中分類→小分類)の並び替えや名称変更
  • 組織・権限グループ管理 — 部署ツリーの再編や、ロール階層への所属変更
  • メニュー・ナビ構造の編集 — サイトのナビゲーション階層をGUI操作で整理する管理画面

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

#パーツこの画面での役割
1ツリービュー(再帰DOM生成)JSON階層データを展開/折りたたみ可能なツリーで表示
2↑↓移動ボタン同じ親内でのノードの並び替え
3子追加ボタン選択ノードの直下に新しいノードを追加
4編集モーダルノード名・ステータスの変更フォーム
5移動先選択モーダル別の親ノードへ移動するツリー選択UI
6削除確認ダイアログ子ノードごと削除する前の確認
7ステータスバッジ各ノードの有効/無効状態を色で表示
8トースト通知保存・削除・移動の完了を画面右下に表示

実装のポイント・注意点

最大の設計ポイントはデータと表示の分離です。ツリーの状態をJSONオブジェクトの配列(nodes)で一元管理し、追加・編集・削除・並び替え・移動はすべてデータ配列を書き換えてから render() で再描画します。ノード移動は「同じ親内の↑↓」と「別の親への移動モーダル」の2段階で実装します。移動モーダル内のツリーは元のツリーと同じ描画ロジックを再利用できますが、移動先として選べないノード(移動元ノード自身と、その子孫)をグレーアウトして選択不可にする処理が必要です。再帰関数での子孫判定は getDescendantIds() を切り出しておくと見通しがよくなります。また render() でDOM全体を毎回再構築するアーキテクチャでは、展開/折りたたみ状態を Set で別管理しないと操作のたびにツリーが閉じてUXが崩れます。

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

動作サンプル

ツリー階層メンテナンスのデモ画面 動作サンプルを別ウィンドウで確認 ↗

試してみる:

  • 「A-2」の「↑」ボタンをクリックして、A-1とA-2の順序が入れ替わることを確認
  • 「A-2-1」の「移動」ボタンをクリックし、カテゴリB配下に移動してみる(移動元のA-2がグレーアウトされることも確認)
  • ノード名をクリックして編集モーダルを開き、名前とステータスを変更して保存する

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

サンプルソース

4つのファイルを同じフォルダに保存し、ローカルサーバー(VS Code Live Server等)経由で index.html を開くと動作確認できます。
ファイル名:index.html / style.css / script.js + data/ フォルダに data.json
fetch を使用しているため file:// での直接表示は動作しません(CORSエラー)。 保存時の文字コードは 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="tree-screen">

  <!-- ===== ヘッダー ===== -->
  <div class="screen-header">
    <h1 class="screen-title">ツリー階層メンテナンス</h1>
    <button type="button" class="btn-primary" id="addRootBtn">+ ルート追加</button>
  </div>

  <!-- ===== ツリー本体(JSで再帰的にDOM生成) ===== -->
  <div class="tree-container" id="treeContainer"></div>

  <!-- ===== 編集モーダル(ノード追加・編集で共用) ===== -->
  <div class="modal-overlay" id="editOverlay" hidden>
    <div class="modal" role="dialog" aria-modal="true" aria-labelledby="editModalTitle">
      <h2 class="modal-title" id="editModalTitle">ノードを編集</h2>
      <div class="modal-body">
        <label class="form-label">
          名前 <span class="required">*</span>
          <input type="text" class="form-input" id="editNameInput" placeholder="ノード名を入力">
          <p class="field-error" id="editNameError" hidden>名前を入力してください</p>
        </label>
        <label class="form-label">
          ステータス
          <select class="form-select" id="editStatusSelect">
            <option value="active">有効</option>
            <option value="inactive">無効</option>
          </select>
        </label>
      </div>
      <div class="modal-actions">
        <button type="button" class="btn-secondary" id="editCancelBtn">キャンセル</button>
        <button type="button" class="btn-primary" id="editSaveBtn">保存</button>
      </div>
    </div>
  </div>

  <!-- ===== 移動先選択モーダル ===== -->
  <div class="modal-overlay" id="moveOverlay" hidden>
    <div class="modal modal-move" role="dialog" aria-modal="true" aria-labelledby="moveModalTitle">
      <h2 class="modal-title" id="moveModalTitle">移動先を選択</h2>
      <div class="modal-body">
        <div class="move-tree-container" id="moveTreeContainer"></div>
      </div>
      <div class="modal-actions">
        <button type="button" class="btn-secondary" id="moveCancelBtn">キャンセル</button>
        <button type="button" class="btn-primary" id="moveConfirmBtn" disabled>ここへ移動</button>
      </div>
    </div>
  </div>

  <!-- ===== 削除確認ダイアログ ===== -->
  <div class="modal-overlay" id="confirmOverlay" hidden>
    <div class="modal modal-confirm" role="alertdialog" aria-modal="true">
      <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 class="toast" id="toast" role="status" aria-live="polite" hidden></div>

</div>

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

:root {
  --color-bg: #F4F6F9;
  --color-surface: #FFFFFF;
  --color-border: #D0D7E0;
  --color-text: #1A1A2E;
  --color-text-sub: #6B7280;
  --color-primary: #2B7FE8;
  --color-primary-hover: #1A6FD8;
  --color-danger: #EF4444;
  --color-danger-hover: #DC2626;
  --color-active: #22C55E;
  --color-inactive: #9CA3AF;
  --color-hover-bg: #F0F4FA;
  --color-selected-bg: #EBF3FF;
  --color-disabled: #D1D5DB;
  --radius: 8px;
  --shadow: 0 2px 8px rgba(0,0,0,0.08);
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  font-size: 14px;
  color: var(--color-text);
  background: var(--color-bg);
  min-height: 100vh;
  padding: 24px;
}

/* ===== 画面レイアウト ===== */
.tree-screen { max-width: 800px; margin: 0 auto; }

.screen-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 20px;
}

.screen-title { font-size: 20px; font-weight: 700; }

/* ===== ツリーコンテナ ===== */
.tree-container {
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  padding: 8px 0;
  min-height: 80px;
}

/* ===== ノード行 ===== */
.tree-node-row {
  display: flex;
  align-items: center;
  gap: 4px;
  padding: 5px 8px;
}
.tree-node-row:hover,
.tree-node-row:focus-within { background: var(--color-hover-bg); }

/* ===== 展開/折りたたみトグル ===== */
.toggle-btn {
  width: 20px; height: 20px;
  display: flex; align-items: center; justify-content: center;
  background: none; border: none; cursor: pointer;
  font-size: 10px; color: var(--color-text-sub);
  flex-shrink: 0; border-radius: 3px;
}
.toggle-btn:hover { background: var(--color-border); }
.toggle-btn.invisible { visibility: hidden; cursor: default; }
.toggle-btn.invisible:hover { background: none; }

/* ===== ノード名 ===== */
.node-name {
  flex: 1; cursor: pointer;
  padding: 2px 4px; border-radius: 4px;
  font-size: 14px;
}
.node-name:hover { color: var(--color-primary); }

/* ===== ステータスバッジ ===== */
.status-badge { font-size: 11px; flex-shrink: 0; }
.status-badge.active  { color: var(--color-active); }
.status-badge.inactive { color: var(--color-inactive); }

/* ===== アクションボタン群(ホバーで表示) ===== */
.node-actions {
  display: flex; gap: 2px;
  opacity: 0; transition: opacity 0.1s; flex-shrink: 0;
}
.tree-node-row:hover .node-actions,
.tree-node-row:focus-within .node-actions { opacity: 1; }

.action-btn {
  padding: 2px 6px; font-size: 11px;
  border: 1px solid var(--color-border); border-radius: 4px;
  background: var(--color-surface); cursor: pointer;
  color: var(--color-text-sub); white-space: nowrap;
  transition: background 0.1s, color 0.1s;
}
.action-btn:hover:not(:disabled) {
  background: var(--color-hover-bg); color: var(--color-text);
  border-color: #9CA3AF;
}
.action-btn:disabled { opacity: 0.3; cursor: default; }
.action-btn.btn-delete:hover:not(:disabled) {
  background: #FEF2F2; color: var(--color-danger); border-color: #FCA5A5;
}

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

.modal {
  background: var(--color-surface);
  border-radius: var(--radius);
  box-shadow: 0 8px 32px rgba(0,0,0,0.18);
  width: 100%; max-width: 400px; padding: 24px;
}

.modal-title { font-size: 16px; font-weight: 700; margin-bottom: 20px; }
.modal-body { margin-bottom: 20px; }

.form-label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 12px; }
.form-label .required { color: var(--color-danger); margin-left: 2px; }

.form-input, .form-select {
  display: block; width: 100%; margin-top: 6px;
  padding: 8px 10px; border: 1px solid var(--color-border);
  border-radius: 6px; font-size: 14px; background: var(--color-surface);
}
.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);
}

.field-error { font-size: 12px; color: var(--color-danger); margin-top: 4px; }
.field-error[hidden] { display: none; }

.modal-actions { display: flex; justify-content: flex-end; gap: 8px; }
.modal-confirm { max-width: 340px; }
.confirm-message { font-size: 14px; line-height: 1.6; margin-bottom: 20px; }

/* ===== 移動モーダル ===== */
.modal.modal-move { max-width: 440px; }

.move-tree-container {
  border: 1px solid var(--color-border); border-radius: 6px;
  max-height: 260px; overflow-y: auto; padding: 4px 0;
}

.move-root-btn {
  display: block; width: 100%; text-align: left;
  padding: 7px 12px; font-size: 13px;
  background: none; border: none;
  border-bottom: 1px solid var(--color-border);
  cursor: pointer; color: var(--color-primary); font-weight: 600;
}
.move-root-btn:hover:not(:disabled) { background: var(--color-hover-bg); }
.move-root-btn:disabled { color: var(--color-disabled); cursor: default; }
.move-root-btn.selected { background: var(--color-selected-bg); }

.move-node-row {
  display: flex; align-items: center; gap: 4px;
  padding: 5px 8px; cursor: pointer;
}
.move-node-row:hover:not(.disabled) { background: var(--color-hover-bg); }
.move-node-row.disabled { color: var(--color-disabled); cursor: default; pointer-events: none; }
.move-node-row.selected { background: var(--color-selected-bg); }

.move-toggle {
  width: 16px; height: 16px;
  display: flex; align-items: center; justify-content: center;
  font-size: 9px; color: var(--color-text-sub);
  flex-shrink: 0; cursor: pointer;
  border-radius: 3px; background: none; border: none;
}
.move-toggle.invisible { visibility: hidden; }
.move-node-name { font-size: 13px; }

/* ===== ボタン ===== */
.btn-primary {
  padding: 8px 16px; font-size: 13px; font-weight: 600;
  background: var(--color-primary); color: #fff;
  border: none; border-radius: 6px; cursor: pointer;
  transition: background 0.15s;
}
.btn-primary:hover:not(:disabled) { background: var(--color-primary-hover); }
.btn-primary:disabled { background: var(--color-border); color: #9CA3AF; cursor: default; }

.btn-secondary {
  padding: 8px 16px; font-size: 13px;
  background: var(--color-surface); color: var(--color-text);
  border: 1px solid var(--color-border); border-radius: 6px;
  cursor: pointer; transition: background 0.15s;
}
.btn-secondary:hover { background: var(--color-hover-bg); }

.btn-danger {
  padding: 8px 16px; font-size: 13px; font-weight: 600;
  background: var(--color-danger); color: #fff;
  border: none; border-radius: 6px; cursor: pointer;
  transition: background 0.15s;
}
.btn-danger:hover { background: var(--color-danger-hover); }

/* ===== トースト ===== */
.toast {
  position: fixed; bottom: 24px; right: 24px;
  background: #1A1A2E; color: #fff;
  padding: 10px 18px; border-radius: 6px; font-size: 13px;
  box-shadow: 0 4px 16px rgba(0,0,0,0.2); z-index: 200;
  opacity: 1; transition: opacity 0.4s;
}
.toast[hidden] { display: none; }
.toast.fade-out { opacity: 0; }

/* ===== レスポンシブ ===== */
@media (max-width: 600px) {
  body { padding: 12px; }
  .screen-header { flex-direction: column; align-items: flex-start; gap: 10px; }
  .modal { padding: 16px; }
  .action-btn { padding: 2px 4px; font-size: 10px; }
}
/* =====================================================
   ツリー階層メンテナンス画面のスクリプト

   仕組み:ツリーデータを nodes[] で一元管理し、
   追加・編集・削除・並べ替え・移動はすべて
   配列を書き換えてから render() で再描画する。

   展開状態は expandedIds(Set)で別管理し、
   再描画のたびにリセットされないようにする。
   ===================================================== */

// ===== 設定値 =====
var INDENT_PX = 20;           // 1階層あたりのインデント幅(px)
var TOAST_DURATION_MS = 2000; // トースト表示時間(ミリ秒)
var NEXT_ID = 100;            // 新規追加ノードのID採番起点

// ===== 状態 =====
var nodes = [];               // ツリーデータ(fetch後に格納)
var expandedIds = new Set();  // 展開中のノードID

var editMode = '';            // 'edit' | 'add-child' | 'add-root'
var editTargetId = '';
var moveSourceId = '';
var moveDestId = '';          // '' = 未選択, 'root' = ルートへ移動, それ以外 = ノードID
var deleteTargetId = '';

// ===== 初期化 =====
fetch('./data/data.json')
  .then(function (res) { return res.json(); })
  .then(function (data) {
    nodes = data;
    nodes.forEach(function (n) { expandedIds.add(n.id); });
    render();
  })
  .catch(function () {
    // fetchに失敗した場合はフォールバックデータを使用
    nodes = [
      { id: '1', name: 'カテゴリA', status: 'active', children: [
        { id: '1-1', name: 'A-1', status: 'active', children: [] },
        { id: '1-2', name: 'A-2', status: 'inactive', children: [
          { id: '1-2-1', name: 'A-2-1', status: 'active', children: [] },
          { id: '1-2-2', name: 'A-2-2', status: 'active', children: [] }
        ]}
      ]},
      { id: '2', name: 'カテゴリB', status: 'active', children: [
        { id: '2-1', name: 'B-1', status: 'active', children: [] }
      ]},
      { id: '3', name: 'カテゴリC', status: 'active', children: [] }
    ];
    nodes.forEach(function (n) { expandedIds.add(n.id); });
    render();
  });

// ===== 再帰ヘルパー =====

// IDでノードを再帰検索して返す
function findNode(id, list) {
  for (var i = 0; i < list.length; i++) {
    if (list[i].id === id) return list[i];
    var found = findNode(id, list[i].children);
    if (found) return found;
  }
  return null;
}

// IDのノードの親ノードを返す(ルート直下の場合は null)
function findParent(id, list, parent) {
  parent = parent || null;
  for (var i = 0; i < list.length; i++) {
    if (list[i].id === id) return parent;
    var found = findParent(id, list[i].children, list[i]);
    if (found !== undefined) return found;
  }
  return undefined;
}

// ノードの子孫ID一覧を配列で返す(移動モーダルのグレーアウト判定)
function getDescendantIds(node) {
  var ids = [];
  node.children.forEach(function (child) {
    ids.push(child.id);
    ids = ids.concat(getDescendantIds(child));
  });
  return ids;
}

// 配列からIDのノードを再帰的に取り除いて返す
function removeNode(id, list) {
  for (var i = 0; i < list.length; i++) {
    if (list[i].id === id) return list.splice(i, 1)[0];
    var removed = removeNode(id, list[i].children);
    if (removed) return removed;
  }
  return null;
}

// 対象IDの兄弟配列を返す
function getSiblingList(id) {
  var parent = findParent(id, nodes);
  if (parent === null) return nodes;
  if (parent) return parent.children;
  return null;
}

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

function render() {
  var container = document.getElementById('treeContainer');
  container.innerHTML = '';
  if (nodes.length === 0) {
    var empty = document.createElement('p');
    empty.style.cssText = 'padding:16px;color:#9CA3AF;font-size:13px;';
    empty.textContent = 'ノードがありません。「+ ルート追加」から追加してください。';
    container.appendChild(empty);
    return;
  }
  renderTree(nodes, container, 0);
}

function renderTree(list, container, depth) {
  list.forEach(function (node, index) {
    var hasChildren = node.children.length > 0;
    var isExpanded = expandedIds.has(node.id);

    var row = document.createElement('div');
    row.className = 'tree-node-row';
    row.style.paddingLeft = (8 + depth * INDENT_PX) + 'px';

    // 展開/折りたたみボタン
    var toggle = document.createElement('button');
    toggle.className = 'toggle-btn' + (hasChildren ? '' : ' invisible');
    toggle.setAttribute('aria-label', isExpanded ? '折りたたむ' : '展開する');
    toggle.textContent = isExpanded ? '▼' : '▶';
    if (hasChildren) {
      toggle.addEventListener('click', function () { toggleExpand(node.id); });
    }
    row.appendChild(toggle);

    // ステータスバッジ
    var badge = document.createElement('span');
    badge.className = 'status-badge ' + node.status;
    badge.textContent = node.status === 'active' ? '●' : '○';
    badge.title = node.status === 'active' ? '有効' : '無効';
    row.appendChild(badge);

    // ノード名(クリックで編集)
    var nameEl = document.createElement('span');
    nameEl.className = 'node-name';
    nameEl.textContent = node.name;
    nameEl.addEventListener('click', function () { openEditModal(node.id); });
    row.appendChild(nameEl);

    // アクションボタン群
    var actions = document.createElement('div');
    actions.className = 'node-actions';

    var btnUp = makeActionBtn('↑', '上へ移動', function () { moveUp(node.id); });
    btnUp.disabled = (index === 0);
    var btnDown = makeActionBtn('↓', '下へ移動', function () { moveDown(node.id); });
    btnDown.disabled = (index === list.length - 1);
    var btnAdd  = makeActionBtn('子追加', '子ノードを追加', function () { openAddChildModal(node.id); });
    var btnMove = makeActionBtn('移動', '別の親へ移動', function () { openMoveModal(node.id); });
    var btnDel  = makeActionBtn('削除', '削除', function () { openConfirm(node.id); });
    btnDel.classList.add('btn-delete');

    [btnUp, btnDown, btnAdd, btnMove, btnDel].forEach(function (b) { actions.appendChild(b); });
    row.appendChild(actions);
    container.appendChild(row);

    if (hasChildren && isExpanded) {
      renderTree(node.children, container, depth + 1);
    }
  });
}

function makeActionBtn(label, title, handler) {
  var btn = document.createElement('button');
  btn.className = 'action-btn';
  btn.textContent = label;
  btn.title = title;
  btn.addEventListener('click', handler);
  return btn;
}

// ===== 展開・折りたたみ =====

function toggleExpand(id) {
  if (expandedIds.has(id)) { expandedIds.delete(id); } else { expandedIds.add(id); }
  render();
}

// ===== 編集・追加モーダル =====

function openEditModal(id) {
  var node = findNode(id, nodes);
  editMode = 'edit'; editTargetId = id;
  document.getElementById('editModalTitle').textContent = 'ノードを編集';
  document.getElementById('editNameInput').value = node.name;
  document.getElementById('editStatusSelect').value = node.status;
  clearEditError(); showOverlay('editOverlay');
  document.getElementById('editNameInput').focus();
}

function openAddChildModal(parentId) {
  editMode = 'add-child'; editTargetId = parentId;
  document.getElementById('editModalTitle').textContent = '子ノードを追加';
  document.getElementById('editNameInput').value = '';
  document.getElementById('editStatusSelect').value = 'active';
  clearEditError(); showOverlay('editOverlay');
  document.getElementById('editNameInput').focus();
}

function openAddRootModal() {
  editMode = 'add-root'; editTargetId = '';
  document.getElementById('editModalTitle').textContent = 'ルートノードを追加';
  document.getElementById('editNameInput').value = '';
  document.getElementById('editStatusSelect').value = 'active';
  clearEditError(); showOverlay('editOverlay');
  document.getElementById('editNameInput').focus();
}

function saveEdit() {
  var name = document.getElementById('editNameInput').value.trim();
  if (!name) { document.getElementById('editNameError').hidden = false; return; }
  var status = document.getElementById('editStatusSelect').value;

  if (editMode === 'edit') {
    var node = findNode(editTargetId, nodes);
    node.name = name; node.status = status;
    hideOverlay('editOverlay'); render(); showToast('保存しました');

  } else if (editMode === 'add-child') {
    var parent = findNode(editTargetId, nodes);
    parent.children.push({ id: String(NEXT_ID++), name: name, status: status, children: [] });
    expandedIds.add(editTargetId);
    hideOverlay('editOverlay'); render(); showToast('追加しました');

  } else if (editMode === 'add-root') {
    nodes.push({ id: String(NEXT_ID++), name: name, status: status, children: [] });
    hideOverlay('editOverlay'); render(); showToast('追加しました');
  }
}

function clearEditError() { document.getElementById('editNameError').hidden = true; }

// ===== 並べ替え(兄弟内↑↓) =====

function moveUp(id) {
  var siblings = getSiblingList(id);
  var idx = siblings.findIndex(function (n) { return n.id === id; });
  if (idx <= 0) return;
  var tmp = siblings[idx - 1]; siblings[idx - 1] = siblings[idx]; siblings[idx] = tmp;
  render();
}

function moveDown(id) {
  var siblings = getSiblingList(id);
  var idx = siblings.findIndex(function (n) { return n.id === id; });
  if (idx < 0 || idx >= siblings.length - 1) return;
  var tmp = siblings[idx + 1]; siblings[idx + 1] = siblings[idx]; siblings[idx] = tmp;
  render();
}

// ===== 移動モーダル =====

function openMoveModal(id) {
  moveSourceId = id; moveDestId = '';
  renderMoveTree();
  document.getElementById('moveConfirmBtn').disabled = true;
  showOverlay('moveOverlay');
}

function renderMoveTree() {
  var container = document.getElementById('moveTreeContainer');
  container.innerHTML = '';

  var isAlreadyRoot = nodes.some(function (n) { return n.id === moveSourceId; });
  var rootBtn = document.createElement('button');
  rootBtn.className = 'move-root-btn' + (moveDestId === 'root' ? ' selected' : '');
  rootBtn.textContent = '▲ ルートへ移動';
  rootBtn.disabled = isAlreadyRoot;
  rootBtn.addEventListener('click', function () {
    if (isAlreadyRoot) return;
    moveDestId = 'root';
    document.getElementById('moveConfirmBtn').disabled = false;
    renderMoveTree();
  });
  container.appendChild(rootBtn);

  var sourceNode = findNode(moveSourceId, nodes);
  var forbiddenIds = new Set([moveSourceId].concat(getDescendantIds(sourceNode)));
  renderMoveNodes(nodes, container, 0, forbiddenIds);
}

function renderMoveNodes(list, container, depth, forbiddenIds) {
  list.forEach(function (node) {
    var isDisabled = forbiddenIds.has(node.id);
    var isSelected = !isDisabled && moveDestId === node.id;
    var hasChildren = node.children.length > 0;
    var isExpanded  = expandedIds.has(node.id);

    var row = document.createElement('div');
    row.className = 'move-node-row' + (isDisabled ? ' disabled' : '') + (isSelected ? ' selected' : '');
    row.style.paddingLeft = (8 + depth * INDENT_PX) + 'px';

    var toggle = document.createElement('span');
    toggle.className = 'move-toggle' + (hasChildren ? '' : ' invisible');
    toggle.textContent = isExpanded ? '▼' : '▶';
    if (hasChildren && !isDisabled) {
      toggle.addEventListener('click', function (e) {
        e.stopPropagation(); toggleExpand(node.id); renderMoveTree();
      });
    }
    row.appendChild(toggle);

    var nameEl = document.createElement('span');
    nameEl.className = 'move-node-name';
    nameEl.textContent = node.name;
    row.appendChild(nameEl);

    if (!isDisabled) {
      row.addEventListener('click', function () {
        moveDestId = node.id;
        document.getElementById('moveConfirmBtn').disabled = false;
        renderMoveTree();
      });
    }
    container.appendChild(row);

    if (hasChildren && isExpanded) {
      renderMoveNodes(node.children, container, depth + 1, forbiddenIds);
    }
  });
}

function confirmMove() {
  if (!moveDestId) return;
  var movedNode = removeNode(moveSourceId, nodes);
  if (moveDestId === 'root') {
    nodes.push(movedNode);
  } else {
    var destNode = findNode(moveDestId, nodes);
    destNode.children.push(movedNode);
    expandedIds.add(moveDestId);
  }
  hideOverlay('moveOverlay'); render(); showToast('移動しました');
}

// ===== 削除確認 =====

function openConfirm(id) {
  deleteTargetId = id;
  var node = findNode(id, nodes);
  document.getElementById('confirmMessage').textContent =
    '「' + node.name + '」を子ノードごと削除しますか?';
  showOverlay('confirmOverlay');
}

function confirmDelete() {
  removeNode(deleteTargetId, nodes);
  hideOverlay('confirmOverlay'); render(); showToast('削除しました');
}

// ===== オーバーレイ開閉 =====
function showOverlay(id) { document.getElementById(id).hidden = false; }
function hideOverlay(id) { document.getElementById(id).hidden = true; }

// ===== トースト =====
var toastTimer = null;
function showToast(msg) {
  var toast = document.getElementById('toast');
  toast.textContent = msg; toast.hidden = false; toast.classList.remove('fade-out');
  if (toastTimer) clearTimeout(toastTimer);
  toastTimer = setTimeout(function () {
    toast.classList.add('fade-out');
    setTimeout(function () { toast.hidden = true; }, 400);
  }, TOAST_DURATION_MS);
}

// ===== イベント登録 =====
document.getElementById('addRootBtn').addEventListener('click', openAddRootModal);
document.getElementById('editSaveBtn').addEventListener('click', saveEdit);
document.getElementById('editCancelBtn').addEventListener('click', function () { hideOverlay('editOverlay'); });
document.getElementById('editNameInput').addEventListener('keydown', function (e) {
  if (e.key === 'Enter') saveEdit();
});
document.getElementById('moveConfirmBtn').addEventListener('click', confirmMove);
document.getElementById('moveCancelBtn').addEventListener('click', function () { hideOverlay('moveOverlay'); });
document.getElementById('confirmOkBtn').addEventListener('click', confirmDelete);
document.getElementById('confirmCancelBtn').addEventListener('click', function () { hideOverlay('confirmOverlay'); });

['editOverlay', 'moveOverlay', 'confirmOverlay'].forEach(function (id) {
  document.getElementById(id).addEventListener('click', function (e) {
    if (e.target === this) hideOverlay(id);
  });
});
[
  {
    "id": "1",
    "name": "カテゴリA",
    "status": "active",
    "children": [
      { "id": "1-1", "name": "A-1", "status": "active", "children": [] },
      {
        "id": "1-2", "name": "A-2", "status": "inactive",
        "children": [
          { "id": "1-2-1", "name": "A-2-1", "status": "active", "children": [] },
          { "id": "1-2-2", "name": "A-2-2", "status": "active", "children": [] }
        ]
      }
    ]
  },
  {
    "id": "2",
    "name": "カテゴリB",
    "status": "active",
    "children": [
      { "id": "2-1", "name": "B-1", "status": "active", "children": [] }
    ]
  },
  { "id": "3", "name": "カテゴリC", "status": "active", "children": [] }
]

AI用プロンプト

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

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

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

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

# ツリー階層メンテナンス画面 作成依頼

## 概要
階層データをGUIで管理できるツリーメンテナンス画面を、
ライブラリなしのバニラJavaScriptで作成してください。
JSONをfetchで取得してツリーを動的に構築し、ノードの
追加・編集・削除・並べ替え・別階層への移動を実装します。

## 要件

### ツリー表示
- JSONファイル(再帰構造:id / name / status / children[])をfetchして動的にツリーを生成する
- ▶/▼ アイコンのクリックで子ノードの展開/折りたたみができる
- 各ノード行にステータスバッジ(有効=緑●・無効=グレー○)とボタン群(↑↓ 子追加 移動 削除)を表示する
- ボタン群はホバー時のみ表示する

### 編集(モーダル)
- ノード名をクリックすると編集モーダルが開く
- 名前(必須)とステータス(有効/無効)を編集して保存できる
- 名前が空のまま保存しようとするとモーダル内にエラーを表示する

### 並べ替え(↑↓ ボタン)
- 同じ親を持つノード間での順序変更のみ行う
- 先頭の「↑」と末尾の「↓」は disabled にする

### 子追加 / ルート追加
- 「子追加」ボタンで対象ノードの直下に新しいノードを追加する
- 画面右上「+ ルート追加」でルート直下に追加する
- 追加後は追加先ノードを自動展開する

### 別の親への移動(モーダル)
- 「移動」ボタンでツリー選択モーダルを開く
- モーダル内にツリーを表示し、移動先となる親ノードをクリックで選択する
- 移動元ノード自身と子孫はグレーアウト・選択不可にする
- 「ルートへ移動」を最上部に表示する
- 移動先を選択したら「ここへ移動」ボタンが有効になり、クリックで移動を確定する

### 削除(確認ダイアログ)
- 「削除」ボタンでダイアログを開く(例:「『カテゴリA』を子ノードごと削除しますか?」)
- 確定で対象ノードを子ごと削除する

### フィードバック
- 保存・追加・移動・削除の完了後に画面右下にトースト通知を表示する(2秒後に消える)

## 技術仕様
- HTML / CSS / バニラJavaScript で実装
- 外部ライブラリ:なし
- ツリーデータはJSONファイルをfetchで取得する(data/data.json)
- ツリーの状態はJavaScriptの配列で一元管理し、操作後は毎回 render() で再描画する
- 展開/折りたたみの状態は Set で別管理し、再描画のたびにリセットされないようにする

## ヘルパー関数
- findNode(id, list) — IDでノードを検索して返す
- findParent(id, list, parent) — ノードの親を返す
- getDescendantIds(node) — 子孫IDの配列を返す(移動モーダルのグレーアウトに使用)
- removeNode(id, list) — 配列からノードを取り除く

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