NumberPad
An on-screen number pad with a decimal and delete, a hold to delete fast and a haptic, for where the system keyboard is wrong.
Demo
Reading: 1204.5
Whole numbers, four digits at most:
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/number_pad.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/number_pad.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/number_pad.qwe
component NumberPadDemoLive() { state reading = "1204.5" <div.demo-stack> <div id="number-pad-demo-reading"> <NumberPad label="Meter reading" name="reading" decimal={true} places={1} limit={6} value={reading} on_change={(t) => { reading = t }}/> </div> <p.demo-note id="number-pad-demo-out">Reading: {reading}</p> <Button size="lg" variant="outline" id="number-pad-demo-clear" on_click={() => { reading = "" }}>Start again</Button> </div>}view NumberPadDemo() = <div.demo-stack> <NumberPadDemoLive/> <div id="number-pad-demo-count"> <p.demo-note>Whole numbers, four digits at most:</p> <NumberPad label="Boxes counted" value="12" limit={4} name="boxes"/> </div> </div>Source
ui/number_pad.qwe
// NumberPad: a number pad drawn on the page, for where the system's keyboard is wrong: on a// shop's counter, over a form the keyboard would cover, for a meter reading with a fixed// form. It has digits, a decimal point if asked, and a delete key: hold it and it deletes// faster and faster. Each key gives a small tap you can feel where the phone can (the// apps' haptics, then the browser's vibrate), and says on screen where it cannot.//// <NumberPad label="Meter reading" name="reading" decimal={true} places={1} on_change={(t) => { reading = t }}/>// <NumberPad label="Quantity" value="12" limit={4}/>//// value: the number to start with, as text, and it replaces what is there when it changes.// decimal: a key for the decimal point (false). places: at most so many digits after it.// limit: at most so many digits before it (12). label: its name for screen readers. name:// a hidden field that holds the text. placeholder: what shows while empty ("0"). quiet: true// says nothing of a device that cannot vibrate. disabled, class as for every field.// on_change(text): inside one of your components, given the text after each key. It is// text, as "0." and "1.50" are not numbers yet.//// Keys go on the finger's touch, not its lift, so two thumbs overlapping lose nothing, and// keep working by click for a keyboard's Enter and a screen reader. With the focus in the// pad a real keyboard works too: digits, the point (or a comma), Backspace.// On a wide container (440 px and up) the number stands beside the keys.//// NumberPadKeys(extra, disabled, label, quiet, on_key, on_lift) is the keys alone, for other// components: extra is the key left of 0 ("." or "00", or none) and on_key(key) is given// the key's text, or "back". on_lift() comes when the finger is off the key: what makes the// pad go away waits for that, or the click that ends the tap lands on what is under it.// number_pad_press(text, key, decimal, places, limit) is the// logic, and number_pad_feel(kind) a tap felt: it gives false where there is none.import ui/theme.qwe use ui_clslet number_pad_css = css`.ui-number-pad { container-type: inline-size; min-width: 0; font: 16px/1.3 var(--ui-font); color: var(--ui-fg); }.ui-number-pad-body { display: grid; gap: 12px; min-width: 0; }.ui-number-pad-figure { display: flex; align-items: center; justify-content: flex-end; box-sizing: border-box; min-height: 64px; padding: 8px 14px; overflow: hidden; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); font: 600 32px/1.2 var(--ui-mono); overflow-wrap: anywhere;}.ui-number-pad-empty { color: var(--ui-muted); }.ui-number-pad-keys { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; max-width: 420px; width: 100%; margin-inline: auto; padding-bottom: env(safe-area-inset-bottom); }/* pan-y: the page still scrolls from here, but two thumbs on the keys cannot zoom it. */.ui-number-pad-key { display: grid; place-items: center; min-height: 60px; min-width: 0; margin: 0; padding: 0; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); color: var(--ui-fg); font: 500 26px/1 var(--ui-font); cursor: pointer; touch-action: pan-y; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; -webkit-tap-highlight-color: transparent;}.ui-number-pad-key:active { background: var(--ui-subtle); }.ui-number-pad-key:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-number-pad-key:disabled { opacity: .5; cursor: not-allowed; }/* A finger resting on delete must not turn into a scroll that ends the hold. */.ui-number-pad-back { touch-action: none; background: var(--ui-subtle); }.ui-number-pad-back svg { width: 26px; height: 26px; }.ui-number-pad-blank { min-height: 60px; }.ui-number-pad-note { grid-column: 1 / -1; margin: 0; color: var(--ui-muted); font-size: 13px; text-align: center; }@media (hover: hover) { .ui-number-pad-key:hover:not(:disabled) { border-color: var(--ui-strong); } }@container (min-width: 440px) { .ui-number-pad-body { grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr); align-items: center; gap: 20px; } .ui-number-pad-figure { min-height: 120px; font-size: 40px; }}`// A tap felt: the app's haptics, then the browser's vibrate, which needs a tap already// made, so call it from a click. Gives false where a device has neither.fn number_pad_feel(kind: str = "light") -> bool { if !is_err(js("window.quilloNative")) { js("quilloNative.haptic", kind) return true } if js("navigator").vibrate == null { return false } js("navigator.vibrate", kind == "error" || kind == "warning" ? [20, 40, 20] : 8) true}// What the text becomes after a key: the same text if the key is refused.fn number_pad_press(text: str, k: str, decimal: bool, places, limit: int) -> str { if k == "back" { return slice(text, 0, max([0, len(text) - 1])) } let parts = split(text, ".") if k == "." { return !decimal || len(parts) > 1 || places == 0 ? text : text == "" ? "0." : text + "." } if len(parts) > 1 { return places != null && len(parts[1]) >= places ? text : text + k } if text == "0" { return k } len(text) >= limit ? text : text + k}// Only text the pad could have made itself.fn number_pad_clean(value, decimal: bool) -> str { let t = value == null ? "" : str(value) re.test(t, decimal ? "^[0-9]{0,30}([.][0-9]{0,30})?$" : "^[0-9]{0,30}$") ? t : ""}component NumberPadKeys(extra: str? = null, disabled: bool = false, label: str = "Number pad", quiet: bool = false, on_key = null, on_lift = null, class: str? = null) { style global number_pad_css state holding = false state rate = 380 state repeats = 0 state numb = false let send = (k: str) => { on_key && on_key(k) } let tick = () => { repeats += 1 rate = repeats < 4 ? 130 : repeats < 12 ? 70 : 35 send("back") } if holding { every(rate, tick) } let stop = () => { holding = false } let felt = (kind: str) => { let has = number_pad_feel(kind) if !has { numb = true } } let lifted = () => { on_lift && on_lift() } let down = (e, k: str) => { if e.button == 0 && !disabled { send(k) if !is_err(js("window.quilloNative")) { js("quilloNative.haptic", "light") } } } // A click with no detail is a keyboard's or a screen reader's: it had no touch to press // the key. Any other was pressed at its touch, and sending it again here would land on // whatever a key press put under the finger. let up = (e, k: str) => { if e.detail == 0 { send(k) lifted() } felt("light") } let digits = ["1", "2", "3", "4", "5", "6", "7", "8", "9"] <div class={ui_cls(["ui-number-pad-keys", class])} role="group" aria-label={label} @keydown={(e) => { if !disabled && !e.metaKey && !e.ctrlKey { if len(e.key) == 1 && re.test(e.key, "^[0-9]$") { e.preventDefault() send(e.key) lifted() } if e.key == "Backspace" || e.key == "Delete" { e.preventDefault() send("back") } if (e.key == "." || e.key == ",") && extra == "." { e.preventDefault() send(".") } } }}> {for k in digits => <button.ui-number-pad-key key={k} type="button" disabled={disabled} data-key={k} @pointerdown={(e) => { down(e, k) }} @pointerup={lifted} @pointercancel={lifted} @click={(e) => { up(e, k) }}>{k}</button>} {extra != null ? <button.ui-number-pad-key type="button" disabled={disabled} data-key={extra} aria-label={extra == "." ? "Decimal point" : extra} @pointerdown={(e) => { down(e, extra) }} @pointerup={lifted} @pointercancel={lifted} @click={(e) => { up(e, extra) }}>{extra}</button> : <span.ui-number-pad-blank aria-hidden="true"></span>} <button.ui-number-pad-key type="button" disabled={disabled} data-key="0" @pointerdown={(e) => { down(e, "0") }} @pointerup={lifted} @pointercancel={lifted} @click={(e) => { up(e, "0") }}>0</button> <button class="ui-number-pad-key ui-number-pad-back" type="button" disabled={disabled} data-key="back" aria-label="Delete" @contextmenu={(e) => { e.preventDefault() }} @pointerdown={(e) => { if e.button == 0 && !disabled { e.target.closest("button").setPointerCapture(e.pointerId) repeats = 0 rate = 380 holding = true } }} @pointerup={stop} @pointercancel={stop} @lostpointercapture={stop} @click={(e) => { if repeats == 0 || e.detail == 0 { send("back") } repeats = 0 felt("light") }}> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <path d="M9 5h11v14H9l-6-7z"/><path d="m12 9 5 6m0-6-5 6"/> </svg> </button> {numb && !quiet && <p.ui-number-pad-note role="status">This device cannot vibrate, so the keys give no feel.</p>} </div>}component NumberPad(value: str = "", decimal: bool = false, places: int? = null, limit: int = 12, label: str = "Number", name: str? = null, placeholder: str = "0", quiet: bool = false, disabled: bool = false, on_change = null, class: str? = null) { style global number_pad_css let given = number_pad_clean(value, decimal) state text = given state seen = given if given != seen { seen = given text = given } <div class={ui_cls(["ui-number-pad", class])}> <div.ui-number-pad-body> <output class={ui_cls(["ui-number-pad-figure", text == "" && "ui-number-pad-empty"])} aria-label={label} aria-live="polite">{text == "" ? placeholder : text}</output> <NumberPadKeys extra={decimal ? "." : null} disabled={disabled} label={label} quiet={quiet} on_key={(k) => { let next = number_pad_press(text, k, decimal, places, limit) if next != text { text = next on_change && on_change(next) } }}/> </div> {name != null && <input type="hidden" name={name} value={text}/>} </div>}Related
StepperRowMinus and plus in a row, a hold to repeat and accelerate, typed values, limits, min and max stated to screen readers.3 views · 7 downloadsAmountPadBig amount entry for money: digits fill from the right, currency rules for decimals, limits, a shake on a refused key.2 views · 6 downloadsComposerBarA message box that grows to a limit, swaps send and voice, keeps attachments in a tray, rides above the keyboard and is safe to paste into.3 views · 9 downloadsDateWheelsMonth, day and year wheels that keep each other valid, with limits, and a value in ISO.2 views · 6 downloadsDraftKeeperKeeps what was typed on this device through a reload and a crash, offers it back, and clears on send.2 views · 8 downloadsGalleryPickerPicks several photos through the system's picker, with thumbnails, an order, caps by count and size, HEIC refused with a reason, and previews freed after.2 views · 5 downloads
Thread
Thread 0 posts
On NumberPad: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.