Old-School Front-End / 2026 ・ No.03
いにしえのフロントエンド入門
第3回 フォーム ── 申請フォームを HTML だけで作り、何が送られるかを見る
公開
AI に頼まず、一行ずつ手で書く「いにしえの」フロントエンド入門。第3回は、業務システムの本丸フォームです。
申請一覧の「+ 新規申請」の先にある申請フォームを、今回も HTML だけで作ります。
入力欄の名前の付け方、部品の選び方、ブラウザがしてくれる入力チェック。そして最後に、実際に送信して、
ボタンを押した瞬間に何が送られているのかを、のぞいてみます。
今回のゴール
新井先輩、申請フォームも AI に作ってもらいました! 入力欄の中に薄い字で「件名」って出るの、おしゃれですよね。
古井ほう。で、件名を入力したら、その薄い字はどうなる?
新井消えます。……あれ、じゃあ入力したあとは、何の欄だったか分からない?
古井そういうことだ。今日はフォームの「名札」と「中身」の話をしよう。
いまここ:第3回
今回作るのは form.html、申請フォームのページです。中身は次のとおり。
- 申請の内容 ── 件名(必須)
- 支払い方法 ── 立て替え(自分で払った)か、会社のカードか
- 明細 ── 日付・区分・内容・金額・通貨を3行まで(行を増やせるようにするのは第8回)
- 領収書 ── 画像か PDF。合計3万円以上なら必須(このルールを守らせるのは第9回)
- 備考と確認 ── 備考の欄、規程を確認したチェック、「下書き保存」「申請する」のボタン
フォームが送られるまで
フォームは <form> で入力欄とボタンを包んで作ります。ボタンを押すと、ブラウザは次の順に動きます。
ボタンを押してから、サーバーに届くまで
| form の属性 | 意味 | 今回の値 |
|---|---|---|
| action | 送り先の URL | /api/expenses(第10回で用意する、仮のサーバー) |
| method | 送り方。get(URL の後ろに付ける)か post(本文に入れる) | post |
| enctype | 本文の詰め方。ファイルを送るときは multipart/form-data が必要 | multipart/form-data |
使い分けの目安は、データを「見るだけ」なら GET、「登録・変更する」なら POST。検索のように同じ URL で同じ結果を見たいなら GET が便利ですが、申請の登録は POST にします。GET では入力した中身が URL にそのまま残り、ブラウザの履歴にも記録されるからです。
名前を付ける:label と name
入力欄には、二種類の「名前」があります。一つは人に見せる名前(label)、もう一つはサーバーに渡す名前(name)です。
label・id・name の関係
冒頭で新井くんが喜んでいた「入力欄の中の薄い字」は placeholder という属性です。label の代わりに使うと、どうなるでしょう。
見た目だけでなく、ブラウザが作るアクセシビリティの木も比べてみました(Playwright で取り出した実際の出力)。
# ① placeholder だけ - paragraph: - textbox "件名": 大阪出張(交通費・宿泊費) - paragraph: - textbox "内容" - paragraph: - spinbutton - paragraph: - button "申請する" # ② label を付けた版 - paragraph: - text: 件名 - textbox "件名": 大阪出張(交通費・宿泊費) - paragraph: - text: 内容 - textbox "内容" - paragraph: - text: 金額 - spinbutton "金額" - paragraph: - button "申請する"
Chrome は placeholder を名前の代わりに使ってくれることもありますが、①の金額の欄(type="number")では名前が消えて、ただの spinbutton になりました。W3C のアクセシビリティの手引きも、placeholder は label の代わりにならないとしています。入力を始めると消える、薄い色で読みにくい、支援技術によって扱いがまちまち、が理由です。
placeholder は「例:2026年10月 大阪出張」のような入力例のヒントに使い、欄の名前は必ず label で付けましょう。AI が作るフォームには placeholder だけのものがよくあります。「label はある?」は、フォームを読むときの最初の確認ポイントです。
入力の部品
入力欄の多くは <input> で、type 属性で種類を切り替えます。経費精算で使う部品を並べました。
フォームの部品
| 部品 | よく使う属性 | 使いどころのコツ |
|---|---|---|
| type="text" | maxlength、autocomplete | 件名・内容。autocomplete でブラウザの自動入力の扱いを指定できる |
| type="number" | min、max、step | 金額。スマホでは数字のキーボードが出る。step="1" で小数を止める |
| type="date" | min、max | 日付。カレンダーで選べ、書き方の揺れ(10/15、10月15日…)がなくなる |
| select と option | value | 区分・通貨。選択肢が多い・決まっているとき。先頭に「選んでください」を置く |
| type="radio" | name、checked | 支払い方法。同じ name のものが一組になり、どれか一つしか選べない |
| type="checkbox" | value、checked | 規程の確認。チェックしないと何も送られない(九章で確認) |
| textarea | rows、maxlength | 備考。終了タグがあり、中身が初期値になる |
| fieldset と legend | ― | 関係する欄をひとまとめにして、まとまりに名前を付ける。ラジオボタンの組には特に大事 |
新井日付も、ふつうの text でよくないですか? 「10/15」って打てばいいし。
古井経理の人が「10/15」「10月15日」「2026.10.15」を並べて集計する姿を想像してみろ。
新井……date にします。
明細の表と、ファイル
明細は、前回の table の中に入力欄を入れて作ります。行と列のあるデータなので、表にするのが自然です。
<tr> <td><input type="date" name="date" aria-label="1行目の日付" required></td> <td><select name="category" aria-label="1行目の区分" required>…</select></td> <td><input type="text" name="item" aria-label="1行目の内容" required></td> <td><input type="number" name="amount" aria-label="1行目の金額" min="1" step="1" required></td> <td><select name="currency" aria-label="1行目の通貨">…</select></td> </tr>
- aria-label ── 表の中の入力欄は、横に label を置く場所がない。列の見出しは目に見えているが、読み上げ用の名前を aria-label で付けておく(「1行目の金額」など)
- name は3行とも同じ ── name="amount" が3つあると、サーバーには amount が3つ並んで届く。何行目かは順番で分かる(九章で実物を見る)
- required は1行目だけ ── 明細は最低1行。2・3行目は空でもよい
領収書は type="file" です。
<input type="file" id="receipt" name="receipt" accept="image/*,.pdf" multiple>
accept はファイルを選ぶ画面に出す種類の絞り込み(画像と PDF)、multiple は複数のファイルをまとめて選べるようにする指定です。ファイルの中身を送るには、form に method="post" と enctype="multipart/form-data" が両方必要です。片方が欠けると、届くのはファイル名だけになります。
ボタンの種類
ボタンは <button> で作ります。type で働きが変わります。
button の type
今回のフォームには、送信ボタンが二つあります。どちらも name="action" で、value が draft(下書き保存)と submit(申請する)。押したボタンの value だけが送られるので、サーバーは「下書きか、申請か」を見分けられます。下書き保存には formnovalidate を付けて、必須の欄が空でも保存できるようにしました。
古井昔は、どのフォームにも「リセット」ボタンが付いていたもんだ。
新井明細を10行入れたあとに押したら……。
古井そう、すべて無に帰す。いにしえの罠だ。業務システムには置かない。
申請フォームを組み立てる
ここまでの部品を組み立てた form.html の全体です。header と footer は前回の index.html と同じものを使っています。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>新規申請 | 経費精算システム</title>
</head>
<body>
<header>
<p><a href="/"><img src="img/logo.svg" alt="" width="32" height="32"> 経費精算システム</a></p>
<nav>
<ul>
<li><a href="/">申請一覧</a></li>
<li><a href="approve.html">承認</a></li>
<li><a href="dashboard.html">ダッシュボード</a></li>
</ul>
</nav>
<p>新井 太郎(申請者)</p>
</header>
<main>
<h1>新規申請</h1>
<p><a href="/">← 申請一覧へ戻る</a></p>
<form action="/api/expenses" method="post" enctype="multipart/form-data">
<fieldset>
<legend>申請の内容</legend>
<p>
<label for="title">件名(必須)</label><br>
<input type="text" id="title" name="title" required maxlength="50">
</p>
</fieldset>
<fieldset>
<legend>支払い方法</legend>
<label><input type="radio" name="payment" value="personal" checked> 立て替え(自分で払った)</label>
<label><input type="radio" name="payment" value="corporate"> 会社のカード</label>
</fieldset>
<fieldset>
<legend>明細</legend>
<table>
<caption>明細(3行まで。行を増やすのは第8回)</caption>
<thead>
<tr><th scope="col">日付</th><th scope="col">区分</th><th scope="col">内容</th><th scope="col">金額</th><th scope="col">通貨</th></tr>
</thead>
<tbody>
<tr>
<td><input type="date" name="date" aria-label="1行目の日付" required></td>
<td><select name="category" aria-label="1行目の区分" required><option value="">選んでください</option><option value="transport">交通費</option><option value="hotel">宿泊費</option><option value="meeting">会議・交際費</option><option value="supplies">消耗品</option><option value="other">その他</option></select></td>
<td><input type="text" name="item" aria-label="1行目の内容" required></td>
<td><input type="number" name="amount" aria-label="1行目の金額" min="1" step="1" required></td>
<td><select name="currency" aria-label="1行目の通貨"><option value="JPY">円</option><option value="USD">ドル</option></select></td>
</tr>
<tr>
<td><input type="date" name="date" aria-label="2行目の日付"></td>
<td><select name="category" aria-label="2行目の区分"><option value="">選んでください</option><option value="transport">交通費</option><option value="hotel">宿泊費</option><option value="meeting">会議・交際費</option><option value="supplies">消耗品</option><option value="other">その他</option></select></td>
<td><input type="text" name="item" aria-label="2行目の内容"></td>
<td><input type="number" name="amount" aria-label="2行目の金額" min="1" step="1"></td>
<td><select name="currency" aria-label="2行目の通貨"><option value="JPY">円</option><option value="USD">ドル</option></select></td>
</tr>
<tr>
<td><input type="date" name="date" aria-label="3行目の日付"></td>
<td><select name="category" aria-label="3行目の区分"><option value="">選んでください</option><option value="transport">交通費</option><option value="hotel">宿泊費</option><option value="meeting">会議・交際費</option><option value="supplies">消耗品</option><option value="other">その他</option></select></td>
<td><input type="text" name="item" aria-label="3行目の内容"></td>
<td><input type="number" name="amount" aria-label="3行目の金額" min="1" step="1"></td>
<td><select name="currency" aria-label="3行目の通貨"><option value="JPY">円</option><option value="USD">ドル</option></select></td>
</tr>
</tbody>
</table>
</fieldset>
<fieldset>
<legend>領収書</legend>
<p>
<label for="receipt">領収書の画像または PDF(合計3万円以上は必須)</label><br>
<input type="file" id="receipt" name="receipt" accept="image/*,.pdf" multiple>
</p>
</fieldset>
<p>
<label for="note">備考</label><br>
<textarea id="note" name="note" rows="3" cols="40" maxlength="500"></textarea>
</p>
<p>
<label><input type="checkbox" name="agree" value="yes" required> 経費規程を確認しました(必須)</label>
</p>
<p>
<button type="submit" name="action" value="draft" formnovalidate>下書き保存</button>
<button type="submit" name="action" value="submit">申請する</button>
</p>
</form>
</main>
<footer>
<p><small>© 2026 KIJI 経費精算システム(練習用)</small></p>
</footer>
</body>
</html>
前回の申請一覧の「+ 新規申請」のリンク先が、このページです。一覧から行き来できるようになりました。
ブラウザの検査を試す
何も入力せずに「申請する」を押すと、ブラウザが最初の空の欄に吹き出しを出して、送信を止めます。各欄のメッセージを、ブラウザから取り出してみました(日本語設定の Chromium の実際の文言)。
件名(必須) → このフィールドを入力してください。 1行目の日付 → このフィールドを入力してください。 1行目の区分 → リスト内の項目を選択してください。 1行目の内容 → このフィールドを入力してください。 1行目の金額 → このフィールドを入力してください。 経費規程を確認しました(必須) → 次に進むにはこのチェックボックスをオンにしてください。 金額に 0 → 値は 1 以上にする必要があります。 金額に 12.5 → 有効な値を入力してください。有効な値として最も近いのは 12 と 13 です。
JavaScript を1行も書いていないのに、これだけのチェックが働きます。HTML だけでできる検査は次のとおりです。
HTML の検査と、その限界
ブラウザの検査は利用者への親切であって、守りではありません。開発者ツールで required を消せば、空のまま送れてしまいます。セキュリティの手引きとして定番の OWASP も「クライアント側の検査は回避できる。サーバーでも必ず検査を」としています。AI に「入力チェックを付けて」と頼んで画面側だけに付いた場合も同じです。お金を扱うシステムでは、サーバー側の検査が本番だと覚えておきましょう。
何が送られるのか
最後に、ボタンを押した瞬間に何が送られているのかを見てみます。まず、送り方を GET に変えて送ったときの URL です(送り先を /echo に変えて試した実際の URL。前半だけ表示し、下はそれを読める形に戻したもの)。
/echo?title=%E5%A4%A7%E9%98%AA%E5%87%BA%E5%BC%B5%EF%BC%88%E4%BA%A4%E9%80%9A%E8%B2%BB%E3%83%BB%E5… title = 大阪出張(交通費・宿泊費) payment = personal date = 2026-10-15 category = transport item = 新幹線(東京→新大阪) amount = 14720 currency = JPY …(2・3行目も date・category・item・amount・currency の同じ名前で続く)… receipt = receipt.pdf note = 取引先A社との打ち合わせのため agree = yes action = submit
name と、入力した値が name=value の組になって、URL の後ろに & でつながっています。日本語は %E5%A4… のような形に変換(URL エンコード)されます。そして、receipt は「receipt.pdf」という名前だけ。GET ではファイルの中身は送れません。
次に、本来の POST(multipart/form-data)で送ったときに、サーバーに届いた本文です。クラウド上に立てた、受け取った中身を記録するだけの小さなサーバーで受けました(長い部分は省略)。
POST /api/expenses HTTP/1.1 Content-Type: multipart/form-data; boundary=----WebKitFormBo… ------WebKitFormBoundarykG… Content-Disposition: form-data; name="title" 大阪出張(交通費・宿泊費) ------WebKitFormBoundarykG… Content-Disposition: form-data; name="payment" personal …(明細の date・category・item・amount・currency が3行ぶん続く)… ------WebKitFormBoundarykG… Content-Disposition: form-data; name="receipt"; filename="receipt.pdf" Content-Type: application/pdf %PDF-1.4 % demo receipt %%EOF ------WebKitFormBoundarykG… Content-Disposition: form-data; name="note" 取引先A社との打ち合わせのため ------WebKitFormBoundarykG… Content-Disposition: form-data; name="agree" yes ------WebKitFormBoundarykG… Content-Disposition: form-data; name="action" submit ------WebKitFormBoundarykG…--
欄ごとに区切り線(boundary)で仕切られ、receipt の部分にはファイルの中身そのものが入っています。最後に、何も入力せずに「下書き保存」を押したときに届いたものです(読める形に整理)。
title = (空) payment = personal date = (空) category = (空) item = (空) amount = (空) currency = JPY receipt = (空) note = (空) action = draft …明細の2〜3行目も空のまま。agree(チェックボックス)は送られていない
- 検査を飛ばして届いた ── formnovalidate のおかげで、必須の欄が空でも送られた。サーバーは action=draft を見て「下書きなので空でもよい」と判断する
- 空の欄も「空」として届く ── title や amount は、値が空のまま名前だけ届く
- チェックしていないチェックボックスは、名前ごと届かない ── agree が丸ごと消えている。サーバーは「agree がない=チェックされていない」と読む必要がある
新井ボタン一つで、こんなものが飛んでいたんですね……。
古井中身を知っていれば、サーバー側の人とも話が通じる。「agree が来ないんですけど」と言われても、もう慌てないだろう?
結 — 一枚まとめと次回予告
- フォームは form で包み、action(送り先)と method(送り方)を決める。登録は POST、ファイルを送るなら enctype="multipart/form-data"。
- 入力欄には label(人に見せる名前)と name(サーバーに渡す名前)を付ける。placeholder は label の代わりにならない。
- 部品は中身に合った type を選ぶ。金額は number、日付は date、一つを選ぶなら radio か select。関係する欄は fieldset でまとめる。
- required・min・step などでブラウザが入力をチェックしてくれる。ただしそれは親切であって守りではない。サーバーでも必ず検査する。
- 送られるのは name=value の組。空の欄は空で届き、チェックしていないチェックボックスは届かない。下書き保存は formnovalidate で検査を飛ばす。
新井HTML の骨組み、これで一通りそろいました! でも、見た目がまだ平成どころか昭和ですね……。
古井次回からいよいよ CSS、服を着せる番だ。第4回はセレクタとボックス。一覧を自前の CSS で整えるぞ。
参考
- MDN — <input>: The HTML Input element
- MDN — <label>: The Label element
- MDN — Sending form data
- MDN — Client-side form validation
- MDN — HTMLButtonElement: formNoValidate property
- MDN — HTML attribute: autocomplete
- W3C WAI — Forms Tutorial: Labeling Controls
- W3C WAI — Forms Tutorial: Form Instructions
- OWASP — Input Validation Cheat Sheet
- WHATWG — HTML Living Standard: Forms
ページの写真は、筆者が作ったデモページを2026年10月17日にクラウド上の Chromium(Playwright)で撮影したものです。検証メッセージは日本語設定の Chromium から取り出した実際の文言、GET の URL・POST の本文・下書き保存の中身は、実際にフォームを送信して記録したものです(POST はクラウド上に立てた記録用の小さなサーバーで受信)。アクセシビリティの木は Playwright の ariaSnapshot() の出力です。ブラウザによって検証メッセージの文言や placeholder の扱いは異なります。登場人物は架空の人物です。
いにしえのフロントエンド入門 第3回 — フォーム:申請フォームを HTML だけで作り、何が送られるかを見る / 2026.10