Skip to main content

Calculator Forms That Don't Lie: An Error Summary, Inline Errors, a Live Result and "Not Available Yet" Instead of Infinity

Updated 11 min readMarkdown
Calculator Forms That Don't Lie: An Error Summary, Inline Errors, a Live Result and "Not Available Yet" Instead of Infinity

Small calculators are some of the most useful pages a content site can have, and some of the easiest to get subtly wrong. Not the maths. The form around it. A field that turns red without saying why. A result that changes silently, so a screen reader user never hears it. A cost per contract of $Infinity because the person hasn't won a contract yet, which is the exact situation they opened the calculator to think about.

I build the free calculators on Digital Dialogue, my freelancing blog, and the one with the most edge cases is the Upwork Connects calculator: price per Connect, Connects per proposal, proposals sent, replies, interviews, contracts. Lots of fields, several of them legitimately zero. This post is the form pattern I'd use for any calculator like it, in plain HTML and JavaScript. It's a generic, cut-down version (five fields, not the full set), not the production source. The validation and maths are tested with Node's test runner, and the DOM wiring was checked in headless Chrome with a small scripted test.

The shape

Three regions, in this order on the page:

  1. An error summary at the top of the form, hidden until a submit fails.
  2. The fields, each with a label, a hint and a slot for its own error.
  3. A result section with a status line that announces updates.

Keep the logic out of the DOM. Validation and calculation are pure functions that take strings and return data. The UI layer only reads the form, calls them and writes the results. That split is what makes the interesting rules testable.

One field, done properly

<div class="field">
  <label for="replies">Client replies <span aria-hidden="true">*</span></label>
  <p id="replies-hint" class="hint">Use 0 if nobody replied.</p>
  <p id="replies-error" class="error"></p>
  <input id="replies" name="replies" inputmode="numeric" required
         aria-describedby="replies-hint">
</div>

A few deliberate choices:

  • The hint is part of the field's description, linked with aria-describedby, so it's read when the field gets focus. "Use 0 if nobody replied" is not decoration. It answers the question that stops people, and it tells them zero is allowed.
  • The asterisk is aria-hidden. The required attribute already tells assistive technology the field is required. A screen reader reading "Client replies star" helps nobody. Explain the asterisk once in visible text at the top of the form.
  • inputmode="numeric" instead of type="number". Number inputs have quirks: the scroll wheel changes the value, some browsers accept e, and the value you read back may not be what the person typed. A text input with inputmode gets the numeric keyboard on phones and gives you the raw string to validate yourself.
  • The error slot sits between the label and the input. When an error appears, it's visible right where the eye goes, and you can add it to the description.

The form itself gets novalidate. Browser validation bubbles are inconsistent, disappear on their own and can't be styled into a summary. You still keep required for its semantics, and you do the checks in JavaScript.

Validation as a pure function

// connects.mjs
export const FIELDS = [
  { name: "price",     label: "Price per Connect",     kind: "money", required: true },
  { name: "perBid",    label: "Connects per proposal", kind: "int",   required: true, min: 1 },
  { name: "proposals", label: "Proposals sent",        kind: "int",   required: true, min: 1 },
  { name: "replies",   label: "Client replies",        kind: "int",   required: true },
  { name: "contracts", label: "Contracts won",         kind: "int",   required: true },
];

export function validate(values) {
  const errors = [];
  const data = {};
  for (const f of FIELDS) {
    const raw = String(values[f.name] ?? "").trim();
    if (raw === "") {
      if (f.required) errors.push({ name: f.name, message: `Enter ${f.label.toLowerCase()}.` });
      continue;
    }
    const ok = f.kind === "int" ? /^\d+$/.test(raw) : /^\d+(\.\d{1,4})?$/.test(raw);
    if (!ok) {
      errors.push({ name: f.name, message: f.kind === "int"
        ? `${f.label} must be a whole number, like 12.`
        : `${f.label} must be a number, like 0.15.` });
      continue;
    }
    const n = Number(raw);
    if (f.min !== undefined && n < f.min) {
      errors.push({ name: f.name, message: `${f.label} must be at least ${f.min}.` });
      continue;
    }
    data[f.name] = n;
  }
  if (!errors.length) {
    if (data.replies > data.proposals)
      errors.push({ name: "replies", message: "Client replies can't be more than proposals sent." });
    if (data.contracts > data.replies)
      errors.push({ name: "contracts", message: "Contracts won can't be more than client replies." });
  }
  return { errors, data: errors.length ? null : data };
}

The rules worth copying:

  • Collect every error, in field order. Stopping at the first problem means the person fixes one thing, submits, and discovers the next. The summary should list everything at once, top to bottom, matching the page.
  • Messages say what to do. "Enter price per connect." "Proposals sent must be at least 1." Not "Invalid input". The field name is in the message, because in the summary the message is all the person sees.
  • Zero is valid where zero is true. Replies and contracts can be zero. Proposals can't, because with zero proposals there's nothing to calculate. Encode that difference with min, not with a blanket "must be positive".
  • Cross-field checks run last. "Replies can't be more than proposals" only makes sense once both are valid numbers. Running it earlier produces confusing double errors on the same field.

Results that explain themselves

The calculation is easy. The interesting part is what to return when a divisor is zero:

function perOutcome(total, count, noun) {
  return count > 0
    ? { ok: true, value: total / count }
    : { ok: false, reason: `Not available yet: add at least one ${noun}.` };
}

export function compute({ price, perBid, proposals, replies, contracts }) {
  const connects = perBid * proposals;
  const spend = connects * price;
  return {
    connects,
    spend,
    perReply: perOutcome(spend, replies, "reply"),
    perContract: perOutcome(spend, contracts, "contract"),
  };
}

spend / 0 in JavaScript is Infinity, and 0 / 0 is NaN. Formatted as currency, those become $∞ and NaN, and I've seen both on real calculators. Showing $0.00 is worse, because it looks like an answer: "each contract cost you nothing". The honest result is that the number doesn't exist yet, plus what would make it exist. Returning { ok, value | reason } forces the UI to handle both cases, because there's no number to accidentally format.

Digital Dialogue's Connects calculator does the same thing, and its FAQ explains why it says "Not available yet" instead of a misleading figure. I'd take that over a clever placeholder every time.

(These are estimates shown to the cent, so plain floats are fine here. If you need exact money arithmetic, keep amounts in integer cents.)

Wiring the errors to the DOM

// ui.mjs (excerpt)
function showErrors(errors) {
  for (const e of errors) {
    const input = form.elements[e.name];
    input.setAttribute("aria-invalid", "true");
    input.setAttribute("aria-describedby", `${e.name}-error ${e.name}-hint`);
    document.getElementById(`${e.name}-error`).textContent = e.message;
  }
  const n = errors.length;
  summary.querySelector("h2").textContent =
    `There ${n === 1 ? "is 1 problem" : `are ${n} problems`} to fix`;
  summary.querySelector("ul").replaceChildren(...errors.map(e => {
    const li = document.createElement("li");
    const a = document.createElement("a");
    a.href = `#${e.name}`;
    a.textContent = e.message;
    li.append(a);
    return li;
  }));
  summary.hidden = false;
  summary.focus();
}

And the summary markup:

<div id="error-summary" tabindex="-1" role="group"
     aria-labelledby="error-summary-title" hidden>
  <h2 id="error-summary-title"></h2>
  <ul></ul>
</div>

What each piece does:

  • aria-invalid="true" marks the field as invalid for assistive technology. Remove it when the field is fixed. A field that stays "invalid" after correction is its own small lie.
  • The error goes first in aria-describedby. When someone lands on the field, they hear the label, then the error, then the hint.
  • Focus moves to the summary. tabindex="-1" makes it focusable by script without adding it to the tab order. Moving focus there means a keyboard or screen reader user starts at the list of problems instead of somewhere below the button. The heading is announced because the summary is labelled by it.
  • Each summary item is a link to its field. By default, following #perBid scrolls to the input but may not focus it in every browser. So intercept the click and focus the input:
summary.addEventListener("click", (event) => {
  const a = event.target.closest("a");
  if (!a) return;
  event.preventDefault();
  const input = form.elements[a.hash.slice(1)];
  input.focus();
  input.scrollIntoView({ block: "center" });
});

On every submit, clear all of this first, then apply the new errors. Clearing means removing aria-invalid, resetting aria-describedby to the hint only, emptying the error slots and hiding the summary. It's the step people skip, and the cause of "the error is still there after I fixed it".

Don't use role="alert" on the summary as well as moving focus. Both get announced, and the person hears the problems twice.

Announcing the result

When the calculation succeeds, focus should stay where it is, on the button the person just pressed. Moving it to the result is disorienting if they want to change one value and recalculate. But the result still needs to be heard. That's what a live region is for:

<section id="result" aria-labelledby="result-title">
  <h2 id="result-title">Your estimate</h2>
  <p role="status">No estimate yet.</p>
  <dl></dl>
</section>

role="status" is a polite live region. When its text changes, screen readers announce it after whatever they're currently reading. Two rules make it work reliably:

  1. The live region must exist in the page before it changes. Injecting a new element that already has role="status" and text often isn't announced. Render it empty or with a placeholder on load, then change its text.
  2. Keep the announcement short. I update the <dl> with the breakdown, then set the status line to "Estimate updated." The breakdown is one Tab or arrow key away. Announcing eight lines of numbers every time someone recalculates is noise.

Use a description list for the breakdown. It's real structure: a term ("Cost per contract") and its value, which screen readers can navigate as pairs.

Testing it

The pure functions get ordinary tests:

import { test } from "node:test";
import assert from "node:assert/strict";
import { validate, compute } from "./connects.mjs";

test("collects every problem, in field order", () => {
  const r = validate({ price: "", perBid: "eight", proposals: "0", replies: "", contracts: "" });
  assert.deepEqual(r.errors.map(e => e.name),
    ["price", "perBid", "proposals", "replies", "contracts"]);
});

test("zero outcomes explain themselves instead of dividing by zero", () => {
  const out = compute({ price: 0.15, perBid: 8, proposals: 25, replies: 0, contracts: 0 });
  assert.equal(out.connects, 200);
  assert.equal(out.perReply.ok, false);
  assert.match(out.perContract.reason, /Not available yet/);
});

The 0.15 here is a made-up price for the test, not a real Connects price. Prices change and can differ by account, which is why the calculator asks for the price you actually pay rather than assuming one.

For the DOM, I filled the demo form with two bad values from a script, called form.requestSubmit() and read the state back in headless Chrome. The checks: the summary heading said "There are 2 problems to fix", focus was on the summary, the price field had aria-invalid="true" and aria-describedby="price-error price-hint", clicking the second summary link focused the "Connects per proposal" input, and after a valid submit the summary was hidden, aria-invalid was gone and the cost per contract read "Not available yet: add at least one contract." That's not a substitute for trying it with a real screen reader, which I'd still do before shipping, but it catches the wiring mistakes that slip in during refactors.

Checklist

  • novalidate on the form, required kept on fields, checks in JavaScript.
  • Every field: visible label, hint in aria-describedby, its own error slot.
  • Collect all errors in page order. Messages that name the field and say what to do.
  • Allow zero where zero is a true answer, and say so in the hint.
  • Error summary: heading with the count, links to fields, focus on submit, cleared on every attempt.
  • No Infinity, NaN or fake $0.00. A reason instead of a number.
  • Result: an existing role="status" line with a short message, and a <dl> with the details. Focus stays put.

If you want to see the pattern on a real tool with more fields, the Upwork Connects calculator on Digital Dialogue works out Connects spending and the cost of each reply, interview and contract, and the other calculators are on the Digital Dialogue tools page.


I'm Abid Ali, a freelance full-stack developer in Karachi working with TypeScript, Next.js, React and Node.js. I write practical freelancing guides and build small free calculators on Digital Dialogue.

About the author

  • I'm Abid Ali, a full stack developer from Karachi with 3+ years of experience in TypeScript, Next.js, Gatsby, React and React Native. I build web apps, mobile apps and interactive UIs with solid API integration. Side projects: OG Scope (ogscope.pk), a free Open Graph checker; Digital Dialogue (digitaldialogue.pk), freelancing guides for Upwork and Fiverr beginners; and Meme Tap (memetap.pk), a free browser meme soundboard.

No comments yet. Be the first to comment.