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

Old-School Front-End

Part 8: the DOM and events, adding and removing line items with a total that keeps up

Published

The Bootstrap claim form. The line-item table has three rows, each with a Remove button. Below it are a + Add a row button and Total: ¥42,240, and under that a yellow note saying the total is ¥30,000 or more so a receipt must be attached
This part's goal: add rows, watch the total change as you type, and get a note once it passes ¥30,000. (The author's demo page, captured in Chromium in the cloud while Playwright actually typed and clicked. 17 October 2026. The browser frame was drawn in for illustration.)

An "old-school" front-end course: no AI doing it for you, every line typed by hand. In part 8 the page finally moves.
We meet HTML as JavaScript sees it (the DOM) and the way to catch "clicked" and "typed" (events).
The line items we promised in part 3 ("adding rows comes in part 8") get Add and Remove,
and we wire them to part 7's functions so the total updates as you type.

1

This part's goal

AraiFurui, my Fukuoka trip has five line items, but the form only takes three…

FuruiIn part 3 I said adding rows comes in part 8. Today's the day.

AraiI asked the AI, and it rebuilt the whole page with innerHTML. Whatever I type vanishes.

FuruiThat's rebuilding the house every time you move a chair. Today you learn to move one part at a time.

You are here: part 8

1 Setup 2 HTML 3 Forms 4 CSS basics 5 Layout 6 Bootstrap 7 JS basics 8 DOM, events 9 Validation 10 Fetch 11 Sign-in 12 Approvals 13 Charts, print 14 jQuery 15 Finish You are here Setup HTML CSS JavaScript Real app Finish

We give part 6's Bootstrap claim form these behaviours.

  • Add and remove rows: + Add a row adds a line item, each row's Remove takes it away (up to 10 rows, at least 1)
  • A live total: the total changes every time you type an amount, full-width digits and commas included (part 7's toAmount)
  • Notes: "receipt required" from ¥30,000, and a note when amounts can't be read or a row isn't in yen

The new file is js/form.js. Part 7's js/app.js doesn't change by a single line.

2

The DOM: HTML as JavaScript sees it

When the browser reads HTML, it builds the "tree" we saw in part 2. The DOM (Document Object Model) is that tree made available to JavaScript. form.js starts by finding the parts it will touch and keeping them in variables.

The DOM around the line items, and form.js's variables

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 variables in form.js Find parts with CSS selectors: document.querySelector('#items')
const tbody = document.querySelector('#items');        // the one element with id items
const rows = document.querySelectorAll('#items tr');   // every matching element
totalOutput.textContent = '¥42,240';                   // replace the text inside
receiptNote.hidden = false;                            // drop the hidden attribute to show it

You find parts with exactly the same selectors as CSS in part 4: #items for an id, [name="amount"] for an attribute. What you learned for CSS pays off right here.

3

Events: catching "clicked"

Things that happen, such as a button being pressed or text being typed, are events. With addEventListener you ask in advance: "when this happens, call this function".

addButton.addEventListener('click', () => addRow());
//          ↑ kind of event  ↑ function to call when it happens

Inputs have two events with similar names. I measured the difference.

input and change (measured)

Typing 14720, then clicking elsewhere (measured) 1 4 7 2 0 move away input × 5 change × 1 input fires on every character; change fires once when you leave We want the total to update as you type, so we use input

We want the total to change as you type, so we use input. The called function receives information about the event (event), and event.target tells you which part it happened on.

4

Keep calculation and page apart

This is why, in part 7, we went out of our way to keep the calculation functions away from the page.

From typing an amount to a new total

form.js: reads and writes the page app.js: sums only input amount typed 1 read amount, currency 2 toAmount text to amount 3 calcTotal needsReceipt 4 write total, notes 5 Page work and calculation live in separate files app.js is unchanged; part 7's tests still pass

The head loads both files with defer. Deferred scripts run in the order they're written, so app.js (the calculations) comes first and form.js, which uses it, comes second.

<script src="js/app.js" defer></script>   <!-- calculation -->
<script src="js/form.js" defer></script>  <!-- the page (uses app.js's functions) -->

AraiWouldn't one file be easier?

FuruiAt first. But if the total comes out wrong, where do you look? Mix page concerns with calculation and you have twice as many places to search. Kept apart, the calculation is checked by part 7's tests and the page in the browser, separately.

5

Adding rows: template and cloning

One line item is five inputs and a button: quite a lot of HTML. Building it from JavaScript strings is hard to read and easy to get wrong. So we write the row's pattern in an HTML template element and let JavaScript simply copy it.

    <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="Date" required></td>
    <td><select class="form-select form-select-sm" name="category" data-label="Category" required>…</select></td>
    <td><input type="text" class="form-control form-control-sm" name="item" data-label="Description" required></td>
    <td><input type="text" class="form-control form-control-sm text-end" name="amount" data-label="Amount" inputmode="numeric" autocomplete="off" required></td>
    <td><select class="form-select form-select-sm" name="currency" data-label="Currency">…</select></td>
    <td><button type="button" class="btn btn-outline-danger btn-sm" data-action="remove">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">+ Add a row</button>
  <p class="mb-0 fs-5">Total: <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>The total is ¥30,000 or more, so a receipt must be attached.</p>
<p class="small text-body-secondary mt-2 mb-0" id="skip-note" hidden></p>

Copy the pattern, add it to the table

<template> pattern (hidden) cloneNode <tr> one copied row append tbody#items After adding, rename the fields for screen readers (measured) "Row 1 Amount" "Row 2 Amount" "Row 3 Amount" The HTML shape stays in HTML; JS only copies it and numbers it Removing a row renumbers too, so no gaps appear
  • What's in a template is asleep: it isn't shown and isn't sent as form data. It becomes a real row only once copied into the table
  • Screen-reader names come from JS: inputs in a table have no room for a label beside them, so, as in part 3, they get aria-label. Which row is which isn't known until it's added, so the names are redone on every add and remove
  • After adding, go to the new row: focus moves to its first field, so keyboard users can carry on typing
6

Removing rows: event delegation

Every row has a Remove button, and rows are added later. Attach addEventListener to each button when the page opens and rows added afterwards won't have one. This is where event bubbling comes in.

Events travel up to the parents

tbody#items tr td button: Remove travels up (bubbling) tbody.addEventListener( 'click', …) one listener, on tbody event.target.closest('tr') find the clicked row Pressing Remove on a row added later (measured) listener on each button at load: still 3 rows (nothing happens) listener on tbody: down to 2 rows
tbody.addEventListener('click', (event) => {
  const button = event.target.closest('[data-action="remove"]');
  if (button) removeRow(button.closest('tr'));
});

A click travels from the part that was pressed up through its parents. So one listener on the box that holds the rows (tbody) catches buttons in rows added later too. closest() returns the first matching element going up from itself, so you know at once which row was pressed. This is called event delegation. The input events for the total are caught on tbody the same way.

7

Showing the total

The total uses an output element: the HTML element for "the result of a calculation", which screen readers treat as a status message.

We also changed the amount field from part 3's type="number" to type="text" (with inputmode="numeric" for a number keypad). Typing into Chromium shows why.

# The value of a type="number" field after typing (measured in Chromium)
'14720' → value = '14720'
'14,720' → value = '14720'
'14720' → value = '14720'
'abc' → value = '' can't tell it from empty

Chromium's number field quietly fixes or drops what you type before handing over the value (other browsers may differ). Above all, "abc" becomes '', the same as an empty field, so you can't tell "not filled in" from "typed something that isn't a number". So we take the text exactly as typed and read it with our own toAmount. Empty fields don't count; unreadable rows and non-yen rows are left out of the total, and a note says how many.

8

textContent versus innerHTML

Whenever form.js writes text to the page, it uses textContent. Its look-alike, innerHTML, reads the string as HTML. What happens if text a user typed goes into innerHTML? I tried it.

A two-column table. The left column, put in with innerHTML, shows a broken-image icon followed by Hotel, 1 night. The right column, put in with textContent, shows the img tag as plain text
The same string put on the page with innerHTML and with textContent. On the left, the planted code actually ran. (The author's demo page, captured in Chromium in the cloud while Playwright actually typed and clicked. 17 October 2026. The browser frame was drawn in for illustration.)
const typed = '<img src="x" onerror="window.__xss = 1">Hotel, 1 night';
a.innerHTML = typed;     // an img element was created and onerror ran (__xss became 1)
b.textContent = typed;   // shown as plain text

Here it only raised a flag (window.__xss), but a real attack would run something like stealing other people's sign-in details (cross-site scripting, XSS). The Description field in an expense claim is free text that anyone filing a claim can write. Show text people typed with textContent. That rule hasn't changed since the old days.

POINT

AI-written page code often builds whole rows with innerHTML. Arai's "what I type vanishes" in the opening scene came from rebuilding the entire table with innerHTML every time the total changed (rebuild it, and the half-typed field is replaced with a fresh one). When you see innerHTML, check whether typed text could end up there, and whether a rebuild is really needed. That alone prevents a lot of accidents.

9

Taking it for a spin

Playwright typed and clicked for real, and after each step I recorded the row count, total, notes and where focus was.

Three moments in the line-item table. 1 Typing 14720 in row 1 gives a total of ¥14,720. 2 With three rows, including full-width 12,800 and 14,720 with a comma, the total is ¥42,240 and the receipt note appears. 3 Removing row 2 leaves two rows, a total of ¥29,440, and no note
1 typing, 2 adding rows, 3 removing. Each time, the total and the notes keep up on the spot. (The author's demo page, captured in Chromium in the cloud while Playwright actually typed and clicked. 17 October 2026. The browser frame was drawn in for illustration.)
# Values recorded while actually using the form (Chromium)
action rows total receipt focus
just opened 1 ¥0 - -
row 1: typed 14720 1 ¥14,720 - Row 1 Amount
+ Add a row 2 ¥14,720 - Row 2 Date
row 2: typed 12,800 2 ¥27,520 - Row 2 Amount
row 3 added: 14,720 3 ¥42,240 needed Row 3 Amount
row 3 currency to USD 3 ¥27,520 - -
 └ 1 row(s) are not in yen (conversion comes in part 10; not included)
row 2 changed to "約1万" 3 ¥29,440 - Row 2 Amount
 └ 1 row(s) have an amount that can't be read (not included in the total)
row 2 back to 12800 3 ¥42,240 needed Row 2 Amount
Remove row 2 2 ¥29,440 - #add-row

Look at the focus column. Adding a row moves focus to the new row's Date; removing one moves it to + Add a row. Focus in a removed row has nowhere to go, so deciding where to send it is part of the page's job. Finally, the whole of form.js.

// Expense System: the claim form's line items (part 8)
// Calculations stay in app.js; this file only reads the page, writes to it and reacts to the user

const MAX_ROWS = 10; // Up to 10 line items

// Find the page parts once, at the start
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');

/** Add one row */
function addRow(moveFocus = true) {
  const row = template.content.firstElementChild.cloneNode(true);
  tbody.append(row);
  renumber();
  if (moveFocus) row.querySelector('input').focus(); // Move to the new row's first field (not for the very first row)
}

/** Remove a row */
function removeRow(row) {
  row.remove();
  if (tbody.rows.length === 0) addRow(false); // Always keep at least one row
  renumber();
  updateTotal();
  addButton.focus(); // After removing, go back to the Add button
}

/** Rename the fields for screen readers to match the row numbers */
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', `Row ${n} ${label}`);
    }
    row.querySelector('[data-action="remove"]').setAttribute('aria-label', `Remove row ${n}`);
  });
  addButton.disabled = tbody.rows.length >= MAX_ROWS; // No more adding once there are 10 rows
}

/** Read the line items from the page and show the total and notes */
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;                          // Empty fields don't count
    if (Number.isNaN(amount)) { unreadable++; continue; }  // Can't be read as an amount
    if (currency !== 'JPY') { foreign++; continue; }       // Not yen: don't add it yet
    items.push({ amount });
  }
  const total = calcTotal(items);
  totalOutput.textContent = formatYen(total);
  receiptNote.hidden = !needsReceipt(total);

  const notes = [];
  if (unreadable > 0) notes.push(`${unreadable} row(s) have an amount that can't be read (not included in the total)`);
  if (foreign > 0) notes.push(`${foreign} row(s) are not in yen (conversion comes in part 10; not included)`);
  skipNote.textContent = notes.join(' / ');
  skipNote.hidden = notes.length === 0;
}

// Recalculate on every input (listen on tbody so rows added later work too)
tbody.addEventListener('input', (event) => {
  const name = event.target.name;
  if (name === 'amount' || name === 'currency') updateTotal();
});

// The Remove buttons (also handled on tbody)
tbody.addEventListener('click', (event) => {
  const button = event.target.closest('[data-action="remove"]');
  if (button) removeRow(button.closest('tr'));
});

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

// Make the first row and show the total
addRow(false);
updateTotal();

§ — On one page, and what is next

  1. The DOM is HTML's tree as JavaScript sees it. Find with querySelector; change with textContent or hidden. You find things with CSS selectors.
  2. Catch events with addEventListener: input to react as people type, change to react when they leave a field.
  3. Put the row pattern in a template; JS only copies and adds it, then renames the fields for screen readers and moves focus.
  4. For parts added later, listen on the parent and tell them apart with closest() (event delegation).
  5. Show typed text with textContent; innerHTML runs it as HTML. Calculation in app.js, page in form.js.

AraiAll five Fukuoka rows are in! …Oh, but if I press Submit with no purpose, it looks like it would just go.

FuruiGood catch. Next time: validation. We'll make receipts required under the ¥30,000 rule and tell people clearly what to fix and where.

References

The page photos show the author's demo pages, captured on 17 October 2026 in Chromium (Playwright) in the cloud. Segoe UI isn't available there, so the screenshots fall back to other system fonts. Row counts, totals, notes, where focus went, the screen-reader names, the delegation experiment, the input and change counts, the number-field values and the innerHTML experiment are real values recorded by driving the same Chromium with Playwright. form.html was checked with the Nu Html Checker (vnu) and showed no errors. The characters are fictional.

Old-School Front-End, part 8: the DOM and events / 2026.10

← Back to all posts