Overlays

ChatWidget

A chat button in the corner that opens a small chat panel, with replies from your server and a count for unseen ones.

#chat#overlay#social#interactive

Demo

Preview

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.qwe
main.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>}
Thread
47Useful? Like it, or add to its thread below.

Thread 0 posts

On ChatWidget: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.