Old-School Front-End / 2026 ・ No.12
いにしえのフロントエンド入門
第12回 承認の流れ ── ダイアログで確かめて、ボタン一つで状態を進める
公開
AI に頼まず、一行ずつ手で書く「いにしえの」フロントエンド入門。第12回は承認の流れです。
前回、古井さんの承認の画面に、新井さんのニューヨーク出張が並びました。でも押すボタンがありません。
今回は <dialog> で申請の中身を確かめる窓を作り、承認・差し戻しを JSON でサーバーへ送ります。
そして、二人の承認者が同じ申請を同時に押したらどうなるかを、実際に起こして確かめます。
今回のゴール
新井先輩、ニューヨーク出張の承認、まだですか? 一覧には出てるんですよね?
古井出てはいる。だが押すボタンがない。今日はそのボタンを作る。
新井「承認」ボタンを一つ置くだけですよね。AI なら3秒です。
古井置くのは3秒。困るのは、押したあとだ。押し間違えたら? 差し戻すなら理由は? 石田さんも同じ申請を押したら?
新井……石田さんって、だれでしたっけ。
古井今日から来た、もう一人の承認者だ。
いまここ:第12回
- 確かめてから押す ── 一覧の「確認する」で <dialog> を開き、申請の中身を見てから「承認する」か「差し戻す」
- 差し戻しには理由 ── コメントが空なら送らない。サーバーでも同じことを確かめる
- 同時に押されても壊れない ── 読んだときの「版」を添えて送り、先を越されていたらサーバーが 409 で断る
- 申請者には履歴 ── だれが・いつ・なぜ差し戻したのかを、新井さんの一覧から開ける
相手は前回の練習用サーバーに、承認の API と履歴を足したものです。承認者は古井 一郎さんに、石田 美咲さん([email protected] / demo-ishida)が加わりました。どちらもデモ専用の架空のアカウントです。差し戻された申請を直して出し直す流れは、今回は省きます。
申請の状態と矢印
ボタンを作る前に、申請がどんな状態を通るのかを地図にします。申請のデータには、これまでも status(状態)がありました。下書き・申請中・承認済み・精算済み・差し戻し。今回作るのは、そのうち申請中から出る2本の矢印です。
状態は箱、操作は矢印。今回作るのは赤い2本
この地図から、画面のきまりが2つ出てきます。
- ボタンは、いまの状態から出ている矢印だけ ── 承認待ちの一覧に並ぶのは申請中のものだけなので、どの行にも「確認する」を付けてよい。承認済みの申請に「承認する」は出さない
- 矢印を通してよいかは、サーバーが決める ── 画面にボタンがなくても、fetch は打てる(第11回の「隠すのと断るのは別」)。サーバーは「いまの状態」と「押した人」を見て、通すか断るかを決める
新井精算済みから「やっぱり差し戻し」はできないんですね。
古井お金が出たあとで差し戻されたら、経理が泣く。矢印がない、とはそういうことだ。
<dialog> で確かめる窓
「確認する」を押したら、画面の上に小さな窓を開いて、申請の中身とコメント欄とボタンを見せます。こういう窓をモーダルと呼びます。昔は div を重ねて自作したものですが、いまは HTML に <dialog> という専用の要素があります。
<dialog id="decide" aria-labelledby="decide-h">
<h2 id="decide-h">申請を確認する <span id="d-no"></span></h2>
<dl>
<dt>申請者</dt><dd id="d-who"></dd>
<dt>申請日</dt><dd id="d-date"></dd>
<dt>件名</dt><dd id="d-title"></dd>
<dt>金額</dt><dd id="d-amount"></dd>
</dl>
<label for="decide-comment">コメント(差し戻すときは必須)</label>
<textarea id="decide-comment" rows="3"></textarea>
<p id="decide-error" role="alert" hidden></p>
<div>
<button type="button" id="d-close">閉じる</button>
<button type="button" id="d-return">差し戻す</button>
<button type="button" id="d-approve">承認する</button>
</div>
</dialog>
中身は、見出し・説明リスト(dl)・label と textarea・ボタン。どれも、ふつうの HTML です。ボタンはどれもフォームを送らないので type="button"。エラーの欄は第9回と同じく role="alert" にしておき、出たら読み上げられるようにします。
開くのは JavaScript です。一覧のボタンは行ごとに作り直されるので、第8回のイベントの委任で、tbody がまとめてクリックを受けます。
// ボタンは tbody で受ける(第8回のイベントの委任)
document.querySelector('#approvals').addEventListener('click', (event) => {
const button = event.target.closest('button[data-id]');
if (button) openDecision(claims.find((x) => x.id === button.dataset.id));
});
/** ダイアログに申請を入れて開く */
function openDecision(claim) {
current = claim;
document.querySelector('#d-no').textContent = claim.id;
document.querySelector('#d-who').textContent = claim.ownerName;
document.querySelector('#d-date').textContent = claim.date;
document.querySelector('#d-title').textContent = claim.title;
document.querySelector('#d-amount').textContent = formatYen(claim.amount);
comment.value = '';
decideError.hidden = true;
dialog.showModal(); // showModal:後ろの画面は操作できなくなる
}
ダイアログの開き方は2つあります。show() はただ表示するだけ。showModal() はモーダルとして開き、ブラウザがいくつもの面倒を引き受けてくれます。
showModal() を呼ぶだけで、ブラウザがやってくれる4つのこと
暗くするのは CSS の ::backdrop です。見た目は Bootstrap に合わせて、枠を消し、角を丸め、影を付けただけです。
.app-dialog {
width: min(32rem, calc(100% - 2rem));
border: 0;
border-radius: .5rem;
padding: 1.5rem;
box-shadow: 0 1rem 3rem rgba(0, 0, 0, .25);
}
.app-dialog::backdrop { /* 後ろの画面を暗くする幕 */
background: rgba(20, 30, 40, .45);
}
ダイアログを試す
「やってくれる」と書きましたが、本当でしょうか。Chromium で開いて、キーボードとマウスの代わりに Playwright で突いてみました。
# <dialog> の振る舞い(Chromium で実測) したこと 結果 showModal() で開く フォーカスはコメント欄(textarea)へ ログアウトのボタンの位置を調べる 当たるのは DIALOG。後ろの画面には触れない Esc キーを押す ダイアログが閉じた(open = false) 閉じたあとのフォーカス 開いたボタン(EXP-2026-0007 の「確認する」)へ戻った 理由なしで「差し戻す」 「差し戻すときは、理由をコメントに書いてください。」
- 開いた瞬間、フォーカスはコメント欄へ。すぐに書き始められる
- ヘッダーの「ログアウト」の位置を document.elementFromPoint() で調べると、当たったのはダイアログ(の後ろの幕)。後ろのボタンはクリックできない
- Esc で閉じ、フォーカスは開いたときの「確認する」(0007 の行)に戻った。キーボードで操作している人が、ページの先頭まで飛ばされない
div を重ねる自作モーダルでは、この「後ろを触らせない」「フォーカスを中へ」「Esc で閉じる」「元へ戻す」を、全部自分で書くことになります。いにしえの人々は、これで何度も泣きました。ブラウザに専用の部品があるなら、まずそれを使う。手で書くのは、部品にないところだけです。
承認・差し戻しを送る
「承認する」「差し戻す」を押したら、サーバーに伝えます。送り先は POST /api/expenses/EXP-2026-0012/decision。中身は、決めたこと・コメント・読んだときの版(六で使います)の3つです。
第10回では、申請フォームを FormData で送りました。今回はフォームではなく、こちらで組み立てた小さなデータなので、JSON にして送ります。ここに一つ、落とし穴があります。
FormData と JSON では、Content-Type の扱いが逆
# body に何を渡すと、Content-Type はどうなるか(Chromium で実測) body に渡したもの ブラウザが付けた Content-Type JSON.stringify(…)(ただの文字列) text/plain;charset=UTF-8 → 練習用サーバーは 415 で断った(JSON 以外は受け付けない) new FormData(…)(第10回) multipart/form-data; boundary=…
第10回では「Content-Type は自分で書くな」でした。区切りの印(boundary)をブラウザが決めるからです。JSON は逆で、JSON.stringify() が作るのはただの文字列なので、ブラウザは text/plain を付けます。JSON だと伝えたければ、自分で書く。
/** 承認・差し戻しをサーバーへ送る */
async function decide(decision) {
decideError.hidden = true;
if (decision === 'return' && comment.value.trim() === '') { // 差し戻しは理由が必須(サーバーでも同じチェック)
decideError.textContent = '差し戻すときは、理由をコメントに書いてください。';
decideError.hidden = false;
comment.focus();
return;
}
const buttons = dialog.querySelectorAll('button');
for (const b of buttons) b.disabled = true;
try {
const res = await fetch(`/api/expenses/${current.id}/decision`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }, // JSON を送るときは Content-Type を自分で書く(FormData のときとは逆)
body: JSON.stringify({ decision, comment: comment.value, version: current.version }), // 読んだときの版。ほかの人が先に変えていたら 409
});
const no = current.id.slice(-4);
if (res.status === 409) { // 409 = ほかの人が先に処理した
const body = await res.json();
const by = body.by, what = WHAT[body.status];
dialog.close();
pageStatus.className = 'alert alert-warning';
pageStatus.textContent = `${no} は、${by}さんが先に${what}していました。一覧を新しくしました。`;
await loadApprovals();
pageStatus.focus(); // 押したボタンは一覧ごと消えたので、結果のメッセージへフォーカスを移す
return;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
dialog.close();
pageStatus.className = 'alert alert-success';
pageStatus.textContent = decision === 'approve' ? `${no} を承認しました。` : `${no} を差し戻しました。`;
await loadApprovals();
pageStatus.focus(); // 押したボタンは一覧ごと消えたので、結果のメッセージへフォーカスを移す
} catch (error) {
decideError.textContent = '送れませんでした。通信の状態を確かめて、もう一度お試しください。';
decideError.hidden = false;
} finally {
for (const b of buttons) b.disabled = false; // うまくいっても、失敗しても、ボタンを戻す
}
}
- 差し戻しで理由が空なら、送らずにダイアログの中で知らせる(第9回の入力チェックと同じ考え)
- 送っている間は、ダイアログのボタンを全部 disabled に。二度押しで2回送らないため(第10回)。finally で必ず戻す
- うまくいったら、ダイアログを閉じて、緑の帯で知らせて、一覧を読み直す
最後の pageStatus.focus() は、試していて見つけたものです。最初はこの行がありませんでした。承認すると一覧が作り直され、押した「確認する」のボタンは表ごと消えます。ダイアログはフォーカスを開いたボタンへ戻そうとしますが、そのボタンはもうありません。調べると、フォーカスは BODY、つまりページの先頭に落ちていました。キーボードで操作している人は、どこにいるのか分からなくなります。そこで、結果のメッセージ(<p id="page-status" role="status" tabindex="-1">)にフォーカスを移しました。直したあとは page-status にいることを確かめています。
新井マウスで使ってたら、一生気づかないやつですね。
古井だから Tab キーで一度は使ってみる。いにしえの作法だ。
二人が同時に押したら
承認者が二人になりました。古井さんが承認の画面を開いたまま、お茶を淹れに行きます。その間に、石田さんが 0007 を承認しました。戻ってきた古井さんの画面には、まだ 0007 が「申請中」で並んでいます。古井さんがそれを差し戻したら……?
何も考えずに作ると、サーバーは後から来た「差し戻し」で上書きします。石田さんの承認は、だれにも知られずに消えます。新井さんから見ると、承認されたはずの申請が、いつの間にか差し戻しに。これは困る。
読んだときの版を添えて送る。違っていたら、サーバーは断る
そこで、申請のデータに版(version)を持たせます。変わるたびに 1 増える番号です。画面は一覧を読んだときの版を覚えておき、送るときに添えます。サーバーは、届いた版が今の版と違えば、「だれかが先に変えた」と分かるので、上書きせずに 409 Conflict で断ります。返事には、いまの状態と、だれが変えたかを入れておきます。
こうしたやり方を楽観的ロックと呼びます。「たぶんだれともぶつからない」と楽観して、ぶつかったときだけ断る。開いた瞬間に鍵をかけて、ほかの人を待たせるやり方(悲観的ロック)もありますが、お茶を淹れに行った人がいると、全員が待たされます。
サーバーに、いろいろ送ってみました。
# 承認の API に、いろいろ送ってみる(古井さんで。Chromium の fetch で実測) 送ったもの 状態コード サーバーの返事 JSON の文字列。Content-Type なし 415 send JSON {decision: approve}(JSON ではない) 400 bad JSON decision が maybe 400 bad decision 差し戻し。コメントは空白だけ 400 a reason is required 版が古い(version 0) 409 changed by someone else 承認(version 1) 200 status: approved, version: 2 もう一度、同じものを送る 409 changed by someone else 申請者(新井さん)が送る 403 approvers only
- Content-Type がないと 415、JSON として読めないと 400、決めたことが変なら 400、理由が空白だけでも 400。画面のチェックを抜けてきても、サーバーで止まる
- 版が古いと 409。同じものを2回送っても、2回目は 409(版が上がっているから)。二度押しの守りにもなっている
- 申請者の新井さんが送ると 403(第11回)
409 が返ってきたら、画面はダイアログを閉じ、黄色い帯で「0007 は、石田 美咲さんが先に承認していました。一覧を新しくしました。」と知らせて、一覧を読み直します。だれが、どうしたのかを言うのがコツです。「エラーが発生しました」だけでは、古井さんはもう一度押してしまいます。
新井お茶を淹れてる間に、世界は変わってるんですね。
古井画面に出ているのは、読んだときの世界だ。今の世界じゃない。それを忘れないことだ。
履歴を残して見せる
差し戻された新井さんが知りたいのは、「なぜ?」です。そこで、申請ごとに出来事の記録(history)を残します。状態は書きかえますが、記録は消さずに1行ずつ書き足します。
状態は書きかえ、履歴は書き足す
時刻はサーバーが付けます。ブラウザの時計は、人によってずれていることがあるからです。サーバーは 2026-10-17T01:06:00.000Z(日本時間の 10:06)のような、時差まで書いた世界共通の書き方(ISO 8601)で保存し、画面が見せるときに、日本時間で、その言語の書き方に直します。
/** 時刻は日本時間で、その言語の書き方に */
function formatTime(iso) {
return new Date(iso).toLocaleString('ja-JP', { timeZone: 'Asia/Tokyo', dateStyle: 'medium', timeStyle: 'short' });
}
/** 申請の履歴をダイアログで見せる */
async function openHistory(id) {
const res = await fetch(`/api/expenses/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const claim = await res.json();
document.querySelector('#h-claim').textContent = `${claim.id} ${claim.title}`;
const items = claim.history.map((h) => {
const li = document.createElement('li');
li.className = 'mb-2';
const head = document.createElement('div');
head.textContent = `${formatTime(h.at)} ${ACT[h.action]} ${h.byName}`;
li.append(head);
if (h.comment) {
const note = document.createElement('div');
note.className = 'small text-body-secondary';
note.textContent = h.comment;
li.append(note);
}
return li;
});
document.querySelector('#h-list').replaceChildren(...items);
historyDialog.showModal();
}
コメントは新井さんではなく承認者が書いたものなので、innerHTML ではなく textContent で入れます(第8回の XSS の話)。timeZone: 'Asia/Tokyo' を指定しているので、パソコンがどの国の時間になっていても、日本時間で出ます。
# 同じ時刻を、3つの言語で(toLocaleString。Chromium で実測) 言語 0012 の承認 0005 の申請 ja-JP 2026/10/17 10:06 2026/10/09 9:30 zh-CN 2026年10月17日 10:06 2026年10月9日 09:30 en-US Oct 17, 2026, 10:06 AM Oct 9, 2026, 9:30 AM
同じ瞬間でも、言語によって書き方が違います。日本語は時の前に 0 を付けず「9:30」、中国語は「09:30」、英語は午前・午後を付けて「9:30 AM」。自分で getHours() をつないで作ると、こういう違いを全部覚えておくことになります。ここもブラウザに任せます。
動かしてみる
練習用サーバーを立て、古井さん・石田さん・新井さんの3つのブラウザで、Playwright で実際に操作しました。古井さんの画面は、石田さんが承認する前に開いたままにしてあります。
# 実際に操作して記録した値(Chromium。サーバーは練習用) だれが したこと 画面に出たこと 古井 承認の画面を開く 承認待ち 3 件:0007, 0010, 0012 古井 0012 を理由なしで差し戻す 差し戻すときは、理由をコメントに書いてください。 古井 0012 を承認 0012 を承認しました。(残り 2 件) 石田 0007 を承認 0007 を承認しました。(残り 1 件) 古井 (古い一覧のまま)0007 を差し戻す 0007 は、石田 美咲さんが先に承認していました。(409) 新井 申請一覧を開く 0012 承認済み / 0007 承認済み / 0006 承認済み / 0005 差し戻し … 新井 0005 の履歴を開く 2 行。最後は「2026/10/09 16:40 差し戻し 古井 一郎」
- 理由なしの差し戻しは、ダイアログの中で止まった。サーバーには何も送っていない
- 古井さんが 0012 を承認すると、一覧は2件に。石田さんが 0007 を承認すると、石田さんの一覧は1件に
- 古井さんが古い一覧のまま 0007 を差し戻すと、サーバーが 409 で断り、石田さんの名前つきで知らせた。一覧は読み直されて 0010 だけに
- 新井さんの一覧では 0012 と 0007 が承認済み。0005 の履歴を開くと、古井さんの差し戻しの理由が読めた
承認の流れで、画面がすることは多くありません。確かめてから押させる、押せないものは押させない、断られたら何が起きたかを言う。状態を進めてよいか、先を越されていないかを決めるのは、ここでもサーバーです。
結 — 一枚まとめと次回予告
- 申請は状態を持つ。ボタンは、いまの状態から出ている矢印だけ。通してよいかはサーバーが決める。
- 確かめる窓は <dialog> と showModal()。後ろを触らせない、フォーカスを中へ、Esc で閉じる、元へ戻す、をブラウザがやってくれる。
- 一覧を作り直すと、押したボタンは消える。フォーカスの行き先を自分で決める。
- JSON を送るときは Content-Type: application/json を自分で書く。FormData のときとは逆。
- 版を添えて送り、違えば 409。断られたら、だれが何をしたかを伝えて、一覧を読み直す。
- 履歴は消さずに書き足す。時刻はサーバーが付け、見せるときに toLocaleString で。
新井ニューヨーク出張、承認済みになりました! 46,851 円、いつ振り込まれます?
古井それは経理に聞け。次回は集計と印刷。月ごと・人ごとの合計を出して、紙に印刷しても見やすい画面にする。経理の人が喜ぶ回だ。
新井経理の人に喜ばれたら、振り込みも早くなりますかね。
参考
- MDN — <dialog>: The Dialog element
- MDN — HTMLDialogElement: showModal() method
- MDN — ::backdrop
- W3C WAI-ARIA APG — Dialog (Modal) Pattern
- MDN — Using the Fetch API
- MDN — 409 Conflict
- MDN — HTTP response status codes (400, 403, 415)
- Martin Fowler — Optimistic Offline Lock
- MDN — Date.prototype.toLocaleString()
- MDN — Intl.DateTimeFormat() constructor
ページの写真は、筆者が作ったデモページを、練習用のサーバー(Node.js)経由で2026年10月17日にクラウド上の Chromium(Playwright)で撮影したものです。撮影環境には Hiragino Sans がないため、日本語は Noto Sans CJK JP で表示されています。アカウント・申請・コメントは、練習用サーバーの架空のデータです。ダイアログの振る舞い、画面に出たメッセージ、状態コード、Content-Type、履歴の時刻の書き方、フォーカスの位置は、同じ Chromium を Playwright で操作・実行して記録した実際の値です。各ページの HTML は Nu Html Checker(vnu)でエラーがないことを確かめています。登場人物は架空の人物です。
いにしえのフロントエンド入門 第12回 — 承認の流れ / 2026.10