マスタ編集フォーム画面(入力部品+バリデーション)

応用例 中級

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

業務アプリのマスタ管理で必ず登場する「1レコードを編集して保存する」フォーム画面のパターンです。 入力部品を1画面に整列させ、インラインバリデーション → 確認ダイアログ → 完了トーストという保存フロー一式を実装します。 個々の部品の使い方ではなく「部品の組み合わせ方・エラー処理のつなぎ方」を見せることが目的です。

こんな場面で使えます

  • 商品・アイテムマスタ — 名称やコード、公開設定を編集する
  • ユーザー・アカウント管理 — 登録情報の編集と有効/無効の切り替え
  • カテゴリ・区分の管理 — 選択式の属性を変更して保存する

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

#パーツこの画面での役割
1テキストボックス名称の入力(必須・50文字以内)
2コード入力(書式チェック付き)半角英数+ハイフンの書式検証
3テキストエリア+文字数カウンター「12/200」の残数をリアルタイム表示
4セレクトボックス区分の選択(必須)
5ラジオボタン公開範囲の3択
6トグルスイッチ有効/無効の切り替え
7インラインバリデーションフィールド直下にエラー表示
8確認ダイアログトースト保存確認 → 完了通知

実装のポイント・注意点

バリデーションは検証ルールを validators 配列にまとめ、保存時に全件実行して結果を集める方式にすると、フィールドが増えてもif文の山になりません。 エラーはフィールド直下に表示し、最初のエラーへフォーカスとスクロールを移すのが定石です。

もう1つの柱が dirty(変更あり)判定で、入力イベントで旗を立てるのではなく「現在値と INITIAL_DATA の値比較」で判定すると、変更後に手で元へ戻したケースも正しく「変更なし」と扱えます。 確認ダイアログは保存用・破棄用で文言とコールバックだけ差し替えて1つを共用し、同じ構造のDOMを2つ作らないようにします。

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

動作サンプル

マスタ編集フォーム画面(入力部品+バリデーション)のデモ画面 動作サンプルを別ウィンドウで確認 ↗

試してみる:

  • 名称を空にして「保存する」を押し、インラインエラーとフォーカス移動を確認
  • 説明欄に長い文章を入力して、文字数カウンターの超過表示(赤字)を確認
  • どこかを変更してから「キャンセル」を押し、破棄確認ダイアログを確認

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

サンプルソース

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="edit-screen">
  <h1 class="screen-title">アイテム編集</h1>

  <!-- ===== 編集フォーム ===== -->
  <form class="edit-form" id="editForm" novalidate>
    <!-- 名称 -->
    <div class="form-row">
      <label class="form-label" for="nameInput">名称 <span class="required-mark">*</span></label>
      <div class="form-control">
        <input type="text" id="nameInput" maxlength="60">
        <p class="field-error" data-error-for="nameInput" hidden></p>
      </div>
    </div>

    <!-- コード -->
    <div class="form-row">
      <label class="form-label" for="codeInput">コード <span class="required-mark">*</span></label>
      <div class="form-control">
        <input type="text" id="codeInput">
        <p class="field-hint">半角英数字とハイフンで入力</p>
        <p class="field-error" data-error-for="codeInput" hidden></p>
      </div>
    </div>

    <!-- 説明(文字数カウンター付き) -->
    <div class="form-row">
      <label class="form-label" for="descInput">説明</label>
      <div class="form-control">
        <textarea id="descInput" rows="4"></textarea>
        <p class="char-counter"><span id="charCount">0</span>/200</p>
        <p class="field-error" data-error-for="descInput" hidden></p>
      </div>
    </div>

    <!-- 区分 -->
    <div class="form-row">
      <label class="form-label" for="typeSelect">区分 <span class="required-mark">*</span></label>
      <div class="form-control">
        <select id="typeSelect">
          <option value="">選択してください</option>
          <option value="タイプ1">タイプ1</option>
          <option value="タイプ2">タイプ2</option>
          <option value="タイプ3">タイプ3</option>
        </select>
        <p class="field-error" data-error-for="typeSelect" hidden></p>
      </div>
    </div>

    <!-- 公開範囲 -->
    <fieldset class="form-row">
      <legend class="form-label">公開範囲 <span class="required-mark">*</span></legend>
      <div class="form-control radio-group">
        <label><input type="radio" name="visibility" value="all" checked> 全体</label>
        <label><input type="radio" name="visibility" value="limited"> 限定</label>
        <label><input type="radio" name="visibility" value="private"> 非公開</label>
      </div>
    </fieldset>

    <!-- 有効/無効トグル -->
    <div class="form-row">
      <span class="form-label">有効/無効</span>
      <div class="form-control">
        <label class="toggle">
          <input type="checkbox" id="enabledToggle" checked>
          <span class="toggle-track"></span>
          <span class="toggle-label-text">有効</span>
        </label>
      </div>
    </div>

    <!-- フッターボタン -->
    <div class="form-footer">
      <button type="button" class="btn-secondary" id="cancelBtn">キャンセル</button>
      <button type="submit" class="btn-primary">保存する</button>
    </div>
  </form>

  <!-- ===== 確認ダイアログ(保存用・破棄用で共用) ===== -->
  <div class="dialog-overlay" id="dialogOverlay" hidden>
    <div class="dialog" role="dialog" aria-modal="true" aria-labelledby="dialogMessage">
      <p class="dialog-message" id="dialogMessage"></p>
      <div class="dialog-actions">
        <button type="button" class="btn-secondary" id="dialogCancelBtn">戻る</button>
        <button type="button" class="btn-primary" id="dialogOkBtn">保存する</button>
      </div>
    </div>
  </div>

  <!-- ===== トースト ===== -->
  <div class="toast" id="toast" role="status" hidden></div>
</div>

<script src="./script.js"></script>
</body>
</html>
/* ===== マスタ編集フォーム画面 — style.css ===== */
*, *::before, *::after { box-sizing: border-box; }

:root {
  --color-primary: #2B7FE8;
  --color-danger:  #D64545;
  --color-text:    #1E293B;
  --color-muted:   #64748B;
  --color-border:  #D0D7E0;
  --color-bg:      #F4F6F9;
  --color-card:    #FFFFFF;
  --label-width:   160px; /* フォームのラベル列の幅 */
}

body {
  margin: 0;
  font-family: "Hiragino Kaku Gothic ProN", "Hiragino Sans", Meiryo, sans-serif;
  background: var(--color-bg);
  color: var(--color-text);
}

/* ===== 画面レイアウト ===== */
.edit-screen {
  max-width: 720px;
  margin: 0 auto;
  padding: 24px 20px 48px;
}

.screen-title {
  font-size: 22px;
  margin: 0 0 16px;
}

.edit-form {
  background: var(--color-card);
  border: 1px solid var(--color-border);
  border-radius: 8px;
  padding: 24px;
}

/* ===== フォーム行(ラベル列+入力列の2カラム) ===== */
.form-row {
  display: grid;
  grid-template-columns: var(--label-width) 1fr;
  gap: 8px 16px;
  align-items: start;
  margin-bottom: 18px;
}

.form-label {
  font-size: 14px;
  font-weight: 700;
  padding-top: 9px; /* 入力欄1行目のテキストと高さを揃える */
}

.required-mark { color: var(--color-danger); }

/* fieldset は display: grid が効かないブラウザがあるため、
   float でラベル列+入力列の2カラムを再現する */
fieldset.form-row {
  display: block;
  border: none;
  padding: 0;
  margin: 0 0 18px;
  min-width: 0;
}

fieldset.form-row legend.form-label {
  float: left;
  width: var(--label-width);
  padding: 0;
}

fieldset.form-row .form-control {
  margin-left: calc(var(--label-width) + 16px);
}

/* ===== 入力部品 ===== */
.form-control input[type="text"],
.form-control textarea,
.form-control select {
  width: 100%;
  padding: 8px 10px;
  font-size: 14px;
  font-family: inherit;
  color: var(--color-text);
  background: var(--color-card);
  border: 1px solid var(--color-border);
  border-radius: 6px;
}

.form-control textarea { resize: vertical; }

.form-control input:focus-visible,
.form-control textarea:focus-visible,
.form-control select:focus-visible {
  outline: 2px solid var(--color-primary);
  outline-offset: -1px;
}

/* バリデーションエラー時は入力枠を赤くする */
.form-control .is-invalid { border-color: var(--color-danger); }

.field-hint {
  margin: 4px 0 0;
  font-size: 12px;
  color: var(--color-muted);
}

.field-error {
  margin: 4px 0 0;
  font-size: 12px;
  color: var(--color-danger);
}

/* ===== 文字数カウンター ===== */
.char-counter {
  margin: 4px 0 0;
  font-size: 12px;
  color: var(--color-muted);
  text-align: right;
}

/* 200文字を超えたら赤字で警告する */
.char-counter.over {
  color: var(--color-danger);
  font-weight: 700;
}

/* ===== ラジオボタン ===== */
.radio-group {
  display: flex;
  flex-wrap: wrap;
  gap: 8px 20px;
  padding-top: 9px;
}

.radio-group label {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-size: 14px;
  cursor: pointer;
}

.radio-group input[type="radio"] {
  accent-color: var(--color-primary);
}

/* ===== トグルスイッチ ===== */
.toggle {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  padding-top: 6px;
  cursor: pointer;
}

/* チェックボックス本体は視覚的に隠す(キーボード操作は残す) */
.toggle input {
  position: absolute;
  opacity: 0;
  width: 1px;
  height: 1px;
}

.toggle-track {
  position: relative;
  flex-shrink: 0;
  width: 44px;
  height: 24px;
  border-radius: 9999px;
  background: #C3CCD6;
  transition: background 0.2s;
}

.toggle-track::after {
  content: "";
  position: absolute;
  top: 2px;
  left: 2px;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  background: #fff;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
  transition: left 0.2s;
}

.toggle input:checked + .toggle-track { background: var(--color-primary); }
.toggle input:checked + .toggle-track::after { left: 22px; }

.toggle input:focus-visible + .toggle-track {
  outline: 2px solid var(--color-primary);
  outline-offset: 2px;
}

.toggle-label-text { font-size: 14px; }

/* ===== フッターボタン ===== */
.form-footer {
  display: flex;
  justify-content: flex-end;
  gap: 12px;
  margin-top: 8px;
  padding-top: 20px;
  border-top: 1px solid #E6EBF1;
}

.btn-primary,
.btn-secondary {
  padding: 10px 22px;
  font-size: 14px;
  font-family: inherit;
  border-radius: 6px;
  cursor: pointer;
  transition: background 0.15s, border-color 0.15s;
}

.btn-primary {
  color: #fff;
  background: var(--color-primary);
  border: 1.5px solid var(--color-primary);
}

.btn-primary:hover { background: #1D6AD0; }

.btn-secondary {
  color: var(--color-muted);
  background: var(--color-card);
  border: 1.5px solid var(--color-border);
}

.btn-secondary:hover {
  background: var(--color-bg);
  border-color: #9AA5B4;
}

/* ===== 確認ダイアログ ===== */
.dialog-overlay {
  position: fixed;
  inset: 0;
  z-index: 10;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(0, 0, 0, 0.4);
  animation: fade-in 0.15s ease;
}

@keyframes fade-in {
  from { opacity: 0; }
  to   { opacity: 1; }
}

.dialog {
  width: calc(100% - 48px);
  max-width: 400px;
  padding: 24px 24px 20px;
  background: var(--color-card);
  border-radius: 10px;
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}

.dialog-message {
  margin: 0 0 20px;
  font-size: 15px;
  line-height: 1.6;
}

.dialog-actions {
  display: flex;
  justify-content: flex-end;
  gap: 12px;
}

/* ===== トースト ===== */
.toast {
  position: fixed;
  bottom: 24px;
  right: 24px;
  z-index: 20;
  padding: 12px 20px;
  font-size: 14px;
  color: #fff;
  background: #1E293B;
  border-radius: 8px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
  animation: toast-in 0.2s ease;
}

@keyframes toast-in {
  from { opacity: 0; transform: translateY(8px); }
  to   { opacity: 1; transform: none; }
}

/* hidden 属性を確実に効かせる(display 指定との競合対策) */
.edit-screen [hidden] { display: none !important; }

/* ===== レスポンシブ(768px以下) ===== */
@media (max-width: 768px) {
  .form-row { grid-template-columns: 1fr; gap: 6px; }
  .form-label { padding-top: 0; }

  fieldset.form-row legend.form-label {
    float: none;
    width: auto;
    margin-bottom: 6px;
  }

  fieldset.form-row .form-control { margin-left: 0; }

  .radio-group,
  .toggle { padding-top: 0; }
}
/* =====================================================
   マスタ編集フォーム画面のスクリプト

   仕組み:保存時に validators の検証ルールを全件実行し、
   エラーがあればフィールド直下に表示して最初のエラーへ
   フォーカスを移す。エラーがなければ確認ダイアログ → トースト。

   変更有無(dirty)はフラグではなく「現在値と INITIAL_DATA の
   比較」で毎回判定する(手で元に戻したケースも正しく非dirty)。
   確認ダイアログは保存用・破棄用で1つを共用する。
   ===================================================== */

// ===== 設定値 =====
var NAME_MAX_LENGTH   = 50;   // 名称の最大文字数
var DESC_MAX_LENGTH   = 200;  // 説明の最大文字数
var TOAST_DURATION_MS = 3000; // トーストの表示時間
var CODE_PATTERN = /^[A-Za-z0-9-]+$/; // コードの書式(半角英数とハイフン)

// 初期値(編集対象の既存レコードを想定)
var INITIAL_DATA = {
  name: 'サンプルアイテムA',
  code: 'ITEM-001',
  description: '管理用のサンプルです。',
  type: 'タイプ1',
  visibility: 'all',   // all | limited | private
  enabled: true
};

// ===== DOM要素 =====
var form          = document.getElementById('editForm');
var nameInput     = document.getElementById('nameInput');
var codeInput     = document.getElementById('codeInput');
var descInput     = document.getElementById('descInput');
var typeSelect    = document.getElementById('typeSelect');
var enabledToggle = document.getElementById('enabledToggle');
var toggleText    = document.querySelector('.toggle-label-text');
var charCount     = document.getElementById('charCount');
var charCounter   = document.querySelector('.char-counter');
var cancelBtn     = document.getElementById('cancelBtn');

var dialogOverlay   = document.getElementById('dialogOverlay');
var dialogMessage   = document.getElementById('dialogMessage');
var dialogOkBtn     = document.getElementById('dialogOkBtn');
var dialogCancelBtn = document.getElementById('dialogCancelBtn');
var toastEl         = document.getElementById('toast');

// ===== バリデーション定義 =====
// フィールドを増やすときはこの配列に1件足すだけでよい
var validators = [
  {
    el: nameInput,
    rules: [
      { ok: function (v) { return v.trim() !== ''; },               message: '名称を入力してください' },
      { ok: function (v) { return v.length <= NAME_MAX_LENGTH; },   message: NAME_MAX_LENGTH + '文字以内で入力してください' }
    ]
  },
  {
    el: codeInput,
    rules: [
      { ok: function (v) { return v.trim() !== ''; },        message: 'コードを入力してください' },
      { ok: function (v) { return CODE_PATTERN.test(v); },   message: '半角英数字とハイフンで入力してください' }
    ]
  },
  {
    el: descInput,
    rules: [
      { ok: function (v) { return v.length <= DESC_MAX_LENGTH; }, message: DESC_MAX_LENGTH + '文字以内で入力してください' }
    ]
  },
  {
    el: typeSelect,
    rules: [
      { ok: function (v) { return v !== ''; }, message: '区分を選択してください' }
    ]
  }
];

// 全フィールドを検証し、エラーの一覧({ el, message })を返す
function validateAll() {
  var errors = [];
  validators.forEach(function (field) {
    for (var i = 0; i < field.rules.length; i++) {
      var rule = field.rules[i];
      if (!rule.ok(field.el.value)) {
        errors.push({ el: field.el, message: rule.message });
        break; // 1フィールドにつき最初に引っかかったルールだけ表示する
      }
    }
  });
  return errors;
}

// エラー一覧をフィールド直下に表示する(毎回リセットしてから表示し直す)
function showErrors(errors) {
  clearErrors();
  errors.forEach(function (error) {
    error.el.classList.add('is-invalid');
    var errorEl = document.querySelector('[data-error-for="' + error.el.id + '"]');
    errorEl.textContent = error.message;
    errorEl.hidden = false;
  });
}

// すべてのエラー表示を消す
function clearErrors() {
  document.querySelectorAll('.field-error').forEach(function (el) {
    el.hidden = true;
    el.textContent = '';
  });
  document.querySelectorAll('.is-invalid').forEach(function (el) {
    el.classList.remove('is-invalid');
  });
}

// ===== フォーム値の読み書き・dirty判定 =====
// 現在のフォーム値をひとまとめにして返す
function getFormData() {
  return {
    name: nameInput.value,
    code: codeInput.value,
    description: descInput.value,
    type: typeSelect.value,
    visibility: document.querySelector('input[name="visibility"]:checked').value,
    enabled: enabledToggle.checked
  };
}

// フォームに値を流し込む(初期表示と「破棄する」で共用)
function setFormData(data) {
  nameInput.value = data.name;
  codeInput.value = data.code;
  descInput.value = data.description;
  typeSelect.value = data.type;
  document.querySelector('input[name="visibility"][value="' + data.visibility + '"]').checked = true;
  enabledToggle.checked = data.enabled;
  updateCharCounter();
  updateToggleText();
}

// 変更有無は「現在値と初期値の比較」で判定する
// (フラグ方式と違い、変更して手で元に戻したケースも正しく「変更なし」になる)
function isDirty() {
  var current = getFormData();
  return Object.keys(INITIAL_DATA).some(function (key) {
    return current[key] !== INITIAL_DATA[key];
  });
}

// ===== 文字数カウンター =====
// 説明欄に入力 → 「12/200」をリアルタイム更新。超過で赤字にする
descInput.addEventListener('input', updateCharCounter);

function updateCharCounter() {
  var length = descInput.value.length;
  charCount.textContent = length;
  charCounter.classList.toggle('over', length > DESC_MAX_LENGTH);
}

// ===== トグルスイッチ =====
// トグル切り替え → 隣のテキストを「有効/無効」に追従させる
enabledToggle.addEventListener('change', updateToggleText);

function updateToggleText() {
  toggleText.textContent = enabledToggle.checked ? '有効' : '無効';
}

// ===== 保存フロー =====
// 「保存する」クリック(submit) → 全件バリデーション → OKなら確認ダイアログ
form.addEventListener('submit', function (e) {
  e.preventDefault(); // 実際のサーバー送信は行わない(UIデモ)

  var errors = validateAll();
  showErrors(errors);

  if (errors.length > 0) {
    // 最初のエラーフィールドへフォーカスとスクロールを移す
    errors[0].el.focus();
    errors[0].el.scrollIntoView({ block: 'center', behavior: 'smooth' });
    return;
  }

  openDialog('この内容で保存しますか?', '保存する', function () {
    // 保存成功とみなし、現在値を新しい初期値にする(これで非dirtyに戻る)
    INITIAL_DATA = getFormData();
    showToast('保存しました');
  });
});

// ===== キャンセルフロー =====
// 「キャンセル」クリック → 変更があるときだけ破棄確認を出す
cancelBtn.addEventListener('click', function () {
  if (!isDirty()) {
    showToast('変更はありません');
    return;
  }
  openDialog('編集内容が破棄されます。よろしいですか?', '破棄する', function () {
    setFormData(INITIAL_DATA);
    clearErrors();
  });
});

// ===== 確認ダイアログ(保存用・破棄用で1つを共用) =====
var dialogOkCallback = null; // OKボタンを押したときに実行する処理

// メッセージ・OKボタンの文言・OK時の処理を差し替えて開く
function openDialog(message, okLabel, onOk) {
  dialogMessage.textContent = message;
  dialogOkBtn.textContent = okLabel;
  dialogOkCallback = onOk;
  dialogOverlay.hidden = false;
  dialogOkBtn.focus();
}

function closeDialog() {
  dialogOverlay.hidden = true;
  dialogOkCallback = null;
}

dialogOkBtn.addEventListener('click', function () {
  var callback = dialogOkCallback;
  closeDialog();
  if (callback) { callback(); }
});

// 「戻る」 → 何もせずフォームへ戻る(入力は保持)
dialogCancelBtn.addEventListener('click', closeDialog);

// オーバーレイ自体のクリックだけで閉じる
// (e.target === overlay の判定でダイアログ内クリックの貫通を防ぐ)
dialogOverlay.addEventListener('click', function (e) {
  if (e.target === dialogOverlay) { closeDialog(); }
});

// ESCキー → ダイアログ表示中のみ閉じる
document.addEventListener('keydown', function (e) {
  if (e.key === 'Escape' && !dialogOverlay.hidden) { closeDialog(); }
});

// ===== トースト =====
var toastTimerId = null;

// 3秒で自動的に消える。連続表示のときはタイマーを張り直す
function showToast(message) {
  toastEl.textContent = message;
  toastEl.hidden = false;
  if (toastTimerId) { clearTimeout(toastTimerId); }
  toastTimerId = setTimeout(function () {
    toastEl.hidden = true;
    toastTimerId = null;
  }, TOAST_DURATION_MS);
}

// ===== 初期化 =====
setFormData(INITIAL_DATA);

AI用プロンプト

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

ChatGPTやClaudeにこのプロンプトを渡すと、同様の画面をゼロから生成・カスタマイズできます。フィールドの追加やバリデーションルールの変更など、要件を追記して使うのがおすすめです。

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

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

# マスタ編集フォーム画面 作成依頼

## 概要
テキストボックス・セレクト・ラジオボタン・トグルスイッチとバリデーションを組み合わせた、
1レコードを編集して保存するフォーム画面を作成してください。

## 要件
- フィールド構成:名称(必須・50文字以内)/コード(必須・半角英数とハイフン)/
  説明(任意・200文字以内・文字数カウンター付き)/区分(セレクト・必須)/
  公開範囲(ラジオ3択)/有効・無効(トグルスイッチ)
- ラベル列と入力列を揃えた2カラムのフォームレイアウト(768px以下で縦積み)
- 保存ボタンで全フィールドをバリデーションし、エラーはフィールド直下に赤字で表示する
- エラーがある場合は最初のエラーフィールドにフォーカスを移動する
- エラーがない場合は「この内容で保存しますか?」の確認ダイアログを表示し、
  OKで「保存しました」のトースト通知を3秒表示する
- 入力を変更した状態でキャンセルを押すと「編集内容が破棄されます」と確認し、
  OKで初期値に戻す

## 技術仕様
- HTML / CSS / バニラJavaScript で実装
- 外部ライブラリ:なし
- 実際のサーバー送信は行わない(UIデモとして完結させる)
- レスポンシブ対応:必要

## 動作詳細
- バリデーションはフィールドごとのルール定義を配列にまとめ、保存時に一括実行する
- エラーメッセージは textContent で設定し、innerHTML に変数を結合しない
- 変更有無(dirty)は現在の入力値と初期値の比較で判定する

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