Mobile

ContextPreview

A long press lifts a card with the page blurred behind it, showing a preview and actions: slide to one and let go, or let go to close.

#mobile#interactive#tablet#long-press#menu#gesture

Demo

Preview

Trip to Lisbon

12 to 16 May. Press and hold.

Lisbon, 4 nights

Flights on 12 May, back on 16 May. Hotel in Alfama, 3 bookings.

Shared with Ana and Tom.

Trip to Oslo

This one stays open when you let go.

Trip to Oslo

This one stays open when you let go.

Chosen: nothing yet. Closed: 0.

Get it

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

Example

The code of the preview above.

demos/context_preview.qwe
let context_preview_demo_css = css`.context-preview-demo-trip { padding: 16px 60px 16px 16px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); }.context-preview-demo-trip h3 { margin: 0 0 4px; font-size: 16px; }.context-preview-demo-trip p { margin: 0; color: var(--ui-muted); font-size: 14px; }.context-preview-demo-sub { margin: 8px 0 0; color: var(--ui-muted); font-size: 14px; }`component ContextPreviewDemoLive() {    style global context_preview_demo_css    state did = "nothing yet"    state closes = 0    <div.demo-stack>        <ContextPreview label="Trip to Lisbon" title="Lisbon, 4 nights"                        actions={[{ label: "Open", value: "open" }, "Share", { label: "Archive", value: "archive", disabled: true },                                  { label: "Delete trip", value: "delete", destructive: true }]}                        preview={<div>                            <p>Flights on 12 May, back on 16 May. Hotel in Alfama, 3 bookings.</p>                            <p class="context-preview-demo-sub">Shared with Ana and Tom.</p>                        </div>}                        on_pick={(v) => { did = v }} on_close={() => { closes += 1 }}>            <div class="context-preview-demo-trip">                <h3>Trip to Lisbon</h3>                <p>12 to 16 May. Press and hold.</p>            </div>        </ContextPreview>        <ContextPreview label="Trip to Oslo" stay={true} actions={["Open", "Share"]} on_pick={(v) => { did = `${v} Oslo` }}>            <div class="context-preview-demo-trip">                <h3>Trip to Oslo</h3>                <p>This one stays open when you let go.</p>            </div>        </ContextPreview>        <p.demo-note>Chosen: <span class="context-preview-demo-did">{did}</span>. Closed: <span class="context-preview-demo-closes">{closes}</span>.</p>    </div>}view ContextPreviewDemo() = <ContextPreviewDemoLive/>

Source

ui/context_preview.qwe
// ContextPreview: press and hold something and it lifts out of the page, with the page// blurred behind it, showing a preview and what can be done with it. Still holding, slide// to an action and let go to do it; let go anywhere else and it closes. It keeps out of the// way of a scroll: a finger that moves before the hold is up is the page's. On a tablet the// actions stand beside the card. It is the browser's own <dialog>, so focus stays in it.////   import ui/context_preview.qwe use ContextPreview////   <ContextPreview label="Trip to Lisbon" actions={[{ label: "Open", value: "open" }, "Share",//                   { label: "Delete", value: "delete", destructive: true }]}//                   preview={<TripDetails/>} on_pick={(what) => { run(what) }}>//       <TripCard/>//   </ContextPreview>//// children: what is pressed. preview: what the card shows (children again when left out):// HTML, so it needs ContextPreview inside one of your components. actions: a list of maps// or plain text, as for ActionSheet: label, value, destructive, disabled. label: what it is// called, for screen readers ("Item"). title: a heading on the card, if any. hold: how long// a press takes, in milliseconds (450). stay: let go and it stays open, so the actions can be// tapped, until a tap away (false). on_pick(value): called with the action's value.// on_open(), on_close(): when it opens and when it has closed.//// The equal of the gesture is the More button on it, and Enter, Space or the Menu key with// focus on it, or a right click: each opens it to stay, for a tap or the keyboard, and a tap// away, Escape or the phone's back gesture closes it.import ui/action_sheet.qwe use action_sheet_rowsimport ui/bottom_sheet.qwe use bottom_sheet_back, bottom_sheet_hapticlet context_preview_css = css`html:has(.ui-context-preview-dialog[open]) { overflow: hidden; }.ui-context-preview { position: relative; }/* Only here, and not the whole page: a long press on this may not select text or open the system's menu. */.ui-context-preview-target {  display: block; border-radius: var(--ui-radius); outline: none; cursor: default; touch-action: pan-y;  -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; transition: transform .2s cubic-bezier(.22, 1, .36, 1);}.ui-context-preview-target:focus-visible { box-shadow: var(--ui-focus); }.ui-context-preview-pressing { transform: scale(.97); }.ui-context-preview-more {  position: absolute; top: 4px; right: 4px; display: grid; place-items: center; width: 44px; height: 44px; padding: 0; border: 0;  border-radius: 999px; background: color-mix(in srgb, var(--ui-surface) 80%, transparent); color: var(--ui-fg); cursor: pointer;}.ui-context-preview-more:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-context-preview-more svg { width: 20px; height: 20px; }.ui-context-preview-dialog {  position: fixed; inset: 0; box-sizing: border-box; width: 100vw; max-width: none; height: 100dvh; max-height: none; margin: 0; border: 0;  padding: max(16px, env(safe-area-inset-top)) 16px max(16px, env(safe-area-inset-bottom)); overflow: auto; background: transparent; color: var(--ui-fg);  font: 15px/1.45 var(--ui-font); overscroll-behavior: contain; -webkit-touch-callout: none; user-select: none;  opacity: 0; transition: opacity .16s, overlay .16s allow-discrete, display .16s allow-discrete;}.ui-context-preview-dialog[open] { display: block; opacity: 1; }@starting-style { .ui-context-preview-dialog[open] { opacity: 0; } }.ui-context-preview-dialog::backdrop {  background: rgba(15, 14, 24, .25); background: color-mix(in srgb, var(--ui-bg) 45%, transparent);  -webkit-backdrop-filter: blur(16px) saturate(1.2); backdrop-filter: blur(16px) saturate(1.2);}.ui-context-preview-stage { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; min-height: 100%; }.ui-context-preview-card {  box-sizing: border-box; width: 100%; max-width: 380px; max-height: 58dvh; overflow: auto; padding: 16px; border-radius: calc(var(--ui-radius) + 6px);  background: var(--ui-surface); box-shadow: var(--ui-shadow-lg); transition: transform .3s cubic-bezier(.22, 1.2, .36, 1);}.ui-context-preview-card:focus { outline: none; }.ui-context-preview-card h2 { margin: 0 0 8px; font-size: 17px; }.ui-context-preview-actions { width: 100%; max-width: 280px; overflow: hidden; border-radius: calc(var(--ui-radius) + 4px); background: var(--ui-surface); box-shadow: var(--ui-shadow-lg); }.ui-context-preview-row {  display: block; box-sizing: border-box; width: 100%; min-height: 48px; padding: 8px 16px; border: 0; background: transparent; color: var(--ui-fg);  font: 16px/1.3 var(--ui-font); text-align: left; cursor: pointer;}.ui-context-preview-row + .ui-context-preview-row { border-top: 1px solid var(--ui-border); }.ui-context-preview-danger { color: var(--ui-danger); }.ui-context-preview-row:disabled { color: var(--ui-muted); opacity: .6; cursor: not-allowed; }.ui-context-preview-over, .ui-context-preview-row:active:not(:disabled) { background: var(--ui-subtle); }@media (hover: hover) { .ui-context-preview-row:hover:not(:disabled) { background: var(--ui-subtle); } }.ui-context-preview-row:focus-visible { outline: 2px solid var(--ui-accent); outline-offset: -2px; }/* A <dialog> sits over the whole window, so the window's width is the right one to ask, Split View included. */@media (min-width: 600px) {  .ui-context-preview-stage { flex-direction: row; align-items: flex-start; align-content: center; padding-top: 12dvh; gap: 16px; }  .ui-context-preview-card { flex: 0 1 420px; max-width: 420px; max-height: 70dvh; }  .ui-context-preview-actions { flex: 0 0 240px; width: 240px; }}@media (prefers-reduced-motion: reduce) {  .ui-context-preview-target, .ui-context-preview-card, .ui-context-preview-dialog { transition: none; }}`// The move and scale that put the card back where it was pressed, for it to lift from:// centre to centre, and its width to the width pressed, within reason.fn context_preview_from(from, to) {    let wide = to.width > 0 ? from.width / to.width : 1    { x: round(from.left + from.width / 2 - (to.left + to.width / 2), 2),      y: round(from.top + from.height / 2 - (to.top + to.height / 2), 2),      scale: round(max([0.4, min([wide, 1.2])]), 3) }}// Whether a finger has moved far enough, before the hold was up, for it to be something else.fn context_preview_strayed(x0: num, y0: num, x: num, y: num, slop: num = 10) = abs(x - x0) > slop || abs(y - y0) > slopcomponent ContextPreview(actions = [], label: str = "Item", title: str? = null, hold: int = 450, stay: bool = false,                         preview = null, on_pick = null, on_open = null, on_close = null, children) {    style global context_preview_css    state dlg = null    state target = null    state card = null    state win = null    state press = null          // { x, y, id } from a finger down until the hold is up    state pressing = false    state holding = false       // it opened under a finger still down    state over = -1             // the action under that finger    state armed = false         // a finger has come down in the dialog, so a click there is a tap away    state chosen = null         // [value], while it closes    state undo_back = null    let rows = action_sheet_rows(actions)    let shut = () => { dlg.close() }    let lift = () => {        let was = target.getBoundingClientRect()        chosen = null        armed = false        dlg.showModal()        // The dialog gives focus to the first action, which under a finger reads as chosen.        if holding { card.focus({ preventScroll: true }) }        let at = card.getBoundingClientRect()        let from = context_preview_from(was, at)        card.style.transition = "none"        card.style.transform = `translate(${from.x}px, ${from.y}px) scale(${from.scale})`        // Measuring makes the browser take that as where it starts.        card.getBoundingClientRect()        card.style.removeProperty("transition")        card.style.removeProperty("transform")        undo_back = bottom_sheet_back(win, shut)        on_open && on_open()    }    // The hold is up: every is the only clock a component has, and this one ticks once.    if pressing {        every(max([50, hold]), () => {            pressing = false            holding = true            over = -1            // A mouse's moves must stay here too, once a dialog covers this.            if press != null { target.setPointerCapture(press.id) }            press = null            lift()            bottom_sheet_haptic(win, "medium")        })    }    let let_go = () => {        press = null        pressing = false    }    let open_for_tap = () => {        let_go()        holding = false        lift()    }    let pick = (r) => {        chosen = [r.value]        shut()    }    <div.ui-context-preview>        <div class={pressing ? "ui-context-preview-target ui-context-preview-pressing" : "ui-context-preview-target"}             tabindex="0" role="button" aria-haspopup="dialog" aria-label={`${label}: press and hold for a preview`}             @mount={(el) => {                 win = js("window")                 target = el             }}             @pointerdown={(e) => {                 if e.button == 0 {                     press = { x: e.clientX, y: e.clientY, id: e.pointerId }                     pressing = true                 }             }}             @pointermove={(e) => {                 if press != null && context_preview_strayed(press.x, press.y, e.clientX, e.clientY) { let_go() }                 if holding {                     let under = win.document.elementFromPoint(e.clientX, e.clientY)                     let row = under == null ? null : under.closest(".ui-context-preview-row")                     let i = row == null ? -1 : int(row.dataset.index)                     let good = i >= 0 && !rows[i].disabled ? i : -1                     if good != over {                         over = good                         if good >= 0 { bottom_sheet_haptic(win, "selection") }                     }                 }             }}             @pointerup={() => {                 let_go()                 if holding {                     holding = false                     if over >= 0 {                         pick(rows[over])                     } else {                         if !stay { shut() }                     }                     over = -1                 }             }}             @pointercancel={() => {                 let_go()                 if holding {                     holding = false                     over = -1                     shut()                 }             }}             @touchmove={(e) => {                 // Once it has lifted the finger is ours, or the browser would scroll and take it.                 if holding && e.cancelable { e.preventDefault() }             }}             @contextmenu={(e) => {                 // Android's long press lands here, and must not bring up the system's menu;                 // a right click has no press going, and opens it.                 e.preventDefault()                 if !pressing && !holding && !dlg.open { open_for_tap() }             }}             @dragstart={(e) => { e.preventDefault() }}             @keydown={(e) => {                 if e.key == "Enter" || e.key == " " || e.key == "ContextMenu" || (e.key == "F10" && e.shiftKey) {                     e.preventDefault()                     open_for_tap()                 }             }}>{children}</div>        <button.ui-context-preview-more type="button" aria-label={`More for ${label}`} @click={open_for_tap}>            <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>        </button>        <dialog.ui-context-preview-dialog aria-label={title ?? label}                @mount={(el) => {                    dlg = el                    // Taken off the page while open, it must not leave its history entry.                    () => { undo_back && undo_back() }                }}                @pointerdown={() => { armed = true }}                @click={(e) => {                    let t = e.target                    let on_it = t.closest(".ui-context-preview-card, .ui-context-preview-actions") != null                    if armed && !on_it { shut() }                }}                @close={() => {                    undo_back && undo_back()                    undo_back = null                    holding = false                    over = -1                    let was = chosen                    chosen = null                    if was != null && on_pick != null { on_pick(was[0]) }                    on_close && on_close()                }}>            <div.ui-context-preview-stage>                <div.ui-context-preview-card tabindex="-1" @mount={(el) => { card = el }}>                    {title != null && <h2>{title}</h2>}                    {preview != null ? preview : children}                </div>                <div.ui-context-preview-actions role="group" aria-label="Actions">                    {for [i, r] in enumerate(rows) =>                        <button key={r.label} type="button" data-index={str(i)}                                class={context_preview_row_class(r, i == over)}                                disabled={r.disabled} @click={() => pick(r)}>{r.label}</button>}                </div>            </div>        </dialog>    </div>}fn context_preview_row_class(r, is_over: bool) {    let danger = r.destructive ? " ui-context-preview-danger" : ""    let lit = is_over ? " ui-context-preview-over" : ""    `ui-context-preview-row${danger}${lit}`}
Thread
35Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.