Navigation

MobileNav

A menu button that opens the site's navigation over the whole screen, as a dialog: groups, the current page, actions at the bottom.

#navigation#mobile#menu#dialog#interactive

Demo

Preview
Click the menu button: the navigation covers the screen.

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

Example

The code of the preview above.

demos/mobile_nav.qwe
component MobileNavDemoLive() {    state shown = false    <div.demo-row>        <MobileNav id="mobile-nav-demo" brand="Acme" wide={true} current="#mobile_nav" on_toggle={(o) => { shown = o }} items={[            { label: "Home", href: "#home", icon: "M3 11 12 3l9 8M5 9.5V21h14V9.5" },            { label: "Products", icon: "M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z", items: [                { label: "Editor", href: "#editor" }, { label: "Components", href: "#mobile_nav" }, { label: "Hosting", href: "#hosting" }            ] },            { label: "Resources", icon: "📚", items: [{ label: "Docs", href: "#docs" }, { label: "Blog", href: "#blog" }] },            { label: "Pricing", href: "#pricing", icon: "🏷" },            { label: "Inbox", href: "#inbox", icon: "✉", badge: 4 }        ]}>            <Button full={true} variant="outline" href="#mobile_nav">Sign in</Button>            <Button full={true} href="#mobile_nav">Start free</Button>        </MobileNav>        <span.demo-note>{shown ? "The menu is open over the page." : "Click the menu button: the navigation covers the screen."}</span>    </div>}view MobileNavDemo() =    <div.demo-stack>        <MobileNavDemoLive/>    </div>

Source

ui/mobile_nav.qwe
// MobileNav: a menu button (three lines) that opens the site's navigation over the// whole screen, for phones: the links one under another, groups that open, the// current page marked, and room at the bottom for a sign-in button. It is the// browser's own <dialog>, so the focus stays in it, Escape closes it, and screen// readers announce it. Hidden on wide screens unless asked to stay.////   <MobileNav brand="Acme" current={request.path} items={[//       { label: "Docs", href: "/docs" },//       { label: "Products", items: [{ label: "Editor", href: "/editor" }, { label: "Hosting", href: "/hosting" }] },//       { label: "Pricing", href: "/pricing" }//   ]}>//       <Button full={true} href="/login">Sign in</Button>//   </MobileNav>//// items: links { label, href, icon?, badge? } and groups { label, items: [...] }, open// when one of theirs is current. icon: a character or an emoji, or an SVG path// (24x24, stroked) when it starts with "M". current: the href of the page showing.// brand: the name at the top of the menu. children: what goes at the bottom.// wide: keep the button on wide screens too (it hides past 760px by default, where// a Navbar's links show). label: the button's name and the menu's ("Menu").// id: the dialog's id ("ui-mobilenav"); give each on a page its own.// on_toggle(open): inside one of your components, called with true when it opens.import ui/theme.qwe use ui_clslet mobile_nav_css = css`.ui-mobilenav-foot:empty { display: none; }.ui-mobilenav-host { display: contents; }.ui-mobilenav-btn {  display: inline-grid; place-items: center; width: 40px; height: 40px; padding: 0; border: 0; border-radius: var(--ui-radius-sm);  background: transparent; color: var(--ui-fg); cursor: pointer;}.ui-mobilenav-btn svg { width: 22px; height: 22px; }.ui-mobilenav-btn:hover { background: var(--ui-subtle); }.ui-mobilenav-btn:focus-visible { outline: none; box-shadow: var(--ui-focus); }@media (min-width: 760px) { .ui-mobilenav-host:not(.ui-mobilenav-wide) .ui-mobilenav-btn { display: none; } }.ui-mobilenav {  position: fixed; inset: 0; width: 100vw; height: 100dvh; max-width: none; max-height: none; margin: 0; padding: 0; border: 0;  background: var(--ui-bg); color: var(--ui-fg); font: 16px/1.5 var(--ui-font); overscroll-behavior: contain;}.ui-mobilenav::backdrop { background: transparent; }.ui-mobilenav[open] { display: flex; flex-direction: column; animation: ui-mobilenav-in .2s ease-out; }@keyframes ui-mobilenav-in { from { opacity: 0; transform: translateY(-8px); } }.ui-mobilenav-head { display: flex; align-items: center; justify-content: space-between; flex: none; height: 60px; padding: 0 12px 0 20px; border-bottom: 1px solid var(--ui-border); }.ui-mobilenav-brand { font-weight: 650; font-size: 17px; }.ui-mobilenav-body { flex: 1; overflow: auto; padding: 12px 12px 20px; }.ui-mobilenav ul { display: grid; gap: 2px; margin: 0; padding: 0; list-style: none; }.ui-mobilenav ul ul { margin: 2px 0 8px 12px; padding-left: 12px; border-left: 1px solid var(--ui-border); }.ui-mobilenav-link {  display: flex; align-items: center; gap: 12px; width: 100%; min-height: 48px; padding: 10px 12px; border: 0; border-radius: var(--ui-radius-sm);  background: transparent; color: var(--ui-fg); font: 500 17px/1.3 var(--ui-font); text-align: left; text-decoration: none; cursor: pointer; list-style: none;}.ui-mobilenav-link::-webkit-details-marker { display: none; }.ui-mobilenav-link:hover { background: var(--ui-subtle); }.ui-mobilenav-link:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-mobilenav-link[aria-current="page"] { color: var(--ui-accent); background: var(--ui-accent-soft); }.ui-mobilenav-icon { display: inline-grid; place-items: center; flex: none; width: 24px; height: 24px; font-size: 20px; line-height: 1; }.ui-mobilenav-icon svg { width: 22px; height: 22px; }.ui-mobilenav-text { flex: 1; min-width: 0; }.ui-mobilenav-badge { min-width: 22px; padding: 1px 8px; border-radius: 999px; background: var(--ui-accent); color: var(--ui-accent-fg); font-size: 13px; font-weight: 600; text-align: center; }.ui-mobilenav-chev { flex: none; width: 18px; height: 18px; color: var(--ui-muted); transition: transform .15s; }details[open] > .ui-mobilenav-link .ui-mobilenav-chev { transform: rotate(180deg); }.ui-mobilenav-foot { flex: none; display: grid; gap: 8px; padding: 12px 16px max(16px, env(safe-area-inset-bottom)); border-top: 1px solid var(--ui-border); }@media (prefers-reduced-motion: reduce) { .ui-mobilenav[open] { animation: none; } .ui-mobilenav-chev { transition: none; } }`fn mobile_nav_holds(it, current: str?) -> bool =    current != null && (get(it, "href") == current || any(get(it, "items") ?? [], (c) => mobile_nav_holds(c, current)))fn mobile_nav_icon(icon: str?) = icon != null && <span.ui-mobilenav-icon aria-hidden="true">{    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>        : icon}</span>fn mobile_nav_entry(it, current: str?) = get(it, "items") != null    ? <li><details open={mobile_nav_holds(it, current)}>        <summary.ui-mobilenav-link>            {mobile_nav_icon(get(it, "icon"))}            <span.ui-mobilenav-text>{it.label}</span>            <svg.ui-mobilenav-chev viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"                 stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>        </summary>        <ul>{for c in it.items => mobile_nav_entry(c, current)}</ul>      </details></li>    : <li><a.ui-mobilenav-link href={it.href} aria-current={current != null && it.href == current ? "page" : null}>        {mobile_nav_icon(get(it, "icon"))}        <span.ui-mobilenav-text>{it.label}</span>        {get(it, "badge") != null && <span.ui-mobilenav-badge>{it.badge}</span>}      </a></li>component MobileNav(items: list = [], current: str? = null, brand: str = "", wide: bool = false, label: str = "Menu",                    id: str = "ui-mobilenav", on_toggle = null, class: str? = null, children) {    style global mobile_nav_css    state dlg = null    state open = false    let show = () => {        if !dlg.open { dlg.showModal() }        open = true        on_toggle && on_toggle(true)    }    <div class={ui_cls(["ui-mobilenav-host", wide && "ui-mobilenav-wide", class])}>        <button.ui-mobilenav-btn type="button" aria-label={label} aria-haspopup="dialog" aria-controls={id}                aria-expanded={open ? "true" : "false"} @click={show}>            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">                <path d="M4 6h16M4 12h16M4 18h16"/></svg>        </button>        <dialog.ui-mobilenav id={id} aria-label={label} @mount={(el) => { dlg = el }}                @close={() => {                    open = false                    on_toggle && on_toggle(false)                }}                @click={(e) => { if e.target.closest("a[href]") != null { dlg.close() } }}>            <div.ui-mobilenav-head>                <span.ui-mobilenav-brand>{brand != "" ? brand : label}</span>                <button.ui-mobilenav-btn type="button" aria-label="Close menu" @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>            <nav.ui-mobilenav-body aria-label={label}>                <ul>{for it in items => mobile_nav_entry(it, current)}</ul>            </nav>            <div.ui-mobilenav-foot>{children}</div>        </dialog>    </div>}
Thread
56Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.