NavRail
A Material navigation rail for medium widths, with a menu, a floating action and badges, turning into a bottom bar on a phone.
Demo
Inbox
0 drafts started
A rail where there is room for one, and a bar along the bottom where there is not.
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/nav_rail.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/nav_rail.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/nav_rail.qwe
let nav_rail_demo_css = css`.demo-rail-page { padding: 18px 20px; font: 15px/1.6 var(--ui-font); }.demo-rail-page h4 { margin: 0 0 6px; font-size: 20px; }`let nav_rail_demo_items = [ { id: "inbox", label: "Inbox", icon: "M4 5h16v14H4zM4 13h5l2 2h2l2-2h5", badge: 4 }, { id: "calls", label: "Calls", icon: "M5 4h4l2 5-2.5 1.5a11 11 0 0 0 5 5L15 13l5 2v4a2 2 0 0 1-2 2A15 15 0 0 1 3 6a2 2 0 0 1 2-2z", badge: 120 }, { id: "team", label: "Team", icon: "M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM2 21a7 7 0 0 1 14 0M16 3a4 4 0 0 1 0 8M22 21a7 7 0 0 0-5-6.7", badge: true }, { id: "files", label: "Files", icon: "M3 6h6l2 2h10v11H3z", badge: 0 }, { id: "plans", label: "Plans", icon: "M4 4h16v16H4zM4 9h16M9 4v16", badge: "soon" }, { id: "stats", label: "Reports", icon: "M4 20V10M10 20V4M16 20v-7M22 20H2" },]let nav_rail_demo_menu = [ { id: "settings", label: "Settings", icon: "S" }, { id: "help", label: "Help", icon: "?" },]component NavRailDemoLive() { style global nav_rail_demo_css state page = "Inbox" state drafts = 0 <div.demo-tablet> <NavRail current="inbox" items={nav_rail_demo_items} menu={nav_rail_demo_menu} action={{ label: "New message", icon: "+" }} on_select={(it) => { page = it.label }} on_action={() => { drafts += 1 }}> <div.demo-rail-page> <h4 id="rail-demo-page">{page}</h4> <p id="rail-demo-drafts">{drafts} drafts started</p> <p>A rail where there is room for one, and a bar along the bottom where there is not.</p> </div> </NavRail> </div>}view NavRailDemo() = <NavRailDemoLive/>Source
ui/nav_rail.qwe
// NavRail: Material's navigation rail for tablets: a slim column of places down the side,// with a menu at the top, a floating action under it, and badges. In a narrow box, a phone// or a Slide Over, the same places become a bar along the bottom and the action floats// above it. Places that do not fit the bar go under More, with whatever `menu` holds.//// import ui/nav_rail.qwe use NavRail//// <NavRail current="inbox" action={{ label: "Compose", icon: "+" }} items={[// { id: "inbox", label: "Inbox", icon: "M4 5h16v14H4zM4 13h5l2 2h2l2-2h5", badge: 4 },// { id: "calls", label: "Calls", icon: "C" },// { id: "team", label: "Team", icon: "T", badge: true },// ]} on_select={(it) => { page = it.id }} on_action={() => { compose() }}>// <Page/>// </NavRail>//// items: { id, label, icon, badge?, href? }, three to seven: the places, in order. icon: a// character, or an SVG path when it starts with "M". badge: a count (99+ past 99), or true// for a dot; 0 or anything else shows nothing. href makes a place a link; without one it// is a button. current: the id of the place showing. A press marks it, and inside one of// your components calls on_select(item).// menu: more places, for the menu only, in the same shape. menu_title: the menu's heading// ("Menu"). The menu is there when `menu` has places or the bar has to leave some out.// action: { label, icon }, the floating action; left out, none. on_action(): inside one// of your components, its press. label: what screen readers call it ("Main").// children: the page beside it, or above it.//// Under 600 px wide, measured on its own box, the rail is a bar of four places and More// when there are over five, or a menu; else all of them. Everything in the bar is in the menu too.// The menu is a <dialog>, so focus stays in it and Escape closes it, as a tap beside it// and a swipe left do. Arrow keys move along the places, Home and End to the ends.import ui/theme.qwe use ui_clslet nav_rail_css = css`.ui-nav-rail { position: relative; box-sizing: border-box; min-width: 0; height: 100%; container: ui-nav-rail / inline-size; background: var(--ui-bg); color: var(--ui-fg); font: 13px/1.25 var(--ui-font); }.ui-nav-rail-frame { display: flex; height: 100%; min-height: 0; }.ui-nav-rail-side { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: none; width: calc(80px + env(safe-area-inset-left)); box-sizing: border-box; padding: max(8px, env(safe-area-inset-top)) 0 8px env(safe-area-inset-left); overflow-y: auto; overscroll-behavior: contain; background: var(--ui-subtle); border-right: 1px solid var(--ui-border);}.ui-nav-rail-page { flex: 1; min-width: 0; min-height: 0; overflow: auto; }.ui-nav-rail-menu { display: grid; place-items: center; flex: none; width: 48px; height: 48px; padding: 0; border: 0; border-radius: 50%; background: transparent; color: var(--ui-fg); cursor: pointer; -webkit-tap-highlight-color: transparent;}.ui-nav-rail-menu svg { width: 24px; height: 24px; }.ui-nav-rail-menu:focus-visible, .ui-nav-rail-action:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-nav-rail-action { display: grid; place-items: center; flex: none; width: 56px; height: 56px; margin: 4px 0 8px; padding: 0; border: 0; border-radius: 16px; background: var(--ui-accent); color: var(--ui-accent-fg); box-shadow: var(--ui-shadow); font: 600 24px/1 var(--ui-font); cursor: pointer; -webkit-tap-highlight-color: transparent;}.ui-nav-rail-action svg { width: 24px; height: 24px; }.ui-nav-rail-places { display: flex; flex-direction: column; align-items: center; gap: 4px; width: 100%; }.ui-nav-rail-item { position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; box-sizing: border-box; width: 72px; min-height: 60px; padding: 4px 2px; border: 0; border-radius: var(--ui-radius); background: transparent; color: var(--ui-muted); font: 500 12px/1.2 var(--ui-font); text-decoration: none; cursor: pointer; -webkit-tap-highlight-color: transparent;}.ui-nav-rail-item:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-nav-rail-item[aria-current] { color: var(--ui-fg); font-weight: 600; }.ui-nav-rail-pill { position: relative; display: inline-grid; place-items: center; width: 56px; height: 32px; border-radius: 999px; font-size: 19px; line-height: 1; transition: background .15s; }.ui-nav-rail-pill svg { width: 24px; height: 24px; }.ui-nav-rail-item[aria-current] .ui-nav-rail-pill { background: var(--ui-accent-soft); color: var(--ui-accent); }.ui-nav-rail-word { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.ui-nav-rail-badge { position: absolute; top: -3px; left: 34px; min-width: 16px; padding: 0 4px; box-sizing: border-box; border-radius: 999px; background: var(--ui-danger); color: #fff; font: 700 10px/16px var(--ui-font); text-align: center; }.ui-nav-rail-dot { top: 2px; left: 38px; width: 8px; min-width: 0; height: 8px; padding: 0; }.ui-nav-rail-more { display: none; }@media (hover: hover) { .ui-nav-rail-item:hover .ui-nav-rail-pill, .ui-nav-rail-menu:hover { background: var(--ui-border); } }@container ui-nav-rail (max-width: 599.98px) { .ui-nav-rail-frame { flex-direction: column-reverse; } .ui-nav-rail-side { flex-direction: row; justify-content: center; width: 100%; padding: 4px env(safe-area-inset-right) max(4px, env(safe-area-inset-bottom)) env(safe-area-inset-left); overflow: visible; border-right: 0; border-top: 1px solid var(--ui-border); } .ui-nav-rail-menu { display: none; } .ui-nav-rail-places { flex-direction: row; justify-content: space-around; gap: 0; } .ui-nav-rail-item { flex: 1 1 0; width: auto; min-width: 0; max-width: 112px; } .ui-nav-rail-extra { display: none; } .ui-nav-rail-more { display: flex; } .ui-nav-rail-action { position: absolute; right: calc(16px + env(safe-area-inset-right)); bottom: calc(84px + env(safe-area-inset-bottom)); z-index: 2; margin: 0; box-shadow: var(--ui-shadow-lg); }}.ui-nav-rail-drawer { inset: 0 auto 0 0; box-sizing: border-box; width: min(320px, 86vw); height: 100dvh; max-width: none; max-height: none; margin: 0; padding: 0; overflow: hidden; border: 0; border-right: 1px solid var(--ui-border); background: var(--ui-surface); color: var(--ui-fg); font: 15px/1.4 var(--ui-font); touch-action: pan-y; transition: transform .22s cubic-bezier(.2, .8, .2, 1);}.ui-nav-rail-drawer[open] { display: flex; flex-direction: column; animation: ui-nav-rail-in .24s cubic-bezier(.2, .8, .2, 1); }.ui-nav-rail-drawer::backdrop { background: var(--ui-overlay); }.ui-nav-rail-dragging { transition: none; }@keyframes ui-nav-rail-in { from { transform: translateX(-100%); } }.ui-nav-rail-drawer-top { display: flex; align-items: center; justify-content: space-between; flex: none; min-height: 56px; padding: env(safe-area-inset-top) 6px 0 16px; border-bottom: 1px solid var(--ui-border); }.ui-nav-rail-drawer-top h2 { margin: 0; font-size: 17px; font-weight: 600; }.ui-nav-rail-drawer-list { flex: 1; min-height: 0; overflow: auto; overscroll-behavior: contain; touch-action: pan-y; padding: 8px 8px calc(8px + env(safe-area-inset-bottom)); }.ui-nav-rail-row { display: flex; align-items: center; gap: 12px; box-sizing: border-box; width: 100%; min-height: 52px; padding: 6px 12px; 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-nav-rail-row[aria-current] { background: var(--ui-accent-soft); color: var(--ui-accent); font-weight: 600; }.ui-nav-rail-row:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-nav-rail-row .ui-nav-rail-pill { width: 28px; }.ui-nav-rail-row .ui-nav-rail-badge { position: static; margin-left: auto; font-size: 12px; line-height: 20px; min-width: 20px; }.ui-nav-rail-row .ui-nav-rail-dot { width: 8px; min-width: 0; height: 8px; }.ui-nav-rail-row span:not([class]) { flex: 1; }@media (prefers-reduced-motion: reduce) { .ui-nav-rail-drawer { transition: none; } .ui-nav-rail-drawer[open] { animation: none; } .ui-nav-rail-pill { transition: none; } }`// What a badge says: "" for a dot, a count, 99+, or null for none.fn nav_rail_badge(n) -> str? { if n == true { return "" } if type_of(n) != "int" && type_of(n) != "float" { return null } if n <= 0 { return null } n > 99 ? "99+" : str(round(n))}// How many of the places a bar shows. Up to five fit; any more, or a menu of its own,// and the last place is More.fn nav_rail_fits(count: int, has_menu: bool) -> int = count <= 5 && !has_menu ? count : (count < 4 ? count : 4)fn nav_rail_same(a, b) -> bool = a != null && b != null && str(a) == str(b)fn nav_rail_step(key: str, at: int, count: int) -> int? { match key { "ArrowDown" | "ArrowRight" => at + 1 < count ? at + 1 : at "ArrowUp" | "ArrowLeft" => at > 0 ? at - 1 : 0 "Home" => 0 "End" => count > 0 ? count - 1 : 0 _ => null }}fn nav_rail_closes(moved: num, speed: num, width: num) -> bool = moved > width * 0.4 || (speed > 0.5 && moved > 24)view NavRailGlyph(it, wide: bool) { let icon = get(it, "icon") let badge = nav_rail_badge(get(it, "badge")) <span.ui-nav-rail-pill 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 ?? "")} {!wide && badge != null && <span class={ui_cls(["ui-nav-rail-badge", badge == "" && "ui-nav-rail-dot"])}>{badge}</span>} </span>}view NavRailWord(it) { let badge = nav_rail_badge(get(it, "badge")) <span.ui-nav-rail-word>{str(get(it, "label", ""))}{badge != null && <span.ui-sr>{badge == "" ? ", something new" : `, ${badge} new`}</span>}</span>}component NavRail(items: list = [], current = null, label: str = "Main", menu: list = [], menu_title: str = "Menu", action = null, on_select = null, on_action = null, class: str? = null, children) { style global nav_rail_css state here = current state shape = "" state dlg = null state pull = null // a swipe on the menu: { x, y, last_x, last_at, speed, on, off } let has_menu = len(menu) > 0 let fits = nav_rail_fits(len(items), has_menu) let more = has_menu || fits < len(items) let rest = slice(items, fits, len(items)) + menu let in_more = any(rest, (it) => nav_rail_same(get(it, "id"), here)) let press = (it) => { here = it.id if dlg != null && dlg.open { dlg.close() } on_select && on_select(it) } let along = (e) => { let rows = e.target.closest(".ui-nav-rail-places").querySelectorAll(".ui-nav-rail-item") let on = range(rows.length) | filter((n) => rows[n].getClientRects().length > 0) let at = range(len(on)) | filter((n) => rows[on[n]].isSameNode(e.target)) let next = len(at) > 0 ? nav_rail_step(e.key, at[0], len(on)) : null if next != null { e.preventDefault() rows[on[next]].focus() } } let swing = (e) => { if pull != null && !pull.off { let dx = 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 { if !pull.on { e.target.setPointerCapture(e.pointerId) dlg.classList.add("ui-nav-rail-dragging") } dlg.style.transform = `translateX(${-max([0, dx])}px)` } let dt = e.timeStamp - pull.last_at let step = dt > 0 ? (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 } } } let lift = (e) => { if pull != null { let was = pull pull = null if was.on { let run = e.timeStamp - was.last_at < 100 ? was.speed : 0 dlg.classList.remove("ui-nav-rail-dragging") dlg.style.transform = "" if nav_rail_closes(was.x - e.clientX, run, dlg.offsetWidth) { dlg.close() } } } } <div class={ui_cls(["ui-nav-rail", class])} data-shape={shape == "" ? null : shape} @mount={(el) => { let doc = js("document") let look = (_e = null) => { let next = el.clientWidth < 600 ? "bar" : "rail" if next != shape { shape = next // A place that has gone under More cannot keep the focus: More takes it. let held = doc.activeElement if next == "bar" && held != null && held.classList != null && held.classList.contains("ui-nav-rail-extra") { el.querySelector(".ui-nav-rail-more").focus({ preventScroll: true }) } } } let seen = js_new("ResizeObserver", look) seen.observe(el) () => { seen.disconnect() } }}> <div.ui-nav-rail-frame> <nav.ui-nav-rail-side aria-label={label}> {more && <button.ui-nav-rail-menu type="button" aria-label={menu_title} aria-haspopup="dialog" @click={() => { dlg.showModal() }}> <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>} {action != null && <button.ui-nav-rail-action type="button" aria-label={str(get(action, "label", "Action"))} @click={() => { on_action && on_action() }}> {type_of(get(action, "icon")) == "str" && starts_with(action.icon, "M") && len(action.icon) > 2 ? <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d={action.icon}/></svg> : <span aria-hidden="true">{str(get(action, "icon", "+"))}</span>} </button>} <div.ui-nav-rail-places @keydown={along}> {for n in range(len(items)) => get(items[n], "href") != null ? <a class={ui_cls(["ui-nav-rail-item", n >= fits && "ui-nav-rail-extra"])} key={str(get(items[n], "id"))} href={items[n].href} data-id={str(get(items[n], "id"))} aria-current={nav_rail_same(get(items[n], "id"), here) ? "page" : null} @click={() => { press(items[n]) }}><NavRailGlyph it={items[n]} wide={false}/><NavRailWord it={items[n]}/></a> : <button class={ui_cls(["ui-nav-rail-item", n >= fits && "ui-nav-rail-extra"])} key={str(get(items[n], "id"))} type="button" data-id={str(get(items[n], "id"))} aria-current={nav_rail_same(get(items[n], "id"), here) ? "page" : null} @click={() => { press(items[n]) }}><NavRailGlyph it={items[n]} wide={false}/><NavRailWord it={items[n]}/></button>} {more && <button class="ui-nav-rail-item ui-nav-rail-more" type="button" aria-haspopup="dialog" aria-current={in_more ? "true" : null} @click={() => { dlg.showModal() }}> <span.ui-nav-rail-pill aria-hidden="true"> <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> </span> <span.ui-nav-rail-word>More</span> </button>} </div> </nav> <div.ui-nav-rail-page>{children}</div> </div> <dialog.ui-nav-rail-drawer aria-label={menu_title} @mount={(el) => { dlg = el }} @click={(e) => { if e.target.tagName == "DIALOG" { dlg.close() } }} @pointerdown={(e) => { if e.button == 0 && e.target.tagName != "DIALOG" { pull = { x: e.clientX, y: e.clientY, last_x: e.clientX, last_at: e.timeStamp, speed: 0, on: false, off: false } } }} @pointermove={swing} @pointerup={lift} @pointercancel={() => { pull = null dlg.classList.remove("ui-nav-rail-dragging") dlg.style.transform = "" }}> <div.ui-nav-rail-drawer-top> <h2>{menu_title}</h2> <button.ui-nav-rail-menu type="button" aria-label="Close" style="display: grid" @click={() => { dlg.close() }}> <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-nav-rail-drawer-list> {for it in items + menu => get(it, "href") != null ? <a.ui-nav-rail-row key={str(get(it, "id"))} href={it.href} data-row={str(get(it, "id"))} aria-current={nav_rail_same(get(it, "id"), here) ? "page" : null} @click={() => { press(it) }}><NavRailGlyph it={it} wide={true}/><span>{str(get(it, "label", ""))}</span>{nav_rail_badge(get(it, "badge")) != null && <span class={ui_cls(["ui-nav-rail-badge", nav_rail_badge(get(it, "badge")) == "" && "ui-nav-rail-dot"])}>{nav_rail_badge(get(it, "badge"))}</span>}</a> : <button.ui-nav-rail-row key={str(get(it, "id"))} type="button" data-row={str(get(it, "id"))} aria-current={nav_rail_same(get(it, "id"), here) ? "page" : null} @click={() => { press(it) }}><NavRailGlyph it={it} wide={true}/><span>{str(get(it, "label", ""))}</span>{nav_rail_badge(get(it, "badge")) != null && <span class={ui_cls(["ui-nav-rail-badge", nav_rail_badge(get(it, "badge")) == "" && "ui-nav-rail-dot"])}>{nav_rail_badge(get(it, "badge"))}</span>}</button>} </div> </dialog> </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 downloadsNavStackA stack of screens pushed and popped with a slide, each keeping its state and scroll, with a real Back through the browser's history; a column beside the rest on a tablet.2 views · 6 downloads
Thread
Thread 0 posts
On NavRail: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.