Mobile

SlideOver

A floating panel that slides over from either edge and stays while the page behind stays usable, moved between edges by drag.

#mobile#tablet#interactive#panel#overlay#drag

Demo

Preview

Stock count

The page behind works while the panel floats over it.

Delivery notes

Crate 1: checked in at the side door.

Crate 2: checked in at the side door.

Crate 3: checked in at the side door.

Crate 4: checked in at the side door.

Crate 5: checked in at the side door.

Crate 6: checked in at the side door.

Crate 7: checked in at the side door.

Crate 8: checked in at the side door.

Crate 9: checked in at the side door.

Crate 10: checked in at the side door.

Crate 11: checked in at the side door.

Crate 12: checked in at the side door.

Crate 13: checked in at the side door.

Crate 14: checked in at the side door.

Crate 15: checked in at the side door.

Crate 16: checked in at the side door.

Crate 17: checked in at the side door.

Crate 18: checked in at the side door.

Crate 19: checked in at the side door.

Crate 20: checked in at the side door.

Crate 21: checked in at the side door.

Crate 22: checked in at the side door.

Crate 23: checked in at the side door.

Crate 24: checked in at the side door.

Crate 25: checked in at the side door.

Crate 26: checked in at the side door.

Crate 27: checked in at the side door.

Crate 28: checked in at the side door.

Crate 29: checked in at the side door.

Crate 30: checked in at the side door.

Crate 31: checked in at the side door.

Crate 32: checked in at the side door.

Crate 33: checked in at the side door.

Crate 34: checked in at the side door.

Crate 35: checked in at the side door.

Crate 36: checked in at the side door.

Crate 37: checked in at the side door.

Crate 38: checked in at the side door.

Crate 39: checked in at the side door.

Crate 40: checked in at the side door.

Crate 41: checked in at the side door.

Crate 42: checked in at the side door.

Crate 43: checked in at the side door.

Crate 44: checked in at the side door.

Crate 45: checked in at the side door.

Crate 46: checked in at the side door.

Crate 47: checked in at the side door.

Crate 48: checked in at the side door.

Crate 49: checked in at the side door.

Crate 50: checked in at the side door.

Crate 51: checked in at the side door.

Crate 52: checked in at the side door.

Crate 53: checked in at the side door.

Crate 54: checked in at the side door.

Crate 55: checked in at the side door.

Crate 56: checked in at the side door.

Crate 57: checked in at the side door.

Crate 58: checked in at the side door.

Crate 59: checked in at the side door.

Crate 60: checked in at the side door.

Panel on the right, open

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

Example

The code of the preview above.

demos/slide_over.qwe
let slide_over_demo_css = css`.demo-over { display: grid; gap: 12px; align-content: start; height: 100%; box-sizing: border-box; padding: 18px 20px; font: 15px/1.6 var(--ui-font); }.demo-over h4 { margin: 0; font-size: 20px; }.demo-over input { box-sizing: border-box; width: min(320px, 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); }.demo-over-note { margin: 0 0 10px; }`component SlideOverDemoLive() {    style global slide_over_demo_css    state tally = 0    state where = "right"    state said = "open"    state wide = false    <div.demo-tablet>        <div.demo-over>            <h4>Stock count</h4>            <p>The page behind works while the panel floats over it.</p>            <div.demo-row>                <Button id="over-demo-count" size="lg" variant="outline" on_click={() => { tally += 1 }}>Counted {tally}</Button>                <SlideOver title="Delivery notes" trigger="Notes" width={wide ? 340 : 320} on_edge={(e) => { where = e }}                           on_close={() => { said = "put away" }} on_open={() => { said = "brought back" }}>                    {for n in range(1, 61) => <p.demo-over-note>Crate {n}: checked in at the side door.</p>}                    <label for="over-demo-note">A note</label>                    <input id="over-demo-note" type="text"/>                </SlideOver>                <Button id="over-demo-wider" size="lg" variant="outline" on_click={() => { wide = !wide }}>Change its width</Button>            </div>            <label for="over-demo-field">Bin number</label>            <input id="over-demo-field" type="text"/>            <p id="over-demo-said">Panel on the {where}, {said}</p>        </div>    </div>}view SlideOverDemo() = <SlideOverDemoLive/>

Source

ui/slide_over.qwe
// SlideOver: a floating panel over the side of the screen, as an iPad's Slide Over app// floats. The page behind goes on working: nothing is dimmed or locked, the focus is not// held, and a tap on the page leaves the panel where it is. Drag its top across to move it// to the other edge, or out past its own edge to put it away; a tab stays on the edge to// bring it back, by a tap or a drag.////   import ui/slide_over.qwe use SlideOver////   <SlideOver title="Notes" trigger="Notes" on_edge={(e) => { side = e }}>//       <Notes/>//   </SlideOver>//// title: its heading, and its name for screen readers.// trigger: the text of a button, placed where this is written, that shows and hides it.// edge: the edge it starts on, "right" (the default) or "left".// open: whether it starts out; inside one of your components it follows yours.// width: how wide it is in px (320), never wider than the screen less a margin.// on_edge(edge): inside one of your components, "left" or "right" when it moves.// on_close(), on_open(): when it is put away and brought back, however that was done.// children: what is in it, which scrolls by itself.//// Every drag has a button: Move across, Hide, and the tab; Escape hides it too while the// focus is inside. It sits in position: fixed, so it is held to the screen, or to any// box around it with a transform. It rises above the on-screen keyboard.import ui/theme.qwe use ui_clslet slide_over_css = css`.ui-slide-over-root { display: contents; }.ui-slide-over-open {  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-slide-over-open[aria-expanded="true"] { border-color: var(--ui-accent); color: var(--ui-accent); }.ui-slide-over-open:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-slide-over { position: fixed; inset: 0; z-index: 40; overflow: hidden; pointer-events: none; font: 15px/1.5 var(--ui-font); }.ui-slide-over-panel {  position: absolute; top: max(12px, env(safe-area-inset-top)); bottom: calc(max(12px, env(safe-area-inset-bottom)) + var(--ui-slide-over-kb, 0px));  left: calc(100% - var(--ui-slide-over-w) - 12px - env(safe-area-inset-right)); display: flex; flex-direction: column; box-sizing: border-box; width: var(--ui-slide-over-w);  overflow: hidden; pointer-events: auto; border: 1px solid var(--ui-border); border-radius: calc(var(--ui-radius) + 6px); background: var(--ui-surface); color: var(--ui-fg); box-shadow: var(--ui-shadow-lg);  transition: left .26s cubic-bezier(.2, .8, .2, 1), transform .26s cubic-bezier(.2, .8, .2, 1), visibility 0s, bottom .2s;}.ui-slide-over-panel[data-edge="left"] { left: calc(12px + env(safe-area-inset-left)); }.ui-slide-over-panel[data-away="true"] { transform: translateX(calc(100% + 24px)); visibility: hidden; transition: left .26s cubic-bezier(.2, .8, .2, 1), transform .26s cubic-bezier(.2, .8, .2, 1), visibility 0s linear .26s; }.ui-slide-over-panel[data-edge="left"][data-away="true"] { transform: translateX(calc(-100% - 24px)); }.ui-slide-over-dragging { transition: none !important; box-shadow: 0 20px 60px rgba(15, 14, 24, .3); }.ui-slide-over-top {  display: flex; align-items: center; gap: 2px; flex: none; min-height: 52px; box-sizing: border-box; padding: 4px 4px 4px 14px; border-bottom: 1px solid var(--ui-border);  background: var(--ui-subtle); cursor: grab; touch-action: pan-y; -webkit-user-select: none; user-select: none; -webkit-touch-callout: none;}.ui-slide-over-dragging .ui-slide-over-top { cursor: grabbing; }.ui-slide-over-top h2 { flex: 1; min-width: 0; margin: 0; overflow: hidden; font-size: 16px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; outline: none; }.ui-slide-over-btn {  display: grid; place-items: center; flex: none; width: 44px; height: 44px; padding: 0; border: 0; border-radius: var(--ui-radius-sm); background: transparent; color: var(--ui-muted); cursor: pointer; -webkit-tap-highlight-color: transparent;}.ui-slide-over-btn svg { width: 20px; height: 20px; transition: transform .2s; }.ui-slide-over-panel[data-edge="left"] .ui-slide-over-across svg { transform: rotate(180deg); }.ui-slide-over-btn:focus-visible, .ui-slide-over-tab:focus-visible { outline: none; box-shadow: var(--ui-focus); }@media (hover: hover) { .ui-slide-over-btn:hover { background: var(--ui-border); color: var(--ui-fg); } }.ui-slide-over-body { flex: 1; min-height: 0; overflow: auto; padding: 14px; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; }.ui-slide-over-tab {  position: absolute; top: 40%; right: 0; display: none; place-items: center; box-sizing: border-box; width: 44px; height: 88px; padding: 0 0 0 6px; border: 1px solid var(--ui-border); border-right: 0;  border-radius: 14px 0 0 14px; background: var(--ui-surface); color: var(--ui-muted); box-shadow: var(--ui-shadow); pointer-events: auto; cursor: pointer; touch-action: pan-y; -webkit-tap-highlight-color: transparent;}.ui-slide-over-tab[data-edge="left"] { right: auto; left: 0; padding: 0 6px 0 0; border-width: 1px 1px 1px 0; border-radius: 0 14px 14px 0; }.ui-slide-over-tab[data-away="true"] { display: grid; }.ui-slide-over-tab span { width: 5px; height: 40px; border-radius: 999px; background: var(--ui-strong); }@media (prefers-reduced-motion: reduce) { .ui-slide-over-panel, .ui-slide-over-panel[data-away="true"] { transition: none; } .ui-slide-over-btn svg { transition: none; } }`fn slide_over_other(edge: str) -> str = edge == "left" ? "right" : "left"// Where a drag lands. edge: where it was. moved: px across, right being positive. speed:// px a millisecond the same way. mid: the panel's middle, after the drag. room: the width// of the screen. width: the panel's. Gives "left", "right", "hide", or "stay" when it// falls short and springs back.fn slide_over_lands(edge: str, moved: num, speed: num, mid: num, room: num, width: num) -> str {    let out = edge == "left" ? -moved : moved       // past its own edge    let run = edge == "left" ? -speed : speed    if out > width * 0.4 || (out > 24 && run > 0.5) { return "hide" }    let crossed = edge == "left" ? mid > room / 2 : mid < room / 2    if crossed || (out < -40 && run < -0.5) { return slide_over_other(edge) }    "stay"}// A drag out from the tab: far enough, or quick enough, and the panel comes.fn slide_over_comes(inward: num, speed: num) -> bool = inward > 60 || (speed > 0.4 && inward > 16)component SlideOver(title: str, trigger: str? = null, edge: str = "right", open: bool = true, width: int = 320,                    on_edge = null, on_close = null, on_open = null, class: str? = null, children) {    style global slide_over_css    state side = edge == "left" ? "left" : "right"    state out = open    state seen = open    state panel = null    state tab = null    state pull = null          // a drag: { x, y, last_x, last_at, speed, on, off }    if open != seen {        seen = open        out = open    }    let move = (to: str) => {        if to != side {            side = to            on_edge && on_edge(to)        }    }    let hide = () => {        let had = panel != null && panel.contains(panel.ownerDocument.activeElement)        if out {            out = false            on_close && on_close()        }        if had {            // At once, not when the page next updates: the tab is what comes in its place.            tab.dataset.away = "true"            tab.focus({ preventScroll: true })        }    }    let show = () => {        if !out {            out = true            panel.dataset.away = "false"            panel.querySelector("h2").focus({ preventScroll: true })            on_open && on_open()        }    }    let down = (e) => {        if e.button == 0 && e.target.closest(".ui-slide-over-btn") == null {            pull = { x: e.clientX, y: e.clientY, last_x: e.clientX, last_at: e.timeStamp, speed: 0, on: false, off: false }        }    }    let drag = (e) => {        if pull != null && !pull.off {            let dx = e.clientX - pull.x            let dy = e.clientY - pull.y            let early = !pull.on && abs(dx) < 10 && abs(dy) < 10            let off = !pull.on && !early && abs(dy) > abs(dx)            let on = pull.on || (!early && !off)            if on {                if !pull.on {                    e.target.setPointerCapture(e.pointerId)                    panel.classList.add("ui-slide-over-dragging")                }                panel.style.transform = `translateX(${dx}px)`            }            let dt = e.timeStamp - pull.last_at            let step = dt > 0 ? (e.clientX - pull.last_x) / dt : pull.speed            pull = { x: pull.x, y: pull.y, last_x: e.clientX, last_at: e.timeStamp, speed: pull.speed == 0 ? step : pull.speed * 0.4 + step * 0.6, on: on, off: off }        }    }    let lift = (e) => {        if pull != null {            let was = pull            pull = null            if was.on {                let r = panel.getBoundingClientRect()                let room = panel.parentElement.clientWidth                let run = e.timeStamp - was.last_at < 100 ? was.speed : 0                let host = panel.parentElement.getBoundingClientRect()                let lands = slide_over_lands(side, e.clientX - was.x, run, r.left - host.left + r.width / 2, room, r.width)                panel.classList.remove("ui-slide-over-dragging")                panel.style.transform = ""                if lands == "hide" {                    hide()                } else if lands != "stay" {                    move(lands)                }            }        }    }    let cancel = () => {        if pull != null {            pull = null            panel.classList.remove("ui-slide-over-dragging")            panel.style.transform = ""        }    }    <div.ui-slide-over-root         @mount={(el) => {             let w = js("window")             let vv = w.visualViewport             let lift_up = (_e = null) => {                 if vv != null && panel != null {                     let kb = max([0, round(w.innerHeight - vv.height - vv.offsetTop)])                     panel.style.setProperty("--ui-slide-over-kb", `${kb}px`)                 }             }             if vv != null {                 vv.addEventListener("resize", lift_up, { passive: true })                 vv.addEventListener("scroll", lift_up, { passive: true })             }             panel = el.querySelector(".ui-slide-over-panel")             tab = el.querySelector(".ui-slide-over-tab")             () => {                 if vv != null {                     vv.removeEventListener("resize", lift_up)                     vv.removeEventListener("scroll", lift_up)                 }             }         }}>        {trigger != null && <button.ui-slide-over-open type="button" aria-expanded={out ? "true" : "false"}                                    @click={() => { if out { hide() } else { show() } }}>{trigger}</button>}        // The width is on the host and not on the panel: a style={...} on what a drag moves        // is written whole when what it reads changes, and the panel would jump home.        <div class={ui_cls(["ui-slide-over", class])} style={`--ui-slide-over-w: min(${width}px, calc(100% - 24px))`}>            <section.ui-slide-over-panel aria-label={title} data-edge={side} data-away={out ? "false" : "true"}                     @keydown={(e) => {                         if e.key == "Escape" {                             e.preventDefault()                             hide()                         }                     }}>                <div.ui-slide-over-top @pointerdown={down} @pointermove={drag} @pointerup={lift} @pointercancel={cancel}>                    <h2 tabindex="-1">{title}</h2>                    <button class="ui-slide-over-btn ui-slide-over-across" type="button" aria-label={`Move to the ${slide_over_other(side)} edge`}                            @click={() => { move(slide_over_other(side)) }}>                        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 12H4M10 6l-6 6 6 6"/></svg>                    </button>                    <button class="ui-slide-over-btn ui-slide-over-hide" type="button" aria-label="Hide" @click={hide}>                        <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-slide-over-body>{children}</div>            </section>            <button.ui-slide-over-tab type="button" aria-label={`Show ${title}`} data-edge={side} data-away={out ? "false" : "true"}                    @click={() => {                        if pull == null { show() }                    }}                    @pointerdown={(e) => {                        if e.button == 0 {                            pull = { x: e.clientX, y: e.clientY, last_x: e.clientX, last_at: e.timeStamp, speed: 0, on: false, off: false }                        }                    }}                    @pointermove={(e) => {                        if pull != null && !pull.off {                            let inward = side == "right" ? pull.x - e.clientX : e.clientX - pull.x                            let dy = e.clientY - pull.y                            let early = !pull.on && abs(inward) < 10 && abs(dy) < 10                            let off = !pull.on && !early && abs(dy) > abs(inward)                            let on = pull.on || (!early && !off && inward > 0)                            if on && !pull.on { e.target.setPointerCapture(e.pointerId) }                            let dt = e.timeStamp - pull.last_at                            let step = dt > 0 ? (side == "right" ? pull.last_x - e.clientX : e.clientX - pull.last_x) / dt : pull.speed                            pull = { x: pull.x, y: pull.y, last_x: e.clientX, last_at: e.timeStamp, speed: pull.speed == 0 ? step : pull.speed * 0.4 + step * 0.6, on: on, off: off }                        }                    }}                    @pointerup={(e) => {                        if pull != null {                            let was = pull                            let inward = side == "right" ? was.x - e.clientX : e.clientX - was.x                            let run = e.timeStamp - was.last_at < 100 ? was.speed : 0                            pull = null                            if was.on && slide_over_comes(inward, run) { show() }                        }                    }}                    @pointercancel={() => { pull = null }}><span aria-hidden="true"></span></button>        </div>    </div>}
Thread
47Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.