Mobile

PeekSheet

A sheet that stays over a map or page and grows with a drag, with what is behind it still in use; on a tablet, a card down the side.

#mobile#interactive#tablet#sheet#map#gesture

Demo

Preview

28 stops nearby

  • Harbour Gate
  • Museum Quarter
  • University
  • Old Town
  • Central Station
  • Riverside
  • Market Square
  • Hospital
  • Stadium
  • North Park
  • Lakeside
  • Airport Road
  • Foundry Lane
  • Tram Depot
  • City Hall
  • Botanic Garden
  • Library
  • Ferry Pier
  • West End
  • Hill Street
  • Clock Tower
  • Bus Station
  • Canal Basin
  • Theatre Row
  • South Gate
  • Old Mill
  • Fair Green
  • Mint Yard

Stop: peek. Pins dropped: 0.

Get it

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

Example

The code of the preview above.

demos/peek_sheet.qwe
let peek_sheet_demo_stops = ["Harbour Gate", "Museum Quarter", "University", "Old Town", "Central Station", "Riverside", "Market Square", "Hospital", "Stadium", "North Park", "Lakeside", "Airport Road", "Foundry Lane", "Tram Depot", "City Hall", "Botanic Garden", "Library", "Ferry Pier", "West End", "Hill Street", "Clock Tower", "Bus Station", "Canal Basin", "Theatre Row", "South Gate", "Old Mill", "Fair Green", "Mint Yard"]let peek_sheet_demo_css = css`.peek-sheet-demo-map { position: absolute; inset: 0; padding: 12px; background: repeating-linear-gradient(0deg, transparent 0 46px, var(--ui-border) 46px 47px), repeating-linear-gradient(90deg, transparent 0 46px, var(--ui-border) 46px 47px), var(--ui-subtle); }.peek-sheet-demo-rows { margin: 0; padding: 0; list-style: none; }.peek-sheet-demo-rows li { padding: 13px 0; border-bottom: 1px solid var(--ui-border); }.peek-sheet-demo-search { box-sizing: border-box; display: block; width: 100%; height: 46px; margin-bottom: 8px; padding: 0 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 16px var(--ui-font); }`component PeekSheetDemoLive() {    style global peek_sheet_demo_css    state stop = "peek"    state pins = 0    state q = ""    state peek_at = 112    <div.demo-stack>        <div.demo-tablet>            <div.peek-sheet-demo-map>                <div.demo-row>                    <Button size="lg" on_click={() => { pins += 1 }}>Drop a pin</Button>                    <Button size="lg" variant="soft" on_click={() => { peek_at = peek_at == 112 ? 160 : 112 }}>Change the peek</Button>                </div>            </div>            <PeekSheet title="28 stops nearby" peek={peek_at} on_detent={(n) => { stop = n }}>                <input.peek-sheet-demo-search type="search" placeholder="Filter stops" aria-label="Filter stops" bind:value={q}/>                <ul.peek-sheet-demo-rows>                    {for s in peek_sheet_demo_stops => <li key={s}>{s}</li>}                </ul>            </PeekSheet>        </div>        <p.demo-note>Stop: <span class="peek-sheet-demo-stop">{stop}</span>. Pins dropped: <span class="peek-sheet-demo-pins">{pins}</span>.</p>    </div>}view PeekSheetDemo() = <PeekSheetDemoLive/>

Source

ui/peek_sheet.qwe
// PeekSheet: a sheet that never leaves, as over the map in a maps app. It rests low with// its heading showing and everything behind it still works: the page can be tapped and// scrolled at any height it stands at. Dragged up it grows, stopping at half and full; it// resists past its ends, goes by speed as well as distance, and shares the finger with the// list inside it. On a tablet, or in any pane 600 px wide or more, it is a card down the// left side, so that what is behind it keeps the room. A field in it opens it to the top.////   import ui/peek_sheet.qwe use PeekSheet////   <div style="position: relative; height: 100dvh">//       <Map/>//       <PeekSheet title="12 places nearby" on_detent={(name) => { stop = name }}>//           <PlaceList/>//       </PeekSheet>//   </div>//// It fills the nearest element around it with a position, which needs a size; fixed={true}// holds it to the screen instead. title: its heading, and its name for screen readers.// detents: which of "peek", "half", "full" it stops at (all three). start: the stop it// starts at ("peek"). peek: how tall "peek" is, in pixels (112). on_detent(name): called// when it comes to rest at another stop. children: what it holds.//// The equal of the gesture is the handle, a button: a tap goes to the next stop, and// ArrowUp and ArrowDown go up and down a stop. Rotation keeps the stop, the scroll and focus.import ui/bottom_sheet.qwe use bottom_sheet_detents, bottom_sheet_heights, bottom_sheet_label, bottom_sheet_snap, bottom_sheet_rubber, bottom_sheet_speed, bottom_sheet_track, bottom_sheet_track_move, bottom_sheet_keyboard_watchlet peek_sheet_css = css`/* The host takes no touches, so all of what is behind stays in reach; only the sheet does. */.ui-peek-sheet-host { position: absolute; inset: 0; z-index: 20; overflow: hidden; pointer-events: none; container-type: size; container-name: ui-peek-sheet; }.ui-peek-sheet-fixed { position: fixed; }.ui-peek-sheet {  position: absolute; inset: 12px 0 var(--ui-peek-sheet-kb, 0px) 0; display: flex; flex-direction: column; box-sizing: border-box; overflow: hidden;  pointer-events: auto; border: 1px solid var(--ui-border); border-bottom: 0; background: var(--ui-surface); color: var(--ui-fg);  border-radius: calc(var(--ui-radius) + 8px) calc(var(--ui-radius) + 8px) 0 0; box-shadow: var(--ui-shadow-lg), 0 160px 0 0 var(--ui-surface);  font: 15px/1.5 var(--ui-font); will-change: transform; transform: translateY(var(--ui-peek-sheet-y, var(--ui-peek-sheet-first)));  transition: transform .32s cubic-bezier(.22, 1, .36, 1);}.ui-peek-sheet-grip {  display: grid; place-items: center; flex: none; width: 100%; height: 44px; padding: 0; border: 0; background: transparent;  cursor: grab; touch-action: none; -webkit-touch-callout: none; user-select: none;}.ui-peek-sheet-grip span { width: 40px; height: 5px; border-radius: 999px; background: var(--ui-strong); }.ui-peek-sheet-grip:focus-visible { outline: none; }.ui-peek-sheet-grip:focus-visible span { background: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-peek-sheet-head { flex: none; margin-top: -8px; padding: 0 20px 10px; touch-action: none; -webkit-touch-callout: none; user-select: none; }.ui-peek-sheet-head h2 { margin: 0; font-size: 17px; font-weight: 600; line-height: 1.3; }.ui-peek-sheet-body {  flex: 1; min-height: 0; overflow: auto; overscroll-behavior: contain;  padding: 4px 20px calc(24px + env(safe-area-inset-bottom) + var(--ui-peek-sheet-hide, 0px));}.ui-peek-sheet-body > :first-child { margin-top: 0; }.ui-peek-sheet-body > :last-child { margin-bottom: 0; }@container ui-peek-sheet (min-width: 600px) {  .ui-peek-sheet { inset: 12px auto calc(12px + var(--ui-peek-sheet-kb, 0px)) 12px; width: 360px; border-bottom: 1px solid var(--ui-border); border-radius: calc(var(--ui-radius) + 8px); box-shadow: var(--ui-shadow-lg); }}@media (prefers-reduced-motion: reduce) { .ui-peek-sheet { transition: none; } }`// How far the sheet stands up before a program in the browser has measured anything, so// the server's page has it in the right place, in CSS.fn peek_sheet_first_y(name: str, peek: int) = name == "full" ? "0px" : name == "half" ? "50%" : `calc(100% - ${peek}px)`// How much of `box_bottom` the keyboard covers, its top being `keyboard` pixels from the// foot of a screen `inner` pixels tall: it is the sheet's own pane that has to rise, and// that need not reach the foot of the screen.fn peek_sheet_cover(box_bottom: num, inner: num, keyboard: num) = keyboard <= 0 ? 0 : max([0, round(box_bottom - (inner - keyboard))])component PeekSheet(title: str, detents = ["peek", "half", "full"], start: str = "peek", peek: int = 112,                    fixed: bool = false, on_detent = null, children) {    style global peek_sheet_css    state host = null    state sheet = null    state body = null    state win = null    let names = bottom_sheet_detents(detents)    let top = len(names) - 1    // index_of gives null when it is not there, not -1.    let first = index_of(names, start) ?? 0    state at = first    state heights = []    state track = null    state zone = null           // what the finger started on: "handle", "body", then "drag" once it moves the sheet    state from = 0    state now_h = 0    state moved = false    state scrolling = false     // the list has this finger; if it gets to the list's top, the sheet takes it    let rest = (h: num) => {        let full = sheet.offsetHeight        now_h = h        sheet.style.setProperty("--ui-peek-sheet-y", `${full - h}px`)        sheet.style.setProperty("--ui-peek-sheet-hide", `${full - h}px`)        sheet.style.removeProperty("transform")        sheet.style.removeProperty("transition")    }    let measure = () => {        heights = bottom_sheet_heights(names, sheet.offsetHeight, peek)        rest(heights[at])    }    // A new peek takes effect at once, unless a finger has the sheet: then, when it lets go.    state seen_peek = peek    if sheet != null && peek != seen_peek && zone != "drag" {        seen_peek = peek        measure()    }    let go = (i: int) => {        let was = at        at = i        rest(heights[i])        if was != i && on_detent != null { on_detent(names[i]) }    }    let decide = (e) => {        if zone == "body" {            let up = track.y < track.y0            let list_owns = up ? at == top : body.scrollTop > 0            if list_owns {                track = null                zone = null                scrolling = !up            } else {                zone = "drag"            }        } else {            if zone == "handle" { zone = "drag" }        }        if zone == "drag" {            moved = true            // iOS may say touchmove first, and a touch has no pointerId.            if e.pointerId != null { sheet.setPointerCapture(e.pointerId) }            sheet.style.transition = "none"        }    }    let follow = () => {        let want = from - (track.y - track.y0)        let ceiling = heights[top]        let lowest = heights[0]        let h = want        if want > ceiling {            if body.scrollHeight > body.clientHeight && from < ceiling {                body.scrollTop = want - ceiling                h = ceiling            } else {                h = ceiling + bottom_sheet_rubber(want - ceiling, 90)            }        }        if want < lowest { h = lowest - bottom_sheet_rubber(lowest - want, 90) }        now_h = h        sheet.style.transform = `translateY(${round(sheet.offsetHeight - h, 2)}px)`    }    let moving = (e) => {        if track != null {            track = bottom_sheet_track_move(track, e.clientX, e.clientY, e.timeStamp)            if track.lock == "x" && zone != "drag" {                track = null                zone = null            }            if track != null && track.lock == "y" {                if zone != "drag" { decide(e) }                if zone == "drag" { follow() }            }        }    }    let ended = (e) => {        if track != null && zone == "drag" {            let speed = bottom_sheet_speed(track, "y", e.timeStamp)            let i = bottom_sheet_snap(heights, now_h, speed, false)            track = null            zone = null            go(i)        } else {            track = null            zone = null        }    }    let cancelled = () => {        if zone == "drag" { rest(heights[at]) }        track = null        zone = null    }    // Where it first stands is on the host, from props alone: the sheet's own style holds where    // it stands now, by hand, and an attribute reading `at` would be rewritten whole by each stop.    <div class={fixed ? "ui-peek-sheet-host ui-peek-sheet-fixed" : "ui-peek-sheet-host"}         style={`--ui-peek-sheet-first: ${peek_sheet_first_y(names[first], peek)}`} @mount={(el) => { host = el }}>        <section.ui-peek-sheet aria-label={title}                 @mount={(el) => {                     win = js("window")                     sheet = el                     let watching = js_new("ResizeObserver", (_x) => { measure() })                     watching.observe(host)                     let undo_kb = bottom_sheet_keyboard_watch(win, (px) => {                         let cover = peek_sheet_cover(host.getBoundingClientRect().bottom, win.innerHeight, px)                         sheet.style.setProperty("--ui-peek-sheet-kb", `${cover}px`)                         measure()                     })                     () => {                         watching.disconnect()                         undo_kb()                     }                 }}                 @pointerdown={(e) => {                     let t = e.target                     let on_handle = t.closest(".ui-peek-sheet-grip, .ui-peek-sheet-head") != null                     let on_body = t.closest(".ui-peek-sheet-body") != null                     moved = false                     // A mouse in the list selects text and its wheel scrolls.                     if e.button == 0 && (on_handle || (on_body && e.pointerType == "touch")) {                         track = bottom_sheet_track(e.clientX, e.clientY, e.timeStamp)                         zone = on_handle ? "handle" : "body"                         from = now_h                     }                 }}                 @pointermove={moving}                 @pointerup={ended}                 @pointercancel={cancelled}                 @touchmove={(e) => {                     // The list got to its top under a finger going down. The browser is                     // scrolling, so no pointer events come, nor can it be stopped.                     if scrolling && body.scrollTop <= 0 {                         let f = e.touches[0]                         scrolling = false                         track = bottom_sheet_track(f.clientX, f.clientY, e.timeStamp)                         zone = "drag"                         from = now_h                         moved = true                         sheet.style.transition = "none"                     }                     if track != null && e.touches.length == 1 {                         if zone != "drag" || !e.cancelable {                             let f = e.touches[0]                             track = bottom_sheet_track_move(track, f.clientX, f.clientY, e.timeStamp)                         }                         if track.lock == "y" && zone == "body" { decide(e) }                         // A pointer event cannot keep the browser from scrolling; this can.                         if zone == "drag" {                             if e.cancelable { e.preventDefault() } else { follow() }                         }                     }                 }}                 @touchend={(e) => {                     scrolling = false                     if track != null && zone == "drag" && !e.cancelable { ended(e) }                 }}                 @focusin={(e) => {                     let t = e.target                     let typing = t.closest("input, textarea, select, [contenteditable]") != null                     if typing && at < top { go(top) }                 }}>            <button.ui-peek-sheet-grip type="button" aria-label={`${bottom_sheet_label(names[at])}: go to the next size`}                    @click={() => {                        if !moved { go(at == top ? 0 : at + 1) }                        moved = false                    }}                    @keydown={(e) => {                        if e.key == "ArrowUp" {                            e.preventDefault()                            if at < top { go(at + 1) }                        }                        if e.key == "ArrowDown" {                            e.preventDefault()                            if at > 0 { go(at - 1) }                        }                    }}><span aria-hidden="true"></span></button>            <div.ui-peek-sheet-head><h2>{title}</h2></div>            <div.ui-peek-sheet-body @mount={(el) => { body = el }}>{children}</div>        </section>    </div>}
Thread
38Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.