Old-School Front-End / 2026 ・ No.08

いにしえのフロントエンド入門

第8回 JavaScript ② ── DOM とイベント:明細の行を足し、消し、合計をその場で出す

公開

Bootstrap の申請フォーム。明細の表に3行が入り、それぞれに削除ボタン。表の下に「+ 行を追加」のボタンと「合計:42,240 円」。その下に黄色い帯で、合計が3万円以上なので領収書が必要というお知らせ
今回のゴール。行を足し、金額を打つそばから合計が変わり、3万円を超えるとお知らせが出る(筆者のデモページを、クラウド上の Chromium で撮影。Playwright で実際に入力・クリックしたところ。2026年10月17日。ブラウザの枠は説明のために描き足したもの)

AI に頼まず、一行ずつ手で書く「いにしえの」フロントエンド入門。第8回は、いよいよ画面が動き出す回です。
JavaScript から見た HTML の姿(DOM)と、「押された」「打たれた」を受け取る仕組み(イベント)。
第3回で「行を増やすのは第8回」と約束した明細を、追加・削除できるようにし、
第7回の計算の関数につないで、入力したそばから合計が変わるフォームにします。

一

今回のゴール

新井先輩、福岡出張の明細が5行あるんですけど、フォームには3行しか入りません……。

古井第3回で「行を増やすのは第8回」と言ったな。今日がその日だ。

新井AI に頼んだら、画面ごと innerHTML で作り直すコードが出てきて、打ったそばから入力が消えるんです。

古井家具を1つ動かすたびに家を建て直すようなものだな。今日は、部品を1つずつ動かす作法を覚えよう。

いまここ:第8回

1 準備 2 HTML の骨組み 3 フォーム 4 CSS の基本 5 レイアウト 6 Bootstrap 7 JS の基本 8 DOM・イベント 9 入力チェック 10 通信 11 ログイン 12 承認の流れ 13 集計と印刷 14 jQuery 15 仕上げ いまここ 準備 HTML CSS JavaScript 実戦 仕上げ

第6回の Bootstrap 版の申請フォームに、次の動きを付けます。

  • 行を追加・削除 ── 「+ 行を追加」で明細の行が増え、各行の「削除」で消える(最大10行、最低1行)
  • 合計をその場で ── 金額を打つたびに合計が変わる。全角やカンマ入りでも読める(第7回の toAmount)
  • お知らせ ── 合計が3万円以上なら「領収書が必要」、読めない金額や円以外の行があればその旨を出す

新しく作るのは js/form.js。第7回の js/app.js は1行も変えません。

二

DOM:JavaScript から見た HTML

ブラウザは HTML を読むと、第2回で見た「木」の形に組み立てます。この木を JavaScript から触れるようにしたものが DOM(Document Object Model)です。form.js は最初に、触る予定の部品を探して変数に入れておきます。

明細まわりの DOM と、form.js の変数

form fieldset table tbody#items tbody tr input[name=amount] button[data-action=remove] template#row-template template button#add-row addButton output#total totalOutput p#receipt-note receiptNote form.js の変数 document.querySelector('#items') のように、CSS と同じセレクタで部品を探す
const tbody = document.querySelector('#items'); // id が items の要素を1つ
const rows = document.querySelectorAll('#items tr'); // 当てはまる要素をすべて
totalOutput.textContent = '42,240 円'; // 中の文字を書きかえる
receiptNote.hidden = false; // hidden 属性を外して表示する

部品の探し方は、第4回の CSS のセレクタとまったく同じです。#items は id、[name="amount"] は属性。CSS で学んだことが、ここでそのまま役に立ちます。

三

イベント:「押された」を受け取る

ボタンが押された、文字が打たれた、といった出来事がイベントです。addEventListener で「この出来事が起きたら、この関数を呼んで」と頼んでおきます。

addButton.addEventListener('click', () => addRow());
//          ↑ 出来事の種類   ↑ 起きたときに呼ぶ関数

入力欄には、似た名前のイベントが二つあります。違いを測ってみました。

input と change(実測)

14720 と打って、ほかの場所をクリックした(実測) 1 4 7 2 0 欄を離れる input × 5 change × 1 input は1文字ごと、change は欄を離れたときに1回 合計は打つそばから変えたいので、input で受ける

合計は打つそばから変えたいので、input を使います。呼ばれた関数にはイベントの情報(event)が渡され、event.target で「どの部品で起きたか」が分かります。

四

計算と画面を分ける

第7回で、計算の関数をわざわざ画面から切り離しておいたのは、この回のためです。

金額を打ってから、合計が変わるまで

form.js:画面を読む・書く app.js:計算だけ input 金額を打った 1 読む 各行の金額・通貨 2 toAmount 文字 → 金額 3 calcTotal needsReceipt 4 書く 合計・お知らせ 5 画面の仕事と計算の仕事を、ファイルごと分けた app.js は1行も変えていない。第7回のテストはそのまま通る

head では、二つのファイルを defer 付きで読み込みます。defer の script は書いた順に実行されるので、計算の app.js を先に、それを使う form.js を後に書きます。

<script src="js/app.js" defer></script>   <!-- 計算 -->
<script src="js/form.js" defer></script>  <!-- 画面(app.js の関数を使う) -->

新井全部1つのファイルに書いたほうが楽じゃないですか?

古井最初はな。だが合計の計算が間違っていたら、どこを見る? 画面の都合と計算の都合が混ざっていると、探す場所が倍になる。分けておけば、計算は第7回のテストで、画面はブラウザで、別々に確かめられる。

五

行を足す:template と複製

明細の1行は、入力欄が5つとボタンが1つの、けっこう大きな HTML です。これを JavaScript の文字列で組み立てると、読みにくく、間違えやすくなります。そこで、行のひな形を HTML の template 要素に書いておき、JavaScript はそれを複製するだけにします。

    <tbody id="items"></tbody>
  </table>
</div>
<template id="row-template">
  <tr>
    <td><input type="date" class="form-control form-control-sm" name="date" data-label="日付" required></td>
    <td><select class="form-select form-select-sm" name="category" data-label="区分" required>…</select></td>
    <td><input type="text" class="form-control form-control-sm" name="item" data-label="内容" required></td>
    <td><input type="text" class="form-control form-control-sm text-end" name="amount" data-label="金額" inputmode="numeric" autocomplete="off" required></td>
    <td><select class="form-select form-select-sm" name="currency" data-label="通貨">…</select></td>
    <td><button type="button" class="btn btn-outline-danger btn-sm" data-action="remove">削除</button></td>
  </tr>
</template>
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mt-3">
  <button type="button" class="btn btn-outline-primary btn-sm" id="add-row">+ 行を追加</button>
  <p class="mb-0 fs-5">合計:<output id="total" class="fw-bold">0</output></p>
</div>
<p class="alert alert-warning py-2 mt-3 mb-0" id="receipt-note" hidden>合計が3万円以上なので、領収書の添付が必要です。</p>
<p class="small text-body-secondary mt-2 mb-0" id="skip-note" hidden></p>

ひな形を複製して、表に足す

<template> ひな形(画面に出ない) cloneNode <tr> 複製した1行 append tbody#items 足したら、読み上げ用の名前を付け直す(実測) "1行目の金額" "2行目の金額" "3行目の金額" HTML の形は HTML に書いておき、JS は複製して数を合わせるだけ 行を消したときも付け直すので、番号に抜けが出ない
  • template の中は「眠っている」 ── 画面に出ず、フォームの値として送られることもない。複製して表に入れたときに、初めて本物の行になる
  • 読み上げ用の名前は JS で付ける ── 表の中の入力欄には横に label が置けないので、第3回と同じく aria-label を付ける。何行目かは足してみるまで分からないので、足したり消したりするたびに付け直す
  • 足したら、その行へ ── 新しい行の最初の欄にフォーカスを移す。キーボードで操作している人が、そのまま入力を続けられる
六

行を消す:イベントの委任

「削除」ボタンは行ごとにあり、行は後から増えます。ページを開いたときに各ボタンへ addEventListener しても、後から足した行のボタンには付いていません。そこで使うのが、イベントの泡立ち(バブリング)です。

イベントは親へ伝わる

tbody#items tr td button「削除」 親へ伝わる(泡立ち) tbody.addEventListener( 'click', …) 聞き耳は tbody に1つだけ event.target.closest('tr') 押された行を見つける 後から足した行の「削除」を押したら?(実測) ボタンごとに最初に付けた場合 → 3行のまま(反応しない) tbody で受けた場合 → 2行に減る
tbody.addEventListener('click', (event) => {
  const button = event.target.closest('[data-action="remove"]');
  if (button) removeRow(button.closest('tr'));
});

click は、押された部品から親へ親へと伝わっていきます。だから、行を入れる箱(tbody)に1つだけ聞き耳を立てておけば、後から足した行のボタンも拾えます。closest() は「自分から親へたどって、最初に当てはまる要素」を返すので、押された行がすぐ分かります。この書き方をイベントの委任と呼びます。合計の計算の input も、同じく tbody で受けています。

七

合計を表示する

合計の欄には output 要素を使いました。「計算の結果」を表す HTML の要素で、読み上げソフトに「状態のお知らせ」(status)として扱われる役割を持っています。

もう一つ、金額の欄を第3回の type="number" から type="text"(inputmode="numeric" で数字のキーボード)に変えました。理由は、Chromium で実際に打ってみると分かります。

# type="number" の欄に打ち込んだときの value(Chromium で実測)
'14720' → value = '14720'
'14,720' → value = '14720'
'14720' → value = '14720'
'abc' → value = '' 空欄と区別できない

Chromium の number 欄は、打った文字を勝手に直したり捨てたりしたうえで value を渡します(ほかのブラウザで同じとは限りません)。とくに「abc」が空欄と同じ '' になるので、「入力していない」と「数字以外を入れた」を区別できません。そこで、打たれた文字をそのまま受け取り、自分の toAmount で読むことにしました。空欄は数えず、読めない行と円以外の行は合計から外して、その数をお知らせします。

八

textContent と innerHTML

画面に文字を書くとき、form.js では一貫して textContent を使っています。似た道具に innerHTML がありますが、こちらは文字列を HTML として解釈します。利用者が打った文字を innerHTML に入れるとどうなるか、試しました。

2列の表。左の列は innerHTML に入れた結果で、壊れた画像のアイコンと「ホテル1泊」が表示されている。右の列は textContent に入れた結果で、img 要素のタグがそのまま文字として表示されている
同じ文字列を、innerHTML と textContent で画面に入れた結果。左では仕込んだ処理が実際に動いた(筆者のデモページを、クラウド上の Chromium で撮影。Playwright で実際に入力・クリックしたところ。2026年10月17日。ブラウザの枠は説明のために描き足したもの)
const typed = '<img src="x" onerror="window.__xss = 1">ホテル1泊';
a.innerHTML = typed;     // img 要素が作られ、onerror の処理が実行された(__xss が 1 に)
b.textContent = typed;   // ただの文字として表示された

今回はただの印(window.__xss)を立てただけですが、本物の攻撃なら、ここで他人のログイン情報を盗むような処理が動きます(クロスサイトスクリプティング、XSS)。経費精算の「内容」の欄は、申請者が自由に書ける場所です。人が打った文字を画面に出すときは textContent。これは、いにしえから変わらない鉄則です。

POINT

AI が書く画面のコードには、行を丸ごと innerHTML で組み立てるものがよくあります。冒頭の新井くんの「打ったそばから入力が消える」も、合計を出すたびに表全体を innerHTML で作り直していたのが原因でした(作り直すと、打ちかけの欄も新品に置き換わる)。innerHTML を見かけたら、そこに人が打った文字が入らないか、作り直しが要るのかを確かめる。それだけで、多くの事故が防げます。

九

動かしてみる

Playwright で実際に入力・クリックし、そのたびに行数・合計・お知らせ・フォーカスの位置を記録しました。

明細の表の3つの場面。1 1行目に 14720 と入れると合計 14,720 円。2 行を足して3行にし、全角の「12,800」やカンマ入りの「14,720」も入れると合計 42,240 円で、領収書のお知らせが出る。3 2行目を削除すると2行になり、合計 29,440 円、お知らせは消える
①入力、②行の追加、③削除。どの場面でも、合計とお知らせがその場で付いてくる(筆者のデモページを、クラウド上の Chromium で撮影。Playwright で実際に入力・クリックしたところ。2026年10月17日。ブラウザの枠は説明のために描き足したもの)
# 実際に操作して記録した値(Chromium)
操作 行数 合計 領収書 フォーカス
開いた直後 1 0 円 ― ―
1行目を入力(14720) 1 14,720 円 ― 1行目の金額
「+ 行を追加」 2 14,720 円 ― 2行目の日付
2行目に「12,800」 2 27,520 円 ― 2行目の金額
3行目を足して「14,720」 3 42,240 円 必要 3行目の金額
3行目の通貨をドルに 3 27,520 円 ― ―
 └ 円以外の行が 1 行あります(換算は第10回。合計に入れていません)
2行目を「約1万」に 3 29,440 円 ― 2行目の金額
 └ 金額として読めない行が 1 行あります(合計に入れていません)
2行目を「12800」に戻す 3 42,240 円 必要 2行目の金額
2行目を「削除」 2 29,440 円 ― #add-row

フォーカスの列に注目してください。行を足すと新しい行の「日付」へ、行を消すと「+ 行を追加」ボタンへ移っています。消した行にあったフォーカスは行き場を失うので、どこへ戻すかを決めておくのも画面の仕事です。最後に form.js の全体です。

// 経費精算システム:申請フォームの明細(第8回)
// 計算は app.js の関数に任せ、ここでは「画面を読む・書く・動きに反応する」だけを担当する

const MAX_ROWS = 10; // 明細は最大10行

// 画面の部品を、最初に一度だけ探しておく
const tbody = document.querySelector('#items');
const template = document.querySelector('#row-template');
const addButton = document.querySelector('#add-row');
const totalOutput = document.querySelector('#total');
const receiptNote = document.querySelector('#receipt-note');
const skipNote = document.querySelector('#skip-note');

/** 行を1つ足す */
function addRow(moveFocus = true) {
  const row = template.content.firstElementChild.cloneNode(true);
  tbody.append(row);
  renumber();
  if (moveFocus) row.querySelector('input').focus(); // 足した行の最初の欄へ(最初の1行のときは動かさない)
}

/** 行を消す */
function removeRow(row) {
  row.remove();
  if (tbody.rows.length === 0) addRow(false); // 最低1行は残す
  renumber();
  updateTotal();
  addButton.focus(); // 消したら「行を追加」ボタンへ戻る
}

/** 行番号に合わせて、読み上げ用の名前を付け直す */
function renumber() {
  [...tbody.rows].forEach((row, i) => {
    const n = i + 1;
    for (const field of row.querySelectorAll('[data-label]')) {
      const label = field.dataset.label;
      field.setAttribute('aria-label', `${n}行目の${label}`);
    }
    row.querySelector('[data-action="remove"]').setAttribute('aria-label', `${n}行目を削除`);
  });
  addButton.disabled = tbody.rows.length >= MAX_ROWS; // 10行になったら追加できなくする
}

/** 画面から明細を読み、合計とお知らせを表示する */
function updateTotal() {
  const items = [];
  let unreadable = 0;
  let foreign = 0;
  for (const row of tbody.rows) {
    const amount = toAmount(row.querySelector('[name="amount"]').value);
    const currency = row.querySelector('[name="currency"]').value;
    if (amount === null) continue;                          // 空欄は数えない
    if (Number.isNaN(amount)) { unreadable++; continue; }  // 金額として読めない
    if (currency !== 'JPY') { foreign++; continue; }       // 円以外は、まだ足さない
    items.push({ amount });
  }
  const total = calcTotal(items);
  totalOutput.textContent = formatYen(total);
  receiptNote.hidden = !needsReceipt(total);

  const notes = [];
  if (unreadable > 0) notes.push(`金額として読めない行が ${unreadable} 行あります(合計に入れていません)`);
  if (foreign > 0) notes.push(`円以外の行が ${foreign} 行あります(換算は第10回。合計に入れていません)`);
  skipNote.textContent = notes.join(' / ');
  skipNote.hidden = notes.length === 0;
}

// 入力のたびに合計を計算し直す(後から足した行にも効くよう、tbody で受ける)
tbody.addEventListener('input', (event) => {
  const name = event.target.name;
  if (name === 'amount' || name === 'currency') updateTotal();
});

// 「削除」ボタン(これも tbody で受ける)
tbody.addEventListener('click', (event) => {
  const button = event.target.closest('[data-action="remove"]');
  if (button) removeRow(button.closest('tr'));
});

addButton.addEventListener('click', () => addRow());

// 最初の1行を作り、合計を表示する
addRow(false);
updateTotal();

結 — 一枚まとめと次回予告

  1. DOM は JavaScript から見た HTML の木。querySelector で探し、textContent や hidden で書きかえる。探し方は CSS のセレクタと同じ。
  2. イベントは addEventListener で受け取る。打つそばから反応したいなら input、欄を離れたときなら change。
  3. 行のひな形は template に書き、JS は複製して足すだけ。足したら読み上げ用の名前を付け直し、フォーカスを移す。
  4. 後から増える部品のイベントは、親で受けて closest() で見分ける(イベントの委任)。
  5. 人が打った文字は textContent で出す。innerHTML は HTML として動いてしまう。計算は app.js、画面は form.js に分ける。

新井福岡出張、5行ぜんぶ入りました! ……あ、でも件名を空のまま「申請する」を押したら、そのまま送れちゃいそうです。

古井いいところに気づいた。次回は入力チェック。3万円ルールで領収書を必須にし、分かりやすいメッセージで「どこを直せばいいか」を伝えるぞ。

参考

ページの写真は、筆者が作ったデモページを2026年10月17日にクラウド上の Chromium(Playwright)で撮影したものです。撮影環境には Hiragino Sans がないため、日本語は Noto Sans CJK JP で表示されています。行数・合計・お知らせ・フォーカスの移り先・読み上げ用の名前、イベントの委任の実験、input と change の回数、number 欄の値、innerHTML の実験は、同じ Chromium を Playwright で操作して記録した実際の値です。form.html は Nu Html Checker(vnu)でエラーがないことを確かめています。登場人物は架空の人物です。

いにしえのフロントエンド入門 第8回 — JavaScript ②:DOM とイベント / 2026.10

← 記事一覧へ