Mobile

CoachOverlay

A one-time spotlight of a real element, tracking it through scroll and rotation, advancing by tap, always skippable.

#mobile#interactive#tablet#onboarding#tour#spotlight

Demo

Preview

Note 1

Note 2

Note 3

Note 4

Note 5

Note 6

Note 7

Note 8

Note 9

Note 10

Note 11

Note 12

Note 13

Note 14

Note 15

Note 16

Not done yet.

Get it

It needs theme.qwe, as every component does, and welcome_carousel.qwe; 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/welcome_carousel.qwe$ curl -O https://quillolang.org/components/ui/coach_overlay.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/coach_overlay.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/coach_overlay.qwe
component CoachOverlayDemoLive() {    state run = 0    state pressed = 0    state said = "Not done yet."    <div.demo-stack>        <div.demo-phone id="coach-overlay-demo-frame">            <div id="coach-overlay-demo-scroll" style="height: 100%; overflow: auto; padding: 16px; box-sizing: border-box">                <div.demo-row style="justify-content: space-between">                    <Button size="lg" id="coach-overlay-demo-add" on_click={() => { pressed = pressed + 1 }}>New note</Button>                    <Button size="lg" variant="outline" id="coach-overlay-demo-search">Search</Button>                </div>                {for i in range(1, 17) => <p key={i} style="height: 90px; margin: 12px 0 0; padding: 12px; border: 1px solid var(--ui-border); border-radius: 10px">Note {i}</p>}                <Button size="lg" variant="soft" id="coach-overlay-demo-far">Settings</Button>                <p style="height: 200px"></p>            </div>            <CoachOverlay key={run} remember="ui-demo-coach" on_done={(h) => { said = `Done: ${h}.` }} steps={[                { target: "coach-overlay-demo-add", title: "Start here", text: "Every note begins with this button." },                { target: "coach-overlay-demo-missing", title: "Not on this screen", text: "Left out." },                { target: "coach-overlay-demo-search", title: "Find anything", text: "Search looks through notes, photos and places." },                { target: "coach-overlay-demo-far", title: "Your settings", text: "Far down the page, so it is brought into view." },            ]}/>        </div>        <div.demo-row>            <Button size="lg" variant="outline" id="coach-overlay-demo-open" on_click={() => { run = run + 1 }}>Open the app again</Button>            <Button size="lg" variant="ghost" id="coach-overlay-demo-forget" on_click={() => {                welcome_carousel_forget("ui-demo-coach")                said = "Forgotten."                run = run + 1            }}>Show it again</Button>        </div>        <p.demo-note id="coach-overlay-demo-said" data-pressed={pressed}>{said}</p>    </div>}view CoachOverlayDemo() = <CoachOverlayDemoLive/>

Source

ui/coach_overlay.qwe
// CoachOverlay: a short lesson on the real screen, shown once: the screen dims but for one// real element, a card says what it is, and a tap anywhere goes on. The light follows the// element as the page scrolls or the phone turns, and the page under it still scrolls by finger.// Skip is always there, and so are the Escape key and the arrow keys.////   <button id="add-note">New note</button>//   <CoachOverlay remember="coach-1" on_done={(how) => { started = true }} steps={[//       { target: "add-note", title: "Start here", text: "Every note begins with this button." },//       { target: "search", title: "Find anything", text: "Search looks through your notes, photos and places." }//   ]}/>//// steps: a list of { target, title, text }; target is an element's id. A step whose element is// not on the page, or not showing, is left out, so a lesson written for a full screen// survives a smaller one. remember: the name it is remembered under; null shows it every time.// active: false holds it back, as behind a WelcomeCarousel, until the app says. pad: the room// left around the element, in pixels (6). next_label ("Next"), done_label ("Got it"),// skip_label ("Skip"). label: what screen readers call it ("A tip").// on_done(how): inside one of your components, once, with "finished", "skipped", or "none"// when no step could be shown (then nothing is remembered). on_step(i): as each step shows.//// The overlay lets every touch through, so a finger on it scrolls what is under it, and takes// only the click that ends a tap, at the document, before the app's own handlers. So a tap on the// lit element goes on to the next step and does not press the element. On a tablet the card// sits beside the element when there is room.import ui/theme.qwe use ui_clsimport ui/button.qwe use Buttonimport ui/welcome_carousel.qwe use welcome_carousel_keep, welcome_carousel_recalllet coach_overlay_css = css`.ui-coach-overlay-host { display: contents; }.ui-coach-overlay { position: fixed; inset: 0; z-index: 65; overflow: hidden; pointer-events: none; font: 15px/1.45 var(--ui-font); color: var(--ui-fg); }.ui-coach-overlay-hole {  position: absolute; left: 0; top: 0; width: 0; height: 0; border-radius: var(--ui-radius-sm); box-shadow: 0 0 0 200vmax var(--ui-overlay), 0 0 0 3px var(--ui-accent);}.ui-coach-overlay-card {  position: absolute; left: 0; top: 0; display: grid; gap: 6px; box-sizing: border-box; width: min(320px, calc(100% - 24px)); padding: 14px; pointer-events: auto;  border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); box-shadow: var(--ui-shadow-lg); touch-action: manipulation;}.ui-coach-overlay-card h3 { margin: 0; font-size: 17px; line-height: 1.3; }.ui-coach-overlay-card p { margin: 0; color: var(--ui-muted); overflow-wrap: anywhere; }.ui-coach-overlay-foot { display: flex; align-items: center; justify-content: space-between; gap: 4px; }.ui-coach-overlay-count { color: var(--ui-muted); font-size: 13px; font-variant-numeric: tabular-nums; }.ui-coach-overlay-acts { display: flex; gap: 4px; }`// The element's box inside the overlay, with room around it. Both are in the window's// coordinates, so a transformed frame around the overlay makes no difference.fn coach_overlay_rect(r, o, pad: num) =    { x: r.left - o.left - pad, y: r.top - o.top - pad, w: r.width + pad * 2, h: r.height + pad * 2 }fn coach_overlay_clamp(v: num, low: num, high: num) -> num = high < low ? low : max([low, min([v, high])])// Where the card goes, for a hole in a w by h overlay: { x, y, side }. Under or over the hole,// in whichever has room, and the larger if neither has; beside it where the overlay is wide.// It stays inside by 12 px, whatever the hole does, as the element may scroll away.fn coach_overlay_place(hole, w: num, h: num, cw: num, ch: num, gap: num = 12) {    let room_below = h - (hole.y + hole.h) - gap - 12    let room_above = hole.y - gap - 12    let room_right = w - (hole.x + hole.w) - gap - 12    let room_left = hole.x - gap - 12    let middle_x = hole.x + hole.w / 2 - cw / 2    let middle_y = hole.y + hole.h / 2 - ch / 2    if w >= 600 && room_right >= cw { return { x: hole.x + hole.w + gap, y: coach_overlay_clamp(middle_y, 12, h - ch - 12), side: "right" } }    if w >= 600 && room_left >= cw { return { x: hole.x - gap - cw, y: coach_overlay_clamp(middle_y, 12, h - ch - 12), side: "left" } }    let below = room_below >= ch || room_below >= room_above    { x: coach_overlay_clamp(middle_x, 12, w - cw - 12),      y: coach_overlay_clamp(below ? hole.y + hole.h + gap : hole.y - gap - ch, 12, h - ch - 12),      side: below ? "below" : "above" }}// Whether the element of that id is on the page and showing. A top-level fn: a component's// `let` function that calls js() may wait, and `filter` cannot call one that may.fn coach_overlay_shows(target) -> bool {    let found = js("document.getElementById", target ?? "")    found != null && found.getClientRects().length > 0 && js("getComputedStyle", found).visibility != "hidden"}component CoachOverlay(steps: list = [], remember: str? = null, active: bool = true, pad: int = 6, next_label: str = "Next", done_label: str = "Got it",                       skip_label: str = "Skip", label: str = "A tip", on_done = null, on_step = null, class: str? = null) {    style global coach_overlay_css    state phase = "waiting"     // "waiting" for the browser's answer, "ready", "on", "over"    state at = 0    state total = 0    state number = 0            // of those shown, from 1    state box = null    state hole = null    state card = null    let node = null             // the lit element    let shown = []              // the steps that could be shown: found once, as the page does not change under a lesson    let before = null           // what had the focus, which it goes back to    let doc = null              // kept, as reading it through js() would wait, and Tab cannot    let n = len(steps)    let step = at < n ? steps[at] : { title: "", text: "" }    let measure = (_e = null) => {        if node != null && box != null && hole != null && card != null {            let o = box.getBoundingClientRect()            let c = coach_overlay_rect(node.getBoundingClientRect(), o, pad)            hole.style.transform = `translate(${c.x}px, ${c.y}px)`            hole.style.width = `${c.w}px`            hole.style.height = `${c.h}px`            let p = coach_overlay_place(c, o.width, o.height, card.offsetWidth, card.offsetHeight)            card.style.transform = `translate(${p.x}px, ${p.y}px)`            card.setAttribute("data-side", p.side)        }    }    let end = (how: str) => {        if phase == "on" {            phase = "over"            node = null            if before != null { before.focus({ preventScroll: true }) }            if remember != null { welcome_carousel_keep(remember, "1") }            on_done && on_done(how)        }    }    let show = (place: int) => {        number = place + 1        at = shown[place]        node = js("document.getElementById", steps[at].target)        let r = node.getBoundingClientRect()        let o = box != null ? box.getBoundingClientRect() : null        if o == null || r.top < o.top || r.bottom > o.bottom { node.scrollIntoView({ block: "center", behavior: "instant" }) }        on_step && on_step(at)    }    let forward = () => {        if number < total { show(number) } else { end("finished") }    }    let begin = () => {        shown = range(0, n) | filter((i) => coach_overlay_shows(get(steps[i], "target")))        total = len(shown)        if total == 0 {            phase = "over"            on_done && on_done("none")        } else {            before = js("document.activeElement")            phase = "on"            show(0)        }    }    if phase == "ready" && active { begin() }    // The element can move for reasons nothing tells of: a picture loading, a rotation settling.    if phase == "on" { every(200, measure) }    <div.ui-coach-overlay-host         @mount={() => {             let seen = remember != null ? js("Promise.resolve", welcome_carousel_recall(remember)) : null             phase = !is_err(seen) && seen == "1" ? "over" : "ready"             let d = js("document")             doc = d             let tapped = (e) => {                 if phase == "on" && box != null && !card.contains(e.target) {                     let o = box.getBoundingClientRect()                     if e.clientX >= o.left && e.clientX <= o.right && e.clientY >= o.top && e.clientY <= o.bottom {                         e.preventDefault()                         e.stopPropagation()                         forward()                     }                 }             }             let keyed = (e) => {                 if phase == "on" {                     if e.key == "Escape" { e.preventDefault(); end("skipped") }                     if e.key == "ArrowRight" { e.preventDefault(); forward() }                     if e.key == "ArrowLeft" && number > 1 { e.preventDefault(); show(number - 2) }                 }             }             let vv = js("window").visualViewport             d.addEventListener("click", tapped, { capture: true })             d.addEventListener("keydown", keyed)             js("window.addEventListener", "scroll", measure, { capture: true, passive: true })             js("window.addEventListener", "resize", measure)             js("window.addEventListener", "orientationchange", measure)             if vv != null {                 vv.addEventListener("resize", measure)                 vv.addEventListener("scroll", measure)             }             () => {                 d.removeEventListener("click", tapped, { capture: true })                 d.removeEventListener("keydown", keyed)                 js("window.removeEventListener", "scroll", measure, { capture: true })                 js("window.removeEventListener", "resize", measure)                 js("window.removeEventListener", "orientationchange", measure)                 if vv != null {                     vv.removeEventListener("resize", measure)                     vv.removeEventListener("scroll", measure)                 }             }         }}>        {phase == "on" && <div class={ui_cls(["ui-coach-overlay", class])} @mount={(el) => { box = el; measure() }}>            <div.ui-coach-overlay-hole aria-hidden="true" @mount={(el) => { hole = el; measure() }}></div>            <div.ui-coach-overlay-card role="dialog" aria-label={label} aria-modal="false" @mount={(el) => { card = el; measure() }}                 @keydown={(e) => {                     // Tab goes round the card's two buttons: the app is dimmed, and out of play. Two                     // buttons, so either way it is the other one. No js() here: it would wait, and                     // preventDefault after a wait comes too late. And isSameNode, as `==` on two                     // elements is always true.                     if e.key == "Tab" {                         e.preventDefault()                         let inside = card.querySelectorAll("button")                         let one = inside.item(0)                         let two = inside.item(inside.length - 1)                         if one.isSameNode(doc.activeElement) { two.focus() } else { one.focus() }                     }                 }}>                <div aria-live="polite">                    <h3>{get(step, "title") ?? ""}</h3>                    {get(step, "text") != null && <p>{step.text}</p>}                </div>                <div.ui-coach-overlay-foot>                    <span.ui-coach-overlay-count>{number} of {total}</span>                    <span.ui-coach-overlay-acts @mount={(el) => {                        let b = el.querySelector(".ui-btn-solid")                        if b != null { b.focus({ preventScroll: true }) }                    }}>                        <Button size="lg" variant="ghost" tone="neutral" on_click={() => { end("skipped") }}>{skip_label}</Button>                        <Button size="lg" on_click={() => { forward() }}>{number < total ? next_label : done_label}</Button>                    </span>                </div>            </div>        </div>}    </div>}
Thread
26Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.