Coachmark
A small callout pointing at an element by id, explaining it once, dismissed with Got it; several make a tour.
Demo
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.qwemain.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>}Related
BannerAn announcement across the top of the page in six tones, with a link, closed for the session once dismissed.7 views · 6 downloadsConnectionStatusSays when the browser goes offline, as a bar across the top or a pill, and that it is back online for a moment.6 views · 7 downloadsCookieConsentA cookie banner with accept, reject and settings by category, remembering the choice in localStorage.6 views · 7 downloadsNotificationDotA small dot or a count on the corner of anything, in tones and four positions, with a label for screen readers.5 views · 7 downloadsSnackbarA short message along the bottom of the window with one action, shown from anywhere by calling snackbar_show().6 views · 6 downloadsAlertA message in the page in six tones and three variants, with an icon, announced to screen readers, and dismissable.5 views · 9 downloads
Thread
Thread 0 posts
On Coachmark: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.