ChatWidget
A chat button in the corner that opens a small chat panel, with replies from your server and a count for unseen ones.
Demo
Inline here, in the corner of the box; without inline it sits in the window's corner.
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/chat_widget.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/chat_widget.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/chat_widget.qwe
// The other side of the chat: a server that answers after a moment.server fn chat_widget_demo_reply(text: str) -> str with time { sleep(400) contains(lower(text), "price") ? "Plans start at €9 a month; the first two weeks are free." : `Thanks! You said: “${text}”. Someone will be with you shortly.`}component ChatWidgetDemoLive() { <div.demo-stack> <div style="position: relative; height: 560px; border: 1px dashed var(--ui-border); border-radius: var(--ui-radius)"> <ChatWidget title="Support" status="We reply in a few minutes" inline={true} messages={[{ text: "Hi! How can we help?", from: "Mia" }]} on_send={(text) => chat_widget_demo_reply(text)}/> </div> <p.demo-note>Inline here, in the corner of the box; without inline it sits in the window's corner.</p> </div>}view ChatWidgetDemo() = <ChatWidgetDemoLive/>Source
ui/chat_widget.qwe
// ChatWidget: a round chat button in the corner of the screen that opens a small chat// panel above it: a header with who you are talking to, the messages (yours on the// right), and a box to write in, where Enter sends and Shift+Enter starts a new line.// While an answer is on its way it shows "typing"; one that comes while the panel is// closed puts a count on the button. Escape closes the panel, putting the focus back// on the button. The messages are an ARIA log, so screen readers hear new ones.//// <ChatWidget title="Support" status="We reply in a few minutes"// messages={[{ text: "Hi! How can we help?" }]} on_send={(text) => ask_support(text)}/>//// title: who you are talking to ("Chat"); also the panel's and the button's name.// status: a line under it. messages: the conversation to start with, a list of// { text, mine?: true, from? }. placeholder ("Write a message…"). open: open to start with.// on_send(text): called with what was sent; what it gives back (text, or a list of// texts, or a promise of either, such as a `server fn`'s answer) is shown as the// reply. Give back null for none, and add replies some other way.// corner: "bottom-right" (default) or "bottom-left". inline: in the corner of its// nearest positioned ancestor rather than the screen.import ui/theme.qwe use ui_clslet chat_widget_css = css`.ui-chat-widget { position: fixed; z-index: 45; right: max(20px, env(safe-area-inset-right)); bottom: max(20px, env(safe-area-inset-bottom)); display: flex; flex-direction: column; align-items: flex-end; gap: 12px; font: 14px/1.45 var(--ui-font); }.ui-chat-widget-bottom-left { right: auto; left: max(20px, env(safe-area-inset-left)); align-items: flex-start; }.ui-chat-widget-inline { position: absolute; z-index: 2; right: 16px; bottom: 16px; }.ui-chat-widget-inline.ui-chat-widget-bottom-left { right: auto; left: 16px; }.ui-chat-widget-bubble { position: relative; display: grid; place-items: center; width: 56px; height: 56px; padding: 0; border: 0; border-radius: 50%; background: var(--ui-accent); color: var(--ui-accent-fg); box-shadow: var(--ui-shadow-lg); cursor: pointer; transition: transform .15s;}.ui-chat-widget-bubble:hover { transform: translateY(-1px); }.ui-chat-widget-bubble:focus-visible { outline: none; box-shadow: var(--ui-shadow-lg), var(--ui-focus); }.ui-chat-widget-bubble svg { width: 26px; height: 26px; }.ui-chat-widget-count { position: absolute; top: -2px; right: -2px; min-width: 20px; height: 20px; padding: 0 6px; box-sizing: border-box; border: 2px solid var(--ui-bg); border-radius: 999px; background: var(--ui-danger); color: #fff; font: 700 11px/16px var(--ui-font);}.ui-chat-widget-panel { display: flex; flex-direction: column; width: min(360px, calc(100vw - 32px)); height: min(480px, calc(100dvh - 120px)); overflow: hidden; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); color: var(--ui-fg); box-shadow: var(--ui-shadow-lg); animation: ui-chat-widget-in .16s ease-out;}.ui-chat-widget-panel[hidden] { display: none; }@keyframes ui-chat-widget-in { from { opacity: 0; transform: translateY(8px); } }.ui-chat-widget-head { display: flex; align-items: center; gap: 10px; flex: none; padding: 12px 10px 12px 16px; background: var(--ui-accent); color: var(--ui-accent-fg); }.ui-chat-widget-who { flex: 1; min-width: 0; }.ui-chat-widget-who h2 { margin: 0; font-size: 15px; font-weight: 600; line-height: 1.3; }.ui-chat-widget-who p { margin: 0; font-size: 12px; opacity: .85; }.ui-chat-widget-x { display: grid; place-items: center; width: 30px; height: 30px; padding: 0; border: 0; border-radius: var(--ui-radius-sm); background: transparent; color: inherit; cursor: pointer;}.ui-chat-widget-x:hover { background: color-mix(in srgb, currentColor 16%, transparent); }.ui-chat-widget-x:focus-visible { outline: 2px solid currentColor; outline-offset: 1px; }.ui-chat-widget-x svg { width: 18px; height: 18px; }.ui-chat-widget-log { display: flex; flex-direction: column-reverse; flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; }.ui-chat-widget-list { display: grid; gap: 6px; margin: 0; padding: 14px; list-style: none; }.ui-chat-widget-msg { display: grid; justify-items: start; }.ui-chat-widget-mine { justify-items: end; }.ui-chat-widget-from { margin: 0 0 2px 10px; color: var(--ui-muted); font-size: 11px; font-weight: 600; }.ui-chat-widget-text { max-width: 82%; padding: 7px 12px; border-radius: 16px; background: var(--ui-subtle); color: var(--ui-fg); white-space: pre-wrap; overflow-wrap: anywhere;}.ui-chat-widget-mine .ui-chat-widget-text { border-bottom-right-radius: 5px; background: var(--ui-accent); color: var(--ui-accent-fg); }.ui-chat-widget-msg:not(.ui-chat-widget-mine) .ui-chat-widget-text { border-bottom-left-radius: 5px; }.ui-chat-widget-typing { display: inline-flex; gap: 4px; padding: 10px 12px; border-radius: 16px; background: var(--ui-subtle); }.ui-chat-widget-typing span { width: 6px; height: 6px; border-radius: 50%; background: var(--ui-muted); animation: ui-chat-widget-dot 1s infinite ease-in-out; }.ui-chat-widget-typing span:nth-child(2) { animation-delay: .15s; }.ui-chat-widget-typing span:nth-child(3) { animation-delay: .3s; }@keyframes ui-chat-widget-dot { 0%, 60%, 100% { opacity: .35; transform: none; } 30% { opacity: 1; transform: translateY(-3px); } }.ui-chat-widget-form { display: flex; align-items: flex-end; gap: 8px; flex: none; padding: 10px; border-top: 1px solid var(--ui-border); }.ui-chat-widget-input { flex: 1; min-width: 0; max-height: 120px; margin: 0; padding: 8px 12px; border: 1px solid var(--ui-border); border-radius: 18px; background: var(--ui-surface); color: var(--ui-fg); font: 14px/1.4 var(--ui-font); resize: none; field-sizing: content;}.ui-chat-widget-input:focus { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-chat-widget-send { display: grid; place-items: center; flex: none; width: 36px; height: 36px; padding: 0; border: 0; border-radius: 50%; background: var(--ui-accent); color: var(--ui-accent-fg); cursor: pointer;}.ui-chat-widget-send:disabled { opacity: .45; cursor: not-allowed; }.ui-chat-widget-send:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-chat-widget-send svg { width: 18px; height: 18px; }@media (prefers-reduced-motion: reduce) { .ui-chat-widget-panel, .ui-chat-widget-typing span { animation: none; } .ui-chat-widget-bubble { transition: none; }}`component ChatWidget(title: str = "Chat", status: str? = null, messages: list = [], placeholder: str = "Write a message…", open: bool = false, on_send = null, corner: str = "bottom-right", inline: bool = false, class: str? = null) { style global chat_widget_css state log = messages | map((m) => { text: m.text, mine: get(m, "mine") == true, from: get(m, "from") }) state shown = open state draft = "" state waiting = 0 state unseen = 0 state host = null let show = () => { shown = true unseen = 0 // Shown at once (the markup follows `shown` a moment later), so the focus can go in. let panel = host.querySelector(".ui-chat-widget-panel") panel.hidden = false panel.querySelector("textarea").focus() } let hide = () => { shown = false host.querySelector(".ui-chat-widget-bubble").focus() } let land = (v = null) => { waiting = max([0, waiting - 1]) let texts = type_of(v) == "list" ? v : v != null ? [str(v)] : [] log = log + (texts | map((t) => { text: str(t), mine: false, from: null })) if !shown { unseen += len(texts) } } let send = () => { let text = trim(draft) if text != "" { log = log | push({ text, mine: true, from: null }) draft = "" if on_send != null { waiting += 1 let r = on_send(text) if type_of(r) == "promise" { r.then(land, (_ = null) => { land(null) }) } else { land(r) } } } } <div class={ui_cls(["ui-chat-widget", corner != "bottom-right" && `ui-chat-widget-${corner}`, inline && "ui-chat-widget-inline", class])} @mount={(el) => { host = el }} @keydown={(e) => { if e.key == "Escape" && shown { e.preventDefault(); hide() } }}> <div.ui-chat-widget-panel role="dialog" aria-label={title} hidden={!shown}> <div.ui-chat-widget-head> <div.ui-chat-widget-who> <h2>{title}</h2> {status != null && <p>{status}</p>} </div> <button.ui-chat-widget-x type="button" aria-label="Close chat" title="Close chat" @click={hide}> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg> </button> </div> <div.ui-chat-widget-log tabindex="0" aria-label="Messages"> <ol.ui-chat-widget-list role="log" aria-live="polite"> {for [i, m] in enumerate(log) => <li key={i} class={ui_cls(["ui-chat-widget-msg", m.mine && "ui-chat-widget-mine"])}> {!m.mine && m.from != null && <span.ui-chat-widget-from>{m.from}</span>} <span.ui-sr>{m.mine ? "You: " : `${m.from ?? title}: `}</span> <span.ui-chat-widget-text>{m.text}</span> </li>} {waiting > 0 && <li.ui-chat-widget-msg> <span.ui-chat-widget-typing role="status" aria-label={`${title} is typing`}><span></span><span></span><span></span></span> </li>} </ol> </div> <form.ui-chat-widget-form @submit={(e) => { send() }}> <textarea.ui-chat-widget-input rows="1" value={draft} placeholder={placeholder} aria-label="Message" @input={(e) => { draft = e.value }} @keydown={(e) => { if e.key == "Enter" && !e.shiftKey && !e.isComposing { e.preventDefault() send() } }}></textarea> <button.ui-chat-widget-send type="submit" aria-label="Send" title="Send" disabled={trim(draft) == ""}> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 2 11 13M22 2l-7 20-4-9-9-4Z"/></svg> </button> </form> </div> <button.ui-chat-widget-bubble type="button" aria-expanded={shown ? "true" : "false"} aria-label={unseen > 0 ? `${title}, ${unseen} new` : title} title={title} @click={() => { if shown { hide() } else { show() } }}> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> {shown ? <path d="M18 6 6 18M6 6l12 12"/> : <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"/>} </svg> {unseen > 0 && <span.ui-chat-widget-count aria-hidden="true">{unseen}</span>} </button> </div>}Related
ShareSheetA bottom sheet with the ways to share a link: copy it, email, X, LinkedIn, WhatsApp and more, and the system's own.6 views · 4 downloadsAlertDialogA modal that only informs, with one OK button, on the browser's own <dialog> and no JavaScript.6 views · 7 downloadsCommandPaletteCtrl+K opens a fuzzy search of commands and pages, grouped, with keys, recent picks and the arrow keys.6 views · 8 downloadsDrawerA panel that slides in from an edge over the page, on the browser's own <dialog>.6 views · 6 downloadsFlyoutA panel that opens beside its button at a chosen side, keeps open while you work in it, and closes on a click outside.5 views · 10 downloadsFullscreenModalA dialog taking the whole window, with a bar holding close, its title and a main action, on the browser's <dialog>.5 views · 4 downloads
Thread
Thread 0 posts
On ChatWidget: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.