Feedback

Coachmark

A small callout pointing at an element by id, explaining it once, dismissed with Got it; several make a tour.

#feedback#popover#notification#interactive

Demo

Preview

Tour step 1 of 2.

The one on Share is remembered once dismissed (localStorage), and stays away on the next load.

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/coachmark.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/coachmark.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/coachmark.qwe
component CoachmarkDemoLive() {    state tour = 0    <div.demo-stack>        <div.demo-row style="gap: 24px">            <Button id="coachmark-demo-export" variant="outline">Export</Button>            <Button id="coachmark-demo-filters" variant="outline">Filters</Button>            <Button id="coachmark-demo-share" variant="outline">Share</Button>        </div>        <Coachmark target="coachmark-demo-export" title="Export is here now" step="1 of 2" done="Next" open={tour == 0}                   on_done={() => { tour = 1 }}>Download any report as CSV or PDF.</Coachmark>        <Coachmark target="coachmark-demo-filters" title="Filter the list" placement="right" step="2 of 2" open={tour == 1}                   on_done={() => { tour = 2 }}>Narrow it by date, owner or status.</Coachmark>        <p.demo-note>{tour < 2 ? `Tour step ${tour + 1} of 2.` : "Tour done."}</p>    </div>}view CoachmarkDemo() =    <div.demo-stack>        <CoachmarkDemoLive/>        <Coachmark target="coachmark-demo-share" placement="top" remember="demo-share">Share a link to this view with your team.</Coachmark>        <p.demo-note>The one on Share is remembered once dismissed (localStorage), and stays away on the next load.</p>    </div>

Source

ui/coachmark.qwe
// Coachmark: a small callout beside an element, pointing at it, that explains it// once and goes away with "Got it". For showing someone a new button or a// moved menu. Several in a row make a tour: give each a step.////   <Coachmark target="export-btn" title="Export is here now">Download any report as CSV or PDF.</Coachmark>//   <Coachmark target="filters" placement="right" remember="filters-2026" step="2 of 3" done="Next"//              on_done={() => { tour += 1 }}>Narrow the list by date, owner or status.</Coachmark>//// target: the id of the element it points at. title: a bold first line.// children: the text. placement: "bottom" (default), "top", "left" or "right"// of the target. done: the button's text ("Got it"). step: a small line such// as "1 of 3". open: shown or not (true); inside one of your components, drive// it from state to make a tour. remember: a name; once dismissed it stays away// (in localStorage) until the name changes. on_done(): inside one of your// components, called with nothing when it is dismissed. It is a dialog// (role="dialog") named by its title; Escape dismisses it too. It follows the// target as the page scrolls, resizes or grows.import ui/theme.qwe use ui_clslet coachmark_css = css`.ui-coach-host { display: contents; }.ui-coach { position: fixed; z-index: 65; width: max-content; max-width: min(300px, calc(100vw - 32px)); padding: 12px 14px;  border-radius: var(--ui-radius); background: var(--ui-accent); color: var(--ui-accent-fg); box-shadow: var(--ui-shadow-lg);  font: 14px/1.45 var(--ui-font); animation: ui-coach-in .18s ease-out; }.ui-coach::after { content: ""; position: absolute; width: 12px; height: 12px; background: inherit; transform: rotate(45deg); }.ui-coach-bottom::after { top: -6px; left: calc(50% - 6px); }.ui-coach-top::after { bottom: -6px; left: calc(50% - 6px); }.ui-coach-left::after { right: -6px; top: calc(50% - 6px); }.ui-coach-right::after { left: -6px; top: calc(50% - 6px); }.ui-coach-step { display: block; margin-bottom: 2px; font-size: 11px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; opacity: .8; }.ui-coach-title { margin: 0 0 2px; font-weight: 600; }.ui-coach-text { margin: 0; opacity: .92; }.ui-coach-btn { display: inline-flex; align-items: center; height: 30px; margin-top: 10px; padding: 0 12px; border: 0;  border-radius: var(--ui-radius-sm); background: var(--ui-accent-fg); color: var(--ui-accent); font: 600 13px/1 var(--ui-font); cursor: pointer; }.ui-coach-btn:hover { opacity: .9; }.ui-coach-btn:focus-visible { outline: 2px solid var(--ui-accent-fg); outline-offset: 2px; }.ui-coach-target { outline: 2px solid var(--ui-accent); outline-offset: 3px; border-radius: var(--ui-radius-sm); }@keyframes ui-coach-in { from { opacity: 0; transform: translateY(4px); } }@media (prefers-reduced-motion: reduce) { .ui-coach { animation: none; } }`component Coachmark(target: str, title: str? = null, placement: str = "bottom", done: str = "Got it", step: str? = null,                    open: bool = true, remember: str? = null, on_done = null, class: str? = null, children) {    style global coachmark_css    state shown = true    state at = null         // { x, y } where the callout's corner goes, once measured    state box = null    let key = remember != null ? `ui-coach:${remember}` : null    let place = () => {        let el = js("document.getElementById", target)        if el == null || box == null { return null }        let r = el.getBoundingClientRect()        let w = box.offsetWidth        let h = box.offsetHeight        let gap = 10        let raw = match placement {            "top" => { x: r.left + r.width / 2 - w / 2, y: r.top - h - gap }            "left" => { x: r.left - w - gap, y: r.top + r.height / 2 - h / 2 }            "right" => { x: r.right + gap, y: r.top + r.height / 2 - h / 2 }            _ => { x: r.left + r.width / 2 - w / 2, y: r.bottom + gap }        }        let vw = js("innerWidth")        at = { x: max([8, min([raw.x, vw - w - 8])]), y: raw.y }    }    let finish = () => {        shown = false        if key != null { js("localStorage.setItem", key, "1") }        let el = js("document.getElementById", target)        if el != null { el.classList.remove("ui-coach-target") }        on_done && on_done()    }    <div class="ui-coach-host" @mount={(_el) => {        if key != null && js("localStorage.getItem", key) == "1" { shown = false }        let w = js("window")        let follow = (_e = null) => { if open && shown { place() } }        w.addEventListener("scroll", follow, { passive: true, capture: true })        w.addEventListener("resize", follow, { passive: true })        // Content above the target that loads or folds moves it without a scroll.        let grown = js_new("ResizeObserver", follow)        grown.observe(js("document.body"))        () => {            w.removeEventListener("scroll", follow, { capture: true })            w.removeEventListener("resize", follow)            grown.disconnect()        }    }}>        {open && shown && <div class={ui_cls(["ui-coach", `ui-coach-${placement}`, class])} role="dialog" aria-label={title}                               style={at != null ? `left: ${at.x}px; top: ${at.y}px` : "visibility: hidden; left: 0; top: 0"}                               @mount={(el) => {                                   box = el                                   place()                                   let t = js("document.getElementById", target)                                   if t != null { t.classList.add("ui-coach-target") }                                   () => { if t != null { t.classList.remove("ui-coach-target") } }                               }}                               @keydown={(e) => { if e.key == "Escape" { finish() } }}>            {step != null && <span.ui-coach-step>{step}</span>}            {title != null && <p.ui-coach-title>{title}</p>}            <p.ui-coach-text>{children}</p>            <button.ui-coach-btn type="button" @click={finish}>{done}</button>        </div>}    </div>}
Thread
68Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.