Inputs

Calculator Input

A number field that does arithmetic — type =2+3*4 or plain 2+3*4 and it commits 14, parsed by a hand-rolled parser that never calls eval.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Calculator, Equal, History } from "lucide-react"
import { cn } from "@/lib/utils"

/** How long a spoken message stays in the live region before it clears itself. */
const ANNOUNCE_MS = 4000
/** Longest expression the parser will look at, in characters. */
const MAX_LENGTH = 200
/**
 * Results at or beyond this magnitude stop round-tripping through the field's own
 * text (Number#toString switches to exponent notation, which this parser refuses),
 * so they are turned away at the commit instead of being displayed unreadably.

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/calculator-input.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "CalculatorInput" component (lucide-react
for the Calculator / Equal / History glyphs; no other runtime deps — the maths
is hand-rolled).

Contract
- Export a forwardRef component whose ref lands on the inner <input>; props
  extend InputHTMLAttributes<HTMLInputElement> minus
  value/defaultValue/onChange/type/min/max/step/readOnly.
- Controlled or uncontrolled number: value (number | null) or defaultValue,
  plus onValueChange(next: number | null). null means "empty" — a cleared
  field must never become 0. The component emits a number, never the text.
- Options: min / max (default ±Infinity, inclusive), precision (0..12
  decimals, default 6), evaluateOnBlur (default true), historyLength
  (default 8, 0 turns history off), formatValue?(value) => string, label,
  description, placeholder, invalid, disabled, name, onRefuse(message),
  className.
- Also export evaluateExpression(source) =>
  { ok: true, value } | { ok: false, message, position } so a server can reuse
  the exact same parser for validation.

The maths (this is the component, do not shortcut it)
- Never eval() and never new Function(): the whole job is accepting typed
  text, so handing it to the JS engine is the one thing that is off the table.
- Tokenizer: digit runs with at most one ".", a "," accepted only as a
  thousands separator (a digit, then exactly three more — "1,234" is a number,
  "1,5" is refused so a European decimal comma is never silently read as
  1234-style grouping), the operators + - * / ( ) %, whitespace of every kind
  skipped (a non-breaking space is what a number pasted out of a spreadsheet or
  a web page arrives wrapped in), and ×, ÷, ·, −, – and — folded onto their
  ASCII twins. Every token carries its 1-based character position.
- Recursive descent over
    expression := term (("+" | "-") term)*
    term       := unary (("*" | "/") unary)*
    unary      := ("+" | "-")* postfix
    postfix    := primary "%"*
    primary    := number | "(" expression ")"
- Percent is a postfix operator meaning "a hundredth", with one contextual
  rule: when a bare percent term is the right operand of + or -, it is a
  percent OF the accumulated left side. So 1200+15% is 1380, 1200-15% is 1020,
  1200*15% is 180, 50% is 0.5, and (200+10%) is 220. The percent flag is
  carried on the parsed operand and cleared by any multiplication or by a
  parenthesised group, which is what keeps that rule from leaking.
- Refusals are sentences that name the position: "Unexpected ")" at position
  6.", "Missing a number after "+" at position 2.", "The group opened at
  position 1 is never closed.", "Division by zero at position 4.", "Use "."
  for decimals — unexpected "," at position 2." Division by zero is caught at
  the operator, not left to produce Infinity.
- A position counts characters of the text the user actually typed, so the
  leading "=" marker is masked to a space before parsing rather than sliced
  off: in "=2+(3*4" the unclosed group is reported at position 4, the bracket
  itself, and not at position 3. Naming the wrong character is worse than
  naming none.
- Guards: expressions longer than 200 characters, non-finite results, and
  answers at or beyond 1e21 are refused — past 1e21 Number#toString switches
  to exponent notation, which this very parser would then refuse, so the field
  would stop being able to read its own display.

Behavior
- Draft vs committed number. Keystrokes go into a local draft string; nothing
  is parsed into the value, clamped or reformatted while typing. When the
  draft is empty the field shows the committed number through formatValue (or
  plain decimals rounded to precision — never exponent notation, because
  whatever is printed has to parse back).
- A hint chip under the field has two modes: while a draft is being written it
  previews what would be committed ("= 14", plus "(clamped)" when the bounds
  would bite); with no draft it shows where the current number came from
  ("2+3*4 = 14"). Preview and commit run through one resolve() so they can
  never disagree. The provenance chip stores the answer alongside the
  expression and hides itself if the value later changes from outside.
- Commit triggers: Enter and the "=" button always evaluate. Blur evaluates
  too when the draft starts with "=" or when evaluateOnBlur is true;
  otherwise blur refuses with "Press Enter to work out …" and leaves the text
  alone. A plain number always commits, on any trigger. An empty draft commits
  null.
- On commit: round to precision (which also kills float tails — 0.1+0.2 is
  0.3, not 0.30000000000000004), clamp into [min, max], and when the clamp
  actually bit say why ("180 is above the maximum 100 — kept 100."). Clamping
  at the commit, not per keystroke, is what lets someone type 5 on the way to
  50 when the floor is 10.
- Refusal is in place: the raw text is kept, the value does not move, the
  message replaces the description, aria-invalid goes on, and onRefuse fires
  with the same sentence. Typing again clears the message — the verdict is
  re-taken on the next commit.
- History: every committed expression (not a bare number) is pushed onto a
  capped list, consecutive duplicates collapsing. ArrowUp walks back,
  ArrowDown walks forward and finally restores the draft that was stashed on
  the way in; the caret is placed at the end of the restored text. The History
  button does one ArrowUp for pointer users. At the oldest entry the key is
  still swallowed and the field says so, because a caret jumping to position 0
  would read as the key having done something else.
- Keyboard map: Enter evaluates (and is only swallowed when there is a draft,
  so an untouched field still submits its form); Escape discards the draft and
  any message, and is silent when there is nothing to undo so a surrounding
  dialog still closes on the same key; ArrowUp / ArrowDown walk the history.
  Mid-composition (IME) keys are left alone.
- Nothing is gesture-only and nothing is drag-driven: every path is a key, and
  the two icon buttons are additive. They preventDefault on pointerdown so a
  press cannot blur-commit the draft before the click that was meant to commit
  it deliberately, and the "=" button focuses the field before evaluating,
  because a successful commit unmounts it.
- disabled is inert, not removed: aria-disabled + readOnly + guards at the top
  of every handler, never the native disabled attribute, so the field keeps
  its place in the tab order and can still be read.
- Defensive props: precision is truncated into 0..12, a NaN bound is dropped,
  a crossed min/max resolves in favour of min, and historyLength floors at 0.
- Cleanup: the only timer is the one that clears the live-region sentence
  (so an identical refusal, repeated, is announced again instead of being
  swallowed as a no-change). It is cleared before each new announcement and in
  an unmount effect. The caret restore is a ref flag consumed by an effect,
  raised and read in the same gesture — no interval, no rAF, no listener, no
  observer, and no clock read anywhere in the component.

Rendering & styling
- Wrapper: flex w-full flex-col gap-1.5, className merged through cn().
- Field shell: h-9 rounded-md border border-input bg-transparent shadow-xs
  with focus-within:border-ring + focus-within:ring-3 focus-within:ring-ring/50;
  invalid swaps to border-destructive + ring-destructive/30; disabled dims with
  opacity-60. A pointerdown on the shell's own padding is redirected to the
  input, so a press between the glyphs can never blur to <body> and quietly
  evaluate a half-typed draft.
- Input: text type with inputMode="text" (a decimal keypad has no brackets or
  operators), tabular-nums, no border of its own so the shell reads as one
  control. Icon buttons: size-7, hover:bg-accent, focus-visible ring,
  aria-disabled styling rather than :disabled.
- The chip row and the message row stay mounted (min-h-5 / min-h-4) so the
  field never jumps as a hint or a refusal arrives.
- ARIA: the input keeps its plain textbox role — it is text that becomes a
  number, not a spinbutton, and it has no stepping to expose.
  aria-describedby links the chip and the message (merged with whatever the
  consumer passes), aria-invalid tracks refusals plus the invalid prop,
  aria-keyshortcuts advertises Enter / Escape / ArrowUp / ArrowDown, and one
  polite aria-atomic status region carries commits, refusals and history
  moves. The visible rows are described, not live, so nothing is read twice.
  A name prop submits the committed number through a hidden input.
- Semantic tokens only: border-input / ring-ring / bg-muted /
  text-muted-foreground / text-destructive / accent. Dark mode comes free.
  Colour transitions are the only motion and they are disabled under
  prefers-reduced-motion; the field works identically with motion off.

Customization levers
- Grammar: the parser is four small functions. Add ^ as a right-associative
  power between term and unary, add named functions (min, max, round) in
  parsePrimary, or add implicit multiplication by treating "number followed by
  (" as a * — each is one branch, and every new failure mode should get its
  own positioned sentence.
- Percent policy: if your domain wants "%" to always mean a hundredth, delete
  the percent branch in parseExpression and keep the postfix division by 100.
- precision: 2 for money-shaped fields, 0 for counts, 6 for general maths.
  Pair it with min/max in the same unit.
- formatValue: hand it Intl.NumberFormat for grouped display — but whatever it
  returns is what the user edits next, so it must be text this parser reads
  back (the built-in tokenizer accepts "1,234" grouping and "." decimals).
- Commit timing: evaluateOnBlur={false} for forms where an accidental blur
  must not change a posted number; keep it on for spreadsheet-like entry.
- History: historyLength={0} removes the feature and the button with it;
  raise it for a calculation-heavy screen.
- Density and chrome: h-9 / size-7 / text-sm are the sizing knobs, and the
  leading Calculator glyph, the "=" button and the hint chip are each
  independent blocks you can drop without touching the state machine.
- Validation: keep invalid + description driven by your form library (zod
  z.number().min(...)); the component clamps, refuses and reports, but it
  never blocks a keystroke. Wire it with a Controller — this is a
  value/onValueChange pair, not a native change event.

Concepts

  • Draft versus committed number — keystrokes edit a local string and nothing is parsed into the value, rounded or clamped until a commit, so half-typed states like 2+ or 1. are never fought mid-word.
  • No eval, ever — a hand-rolled tokenizer plus recursive descent over + - * / ( ) % is the entire maths surface. The field's job is to accept typed text, which is exactly the text you must not hand to the JavaScript engine.
  • Refuse in place, name the position — an illegal expression keeps the raw text and answers with the character index (Unexpected “)” at position 6.), counted over the text as typed rather than over an internally trimmed copy. Retyping a whole expression to fix one bracket is the worst thing a calculator field can ask for.
  • Percent means “of” next to + and 1200+15% is 1380 because a bare percent term on the right of an addition is measured against the left side; anywhere else % is just a hundredth, so 1200*15% is 180.
  • Clamp at the commit boundarymin/max apply once, when the answer is taken, and say when they bit. Clamping per keystroke would make typing 5 on the way to 50 impossible with a floor of 10.
  • Expression history with a stashed draft — ArrowUp walks back through committed expressions and ArrowDown walks forward, restoring the text that was being written on the way in; the caret lands at the end of whatever arrives.

On This Page