Mobile

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.

#mobile#interactive#keypad#number#input#forms

Demo

Preview
1204.5

Reading: 1204.5

Whole numbers, four digits at most:

12

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.qwe
main.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>}
Thread
57Useful? Like it, or add to its thread below.

Thread 0 posts

On NumberPad: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.