MaskedInput
A box that shapes what is typed to a pattern (### ###-####, AA-00-AA) as it is typed, dropping what does not fit.
Demo
Sent as digits only.
Nothing typed yet.
That card has expired.
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/masked_input.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/masked_input.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/masked_input.qwe
component MaskedInputDemoLive() { state plate = "" state done = false <div.demo-stack> <MaskedInput name="mi-demo-plate" label="Licence plate" mask="AA-00-AA" on_change={(v, d) => { plate = v done = d }}/> <p.demo-note>{plate == "" ? "Nothing typed yet." : done ? `Plate ${plate} is complete.` : `So far: ${plate}`}</p> </div>}view MaskedInputDemo() { <div.demo-stack> <div.demo-row> <MaskedInput name="mi-demo-phone" label="Phone" mask="(###) ###-####" raw={true} hint="Sent as digits only."/> <MaskedInputDemoLive/> </div> <div.demo-row> <MaskedInput name="mi-demo-code" label="Product code" mask="AAA-####" value="abc1234" size="sm"/> <MaskedInput name="mi-demo-exp" label="Expiry" mask="##/##" placeholder="MM/YY" error="That card has expired."/> </div> </div>}Source
ui/masked_input.qwe
// MaskedInput: a box that shapes what is typed to a pattern as it is typed: a// phone as "### ### ####", a licence plate as "AA-00-AA", a card's expiry as// "##/##", a product code as "AAA-####". Characters that do not fit are dropped,// the mask's own characters (spaces, dashes, slashes) appear by themselves, and// letters are made upper case. The value goes in a hidden input named `name`, so// a plain <form> sends it: shaped ("555 123 4567"), or with `raw` the characters// typed alone ("5551234567").//// <MaskedInput name="phone" label="Phone" mask="(###) ###-####" raw={true}/>// <MaskedInput name="plate" label="Plate" mask="AA-00-AA" on_change={(v, done) => { plate = done ? v : null }}/>//// mask: "#" or "0" take a digit, "A" a letter, "*" a letter or digit; anything// else is written as it is. value: text to start with, shaped by the mask.// placeholder: shown while empty (the mask with "_" in the slots by default).// raw: the hidden input gets only what was typed, not the mask's characters.// name, label, hint, error, required, disabled, field (the box's id,// "masked-input-" + name by default) and size ("sm" or "md") as for every field.// on_change(value, complete): inside one of your components, given the value the// form would send and whether every slot is filled, as it is typed.import ui/theme.qwe use ui_clslet masked_input_css = css`.ui-masked-input { display: grid; gap: 6px; min-width: 0; font: 14px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-masked-input-label { font-size: 13px; font-weight: 500; }.ui-masked-input-req { margin-left: 2px; color: var(--ui-danger); }.ui-masked-input-box { display: flex; align-items: center; gap: 8px; width: 220px; max-width: 100%; height: 38px; padding: 0 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); transition: border-color .15s, box-shadow .15s;}.ui-masked-input-box:hover { border-color: var(--ui-strong); }.ui-masked-input-box:focus-within { border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-masked-input-box input { flex: 1; min-width: 0; height: 100%; padding: 0; border: 0; outline: none; background: transparent; color: var(--ui-fg); font: 14px var(--ui-mono); letter-spacing: .04em;}.ui-masked-input-box input::placeholder { color: var(--ui-muted); opacity: 1; }.ui-masked-input-ok { flex: none; width: 16px; height: 16px; color: var(--ui-success); opacity: 0; transition: opacity .15s; }.ui-masked-input-done .ui-masked-input-ok { opacity: 1; }.ui-masked-input-sm .ui-masked-input-box { height: 30px; width: 180px; padding: 0 10px; }.ui-masked-input-sm .ui-masked-input-box input { font-size: 13px; }.ui-masked-input-invalid .ui-masked-input-box, .ui-masked-input-invalid .ui-masked-input-box:hover { border-color: var(--ui-danger); }.ui-masked-input-hint, .ui-masked-input-error { margin: 0; font-size: 13px; color: var(--ui-muted); }.ui-masked-input-error { color: var(--ui-danger); }.ui-masked-input-disabled { opacity: .55; }.ui-masked-input-disabled input { cursor: not-allowed; }@media (prefers-reduced-motion: reduce) { .ui-masked-input-box, .ui-masked-input-ok { transition: none; } }`// Whether `c` fits a mask character: a digit for # and 0, a letter for A, either for *.fn masked_input_fits(m: str, c: str) -> bool = m == "#" || m == "0" ? re.test(c, "^[0-9]$") : m == "A" ? re.test(c, "^[A-Za-z]$") : m == "*" ? re.test(c, "^[A-Za-z0-9]$") : falsefn masked_input_is_slot(m: str) -> bool = m == "#" || m == "0" || m == "A" || m == "*"// `text` shaped by `mask`: { shown, raw, complete }. Characters that fit no slot// are dropped; the mask's own characters are put in as long as a slot after them// is filled (so "555" shows as "555", not "555 ").fn masked_input_apply(mask: str, text: str) { let slots = split(mask, "") let chars = split(upper(text), "") | filter((c) => re.test(c, "^[A-Za-z0-9]$")) let r = reduce(slots, { out: "", pending: "", raw: "", i: 0, done: false }, (acc, m) => { if acc.done { return acc } if !masked_input_is_slot(m) { return { ...acc, pending: acc.pending + m } } // The next typed character that fits this slot; those before it are dropped. let rest = drop(chars, acc.i) let at = index_of(rest | map((c) => masked_input_fits(m, c)), true) if at == null { return { ...acc, done: true } } let c = rest[at] { out: acc.out + acc.pending + c, pending: "", raw: acc.raw + c, i: acc.i + at + 1, done: false } }) { shown: r.out, raw: r.raw, complete: len(r.raw) == len(slots | filter((m) => masked_input_is_slot(m))) }}fn masked_input_placeholder(mask: str) -> str = join(split(mask, "") | map((m) => masked_input_is_slot(m) ? "_" : m), "")component MaskedInput(name: str? = null, label: str? = null, mask: str = "### ### ####", value: str = "", placeholder: str? = null, raw: bool = false, hint: str? = null, error: str? = null, required: bool = false, disabled: bool = false, field: str? = null, size: str = "md", on_change = null, class: str? = null) { style global masked_input_css state text = masked_input_apply(mask, value).shown let got = masked_input_apply(mask, text) let sent = raw ? got.raw : got.shown let id = field ?? `masked-input-${name ?? re.replace(lower(label ?? "field"), "[^a-z0-9]+", "-")}` let described = ui_cls([hint != null && `${id}-hint`, error != null && `${id}-error`]) <div class={ui_cls(["ui-masked-input", size == "sm" && "ui-masked-input-sm", got.complete && "ui-masked-input-done", error != null && "ui-masked-input-invalid", disabled && "ui-masked-input-disabled", class])}> {label != null && <label.ui-masked-input-label for={id}> {label}{required && <span.ui-masked-input-req aria-hidden="true">*</span>} </label>} <div.ui-masked-input-box> <input type="text" id={id} value={text} placeholder={placeholder ?? masked_input_placeholder(mask)} maxlength={len(mask)} inputmode={re.test(mask, "^[^A*]*$") ? "numeric" : "text"} autocomplete="off" autocapitalize="characters" spellcheck="false" required={required} disabled={disabled} aria-invalid={error != null ? "true" : null} aria-describedby={described != "" ? described : null} @input={(e) => { let caret = e.target.selectionStart ?? len(e.value) let at_end = caret >= len(e.value) let next = masked_input_apply(mask, e.value) text = next.shown e.target.value = next.shown if !at_end { // Keep the caret after the characters typed before it. let before = len(split(upper(slice(e.value, 0, caret)), "") | filter((c) => re.test(c, "^[A-Za-z0-9]$"))) let pos = reduce(enumerate(split(next.shown, "")), { n: 0, at: len(next.shown) }, (acc, p) => acc.n >= before && acc.at == len(next.shown) && masked_input_is_slot(mask[p[0]]) ? { n: acc.n, at: p[0] } : re.test(p[1], "^[A-Za-z0-9]$") && masked_input_is_slot(mask[p[0]]) ? { n: acc.n + 1, at: acc.at } : acc) e.target.setSelectionRange(pos.at, pos.at) } on_change && on_change(raw ? next.raw : next.shown, next.complete) }}/> <svg.ui-masked-input-ok viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3.5 8.5l3 3 6-7"/></svg> </div> {name != null && <input type="hidden" name={name} value={sent}/>} {hint != null && <p.ui-masked-input-hint id={`${id}-hint`}>{hint}</p>} {error != null && <p.ui-masked-input-error id={`${id}-error`} role="alert">{error}</p>} </div>}Related
CharacterCounterA text box or textarea with a live 120 / 280 count that warns near the limit, as a hard stop or a soft one that blocks sending.5 views · 6 downloadsInlineEditText that turns into a box to change it in place: Enter saves, Escape puts it back.5 views · 7 downloadsMarkdownEditorA Markdown textarea with a toolbar that wraps the selection (bold, italic, link, list) and a Preview tab rendered as the page will show it.5 views · 8 downloadsRichTextEditorA formatted-text editor (bold, italic, lists, links, headings) on contenteditable, its HTML kept in a hidden input a form sends.7 views · 5 downloadsTextAreaA box for several lines, with a live count against a limit and an option to grow with what is typed.5 views · 8 downloadsTextFieldA labelled input with help text, an error message and short text before or after it.5 views · 8 downloads
Thread
Thread 0 posts
On MaskedInput: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.