MentionTray
Typing @ opens a tray of people above the keyboard, fuzzy-ranked and placed at the cursor, and the name goes in as one thing.
Demo
Nothing yet.
Get it
It needs theme.qwe, as every component does, and composer_bar.qwe and fuzzy_select.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/composer_bar.qwe$ curl -O https://quillolang.org/components/ui/fuzzy_select.qwe$ curl -O https://quillolang.org/components/ui/mention_tray.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/mention_tray.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/mention_tray.qwe
let mention_tray_demo_people = [ { id: 1, name: "Ana Silva", handle: "ana" }, { id: 2, name: "Andrei Pop", handle: "andrei" }, { id: 3, name: "Joan Hart", handle: "joan" }, { id: 4, name: "Ben Oduya", handle: "benny" }, { id: 5, name: "Cleo Marsh" }, { id: 6, name: "Hanna Lee", handle: "hl" }, { id: "bad" }, { name: "No id" }, "not a person"]component MentionTrayDemoLive() { state seen = "Nothing yet." <div.demo-phone id="mention-tray-demo" style="height: 420px; display: flex; flex-direction: column; justify-content: flex-end; padding: 8px; box-sizing: border-box"> <p.demo-note id="mention-tray-note" style="flex: 1">{seen}</p> <MentionTray id="mention-tray-live" label="Message the crew" placeholder="Type @ to mention someone" people={mention_tray_demo_people} on_change={(t, ms) => { seen = `${t} | ${join(ms | map((m) => `${m.id}:${m.start}-${m.end}`), ",")}` }}/> </div>}view MentionTrayDemo() = <MentionTrayDemoLive/>Source
ui/mention_tray.qwe
// MentionTray: a message box where typing @ opens a tray of people, ranked by a fuzzy// match as you go on typing, over the line you are typing on, and above the on-screen// keyboard on both systems. Tap one, or use the arrow keys and Enter, and the name goes in// as one thing, an entity: it is marked, the cursor cannot stop inside it, and a Backspace// takes all of it at once, on any keyboard, as it is worked out from what changed rather// than from keys, which Android's do not report. On a phone the tray is the width of the// box; on a tablet it is a small card at the cursor.//// <MentionTray people={[{ id: 7, name: "Ana Silva", handle: "ana" }, { id: 9, name: "Ben Oduya" }]}/>// <MentionTray people={team} on_change={(text, mentions) => { draft = text; tagged = mentions }}/>//// people: a list of { id, name, handle?, avatar? }: name is what goes in the text, and name// and handle are what is matched. trigger: the character that opens it ("@"). limit: most// offered (6). value: what it starts with. name ("message"): a plain <form> gets the text// as this, and the ids mentioned, in order, each once, as name_mentions. label ("Message"),// placeholder, rows: lines it starts at (2). max_rows: lines it grows to (6). keyboard: false// when something around it keeps it above the keyboard, as ChatScreen and CommentSheet do.// id, class: for the outer element.// on_change(text, mentions): inside one of your components, as it is typed. mentions is// a list of { id, name, start, end }, where start and end count characters in text.import ui/theme.qwe use ui_clsimport ui/composer_bar.qwe use composer_bar_followimport ui/fuzzy_select.qwe use fuzzy_select_index, fuzzy_select_ranklet mention_tray_css = css`.ui-mention-tray { position: relative; container-type: inline-size; min-width: 0; font: 16px/22px var(--ui-font); color: var(--ui-fg); }.ui-mention-tray-sticky { position: sticky; bottom: 0; z-index: 3; }.ui-mention-tray-lifted { position: relative; transform: translateY(calc(-1 * var(--ui-mention-tray-lift, 0px))); }.ui-mention-tray-box { position: relative; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-bg); }.ui-mention-tray-box:focus-within { border-color: var(--ui-accent); box-shadow: var(--ui-focus); }/* The mirror lies behind the box and has its text, letter for letter: what the box cannot draw, the marks, and where its cursor is. */.ui-mention-tray-mirror, .ui-mention-tray-field { box-sizing: border-box; display: block; width: 100%; margin: 0; padding: 10px 12px; border: 0; font: inherit; letter-spacing: normal; white-space: pre-wrap; overflow-wrap: break-word; word-break: normal; tab-size: 8;}.ui-mention-tray-mirror { position: absolute; inset: 0; overflow: hidden; color: transparent; pointer-events: none; }.ui-mention-tray-mirror .ui-mention-tray-name { border-radius: 4px; background: var(--ui-accent-soft); box-shadow: 0 0 0 1px var(--ui-accent-soft); }.ui-mention-tray-field { position: relative; resize: none; overflow-y: hidden; border-radius: var(--ui-radius); background: transparent; color: var(--ui-fg); outline: none; -webkit-appearance: none; appearance: none; overscroll-behavior: contain; }.ui-mention-tray-field::placeholder { color: var(--ui-muted); opacity: 1; }.ui-mention-tray-pop { position: absolute; left: 0; right: 0; z-index: 6; box-sizing: border-box; margin: 0; padding: 4px; overflow-y: auto; list-style: none; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); box-shadow: var(--ui-shadow-lg); overscroll-behavior: contain;}.ui-mention-tray-pop::after { content: ""; display: block; height: 0; }.ui-mention-tray-opt { display: flex; align-items: center; gap: 10px; min-height: 44px; padding: 4px 8px; border-radius: var(--ui-radius-sm); cursor: pointer; -webkit-touch-callout: none; user-select: none; -webkit-user-select: none; }.ui-mention-tray-opt[aria-selected="true"] { background: var(--ui-accent-soft); }.ui-mention-tray-face { flex: none; display: grid; place-items: center; width: 32px; height: 32px; overflow: hidden; border-radius: 50%; background: var(--ui-subtle); color: var(--ui-muted); font: 600 12px var(--ui-font); }.ui-mention-tray-face img { width: 100%; height: 100%; object-fit: cover; }.ui-mention-tray-who { display: grid; min-width: 0; line-height: 1.25; }.ui-mention-tray-who b { overflow: hidden; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }.ui-mention-tray-who small { overflow: hidden; color: var(--ui-muted); font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }@container (min-width: 520px) { .ui-mention-tray-pop { right: auto; width: 280px; } }`// A textarea counts its cursor in UTF-16 units and Quillo counts characters, so an emoji// before the cursor puts the two apart. A String object of the page counts as the textarea does.fn mention_tray_points(text: str, units: int) -> int = len(js_new("String", text).slice(0, units))fn mention_tray_units(text: str, points: int) -> int = js_new("String", slice(text, 0, points)).length// The mention being typed before the caret: { at, query }, at being where the trigger is;// null when there is none. It starts a word, stays on one line, and may hold one space, for// a first and a last name. Inside an entity there is none.fn mention_tray_query(text: str, caret: int, trigger: str, ents: list) { if caret < 1 || any(ents, (e) => caret > e.start && caret <= e.end) { return null } let span = max([caret - 40, 0]) let before = split(slice(text, span, caret), "") let i = len(before) - 1 let spaces = 0 while i >= 0 { let c = before[i] if c == "\n" { return null } if c == " " || c == "\t" { spaces += 1 if spaces > 1 || i == len(before) - 1 { return null } } if c == trigger { let prev = i > 0 ? before[i - 1] : (span > 0 ? slice(text, span - 1, span) : "") let starts = prev == "" || prev == " " || prev == "\n" || prev == "\t" || prev == "(" let at = span + i if !starts || any(ents, (e) => at >= e.start && at < e.end) { return null } return { at, query: join(slice(before, i + 1, len(before)), "") } } i -= 1 } null}// What an edit did to the text. `before` became `after`, by the shortest change; an entity// that the change touched goes, all of it, and an entity after the change moves with it.// { text, ents, caret }, caret being where to put the cursor if the text had to be// cut further than the edit went, else null.fn mention_tray_edit(before: str, after: str, ents: list) { let a = split(before, "") let b = split(after, "") let la = len(a) let lb = len(b) let head = 0 while head < la && head < lb && a[head] == b[head] { head += 1 } let tail = 0 while tail < la - head && tail < lb - head && a[la - 1 - tail] == b[lb - 1 - tail] { tail += 1 } let old_end = la - tail let shift = lb - la let keep = ents | filter((e) => e.end <= head || e.start >= old_end) | map((e) => e.end <= head ? e : { ...e, start: e.start + shift, end: e.end + shift }) let hit = sort_by(ents | filter((e) => !(e.end <= head || e.start >= old_end)), (e) => e.start) if len(hit) == 0 { return { text: after, ents: keep, caret: null } } // What is left of those entities on either side of the change goes as well. let cut_left = hit[0].start let cut_right = max(hit | map((e) => e.end)) let left = min([cut_left, head]) let right_old = max([cut_right, old_end]) let added = slice(b, head, lb - tail) let text = join(slice(a, 0, left), "") + join(added, "") + join(slice(a, right_old, la), "") let moved = left + len(added) - right_old let others = ents | filter((e) => e.end <= left || e.start >= right_old) | map((e) => e.end <= left ? e : { ...e, start: e.start + moved, end: e.end + moved }) { text, ents: others, caret: left + len(added) }}// A cursor or a selection in terms of the entities: { start, end }, a cursor inside one moved// out of it, and a selection cut through one widened to take it.fn mention_tray_snap(ents: list, start: int, end: int, was: int) { if start == end { let inside = find(ents, (e) => start > e.start && start < e.end) if inside == null { return { start, end } } // An arrow key steps across it; a tap anywhere else goes to the nearer edge. let nearer = start - inside.start < inside.end - start ? inside.start : inside.end let to = was == inside.start && start == was + 1 ? inside.end : (was == inside.end && start == was - 1 ? inside.start : nearer) return { start: to, end: to } } let from = reduce(ents, start, (at, e) => at > e.start && at < e.end ? e.start : at) let upto = reduce(ents, end, (at, e) => at > e.start && at < e.end ? e.end : at) { start: from, end: upto }}// The text cut at the entities, for the mirror: [{ key, text, mark }] with the cursor's// place as its own piece, which is what is measured. It holds a word joiner, which has// a place on a line without being one where a line can break.fn mention_tray_parts(text: str, ents: list, caret: int) { let cuts = unique(sort(flatten(ents | map((e) => [e.start, e.end])) + [0, caret, len(text)])) let pieces = for i in range(len(cuts) - 1) => { let from = cuts[i] let upto = cuts[i + 1] let piece = { key: `t${from}`, caret: false, text: slice(text, from, upto), mark: any(ents, (e) => from >= e.start && upto <= e.end) } from == caret ? [{ key: "caret", caret: true, text: "", mark: false }, piece] : [piece] } caret >= len(text) ? flatten(pieces) + [{ key: "caret", caret: true, text: "", mark: false }] : flatten(pieces)}// Which way the tray opens from the line it serves, and how tall it may be: over the line// unless what is there is too little and there is more under it.fn mention_tray_place(above: num, below: num, want: num) { let up = above >= min([want, 132]) || above >= below { up, height: max([min([want, up ? above : below]), 96]) }}// Who to offer for a query, the best first: names by a fuzzy match, and after them anyone// whose handle starts with it. The two are not one text to match, or letters of a name// and of a handle far apart would make a match.fn mention_tray_rank(index: list, people: list, query: str, recent: list, limit: int) { let hits = fuzzy_select_rank(index, query, recent, limit).shown | map((r) => r.row.value) let by_name = hits | map((v) => find(people, (p) => str(p.id) == v)) let q = lower(query) let by_handle = q == "" ? [] : people | filter((p) => !contains(hits, str(p.id)) && starts_with(lower(str(p.handle ?? "")), q)) slice(by_name + by_handle, 0, limit)}// Where the tray's left edge goes for a cursor at x, held inside the box.fn mention_tray_left(x: num, width: num, box_width: num) -> num = round(max([0, min([x - 16, box_width - width])]))// How tall the box is for what it holds, between its starting rows and max_rows.fn mention_tray_height(content: num, rows: int, max_rows: int) -> num = min([max([content, rows * 22 + 20]), max([max_rows, rows]) * 22 + 20])// The ids for a form, in order, each once.fn mention_tray_ids(ents: list) -> str = join(unique(ents | map((e) => str(e.id))), ",")// Up to two letters for someone with no picture.fn mention_tray_initials(name) -> str = upper(join(split(trim(str(name ?? "")), " ") | filter((w) => w != "") | map((w) => slice(w, 0, 1)) | slice(0, 2), ""))component MentionTray(people: list = [], trigger: str = "@", limit: int = 6, value: str = "", name: str = "message", label: str = "Message", placeholder: str = "", rows: int = 2, max_rows: int = 6, keyboard: bool = true, on_change = null, id: str? = null, class: str? = null) { style global mention_tray_css state text = value state ents = [] state caret = 0 state was = 0 // the cursor before it last moved, to tell the way it was going state ask = null // { at, query }, while a tray is wanted state shut_at = -1 // the trigger Escape closed, so the same one does not reopen state active = 0 state recent = [] state said = "" state box = null state mirror = null state tray = null state win = null let usable = (type_of(people) == "list" ? people : []) | filter((p) => type_of(p) == "map" && p.id != null && str(p.name ?? "") != "") let index = fuzzy_select_index(usable | map((p) => { label: p.name, value: str(p.id) })) let found = ask != null ? mention_tray_rank(index, usable, ask.query, recent, limit) : [] let open = ask != null && ask.at != shut_at && len(found) > 0 let list_id = `${id ?? name}-mention-tray` let grow = () => { box.style.height = "auto" let full = box.scrollHeight let height = mention_tray_height(full, rows, max_rows) box.style.height = `${height}px` box.style.overflowY = full > height ? "auto" : "hidden" } let place = () => { if tray != null && win != null { let spot = mirror.querySelector("[data-caret]") let frame = mirror.getBoundingClientRect() let at = spot.getBoundingClientRect() let vv = win.visualViewport let top_edge = vv == null ? 0 : vv.offsetTop let bottom_edge = vv == null ? win.innerHeight : vv.offsetTop + vv.height let line_top = at.top - frame.top let line_bottom = line_top + 22 let fit = mention_tray_place(at.top - top_edge - 8, bottom_edge - at.top - 22 - 8, 44 * len(found) + 10) tray.style.maxHeight = `${fit.height}px` tray.style.top = fit.up ? "" : `${line_bottom + 2}px` tray.style.bottom = fit.up ? `calc(100% - ${line_top - 2}px)` : "" tray.dataset.way = fit.up ? "up" : "down" let w = tray.offsetWidth tray.style.left = w < frame.width ? `${mention_tray_left(at.left - frame.left, w, frame.width)}px` : "" } } let sync = () => { let at = mention_tray_points(text, box.selectionStart) if at != caret { was = caret caret = at } let wanted = box.selectionStart == box.selectionEnd ? mention_tray_query(text, at, trigger, ents) : null if wanted == null { shut_at = -1 } if wanted == null || ask == null || wanted.at != ask.at || wanted.query != ask.query { active = 0 } ask = wanted js("requestAnimationFrame", (_t = null) => { place() }) } let told = () => { on_change && on_change(text, ents | map((e) => { id: e.id, name: e.name, start: e.start, end: e.end })) } let put_caret = (at: int) => { let u = mention_tray_units(text, at) box.setSelectionRange(u, u) } let pick = (p) => { let shown = `${trigger}${p.name}` let from = ask.at let upto = caret let added = len(shown) + 1 let after = slice(text, 0, from) + shown + " " + slice(text, upto, len(text)) let shift = added - (upto - from) let moved = ents | map((e) => e.start >= upto ? { ...e, start: e.start + shift, end: e.end + shift } : e) box.setRangeText(`${shown} `, mention_tray_units(text, from), mention_tray_units(text, upto), "end") text = after ents = sort_by(moved + [{ id: p.id, name: p.name, start: from, end: from + len(shown) }], (e) => e.start) recent = unique([str(p.id)] + recent) ask = null said = `${p.name} mentioned` grow() sync() told() } <div class={ui_cls(["ui-mention-tray", keyboard && "ui-mention-tray-sticky", class])} id={id} @mount={(el) => { let w = js("window") let d = w.document win = w let moved = (_e = null) => { if box.isSameNode(d.activeElement) { // Where the cursor went, kept out of any entity. let start = mention_tray_points(text, box.selectionStart) let end = mention_tray_points(text, box.selectionEnd) let snap = mention_tray_snap(ents, start, end, caret) if snap.start != start || snap.end != end { box.setSelectionRange(mention_tray_units(text, snap.start), mention_tray_units(text, snap.end)) } sync() } } d.addEventListener("selectionchange", moved) let stop = keyboard ? composer_bar_follow(el, (lift, _top, _height, _open) => { el.style.setProperty("--ui-mention-tray-lift", `${lift}px`) place() }) : () => null () => { d.removeEventListener("selectionchange", moved) stop() } }}> <div.ui-mention-tray-lifted> <div.ui-mention-tray-box> <div.ui-mention-tray-mirror aria-hidden="true" @mount={(el) => { mirror = el }}> {for part in mention_tray_parts(text, ents, caret) => <span key={part.key} class={part.mark ? "ui-mention-tray-name" : null} data-caret={part.caret ? "true" : null}>{part.caret ? "\u2060" : part.text}</span>} </div> <textarea.ui-mention-tray-field name={name} rows={rows} aria-label={label} placeholder={placeholder} role="combobox" aria-autocomplete="list" aria-expanded={open ? "true" : "false"} aria-controls={list_id} aria-activedescendant={open ? `${list_id}-${active}` : null} autocomplete="off" value={text} @mount={(el) => { box = el grow() }} @input={(e) => { let typed = e.value let done = mention_tray_edit(text, typed, ents) ents = done.ents text = done.text if done.text != typed { box.value = done.text put_caret(done.caret) } grow() sync() told() }} @scroll={() => { mirror.scrollTop = box.scrollTop }} @blur={() => { ask = null }} @keydown={(e) => { if open && !e.isComposing { let n = len(found) if e.key == "ArrowDown" { e.preventDefault(); active = (active + 1) % n } if e.key == "ArrowUp" { e.preventDefault(); active = (active + n - 1) % n } if e.key == "Enter" || e.key == "Tab" { e.preventDefault(); pick(found[active]) } if e.key == "Escape" { e.preventDefault(); shut_at = ask.at } } }}></textarea> </div> <input type="hidden" name={`${name}_mentions`} value={mention_tray_ids(ents)}/> {open && <ul.ui-mention-tray-pop id={list_id} role="listbox" aria-label="People" @mount={(el) => { tray = el place() () => { tray = null } }}> {for i in range(len(found)) => <li.ui-mention-tray-opt key={str(found[i].id)} id={`${list_id}-${i}`} role="option" aria-selected={i == active ? "true" : "false"} @mousedown={(e) => { e.preventDefault() }} @click={() => { pick(found[i]) }}> <span.ui-mention-tray-face aria-hidden="true">{found[i].avatar != null ? <img src={found[i].avatar} alt=""/> : mention_tray_initials(found[i].name)}</span> <span.ui-mention-tray-who> <b>{found[i].name}</b> {found[i].handle != null && <small>@{found[i].handle}</small>} </span> </li>} </ul>} <span.ui-sr role="status">{open ? `${len(found)} ${len(found) == 1 ? "person" : "people"} offered` : said}</span> </div> </div>}Related
ChatScreenMessages grouped by sender and day, keeping your place while older ones load, at the bottom as new ones come, and clear of the keyboard.3 views · 7 downloadsComposerBarA message box that grows to a limit, swaps send and voice, keeps attachments in a tray, rides above the keyboard and is safe to paste into.3 views · 9 downloadsAppTabBarBottom tabs for a phone app: badges, safe-area padding, a second press scrolls to the top, each tab keeps its place, gone while the keyboard is up; a rail on a tablet.3 views · 8 downloadsCommentSheetComments in a sheet: threads, a reply bar above the keyboard, sort, and a count that stays right. A column at the side on a tablet.2 views · 7 downloadsConversationListChats with unread counts, a muted state, pinned at the top, typing indicator, swipe to archive, and relative times from a prop.3 views · 6 downloadsKeyboardAvoiderKeeps a focused field in view above the on-screen keyboard by visualViewport, on both systems, handing back on close.2 views · 6 downloads
Thread
Thread 0 posts
On MentionTray: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.