Layout

AppShell

An app's frame: a sidebar of links, a top bar and the page; the sidebar is a drawer on phones.

#layout#page#navigation#no-js

Demo

Preview
Acme
Overview

Overview

The page goes here. The sidebar stays put as it scrolls.

Paragraph 1 of the page, to give it some length.

Paragraph 2 of the page, to give it some length.

Paragraph 3 of the page, to give it some length.

Paragraph 4 of the page, to give it some length.

Paragraph 5 of the page, to give it some length.

When it is narrow, the sidebar becomes a drawer, opened by the menu button:

Acme
Overview

Overview

The page goes here. The sidebar stays put as it scrolls.

Paragraph 1 of the page, to give it some length.

Paragraph 2 of the page, to give it some length.

Paragraph 3 of the page, to give it some length.

Paragraph 4 of the page, to give it some length.

Paragraph 5 of the page, to give it some length.

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

Example

The code of the preview above.

demos/app_shell.qwe
let app_shell_demo_nav = [{ label: "Overview", href: "/", icon: "◎" }, { label: "Projects", href: "#app_shell", icon: "▤" },                          { label: "Team", href: "#team", icon: "☺" }, { label: "Billing", href: "#billing", icon: "▭" }]view AppShellDemoPage() =    <div>        <h2 style="margin: 0 0 6px; font-size: 20px">Overview</h2>        <p style="margin: 0; color: var(--ui-muted)">The page goes here. The sidebar stays put as it scrolls.</p>        {for i in range(1, 6) => <p>Paragraph {i} of the page, to give it some length.</p>}    </div>view AppShellDemo() =    <div.demo-stack>        <div style="--ui-shell-height: 340px; height: 340px; overflow: auto; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); transform: translateZ(0)">            <AppShell id="app-shell-demo" brand="Acme" nav={app_shell_demo_nav} current="/"                      header={[<b>Overview</b>, <Button size="sm" variant="outline">Invite</Button>]}>                <AppShellDemoPage/>            </AppShell>        </div>        <p.demo-note>When it is narrow, the sidebar becomes a drawer, opened by the menu button:</p>        <div style="--ui-shell-height: 340px; width: min(380px, 100%); height: 340px; overflow: auto; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); transform: translateZ(0)">            <AppShell id="app-shell-demo-narrow" brand="Acme" nav={app_shell_demo_nav} current="/"                      header={[<b>Overview</b>, <Button size="sm" variant="outline">Invite</Button>]}>                <AppShellDemoPage/>            </AppShell>        </div>    </div>

Source

ui/app_shell.qwe
// AppShell: the frame of an app's pages: a sidebar of links on the left, a bar// across the top and the page in the rest. When it is narrow (under 760px) the// sidebar hides and a menu button opens it as a drawer over the page, with the// browser's `popover`: Escape or a click beside it closes it, and no script runs.////   <AppShell brand="Acme" current={request.path}//             nav={[{ label: "Overview", href: "/", icon: "◎" }, { label: "Billing", href: "/billing" }]}//             header={<Button size="sm" variant="outline">Invite</Button>}>//       <h1>Overview</h1> ...//   </AppShell>//// brand, brand_href ("/"): the app's name at the top of the sidebar.// nav: a list of { label, href, icon? }; icon is text (an emoji) or HTML (an <svg>).// The one whose href is `current` is marked aria-current="page".// sidebar: HTML for the sidebar, after the nav (or instead of it).// header: HTML for the top bar (a title, a search, actions: the first at its left,// the last at its right). children: the page.// id: what the drawer is named by ("ui-shell"); give each shell on a page its own.// It fills the window's height; set --ui-shell-height to make it another.import ui/theme.qwe use ui_clslet app_shell_css = css`.ui-shell { container: ui-shell / inline-size; background: var(--ui-bg); color: var(--ui-fg); font: 15px/1.5 var(--ui-font); }.ui-shell-in { display: grid; grid-template-columns: 240px minmax(0, 1fr); min-height: var(--ui-shell-height, 100vh); }.ui-shell-side {  position: sticky; top: 0; inset-inline: auto; bottom: auto; display: flex; flex-direction: column; gap: 4px;  width: auto; height: var(--ui-shell-height, 100vh); margin: 0; padding: 14px 12px; overflow: auto;  border: 0; border-right: 1px solid var(--ui-border); background: var(--ui-subtle); color: var(--ui-fg);}.ui-shell-brand { display: flex; align-items: center; justify-content: space-between; min-height: 36px; padding: 0 8px 10px; }.ui-shell-brand a { color: var(--ui-fg); font-weight: 650; font-size: 16px; text-decoration: none; border-radius: var(--ui-radius-sm); }.ui-shell-side ul { display: grid; gap: 2px; margin: 0; padding: 0; list-style: none; }.ui-shell-side li a {  display: flex; align-items: center; gap: 10px; padding: 7px 10px; border-radius: var(--ui-radius-sm);  color: var(--ui-muted); font-size: 14px; font-weight: 500; text-decoration: none;}.ui-shell-side li a:hover { color: var(--ui-fg); background: color-mix(in srgb, var(--ui-fg) 6%, transparent); }.ui-shell-side li a[aria-current="page"] { color: var(--ui-accent); background: var(--ui-accent-soft); }.ui-shell-icon { display: inline-grid; place-items: center; width: 18px; flex: none; }.ui-shell-icon svg { width: 18px; height: 18px; }.ui-shell-main { display: flex; flex-direction: column; min-width: 0; }.ui-shell-header {  position: sticky; top: 0; z-index: 5; display: flex; align-items: center; gap: 10px;  min-height: 56px; padding: 0 20px; border-bottom: 1px solid var(--ui-border); background: var(--ui-bg);}.ui-shell-bar { display: flex; flex: 1; align-items: center; justify-content: space-between; gap: 8px; min-width: 0; }.ui-shell-hbrand { display: none; color: var(--ui-fg); font-weight: 650; text-decoration: none; }.ui-shell-content { flex: 1; padding: 24px; }.ui-shell-btn {  display: none; place-items: center; flex: none; width: 36px; height: 36px; padding: 0; border: 0;  border-radius: var(--ui-radius-sm); background: transparent; color: var(--ui-fg); cursor: pointer;}.ui-shell-btn:hover { background: var(--ui-subtle); }.ui-shell a:focus-visible, .ui-shell-btn:focus-visible { outline: none; box-shadow: var(--ui-focus); }@container ui-shell (max-width: 760px) {  .ui-shell-in { grid-template-columns: minmax(0, 1fr); }  .ui-shell-btn, .ui-shell-hbrand { display: inline-grid; }  .ui-shell-header { padding: 0 12px; }  .ui-shell-content { padding: 18px 16px; }  .ui-shell-side:not(:popover-open) { display: none; }  .ui-shell-side:popover-open {    position: fixed; inset: 0 auto 0 0; width: min(290px, 85vw); height: 100%; max-height: none;    box-shadow: var(--ui-shadow-lg); animation: ui-shell-in .2s ease-out;  }  .ui-shell-side::backdrop { background: var(--ui-overlay); }}@keyframes ui-shell-in { from { transform: translateX(-100%); } }@media (prefers-reduced-motion: reduce) { .ui-shell-side:popover-open { animation: none; } }`view AppShell(brand: str = "", brand_href: str = "/", nav: list = [], current: str? = null, sidebar = null,              header = null, id: str = "ui-shell", label: str = "Main", class: str? = null, children) {    style global app_shell_css    let side = `${id}-side`    <div class={ui_cls(["ui-shell", class])}><div.ui-shell-in>        <aside.ui-shell-side id={side} popover="auto">            <div.ui-shell-brand>                {brand != "" && <a href={brand_href}>{brand}</a>}                <button.ui-shell-btn type="button" popovertarget={side} popovertargetaction="hide" aria-label="Close menu">                    <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2"                         stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg>                </button>            </div>            {len(nav) > 0 && <nav aria-label={label}><ul>                {for n in nav => <li><a href={n.href} aria-current={current != null && n.href == current ? "page" : null}>                    {get(n, "icon") != null && <span.ui-shell-icon aria-hidden="true">{get(n, "icon")}</span>}                    <span>{n.label}</span>                </a></li>}            </ul></nav>}            {sidebar}        </aside>        <div.ui-shell-main>            <header.ui-shell-header>                <button.ui-shell-btn type="button" popovertarget={side} aria-label="Open menu">                    <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2"                         stroke-linecap="round" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h16"/></svg>                </button>                {brand != "" && <a.ui-shell-hbrand href={brand_href}>{brand}</a>}                <div.ui-shell-bar>{header}</div>            </header>            <main.ui-shell-content>{children}</main>        </div>    </div></div>}
Thread
107Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.