ExpressionInput
A number field that works out 12*3+4, 15% of 80 and (1+2)/3 exactly with its own parser, shows the answer as typed, points at what it cannot read, and sends the answer.
Demo
= 1,380.00
Change the sum to see on_change.
Get it
It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.
$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/expression_input.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/expression_input.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/expression_input.qwe
component ExpressionInputDemoLive() { state fee = null <div.demo-stack> <ExpressionInput id="expression-demo-live" name="fee" label="Fee" decimals={2} lowest={0} highest={10000} value="1200 + 15%" on_change={(v) => { fee = v }}/> <p.demo-note>{fee == null ? "Change the sum to see on_change." : `on_change: ${fee}`}</p> </div>}view ExpressionInputDemo() = <div class="demo-stack demo-narrow"> <form> <ExpressionInput id="expression-demo" name="qty" label="Quantity" value="12*3+4" hint="Sums work: 15% of 80, (1+2)/3."/> </form> <ExpressionInputDemoLive/> </div>Source
ui/expression_input.qwe
// ExpressionInput: a number field that does sums. It takes "12*3+4", "15% of 80",// "(1+2)/3", "100 + 10%" or "1,234.50 × 2", shows the answer as it is typed, and sends// the answer, not the words. Its parser is its own, nothing is evaluated as code, and// the arithmetic is exact: fractions are kept as fractions to the end, so 0.1+0.2 is// 0.3 and 1/3*3 is 1. Where it cannot read something, it says what and marks the place.//// <ExpressionInput id="qty" name="qty" label="Quantity" value="12*3+4"/>// <ExpressionInput id="fee" name="fee" label="Fee" decimals={2} lowest={0}// on_change={(v) => { fee = v }}/>//// id: a prefix for its ids, unique on the page. name: the hidden field that holds the// answer as plain digits ("1234.5"), empty while there is none. label, hint,// placeholder, required, disabled as for every field. value: the expression to start// with. decimals: places to round the answer to, half up ("10/3" is 3.33); left out,// an answer is as long as it needs, and one that never ends (1/3) is cut at 28 places// and says so. lowest, highest: decimals the answer must fall between. locale: how the// answer is written on the page ("en-US"; the field always has plain digits). class:// more classes. on_change(value): inside one of your components, the answer as a// decimal after each change, null when there is none.//// Reads + - * / and × ÷ x, brackets, a minus before a number, % after one (a hundredth,// so "50% * 20" is 10), "of" after a percentage, and commas that group thousands.// After + or - a percentage is of what stands before it, as on a calculator: "100 +// 10%" is 110. At most 500 characters and 64 brackets deep, and numbers of 28 digits;// an answer past 28 digits is an error, not a wrong number.//// expression_input_eval(text, decimals) is the same for a program's own use (a route// checking what was sent): { status, value, rounded, message, at }. status is "empty",// "ok" or "bad"; value the answer as text; rounded whether digits were cut; at the// character the message is about, from 0.import ui/theme.qwe use ui_clslet expression_input_css = css`.ui-exprinput { display: grid; gap: 6px; min-width: 0; font: 14px/1.45 var(--ui-font); color: var(--ui-fg); }.ui-exprinput-label { font-size: 13px; font-weight: 500; }.ui-exprinput-req { margin-left: 2px; color: var(--ui-danger); }.ui-exprinput-control { box-sizing: border-box; width: 100%; height: 38px; margin: 0; padding: 0 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 14px/1 var(--ui-mono); font-variant-numeric: tabular-nums; transition: border-color .15s, box-shadow .15s; }.ui-exprinput-control::placeholder { color: var(--ui-muted); opacity: 1; font-family: var(--ui-font); }.ui-exprinput-control:hover { border-color: var(--ui-strong); }.ui-exprinput-control:focus { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-exprinput-control[aria-invalid="true"] { border-color: var(--ui-danger); }.ui-exprinput-control:disabled { opacity: .55; cursor: not-allowed; }.ui-exprinput-hint { margin: 0; font-size: 13px; color: var(--ui-muted); }.ui-exprinput-out { margin: 0; min-height: 20px; font-size: 13px; color: var(--ui-muted); overflow-wrap: anywhere; }.ui-exprinput-out strong { color: var(--ui-fg); font-size: 15px; font-weight: 650; font-variant-numeric: tabular-nums; }.ui-exprinput-note { display: block; font-size: 12px; }.ui-exprinput-bad { color: var(--ui-danger); }.ui-exprinput-where { display: block; margin: 2px 0 0; padding: 4px 8px; border-radius: 4px; background: var(--ui-subtle); color: var(--ui-fg); font: 13px/1.5 var(--ui-mono); white-space: pre-wrap; overflow-wrap: anywhere; }.ui-exprinput-where mark { border-radius: 2px; background: var(--ui-danger-soft); color: var(--ui-danger); font-weight: 700; text-decoration: underline; }@media (prefers-reduced-motion: reduce) { .ui-exprinput-control { transition: none; } }`let expression_input_limit = 500let expression_input_depth_limit = 64let expression_input_digits_limit = 28// A dec has 28 or 29 digits, and going past them stops the program: nothing here can// catch that, so every product is measured first.fn expression_input_size(x) = len(str(floor(abs(x))))fn expression_input_fits(a, b) = expression_input_size(a) + expression_input_size(b) <= 28fn expression_input_gcd(a, b) { let x = abs(a) let y = abs(b) while y != 0 { let r = x % y x = y y = r } x}// A fraction in its lowest terms, the sign on top. { n, d } are whole decimals. Past// what they hold a value is `rough`: a decimal in n, no longer exact.fn expression_input_fraction(n, d) { let g = expression_input_gcd(n, d) g > 1 ? { n: n / g, d: d / g } : { n, d }}fn expression_input_rough(x) = { n: x, d: 1m, rough: true }fn expression_input_decimal(v) = v.d == 1 ? v.n : v.n / v.dfn expression_input_too_large() = { err: "The answer is too large: past 28 digits." }fn expression_input_rough_mul(a, b) { let x = expression_input_decimal(a) let y = expression_input_decimal(b) if expression_input_size(x) + expression_input_size(y) > 27 { return expression_input_too_large() } expression_input_rough(x * y)}fn expression_input_mul(a, b) { if a.rough || b.rough { return expression_input_rough_mul(a, b) } let g1 = expression_input_gcd(a.n, b.d) let g2 = expression_input_gcd(b.n, a.d) let n1 = g1 > 1 ? a.n / g1 : a.n let d2 = g1 > 1 ? b.d / g1 : b.d let n2 = g2 > 1 ? b.n / g2 : b.n let d1 = g2 > 1 ? a.d / g2 : a.d if !expression_input_fits(n1, n2) || !expression_input_fits(d1, d2) { return expression_input_rough_mul(a, b) } expression_input_fraction(n1 * n2, d1 * d2)}fn expression_input_div(a, b) { if a.rough || b.rough { let x = expression_input_decimal(a) let y = expression_input_decimal(b) // Dividing by something small makes something large. if abs(y) < 1 && abs(x) >= abs(y) * 10000000000000000000000000000m { return expression_input_too_large() } return expression_input_rough(x / y) } let negative = b.n < 0 expression_input_mul(a, { n: negative ? 0 - b.d : b.d, d: abs(b.n) })}fn expression_input_add(a, b) { let rough = () => { let x = expression_input_decimal(a) let y = expression_input_decimal(b) if expression_input_size(x) > 27 || expression_input_size(y) > 27 { return expression_input_too_large() } expression_input_rough(x + y) } if a.rough || b.rough { return rough() } let g = expression_input_gcd(a.d, b.d) let ad = a.d / g let bd = b.d / g if !expression_input_fits(a.n, bd) || !expression_input_fits(b.n, ad) || !expression_input_fits(ad, b.d) { return rough() } expression_input_fraction(a.n * bd + b.n * ad, ad * b.d)}fn expression_input_neg(v) = { ...v, n: 0 - v.n }// What is written, cut into tokens { k, at, ... }, or { err, at }. A number's token holds// its fraction; the last token is { k: "end" }.fn expression_input_tokens(text: str) { let ops = { "+": "+", "-": "-", "−": "-", "–": "-", "*": "*", "×": "*", "x": "*", "X": "*", "/": "/", "÷": "/", "(": "(", ")": ")", "%": "%" } let tokens = [] let pieces = re.find_all(text, "\\s+|\\d[\\d,]*(?:\\.\\d*)?|\\.\\d+|of\\b|.", "is") | filter((m) => !re.test(m.text, "^\\s+$")) for m in pieces { let piece = m.text if re.test(piece, "^[\\d.]") && piece != "." { if contains(piece, ",") && !re.test(piece, "^\\d{1,3}(?:,\\d{3})+(?:\\.\\d*)?$") { let comma = index_of(piece, ",") return { err: "A comma groups thousands, as in 1,234. A decimal is written with a point.", at: m.start + comma } } let plain = replace(piece, ",", "") let parts = split(plain, ".") let whole = re.replace(parts[0], "^0+", "") let frac = re.replace(parts[1] ?? "", "0+$", "") if len(whole) + len(frac) > expression_input_digits_limit { return { err: `This number is too long: ${expression_input_digits_limit} digits at most.`, at: m.start } } let digits = filled(whole + frac) ?? "0" tokens = push(tokens, { k: "n", at: m.start, v: expression_input_fraction(dec(digits), dec("1" + repeat("0", len(frac)))) }) } else if lower(piece) == "of" { tokens = push(tokens, { k: "of", at: m.start }) } else if ops[piece] != null { tokens = push(tokens, { k: ops[piece], at: m.start }) } else { return { err: `"${piece}" cannot be read here: use numbers, + - * / %, brackets and "of".`, at: m.start } } } { tokens: push(tokens, { k: "end", at: len(text) }) }}// Each level gives { v, i }, the value and the token after it, or { err, at }.fn expression_input_atom(ts: list, i: int, depth: int) { let tok = ts[i] if tok.k == "n" { return { v: { ...tok.v, pct: false }, i: i + 1 } } if tok.k == "(" { if depth >= expression_input_depth_limit { return { err: `Too deep: ${expression_input_depth_limit} brackets at most.`, at: tok.at } } let inner = expression_input_sum(ts, i + 1, depth + 1) if inner.err != null { return inner } if ts[inner.i].k != ")" { return { err: "This bracket is never closed.", at: tok.at } } return { v: { ...inner.v, pct: false }, i: inner.i + 1 } } if tok.k == "end" { return { err: "Something is missing at the end: a number.", at: tok.at } } if tok.k == ")" { return { err: "A number should come before this bracket.", at: tok.at } } { err: "A number should come here.", at: tok.at }}fn expression_input_percent(ts: list, i: int, depth: int) { let got = expression_input_atom(ts, i, depth) if got.err != null { return got } let v = got.v let at = got.i while ts[at].k == "%" { let cut = expression_input_mul(v, { n: 1m, d: 100m }) if cut.err != null { return { err: cut.err, at: ts[at].at } } v = { ...cut, pct: true } at += 1 } { v, i: at }}fn expression_input_unary(ts: list, i: int, depth: int) { let k = ts[i].k if k != "-" && k != "+" { return expression_input_percent(ts, i, depth) } if depth > expression_input_depth_limit { return { err: "Too many signs in a row.", at: ts[i].at } } let got = expression_input_unary(ts, i + 1, depth + 1) if got.err != null || k == "+" { return got } { v: expression_input_neg(got.v), i: got.i }}fn expression_input_product(ts: list, i: int, depth: int) { let left = expression_input_unary(ts, i, depth) if left.err != null { return left } let v = left.v let at = left.i while contains(["*", "/", "of"], ts[at].k) { let op = ts[at] if op.k == "of" && !v.pct { return { err: `"of" follows a percentage, as in 15% of 80.`, at: op.at } } let right = expression_input_unary(ts, at + 1, depth) if right.err != null { return right } if op.k == "/" && right.v.n == 0 { return { err: "Cannot divide by zero.", at: ts[at + 1].at } } let next = op.k == "/" ? expression_input_div(v, right.v) : expression_input_mul(v, right.v) if next.err != null { return { err: next.err, at: op.at } } v = { ...next, pct: false } at = right.i } { v, i: at }}fn expression_input_sum(ts: list, i: int, depth: int) { let left = expression_input_product(ts, i, depth) if left.err != null { return left } let v = left.v let at = left.i while ts[at].k == "+" || ts[at].k == "-" { let op = ts[at] let right = expression_input_product(ts, at + 1, depth) if right.err != null { return right } // A percentage after + or - is of what stands before it. let part = right.v.pct && !v.pct ? expression_input_mul(v, right.v) : right.v if part.err != null { return { err: part.err, at: op.at } } let next = expression_input_add(v, op.k == "-" ? expression_input_neg(part) : part) if next.err != null { return { err: next.err, at: op.at } } v = { ...next, pct: false } at = right.i } { v, i: at }}// How many places a fraction's decimals run to, or null if they never end.fn expression_input_places(d) { let rest = d let twos = 0 let fives = 0 while rest % 2 == 0 { rest = rest / 2 twos += 1 } while rest % 5 == 0 { rest = rest / 5 fives += 1 } rest != 1 ? null : max([twos, fives])}// Without the zeros a division leaves at the end.fn expression_input_plain(x) { let s = str(x) contains(s, ".") ? re.replace(s, "\\.?0+$", "") : s}// The answer as text: { value, rounded }.fn expression_input_answer(v, decimals) { let exact = !v.rough && (expression_input_places(v.d) ?? 99) <= 28 let x = expression_input_decimal(v) if decimals == null { return { value: expression_input_plain(x), rounded: !exact } } let cut = round(x, decimals, "half_up") { value: str(cut), rounded: !exact || cut != x }}// The whole reading: see the opening comment.fn expression_input_eval(text, decimals = null) { let bad = (message: str, at: int) => { status: "bad", value: "", rounded: false, message, at } let raw = str(text ?? "") if trim(raw) == "" { return { status: "empty", value: "", rounded: false, message: null, at: null } } if len(raw) > expression_input_limit { return bad(`Too long to work out: ${expression_input_limit} characters at most.`, expression_input_limit) } let cut = expression_input_tokens(raw) if cut.err != null { return bad(cut.err, cut.at) } let got = expression_input_sum(cut.tokens, 0, 0) if got.err != null { return bad(got.err, got.at) } let stop = cut.tokens[got.i] if stop.k == ")" { return bad("No bracket was opened for this one.", stop.at) } if stop.k != "end" { return bad("An operator should come here, such as + or *.", stop.at) } let out = expression_input_answer(got.v, decimals) { status: "ok", value: out.value, rounded: out.rounded, message: null, at: null }}// The place an error is about: the text around it, so a long expression stays short.fn expression_input_window(text: str, at: int) { let from = max([0, at - 24]) let upto = min([len(text), at + 25]) { before: `${from > 0 ? "…" : ""}${slice(text, from, at)}`, here: at < len(text) ? slice(text, at, at + 1) : " ", after: `${slice(text, at + 1, upto)}${upto < len(text) ? "…" : ""}` }}fn expression_input_shown(value: str, locale: str) { let places = len(split(value, ".")[1] ?? "") format_number(dec(value), locale, places)}component ExpressionInput(id: str, name: str? = null, label: str? = null, value: str = "", placeholder: str = "12*3+4, 15% of 80, (1+2)/3", hint: str? = null, required: bool = false, disabled: bool = false, decimals: int? = null, lowest = null, highest = null, locale: str = "en-US", on_change = null, class: str? = null) { style global expression_input_css state text = value let read = expression_input_eval(text, decimals) let answer = read.status == "ok" ? dec(read.value) : null let low = lowest != null ? dec(lowest) : null let high = highest != null ? dec(highest) : null let under = answer != null && low != null && answer < low let over = answer != null && high != null && answer > high let refused = under ? `The answer must be at least ${str(low)}.` : over ? `The answer must be at most ${str(high)}.` : null let result = read.status == "ok" && refused == null ? read.value : "" let place = read.at != null ? expression_input_window(text, read.at) : null let described = ui_cls([hint != null && `${id}-hint`, `${id}-out`]) <div class={ui_cls(["ui-exprinput", class])}> {label != null && <label.ui-exprinput-label for={id}> {label}{required && <span.ui-exprinput-req aria-hidden="true">*</span>} </label>} <input.ui-exprinput-control type="text" id={id} value={text} placeholder={placeholder} required={required} disabled={disabled} autocomplete="off" spellcheck="false" inputmode="text" aria-invalid={read.status == "bad" || refused != null ? "true" : null} aria-describedby={described} @input={(e) => { text = e.value let r = expression_input_eval(e.value, decimals) let v = r.status == "ok" ? dec(r.value) : null let fits = v != null && (low == null || v >= low) && (high == null || v <= high) on_change && on_change(fits ? v : null) }}/> {name != null && <input type="hidden" name={name} value={result}/>} {hint != null && <p.ui-exprinput-hint id={`${id}-hint`}>{hint}</p>} <div.ui-exprinput-out id={`${id}-out`} aria-live="polite"> {read.status == "bad" ? <span.ui-exprinput-bad>{read.message} (character {read.at + 1})</span> : refused != null ? <span.ui-exprinput-bad>{refused}</span> : read.status == "ok" ? <span>= <strong>{expression_input_shown(read.value, locale)}</strong> {read.rounded && <span.ui-exprinput-note>{decimals != null ? `Rounded to ${decimals} places.` : "Rounded: the decimals never end."}</span>}</span> : null} {place != null && <code.ui-exprinput-where aria-hidden="true">{place.before}<mark>{place.here}</mark>{place.after}</code>} </div> </div>}Related
AmountInputReads 1.5k, 2m, 1,234.50, 1.234,50, $12 and EUR 40, decides which mark groups, shows the normalised amount, flags what could go two ways, and sends a real decimal.3 views · 8 downloadsContactPastePaste an email signature or a blurb: it pulls out the name, email, phone, website, company and address into editable fields, marks how sure each guess is, and never overwrites an edit.5 views · 6 downloadsNaturalDateInputUnderstands next friday 5pm, in 3 days, end of month and 12/3 in the locale's order, says what it took, and offers the other readings when words mean two things.3 views · 6 downloadsBarcodeInputTells EAN-13, EAN-8, UPC-A, ISBN-10 and ISBN-13 apart by shape, checks the check digit, says what the right one would be, and converts ISBN-10 to ISBN-13.2 views · 5 downloadsEmailTypoInputChecks an address's shape, offers did you mean gmail.com by edit distance with one click to fix, warns of throwaway domains, and stays quiet on a valid uncommon domain.3 views · 6 downloadsFuzzySelectA combobox that ranks options by fuzzy subsequence score with matched letters highlighted and recent picks first, ignoring case and accents, with full ARIA keyboard behaviour and a real field.2 views · 6 downloads
Thread
Thread 0 posts
On ExpressionInput: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.