Mobile

AnchoredPopover

A popover with an arrow tied to its button on a tablet and a sheet on a phone, re-anchored on rotation and when a keyboard opens.

#mobile#tablet#interactive#popover#sheet#overlay

Demo

Preview

Opened 0, closed 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/anchored_popover.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/anchored_popover.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/anchored_popover.qwe
let anchored_popover_demo_css = css`.demo-anchored { display: grid; grid-template-rows: auto 1fr auto; gap: 12px; height: 100%; box-sizing: border-box; padding: 14px; font: 14px/1.5 var(--ui-font); }.demo-anchored-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }.demo-anchored-mid { display: grid; place-items: center; color: var(--ui-muted); text-align: center; }.demo-anchored-long p { margin: 0 0 12px; }.demo-anchored-field { display: grid; gap: 6px; }.demo-anchored-field input { box-sizing: border-box; width: 100%; height: 46px; padding: 0 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 16px/1.2 var(--ui-font); }`component AnchoredPopoverDemoLive() {    style global anchored_popover_demo_css    state opened = 0    state closed = 0    state controlled = false    state wide = false    <div.demo-tablet>        <div.demo-anchored>            <div.demo-anchored-row>                <AnchoredPopover id="anchored-demo-sort" trigger="Sort" title="Sort by"                                 on_open={() => { opened += 1 }} on_close={() => { closed += 1 }}>                    <p>Newest first, with the unread at the top.</p>                </AnchoredPopover>                <AnchoredPopover id="anchored-demo-reply" trigger="Reply" title="Quick reply" width={wide ? 360 : 320}>                    <div.demo-anchored-field>                        <label for="anchored-demo-text">Your reply</label>                        <input id="anchored-demo-text" type="text"/>                        <button.ui-anchored-trigger id="anchored-demo-wider" type="button" @click={() => { wide = !wide }}>Change the width</button>                    </div>                </AnchoredPopover>            </div>            <div.demo-anchored-mid>                <div>                    <p id="anchored-demo-count">Opened {opened}, closed {closed}</p>                    <button.ui-anchored-trigger id="anchored-demo-other" type="button"                            @click={() => { controlled = !controlled }}>{controlled ? "Showing the note" : "Show a note"}</button>                </div>            </div>            <div.demo-anchored-row>                <AnchoredPopover id="anchored-demo-info" trigger="Details" side="top" width={360}>                    <div.demo-anchored-long>                        <p>Delivered on Tuesday to the side door, signed for by the caretaker.</p>                        <p>Three boxes, one marked fragile. Nothing was left at the front.</p>                    </div>                </AnchoredPopover>                <AnchoredPopover id="anchored-demo-side" trigger="Long" title="All the steps" side="right" width={300}>                    <div.demo-anchored-long>                        {for n in range(1, 41) => <p>Step {n} of the route</p>}                    </div>                </AnchoredPopover>            </div>            <AnchoredPopover id="anchored-demo-tied" trigger={null} anchor="anchored-demo-other" open={controlled} label="A note"                             on_close={() => { controlled = false }}>                <p>Tied to a button of yours, by its id, and opened from your own state.</p>            </AnchoredPopover>        </div>    </div>}view AnchoredPopoverDemo() = <AnchoredPopoverDemoLive/>

Source

ui/anchored_popover.qwe
// AnchoredPopover: a panel with an arrow tied to the button that opened it, as an iPad// app shows one, and a sheet from the bottom on a phone, where a popover would have no// room. The arrow points at the button's middle wherever the panel has to sit. It stays// tied through a turn, a scroll and the on-screen keyboard: the panel moves, turns to// the other side, or shortens to stay in what can be seen. It is the browser's own// `popover`, so Escape, a tap elsewhere and the back of focus are the browser's.////   import ui/anchored_popover.qwe use AnchoredPopover////   <AnchoredPopover id="sort" trigger="Sort" title="Sort by">//       <p>Newest first</p>//   </AnchoredPopover>//// id (required): unique on the page.// trigger: the button's text. Left null, there is no button: give `anchor`, and open// and close it with `open` from your own state.// title: a heading, with a Close button; also its name for screen readers.// label: its name for screen readers when it has no title (else the trigger's text).// side: where it would like to be, "bottom" (the default), "top", "left" or "right".// It takes the far side, then either other, when that one has no room.// width: its width in px on a large screen (320). On a screen under 600 px, it is a// sheet: the grip and the heading pull it down, the Close button or a tap above it// close it, and it rises above the keyboard.// anchor: the id of another element to tie it to, in place of the button.// open: with no trigger, inside one of your components, whether it shows.// on_open(), on_close(): inside one of your components, when it opens and closes.// children: what is in it. Give a field `autofocus` to start there; else the panel// takes the focus, so a phone's keyboard does not jump up unasked.import ui/theme.qwe use ui_clslet anchored_popover_css = css`.ui-anchored-host { display: contents; }.ui-anchored-trigger {  display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; min-width: 46px; min-height: 46px; padding: 0 16px;  border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg);  font: 500 15px/1.2 var(--ui-font); cursor: pointer; -webkit-tap-highlight-color: transparent;}.ui-anchored-trigger[aria-expanded="true"] { border-color: var(--ui-accent); color: var(--ui-accent); }.ui-anchored-trigger:focus-visible { outline: none; box-shadow: var(--ui-focus); }@media (hover: hover) { .ui-anchored-trigger:hover { background: var(--ui-subtle); } }.ui-anchored {  box-sizing: border-box; width: min(var(--ui-anchored-w, 320px), calc(100vw - 24px)); max-height: calc(100dvh - 24px); padding: 0; overflow: visible;  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); font: 15px/1.5 var(--ui-font); outline: none;}.ui-anchored:popover-open { display: flex; flex-direction: column; animation: ui-anchored-in .16s ease-out; }.ui-anchored[data-side] { right: auto; bottom: auto; margin: 0; }@keyframes ui-anchored-in { from { opacity: 0; } }.ui-anchored-arrow {  position: absolute; width: 14px; height: 14px; box-sizing: border-box; background: var(--ui-surface); border: 1px solid var(--ui-border);  border-right: 0; border-bottom: 0; pointer-events: none; display: none;}.ui-anchored[data-tied="true"] > .ui-anchored-arrow { display: block; }.ui-anchored[data-side="bottom"] > .ui-anchored-arrow { top: -8px; left: calc(var(--ui-anchored-arrow) - 7px); transform: rotate(45deg); }.ui-anchored[data-side="top"] > .ui-anchored-arrow { bottom: -8px; left: calc(var(--ui-anchored-arrow) - 7px); transform: rotate(225deg); }.ui-anchored[data-side="right"] > .ui-anchored-arrow { left: -8px; top: calc(var(--ui-anchored-arrow) - 7px); transform: rotate(-45deg); }.ui-anchored[data-side="left"] > .ui-anchored-arrow { right: -8px; top: calc(var(--ui-anchored-arrow) - 7px); transform: rotate(135deg); }.ui-anchored-grip { display: none; flex: none; place-items: center; height: 26px; touch-action: none; cursor: grab; }.ui-anchored-grip span { width: 40px; height: 5px; border-radius: 999px; background: var(--ui-strong); }.ui-anchored-head { display: flex; align-items: center; gap: 8px; flex: none; min-height: 48px; padding: 2px 4px 2px 16px; border-bottom: 1px solid var(--ui-border); }.ui-anchored-head h2 { flex: 1; min-width: 0; margin: 0; font-size: 16px; font-weight: 600; line-height: 1.3; }.ui-anchored-bare .ui-anchored-head { display: none; }.ui-anchored-close {  display: grid; place-items: center; flex: none; width: 44px; height: 44px; margin-left: auto; padding: 0; border: 0; border-radius: var(--ui-radius-sm);  background: transparent; color: var(--ui-muted); cursor: pointer;}.ui-anchored-close:focus-visible { outline: none; box-shadow: var(--ui-focus); }@media (hover: hover) { .ui-anchored-close:hover { background: var(--ui-subtle); color: var(--ui-fg); } }.ui-anchored-close svg { width: 18px; height: 18px; }.ui-anchored-body { flex: 1; min-height: 0; overflow: auto; padding: 12px 16px 16px; overscroll-behavior: contain; }.ui-anchored-body > :first-child { margin-top: 0; }.ui-anchored-body > :last-child { margin-bottom: 0; }/* The media query and not a class, so a phone gets a sheet even before any script. */@media (max-width: 599.98px) {  .ui-anchored {    left: 0; right: 0; top: auto; bottom: var(--ui-anchored-kb, 0px); width: 100%; max-width: none; margin: 0;    max-height: calc(var(--ui-anchored-room, 100dvh) - 16px); padding-bottom: env(safe-area-inset-bottom);    border-width: 1px 0 0; border-radius: calc(var(--ui-radius) + 8px) calc(var(--ui-radius) + 8px) 0 0;    transition: transform .22s cubic-bezier(.2, .8, .2, 1);  }  .ui-anchored[data-side] { right: 0; bottom: var(--ui-anchored-kb, 0px); }  .ui-anchored:popover-open { animation: ui-anchored-up .24s cubic-bezier(.2, .8, .2, 1); }  .ui-anchored::backdrop { background: var(--ui-overlay); }  .ui-anchored-dragging { transition: none; }  .ui-anchored > .ui-anchored-arrow { display: none !important; }  .ui-anchored-grip { display: grid; }  .ui-anchored-bare .ui-anchored-head { display: flex; }  .ui-anchored-head { touch-action: none; user-select: none; -webkit-user-select: none; }}@keyframes ui-anchored-up { from { transform: translateY(100%); } }@media (prefers-reduced-motion: reduce) { .ui-anchored { transition: none; } .ui-anchored:popover-open { animation: none; } }`let anchored_popover_gap = 12    // from the button to the panel: the arrow fills itlet anchored_popover_pad = 8     // from the panel to what can be seenfn anchored_popover_clamp(v: num, lo: num, hi: num) -> num = hi < lo ? lo : v < lo ? lo : v > hi ? hi : vfn anchored_popover_room(a, v, gap: num, pad: num) = {    top: a.y - v.y - gap - pad,    bottom: v.y + v.h - a.y - a.h - gap - pad,    left: a.x - v.x - gap - pad,    right: v.x + v.w - a.x - a.w - gap - pad,}// Where the panel goes. a: the button, { x, y, w, h }. p: the panel, { w, h }. v: what// can be seen, { x, y, w, h }, in the same coordinates. side: the side wanted. Gives// { side, x, y, arrow, tall, tied }: `arrow` runs along the panel's edge, and never so// near a corner that it leaves it; `tall` is the most the panel may be; `tied` is false// when the button is out of sight, as a keyboard can put it, and an arrow would lie.fn anchored_popover_place(a, p, v, side: str, gap: num, pad: num) {    let room = anchored_popover_room(a, v, gap, pad)    let flip = { top: "bottom", bottom: "top", left: "right", right: "left" }    let want = contains(["top", "bottom", "left", "right"], side) ? side : "bottom"    let tall_way = want == "top" || want == "bottom"    let order = [want, flip[want]] + (tall_way ? ["right", "left"] : ["bottom", "top"])    let fits = order | filter((s) => (s == "top" || s == "bottom") ? room[s] >= p.h : room[s] >= p.w)    // Nowhere fits: the side with the most room of the two that face the button's long    // way, and the panel scrolls.    let best = len(fits) > 0 ? fits[0] : (room.bottom >= room.top ? "bottom" : "top")    let down = best == "top" || best == "bottom"    let h = down ? min([p.h, max([room[best], 80])]) : min([p.h, v.h - pad * 2])    let mid_x = a.x + a.w / 2    let mid_y = a.y + a.h / 2    // Whatever the side, it stays in what can be seen: a keyboard can leave the button below it all.    let x = anchored_popover_clamp(down ? mid_x - p.w / 2 : (best == "right" ? a.x + a.w + gap : a.x - gap - p.w),                                   v.x + pad, v.x + v.w - p.w - pad)    let y = anchored_popover_clamp(down ? (best == "bottom" ? a.y + a.h + gap : a.y - gap - h) : mid_y - h / 2,                                   v.y + pad, v.y + v.h - h - pad)    let arrow = down ? anchored_popover_clamp(mid_x - x, 18, p.w - 18) : anchored_popover_clamp(mid_y - y, 18, h - 18)    let seen = mid_x >= v.x && mid_x <= v.x + v.w && mid_y >= v.y && mid_y <= v.y + v.h    { side: best, x: x, y: y, arrow: arrow, tall: h, tied: seen }}// A pull lets go: past a third of the sheet, or a quick pull already under way.fn anchored_popover_lets_go(pulled: num, speed: num, height: num) -> bool =    pulled > height * 0.35 || (speed > 0.5 && pulled > 24)component AnchoredPopover(id: str, trigger: str? = "Open", title: str? = null, label: str? = null, side: str = "bottom",                          width: int = 320, anchor: str? = null, open: bool = false, on_open = null, on_close = null,                          class: str? = null, children) {    style global anchored_popover_css    state panel = null    state button = null    state win = null    state shown = false    state pull = null          // { y, last_y, last_at, speed, on } while a finger is on the grip    if open && trigger == null && panel != null && !panel.matches(":popover-open") { panel.showPopover() }    if !open && trigger == null && panel != null && shown { panel.hidePopover() }    let name = title ?? label ?? trigger ?? "Details"    let target = () => (anchor != null ? win.document.getElementById(anchor) : button)    let place = (_e = null) => {        if panel != null && win != null && panel.matches(":popover-open") {            let vv = win.visualViewport            let v = vv != null ? { x: vv.offsetLeft, y: vv.offsetTop, w: vv.width, h: vv.height }                               : { x: 0, y: 0, w: win.innerWidth, h: win.innerHeight }            if win.innerWidth < 600 {                panel.dataset.side = "sheet"                panel.dataset.tied = "false"                panel.style.left = ""                panel.style.top = ""                panel.style.maxHeight = ""                panel.style.setProperty("--ui-anchored-kb", `${max([0, round(win.innerHeight - v.h - v.y)])}px`)                panel.style.setProperty("--ui-anchored-room", `${round(v.h)}px`)            } else {                let to = target()                if to == null || to.getClientRects().length == 0 {                    panel.hidePopover()                } else {                    panel.style.maxHeight = ""                    panel.style.setProperty("--ui-anchored-kb", "0px")                    let r = to.getBoundingClientRect()                    let spot = anchored_popover_place({ x: r.left, y: r.top, w: r.width, h: r.height },                                                      { w: panel.offsetWidth, h: panel.offsetHeight }, v, side,                                                      anchored_popover_gap, anchored_popover_pad)                    panel.dataset.side = spot.side                    panel.dataset.tied = spot.tied ? "true" : "false"                    panel.style.left = `${round(spot.x)}px`                    panel.style.top = `${round(spot.y)}px`                    panel.style.maxHeight = `${round(spot.tall)}px`                    panel.style.setProperty("--ui-anchored-arrow", `${round(spot.arrow)}px`)                }            }        }    }    let down = (e) => {        if win != null && win.innerWidth < 600 && e.button == 0 && e.target.closest("button") == null {            e.target.setPointerCapture(e.pointerId)            pull = { y: e.clientY, last_y: e.clientY, last_at: e.timeStamp, speed: 0, on: false }        }    }    let move = (e) => {        if pull != null {            let dy = e.clientY - pull.y            let on = pull.on || abs(dy) > 6            if on {                panel.classList.add("ui-anchored-dragging")                panel.style.transform = `translateY(${max([0, dy])}px)`            }            let dt = e.timeStamp - pull.last_at            let step = dt > 0 ? (e.clientY - pull.last_y) / dt : pull.speed            pull = { y: pull.y, last_y: e.clientY, last_at: e.timeStamp, speed: pull.speed == 0 ? step : pull.speed * 0.4 + step * 0.6, on: on }        }    }    let spring = () => {        pull = null        panel.classList.remove("ui-anchored-dragging")        panel.style.transform = ""    }    let up = (e) => {        if pull != null {            let dy = e.clientY - pull.y            // A finger held still before lifting has stopped, whatever it did before.            let run = e.timeStamp - pull.last_at < 100 ? pull.speed : 0            if pull.on && anchored_popover_lets_go(dy, run, panel.offsetHeight) {                pull = null                panel.classList.remove("ui-anchored-dragging")                let fall = panel.animate([{ transform: `translateY(${max([0, dy])}px)` }, { transform: "translateY(100%)" }],                                         { duration: win.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 160, easing: "ease-in" })                fall.addEventListener("finish", (_e) => {                    panel.style.transform = ""                    panel.hidePopover()                })                panel.style.transform = "translateY(100%)"            } else {                spring()            }        }    }    // The width is on the host, not the panel: the panel is placed and pulled through its own    // style, and a style={...} there would be written whole when what it reads changes.    <div.ui-anchored-host style={`--ui-anchored-w: ${width}px`}         @mount={(el) => {             let w = js("window")             let vv = w.visualViewport             let self = el.querySelector(".ui-anchored")             let tied = (_e = null) => { place() }             let grown = js_new("ResizeObserver", tied)             grown.observe(self)             grown.observe(w.document.body)             w.addEventListener("resize", tied, { passive: true })             w.addEventListener("orientationchange", tied, { passive: true })             w.addEventListener("scroll", tied, { capture: true, passive: true })             if vv != null {                 vv.addEventListener("resize", tied, { passive: true })                 vv.addEventListener("scroll", tied, { passive: true })             }             win = w             button = el.querySelector(".ui-anchored-trigger")             panel = self             if open && trigger == null { self.showPopover() }             () => {                 grown.disconnect()                 w.removeEventListener("resize", tied)                 w.removeEventListener("orientationchange", tied)                 w.removeEventListener("scroll", tied, { capture: true })                 if vv != null {                     vv.removeEventListener("resize", tied)                     vv.removeEventListener("scroll", tied)                 }             }         }}>        {trigger != null && <button.ui-anchored-trigger type="button" popovertarget={id} aria-haspopup="dialog"                                    aria-expanded={shown ? "true" : "false"} aria-controls={id}>{trigger}</button>}        <div class={ui_cls(["ui-anchored", title == null && "ui-anchored-bare", class])} id={id} popover="auto" role="dialog"             aria-label={name} tabindex="-1"             @toggle={(e) => {                 if e.newState == "open" {                     shown = true                     place()                     let at = win.document.activeElement                     if at == null || !panel.contains(at) { panel.focus({ preventScroll: true }) }                     on_open && on_open()                 } else {                     shown = false                     pull = null                     panel.classList.remove("ui-anchored-dragging")                     panel.style.transform = ""                     let to = target()                     // isSameNode and not ==, which calls any two elements equal.                     let body = win.document.body                     if to != null && body.isSameNode(win.document.activeElement) { to.focus({ preventScroll: true }) }                     on_close && on_close()                 }             }}>            <span.ui-anchored-arrow aria-hidden="true"></span>            <div.ui-anchored-grip aria-hidden="true" @pointerdown={down} @pointermove={move} @pointerup={up} @pointercancel={spring}><span></span></div>            <div.ui-anchored-head @pointerdown={down} @pointermove={move} @pointerup={up} @pointercancel={spring}>                {title != null && <h2>{title}</h2>}                <button.ui-anchored-close type="button" popovertarget={id} popovertargetaction="hide" aria-label="Close">                    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"                         aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg>                </button>            </div>            <div.ui-anchored-body>{children}</div>        </div>    </div>}
Thread
35Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.