Navigation

CollapsibleNav

Side navigation in groups that fold under their titles, the browser remembering which are open.

#navigation#menu#link#document#interactive

Demo

Preview

Fold a group: the browser remembers it (reload the page to see).

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

Example

The code of the preview above.

demos/collapsible_nav.qwe
let collapsible_nav_demo_groups = [    { label: "Overview", href: "#overview" },    { title: "Getting started", items: [        { label: "Install", href: "#install" }, { label: "Your first program", href: "#collapsible_nav" }, { label: "Editors", href: "#editors" }    ] },    { title: "Language", items: [        { label: "Values", href: "#values" }, { label: "Functions", href: "#functions" }, { label: "Views", href: "#views" }, { label: "Components", href: "#components", badge: 4 }    ] },    { title: "Deploying", open: false, items: [        { label: "One server", href: "#server" }, { label: "Docker", href: "#docker" }    ] }]component CollapsibleNavDemoLive() {    state last = null    <div.demo-row style="align-items: flex-start; gap: 24px">        <div style="width: 240px">            <CollapsibleNav id="collapsible-nav-demo" groups={collapsible_nav_demo_groups} current="#collapsible_nav"                            on_toggle={(t, o) => { last = `${t} ${o ? "opened" : "closed"}` }}/>        </div>        <p.demo-note style="flex: 1 1 200px">{last ?? "Fold a group: the browser remembers it (reload the page to see)."}</p>    </div>}view CollapsibleNavDemo() =    <div.demo-stack>        <CollapsibleNavDemoLive/>    </div>

Source

ui/collapsible_nav.qwe
// CollapsibleNav: navigation down the side of docs or an app, its links in groups// that fold and unfold under their titles (the browser's own <details>), and which// groups are open is remembered in the browser (localStorage), so a page later shows// them as they were left. The group holding the current page is always open, and// the page is marked.////   <CollapsibleNav id="docs" current={request.path} groups={[//       { title: "Getting started", items: [{ label: "Install", href: "/docs/install" }, { label: "Hello", href: "/docs/hello" }] },//       { title: "Guides", open: false, items: [{ label: "Forms", href: "/docs/forms" }] }//   ]}/>//// groups: { title, items: [{ label, href, badge? }], open? } (open: whether it starts// open the first time, true by default); and { label, href } alone for a link outside// any group. current: the href of the page showing (aria-current="page").// id (required): names the navigation, and what the browser remembers it under.// remember: keep the open groups in localStorage (true). label: what screen readers// call it ("Navigation"). on_toggle(title, open): inside one of your components,// called when a group folds or unfolds.import ui/theme.qwe use ui_clslet collapsible_nav_css = css`.ui-colnav { display: grid; gap: 4px; min-width: 0; padding: 4px; font: 14px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-colnav ul { display: grid; gap: 1px; margin: 0; padding: 0; list-style: none; }.ui-colnav-group { margin: 0; }.ui-colnav-group + .ui-colnav-group { margin-top: 4px; }.ui-colnav-title {  display: flex; align-items: center; gap: 8px; padding: 7px 10px; border-radius: var(--ui-radius-sm); color: var(--ui-fg);  font-weight: 600; cursor: pointer; list-style: none; user-select: none;}.ui-colnav-title::-webkit-details-marker { display: none; }.ui-colnav-title:hover { background: var(--ui-subtle); }.ui-colnav-title:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-colnav-chev { flex: none; width: 14px; height: 14px; color: var(--ui-muted); transition: transform .15s; }.ui-colnav-group[open] > .ui-colnav-title .ui-colnav-chev { transform: rotate(90deg); }.ui-colnav-title span { flex: 1; }.ui-colnav-group > ul { margin: 2px 0 6px 16px; padding-left: 8px; border-left: 1px solid var(--ui-border); }.ui-colnav-link {  display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-radius: var(--ui-radius-sm); color: var(--ui-muted);  text-decoration: none; transition: color .12s, background .12s;}.ui-colnav-link:hover { color: var(--ui-fg); background: var(--ui-subtle); }.ui-colnav-link:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-colnav-link[aria-current="page"] { color: var(--ui-accent); background: var(--ui-accent-soft); font-weight: 500; }.ui-colnav-link span:first-child { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.ui-colnav-badge {  min-width: 20px; padding: 1px 6px; border-radius: 999px; background: var(--ui-subtle); border: 1px solid var(--ui-border);  color: var(--ui-muted); font-size: 12px; font-weight: 600; text-align: center;}.ui-colnav-top > .ui-colnav-link { font-weight: 600; color: var(--ui-fg); }@media (prefers-reduced-motion: reduce) { .ui-colnav-chev, .ui-colnav-link { transition: none; } }`fn collapsible_nav_holds(g, current: str?) -> bool =    current != null && any(get(g, "items") ?? [], (it) => it.href == current)component CollapsibleNav(id: str, groups: list = [], current: str? = null, remember: bool = true, label: str = "Navigation",                         on_toggle = null, class: str? = null) {    style global collapsible_nav_css    // The titles of the groups the browser remembers as open or closed: { title: bool }.    state saved = {}    let is_open = (g) => collapsible_nav_holds(g, current) || (has(saved, g.title) ? saved[g.title] : get(g, "open") != false)    <nav class={ui_cls(["ui-colnav", class])} id={id} aria-label={label}         @mount={(el) => {             if remember {                 let text = js("localStorage.getItem", `ui-colnav-${id}`)                 if text != null { saved = json.parse(text) ?? {} }             }         }}>        {for g in groups => get(g, "items") == null            ? <li.ui-colnav-top key={g.label} style="list-style: none"><a.ui-colnav-link href={g.href}                  aria-current={current != null && g.href == current ? "page" : null}><span>{g.label}</span></a></li>            : <details.ui-colnav-group key={g.title} open={is_open(g)}                       @toggle={(e) => {                           let now = e.target.open                           if has(saved, g.title) ? saved[g.title] != now : now != is_open(g) {                               saved = set(saved, g.title, now)                               if remember { js("localStorage.setItem", `ui-colnav-${id}`, json.stringify(saved)) }                               on_toggle && on_toggle(g.title, now)                           }                       }}>                <summary.ui-colnav-title>                    <svg.ui-colnav-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>                    <span>{g.title}</span>                </summary>                <ul>                    {for it in g.items => <li><a.ui-colnav-link href={it.href} aria-current={current != null && it.href == current ? "page" : null}>                        <span>{it.label}</span>                        {get(it, "badge") != null && <span.ui-colnav-badge>{it.badge}</span>}                    </a></li>}                </ul>              </details>}    </nav>}
Thread
67Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.