LongPressMenu
A long press opens a menu at the finger, with a haptic, no text selected and no browser callout.
Demo
Morning walk
6.2 km along the river, shared with two people. Press and hold anywhere here.
Nothing chosen yet.
Press and hold to see.
Bad
Worse
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/long_press_menu.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/long_press_menu.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/long_press_menu.qwe
let long_press_menu_demo_items = [ { id: "copy", label: "Copy link" }, { id: "share", label: "Share" }, { id: "pin", label: "Pin to top", disabled: true }, { id: "delete", label: "Delete", tone: "danger" },]component LongPressMenuDemoLive() { state said = "Nothing chosen yet." state felt = "Press and hold to see." <div.demo-stack> <LongPressMenu label="Morning walk" items={long_press_menu_demo_items} on_select={(id) => { said = `Chose ${id}.` }} on_open={(how) => { felt = how == "none" ? "This device gave no tap: the menu opening is the signal." : `Tap felt through: ${how}.` }}> <div style="padding: 14px 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm)"> <strong id="long-press-demo-text">Morning walk</strong> <div style="color: var(--ui-muted)">6.2 km along the river, shared with two people. Press and hold anywhere here.</div> </div> </LongPressMenu> <p.demo-note id="long-press-demo-said" role="status">{said}</p> <p.demo-note id="long-press-demo-felt">{felt}</p> // Bad data, for the test to look at: nothing here should stop the page. <div id="long-press-bad" hidden="true"><LongPressMenu items={[null, 3, { id: "x" }, { label: "Only" }]}>Bad</LongPressMenu><LongPressMenu items="none">Worse</LongPressMenu></div> </div>}view LongPressMenuDemo() = <LongPressMenuDemoLive/>Source
ui/long_press_menu.qwe
// LongPressMenu: a menu that opens where a finger rests, with a tap felt on the phones// that can give one. The finger can slide straight to an item and lift, or lift first// and tap one. The press selects no text, and neither the browser's menu nor iOS's callout// comes up. A More button opens the same menu for keyboards and screen readers; the Menu// key and Shift+F10 work on the focused area, and a right click opens it at the pointer.// On a tablet it opens beside the finger, where the hand does not cover it; on a phone,// above it, and below when there is no room.//// component Photo(p) {// <LongPressMenu label={p.title} items={[{ id: "share", label: "Share" }, { id: "del", label: "Delete", tone: "danger" }]}// on_select={(id) => { act(p, id) }}>// <img src={p.url} alt={p.title}/>// </LongPressMenu>// }//// items: each { id, label, tone?, disabled? }: tone "danger" marks it, and one without a// label is left out. on_select(id): inside one of your components, which was chosen.// on_open(felt): the menu opened; felt says what the hand got: "native" (the app's// haptics), "vibrate" (the web's), or "none", as in iOS's browser, which has neither.// delay: the press's length in milliseconds (500). label: what the area is called,// for its More button. class. children: what is pressed.import ui/theme.qwe use ui_clslet long_press_menu_css = css`.ui-long-press { display: flex; align-items: center; gap: 4px; color: var(--ui-fg); font: 15px/1.45 var(--ui-font); }/* The callout and the browser's drag are off for good here; selection only during a press. */.ui-long-press-area { flex: 1; min-width: 0; border-radius: var(--ui-radius-sm); outline: none; touch-action: pan-y; -webkit-touch-callout: none; transition: transform .18s, opacity .18s; }.ui-long-press-area img { -webkit-user-drag: none; }.ui-long-press-area:focus-visible { box-shadow: var(--ui-focus); }.ui-long-press-pressing { transform: scale(.98); opacity: .85; user-select: none; -webkit-user-select: none; }.ui-long-press-more { display: grid; flex: none; place-items: center; width: 44px; height: 44px; padding: 0; border: 0; border-radius: var(--ui-radius-sm); background: transparent; color: var(--ui-muted); cursor: pointer; }.ui-long-press-more svg { width: 20px; height: 20px; }.ui-long-press-more:focus-visible { outline: none; box-shadow: var(--ui-focus); }/* In the top layer, so a frame's transform or overflow does not hold it. */.ui-long-press-menu { position: fixed; inset: auto; box-sizing: border-box; width: 240px; max-width: calc(100vw - 24px); max-height: calc(100dvh - 24px); margin: 0; padding: 6px; overflow: auto; border: 1px solid var(--ui-border); border-radius: calc(var(--ui-radius) + 2px); background: var(--ui-surface); color: var(--ui-fg); box-shadow: var(--ui-shadow-lg); }.ui-long-press-menu[open] { animation: ui-long-press-pop .16s cubic-bezier(.2, .9, .3, 1.2); }@keyframes ui-long-press-pop { from { opacity: 0; transform: scale(.9); } }.ui-long-press-menu::backdrop { background: var(--ui-overlay); }.ui-long-press-item { display: flex; align-items: center; width: 100%; min-height: 48px; padding: 0 12px; border: 0; border-radius: var(--ui-radius-sm); background: transparent; color: inherit; font: inherit; text-align: start; cursor: pointer; }.ui-long-press-item:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-long-press-hot { background: var(--ui-subtle); }.ui-long-press-danger { color: var(--ui-danger); }.ui-long-press-item[disabled] { color: var(--ui-muted); opacity: .6; cursor: default; }@media (hover: hover) { .ui-long-press-more:hover { background: var(--ui-subtle); color: var(--ui-fg); } .ui-long-press-item:not([disabled]):hover { background: var(--ui-subtle); }}@media (min-width: 700px) { .ui-long-press-menu::backdrop { background: transparent; } }@media (prefers-reduced-motion: reduce) { .ui-long-press-area { transition: none; } .ui-long-press-menu[open] { animation: none; } }`// Items with a label, each with an id, the label standing in.fn long_press_menu_clean(items) { if type_of(items) != "list" { return [] } items | filter((i) => type_of(i) == "map" && get(i, "label") != null) | map((i) => { ...i, id: get(i, "id") ?? i.label })}fn long_press_menu_clamp(v: num, size: num, room: num, edge: num) -> num { max([edge, min([v, room - size - edge])])}// Where the menu goes, as [left, top], for a finger at x, y, a menu of w by h and a screen of// vw by vh. A wide screen puts it beside the finger, on the right unless it does not fit; a// narrow one above the finger, or below it with no room above. 12 px from every edge.fn long_press_menu_place(x: num, y: num, w: num, h: num, vw: num, vh: num, wide: bool) -> list { let edge = 12 let gap = 16 if wide { let right = x + gap let left = right + w > vw - edge ? x - gap - w : right return [long_press_menu_clamp(left, w, vw, edge), long_press_menu_clamp(y - h / 2, h, vh, edge)] } let above = y - gap - h let top = above < edge ? y + gap : above [long_press_menu_clamp(x - w / 2, w, vw, edge), long_press_menu_clamp(top, h, vh, edge)]}// A tap felt, and which kind. The web's vibrate is refused, with a warning, until the page// has been tapped once, which a first long press is not.fn long_press_menu_buzz(ms: int) -> str { if !is_err(js("quilloNative.haptic", "medium")) { return "native" } if js("navigator.userActivation.hasBeenActive") != true { return "none" } js("navigator.vibrate", ms) == true ? "vibrate" : "none"}// Which of `all`, the items not disabled, a key takes the focus to from `target`: the arrows// go round, Home and End to the ends, and any other key is -1. Out here, not in the handler,// where the `??` would wait a turn before the focus moves.fn long_press_menu_step(all, target, key: str) -> int { let n = all.length if n == 0 { return -1 } let at = index_of(for i in range(0, n) => all[i].isSameNode(target), true) ?? -1 match key { "ArrowDown" => (at + 1) % n "ArrowUp" => (at - 1 + n) % n "Home" => 0 "End" => n - 1 _ => -1 }}component LongPressMenu(items = [], on_select = null, on_open = null, delay: int = 500, label: str = "Item", class: str? = null, children) { style global long_press_menu_css let list = long_press_menu_clean(items) state dlg = null state press = null // { id, x, y }: a finger down, its time not yet up state slide = null // { id, x, y }: the finger that opened the menu, still down state hot = null // the item under that finger state lifted = -1000 // when that finger lifted let open_at = (x: num, y: num, felt: str) => { if !dlg.open { dlg.showModal() let vw = js("document.documentElement.clientWidth") // What the on-screen keyboard leaves, on iOS, where it does not shrink the page. let seen = js("visualViewport.height") let vh = is_err(seen) || seen == null ? js("window.innerHeight") : seen let to = long_press_menu_place(x, y, dlg.offsetWidth, dlg.offsetHeight, vw, vh, vw >= 700) dlg.style.left = `${to[0]}px` dlg.style.top = `${to[1]}px` on_open && on_open(felt) } } let fire = () => { if press != null { let p = press press = null slide = p open_at(p.x, p.y, long_press_menu_buzz(14)) } } let shut = () => { slide = null hot = null if dlg.open { dlg.close() } } let pick = (it) => { if get(it, "disabled") != true { shut() on_select && on_select(it.id) } } // The item at a point: the menu is in the top layer, so the finger's own element is no help. let item_at = (x: num, y: num) => { let at = js("document.elementFromPoint", x, y) let el = at != null ? at.closest(".ui-long-press-item") : null el != null ? list[int(el.dataset.at)] : null } if press != null { every(delay, fire) } <div class={ui_cls(["ui-long-press", class])}> <div class={ui_cls(["ui-long-press-area", (press != null || slide != null) && "ui-long-press-pressing"])} tabindex="0" role="group" aria-label={label} @mount={(me) => { // Once the menu is open the finger is ours for sliding to an item, which pan-y alone // would give to the page on the first move. let guard = (e) => { if slide != null && e.cancelable { e.preventDefault() } } // Chrome can follow a long press with a click, aimed at whatever is at that spot by // then: the backdrop, an item, or the page once the menu has gone. It is not a choice. let ghost = (e) => { if e.timeStamp - lifted < 80 { e.preventDefault() e.stopPropagation() } } let d = js("document") me.addEventListener("touchmove", guard, { passive: false }) d.addEventListener("click", ghost, true) () => { me.removeEventListener("touchmove", guard) d.removeEventListener("click", ghost, true) } }} @contextmenu={(e) => { // Android's long press, and a right click. With a finger the timer may have got there first. e.preventDefault() if e.pointerType == "touch" || press != null { fire() } else { open_at(e.clientX, e.clientY, "none") } }} @selectstart={(e) => { if press != null || slide != null { e.preventDefault() } }} @keydown={(e) => { if e.key == "ContextMenu" || (e.key == "F10" && e.shiftKey) { e.preventDefault() let r = e.target.getBoundingClientRect() open_at(r.left + r.width / 2, r.top + r.height / 2, "none") } }} @pointerdown={(e) => { if e.pointerType != "mouse" && e.isPrimary { press = { id: e.pointerId, x: e.clientX, y: e.clientY } } }} @pointermove={(e) => { if press != null && e.pointerId == press.id && abs(e.clientX - press.x) + abs(e.clientY - press.y) > 10 { press = null } if slide != null && e.pointerId == slide.id { let it = item_at(e.clientX, e.clientY) hot = it != null && get(it, "disabled") != true ? it.id : null } }} @pointerup={(e) => { press = null if slide != null && e.pointerId == slide.id { let chose = hot != null ? find(list, (i) => i.id == hot) : null lifted = e.timeStamp slide = null hot = null if chose != null { pick(chose) } } }} @pointercancel={() => { press = null slide = null hot = null }}>{children}</div> <button.ui-long-press-more type="button" aria-label={`More for ${label}`} aria-haspopup="menu" @click={(e) => { let r = e.target.closest("button").getBoundingClientRect() open_at(r.left + r.width / 2, r.top, "none") }}> <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-long-press-menu aria-label={label} @mount={(me) => { dlg = me }} @close={() => { slide = null hot = null }} @click={(e) => { // The backdrop is the dialog too, as is its own edge: only the point tells them apart. let r = dlg.getBoundingClientRect() let inside = e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom if e.target.isSameNode(dlg) && !inside { shut() } }} @keydown={(e) => { let all = dlg.querySelectorAll(".ui-long-press-item:not([disabled])") let to = long_press_menu_step(all, e.target, e.key) if to >= 0 { e.preventDefault() all[to].focus() } }}> <div role="menu" aria-label={label}> {for [i, it] in enumerate(list) => <button key={it.id} type="button" role="menuitem" data-at={i} disabled={get(it, "disabled") == true} class={ui_cls(["ui-long-press-item", get(it, "tone") == "danger" && "ui-long-press-danger", hot == it.id && "ui-long-press-hot"])} @click={() => pick(it)}>{it.label}</button>} </div> </dialog> </div>}Related
ContextPreviewA 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.3 views · 6 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 LongPressMenu: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.