DoubleTapLike
Double tap a photo card to like it, a heart bursting at the finger, with a plain Like button as the equal.
Demo
Harbour at dusk
Taken from the old sea wall, a little after eight.
Nothing yet.
Single taps: 0
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/double_tap_like.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/double_tap_like.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/double_tap_like.qwe
component DoubleTapLikeDemoLive() { state said = "Nothing yet." state taps = 0 <div.demo-stack style="max-width: 760px"> <DoubleTapLike title="Harbour at dusk" text="Taken from the old sea wall, a little after eight." count={1284} alt="Harbour at dusk" color="linear-gradient(160deg, #f6b26b, #674ea7 70%, #20124d)" on_like={(on, n) => { said = `liked ${on} ${n}` }} on_tap={() => { taps += 1 }}/> <p.demo-note id="double-tap-said">{said}</p> <p.demo-note id="double-tap-taps">Single taps: {taps}</p> // Bad data, for the test to look at: nothing here should stop the page. <div id="double-tap-bad" hidden="true"><DoubleTapLike count="many"/><DoubleTapLike count={-4}/><DoubleTapLike count={12.7}/><DoubleTapLike count={1500000}/></div> </div>}view DoubleTapLikeDemo() = <DoubleTapLikeDemoLive/>Source
ui/double_tap_like.qwe
// DoubleTapLike: a photo card liked by tapping twice on the picture, as in a photo feed.// A heart bursts from where the finger was. A second double tap stays liked, as taking a like// back by accident is the worse mistake; only the plain Like button, which does everything// the gesture does, takes it back. A single tap stays a tap: it runs on_tap, a moment later,// once it is clear no second tap is coming. In a wide container the words stand// beside the picture instead of under it.//// component Post(p) {// <DoubleTapLike src={p.url} alt={p.alt} title={p.who} count={p.likes} liked={p.mine}// on_like={(on, n) => { save_like(p.id, on) }} on_tap={() => { open_photo(p) }}/>// }//// src, alt: the picture. color: a CSS colour or gradient behind it, or in place of it.// title, text: the words on the card. liked: whether it starts liked. count: how many// likes it starts with; a bad one counts as none. ratio: the picture's shape, as CSS// ("4 / 3"). on_like(liked, count): inside one of your components, the like changed. on_tap():// a single tap on the picture, or Enter on it. gap: the longest wait between two taps, in// milliseconds (280). class. children: anything more under the words.import ui/theme.qwe use ui_clslet double_tap_like_css = css`.ui-double-tap { container-type: inline-size; overflow: hidden; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); color: var(--ui-fg); font: 15px/1.45 var(--ui-font); }.ui-double-tap-inner { display: grid; }/* manipulation: pans and pinches stay the page's, and the browser's own double tap to zoom is off. */.ui-double-tap-media { position: relative; display: block; width: 100%; overflow: hidden; background: var(--c, var(--ui-subtle)); touch-action: manipulation; -webkit-tap-highlight-color: transparent; -webkit-touch-callout: none; user-select: none; -webkit-user-select: none; outline: none; }.ui-double-tap-media:focus-visible { box-shadow: inset var(--ui-focus); }.ui-double-tap-media img { display: block; width: 100%; height: 100%; object-fit: cover; pointer-events: none; -webkit-user-drag: none; }.ui-double-tap-burst { position: absolute; width: 0; height: 0; pointer-events: none; }.ui-double-tap-burst svg { position: absolute; left: -44px; top: -44px; width: 88px; height: 88px; color: #fff; filter: drop-shadow(0 2px 8px rgba(0, 0, 0, .35)); animation: ui-double-tap-pop .7s cubic-bezier(.2, .9, .3, 1.3) forwards; }.ui-double-tap-ring { position: absolute; left: -50px; top: -50px; width: 100px; height: 100px; border: 3px solid #fff; border-radius: 50%; animation: ui-double-tap-ring .55s ease-out forwards; }@keyframes ui-double-tap-pop { 0% { opacity: 0; transform: scale(.3); } 25% { opacity: 1; transform: scale(1.15); } 45% { transform: scale(1); } 75% { opacity: 1; } 100% { opacity: 0; transform: scale(1.05) translateY(-14px); } }@keyframes ui-double-tap-ring { from { opacity: .8; transform: scale(.4); } to { opacity: 0; transform: scale(1.5); } }.ui-double-tap-side { display: grid; gap: 4px; align-content: start; padding: 8px 14px 12px; }.ui-double-tap-row { display: flex; align-items: center; gap: 8px; }.ui-double-tap-title { margin: 0; font-weight: 600; }.ui-double-tap-text { margin: 0; color: var(--ui-muted); font-size: 14px; }.ui-double-tap-like { display: inline-flex; align-items: center; gap: 6px; min-width: 44px; min-height: 44px; padding: 0 10px 0 6px; border: 0; border-radius: var(--ui-radius-sm); background: transparent; color: var(--ui-fg); font: 600 14px/1 var(--ui-font); cursor: pointer; }.ui-double-tap-like svg { width: 26px; height: 26px; fill: none; stroke: currentColor; stroke-width: 2; transition: transform .16s; }.ui-double-tap-like[aria-pressed="true"] { color: var(--ui-danger); }.ui-double-tap-like[aria-pressed="true"] svg { fill: currentColor; transform: scale(1.08); }.ui-double-tap-like:focus-visible { outline: none; box-shadow: var(--ui-focus); }@media (hover: hover) { .ui-double-tap-like:hover { background: var(--ui-subtle); } }@container (min-width: 520px) { .ui-double-tap-inner { grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); } .ui-double-tap-side { padding: 14px 16px; }}@media (prefers-reduced-motion: reduce) { /* Still there to see, without the movement; the end of this fade removes it. */ .ui-double-tap-burst svg { animation: ui-double-tap-still .6s linear forwards; } .ui-double-tap-ring { display: none; } .ui-double-tap-like svg { transition: none; }}@keyframes ui-double-tap-still { 0%, 70% { opacity: 1; } 100% { opacity: 0; } }`let double_tap_like_heart = "M12 21s-7.5-4.6-9.6-9.2C.9 8.3 3 5 6.4 5c2 0 3.6 1.1 4.6 2.6l1 1.4 1-1.4C14 6.1 15.6 5 17.6 5 21 5 23.1 8.3 21.6 11.8 19.5 16.4 12 21 12 21z"// Whether a tap is the second of two: soon enough after the first and near it. Each is { t, x, y }.fn double_tap_like_second(first, tap, gap: num) -> bool { if first == null { return false } tap.t - first.t <= gap && abs(tap.x - first.x) + abs(tap.y - first.y) <= 40}// A count as it reads: 987, 1.2k, 34k, 1.5m.fn double_tap_like_short(n: int) -> str { if n < 1000 { return str(n) } if n < 10000 { return `${round(floor(n / 100) / 10, 1)}k` } if n < 1000000 { return `${floor(n / 1000)}k` } `${round(floor(n / 100000) / 10, 1)}m`}// A prop's count as a whole number of likes: none for anything else.fn double_tap_like_count(n) -> int { if type_of(n) == "int" { return max([0, n]) } if type_of(n) == "float" && n > 0 && n < 1000000000000 { return int(n) } 0}// A tap felt, in the app; a phone's browser may have vibrate, and the page has been tapped by now.fn double_tap_like_buzz() { if is_err(js("quilloNative.haptic", "light")) && js("navigator.userActivation.hasBeenActive") == true { js("navigator.vibrate", 10) }}component DoubleTapLike(src: str? = null, alt: str = "", color: str? = null, title: str? = null, text: str? = null, liked: bool = false, count = 0, ratio: str = "4 / 3", gap: int = 280, on_like = null, on_tap = null, class: str? = null, children) { style global double_tap_like_css state on = liked state n = double_tap_like_count(count) state media = null state press = null // the finger on the picture: { id, x, y, t } state first = null // a tap waiting to see if a second comes state waiting = 0 // counts up, to start the wait again for each first tap state bursts = [] // hearts in the air: { id, x, y } state made = 0 let set_like = (to: bool) => { if to != on { on = to n = max([0, n + (to ? 1 : -1)]) on_like && on_like(on, n) } } let single = () => { first = null waiting = 0 on_tap && on_tap() } let tapped = (tap) => { if double_tap_like_second(first, tap, gap) { first = null waiting = 0 made += 1 // Six at most, for someone tapping as fast as they can. bursts = slice(bursts, -5, len(bursts)) + [{ id: made, x: tap.x, y: tap.y }] set_like(true) double_tap_like_buzz() } else { // A tap too far from the last one: that one was a tap of its own. if first != null { on_tap && on_tap() } first = tap waiting += 1 } } if waiting > 0 { every(gap, single) } <div class={ui_cls(["ui-double-tap", class])}> <div.ui-double-tap-inner> <div.ui-double-tap-media style={`aspect-ratio: ${ratio}${color != null ? `; --c: ${color}` : ""}`} role={on_tap != null ? "button" : "group"} tabindex={on_tap != null ? "0" : null} aria-label={alt != "" ? alt : title} @mount={(me) => { media = me }} @keydown={(e) => { if on_tap != null && (e.key == "Enter" || e.key == " ") { e.preventDefault() on_tap() } }} @pointerdown={(e) => { if e.isPrimary && e.button == 0 { let r = media.getBoundingClientRect() press = { id: e.pointerId, x: e.clientX - r.left, y: e.clientY - r.top, cx: e.clientX, cy: e.clientY, t: e.timeStamp } } }} @pointermove={(e) => { if press != null && e.pointerId == press.id && abs(e.clientX - press.cx) + abs(e.clientY - press.cy) > 12 { press = null } }} @pointerup={(e) => { if press != null && e.pointerId == press.id { let p = press press = null if e.timeStamp - p.t < 450 { tapped({ t: e.timeStamp, x: p.x, y: p.y }) } } }} @pointercancel={() => { press = null }}> {src != null && <img src={src} alt={alt} draggable="false"/>} {for b in bursts => <span.ui-double-tap-burst key={b.id} style={`left: ${b.x}px; top: ${b.y}px`} aria-hidden="true" @animationend={(e) => { if e.target.tagName == "svg" { bursts = bursts | filter((x) => x.id != b.id) } }}> <span.ui-double-tap-ring></span> <svg viewBox="0 0 24 24" fill="currentColor"><path d={double_tap_like_heart}/></svg> </span>} </div> <div.ui-double-tap-side> <div.ui-double-tap-row> <button.ui-double-tap-like type="button" aria-pressed={on ? "true" : "false"} aria-label={on ? `Liked, ${n} likes` : `Like, ${n} likes`} @click={() => { set_like(!on) }}> <svg viewBox="0 0 24 24" aria-hidden="true"><path d={double_tap_like_heart}/></svg> <span.ui-double-tap-n>{double_tap_like_short(n)}</span> </button> </div> {title != null && <p.ui-double-tap-title>{title}</p>} {text != null && <p.ui-double-tap-text>{text}</p>} {children} </div> </div> </div>}Related
StoryViewerStories with segmented progress: hold to pause, tap to go on or back, swipe down to leave, with the next ones fetched ahead.3 views · 7 downloadsActionSheetA 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 · 6 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 downloads
Thread
Thread 0 posts
On DoubleTapLike: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.