確認ダイアログの実装

にメンテナンス済み

kintone で業務アプリを運用していると、「重要な操作の前に確認を挟みたい」「処理の完了をユーザーに分かりやすく伝えたい」といった要件が出てきます。標準の window.confirm は手軽ですが、見た目が素っ気なく情報量にも限界があります。

この記事では、kintone カスタマイズにおける確認ダイアログと**カスタム通知(トースト)**の実装パターンを紹介します。ブラウザ標準のダイアログから、CSS を使ったモーダルダイアログ、処理結果の通知表示まで、段階的に解説します。

ブラウザ標準の confirm による確認

保存前の確認ダイアログ

最もシンプルな方法として、submit イベント内で window.confirm を使う方法があります。

confirm-basic.js
(() => {
  'use strict';

  const events = [
    'app.record.create.submit',
    'app.record.edit.submit',
  ];

  kintone.events.on(events, (event) => {
    const record = event.record;
    const amount = Number(record['金額'].value) || 0;

    // 金額が100万円を超える場合に確認
    if (amount > 1000000) {
      const confirmed = confirm(
        `金額が${amount.toLocaleString()}円です。このまま保存しますか?`
      );

      if (!confirmed) {
        event.error = '保存がキャンセルされました';
      }
    }

    return event;
  });
})();
チェック

confirmfalse を返した場合、event.error にメッセージを設定することで保存をキャンセルできます。ユーザーには画面上部にエラーメッセージとして表示されます。

プロセス管理のアクション実行前に確認

承認や差し戻しなどの重要な操作前に確認を入れるパターンです。

confirm-process.js
(() => {
  'use strict';

  const events = [
    'app.record.detail.process.proceed',
    'mobile.app.record.detail.process.proceed',
  ];

  kintone.events.on(events, (event) => {
    const action = event.action.value;
    const nextStatus = event.nextStatus.value;

    // 差し戻しアクションの場合に確認
    if (action === '差し戻す') {
      const confirmed = confirm(
        `ステータスを「${nextStatus}」に変更します。差し戻してよろしいですか?`
      );

      if (!confirmed) {
        event.error = 'アクションがキャンセルされました';
      }
    }

    return event;
  });
})();

カスタムモーダルダイアログ

CSS で装飾したモーダルダイアログ

標準の confirm よりもリッチな見た目で、追加情報やアイコンを含むモーダルダイアログを表示するパターンです。

custom-modal.js
(() => {
  'use strict';

  /**
   * カスタム確認ダイアログを表示する
   * @param { Object } options
   * @param { string } options.title - ダイアログのタイトル
   * @param { string } options.message - メッセージ本文
   * @param { string } [options.confirmText] - 確認ボタンのテキスト(デフォルト: OK)
   * @param { string } [options.cancelText] - キャンセルボタンのテキスト(デフォルト: キャンセル)
   * @returns { Promise<boolean> } ユーザーの選択結果
   */
  const showConfirmDialog = ({ title, message, confirmText = 'OK', cancelText = 'キャンセル' }) => {
    return new Promise((resolve) => {
      // オーバーレイ
      const overlay = document.createElement('div');
      overlay.style.cssText = `
        position: fixed; top: 0; left: 0; width: 100%; height: 100%;
        background: rgba(0, 0, 0, 0.5); z-index: 10000;
        display: flex; align-items: center; justify-content: center;
      `;

      // ダイアログ本体
      const dialog = document.createElement('div');
      dialog.style.cssText = `
        background: #fff; border-radius: 8px; padding: 24px;
        max-width: 420px; width: 90%; box-shadow: 0 4px 24px rgba(0,0,0,0.2);
      `;

      // タイトル
      const titleEl = document.createElement('h3');
      titleEl.textContent = title;
      titleEl.style.cssText = 'margin: 0 0 12px; font-size: 16px; color: #333;';

      // メッセージ
      const messageEl = document.createElement('p');
      messageEl.textContent = message;
      messageEl.style.cssText = 'margin: 0 0 20px; font-size: 14px; color: #666; line-height: 1.6;';

      // ボタンコンテナ
      const buttons = document.createElement('div');
      buttons.style.cssText = 'display: flex; justify-content: flex-end; gap: 8px;';

      // キャンセルボタン
      const cancelBtn = document.createElement('button');
      cancelBtn.textContent = cancelText;
      cancelBtn.style.cssText = `
        padding: 8px 16px; border: 1px solid #ccc; border-radius: 4px;
        background: #fff; cursor: pointer; font-size: 14px;
      `;
      cancelBtn.addEventListener('click', () => {
        overlay.remove();
        resolve(false);
      });

      // 確認ボタン
      const confirmBtn = document.createElement('button');
      confirmBtn.textContent = confirmText;
      confirmBtn.style.cssText = `
        padding: 8px 16px; border: none; border-radius: 4px;
        background: #3498db; color: #fff; cursor: pointer; font-size: 14px;
      `;
      confirmBtn.addEventListener('click', () => {
        overlay.remove();
        resolve(true);
      });

      buttons.appendChild(cancelBtn);
      buttons.appendChild(confirmBtn);
      dialog.appendChild(titleEl);
      dialog.appendChild(messageEl);
      dialog.appendChild(buttons);
      overlay.appendChild(dialog);
      document.body.appendChild(overlay);

      // ESC キーでキャンセル
      const handleKeydown = (e) => {
        if (e.key === 'Escape') {
          overlay.remove();
          document.removeEventListener('keydown', handleKeydown);
          resolve(false);
        }
      };
      document.addEventListener('keydown', handleKeydown);
    });
  };

  // 使用例: ボタンクリック時にカスタムダイアログを表示
  const events = ['app.record.detail.show'];

  kintone.events.on(events, (event) => {
    const space = kintone.app.record.getSpaceElement('アクションボタン');
    if (!space || space.childNodes.length > 0) return event;

    const button = document.createElement('button');
    button.textContent = '一括処理を実行';
    button.className = 'kintoneplugin-button-dialog-ok';

    button.addEventListener('click', async () => {
      const confirmed = await showConfirmDialog({
        title: '一括処理の確認',
        message: 'この操作を実行すると、関連する全てのレコードが更新されます。この操作は取り消せません。',
        confirmText: '実行する',
        cancelText: 'やめる',
      });

      if (confirmed) {
        // 一括処理の実行
        alert('処理を開始します');
      }
    });

    space.appendChild(button);
    return event;
  });
})();
チェック

showConfirmDialogPromise を返すので、async/await で結果を受け取れます。これにより、既存のコードにカスタムダイアログを追加する際も、confirm を置き換えるだけで済みます。

トースト通知(処理完了通知)

画面右上にフェードイン・フェードアウトする通知

処理の完了や軽微なエラーをユーザーに通知するための、トースト型通知コンポーネントです。

toast-notification.js
(() => {
  'use strict';

  /**
   * トースト通知を表示する
   * @param { Object } options
   * @param { string } options.message - 通知メッセージ
   * @param { 'success' | 'error' | 'info' | 'warning' } [options.type] - 通知タイプ
   * @param { number } [options.duration] - 表示時間(ミリ秒、デフォルト: 3000)
   */
  const showToast = ({ message, type = 'info', duration = 3000 }) => {
    const colors = {
      success: { bg: '#d4edda', border: '#28a745', text: '#155724' },
      error: { bg: '#f8d7da', border: '#dc3545', text: '#721c24' },
      info: { bg: '#d1ecf1', border: '#17a2b8', text: '#0c5460' },
      warning: { bg: '#fff3cd', border: '#ffc107', text: '#856404' },
    };
    const color = colors[type] || colors.info;

    const toast = document.createElement('div');
    toast.textContent = message;
    toast.style.cssText = `
      position: fixed; top: 16px; right: 16px; z-index: 10001;
      padding: 12px 20px; border-radius: 6px; font-size: 14px;
      background: ${color.bg}; border-left: 4px solid ${color.border}; color: ${color.text};
      box-shadow: 0 2px 12px rgba(0,0,0,0.15);
      transition: opacity 0.3s ease, transform 0.3s ease;
      opacity: 0; transform: translateX(20px);
    `;

    document.body.appendChild(toast);

    // フェードイン
    requestAnimationFrame(() => {
      toast.style.opacity = '1';
      toast.style.transform = 'translateX(0)';
    });

    // 指定時間後にフェードアウト
    setTimeout(() => {
      toast.style.opacity = '0';
      toast.style.transform = 'translateX(20px)';
      setTimeout(() => toast.remove(), 300);
    }, duration);
  };

  // 使用例: レコード保存成功時に通知
  const successEvents = [
    'app.record.create.submit.success',
    'app.record.edit.submit.success',
  ];

  kintone.events.on(successEvents, (event) => {
    showToast({
      message: 'レコードを保存しました',
      type: 'success',
      duration: 3000,
    });
    return event;
  });
})();

複数の通知をスタック表示

複数のトースト通知を同時に表示する場合、重ならないようにスタック(積み重ね)表示する実装です。

toast-stack.js
(() => {
  'use strict';

  // トーストコンテナを作成
  const createContainer = () => {
    let container = document.getElementById('kintone-toast-container');
    if (!container) {
      container = document.createElement('div');
      container.id = 'kintone-toast-container';
      container.style.cssText = `
        position: fixed; top: 16px; right: 16px; z-index: 10001;
        display: flex; flex-direction: column; gap: 8px;
      `;
      document.body.appendChild(container);
    }
    return container;
  };

  /**
   * スタック対応のトースト通知を表示する
   * @param { string } message - 通知メッセージ
   * @param { 'success' | 'error' | 'info' | 'warning' } [type] - 通知タイプ
   */
  const showToast = (message, type = 'info') => {
    const container = createContainer();

    const colors = {
      success: { bg: '#d4edda', border: '#28a745', text: '#155724', icon: '✓' },
      error: { bg: '#f8d7da', border: '#dc3545', text: '#721c24', icon: '✕' },
      info: { bg: '#d1ecf1', border: '#17a2b8', text: '#0c5460', icon: 'ℹ' },
      warning: { bg: '#fff3cd', border: '#ffc107', text: '#856404', icon: '⚠' },
    };
    const color = colors[type] || colors.info;

    const toast = document.createElement('div');
    toast.innerHTML = `<span style="margin-right: 8px; font-weight: bold;">${color.icon}</span>${message}`;
    toast.style.cssText = `
      padding: 12px 20px; border-radius: 6px; font-size: 14px;
      background: ${color.bg}; border-left: 4px solid ${color.border}; color: ${color.text};
      box-shadow: 0 2px 12px rgba(0,0,0,0.15);
      transition: opacity 0.3s ease; opacity: 0; cursor: pointer;
    `;

    // クリックで閉じる
    toast.addEventListener('click', () => {
      toast.style.opacity = '0';
      setTimeout(() => toast.remove(), 300);
    });

    container.appendChild(toast);

    requestAnimationFrame(() => {
      toast.style.opacity = '1';
    });

    // 5秒後に自動消去
    setTimeout(() => {
      toast.style.opacity = '0';
      setTimeout(() => toast.remove(), 300);
    }, 5000);
  };

  // グローバルに公開(他のスクリプトから利用可能にする)
  window.kintoneToast = showToast;
})();
チェック

window.kintoneToast としてグローバルに公開することで、他のカスタマイズスクリプトからも kintoneToast('メッセージ', 'success') のように呼び出せます。

クリップボードへのコピー通知

レコード ID やフィールド値をクリップボードにコピーし、コピー完了をトーストで通知する実用的な例です。

copy-to-clipboard.js
(() => {
  'use strict';

  const showToast = (message, type = 'info') => {
    const toast = document.createElement('div');
    toast.textContent = message;
    toast.style.cssText = `
      position: fixed; top: 16px; right: 16px; z-index: 10001;
      padding: 12px 20px; border-radius: 6px; font-size: 14px;
      background: ${type === 'success' ? '#d4edda' : '#f8d7da'};
      color: ${type === 'success' ? '#155724' : '#721c24'};
      box-shadow: 0 2px 12px rgba(0,0,0,0.15);
      transition: opacity 0.3s; opacity: 0;
    `;
    document.body.appendChild(toast);
    requestAnimationFrame(() => { toast.style.opacity = '1'; });
    setTimeout(() => {
      toast.style.opacity = '0';
      setTimeout(() => toast.remove(), 300);
    }, 2000);
  };

  /**
   * テキストをクリップボードにコピーする
   * @param { string } text - コピーするテキスト
   * @returns { Promise<void> }
   */
  const copyToClipboard = async (text) => {
    try {
      await navigator.clipboard.writeText(text);
      showToast('クリップボードにコピーしました', 'success');
    } catch {
      // Clipboard API が使えない場合のフォールバック
      const textarea = document.createElement('textarea');
      textarea.value = text;
      textarea.style.cssText = 'position: fixed; top: -9999px;';
      document.body.appendChild(textarea);
      textarea.select();
      document.execCommand('copy');
      textarea.remove();
      showToast('クリップボードにコピーしました', 'success');
    }
  };

  const events = ['app.record.detail.show'];

  kintone.events.on(events, (event) => {
    const space = kintone.app.record.getSpaceElement('コピーボタン');
    if (!space || space.childNodes.length > 0) return event;

    // レコード ID コピーボタン
    const idButton = document.createElement('button');
    idButton.textContent = 'ID をコピー';
    idButton.className = 'kintoneplugin-button-normal';
    idButton.addEventListener('click', () => {
      const recordId = kintone.app.record.getId();
      copyToClipboard(String(recordId));
    });

    // レコード URL コピーボタン
    const urlButton = document.createElement('button');
    urlButton.textContent = 'URL をコピー';
    urlButton.className = 'kintoneplugin-button-normal';
    urlButton.style.marginLeft = '8px';
    urlButton.addEventListener('click', () => {
      copyToClipboard(location.href);
    });

    space.appendChild(idButton);
    space.appendChild(urlButton);
    return event;
  });
})();
Clipboard API のブラウザサポート

navigator.clipboard.writeText は HTTPS 環境(または localhost)でのみ動作します。kintone は HTTPS で提供されるため通常は問題ありませんが、念のため document.execCommand('copy') によるフォールバックを実装しています。

まとめ

  • 標準の window.confirm は手軽だが、表示情報に限界がある
  • カスタムモーダルダイアログは Promise を返す設計にすると async/await で扱いやすい
  • ESC キーやオーバーレイクリックでの閉じる操作も実装すると使い勝手が向上する
  • トースト通知は position: fixed + CSS transition で簡潔に実装できる
  • 複数通知のスタック表示には、コンテナ要素を使って flex-direction: column で配置する
  • navigator.clipboard.writeText を使えば、フィールド値やレコード URL のコピーが容易
  • Clipboard API が使えない環境のフォールバックとして document.execCommand('copy') を用意する

関連記事

スペースフィールドの活用
kintone のスペースフィールドを活用して、ボタン、ステータス表示、入力フォーム、グラフなどのカスタム要素を画面内に配置する方法を解説します。kintone.app.record.getSpaceElement の基本的な使い方から実践
プロセス管理のカスタマイズ
kintone のプロセス管理機能をJavaScriptカスタマイズで拡張する方法を解説します。ステータス変更イベントの活用、ステータスに応じたフィールド制御、プロセス管理 REST API によるステータス操作まで紹介します。
submit イベントのバリデーション
kintone の submit イベント(create.submit / edit.submit)を使って、レコードの保存前にカスタムバリデーションを実装する方法を解説します。フィールド単位のエラー表示、複数フィールドの相関チェック、非同
レコード一覧・詳細画面にボタンを設置するサンプル
kintoneカスタマイズを行う際によく使われる、レコード一覧・詳細画面にボタンを設置する方法を紹介します。

練習問題

カスタム確認ダイアログを Promise で実装する最も大きなメリットはどれですか?

navigator.clipboard.writeText が動作するための条件として正しいものはどれですか?

#kintone #JavaScript #TypeScript