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.
Demo
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.qwemain.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>}Related
BottomNavA fixed bar of icon tabs along the bottom of a phone's screen, with counts and the current one marked.6 views · 7 downloadsCollapsibleNavSide navigation in groups that fold under their titles, the browser remembering which are open.6 views · 7 downloadsLanguageSwitcherA button showing the page's language that opens the others, each in its own name, as links to the same page in it.5 views · 6 downloadsMegaMenuA top bar whose items open wide panels of grouped links under the whole bar, on hover and from the keyboard, with no script.5 views · 7 downloadsMenuA vertical list of links and actions in titled sections, with icons, shortcuts, counts, disabled items and the active one marked.6 views · 6 downloadsScrollTabsA row of tabs that scrolls sideways when there are more than fit, with arrows at the ends and the keyboard.5 views · 8 downloads
Thread
Thread 0 posts
On MobileNav: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.