ReactionBar
A long press raises a bar of reactions to slide along and pick from, one for each person, with counts, and a route for keys.
Demo
No change yet.
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/reaction_bar.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/reaction_bar.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/reaction_bar.qwe
component ReactionBarDemoLive() { state note = "No change yet." <div.demo-stack id="reaction-bar-demo"> <ReactionBar id="reaction-bar-first" reactions={[{ emoji: "👍", label: "Like", count: 3 }, { emoji: "❤️", label: "Love", count: 1 }]} on_change={(e, rows) => { note = `${e ?? "none"}: ${join(rows | map((r) => `${r.label} ${r.count}`), ", ")}` }}> <MessageBubble name="Ana Silva" time="09:41">The crane is booked for Monday at eight.</MessageBubble> </ReactionBar> <ReactionBar id="reaction-bar-mine" mine="😂" reactions={[{ emoji: "😂", label: "Funny", count: 2 }, { emoji: "🦆", label: "Duck", count: 4 }]}> <MessageBubble mine={true} time="09:42">And I will bring the ducks.</MessageBubble> </ReactionBar> <p.demo-note id="reaction-bar-note" role="status">{note}</p> </div>}view ReactionBarDemo() = <ReactionBarDemoLive/>Source
ui/reaction_bar.qwe
// ReactionBar: reactions as a phone's chat app does them. A long press on a message// raises a bar of emoji; keep the finger down and slide to one, and letting go there// picks it, while letting go elsewhere picks nothing. A long press with no slide leaves// the bar up to tap. Each person has one reaction: a new one takes the place of the old,// and the same one again takes it back. The counts sit under the message, and each is a// button too. The button at their end opens the same bar for keys and screen readers: the// arrows move, Enter picks, Escape closes.//// <ReactionBar reactions={m.reactions} mine={m.my_reaction} on_change={(emoji, rows) => { save(m.id, emoji) }}>// <MessageBubble name="Ana">{m.text}</MessageBubble>// </ReactionBar>//// reactions: the counts now, a list of { emoji, count, label? }. mine: the emoji that is// yours, or null. choices: what the bar offers, a list of { emoji, label } (six by// default): a count for an emoji not offered is still shown. hold: milliseconds to a long// press (420). label: what the button is called ("React"). id, class: for the outer element.// children: the message.// on_change(emoji, rows): inside one of your components, your reaction now (null for// none) and the counts as they stand, for you to save.//// The press is told from a scroll by distance: move more than 10 px before it has risen// and the page has the finger. Once it has, the finger is the bar's, by a touchmove// listener that is not passive, as touch-action cannot change in the middle of a touch.// Felt taps come from quilloNative.haptic in the apps and navigator.vibrate on Android.import ui/theme.qwe use ui_clslet reaction_bar_css = css`.ui-reaction-bar { position: relative; display: grid; gap: 2px; min-width: 0; font: 14px/1.3 var(--ui-font); color: var(--ui-fg); }.ui-reaction-bar-target { min-width: 0; touch-action: pan-y; -webkit-touch-callout: none; border-radius: var(--ui-radius); transition: transform .16s; }.ui-reaction-bar-pressing { user-select: none; -webkit-user-select: none; transform: scale(.985); }.ui-reaction-bar-tally { display: flex; flex-wrap: wrap; align-items: center; gap: 2px; min-width: 0; }.ui-reaction-bar-chip { display: inline-flex; align-items: center; justify-content: center; min-width: 44px; min-height: 44px; padding: 0; border: 0; background: transparent; color: inherit; font: inherit; cursor: pointer; }.ui-reaction-bar-chip span { display: inline-flex; align-items: center; gap: 4px; height: 30px; padding: 0 9px; border: 1px solid var(--ui-border); border-radius: 999px; background: var(--ui-surface); font-size: 13px; font-weight: 500; font-variant-numeric: tabular-nums;}.ui-reaction-bar-chip[aria-pressed="true"] span { border-color: var(--ui-accent); background: var(--ui-accent-soft); color: var(--ui-accent); }.ui-reaction-bar-chip:focus-visible { outline: none; }.ui-reaction-bar-chip:focus-visible span { box-shadow: var(--ui-focus); }.ui-reaction-bar-add svg { width: 18px; height: 18px; color: var(--ui-muted); }.ui-reaction-bar-add span { width: 34px; justify-content: center; padding: 0; border-style: dashed; }.ui-reaction-bar-pop { position: fixed; inset: auto; margin: 0; padding: 4px; border: 1px solid var(--ui-border); border-radius: 999px; overflow: visible; background: var(--ui-surface); color: var(--ui-fg); box-shadow: var(--ui-shadow-lg); -webkit-touch-callout: none; user-select: none; -webkit-user-select: none;}.ui-reaction-bar-pop:popover-open { display: flex; animation: ui-reaction-bar-rise .18s cubic-bezier(.2, 1.4, .4, 1); }@keyframes ui-reaction-bar-rise { from { opacity: 0; transform: translateY(10px) scale(.9); } }.ui-reaction-bar-item { position: relative; display: grid; place-items: center; flex: none; width: 46px; height: 46px; padding: 0; border: 0; border-radius: 50%; background: transparent; font-size: 26px; line-height: 1; cursor: pointer; transition: transform .12s;}.ui-reaction-bar-item[aria-checked="true"] { background: var(--ui-accent-soft); }.ui-reaction-bar-item:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-reaction-bar-on { transform: translateY(-10px) scale(1.3); }.ui-reaction-bar-on::after { content: attr(data-tip); position: absolute; bottom: 100%; left: 50%; margin-bottom: 2px; padding: 1px 7px; border-radius: 999px; transform: translateX(-50%) scale(.77); background: var(--ui-fg); color: var(--ui-bg); font: 600 12px/18px var(--ui-font); white-space: nowrap;}@media (prefers-reduced-motion: reduce) { .ui-reaction-bar-target, .ui-reaction-bar-item { transition: none; } .ui-reaction-bar-pop:popover-open { animation: none; }}`let reaction_bar_default = [ { emoji: "👍", label: "Like" }, { emoji: "❤️", label: "Love" }, { emoji: "😂", label: "Funny" }, { emoji: "😮", label: "Wow" }, { emoji: "😢", label: "Sad" }, { emoji: "🙏", label: "Thanks" }]// Counts that can be trusted: a whole number of 0 or more, each emoji once, and none at nothing.fn reaction_bar_clean(reactions) { let rows = (type_of(reactions) == "list" ? reactions : []) | filter((r) => type_of(r) == "map" && str(r.emoji ?? "") != "") let plain = rows | map((r) => { ...r, emoji: str(r.emoji), count: type_of(r.count) == "int" && r.count > 0 ? r.count : 0 }) let once = unique(plain | map((r) => r.emoji)) | map((e) => { let same = plain | filter((r) => r.emoji == e) { ...same[0], count: sum(same | map((r) => r.count)) } }) once | filter((r) => r.count > 0)}// The counts after one person's reaction goes from `was` to `next`, either of which may// be null. A count at nothing goes, and a new one joins at the end, so that no chip// moves under a finger.fn reaction_bar_apply(rows: list, was, next, label) { let taken = rows | map((r) => r.emoji == was ? { ...r, count: r.count - 1 } : r) | filter((r) => r.count > 0) if next == null { return taken } any(taken, (r) => r.emoji == next) ? taken | map((r) => r.emoji == next ? { ...r, count: r.count + 1 } : r) : taken + [{ emoji: next, label, count: 1 }]}// Which of n items a finger at x, y is over, or -1: within `reach` above or below the// bar, as a finger hides what it is on, and off either end is off the bar.fn reaction_bar_index(x: num, y: num, left: num, width: num, top: num, bottom: num, n: int, reach: num) -> int { if n < 1 || width <= 0 || x < left || x > left + width || y < top - reach || y > bottom + reach { return -1 } min([floor((x - left) * n / width), n - 1])}// Where the bar's left edge goes: centred on `x`, kept `margin` inside the screen.fn reaction_bar_left(x: num, width: num, screen: num, margin: num) -> num = max([margin, min([x - width / 2, screen - width - margin])])// Where its top goes: over the message, or under it when there is no room above.fn reaction_bar_top(top: num, bottom: num, height: num, screen: num, margin: num) -> num { let over = top - height - margin if over >= margin { return over } min([bottom + margin, screen - height - margin])}// What a count says to a screen reader.fn reaction_bar_count_label(r, mine: bool) -> str = `${r.label ?? r.emoji}: ${r.count}${mine ? ", yours" : ""}`// A tap felt: the app's, else Android's, else nothing. The app answers later, and in a// plain function nothing waits for it, so a refusal needs a home.fn reaction_bar_haptic(kind: str, ms: int) { let felt = js("quilloNative.haptic", kind) if is_err(felt) { js("navigator.vibrate", ms) } if type_of(felt) == "promise" { felt.then((_v = null) => null, (_e = null) => null) }}component ReactionBar(reactions: list = [], mine: str? = null, choices: list = reaction_bar_default, hold: int = 420, label: str = "React", on_change = null, id: str? = null, class: str? = null, children) { style global reaction_bar_css state rows = reaction_bar_clean(reactions) state chosen = mine state said = "" state open = false state active = -1 state press = null // { id, x, y }, from the finger coming down until the bar rises or it moves state picking = false // the finger that raised the bar is still down state slid = false state from = null state target = null state bar = null state add = null state win = null let n = len(choices) let label_of = (emoji) => { let c = find(choices, (x) => x.emoji == emoji) c != null ? c.label : emoji } let place = (x: num) => { let r = target.getBoundingClientRect() let w = bar.offsetWidth let h = bar.offsetHeight bar.style.left = `${reaction_bar_left(x, w, win.innerWidth, 8)}px` bar.style.top = `${reaction_bar_top(r.top, r.bottom, h, win.innerHeight, 8)}px` } let rise = (x: num) => { bar.showPopover() place(x) open = true } let close = () => { if open { bar.hidePopover() open = false active = -1 } } let react = (emoji) => { let next = chosen == emoji ? null : emoji rows = reaction_bar_apply(rows, chosen, next, label_of(emoji)) chosen = next said = next != null ? `You reacted with ${label_of(next)}` : "Your reaction was taken back" on_change && on_change(next, rows) } let pick = (i: int) => { close() react(choices[i].emoji) } let by_keys = () => { if open { close() } else { let r = add.getBoundingClientRect() rise(r.left + r.width / 2) let at = index_of(choices | map((c) => c.emoji), chosen) ?? 0 active = at js("Array.from", bar.querySelectorAll("button"))[at].focus() } } if press != null { every(hold, () => { if press != null { let p = press press = null picking = true slid = false from = { x: p.x, y: p.y } rise(p.x) target.setPointerCapture(p.id) reaction_bar_haptic("medium", 14) } }) } <div class={ui_cls(["ui-reaction-bar", class])} id={id} @mount={(el) => { let w = js("window") let d = w.document win = w let outside = (e) => { if open && !picking && !bar.contains(e.target) && !add.contains(e.target) { close() } } let turned = (_e = null) => { if open { let r = bar.getBoundingClientRect() place(r.left + r.width / 2) } } d.addEventListener("pointerdown", outside) w.addEventListener("resize", turned) () => { d.removeEventListener("pointerdown", outside) w.removeEventListener("resize", turned) } }}> <div class={ui_cls(["ui-reaction-bar-target", (press != null || picking) && "ui-reaction-bar-pressing"])} @mount={(el) => { target = el }} @touchmove={(e) => { // Once the bar is up the finger slides on it, up and down too, and the page must not scroll under it. if picking && e.cancelable { e.preventDefault() } }} @pointerdown={(e) => { if e.button == 0 && e.isPrimary && !open { press = { id: e.pointerId, x: e.clientX, y: e.clientY } } }} @pointermove={(e) => { if press != null { if abs(e.clientX - press.x) > 10 || abs(e.clientY - press.y) > 10 { press = null } } else if picking { if abs(e.clientX - from.x) > 8 || abs(e.clientY - from.y) > 8 { slid = true } let r = bar.getBoundingClientRect() let at = reaction_bar_index(e.clientX, e.clientY, r.left, r.width, r.top, r.bottom, n, 60) if at != active { active = at if at >= 0 { reaction_bar_haptic("selection", 6) } } } }} @pointerup={() => { press = null if picking { picking = false if slid { if active >= 0 { pick(active) } else { close() } } } }} @pointercancel={() => { press = null if picking { picking = false close() } }} @contextmenu={(e) => { if press != null || picking { e.preventDefault() } }}>{children}</div> <div.ui-reaction-bar-tally role="group" aria-label="Reactions"> {for r in rows => <button.ui-reaction-bar-chip key={r.emoji} type="button" aria-pressed={r.emoji == chosen ? "true" : "false"} aria-label={reaction_bar_count_label(r, r.emoji == chosen)} @click={() => { react(r.emoji) }}> <span>{r.emoji} {r.count}</span> </button>} <button.ui-reaction-bar-chip.ui-reaction-bar-add type="button" aria-label={label} aria-haspopup="menu" aria-expanded={open ? "true" : "false"} @mount={(el) => { add = el }} @click={by_keys}> <span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M8.5 14.5a4.5 4.5 0 0 0 7 0M9 9.5v.01M15 9.5v.01"/></svg></span> </button> </div> <div.ui-reaction-bar-pop popover="manual" role="menu" aria-label={label} @mount={(el) => { bar = el }} @keydown={(e) => { let to = match e.key { "ArrowRight" => (active + 1) % n "ArrowLeft" => (active + n - 1) % n "ArrowDown" => (active + 1) % n "ArrowUp" => (active + n - 1) % n "Home" => 0 "End" => n - 1 _ => -1 } if to >= 0 { e.preventDefault() active = to js("Array.from", bar.querySelectorAll("button"))[to].focus() } if e.key == "Escape" || e.key == "Tab" { e.preventDefault() close() add.focus() } }}> {for i in range(n) => <button key={choices[i].emoji} class={ui_cls(["ui-reaction-bar-item", active == i && "ui-reaction-bar-on"])} type="button" role="menuitemradio" aria-label={choices[i].label} aria-checked={choices[i].emoji == chosen ? "true" : "false"} data-tip={choices[i].label} tabindex={active == i ? "0" : "-1"} @click={() => { pick(i) }}>{choices[i].emoji}</button>} </div> <span.ui-sr role="status">{said}</span> </div>}Related
ConversationListChats with unread counts, a muted state, pinned at the top, typing indicator, swipe to archive, and relative times from a prop.3 views · 6 downloadsSwipeReplyDrag a message sideways to quote it in the reply, with a tap felt at the threshold, and a menu that does the same.3 views · 6 downloadsActionSheetA list of actions rising from the bottom, with a destructive style and Cancel: swipe down or the phone's back gesture closes it; on a tablet, a card by what opened it.3 views · 9 downloadsAlertInboxAlerts by severity, swiped to acknowledge or snooze, with buttons and keys to the same, and a badge that stays right as snoozes run out.3 views · 8 downloadsAlphaRailListA long list with sticky letter headers and an A to Z rail scrubbed by finger, with a bubble showing the letter and a haptic per step.1 view · 6 downloadsApprovalQueueOne request at a time, swiped to approve or reject, with a required reason, undo and a count of what is left.3 views · 5 downloads
Thread
Thread 0 posts
On ReactionBar: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.