SideNav
An app's navigation down the side: sections, groups that open, icons, counts; it folds to its icons.
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/side_nav.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/side_nav.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/side_nav.qwe
let side_nav_demo_items = [ { label: "Home", href: "#home", icon: "M3 11 12 3l9 8M5 9.5V21h14V9.5" }, { label: "Inbox", href: "#side_nav", icon: "M4 4h16v16H4zM4 13h5l1.5 2h3L15 13h5", badge: 12 }, { label: "Projects", icon: "M3 6h7l2 2h9v11H3z", items: [ { label: "Website", href: "#website", badge: "new" }, { label: "Mobile app", items: [ { label: "iOS", href: "#ios" }, { label: "Android", href: "#android" } ] }, { label: "Archive", href: "#archive" } ] }, { label: "Calendar", href: "#calendar", icon: "M4 5h16v15H4zM4 10h16M9 3v4M15 3v4" }, { section: "Workspace" }, { label: "Members", href: "#members", icon: "M16 19v-1a4 4 0 0 0-8 0v1M12 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6", badge: 8 }, { label: "Settings", icon: "M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM4 12h2M18 12h2M12 4v2M12 18v2", items: [ { label: "General", href: "#general" }, { label: "Billing", href: "#billing" } ] }, { label: "Help", href: "#help" }]component SideNavDemoLive() { state slim = false <div.demo-stack> <div style="display: flex; height: 380px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); overflow: hidden"> <SideNav id="side-nav-demo" items={side_nav_demo_items} current="#side_nav" on_toggle={(c) => { slim = c }}/> <div style="flex: 1; min-width: 0; padding: 16px 20px; overflow: hidden"> <h4 style="margin: 0 0 6px">Inbox</h4> <p.demo-note>{slim ? "The sidebar shows its icons only." : "The page, beside the sidebar."}</p> </div> </div> </div>}view SideNavDemo() = <div.demo-stack> <SideNavDemoLive/> <div style="height: 360px; display: flex; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); overflow: hidden"> <SideNav id="side-nav-demo-slim" items={side_nav_demo_items} current="#ios" collapsed={true} label="Folded"/> <p.demo-note style="padding: 16px">Started folded: the icons only, each named by its tooltip.</p> </div> </div>Source
ui/side_nav.qwe
// SideNav: an app's navigation down the side: links in sections, groups that open// to show more links (as many levels deep as needed), icons, counts, the current// page marked (aria-current="page") and its groups open. A button folds it to its// icons only, to give the page more room; then each link's name is its tooltip and// still what screen readers say, and a group's icon unfolds it again.//// <SideNav current={request.path} items={[// { label: "Inbox", href: "/inbox", icon: "✉", badge: 12 },// { label: "Projects", icon: "▤", items: [// { label: "Website", href: "/projects/website" },// { label: "Mobile app", href: "/projects/app" }// ] },// { section: "Settings" },// { label: "Billing", href: "/billing", icon: "M3 7h18v10H3zM3 11h18" }// ]}/>//// items: links { label, href, icon?, badge? }; groups { label, icon?, badge?, open?,// items: [...] }, open when one of theirs is current or `open: true`; and// { section: "Title" }, which starts a section of the links after it under that title.// icon: a character or an emoji, or an SVG path (`d`, 24x24, stroked) when it starts// with "M"; folded, a link without one shows its first letter. badge: a count or a// short text after the label. current: the href of the page showing.// label: what screen readers call the navigation ("Main"). collapsible: show the// fold button (true). collapsed: start folded. id: the nav's id ("ui-sidenav").// on_toggle(collapsed): inside one of your components, given true when it folds.import ui/theme.qwe use ui_clslet side_nav_css = css`.ui-sidenav { display: flex; flex-direction: column; gap: 2px; width: min(248px, 100%); height: 100%; min-height: 0; padding: 10px 8px; overflow: auto; background: var(--ui-subtle); color: var(--ui-fg); font: 14px/1.4 var(--ui-font); transition: width .18s ease-out;}.ui-sidenav-folded { width: 60px; overflow-x: hidden; }.ui-sidenav ul { display: grid; gap: 1px; margin: 0; padding: 0; list-style: none; }.ui-sidenav ul ul { margin: 1px 0 2px 19px; padding-left: 8px; border-left: 1px solid var(--ui-border); }.ui-sidenav-section + .ui-sidenav-section { margin-top: 12px; }.ui-sidenav-title { display: block; padding: 4px 10px 6px; color: var(--ui-muted); font-size: 12px; font-weight: 600; letter-spacing: .05em; text-transform: uppercase; white-space: nowrap;}.ui-sidenav-link { display: flex; align-items: center; gap: 10px; width: 100%; min-height: 34px; padding: 6px 10px; border: 0; border-radius: var(--ui-radius-sm); background: transparent; color: var(--ui-muted); font: 500 14px/1.3 var(--ui-font); text-align: left; text-decoration: none; cursor: pointer; list-style: none;}.ui-sidenav-link::-webkit-details-marker { display: none; }.ui-sidenav-link:hover { color: var(--ui-fg); background: color-mix(in srgb, var(--ui-fg) 6%, transparent); }.ui-sidenav-link:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-sidenav-link[aria-current="page"] { color: var(--ui-accent); background: var(--ui-accent-soft); }.ui-sidenav-holds > .ui-sidenav-link { color: var(--ui-fg); }.ui-sidenav-icon { position: relative; display: inline-grid; place-items: center; flex: none; width: 20px; height: 20px; font-size: 16px; line-height: 1; }.ui-sidenav-icon svg { width: 18px; height: 18px; }.ui-sidenav-initial { display: none; font-size: 13px; font-weight: 650; }.ui-sidenav-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.ui-sidenav-badge { flex: none; min-width: 20px; padding: 1px 7px; border-radius: 999px; background: var(--ui-surface); border: 1px solid var(--ui-border); color: var(--ui-fg); font-size: 12px; font-weight: 600; text-align: center; font-variant-numeric: tabular-nums;}.ui-sidenav-link[aria-current="page"] .ui-sidenav-badge { border-color: transparent; background: var(--ui-accent); color: var(--ui-accent-fg); }.ui-sidenav-chev { flex: none; width: 14px; height: 14px; transition: transform .15s; }details[open] > .ui-sidenav-link .ui-sidenav-chev { transform: rotate(90deg); }.ui-sidenav-foot { margin-top: auto; padding-top: 8px; }.ui-sidenav-fold svg { transition: transform .18s; }.ui-sidenav-folded .ui-sidenav-fold svg { transform: rotate(180deg); }.ui-sidenav-folded .ui-sidenav-link { justify-content: center; gap: 0; padding: 6px 0; }.ui-sidenav-folded :is(.ui-sidenav-text, .ui-sidenav-title, .ui-sidenav-chev) { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap;}.ui-sidenav-folded .ui-sidenav-section + .ui-sidenav-section { margin-top: 6px; padding-top: 6px; border-top: 1px solid var(--ui-border); }.ui-sidenav-folded ul ul { display: none; }.ui-sidenav-folded .ui-sidenav-initial { display: inline; }.ui-sidenav-folded .ui-sidenav-badge { position: absolute; top: 2px; left: 50%; min-width: 16px; margin-left: 4px; padding: 0 4px; font-size: 10px; line-height: 14px;}.ui-sidenav-folded li { position: relative; }@media (prefers-reduced-motion: reduce) { .ui-sidenav, .ui-sidenav-chev, .ui-sidenav-fold svg { transition: none; } }`// The items, cut into sections at each { section }.fn side_nav_sections(items: list) -> list = reduce(items, [], (acc, it) => get(it, "section") != null ? acc | push({ title: it.section, items: [] }) : len(acc) == 0 ? [{ title: null, items: [it] }] : slice(acc, 0, len(acc) - 1) | push({ title: acc[-1].title, items: acc[-1].items | push(it) }))// Whether the item is the current page, or holds it.fn side_nav_holds(it, current: str?) -> bool = current != null && (get(it, "href") == current || any(get(it, "items") ?? [], (c) => side_nav_holds(c, current)))fn side_nav_icon(icon: str?, label: str) = <span.ui-sidenav-icon aria-hidden="true">{icon == null ? <span.ui-sidenav-initial>{upper(slice(label, 0, 1))}</span> : 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 side_nav_badge(it) = get(it, "badge") != null && <span.ui-sidenav-badge>{get(it, "badge")}</span>// One link or group, and the groups inside it. `tips`: the links have their names// as tooltips (folded). on_group(e): a group's summary was clicked.fn side_nav_entry(it, depth: int, current: str?, tips: bool, on_group) = get(it, "items") != null ? <li><details class={side_nav_holds(it, current) ? "ui-sidenav-holds" : null} open={get(it, "open") == true || side_nav_holds(it, current)}> <summary.ui-sidenav-link data-label={it.label} title={tips ? it.label : null} @click={(e) => { on_group(e) }}> {side_nav_icon(get(it, "icon"), it.label)} <span.ui-sidenav-text>{it.label}</span> {side_nav_badge(it)} <svg.ui-sidenav-chev viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 6 6 6-6 6"/></svg> </summary> <ul>{for c in it.items => side_nav_entry(c, depth + 1, current, tips, on_group)}</ul> </details></li> : <li><a.ui-sidenav-link href={it.href} data-label={it.label} title={tips ? it.label : null} aria-current={current != null && it.href == current ? "page" : null}> {depth == 0 || get(it, "icon") != null ? side_nav_icon(get(it, "icon"), it.label) : null} <span.ui-sidenav-text>{it.label}</span> {side_nav_badge(it)} </a></li>component SideNav(items: list = [], current: str? = null, label: str = "Main", collapsible: bool = true, collapsed: bool = false, id: str = "ui-sidenav", on_toggle = null, class: str? = null) { style global side_nav_css state folded = collapsed state nav = null let fold = (to: bool) => { folded = to // Folded, each link's name is its tooltip. (Set here, so the links are not made again.) for a in js("Array.from", nav.querySelectorAll("[data-label]")) => { a.title = to ? a.dataset.label : "" } on_toggle && on_toggle(to) } // Folded, a group's icon unfolds the sidebar with the group open. let on_group = (e) => { if folded { e.preventDefault() e.target.closest("details").open = true fold(false) } } <nav class={ui_cls(["ui-sidenav", folded && "ui-sidenav-folded", class])} id={id} aria-label={label} @mount={(el) => { nav = el }}> {for [i, s] in enumerate(side_nav_sections(items)) => <div.ui-sidenav-section> {s.title != null && <span.ui-sidenav-title id={`${id}-s${i}`}>{s.title}</span>} <ul aria-labelledby={s.title != null ? `${id}-s${i}` : null}> {for it in s.items => side_nav_entry(it, 0, current, collapsed, on_group)} </ul> </div>} {collapsible && <div.ui-sidenav-foot> <button.ui-sidenav-link.ui-sidenav-fold type="button" aria-controls={id} title={folded ? "Expand sidebar" : null} @click={() => { fold(!folded) }}> <span.ui-sidenav-icon aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 6-6 6 6 6"/></svg></span> <span.ui-sidenav-text>{folded ? "Expand sidebar" : "Collapse sidebar"}</span> </button> </div>} </nav>}Related
CollapsibleNavSide 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 downloadsMobileNavA menu button that opens the site's navigation over the whole screen, as a dialog: groups, the current page, actions at the bottom.5 views · 6 downloadsUserMenuAn avatar button that opens the account menu: name and email, links, and sign out; the arrow keys move through it.5 views · 6 downloads
Thread
Thread 0 posts
On SideNav: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.