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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/calculator-input.jsonPrompt
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+or1.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, so1200*15%is 180. - Clamp at the commit boundary —
min/maxapply once, when the answer is taken, and say when they bit. Clamping per keystroke would make typing5on the way to50impossible 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.
Poll Composer
A create-a-poll form — a question, 2 to 10 reorderable option rows, duplicate answers marked on both halves, an allow-multiple switch, a duration menu and a live ballot preview, emitting a typed draft on every edit.
Coordinates Input
A latitude/longitude pair that reads decimal degrees or DMS in either field, re-prints between the two on a toggle, names the field that leaves ±90 / ±180, and splits a pasted pair across both inputs.