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.
Demo
Trip to Lisbon
12 to 16 May. Press and hold.
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.qwemain.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}`}Related
LongPressMenuA long press opens a menu at the finger, with a haptic, no text selected and no browser callout.4 views · 5 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 downloadsEdgeDrawerA navigation drawer pulled from the screen's edge: follows the finger, snaps by speed, holds the focus, closes on Back; stands beside the content on a tablet.4 views · 7 downloadsPullDownMenuA menu pulled out of a header by a finger, chosen by sliding to an item and letting go, or by its button; on a tablet, a small menu at the end of the header.5 views · 5 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 downloads
Thread
Thread 0 posts
On ContextPreview: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.