TabletSidebar
A sidebar that folds to icons, then to an overlay, by width class, with sections, badges and a pin kept per device.
Demo
jobs
Turn the tablet, or take it into Split View. The sidebar decides by the room it has, not the screen.
The pin: not changed
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/tablet_sidebar.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/tablet_sidebar.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/tablet_sidebar.qwe
let tablet_sidebar_demo_css = css`.demo-side-page { padding: 16px 20px; font: 15px/1.6 var(--ui-font); }.demo-side-page h4 { margin: 0 0 8px; font-size: 20px; }.demo-side-out { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }`let tablet_sidebar_demo_sections = [ { title: "Work", items: [ { id: "jobs", label: "Jobs", icon: "M4 8h16v11H4zM9 8V5h6v3", badge: 3 }, { id: "map", label: "Map", icon: "M12 21s7-6.5 7-12a7 7 0 1 0-14 0c0 5.5 7 12 7 12zM12 11a2 2 0 1 0 0-4 2 2 0 0 0 0 4" }, { id: "chat", label: "Messages", icon: "M4 5h16v11H9l-5 4z", badge: 240 }, { id: "docs", label: "Documents", icon: "M6 3h9l4 4v14H6zM14 3v5h5", badge: 0 }, ] }, { title: "Crew", items: [ { id: "rota", label: "Rota", icon: "M4 6h16v14H4zM4 10h16M9 3v4M15 3v4" }, { id: "time", label: "Timesheets", icon: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 7v5l3 2", badge: "soon" }, ] }, { items: [ { id: "help", label: "Help", icon: "?" }, ] },]component TabletSidebarDemoLive() { style global tablet_sidebar_demo_css state page = "jobs" state pin = "not changed" <div.demo-stack> <div.demo-tablet> <TabletSidebar id="demo-field" title="Field" current="jobs" sections={tablet_sidebar_demo_sections} on_select={(it) => { page = it.label }} on_pin={(p) => { pin = p ? "pinned" : "unpinned" }}> <div.demo-side-page> <h4 id="side-demo-page">{page}</h4> <p>Turn the tablet, or take it into Split View. The sidebar decides by the room it has, not the screen.</p> <Button id="side-demo-behind" size="lg" variant="outline">Something on the page</Button> </div> </TabletSidebar> </div> <p.demo-note id="side-demo-pin">The pin: {pin}</p> </div>}view TabletSidebarDemo() = <TabletSidebarDemoLive/>Source
ui/tablet_sidebar.qwe
// TabletSidebar: the side of an app with sections of places in it. With room it stands// open; at medium widths it folds to a rail of icons, which opens over the page when// asked; on a narrow screen it is out of sight and slides in. The pin keeps it open at// medium widths, and is remembered on this device. Width means its own box, so a turn,// Split View and Slide Over are all the same to it.//// import ui/tablet_sidebar.qwe use TabletSidebar//// <TabletSidebar id="main" title="Field" current={page} sections={[// { title: "Work", items: [// { id: "jobs", label: "Jobs", icon: "M4 7h16v12H4zM9 7V4h6v3", badge: 3 },// { id: "map", label: "Map", icon: "M12 21s7-6.5 7-12a7 7 0 1 0-14 0c0 5.5 7 12 7 12z" },// ] },// { title: "You", items: [{ id: "me", label: "Profile", icon: "P", href: "/me" }] },// ]} on_select={(item) => { page = item.id }}>// <Page/>// </TabletSidebar>//// sections: { title?, items }. items: { id, label, icon, badge?, href? }. icon: a// character, or an SVG path when it starts with "M". badge: a count; 0 or anything that is// not a number shows nothing, and over 99 shows 99+. href makes it a link; without one// it is a button, and inside one of your components calls on_select(item). A press also// marks it as the current one and closes the sidebar if it lay over the page.// current: the id of the place showing. title: the heading at its top, also its name for// screen readers ("Menu").// id: names what is remembered: the pin is kept in this browser's localStorage under// `ui-tablet-side:<id>`. In a phone app that storage comes back empty at each launch: keep// the pin yourself by on_pin, through a server fn, and give it back as `saved`.// saved: true or false: the pin as you kept it, in place of what the browser remembers.// on_pin(pinned): inside one of your components, when the pin changes.// children: the page beside it.//// expanded, 840 px and up: open, and the pin waits for when it matters. medium, from// 600: a rail, unless pinned. compact: out of sight, even if pinned. A swipe from the// left edge, or right along the rail, brings it over the page, following the finger; a// swipe left, Close, a tap beside it or Escape put it away; the menu button does both. Up and// Down move along the places.import ui/theme.qwe use ui_clslet tablet_sidebar_css = css`.ui-tablet-side { position: relative; box-sizing: border-box; min-width: 0; height: 100%; overflow: hidden; container: ui-tablet-side / inline-size; background: var(--ui-bg); color: var(--ui-fg); font: 15px/1.4 var(--ui-font);}.ui-tablet-side-frame { position: relative; display: flex; height: 100%; min-height: 0; }.ui-tablet-side-slot { position: relative; flex: 0 0 260px; min-width: 0; }.ui-tablet-side-panel { position: absolute; inset: 0 auto 0 0; z-index: 3; display: flex; flex-direction: column; box-sizing: border-box; width: 260px; min-height: 0; overflow: hidden; padding-left: env(safe-area-inset-left); background: var(--ui-subtle); border-right: 1px solid var(--ui-border); touch-action: pan-y;}.ui-tablet-side-top { display: flex; align-items: center; gap: 2px; flex: none; min-height: 56px; box-sizing: border-box; padding: max(4px, env(safe-area-inset-top)) 6px 4px; }.ui-tablet-side-top h2 { flex: 1; min-width: 0; margin: 0; padding: 0 8px; overflow: hidden; font-size: 17px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }.ui-tablet-side-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-tablet-side-btn[aria-pressed="true"] { color: var(--ui-accent); background: var(--ui-accent-soft); }.ui-tablet-side-btn:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-tablet-side-btn svg { width: 22px; height: 22px; }.ui-tablet-side-menu, .ui-tablet-side-close { display: none; }.ui-tablet-side-list { flex: 1; min-height: 0; overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain; touch-action: pan-y; padding: 0 8px calc(8px + env(safe-area-inset-bottom)); }.ui-tablet-side-section { padding: 6px 0; }.ui-tablet-side-section + .ui-tablet-side-section { border-top: 1px solid var(--ui-border); }.ui-tablet-side-section h3 { margin: 6px 10px 4px; color: var(--ui-muted); font-size: 12px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; }.ui-tablet-side-item { position: relative; display: flex; align-items: center; gap: 12px; box-sizing: border-box; width: 100%; min-height: 48px; padding: 6px 10px; border: 0; border-radius: var(--ui-radius); background: transparent; color: var(--ui-fg); font: inherit; text-align: left; text-decoration: none; cursor: pointer; -webkit-tap-highlight-color: transparent;}.ui-tablet-side-item[aria-current] { background: var(--ui-accent-soft); color: var(--ui-accent); font-weight: 600; }.ui-tablet-side-item:focus-visible { outline: none; box-shadow: var(--ui-focus); }@media (hover: hover) { .ui-tablet-side-item:hover, .ui-tablet-side-btn:hover { background: var(--ui-border); } }.ui-tablet-side-icon { display: inline-grid; place-items: center; flex: none; width: 28px; height: 28px; font-size: 19px; line-height: 1; }.ui-tablet-side-icon svg { width: 24px; height: 24px; }.ui-tablet-side-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.ui-tablet-side-badge { flex: none; min-width: 20px; padding: 0 6px; box-sizing: border-box; border-radius: 999px; background: var(--ui-accent); color: var(--ui-accent-fg); font-size: 12px; font-weight: 700; line-height: 20px; text-align: center; }.ui-tablet-side-main { display: flex; flex-direction: column; flex: 1; min-width: 0; min-height: 0; }.ui-tablet-side-bar { display: none; align-items: center; gap: 4px; flex: none; min-height: 52px; box-sizing: border-box; padding: 3px 6px; border-bottom: 1px solid var(--ui-border); }.ui-tablet-side-bar b { font-weight: 600; }.ui-tablet-side-page { flex: 1; min-height: 0; overflow: auto; }.ui-tablet-side-scrim { display: none; position: absolute; inset: 0; z-index: 2; padding: 0; border: 0; background: var(--ui-overlay); }.ui-tablet-side-edge { display: none; position: absolute; inset: 0 auto 0 0; z-index: 1; width: 16px; touch-action: pan-y; }.ui-tablet-side-moving .ui-tablet-side-panel { transition: width .22s cubic-bezier(.2, .8, .2, 1), transform .24s cubic-bezier(.2, .8, .2, 1); }@container ui-tablet-side (min-width: 600px) and (max-width: 839.98px) { .ui-tablet-side:not(.ui-tablet-side-pinned) .ui-tablet-side-slot { flex-basis: 76px; } .ui-tablet-side:not(.ui-tablet-side-pinned) .ui-tablet-side-panel { width: 76px; } .ui-tablet-side:not(.ui-tablet-side-pinned):not(.ui-tablet-side-peek) .ui-tablet-side-top { justify-content: center; } .ui-tablet-side:not(.ui-tablet-side-pinned):not(.ui-tablet-side-peek) .ui-tablet-side-top h2, .ui-tablet-side:not(.ui-tablet-side-pinned):not(.ui-tablet-side-peek) .ui-tablet-side-pin, .ui-tablet-side:not(.ui-tablet-side-pinned):not(.ui-tablet-side-peek) .ui-tablet-side-section h3 { display: none; } .ui-tablet-side:not(.ui-tablet-side-pinned):not(.ui-tablet-side-peek) .ui-tablet-side-list { padding-inline: 6px; } .ui-tablet-side:not(.ui-tablet-side-pinned):not(.ui-tablet-side-peek) .ui-tablet-side-item { justify-content: center; padding-inline: 4px; } .ui-tablet-side:not(.ui-tablet-side-pinned):not(.ui-tablet-side-peek) .ui-tablet-side-name { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } .ui-tablet-side:not(.ui-tablet-side-pinned):not(.ui-tablet-side-peek) .ui-tablet-side-badge { position: absolute; top: 2px; right: 6px; min-width: 18px; padding: 0 4px; font-size: 11px; line-height: 18px; } .ui-tablet-side:not(.ui-tablet-side-pinned) .ui-tablet-side-menu { display: grid; }}@container ui-tablet-side (max-width: 839.98px) { .ui-tablet-side-peek .ui-tablet-side-panel { width: 260px !important; box-shadow: var(--ui-shadow-lg); } .ui-tablet-side-peek .ui-tablet-side-menu { display: none !important; } .ui-tablet-side-peek .ui-tablet-side-close { display: grid; } .ui-tablet-side-peek .ui-tablet-side-scrim { display: block; }}@container ui-tablet-side (max-width: 599.98px) { .ui-tablet-side .ui-tablet-side-slot { flex-basis: 0; width: 0; } .ui-tablet-side .ui-tablet-side-panel { width: min(280px, 85vw) !important; transform: translateX(-105%); visibility: hidden; box-shadow: var(--ui-shadow-lg); } .ui-tablet-side.ui-tablet-side-peek .ui-tablet-side-panel, .ui-tablet-side-peeking .ui-tablet-side-panel { transform: none; visibility: visible; } .ui-tablet-side-peeking .ui-tablet-side-panel { transform: translateX(-105%); } .ui-tablet-side .ui-tablet-side-menu { display: none; } .ui-tablet-side-bar { display: flex; } .ui-tablet-side-bar .ui-tablet-side-menu { display: grid; } .ui-tablet-side-edge { display: block; } .ui-tablet-side .ui-tablet-side-pin { display: none; }}@media (prefers-reduced-motion: reduce) { .ui-tablet-side-panel { transition: none !important; } }`// Which way it stands. A width not measured yet is "": the CSS has already said.fn tablet_sidebar_mode(width: num, pinned: bool) -> str { if width <= 0 { return "" } if width < 600 { return "overlay" } if width < 840 && !pinned { return "rail" } "full"}// What a badge says: nothing for 0, a count, or 99+. Anything else says nothing.fn tablet_sidebar_badge(n) -> str? { if type_of(n) != "int" && type_of(n) != "float" { return null } if n <= 0 { return null } n > 99 ? "99+" : str(round(n))}// Whether a pull is carried through: 40% of the way, or a flick.fn tablet_sidebar_goes(moved: num, speed: num, room: num) -> bool = moved > room * 0.4 || (speed > 0.5 && moved > 24)fn tablet_sidebar_step(key: str, at: int, count: int) -> int? { match key { "ArrowDown" => at + 1 < count ? at + 1 : at "ArrowUp" => at > 0 ? at - 1 : 0 "Home" => 0 "End" => count > 0 ? count - 1 : 0 _ => null }}// The pin as a browser keeps it: "1", "0", or anything else for never set.fn tablet_sidebar_read(text) -> bool? = text == "1" ? true : text == "0" ? false : nullview TabletSidebarIcon(icon) = <span.ui-tablet-side-icon aria-hidden="true"> {type_of(icon) == "str" && starts_with(icon, "M") && len(icon) > 2 ? <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d={icon}/></svg> : str(icon ?? "")} </span>view TabletSidebarInner(it) { let badge = tablet_sidebar_badge(get(it, "badge")) <span.ui-tablet-side-inner style="display: contents"> <TabletSidebarIcon icon={get(it, "icon")}/> <span.ui-tablet-side-name>{str(get(it, "label", ""))}{badge != null && <span.ui-sr>, {badge} new</span>}</span> {badge != null && <span.ui-tablet-side-badge aria-hidden="true">{badge}</span>} </span>}component TabletSidebar(sections: list = [], current = null, title: str = "Menu", id: str = "ui", saved = null, on_select = null, on_pin = null, class: str? = null, children) { style global tablet_sidebar_css state here = current state pinned = saved == true state peek = false state mode = "" state root = null state win = null state opener = null // what had the focus before it came over the page state pull = null // a swipe: { x, y, last_x, last_at, speed, on, off, opening } let slot = `ui-tablet-side:${id}` // Classes that come and go go on the frame: the root's class={...} is written whole when // peek or pinned changes, and anything added by hand would go with it. let frame = () => root.querySelector(".ui-tablet-side-frame") let moving = () => { if root != null { let f = frame() f.classList.add("ui-tablet-side-moving") f.addEventListener("transitionend", (_e) => { f.classList.remove("ui-tablet-side-moving") }, { once: true }) } } let show = () => { moving() opener = win.document.activeElement peek = true root.classList.add("ui-tablet-side-peek") let first = root.querySelector(".ui-tablet-side-item[aria-current]") ?? root.querySelector(".ui-tablet-side-item") if first != null { first.focus({ preventScroll: true }) } } let hide = () => { if peek { moving() peek = false root.classList.remove("ui-tablet-side-peek") // At once, not when the page next updates: an inert page cannot take the focus. root.querySelector(".ui-tablet-side-main").inert = false // Back where it came from; a phone's tap leaves no focus, so else to the menu button. let panel = root.querySelector(".ui-tablet-side-panel") let fine = opener != null && opener.tagName != "BODY" && !panel.contains(opener) && opener.getClientRects().length > 0 let menus = root.querySelectorAll(".ui-tablet-side-menu") let shown = range(menus.length) | filter((n) => menus[n].getClientRects().length > 0) if fine { opener.focus({ preventScroll: true }) } else if len(shown) > 0 { menus[shown[0]].focus({ preventScroll: true }) } } } let pin = () => { let next = !pinned moving() pinned = next mode = tablet_sidebar_mode(root.clientWidth, next) if next { hide() } js("Promise.resolve", js_new("Promise", (done) => { done(js("localStorage.setItem", slot, next ? "1" : "0") ?? true) })) on_pin && on_pin(next) } let look = (el) => { mode = tablet_sidebar_mode(el.clientWidth, pinned) if mode == "full" && peek { hide() } } let press = (it) => { here = it.id hide() on_select && on_select(it) } let edge = (e) => { if !peek && mode != "full" && mode != "" && e.button == 0 { pull = { x: e.clientX, y: e.clientY, last_x: e.clientX, last_at: e.timeStamp, speed: 0, on: false, off: false, opening: true } } } let track = (e) => { if pull != null && !pull.off { let dx = pull.opening ? e.clientX - pull.x : pull.x - e.clientX let dy = e.clientY - pull.y let early = !pull.on && abs(dx) < 8 && abs(dy) < 8 let off = !pull.on && !early && abs(dy) > abs(dx) let on = pull.on || (!early && !off && dx > 0) if on { let panel = root.querySelector(".ui-tablet-side-panel") if !pull.on { e.target.setPointerCapture(e.pointerId) frame().classList.remove("ui-tablet-side-moving") if pull.opening { frame().classList.add("ui-tablet-side-peeking") } } let d = max([0, dx]) if mode == "rail" { panel.style.width = pull.opening ? `${min([260, 76 + d])}px` : `${max([76, 260 - d])}px` } else { panel.style.visibility = "visible" panel.style.transform = pull.opening ? `translateX(calc(-100% + ${min([d, panel.offsetWidth])}px))` : `translateX(${-d}px)` } } let dt = e.timeStamp - pull.last_at let step = dt > 0 ? (pull.opening ? e.clientX - pull.last_x : pull.last_x - e.clientX) / 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, opening: pull.opening } } } let clean = () => { let panel = root.querySelector(".ui-tablet-side-panel") panel.style.transform = "" panel.style.width = "" panel.style.visibility = "" frame().classList.remove("ui-tablet-side-peeking") moving() } let lift = (e) => { if pull != null { let was = pull pull = null if was.on { let moved = was.opening ? e.clientX - was.x : was.x - e.clientX let run = e.timeStamp - was.last_at < 100 ? was.speed : 0 let room = mode == "rail" ? 184 : root.querySelector(".ui-tablet-side-panel").offsetWidth clean() if tablet_sidebar_goes(moved, run, room) { if was.opening { show() } else { hide() } } } } } let cancel = () => { if pull != null { let was = pull pull = null if was.on { clean() } } } <div class={ui_cls(["ui-tablet-side", pinned && "ui-tablet-side-pinned", peek && "ui-tablet-side-peek", class])} data-mode={mode == "" ? null : mode} @mount={(el) => { let w = js("window") let remembered = saved == null ? js("Promise.resolve", js_new("Promise", (done) => { done(js("localStorage.getItem", slot)) })) : null let kept = tablet_sidebar_read(remembered) win = w root = el if kept != null { pinned = kept } let seen = js_new("ResizeObserver", (_e) => { look(el) }) seen.observe(el) () => { seen.disconnect() } }} @keydown={(e) => { if e.key == "Escape" && peek { e.preventDefault() hide() } }}> <div.ui-tablet-side-frame> <div.ui-tablet-side-slot> <nav.ui-tablet-side-panel aria-label={title} @pointerdown={(e) => { if e.button == 0 { if peek { pull = { x: e.clientX, y: e.clientY, last_x: e.clientX, last_at: e.timeStamp, speed: 0, on: false, off: false, opening: false } } else if mode == "rail" { edge(e) } } }} @pointermove={track} @pointerup={lift} @pointercancel={cancel}> <div.ui-tablet-side-top> <button class="ui-tablet-side-btn ui-tablet-side-menu" type="button" aria-label={`Open ${title}`} @click={show}> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg> </button> <h2>{title}</h2> <button class="ui-tablet-side-btn ui-tablet-side-pin" type="button" aria-label="Keep it open" aria-pressed={pinned ? "true" : "false"} @click={pin}> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 4h6l-1 6 4 4H6l4-4-1-6zM12 14v7"/></svg> </button> <button class="ui-tablet-side-btn ui-tablet-side-close" type="button" aria-label="Close" @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-tablet-side-list @keydown={(e) => { let rows = root.querySelectorAll(".ui-tablet-side-item") let at = range(rows.length) | filter((n) => rows[n].isSameNode(e.target)) let next = len(at) > 0 ? tablet_sidebar_step(e.key, at[0], rows.length) : null if next != null { e.preventDefault() rows[next].focus() } }}> {for s in sections => <div.ui-tablet-side-section key={str(get(s, "title", ""))}> {get(s, "title") != null && <h3>{s.title}</h3>} {for it in get(s, "items", []) => get(it, "href") != null ? <a.ui-tablet-side-item key={str(it.id)} href={it.href} data-id={str(it.id)} aria-current={str(it.id) == str(here) ? "page" : null} @click={() => { press(it) }}><TabletSidebarInner it={it}/></a> : <button.ui-tablet-side-item key={str(it.id)} type="button" data-id={str(it.id)} aria-current={str(it.id) == str(here) ? "page" : null} @click={() => { press(it) }}><TabletSidebarInner it={it}/></button>} </div>} </div> </nav> </div> <div.ui-tablet-side-main inert={peek}> <div.ui-tablet-side-bar> <button class="ui-tablet-side-btn ui-tablet-side-menu" type="button" aria-label={`Open ${title}`} @click={show}> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg> </button> <b>{title}</b> </div> <div.ui-tablet-side-page>{children}</div> </div> <div.ui-tablet-side-edge aria-hidden="true" @pointerdown={edge} @pointermove={track} @pointerup={lift} @pointercancel={cancel}></div> <button.ui-tablet-side-scrim type="button" tabindex="-1" aria-label="Close" data-small-target @click={hide}></button> </div> </div>}Related
AppTabBarBottom tabs for a phone app: badges, safe-area padding, a second press scrolls to the top, each tab keeps its place, gone while the keyboard is up; a rail on a tablet.3 views · 8 downloadsBottomAppBarA bottom bar with actions and a notched floating button, hiding on scroll and standing clear of the safe area; a floating toolbar on a tablet.3 views · 6 downloadsEdgeDrawerA 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.4 views · 7 downloadsFolderBrowserFolders with breadcrumbs, a Back, and move and rename in sheets.3 views · 6 downloadsLargeTitleBarAn iOS large title that shrinks into the bar as content scrolls, with back and action slots, tied to the scroller without jank.3 views · 6 downloadsNavRailA Material navigation rail for medium widths, with a menu, a floating action and badges, turning into a bottom bar on a phone.2 views · 7 downloads
Thread
Thread 0 posts
On TabletSidebar: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.