PinPad
A passcode keypad with dots, a shake on a wrong entry, a lock-out with a countdown, the code never kept in the page's text.
Demo
Enter passcode
The code is the middle column, top to bottom.
Two digits, checked here on the spot: 4 and 2.
Quick code
Get it
It needs theme.qwe, as every component does, and number_pad.qwe; 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$ curl -O https://quillolang.org/components/ui/pin_pad.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/pin_pad.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/pin_pad.qwe
// As a `server fn` would answer: later. The code stays out of the page, and so out of the demo's markup.fn pin_pad_demo_ask(code: str) = js("Promise.resolve", code == join(["2", "5", "8", "0"], ""))component PinPadDemoLive() { state open = false state showing = true state told = "" <div.demo-stack> <div id="pin-pad-demo-main"> {showing ? (open ? <p id="pin-pad-demo-open">Unlocked. Welcome back.</p> : <PinPad length={4} tries={3} lock_seconds={3} remember="pin-pad-demo" check={(code) => pin_pad_demo_ask(code)} on_success={() => { open = true }} on_locked={(s) => { told = `Locked for ${s} s` }}/>) : <p>The pad is away.</p>} </div> <p.demo-note id="pin-pad-demo-out">The code is the middle column, top to bottom. {told}</p> <div.demo-row> <Button size="lg" variant="outline" id="pin-pad-demo-again" on_click={() => { open = false; showing = !showing }}>{showing ? "Put it away" : "Bring it back"}</Button> </div> </div>}component PinPadDemoQuick() { state said = "Two digits, checked here on the spot: 4 and 2." <div id="pin-pad-demo-quick"> <p.demo-note id="pin-pad-demo-quick-out">{said}</p> <PinPad length={2} remember={null} title="Quick code" check={(code) => code == join(["4", "2"], "")} on_success={() => { said = "Quick code right." }}/> </div>}// What a broken check does: nothing as an answer, or no answer at all.component PinPadDemoBlank() { state showing = false <div.demo-stack> <Button size="lg" variant="outline" id="pin-pad-demo-nothing" on_click={() => { showing = true }}>A check that goes wrong</Button> <div id="pin-pad-demo-blank"> {showing && <PinPad length={1} remember={null} title="One digit" check={(code) => code == "2" ? js("Promise.reject", "offline") : js("Promise.resolve", js("window").nothing_here)}/>} </div> </div>}view PinPadDemo() = <div.demo-stack> <PinPadDemoLive/> <PinPadDemoQuick/> <PinPadDemoBlank/> </div>Source
ui/pin_pad.qwe
// PinPad: the passcode screen of a phone: dots for what has been typed and a keypad. A// wrong code shakes the dots and clears them, too many wrong in a row lock the pad for a// countdown, longer with each lock-out, which survives a reload, the phone sleeping and an// app being started again, as it is kept in a cookie (an app's localStorage is empty each launch).// The code is held only in the component's memory until it is checked: no input, no// text, no attribute of the page ever has a digit of it, and the keys' own light// goes as the finger lifts.//// component Lock() {// state open = false// open ? <Home/> : <PinPad length={4} check={(code) => check_pin(code)} on_success={() => { open = true }}/>// }//// check(code): required, gives true or false, and may be a `server fn`; an error value (no// network) says so and costs no try. length: digits (6). tries: wrong codes before// a lock-out (5). lock_seconds: the first lock-out (30); each one after is twice as long, to an// hour at most, until a right code. title: above the dots ("Enter passcode"). remember: the// name of the cookie that keeps tries and the lock-out, for 30 days, one per pad; null keeps// them in memory. disabled, class as for every field. on_success(): the code was right.// on_locked(seconds): a lock-out began.//// The lock-out here is a courtesy: it slows a person at the keys, not someone who can clear// the page's cookies or call the server. A server must count tries and lock for itself.// It stores only counts and a time. It needs number_pad.qwe. The keys// never move; there is no shuffled pad.//// pin_pad_lock_ms, pin_pad_load, pin_pad_save, pin_pad_cookie and pin_pad_time are the logic.import ui/theme.qwe use ui_clsimport ui/number_pad.qwe use NumberPadKeys, number_pad_feellet pin_pad_css = css`.ui-pin-pad { container-type: inline-size; min-width: 0; font: 15px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-pin-pad-body { display: grid; gap: 18px; min-width: 0; }.ui-pin-pad-head { display: grid; gap: 10px; justify-items: center; text-align: center; }.ui-pin-pad-title { margin: 0; font-size: 18px; font-weight: 600; }.ui-pin-pad-dots { display: flex; justify-content: center; gap: 14px; padding: 6px 0; }.ui-pin-pad-dot { width: 14px; height: 14px; border-radius: 50%; border: 2px solid var(--ui-strong); box-sizing: border-box; transition: background .1s; }.ui-pin-pad-on { border-color: var(--ui-accent); background: var(--ui-accent); }.ui-pin-pad-wrong .ui-pin-pad-dot { border-color: var(--ui-danger); }.ui-pin-pad-shake-1 { animation: ui-pin-pad-shake-a .4s ease-in-out; }.ui-pin-pad-shake-2 { animation: ui-pin-pad-shake-b .4s ease-in-out; }/* Two names for one shake: changing the class is what starts it again. */@keyframes ui-pin-pad-shake-a { 0%, 100% { transform: none; } 20%, 60% { transform: translateX(-10px); } 40%, 80% { transform: translateX(10px); } }@keyframes ui-pin-pad-shake-b { 0%, 100% { transform: none; } 20%, 60% { transform: translateX(-10px); } 40%, 80% { transform: translateX(10px); } }.ui-pin-pad-said { min-height: 21px; margin: 0; color: var(--ui-muted); }.ui-pin-pad-said[data-bad] { color: var(--ui-danger); }.ui-pin-pad-keys .ui-number-pad-key { border-radius: 50%; aspect-ratio: 1; min-height: 64px; max-width: 76px; justify-self: center; width: 100%; }.ui-pin-pad-keys .ui-number-pad-blank { min-height: 0; }@media (prefers-reduced-motion: reduce) { .ui-pin-pad-shake-1, .ui-pin-pad-shake-2 { animation: none; } .ui-pin-pad-dot { transition: none; }}@container (min-width: 440px) { .ui-pin-pad-body { grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr); align-items: center; gap: 16px; }}`// How long a lock-out lasts, in ms: seconds, then twice that each time, an hour at most.fn pin_pad_lock_ms(seconds: int, locks: int) -> int { let base = max([1, seconds]) let doubled = [1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096] min([base * doubled[min([max([0, locks - 1]), 12])], 3600]) * 1000}// Tries and lock-out as a cookie's value, and back: { wrong, locks, until }, with// nothing for text that is not ours.fn pin_pad_save(wrong: int, locks: int, until: int) -> str = `${wrong}.${locks}.${until}`fn pin_pad_load(text) { let blank = { wrong: 0, locks: 0, until: 0 } if type_of(text) != "str" || !re.test(text, "^[0-9]{1,4}[.][0-9]{1,4}[.][0-9]{1,15}$") { return blank } let p = split(text, ".") | map((s) => int(s)) { wrong: p[0], locks: p[1], until: p[2] }}// A cookie's name made safe, as it comes from a prop.fn pin_pad_name(remember: str) -> str = re.replace(remember, "[^A-Za-z0-9_-]", "_")// The value of one cookie in `document.cookie`, or null.fn pin_pad_cookie(all, name: str) { if type_of(all) != "str" { return null } let hits = split(all, ";") | map((c) => trim(c)) | filter((c) => starts_with(c, `${name}=`)) len(hits) > 0 ? slice(hits[0], len(name) + 1, len(hits[0])) : null}// Seconds as "0:27" or "1:05:00".fn pin_pad_time(seconds: int) -> str { let m = floor(seconds / 60) let s = seconds % 60 `${m}:${s < 10 ? "0" : ""}${s}`}fn pin_pad_clock() -> int = js("Date.now")component PinPad(check = null, length: int = 6, tries: int = 5, lock_seconds: int = 30, title: str = "Enter passcode", remember: str? = "quillo-pin-pad", disabled: bool = false, on_success = null, on_locked = null, class: str? = null) { style global pin_pad_css state entered = [] // the digits: only ever here, in memory state count = 0 state busy = false state opened = false state wrong = 0 state locks = 0 state until = 0 state left = 0 state shakes = 0 state bad = false state said = "" state due = false // full, and to be checked just after the finger is off let keep = () => { if remember != null { let d = js("document") d.cookie = `${pin_pad_name(remember)}=${pin_pad_save(wrong, locks, until)}; path=/; max-age=2592000; samesite=strict` } } let tick = () => { let ms = until - pin_pad_clock() // A clock set back cannot make a lock-out longer than it was. left = min([ceil(ms / 1000), floor(pin_pad_lock_ms(lock_seconds, locks) / 1000)]) if left <= 0 { until = 0 left = 0 bad = false said = "" keep() } } if until > 0 { every(250, tick) } // null by default: a Promise of nothing calls this with nothing. let settle = (ok = null) => { busy = false count = 0 if is_err(ok) { bad = true said = "Could not check it. Try again." } else { if ok == true { wrong = 0 locks = 0 opened = true bad = false said = "Unlocked" keep() on_success && on_success() number_pad_feel("success") } else { wrong += 1 shakes += 1 bad = true if wrong >= tries { wrong = 0 locks += 1 let ms = pin_pad_lock_ms(lock_seconds, locks) until = pin_pad_clock() + ms left = floor(ms / 1000) said = "Too many wrong codes." keep() on_locked && on_locked(left) } else { let more = tries - wrong said = `Wrong code. ${more} ${more == 1 ? "try" : "tries"} left.` keep() } number_pad_feel("error") } } } let submit = () => { let code = join(entered, "") entered = [] busy = true let answer = check != null ? check(code) : false // A function given as a prop is not waited for, even a `server fn`: it comes as a Promise. if type_of(answer) == "promise" { answer.then(settle, (_why = null) => { settle(err("no answer")) }) } else { settle(answer) } } // Past the click that ends the tap: a pad that opened on the touch would leave that // click to land on whatever came up under the finger. let send_it = () => { due = false submit() } if due { every(60, send_it) } let locked = until > 0 <div class={ui_cls(["ui-pin-pad", class])} @mount={() => { if remember != null { let was = pin_pad_load(pin_pad_cookie(js("document").cookie, pin_pad_name(remember))) wrong = was.wrong locks = was.locks if was.until > pin_pad_clock() { until = was.until tick() } } }}> <div.ui-pin-pad-body> <div.ui-pin-pad-head> <p.ui-pin-pad-title>{title}</p> <div class={ui_cls(["ui-pin-pad-dots", shakes > 0 && `ui-pin-pad-shake-${shakes % 2 + 1}`, bad && count == 0 && "ui-pin-pad-wrong"])} role="img" aria-label={`${count} of ${length} digits entered`}> {for i in range(length) => <span key={i} class={ui_cls(["ui-pin-pad-dot", i < count && "ui-pin-pad-on"])}></span>} </div> <p.ui-pin-pad-said aria-live="polite" data-bad={bad ? "" : null}>{locked ? `Try again in ${pin_pad_time(left)}` : said}</p> </div> <NumberPadKeys class="ui-pin-pad-keys" disabled={disabled || locked || opened} label={title} quiet={true} on_key={(k) => { if !busy && !locked && !opened { if k == "back" { entered = slice(entered, 0, max([0, len(entered) - 1])) count = len(entered) } else { if said != "" { said = "" bad = false } if count < length { entered = entered + [k] count = len(entered) } } } }} on_lift={() => { if count >= length && !busy && !due { due = true } }}/> </div> </div>}Related
AmountPadBig amount entry for money: digits fill from the right, currency rules for decimals, limits, a shake on a refused key.2 views · 6 downloadsConfirmWithPinA step-up before something risky: what is about to happen, a PIN pad, tries counted, and a clock.1 view · 5 downloadsMeterReadingA round of meter readings on a big keypad, each checked against the last value and a sane range, with a photo on anything odd.4 views · 6 downloadsNumberPadAn on-screen number pad with a decimal and delete, a hold to delete fast and a haptic, for where the system keyboard is wrong.5 views · 7 downloadsQuickTransferRecent people in a row, a keypad amount checked against every limit, and a slide to send.2 views · 8 downloadsActionSheetA list of actions rising from the bottom, with a destructive style and Cancel: swipe down or the phone's back gesture closes it; on a tablet, a card by what opened it.3 views · 9 downloads
Thread
Thread 0 posts
On PinPad: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.