GestureTutorial
Teaches a gesture with a looping hand over the real thing, checks it was done there, then does not come back.
Demo
The real thing
Outside the spot.
Not done yet.
Real
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.qwemain.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>}Related
ActionSheetA list of actions rising from the bottom, with a destructive style and Cancel: swipe down or the phone's back gesture closes it; on a tablet, a card by what opened it.3 views · 9 downloadsAlertInboxAlerts by severity, swiped to acknowledge or snooze, with buttons and keys to the same, and a badge that stays right as snoozes run out.3 views · 8 downloadsAlphaRailListA long list with sticky letter headers and an A to Z rail scrubbed by finger, with a bubble showing the letter and a haptic per step.1 view · 6 downloadsApprovalQueueOne request at a time, swiped to approve or reject, with a required reason, undo and a count of what is left.3 views · 5 downloadsBookSpreadReader pages, one column upright and a two-page spread turned, by swipe or edge tap, the place kept by text and not page number.2 views · 7 downloadsBottomSheetA sheet with peek, half and full stops, dragged by finger: it resists past its ends, snaps by speed, shares the finger with the list inside and rises above the keyboard.4 views · 7 downloads
Thread
Thread 0 posts
On GestureTutorial: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.