Navigation

Menu

A vertical list of links and actions in titled sections, with icons, shortcuts, counts, disabled items and the active one marked.

#navigation#menu#list#link#no-js

Demo

Preview

Picked: nothing yet

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

Example

The code of the preview above.

demos/menu.qwe
component MenuDemoLive() {    state did = "nothing yet"    <div.demo-stack>        <Menu id="menu-demo-live" label="Actions" bordered={true} size="sm" items={[            { label: "Rename", shortcut: "F2", icon: "M4 20h4L18.5 9.5a2.1 2.1 0 0 0-3-3L5 17z" },            { label: "Duplicate", shortcut: "⌘D", icon: "M8 8h12v12H8zM4 16V4h12" },            { label: "Archive", disabled: true },            { divider: true },            { label: "Delete", shortcut: "⌫", tone: "danger", icon: "M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" }        ]} on_select={(it) => { did = it.label }}/>        <p.demo-note>Picked: {did}</p>    </div>}view MenuDemo() =    <div.demo-row style="align-items: flex-start; gap: 32px">        <div style="width: 240px">            <Menu id="menu-demo" label="Settings" active="#menu" items={[                { section: "Account" },                { label: "Profile", href: "#profile", icon: "M16 19v-1a4 4 0 0 0-8 0v1M12 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6" },                { label: "Notifications", href: "#menu", icon: "M6 16V11a6 6 0 0 1 12 0v5l2 2H4zM10 21h4", badge: 3 },                { label: "Billing", href: "#billing", icon: "M3 7h18v10H3zM3 11h18" },                { section: "Workspace" },                { label: "Members", href: "#members", icon: "👥", badge: 12 },                { label: "Integrations", href: "#integrations", icon: "🔌", disabled: true },                { divider: true },                { label: "Sign out", href: "#signout", tone: "danger", icon: "M10 17l5-5-5-5M15 12H3M21 4v16" }            ]}/>        </div>        <div style="width: 240px"><MenuDemoLive/></div>    </div>

Source

ui/menu.qwe
// Menu: a vertical list of links and actions, in titled sections, each with an icon,// a shortcut or a count where it has one; items can be disabled, and the active one is// marked. For a settings page's side list, an account menu, a list of commands.////   <Menu label="Settings" active="/settings/profile" items={[//       { section: "Account" },//       { label: "Profile", href: "/settings/profile", icon: "👤" },//       { label: "Billing", href: "/settings/billing", icon: "M3 7h18v10H3zM3 11h18", badge: 2 },//       { divider: true },//       { label: "Sign out", shortcut: "⇧⌘Q", tone: "danger" }//   ]}/>//// items: links { label, href, icon?, shortcut?, badge?, disabled?, tone?: "danger" }; an// item without an href is a button that, inside one of your components, calls// on_select(item). { section: "Title" } starts a titled section of the items after it;// { divider: true } draws a line. icon: a character or an emoji, or an SVG path (`d`,// 24x24, stroked) when it starts with "M". shortcut: shown at the right ("⌘S"), and// told to screen readers as aria-keyshortcuts. badge: a count or a short text.// active: the href (or, for a button, the label) of the item marked current.// label: what screen readers call it ("Menu"). size: "sm" or "md" (default).// bordered: a box around it. on_select(item): see items.import ui/theme.qwe use ui_clslet menu_css = css`.ui-menu { display: grid; gap: 4px; min-width: 0; padding: 4px; font: 14px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-menu-bordered { border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); }.ui-menu ul { display: grid; gap: 1px; margin: 0; padding: 0; list-style: none; }.ui-menu-section + .ui-menu-section { margin-top: 6px; }.ui-menu-title {  margin: 0; padding: 6px 10px 4px; color: var(--ui-muted); font-size: 12px; font-weight: 600;  letter-spacing: .05em; text-transform: uppercase;}.ui-menu-item {  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-fg); font: inherit; font-weight: 500;  text-align: left; text-decoration: none; cursor: pointer; transition: background .12s, color .12s;}.ui-menu-item:hover { background: var(--ui-subtle); }.ui-menu-item:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-menu-item[aria-current] { color: var(--ui-accent); background: var(--ui-accent-soft); }.ui-menu-item[aria-disabled="true"], .ui-menu-item:disabled { opacity: .5; cursor: not-allowed; pointer-events: none; }.ui-menu-danger { color: var(--ui-danger); }.ui-menu-danger:hover { background: var(--ui-danger-soft); }.ui-menu-icon { display: inline-grid; place-items: center; flex: none; width: 20px; height: 20px; font-size: 16px; line-height: 1; }.ui-menu-icon svg { width: 18px; height: 18px; }.ui-menu-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.ui-menu-key { flex: none; color: var(--ui-muted); font-size: 12px; letter-spacing: .02em; white-space: nowrap; }.ui-menu-item[aria-current] .ui-menu-key { color: inherit; opacity: .8; }.ui-menu-badge {  flex: none; min-width: 20px; padding: 1px 7px; border-radius: 999px; border: 1px solid var(--ui-border);  background: var(--ui-surface); color: var(--ui-fg); font-size: 12px; font-weight: 600; text-align: center;  font-variant-numeric: tabular-nums;}.ui-menu-item[aria-current] .ui-menu-badge { border-color: transparent; background: var(--ui-accent); color: var(--ui-accent-fg); }.ui-menu-sep { height: 1px; margin: 5px 6px; background: var(--ui-border); }.ui-menu-sm .ui-menu-item { min-height: 30px; padding: 4px 8px; font-size: 13px; }.ui-menu-sm .ui-menu-title { font-size: 11px; }@media (prefers-reduced-motion: reduce) { .ui-menu-item { transition: none; } }`// The items cut into sections at each { section }: [{ title, items }].fn menu_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) }))fn menu_icon(icon: str?) = icon != null && <span.ui-menu-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 menu_body(it) = [    menu_icon(get(it, "icon")),    <span.ui-menu-text>{it.label}</span>,    get(it, "badge") != null && <span.ui-menu-badge>{it.badge}</span>,    get(it, "shortcut") != null && <span.ui-menu-key aria-hidden="true">{it.shortcut}</span>,]fn menu_item(it, active: str?, on_select) {    let cls = ui_cls(["ui-menu-item", get(it, "tone") == "danger" && "ui-menu-danger"])    let off = get(it, "disabled") == true    let keys = get(it, "shortcut") != null ? replace(it.shortcut, " ", "+") : null    get(it, "href") != null        ? <a class={cls} href={it.href} aria-current={active != null && it.href == active ? "page" : null}             aria-disabled={off ? "true" : null} tabindex={off ? "-1" : null} aria-keyshortcuts={keys}>{menu_body(it)}</a>        : <button class={cls} type="button" disabled={off} aria-keyshortcuts={keys}                  aria-current={active != null && it.label == active ? "true" : null}                  @click={() => { on_select && on_select(it) }}>{menu_body(it)}</button>}view Menu(items: list = [], active: str? = null, label: str = "Menu", size: str = "md", bordered: bool = false,          id: str? = null, on_select = null, class: str? = null) {    style global menu_css    <nav class={ui_cls(["ui-menu", size == "sm" && "ui-menu-sm", bordered && "ui-menu-bordered", class])} id={id}         aria-label={label}>        {for [i, s] in enumerate(menu_sections(items)) => <div.ui-menu-section>            {s.title != null && <p.ui-menu-title id={id != null ? `${id}-s${i}` : null}>{s.title}</p>}            <ul aria-label={s.title}>                {for it in s.items => get(it, "divider") == true                    ? <li.ui-menu-sep role="separator"></li>                    : <li>{menu_item(it, active, on_select)}</li>}            </ul>        </div>}    </nav>}
Thread
66Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.