SlideOver
A floating panel that slides over from either edge and stays while the page behind stays usable, moved between edges by drag.
Demo
Stock count
The page behind works while the panel floats over it.
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.qwemain.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>}Related
AnchoredPopoverA 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.3 views · 5 downloadsDragBetweenPanesDrag an item from one pane to another by finger or pen, with a drop preview, a keyboard route and on_move.3 views · 5 downloadsInspectorPanelA right-hand panel of properties for the selected thing, in sections, live both ways, and a sheet on a phone.3 views · 5 downloadsSwipeKanbanColumns swiped by page, cards moved to a neighbour by drag near the edge, or from a menu.2 views · 5 downloadsWidgetGridA home screen of tiles of different sizes, rearranged by long press, with a keyboard and button route, and kept as a list of data.3 views · 6 downloadsActionSheetA list of actions rising from the bottom, with a destructive style and Cancel: swipe down or the phone's back gesture closes it; on a tablet, a card by what opened it.3 views · 9 downloads
Thread
Thread 0 posts
On SlideOver: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.