Navigation

SideNav

An app's navigation down the side: sections, groups that open, icons, counts; it folds to its icons.

#navigation#menu#no-js

Demo

Preview

Inbox

The page, beside the sidebar.

Started folded: the icons only, each named by its tooltip.

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

Thread 0 posts

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

No posts yet. The first one opens the thread.