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.
Demo
Sort by
Newest first, with the unread at the top.
Quick reply
Opened 0, closed 0
All the steps
Step 1 of the route
Step 2 of the route
Step 3 of the route
Step 4 of the route
Step 5 of the route
Step 6 of the route
Step 7 of the route
Step 8 of the route
Step 9 of the route
Step 10 of the route
Step 11 of the route
Step 12 of the route
Step 13 of the route
Step 14 of the route
Step 15 of the route
Step 16 of the route
Step 17 of the route
Step 18 of the route
Step 19 of the route
Step 20 of the route
Step 21 of the route
Step 22 of the route
Step 23 of the route
Step 24 of the route
Step 25 of the route
Step 26 of the route
Step 27 of the route
Step 28 of the route
Step 29 of the route
Step 30 of the route
Step 31 of the route
Step 32 of the route
Step 33 of the route
Step 34 of the route
Step 35 of the route
Step 36 of the route
Step 37 of the route
Step 38 of the route
Step 39 of the route
Step 40 of the route
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.qweimport 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.
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
// 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>}Related
Thread 0 posts
On AnchoredPopover: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.