UsernameInput
Ticks off each rule live, checks taken names in any case and for look-alikes such as l and 1 or O and 0, and offers free names made from the person's own.
Demo
- 3 to 12 characters
- Starts with a letter
- Only plain letters, digits and -
- No separator at the end, or two in a row
Type a name to see on_change.
Get it
It needs theme.qwe, as every component does, and slug_input.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/slug_input.qwe$ curl -O https://quillolang.org/components/ui/username_input.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/username_input.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/username_input.qwe
let username_input_demo_taken = ["ana", "ana_nunez", "anunez", "bill", "cool", "Mike", "Ivan", "summer"]let username_input_demo_reserved = ["admin", "root", "support", "help"]component UsernameInputDemoLive() { state got = null <div.demo-stack> <UsernameInput id="username-demo-live" name="handle" label="Handle" taken={username_input_demo_taken} separators="-" max_length={12} suggestions={3} on_change={(r) => { got = r }}/> <p.demo-note>{got == null ? "Type a name to see on_change." : `on_change: ${got.status} ${got.username}`}</p> </div>}view UsernameInputDemo() = <div class="demo-stack demo-narrow"> <form> <UsernameInput id="username-demo" taken={username_input_demo_taken} reserved={username_input_demo_reserved} person="Ana Sofía Núñez" required={true}/> </form> <UsernameInputDemoLive/> </div>Source
ui/username_input.qwe
// UsernameInput: a username field that says what it wants before the form is sent. Each// rule ticks as it is met, a name is checked against those in use in any case, and also// for look-alikes: with "bill" taken, "bi11" and "biIl" are refused, since nobody could// tell them apart on a page and one could pass for the other. When a name will not do it// offers free ones made from the person's own name.//// <UsernameInput id="user" name="username" label="Username" taken={names} reserved={["admin"]} person="Ana Sofía Núñez"/>// <UsernameInput id="handle" taken={names} on_change={(r) => { ok = r.status == "free" }}/>//// id: a prefix for its ids, unique on the page. name: the field's name ("username"), a real// field. label: its label ("Username"). value: the name to start with. taken: the names in// use, any case. reserved: names kept for the site, refused the same way. person: the// person's own name, to make suggestions from; without it they come from what is typed.// min_length (3), max_length (20): how long a name may be. separators: which of _ . - may// join words ("_."). suggestions: how many to offer (4). hint, required, disabled as for// every field. class: more classes. on_change(result): inside one of your components, {// username, status } after each change; status as below. A leading @ is dropped as typed.//// username_input_check(text, taken, reserved, min_length, max_length, separators) is the// check, for a program's own use (the server must check again): { status, message, near,// rules }. status is "empty", "invalid", "reserved", "taken", "lookalike" or "free"; near// is the name it clashes with; rules are { id, label, ok }. username_input_skeleton(text)// is what a name looks like: two names with one skeleton look the same. It folds case,// 1 and l, 0 and O, rn and m, vv and w, and a capital I is compared as an l and as an i// (username_input_shapes). username_input_suggest(person, text,// taken, reserved, min_length, max_length, separators, count) gives free names.import ui/slug_input.qwe use slug_input_transliterateimport ui/theme.qwe use ui_clslet username_input_css = css`.ui-userinput { display: grid; gap: 6px; min-width: 0; font: 14px/1.45 var(--ui-font); color: var(--ui-fg); }.ui-userinput-label { font-size: 13px; font-weight: 500; }.ui-userinput-req { margin-left: 2px; color: var(--ui-danger); }.ui-userinput-control { box-sizing: border-box; width: 100%; height: 38px; margin: 0; padding: 0 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 14px/1 var(--ui-mono); transition: border-color .15s, box-shadow .15s; }.ui-userinput-control:hover { border-color: var(--ui-strong); }.ui-userinput-control:focus { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-userinput-control[aria-invalid="true"] { border-color: var(--ui-danger); }.ui-userinput-control:disabled { opacity: .55; cursor: not-allowed; }.ui-userinput-hint { margin: 0; font-size: 13px; color: var(--ui-muted); }.ui-userinput-rules { display: grid; gap: 2px; margin: 0; padding: 0; list-style: none; font-size: 12px; color: var(--ui-muted); }.ui-userinput-rule { display: flex; align-items: center; gap: 6px; }.ui-userinput-rule svg { flex: none; width: 12px; height: 12px; opacity: .35; }.ui-userinput-rule[data-ok="true"] { color: var(--ui-success); }.ui-userinput-rule[data-ok="true"] svg { opacity: 1; }.ui-userinput-rule[data-ok="false"] { color: var(--ui-danger); }.ui-userinput-out { min-height: 20px; font-size: 13px; color: var(--ui-muted); overflow-wrap: anywhere; }.ui-userinput-free { color: var(--ui-success); }.ui-userinput-bad { color: var(--ui-danger); }.ui-userinput-offers { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 0; padding: 0; list-style: none; }.ui-userinput-offers-label { color: var(--ui-muted); font-size: 12px; }.ui-userinput-offer { min-height: 28px; padding: 3px 10px; border: 1px solid var(--ui-border); border-radius: 999px; background: var(--ui-surface); color: var(--ui-fg); font: 13px/1.3 var(--ui-mono); cursor: pointer; }.ui-userinput-offer:hover { border-color: var(--ui-accent); color: var(--ui-accent); }.ui-userinput-offer:focus-visible { outline: none; box-shadow: var(--ui-focus); }@media (prefers-reduced-motion: reduce) { .ui-userinput-control { transition: none; } }`// What a name looks like: names that come out the same cannot be told apart by eye.fn username_input_skeleton(text) { let s = replace(replace(lower(str(text ?? "")), "1", "l"), "0", "o") replace(replace(s, "rn", "m"), "vv", "w")}// The two ways a name can be seen. A capital I looks like an l in "biIl", and like the// i it is once case is ignored in "BIl1", so each name is looked up both ways.fn username_input_shapes(text) { let s = str(text ?? "") unique([username_input_skeleton(replace(s, "I", "l")), username_input_skeleton(s)])}// Only these three may join words; each goes in a pattern's [...], the hyphen last.fn username_input_marks(separators: str) = ["_", ".", "-"] | filter((c) => contains(separators, c))fn username_input_rules(text: str, min_length: int, max_length: int, separators: str) { let marks = username_input_marks(separators) let cls = join(marks, "") let plain = cls == "" ? "digits" : `digits and ${join(marks, " ")}` [ { id: "length", label: `${min_length} to ${max_length} characters`, ok: len(text) >= min_length && len(text) <= max_length }, { id: "start", label: "Starts with a letter", ok: re.test(text, "^[A-Za-z]") }, { id: "chars", label: `Only plain letters, ${plain}`, ok: text != "" && re.test(text, `^[A-Za-z0-9${cls}]*$`) }, { id: "joins", label: "No separator at the end, or two in a row", ok: text != "" && (cls == "" || !re.test(text, `[${cls}]$|[${cls}]{2}`)) }, ]}// Who a name clashes with: { same, like } maps from lower case and from skeletons.fn username_input_index(names: list) { reduce(names, { same: {}, like: {} }, (m, x) => { let n = str(x) { same: set(m.same, lower(n), n), like: reduce(username_input_shapes(n), m.like, (acc, shape) => set(acc, shape, n)) } })}fn username_input_judge(text: str, used, kept, rules: list) { if text == "" { return { status: "empty", message: null, near: null, rules } } let broken = find(rules, (r) => !r.ok) if broken != null { return { status: "invalid", message: `Not yet: ${lower(slice(broken.label, 0, 1))}${slice(broken.label, 1, len(broken.label))}.`, near: null, rules } } let low = lower(text) let shapes = username_input_shapes(text) let look = (found) => shapes | map((shape) => found[shape]) | filter((x) => x != null) | first let hold = kept.same[low] ?? look(kept.like) if hold != null { return { status: "reserved", message: `${text} is kept for the site.`, near: hold, rules } } let same = used.same[low] if same != null { return { status: "taken", message: `${text} is taken.`, near: same, rules } } let like = look(used.like) if like != null { return { status: "lookalike", message: `${text} looks too much like ${like}, which is taken.`, near: like, rules } } { status: "free", message: `${text} is free.`, near: null, rules }}fn username_input_check(text, taken: list = [], reserved: list = [], min_length: int = 3, max_length: int = 20, separators: str = "_.") { let s = str(text ?? "") username_input_judge(s, username_input_index(taken), username_input_index(reserved), username_input_rules(s, min_length, max_length, separators))}// The words of a name in plain letters.fn username_input_words(text) = re.split(lower(slug_input_transliterate(text)), "[^a-z0-9]+") | filter((w) => w != "")// Free names, from the person's name first, then from what was typed, then numbered.// Each must pass every rule, and no two may look alike.fn username_input_suggest(person, text, taken: list = [], reserved: list = [], min_length: int = 3, max_length: int = 20, separators: str = "_.", count: int = 4) { let used = username_input_index(taken) let kept = username_input_index(reserved) let marks = username_input_marks(separators) let typed = username_input_words(text) let given = username_input_words(person) let words = len(given) > 0 ? given : typed let first_word = words[0] ?? "" let last_word = len(words) > 1 ? words[-1] : "" let initial = slice(first_word, 0, 1) let joined = marks | map((m) => last_word != "" ? `${first_word}${m}${last_word}` : null) | filter((x) => x != null) let bases = [...joined, first_word + last_word, initial + last_word, first_word + slice(last_word, 0, 1), last_word + initial, first_word, last_word, join(typed, marks[0] ?? "")] let found = [] let shapes = {} let consider = (name: str) => { let cut = slice(name, 0, max_length) let shape = username_input_skeleton(cut) if len(found) < count && cut != "" && !has(shapes, shape) && username_input_judge(cut, used, kept, username_input_rules(cut, min_length, max_length, separators)).status == "free" { found = push(found, cut) shapes = set(shapes, shape, true) } } for b in bases { consider(b) } // Numbers come last: they are what is left when the name itself is gone. let base = bases | find((b) => b != "" && len(b) >= 2) ?? "" let n = 2 while len(found) < count && n < 200 && base != "" { let tail = str(n) consider(slice(base, 0, max_length - len(tail)) + tail) n += 1 } found}component UsernameInput(id: str, name: str = "username", label: str = "Username", value: str = "", taken: list = [], reserved: list = [], person: str? = null, min_length: int = 3, max_length: int = 20, separators: str = "_.", suggestions: int = 4, hint: str? = null, required: bool = false, disabled: bool = false, on_change = null, class: str? = null) { style global username_input_css state text = value let used = username_input_index(taken) let kept = username_input_index(reserved) let read = username_input_judge(text, used, kept, username_input_rules(text, min_length, max_length, separators)) let blocked = read.status == "reserved" || read.status == "taken" || read.status == "lookalike" let wrong = blocked || read.status == "invalid" let offers = read.status == "free" ? [] : username_input_suggest(person, text, taken, reserved, min_length, max_length, separators, suggestions) let described = ui_cls([hint != null && `${id}-hint`, `${id}-rules`, `${id}-out`]) let tell = (s: str) => { on_change && on_change({ username: s, status: username_input_judge(s, used, kept, username_input_rules(s, min_length, max_length, separators)).status }) } <div class={ui_cls(["ui-userinput", class])}> <label.ui-userinput-label for={id}> {label}{required && <span.ui-userinput-req aria-hidden="true">*</span>} </label> <input.ui-userinput-control type="text" id={id} name={name} value={text} required={required} disabled={disabled} autocomplete="username" spellcheck="false" autocapitalize="none" aria-invalid={wrong ? "true" : null} aria-describedby={described} @input={(e) => { let clean = re.replace(e.value, "^@+", "") text = clean tell(clean) }}/> {hint != null && <p.ui-userinput-hint id={`${id}-hint`}>{hint}</p>} <ul.ui-userinput-rules id={`${id}-rules`}> {for r in read.rules => <li.ui-userinput-rule key={r.id} data-rule={r.id} data-ok={text == "" ? null : r.ok ? "true" : "false"}> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 8.5l3.5 3.5 6.5-8"/></svg> <span>{r.label}</span> <span.ui-sr>{text == "" ? "" : r.ok ? " (met)" : " (not met)"}</span> </li>} </ul> <div.ui-userinput-out id={`${id}-out`} aria-live="polite"> {read.status == "free" ? <span.ui-userinput-free>{read.message}</span> : wrong ? <span.ui-userinput-bad>{read.message}</span> : null} </div> {len(offers) > 0 && <ul.ui-userinput-offers aria-label="Free names"> <li.ui-userinput-offers-label aria-hidden="true">Free:</li> {for o in offers => <li key={o}> <button.ui-userinput-offer type="button" @click={(e) => { // The field is found first: once the name is free this button's list // is gone, and the page may already be updated by the next line. let field = e.target.closest(".ui-userinput").querySelector("input") let picked = o text = picked tell(picked) field.focus() }}>{o}</button> </li>} </ul>} </div>}Related
BarcodeInputTells EAN-13, EAN-8, UPC-A, ISBN-10 and ISBN-13 apart by shape, checks the check digit, says what the right one would be, and converts ISBN-10 to ISBN-13.2 views · 5 downloadsEmailTypoInputChecks an address's shape, offers did you mean gmail.com by edit distance with one click to fix, warns of throwaway domains, and stays quiet on a valid uncommon domain.3 views · 6 downloadsIbanInputGroups an IBAN in fours as typed, knows each country's length, validates mod-97 with big-number arithmetic, points to a likely typing slip, and sends it without spaces.4 views · 8 downloadsSlugInputMakes a URL slug from a title as it is typed, writing accents plainly, stops following once edited by hand, and checks taken and reserved links, offering the next free one.3 views · 6 downloadsAmountInputReads 1.5k, 2m, 1,234.50, 1.234,50, $12 and EUR 40, decides which mark groups, shows the normalised amount, flags what could go two ways, and sends a real decimal.3 views · 8 downloadsContactPastePaste an email signature or a blurb: it pulls out the name, email, phone, website, company and address into editable fields, marks how sure each guess is, and never overwrites an edit.5 views · 6 downloads
Thread
Thread 0 posts
On UsernameInput: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.