Forms

OtpInput

Boxes for a one-time code: typing moves along, Backspace goes back, pasting fills them all.

#forms#input#security#interactive

Demo

Preview
Verification code

We sent it to ada@example.com.

Type the code: 6 digits.

Recovery key

Letters and digits; paste it whole.

PIN

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

Thread 0 posts

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

No posts yet. The first one opens the thread.