MentionInput
A textarea that offers people when @ is typed, puts @handle in, and sends the handles mentioned beside the text.
Demo
Nobody mentioned.
Say a little more.
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/mention_input.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/mention_input.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/mention_input.qwe
let mention_input_demo_people = [ { handle: "ana", name: "Ana Lopes" }, { handle: "andrei", name: "Andrei Popescu" }, { handle: "ben", name: "Ben Oduya" }, { handle: "chloe", name: "Chloé Martin" }, { handle: "dev.team", name: "Developers" },]component MentionInputDemoLive() { state who = [] <div.demo-stack> <MentionInput name="mn-demo-comment" label="Comment" people={mention_input_demo_people} placeholder="Type @ to mention someone" on_change={(t, m) => { who = m }}/> <p.demo-note>{len(who) == 0 ? "Nobody mentioned." : `Will notify ${join(who, ", ")}.`}</p> </div>}view MentionInputDemo() { <div.demo-stack.demo-narrow> <MentionInputDemoLive/> <MentionInput name="mn-demo-reply" label="Reply" value="Thanks @ben, looks good." rows={2} people={mention_input_demo_people} error="Say a little more."/> </div>}Source
ui/mention_input.qwe
// MentionInput: a box for a comment or a message that offers people when @ is// typed: "@an" lists Ana and Andrei, the arrow keys move, Enter or a click puts// "@ana " in, and typing on filters. It is a real <textarea> named `name`, so a// plain <form> sends the text with the handles in it; the handles mentioned also// go in `name`_mentions, comma-separated, so the route can notify them.//// <MentionInput name="comment" label="Comment" people={[{ handle: "ana", name: "Ana Lopes" }, { handle: "ben", name: "Ben Oduya" }]}/>// <MentionInput name="reply" people={team} rows={3} on_change={(t, who) => { text = t; mentioned = who }}/>//// people: a list of { handle, name, avatar? }: the handle is what goes in the text// (letters, digits, _ and .), the name is matched too, the avatar a picture's URL.// trigger: the one character that starts a mention ("@"; "#" or "+" work too). limit: at most this many// offered (6). value, rows (3), placeholder, name, label, hint, error, required,// disabled and field (the box's id, "mention-input-" + name by default) as for// every field.// on_change(text, mentions): inside one of your components, given the text as it// is typed and the handles mentioned in it, in order, each once.import ui/theme.qwe use ui_clslet mention_input_css = css`.ui-mention-input { position: relative; display: grid; gap: 6px; min-width: 0; font: 14px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-mention-input-label { font-size: 13px; font-weight: 500; }.ui-mention-input-req { margin-left: 2px; color: var(--ui-danger); }.ui-mention-input-box { position: relative; }.ui-mention-input-box textarea { display: block; box-sizing: border-box; width: 100%; min-height: 38px; margin: 0; padding: 8px 12px; resize: vertical; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 14px/1.5 var(--ui-font); transition: border-color .15s, box-shadow .15s;}.ui-mention-input-box textarea::placeholder { color: var(--ui-muted); opacity: 1; }.ui-mention-input-box textarea:hover { border-color: var(--ui-strong); }.ui-mention-input-box textarea:focus { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-mention-input-box textarea:disabled { opacity: .55; cursor: not-allowed; }.ui-mention-input-pop { position: absolute; z-index: 20; top: 100%; left: 0; width: 280px; max-width: 100%; margin: 4px 0 0; padding: 4px; list-style: none; max-height: 240px; overflow: auto; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); box-shadow: var(--ui-shadow);}.ui-mention-input-pop[hidden] { display: none; }.ui-mention-input-opt { display: flex; align-items: center; gap: 10px; padding: 6px 10px; border-radius: 4px; cursor: pointer; }.ui-mention-input-opt[aria-selected="true"] { background: var(--ui-accent-soft); }.ui-mention-input-avatar { flex: none; display: grid; place-items: center; width: 26px; height: 26px; border-radius: 50%; overflow: hidden; background: var(--ui-subtle); color: var(--ui-muted); font: 600 11px var(--ui-font); text-transform: uppercase;}.ui-mention-input-avatar img { width: 100%; height: 100%; object-fit: cover; }.ui-mention-input-who { display: grid; min-width: 0; line-height: 1.25; }.ui-mention-input-who b { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.ui-mention-input-who span { font-size: 12px; color: var(--ui-muted); }.ui-mention-input-opt[aria-selected="true"] .ui-mention-input-who span { color: var(--ui-accent); }.ui-mention-input-invalid .ui-mention-input-box textarea { border-color: var(--ui-danger); }.ui-mention-input-hint, .ui-mention-input-error { margin: 0; font-size: 13px; color: var(--ui-muted); }.ui-mention-input-error { color: var(--ui-danger); }@media (prefers-reduced-motion: reduce) { .ui-mention-input-box textarea { transition: none; } }`// The mention being typed before the caret: { start, text } (start is where the// trigger is), or null when the caret is not in one.fn mention_input_at(text: str, caret: int, trigger: str) { let before = slice(text, 0, caret) let m = re.find(before, `(?:^|\\s)[${trigger}]([A-Za-z0-9_.]*)$`) if m == null { return null } let start = m.start + (len(m.text) - len(m.groups[0]) - 1) { start, text: m.groups[0] }}// The handles mentioned in `text`, in order, each once.fn mention_input_handles(text: str, people: list, trigger: str) -> list { let found = re.find_all(text, `(?:^|\\s)[${trigger}]([A-Za-z0-9_.]+)`) | map((m) => m.groups[0]) unique(found | filter((h) => any(people, (p) => lower(p.handle) == lower(h))))}component MentionInput(name: str? = null, label: str? = null, value: str = "", people: list = [], trigger: str = "@", limit: int = 6, rows: int = 3, placeholder: str? = null, hint: str? = null, error: str? = null, required: bool = false, disabled: bool = false, field: str? = null, on_change = null, class: str? = null) { style global mention_input_css state text = value state caret = 0 state open = false state active = 0 let id = field ?? `mention-input-${name ?? re.replace(lower(label ?? "text"), "[^a-z0-9]+", "-")}` let typing = open ? mention_input_at(text, caret, trigger) : null let needle = typing != null ? lower(typing.text) : "" let shown = typing == null ? [] : take(people | filter((p) => starts_with(lower(p.handle), needle) || starts_with(lower(p.name), needle) || any(split(lower(p.name), " "), (w) => starts_with(w, needle))), limit) let listed = len(shown) > 0 let act = active < len(shown) ? active : 0 let described = ui_cls([hint != null && `${id}-hint`, error != null && `${id}-error`]) let mentions = mention_input_handles(text, people, trigger) let tell = (t: str) => { on_change && on_change(t, mention_input_handles(t, people, trigger)) } let put = (e, p) => { let at = mention_input_at(text, caret, trigger) if at == null { return null } let next = slice(text, 0, at.start) + trigger + p.handle + " " + slice(text, caret) let pos = at.start + len(trigger) + len(p.handle) + 1 text = next caret = pos open = false let box = e.target.closest(".ui-mention-input").querySelector("textarea") box.value = next box.focus() box.setSelectionRange(pos, pos) tell(next) } <div class={ui_cls(["ui-mention-input", error != null && "ui-mention-input-invalid", class])}> {label != null && <label.ui-mention-input-label for={id}> {label}{required && <span.ui-mention-input-req aria-hidden="true">*</span>} </label>} <div.ui-mention-input-box> <textarea id={id} name={name} value={text} rows={rows} placeholder={placeholder} required={required} disabled={disabled} role="combobox" aria-autocomplete="list" aria-expanded={listed ? "true" : "false"} aria-controls={`${id}-list`} aria-activedescendant={listed ? `${id}-opt-${act}` : null} aria-haspopup="listbox" aria-invalid={error != null ? "true" : null} aria-describedby={described != "" ? described : null} @input={(e) => { text = e.value caret = e.target.selectionStart ?? len(e.value) open = true active = 0 tell(e.value) }} @click={(e) => { caret = e.target.selectionStart ?? 0 }} @keyup={(e) => { if e.key == "ArrowLeft" || e.key == "ArrowRight" || e.key == "Home" || e.key == "End" { caret = e.target.selectionStart ?? 0 } }} @blur={() => { open = false }} @keydown={(e) => { let n = len(shown) if listed && (e.key == "ArrowDown" || e.key == "ArrowUp") { e.preventDefault() active = e.key == "ArrowDown" ? (act + 1) % n : (act + n - 1) % n } if listed && (e.key == "Enter" || e.key == "Tab") { e.preventDefault() put(e, shown[act]) } if e.key == "Escape" && listed { e.preventDefault() open = false } }}></textarea> <ul.ui-mention-input-pop id={`${id}-list`} role="listbox" aria-label="People" hidden={!listed}> {for [i, p] in enumerate(shown) => <li.ui-mention-input-opt key={p.handle} id={`${id}-opt-${i}`} role="option" aria-selected={i == act ? "true" : "false"} @mousedown={(e) => { e.preventDefault() }} @click={(e) => { put(e, p) }}> <span.ui-mention-input-avatar aria-hidden="true"> {p.avatar != null ? <img src={p.avatar} alt=""/> : slice(p.name ?? p.handle, 0, 1)} </span> <span.ui-mention-input-who><b>{p.name ?? p.handle}</b><span>{trigger}{p.handle}</span></span> </li>} </ul> </div> {name != null && <input type="hidden" name={`${name}_mentions`} value={join(mentions, ",")}/>} {hint != null && <p.ui-mention-input-hint id={`${id}-hint`}>{hint}</p>} {error != null && <p.ui-mention-input-error id={`${id}-error`} role="alert">{error}</p>} </div>}Related
ReplyBoxA reply box that is one line until focused, then grows and shows Post and Cancel; it posts a form or calls back.6 views · 5 downloadsAddressFormStreet, city, region, postcode and country in the usual layout, with the autocomplete names browsers fill from.5 views · 7 downloadsAutocompleteA free-text box that offers matches as one types, with the arrow keys and Enter; whatever is typed may stay.5 views · 7 downloadsCardInputA payment card as it is typed: the number grouped, the brand, MM/YY and the code, with typos said.6 views · 6 downloadsCharacterCounterA 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 downloadsCheckboxA checkbox drawn by CSS on a real input, with an indeterminate state, and a group of them in a fieldset.5 views · 6 downloads
Thread
Thread 0 posts
On MentionInput: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.