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.
Demo
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.
Opened at load
Open from the start.
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.qweimport 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.
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
// 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>}Related
Thread 0 posts
On EdgeDrawer: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.