Mobile

GestureTutorial

Teaches a gesture with a looping hand over the real thing, checks it was done there, then does not come back.

#mobile#interactive#gesture#onboarding#tutorial

Demo

Preview
The real thing

Outside the spot.

Not done yet.

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

Example

The code of the preview above.

demos/gesture_tutorial.qwe
let gesture_tutorial_demo_kinds = ["swipe-left", "swipe-right", "swipe-up", "swipe-down", "tap", "double-tap", "long-press", "pinch"]let gesture_tutorial_demo_css = css`.demo-tut-spot { display: grid; place-items: center; height: 170px; border: 1px dashed var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-subtle); color: var(--ui-muted); touch-action: none; }.demo-tut-picks { display: flex; flex-wrap: wrap; gap: 6px; }.demo-tut-pick { min-height: 44px; min-width: 44px; padding: 0 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 14px var(--ui-font); }.demo-tut-pick[aria-pressed="true"] { border-color: var(--ui-accent); color: var(--ui-accent); }.demo-tut-scroll { height: 170px; overflow-y: auto; border: 1px dashed var(--ui-border); border-radius: var(--ui-radius-sm); }.demo-tut-scroll p { margin: 0; padding: 40px 16px; }`component GestureTutorialDemoLive() {    style global gesture_tutorial_demo_css    state kind = "swipe-left"    state said = "Not done yet."    state again = 0    <div.demo-stack>        <div.demo-tut-picks role="group" aria-label="Gesture">            {for k in gesture_tutorial_demo_kinds => <button.demo-tut-pick key={k} type="button" aria-pressed={k == kind ? "true" : "false"} @click={() => { kind = k }}>{k}</button>}        </div>        {for k in [kind] => <GestureTutorial key={k} gesture={k} name={`demo-${k}`} replay={again} target=".demo-tut-spot"                on_done={(how) => { said = `${k}: ${how}` }}>            {k == "swipe-up" || k == "swipe-down"                ? <div.demo-tut-scroll><div.demo-tut-spot style="touch-action: pan-y; height: 600px">A list that scrolls</div></div>                : <div.demo-tut-spot>The real thing</div>}            <p.demo-note id="gesture-tut-outside">Outside the spot.</p>        </GestureTutorial>}        <p.demo-note id="gesture-tut-said">{said}</p>        <button.demo-tut-pick id="gesture-tut-again" type="button" @click={() => { again += 1 }}>Show it again</button>        // Bad data, for the test to look at: nothing here should stop the page.        <div id="gesture-tut-bad" hidden="true"><GestureTutorial gesture="wiggle" name="demo-wiggle">Real</GestureTutorial></div>    </div>}view GestureTutorialDemo() = <GestureTutorialDemoLive/>

Source

ui/gesture_tutorial.qwe
// GestureTutorial: shows someone a gesture once, the first time they meet it. A hand// loops the movement over the real thing, in place, and whatever is under it goes on// working; the tutorial only watches. When the person has done the gesture on that very// spot, it says so and does not come back on this device. It remembers that in a cookie,// as a phone app comes back to an empty localStorage at each launch and keeps its cookies;// clearing a site's data shows it again, and where cookies are refused it shows each time.// A wrong try is told what was wrong: too short, the other way. Skip ends it too, for// anyone who cannot or need not do it, and counts as seen.////   <GestureTutorial gesture="swipe-left" name="inbox-swipe" text="Swipe a message left to see its actions" target=".first-row">//       <MessageList/>//   </GestureTutorial>//// gesture: "swipe-left", "swipe-right", "swipe-up", "swipe-down", "tap", "double-tap",// "long-press" or "pinch"; anything else is taken as "tap". name: what it is remembered// by: a new name shows it again. text: what it// asks, in words; each gesture has its own without. target: a CSS selector inside// the children for the spot that counts; without it, all of them. done_text: what it says// after. skip_label: the word on the way out ("Skip"). on_done(how): inside one of your// components, "done" or "skipped", once.// replay: raise this number to show it again. class. children: the real thing.import ui/theme.qwe use ui_clslet gesture_tutorial_css = css`.ui-gesture-tut { position: relative; container-type: inline-size; color: var(--ui-fg); font: 15px/1.45 var(--ui-font); }/* The layer never takes a touch: the gesture has to reach the real thing under it. */.ui-gesture-tut-layer { position: absolute; inset: 0; z-index: 4; overflow: hidden; pointer-events: none; }.ui-gesture-tut-ring { position: absolute; border: 2px solid var(--ui-accent); border-radius: var(--ui-radius-sm); box-shadow: 0 0 0 4px var(--ui-accent-soft); }.ui-gesture-tut-hand { position: absolute; width: 0; height: 0; animation: var(--a) 1.8s ease-in-out infinite; }/* A ring where the fingertip lands, and the hand itself, its fingertip on the ring. Dark on white reads on any background. */.ui-gesture-tut-dot { position: absolute; left: -16px; top: -16px; width: 32px; height: 32px; border: 3px solid #fff; border-radius: 50%; background: color-mix(in srgb, var(--ui-accent) 55%, transparent); box-shadow: var(--ui-shadow); }.ui-gesture-tut-dot svg { position: absolute; left: 3px; top: 8px; width: 52px; height: 52px; overflow: visible; color: #17161f; filter: drop-shadow(0 2px 3px rgba(0, 0, 0, .35)); }.ui-gesture-tut-dot2 { display: none; }.ui-gesture-tut-card { display: flex; align-items: center; gap: 10px; margin-top: 8px; padding: 6px 6px 6px 14px; border-radius: var(--ui-radius-sm); background: var(--ui-accent-soft); }.ui-gesture-tut-words { flex: 1; min-width: 0; }.ui-gesture-tut-title { display: block; font-weight: 600; }.ui-gesture-tut-why { display: block; color: var(--ui-danger); font-size: 14px; }.ui-gesture-tut-good { color: var(--ui-success); font-weight: 600; }.ui-gesture-tut-skip { flex: none; min-width: 44px; min-height: 44px; padding: 0 12px; border: 0; border-radius: var(--ui-radius-sm); background: transparent; color: var(--ui-fg); font: 500 14px/1 var(--ui-font); text-decoration: underline; cursor: pointer; }.ui-gesture-tut-skip:focus-visible { outline: none; box-shadow: var(--ui-focus); }@container (min-width: 520px) { .ui-gesture-tut-card { max-width: 420px; margin-inline: auto; } }@keyframes ui-gesture-tut-left { 0%, 15% { transform: translateX(36px); opacity: 0; } 25% { opacity: 1; } 70% { opacity: 1; } 85%, 100% { transform: translateX(-46px); opacity: 0; } }@keyframes ui-gesture-tut-right { 0%, 15% { transform: translateX(-36px); opacity: 0; } 25% { opacity: 1; } 70% { opacity: 1; } 85%, 100% { transform: translateX(46px); opacity: 0; } }@keyframes ui-gesture-tut-up { 0%, 15% { transform: translateY(36px); opacity: 0; } 25% { opacity: 1; } 70% { opacity: 1; } 85%, 100% { transform: translateY(-46px); opacity: 0; } }@keyframes ui-gesture-tut-down { 0%, 15% { transform: translateY(-36px); opacity: 0; } 25% { opacity: 1; } 70% { opacity: 1; } 85%, 100% { transform: translateY(46px); opacity: 0; } }@keyframes ui-gesture-tut-tap { 0%, 100% { transform: scale(1.25); opacity: .2; } 30% { transform: scale(.85); opacity: 1; } 50% { transform: scale(1.1); opacity: .9; } }@keyframes ui-gesture-tut-double { 0%, 100% { transform: scale(1.25); opacity: .2; } 20%, 50% { transform: scale(.85); opacity: 1; } 35%, 65% { transform: scale(1.1); opacity: .9; } }@keyframes ui-gesture-tut-hold { 0%, 100% { transform: scale(1.3); opacity: .2; } 20%, 75% { transform: scale(.8); opacity: 1; } }.ui-gesture-tut-pinch .ui-gesture-tut-dot { animation: ui-gesture-tut-p1 1.8s ease-in-out infinite; }.ui-gesture-tut-pinch .ui-gesture-tut-dot.ui-gesture-tut-dot2 { display: block; animation-name: ui-gesture-tut-p2; }@keyframes ui-gesture-tut-p1 { 0%, 20% { transform: translate(-14px, 14px); opacity: .3; } 70%, 100% { transform: translate(-42px, 42px); opacity: 1; } }@keyframes ui-gesture-tut-p2 { 0%, 20% { transform: translate(14px, -14px); opacity: .3; } 70%, 100% { transform: translate(42px, -42px); opacity: 1; } }@media (prefers-reduced-motion: reduce) {  /* A hand standing where the gesture starts, and the words do the rest. */  .ui-gesture-tut-hand, .ui-gesture-tut-pinch .ui-gesture-tut-dot { animation: none; }}`// The gestures this knows; anything else is a tap.fn gesture_tutorial_kind(g: str) -> str {    contains(["swipe-left", "swipe-right", "swipe-up", "swipe-down", "tap", "double-tap", "long-press", "pinch"], g) ? g : "tap"}fn gesture_tutorial_words(g: str) -> str {    match g {        "swipe-left" => "Swipe left here"        "swipe-right" => "Swipe right here"        "swipe-up" => "Swipe up here"        "swipe-down" => "Swipe down here"        "double-tap" => "Tap twice, quickly, here"        "long-press" => "Press and hold here"        "pinch" => "Pinch here with two fingers"        _ => "Tap here"    }}fn gesture_tutorial_motion(g: str) -> str {    match g {        "swipe-left" => "ui-gesture-tut-left"        "swipe-right" => "ui-gesture-tut-right"        "swipe-up" => "ui-gesture-tut-up"        "swipe-down" => "ui-gesture-tut-down"        "double-tap" => "ui-gesture-tut-double"        "long-press" => "ui-gesture-tut-hold"        _ => "ui-gesture-tut-tap"    }}// What a try came to: { ok, why }, why being what to say, which may be nothing. t is what the// fingers did: { dx, dy, ms, fingers, scale, taps }, taps counting those close enough in a row// and slow true for a second tap that came too late.fn gesture_tutorial_judge(g: str, t) {    let far = abs(t.dx) + abs(t.dy)    if g == "pinch" {        if t.fingers < 2 { return { ok: false, why: "Use two fingers" } }        if t.scale >= 1.3 || t.scale <= 0.77 { return { ok: true, why: "" } }        return { ok: false, why: "Move them further" }    }    if starts_with(g, "swipe-") {        let main = g == "swipe-left" ? -t.dx : g == "swipe-right" ? t.dx : g == "swipe-up" ? -t.dy : t.dy        let across = g == "swipe-left" || g == "swipe-right" ? abs(t.dy) : abs(t.dx)        if main >= 60 { return main >= across * 1.5 ? { ok: true, why: "" } : { ok: false, why: "Keep it straight" } }        if main <= -40 { return { ok: false, why: "The other way" } }        if far < 12 { return { ok: false, why: "" } }        return { ok: false, why: "A little further" }    }    if far >= 14 { return { ok: false, why: "Keep your finger still" } }    if g == "long-press" {        if t.ms >= 450 { return { ok: true, why: "" } }        return { ok: false, why: "Hold it a little longer" }    }    if g == "double-tap" {        if t.taps >= 2 { return { ok: true, why: "" } }        return { ok: false, why: get(t, "slow") == true ? "A little quicker" : "" }    }    { ok: t.ms < 500, why: "" }}// The cookie's name.fn gesture_tutorial_key(name: str, g: str) -> str {    `quillo_tutorial_${url_encode(name != "" ? name : g)}`}// Whether the page's cookies, as document.cookie gives them, hold this one. Anything but// text, as when reading them was refused, holds nothing.fn gesture_tutorial_seen(jar, key: str) -> bool {    if type_of(jar) != "str" { return false }    len(split(jar, "; ") | filter((c) => starts_with(c, `${key}=`))) > 0}fn gesture_tutorial_gap(a, b) -> num {    math.sqrt((a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y))}// A pointing hand, its fingertip at the top left.view GestureTutorialHand() =    <svg viewBox="0 0 24 24" fill="#fff" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">        <path d="M18 11V10a2 2 0 0 0-4 0V9a2 2 0 0 0-4 0V4a2 2 0 0 0-4 0v11l-1.6-1.8a2 2 0 0 0-2.9 2.8l3.6 3.7C6.6 21.2 8.3 22 11 22h3a8 8 0 0 0 8-8v-3a2 2 0 0 0-4 0z"/>    </svg>component GestureTutorial(gesture: str = "swipe-left", name: str = "", text: str? = null, target: str? = null, done_text: str = "That's it", skip_label: str = "Skip",                          on_done = null, replay: int = 0, class: str? = null, children) {    style global gesture_tutorial_css    let g_kind = gesture_tutorial_kind(gesture)    let store = gesture_tutorial_key(name, g_kind)    state phase = "wait"     // wait (until the browser has looked), teach, well, gone    state why = ""    state ring = null        // { x, y, w, h } of the spot, from the corner of this component    state box = null    state play = replay    state down = []          // pointers on the spot: { id, x, y }    state g = null           // the try under way    state prev = null        // the tap before, for a double tap: { t, x, y }    state on_spot = false    state said_done = false    let aim = () => {        let spot = target != null ? box.querySelector(target) : box.querySelector(".ui-gesture-tut-real")        if spot != null {            let a = box.getBoundingClientRect()            let r = spot.getBoundingClientRect()            ring = { x: r.left - a.left, y: r.top - a.top, w: r.width, h: r.height }        }    }    let finish = (how: str) => {        if !said_done {            said_done = true            on_done && on_done(how)        }        // A year, for what a person should meet once. Refused, it is a Promise refused, which harms nothing.        js("Promise.resolve", js_new("Promise", (done) => {            js("document").cookie = `${store}=${how}; max-age=31536000; path=/; samesite=lax`            done(true)        }))    }    let judge = (t) => {        let r = gesture_tutorial_judge(g_kind, t)        if r.ok {            why = ""            phase = "well"            finish("done")        } else {            why = r.why        }    }    if phase == "well" { every(1300, () => { phase = "gone" }) }    // Raised from outside, it starts again.    if replay != play {        play = replay        js("Promise.resolve", js_new("Promise", (done) => {            js("document").cookie = `${store}=; max-age=0; path=/; samesite=lax`            done(true)        }))        said_done = false        why = ""        phase = "teach"    }    // Is this event on the spot that counts?    let on_target = (e) => target == null || e.target.closest(target) != null    <div class={ui_cls(["ui-gesture-tut", class])}         @mount={(me) => {             // First: state set before a call to the page can be read during its wait. Reading may be             // refused, in a sandboxed page, and this is how that becomes a value and not a stop.             let jar = js("Promise.resolve", js_new("Promise", (done) => { done(js("document.cookie")) }))             box = me             phase = gesture_tutorial_seen(jar, store) ? "gone" : "teach"             aim()             let real = me.querySelector(".ui-gesture-tut-real")             // All in the capture phase, passive: it only watches, and what the real thing does             // with the event, capture included, stops none of this.             let press = (e) => {                 if phase == "teach" {                     if len(down) == 0 { on_spot = on_target(e) }                     down = down + [{ id: e.pointerId, x: e.clientX, y: e.clientY }]                     if len(down) == 1 {                         g = { x: e.clientX, y: e.clientY, t: e.timeStamp, dx: 0, dy: 0, fingers: 1, gap: 0, scale: 1, cut: false }                     }                     if len(down) == 2 && g != null {                         g = { ...g, fingers: 2, gap: max([1, gesture_tutorial_gap(down[0], down[1])]) }                     }                 }             }             let track = (x: num, y: num) => {                 if g != null { g = { ...g, dx: x - g.x, dy: y - g.y } }             }             let move = (e) => {                 if phase == "teach" && g != null {                     down = down | map((d) => d.id == e.pointerId ? { id: d.id, x: e.clientX, y: e.clientY } : d)                     if len(down) == 1 { track(e.clientX, e.clientY) }                     if len(down) == 2 && g.fingers == 2 {                         let s = gesture_tutorial_gap(down[0], down[1]) / g.gap                         // The furthest it got: the fingers relax before they lift.                         g = { ...g, scale: abs(s - 1) > abs(g.scale - 1) ? s : g.scale }                     }                 }             }             let ended = (now: num, x: num, y: num) => {                 if g != null {                     let was = g                     g = null                     if !on_spot {                         why = "On the marked spot"                     } else {                         let quick = abs(was.dx) + abs(was.dy) < 14 && now - was.t < 450                         let here = { t: now, x, y }                         let twice = quick && prev != null && now - prev.t <= 400 && gesture_tutorial_gap(prev, here) <= 40                         let slow = quick && prev != null && !twice                         prev = quick && !twice ? here : null                         judge({ dx: was.dx, dy: was.dy, ms: now - was.t, fingers: was.fingers, scale: was.scale, taps: twice ? 2 : 1, slow })                     }                 }             }             let lift = (e) => {                 if phase == "teach" {                     down = down | filter((d) => d.id != e.pointerId)                     if len(down) == 0 { ended(e.timeStamp, e.clientX, e.clientY) }                 }             }             // The browser taking a swipe for a scroll ends the pointer, with touches still coming.             let taken = (e) => {                 down = down | filter((d) => d.id != e.pointerId)                 if g != null { g = { ...g, cut: true } }             }             let touching = (e) => {                 if g != null && g.cut && e.touches.length > 0 { track(e.touches[0].clientX, e.touches[0].clientY) }             }             let touch_end = (e) => {                 if g != null && g.cut && phase == "teach" {                     down = []                     ended(e.timeStamp, g.x + g.dx, g.y + g.dy)                 }             }             // Android's long press ends in the system's menu or in nothing: it is a long press all the same.             let menu = (e) => {                 if phase == "teach" && g != null && g_kind == "long-press" { ended(e.timeStamp, e.clientX, e.clientY) }             }             let quiet = { capture: true, passive: true }             real.addEventListener("pointerdown", press, quiet)             real.addEventListener("pointermove", move, quiet)             real.addEventListener("pointerup", lift, quiet)             real.addEventListener("pointercancel", taken, quiet)             real.addEventListener("touchmove", touching, quiet)             real.addEventListener("touchend", touch_end, quiet)             real.addEventListener("contextmenu", menu, quiet)             // A turned phone or a new layout moves the spot.             let watcher = js_new("ResizeObserver", (_a = null) => { aim() })             watcher.observe(me)             () => { watcher.disconnect() }         }}>        <div.ui-gesture-tut-real>{children}</div>        {phase == "teach" && ring != null && <div.ui-gesture-tut-layer aria-hidden="true">            <div.ui-gesture-tut-ring style={`left: ${ring.x}px; top: ${ring.y}px; width: ${ring.w}px; height: ${ring.h}px`}></div>            <div class={ui_cls(["ui-gesture-tut-hand", g_kind == "pinch" && "ui-gesture-tut-pinch"])}                 style={`left: ${ring.x + ring.w / 2}px; top: ${ring.y + ring.h / 2}px; --a: ${gesture_tutorial_motion(g_kind)}`}>                <span.ui-gesture-tut-dot><GestureTutorialHand/></span>                <span.ui-gesture-tut-dot.ui-gesture-tut-dot2></span>            </div>        </div>}        {phase == "teach" && <div.ui-gesture-tut-card role="status">            <span.ui-gesture-tut-words>                <span.ui-gesture-tut-title>{text ?? gesture_tutorial_words(g_kind)}</span>                {why != "" && <span.ui-gesture-tut-why>{why}</span>}            </span>            <button.ui-gesture-tut-skip type="button" @click={() => {                phase = "gone"                finish("skipped")            }}>{skip_label}</button>        </div>}        {phase == "well" && <div.ui-gesture-tut-card role="status"><span.ui-gesture-tut-words.ui-gesture-tut-good>{done_text}</span></div>}    </div>}
Thread
47Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.