OtpInput
Boxes for a one-time code: typing moves along, Backspace goes back, pasting fills them all.
Demo
Type the code: 6 digits.
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/otp_input.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/otp_input.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/otp_input.qwe
component OtpInputDemoLive() { state status = "Type the code: 6 digits." <div.demo-stack> <OtpInput name="otp-demo-live" label="Verification code" hint="We sent it to ada@example.com." on_change={(c) => { status = `${len(c)} of 6 typed.` }} on_complete={(c) => { status = `Checking ${c}…` }}/> <p.demo-note>{status}</p> </div>}view OtpInputDemo() = <div.demo-stack> <OtpInputDemoLive/> <OtpInput name="otp-demo-key" label="Recovery key" length={8} digits={false} groups={4} hint="Letters and digits; paste it whole."/> <OtpInput name="otp-demo-pin" label="PIN" length={4} masked={true} value="12" error="That PIN is not right: 2 tries left."/> </div>Source
ui/otp_input.qwe
// OtpInput: a one-time code typed into a row of boxes, one character each, as// sent by email or text message. Typing moves to the next box, Backspace in an// empty box goes back, the arrow keys move along, and pasting the whole code// (or the phone offering it, autocomplete "one-time-code") fills every box. The// code goes in a hidden input named `name`, so a plain <form> sends it whole.//// <OtpInput name="code" label="Code from the email" hint="It is valid for 10 minutes."/>// <OtpInput name="key" label="Recovery key" length={8} digits={false} groups={4}/>// <OtpInput name="pin" label="PIN" length={4} masked={true} on_complete={(c) => { check(c) }}/>//// length: how many boxes (6). digits: only 0-9 (the default; false takes letters// and digits, made upper case). groups: a gap after every so many boxes (3 for// 6 boxes, none otherwise). masked: shows dots, for a PIN. value: a code to start with.// name ("code"), label, hint, error, required (each box), disabled and field (the// ids' start, "otp-input-" + name by default) as for every field.// on_change(code) on each change and on_complete(code) once every box is filled:// inside one of your components, given the code typed so far.import ui/theme.qwe use ui_clslet otp_input_css = css`.ui-otp-input { display: grid; grid-template-columns: minmax(0, 1fr); gap: 6px; min-width: 0; margin: 0; padding: 0; border: 0; font: 14px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-otp-input-label { padding: 0; font-size: 13px; font-weight: 500; color: var(--ui-fg); }.ui-otp-input-req { margin-left: 2px; color: var(--ui-danger); }.ui-otp-input-boxes { display: flex; flex-wrap: nowrap; align-items: center; gap: 8px; min-width: 0; }.ui-otp-input-sep { flex: 0 1 10px; min-width: 4px; width: 10px; height: 2px; border-radius: 1px; background: var(--ui-strong); }.ui-otp-input-box { flex: 0 1 44px; min-width: 0; width: 44px; height: 52px; margin: 0; padding: 0; text-align: center; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 600 22px/1 var(--ui-mono); caret-color: var(--ui-accent); transition: border-color .15s, box-shadow .15s;}.ui-otp-input-box:hover { border-color: var(--ui-strong); }.ui-otp-input-box:focus { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-otp-input-filled { border-color: var(--ui-strong); background: var(--ui-subtle); }.ui-otp-input-box[aria-invalid="true"] { border-color: var(--ui-danger); }.ui-otp-input-box:disabled { opacity: .55; cursor: not-allowed; }.ui-otp-input-masked .ui-otp-input-box { -webkit-text-security: disc; }.ui-otp-input-hint, .ui-otp-input-error { margin: 0; font-size: 13px; color: var(--ui-muted); }.ui-otp-input-error { color: var(--ui-danger); }@media (max-width: 420px) { .ui-otp-input-boxes { gap: 5px; } .ui-otp-input-box { height: 46px; font-size: 19px; } }@media (prefers-reduced-motion: reduce) { .ui-otp-input-box { transition: none; } }`// The characters of `text` a box takes: digits, or letters and digits in upper case.fn otp_input_clean(text: str, digits: bool) -> list = split(digits ? re.replace(text, "[^0-9]", "") : upper(re.replace(text, "[^A-Za-z0-9]", "")), "") | filter((c) => c != "")// `chars` with `put` written over them from box `at` on (as far as there are boxes).fn otp_input_put(chars: list, at: int, put: list) -> list = for [i, c] in enumerate(chars) => i >= at && i - at < len(put) ? put[i - at] : ccomponent OtpInput(name: str = "code", label: str? = null, length: int = 6, digits: bool = true, groups: int? = null, masked: bool = false, value: str = "", hint: str? = null, error: str? = null, required: bool = false, disabled: bool = false, field: str? = null, on_change = null, on_complete = null, class: str? = null) { style global otp_input_css state chars = otp_input_put(range(length) | map((i) => ""), 0, otp_input_clean(value, digits)) state said = "" let id = field ?? `otp-input-${name}` let every_n = groups ?? (length == 6 ? 3 : length) let code = join(chars, "") let described = ui_cls([hint != null && `${id}-hint`, error != null && `${id}-error`]) let box = (e, i: int) => e.target.closest(".ui-otp-input-boxes").querySelector(`[data-i="${i}"]`) let go = (e, i: int) => { let el = box(e, i < 0 ? 0 : i >= length ? length - 1 : i) el.focus() el.select() } let update = (next: list) => { if next != chars { chars = next let c = join(next, "") on_change && on_change(c) if len(c) == length { said = "Code complete" on_complete && on_complete(c) } else { said = "" } } } // Puts typed or pasted characters in from box i, then moves to the box after them. let fill = (e, i: int, text: str) => { let got = otp_input_clean(text, digits) if len(got) > 0 { let from = len(got) >= length ? 0 : i update(otp_input_put(chars, from, got)) go(e, from + len(got)) } } <fieldset class={ui_cls(["ui-otp-input", masked && "ui-otp-input-masked", class])} id={id} disabled={disabled} aria-describedby={described != "" ? described : null}> {label != null && <legend.ui-otp-input-label> {label}{required && <span.ui-otp-input-req aria-hidden="true">*</span>} </legend>} <div.ui-otp-input-boxes> {for [i, c] in enumerate(chars) => [ i > 0 && i % every_n == 0 && <span.ui-otp-input-sep aria-hidden="true"></span>, <input class={ui_cls(["ui-otp-input-box", c != "" && "ui-otp-input-filled"])} key={i} data-i={i} id={i == 0 ? `${id}-0` : null} type="text" value={c} inputmode={digits ? "numeric" : "text"} pattern={digits ? "[0-9]*" : null} autocomplete={i == 0 ? "one-time-code" : "off"} autocapitalize="characters" spellcheck="false" required={required} aria-label={`Character ${i + 1} of ${length}`} aria-invalid={error != null ? "true" : null} @focus={(e) => { e.target.select() }} @input={(e) => { let typed = e.value if typed == "" { update(otp_input_put(chars, i, [""])) } else { // The box may hold the old character too: keep what is new. let fresh = len(typed) == 2 && c != "" && starts_with(typed, c) ? slice(typed, 1, 2) : len(typed) == 2 && c != "" && ends_with(typed, c) ? slice(typed, 0, 1) : typed let got = otp_input_clean(fresh, digits) e.target.value = len(got) > 0 ? got[0] : c fill(e, i, fresh) } }} @paste={(e) => { e.preventDefault() fill(e, i, e.clipboardData.getData("text")) }} @keydown={(e) => { if e.key == "Backspace" && c == "" && i > 0 { e.preventDefault() update(otp_input_put(chars, i - 1, [""])) go(e, i - 1) } if e.key == "ArrowLeft" || e.key == "ArrowRight" || e.key == "Home" || e.key == "End" { e.preventDefault() go(e, e.key == "ArrowLeft" ? i - 1 : e.key == "ArrowRight" ? i + 1 : e.key == "Home" ? 0 : length - 1) } }}/>, ]} </div> <input type="hidden" name={name} value={code}/> <span.ui-sr aria-live="polite">{said}</span> {hint != null && <p.ui-otp-input-hint id={`${id}-hint`}>{hint}</p>} {error != null && <p.ui-otp-input-error id={`${id}-error`} role="alert">{error}</p>} </fieldset>}Related
ConsentCheckboxA required checkbox beside legal text with links to the terms and the privacy policy, posted as a real checkbox.5 views · 6 downloadsPasswordInputA password field with a show/hide toggle and, for new passwords, a strength meter.5 views · 4 downloadsPasswordStrengthA new-password field with a meter that fills as it gets stronger and a checklist of rules ticked as they are met.5 views · 4 downloadsAddressFormStreet, city, region, postcode and country in the usual layout, with the autocomplete names browsers fill from.5 views · 7 downloadsAuthFormSign in, sign up, forgot and reset password in one form that switches in place, each posting to its own route.7 views · 4 downloadsAutocompleteA free-text box that offers matches as one types, with the arrow keys and Enter; whatever is typed may stay.5 views · 7 downloads
Thread
Thread 0 posts
On OtpInput: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.