Mobile

DoubleTapLike

Double tap a photo card to like it, a heart bursting at the finger, with a plain Like button as the equal.

#mobile#interactive#gesture#like#media#tablet

Demo

Preview

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.qwe
main.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>}
Thread
35Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.