Mobile

EdgeDrawer

A 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.

#mobile#interactive#tablet#gesture#navigation#menu

Demo

Preview

Inbox

Inbox, line 1

Inbox, line 2

Inbox, line 3

Inbox, line 4

Inbox, line 5

Inbox, line 6

Inbox, line 7

Inbox, line 8

Inbox, line 9

Inbox, line 10

Inbox, line 11

Inbox, line 12

Inbox, line 13

Inbox, line 14

Inbox, line 15

Inbox, line 16

Inbox, line 17

Inbox, line 18

Inbox, line 19

Inbox, line 20

Inbox, line 21

Inbox, line 22

Inbox, line 23

Inbox, line 24

Inbox, line 25

Inbox, line 26

Inbox, line 27

Inbox, line 28

Inbox, line 29

Opened or closed 0 times

Open from the start:

The screen under it.

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

Example

The code of the preview above.

demos/edge_drawer.qwe
let edge_drawer_demo_css = css`.ui-edgedrawer-demo-list { margin: 0; padding: 8px; list-style: none; }.ui-edgedrawer-demo-list a, .ui-edgedrawer-demo-list button {  box-sizing: border-box; display: flex; align-items: center; width: 100%; min-height: 48px; padding: 0 12px; border: 0; border-radius: var(--ui-radius-sm);  background: transparent; color: var(--ui-fg); font: 15px var(--ui-font); text-align: start; text-decoration: none; cursor: pointer;}.ui-edgedrawer-demo-list [aria-current] { background: var(--ui-accent-soft); color: var(--ui-accent); }.ui-edgedrawer-demo-top { display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-bottom: 1px solid var(--ui-border); }.ui-edgedrawer-demo-top h3 { margin: 0; font-size: 17px; }`let edge_drawer_demo_places = ["Inbox", "Field notes", "Photos", "Settings"]component EdgeDrawerDemoLive() {    style global edge_drawer_demo_css    state menu = false    state place = "Inbox"    state changes = 0    state overlay = false    <div.demo-stack>        <div.demo-tablet>            <EdgeDrawer title="Fieldbook" dock={!overlay} open={menu} on_change={(on) => { menu = on; changes += 1 }}                        drawer={<ul.ui-edgedrawer-demo-list>                            {for p in edge_drawer_demo_places => <li key={p}><button type="button" aria-current={p == place ? "page" : null}                                @click={() => { place = p; menu = false }}>{p}</button></li>}                            <li><a href="#edge-drawer-help">Help (a link)</a></li>                            <li><a href="#edge-drawer-about">About (a link)</a></li>                            <li><a href="#edge-drawer-terms">Terms (a link)</a></li>                        </ul>}>                <div.ui-edgedrawer-demo-top>                    <Button size="lg" variant="outline" class="ui-edgedrawer-menu" id="edge-drawer-demo-menu" on_click={() => { menu = true }}>Menu</Button>                    <h3>{place}</h3>                </div>                <div style="padding: 12px 16px">                    {for i in range(1, 30) => <p key={i}>{place}, line {i}</p>}                </div>            </EdgeDrawer>        </div>        <p class="demo-note">Opened or closed {changes} {changes == 1 ? "time" : "times"}</p>        <Button size="lg" variant="outline" on_click={() => { overlay = !overlay }}>{overlay ? "Dock it when there is room" : "Always an overlay"}</Button>        <p class="demo-note">Open from the start:</p>        <div style="height: 320px; border: 1px solid var(--ui-border)">            <EdgeDrawer title="Opened at load" open={true} drawer={<p style="margin: 16px">Open from the start.</p>}>                <p style="margin: 16px">The screen under it.</p>            </EdgeDrawer>        </div>    </div>}view EdgeDrawerDemo() = <EdgeDrawerDemoLive/>

Source

ui/edge_drawer.qwe
// EdgeDrawer: a navigation drawer pulled in from the screen's edge. It follows the// finger, and on release snaps open or shut by speed, or by how far it came. Open, it// holds the focus, and closes with a swipe back, a tap beside it, Escape, its Close// button or the Back button of a browser or an Android phone. In a pane 700 px wide or// more it stops being an overlay and stands beside the content. In a right-to-left// page it comes from the right.////   import ui/edge_drawer.qwe use EdgeDrawer////   component App() {//       state menu = false//       <EdgeDrawer title="Fieldbook" open={menu} on_change={(on) => { menu = on }}//                   drawer={<SideNav/>}>//           <button @click={() => { menu = true }}>Menu</button>//           <Notes/>//       </EdgeDrawer>//   }//// drawer: what is in it, as HTML, so it goes inside one of your components; a link in// it closes it. children: the screen. title: the drawer's heading and its name for// screen readers. open: follows your state; the drawer also opens and closes by itself,// so take on_change(open), called each time it does. width: how wide it is, in// pixels (300, and never more than 85% of the pane). edge: how near the edge a swipe// starts (24). dock: stand beside the content on a wide pane (true). back: use a step// of the history while it is open, so Back closes it (true; false for a drawer inside// a screen that has its own). The box takes the size of its parent, which needs a// height.//// A menu button of your own is the way in for keys and screen readers; there is none// here, as it belongs in your bar (TopAppBar's on_nav suits it). Give it the class// ui-edgedrawer-menu and it goes when the drawer is docked. A link followed from// the drawer leaves its step of the history behind, as taking it back would undo the// link: one press of Back then shows the same page. Android's gesture navigation and// Safari on an iPhone keep the edge for their own Back: there the menu button is the// way in.import ui/theme.qwe use ui_clslet edge_drawer_css = css`.ui-edgedrawer { display: block; width: 100%; height: 100%; min-height: 0; container-type: inline-size; color: var(--ui-fg); font: 15px/1.5 var(--ui-font); }.ui-edgedrawer-in { position: relative; display: flex; width: 100%; height: 100%; overflow: hidden; overscroll-behavior-x: contain; touch-action: pan-y pinch-zoom; background: var(--ui-bg); }.ui-edgedrawer-main { flex: 1; min-width: 0; height: 100%; overflow: auto; overscroll-behavior: contain; touch-action: pan-y pinch-zoom; }.ui-edgedrawer-panel {  position: absolute; inset-block: 0; inset-inline-start: 0; z-index: 3; display: flex; flex-direction: column; box-sizing: border-box;  width: min(var(--ui-edgedrawer-w, 300px), 85%); padding-top: env(safe-area-inset-top); padding-inline-start: env(safe-area-inset-left);  background: var(--ui-surface); box-shadow: var(--ui-shadow-lg); outline: none; visibility: hidden; transform: translate3d(-100%, 0, 0);  transition: transform .26s cubic-bezier(.2, .8, .2, 1), visibility 0s .26s; touch-action: pan-y pinch-zoom; overscroll-behavior: contain;}.ui-edgedrawer-panel:dir(rtl) { transform: translate3d(100%, 0, 0); }.ui-edgedrawer-open .ui-edgedrawer-panel { visibility: visible; transform: none; transition: transform .26s cubic-bezier(.2, .8, .2, 1); }.ui-edgedrawer-moving .ui-edgedrawer-panel { visibility: visible; transition: none; will-change: transform; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; }.ui-edgedrawer-scrim {  position: absolute; inset: 0; z-index: 2; background: var(--ui-overlay); opacity: 0; visibility: hidden; touch-action: none;  transition: opacity .26s cubic-bezier(.2, .8, .2, 1), visibility 0s .26s;}.ui-edgedrawer-open .ui-edgedrawer-scrim { opacity: 1; visibility: visible; transition: opacity .26s cubic-bezier(.2, .8, .2, 1); }.ui-edgedrawer-moving .ui-edgedrawer-scrim { visibility: visible; transition: none; }.ui-edgedrawer-head { display: flex; flex: none; align-items: center; gap: 8px; min-height: 52px; padding-inline: 16px 4px; border-bottom: 1px solid var(--ui-border); }.ui-edgedrawer-head h2 { flex: 1; min-width: 0; margin: 0; overflow: hidden; font-size: 17px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }.ui-edgedrawer-close {  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-edgedrawer-close svg { width: 22px; height: 22px; }.ui-edgedrawer-close:focus-visible { outline: none; box-shadow: var(--ui-focus); }@media (hover: hover) { .ui-edgedrawer-close:hover { background: var(--ui-subtle); color: var(--ui-fg); } }.ui-edgedrawer-menu { flex: none; }/* touch-action stops counting at a scroller, so the one on the panel does not reach what is in this. */.ui-edgedrawer-body { flex: 1; min-height: 0; padding-bottom: env(safe-area-inset-bottom); overflow: auto; overscroll-behavior: contain; touch-action: pan-y pinch-zoom; }@container (min-width: 700px) {  .ui-edgedrawer-dock .ui-edgedrawer-panel {    position: static; flex: none; order: -1; width: min(var(--ui-edgedrawer-w, 300px), 40%); border-inline-end: 1px solid var(--ui-border);    box-shadow: none; visibility: visible; transform: none; transition: none;  }  .ui-edgedrawer-dock .ui-edgedrawer-scrim, .ui-edgedrawer-dock .ui-edgedrawer-close, .ui-edgedrawer-dock .ui-edgedrawer-menu { display: none; }}@media (prefers-reduced-motion: reduce) {  .ui-edgedrawer-panel, .ui-edgedrawer-scrim, .ui-edgedrawer-open .ui-edgedrawer-panel, .ui-edgedrawer-open .ui-edgedrawer-scrim { transition: none; }}`// Where a drawer ends up. A flick decides by its direction; otherwise, the nearer side.fn edge_drawer_settle(pos: num, width: num, speed: num) -> bool {    if speed >= 0.35 { return true }    if speed <= -0.35 { return false }    pos >= width / 2}// A place for the panel, inside its range for any input.fn edge_drawer_clamp(pos: num, width: num) -> num = pos < 0 ? 0 : pos > width ? width : pos// Speed at release in px/ms: none if the finger rested before it lifted.fn edge_drawer_speed(from_x: num, from_t: num, x: num, t: num, now: num) -> num {    if now - t > 90 || t <= from_t { return 0 }    (x - from_x) / (t - from_t)}// Where Tab goes next inside the panel: null leaves it to the browser. at: the place// of what has the focus, -1 for the panel itself. Wraps at the ends.fn edge_drawer_wrap(count: int, at: int, back: bool) -> int? {    if count == 0 { return null }    if back { return at <= 0 ? count - 1 : null }    at < 0 || at == count - 1 ? 0 : null}let edge_drawer_focusable = "a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), summary, [tabindex]:not([tabindex='-1'])"fn edge_drawer_paint(panel, scrim, pos: num, width: num, sign: num) {    panel.style.transform = `translate3d(${(pos - width) * sign}px, 0, 0)`    scrim.style.opacity = `${width > 0 ? pos / width : 0}`}fn edge_drawer_clear(panel, scrim) {    panel.style.transform = ""    scrim.style.opacity = ""}component EdgeDrawer(title: str = "Menu", open: bool = false, drawer = null, width: num = 300, edge: num = 24,                     dock: bool = true, back: bool = true, on_change = null, class: str? = null, children) {    style global edge_drawer_css    state shown = open    state asked = open          // the last `open` given: a change of it is a request    state roomy = false         // the pane is wide enough to dock in    state moving = false    state stepped = false       // this open drawer has a history step of its own    state win = null            // the window, as js() in a handler would wait, and a key's default not be stopped    state root = null    state grip = null           // what the handlers are on, so what a finger is held to    state panel = null    state scrim = null    state before = null         // what had the focus, to give it back    state g = null              // the finger: { sx, sy, fx, ft, x, t, on, base, sign, id, w }    let wide = dock && roomy    // docked: nothing to pull, hold or close    let modal = shown && !wide    // An overlay coming open: it takes the focus and a step of the history.    let enter = () => {        before = win.document.activeElement        if back {            stepped = true            win.history.pushState({ ...(win.history.state ?? {}), ui_edge_drawer: true }, "")        }        // Once it can take the focus: visibility comes with the state.        win.requestAnimationFrame(() => { panel.focus({ preventScroll: true }) })    }    let place = (on: bool, step_back: bool) => {        if on != shown {            shown = on            if on {                if !wide { enter() }            } else {                if stepped {                    stepped = false                    if step_back { win.history.back() }                }                // After the update, as the content cannot take it while it is inert.                let was = before                before = null                win.requestAnimationFrame(() => {                    if was != null && was.isConnected { was.focus({ preventScroll: true }) }                })            }            on_change && on_change(on)        }    }    if win != null && open != asked {        asked = open        place(open, true)    }    // Back to how it was, for when the system takes the gesture, or a second finger comes.    let abandon = () => {        if g.on {            moving = false            edge_drawer_clear(panel, scrim)        }        g = null    }    let start = (e) => {        if g != null && !e.isPrimary { abandon() }        let sign = win.getComputedStyle(grip).direction == "rtl" ? -1 : 1        let r = root.getBoundingClientRect()        let from_edge = sign > 0 ? e.clientX - r.left : r.right - e.clientX        if !wide && g == null && e.isPrimary && e.button == 0 && (shown || (from_edge >= 0 && from_edge <= edge)) {            g = { sx: e.clientX, sy: e.clientY, fx: e.clientX, ft: e.timeStamp, x: e.clientX, t: e.timeStamp, on: false,                  base: shown ? panel.offsetWidth : 0, sign: sign, id: e.pointerId, w: panel.offsetWidth }        }    }    let move = (e) => {        if g != null && e.pointerId == g.id {            let dx = (e.clientX - g.sx) * g.sign            let dy = e.clientY - g.sy            if !g.on && abs(dx) + abs(dy) >= 10 {                // Locked to one axis; opening starts only towards the middle.                if abs(dy) >= abs(dx) || (!shown && dx < 0) {                    g = null                } else {                    g = { ...g, on: true }                    moving = true                    grip.setPointerCapture(e.pointerId)                }            }            if g != null && g.on {                let keep = e.timeStamp - g.t > 40                g = { ...g, fx: keep ? g.x : g.fx, ft: keep ? g.t : g.ft, x: e.clientX, t: e.timeStamp }                edge_drawer_paint(panel, scrim, edge_drawer_clamp(g.base + dx, g.w), g.w, g.sign)            }        }    }    let release = (e) => {        if g != null && e.pointerId == g.id {            let was = g            g = null            if was.on {                moving = false                edge_drawer_clear(panel, scrim)                let dx = (e.clientX - was.sx) * was.sign                let speed = edge_drawer_speed(was.fx, was.ft, was.x, was.t, e.timeStamp) * was.sign                place(edge_drawer_settle(edge_drawer_clamp(was.base + dx, was.w), was.w, speed), true)            }        }    }    let cancel = (e) => {        if g != null && e.pointerId == g.id { abandon() }    }    <div.ui-edgedrawer @mount={(el) => {             win = js("window")             root = el             roomy = el.clientWidth >= 700             // Open from the start: it is an overlay like any other.             if shown && !(dock && roomy) { enter() }             let ro = js_new("ResizeObserver", () => { roomy = el.clientWidth >= 700 })             ro.observe(el)             // Back in a browser or on an Android phone. Forward does not open it again.             let popped = () => {                 let now = win.history.state                 if shown && stepped && !(now != null && get(now, "ui_edge_drawer", false)) { place(false, false) }             }             win.addEventListener("popstate", popped)             () => {                 ro.disconnect()                 win.removeEventListener("popstate", popped)             }         }}>        <div class={ui_cls(["ui-edgedrawer-in", shown && "ui-edgedrawer-open", moving && "ui-edgedrawer-moving", dock && "ui-edgedrawer-dock", class])}             style={`--ui-edgedrawer-w: ${width}px`} @mount={(el) => { grip = el }}             @pointerdown={start} @pointermove={move} @pointerup={release} @pointercancel={cancel}             @keydown={(e) => {                 if modal && e.key == "Escape" {                     e.preventDefault()                     place(false, true)                 }                 if modal && e.key == "Tab" {                     let items = panel.querySelectorAll(edge_drawer_focusable)                     let count = items.length                     let on = win.document.activeElement                     let at = on.isSameNode(items.item(0)) ? 0 : on.isSameNode(items.item(count - 1)) ? count - 1 : panel.contains(on) && !on.isSameNode(panel) ? 1 : -1                     let to = edge_drawer_wrap(count, at, e.shiftKey)                     if to != null {                         e.preventDefault()                         items.item(to).focus()                     }                 }             }}>            <div.ui-edgedrawer-main inert={modal ? "" : null}>{children}</div>            <div.ui-edgedrawer-scrim aria-hidden="true" @mount={(el) => { scrim = el }}                 @click={() => { if !wide { place(false, true) } }}></div>            <div.ui-edgedrawer-panel role={wide ? "navigation" : "dialog"} aria-modal={modal ? "true" : null} aria-label={title} tabindex="-1"                 @mount={(el) => { panel = el }}                 @click={(e) => {                     if modal && e.target.closest("a[href]") != null { place(false, false) }                 }}>                <div.ui-edgedrawer-head>                    <h2>{title}</h2>                    <button.ui-edgedrawer-close type="button" aria-label="Close" @click={() => { place(false, true) }}>                        <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-edgedrawer-body>{drawer}</div>            </div>        </div>    </div>}
Thread
47Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.