Mobile

NavRail

A Material navigation rail for medium widths, with a menu, a floating action and badges, turning into a bottom bar on a phone.

#mobile#tablet#interactive#navigation#rail#bar

Demo

Preview

Inbox

0 drafts started

A rail where there is room for one, and a bar along the bottom where there is not.

Menu

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.qwe
main.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>}
Thread
27Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.