Navigation

UserMenu

An avatar button that opens the account menu: name and email, links, and sign out; the arrow keys move through it.

#navigation#menu#user#popover#interactive

Demo

Preview
Initials on a colour of their own, or a picture.
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/user_menu.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/user_menu.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/user_menu.qwe
let user_menu_demo_items = [    { label: "Profile", href: "#user_menu", 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: "Settings", href: "#settings", icon: "M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM4 12h2M18 12h2M12 4v2M12 18v2", shortcut: "⌘," },    { divider: true },    { label: "Upgrade to Pro", href: "#billing", icon: "✨", badge: "New" }]component UserMenuDemoLive() {    state did = "nothing yet"    <div.demo-row>        <UserMenu id="user-menu-demo-live" name="Grace Hopper" email="grace@example.com" show_name={true} size="sm"                  items={[{ label: "Switch team" }, { label: "Keyboard shortcuts", shortcut: "?" }, { label: "Delete account", tone: "danger" }]}                  on_select={(it) => { did = it.label }} on_sign_out={() => { did = "Sign out" }}/>        <span.demo-note>Picked: {did}</span>    </div>}view UserMenuDemo() =    <div.demo-stack>        <div.demo-row style="gap: 20px">            <UserMenu id="user-menu-demo" name="Ada Lovelace" email="ada@example.com" items={user_menu_demo_items} sign_out_href="#signout"/>            <UserMenu id="user-menu-demo-pic" name="Linus Torvalds" email="linus@example.com" size="lg" items={user_menu_demo_items}                      sign_out_href="#signout" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Crect width='48' height='48' fill='%235b4bdb'/%3E%3Ccircle cx='24' cy='18' r='9' fill='%23fff'/%3E%3Cpath d='M6 48a18 14 0 0 1 36 0z' fill='%23fff'/%3E%3C/svg%3E"/>            <span.demo-note>Initials on a colour of their own, or a picture.</span>        </div>        <UserMenuDemoLive/>    </div>

Source

ui/user_menu.qwe
// UserMenu: the signed-in person's avatar as a button that opens their account menu:// their name and email, links (Profile, Settings, Billing), and Sign out at the end.// It is the browser's `popover`, so a click elsewhere closes it; inside it the arrow// keys move, a letter jumps, Escape closes and puts the focus back on the avatar.// Where the browser can anchor it (CSS anchor positioning) it opens under the avatar,// at its right edge.////   <UserMenu id="me" name="Ada Lovelace" email="ada@example.com" src="/img/ada.jpg" items={[//       { label: "Profile", href: "/me", icon: "👤" },//       { label: "Settings", href: "/settings", shortcut: "⌘," },//       { divider: true },//       { label: "Upgrade", href: "/billing", badge: "Pro" }//   ]} sign_out_href="/logout"/>//// id (required): the menu's id, unique on the page. name: whose it is: the initials// (and a colour of their own) show when there is no picture (src), or until it loads.// email: shown under the name. items: links { label, href, icon?, shortcut?, badge?,// tone?: "danger" } or { divider: true }; an item without an href is a button that// closes the menu and, inside one of your components, calls on_select(item).// sign_out_href: where Sign out goes, as a link; without one it is a button that// calls on_sign_out(). Give it a form instead to sign out with a POST: wrap the// UserMenu in your own form and set sign_out_href to null. sign_out: its text// ("Sign out"); null leaves it out. label: the button's name ("Account menu").// size: the avatar's: "sm" (32px), "md" (36px, default) or "lg" (44px).// show_name: the name beside the avatar in the button, with a chevron.import ui/theme.qwe use ui_clslet user_menu_css = css`.ui-usermenu { position: relative; display: inline-block; }.ui-usermenu-btn {  display: inline-flex; align-items: center; gap: 8px; padding: 2px; border: 0; border-radius: 999px; background: transparent;  color: var(--ui-fg); font: 500 14px/1 var(--ui-font); cursor: pointer;}.ui-usermenu-btn:hover { background: var(--ui-subtle); }.ui-usermenu-btn:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-usermenu-named { padding-right: 10px; }.ui-usermenu-chev { width: 14px; height: 14px; color: var(--ui-muted); transition: transform .15s; }.ui-usermenu-btn:has(+ :popover-open) .ui-usermenu-chev { transform: rotate(180deg); }.ui-usermenu-avatar {  position: relative; display: inline-grid; place-items: center; flex: none; width: var(--s, 36px); height: var(--s, 36px); border-radius: 50%;  background: color-mix(in srgb, var(--h) 24%, var(--ui-bg)); color: color-mix(in srgb, var(--h) 70%, var(--ui-fg));  font: 600 calc(var(--s, 36px) * .38)/1 var(--ui-font); letter-spacing: .02em; user-select: none; overflow: hidden;}.ui-usermenu-avatar img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }.ui-usermenu-sm .ui-usermenu-avatar { --s: 32px; }.ui-usermenu-lg .ui-usermenu-avatar { --s: 44px; }.ui-usermenu-pop {  min-width: 230px; max-width: min(320px, calc(100vw - 16px)); margin: auto; padding: 5px; border: 1px solid var(--ui-border);  border-radius: var(--ui-radius); background: var(--ui-surface); color: var(--ui-fg); box-shadow: var(--ui-shadow-lg); font: 14px/1.4 var(--ui-font);}.ui-usermenu-who { display: flex; align-items: center; gap: 10px; padding: 8px 10px 10px; }.ui-usermenu-who .ui-usermenu-avatar { --s: 36px; }.ui-usermenu-name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.ui-usermenu-email { color: var(--ui-muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.ui-usermenu-item {  display: flex; align-items: center; gap: 10px; width: 100%; padding: 8px 10px; border: 0; border-radius: var(--ui-radius-sm);  background: transparent; color: var(--ui-fg); font: inherit; text-align: left; text-decoration: none; cursor: pointer;}.ui-usermenu-item:hover, .ui-usermenu-item:focus { outline: none; background: var(--ui-subtle); }.ui-usermenu-item:focus-visible { box-shadow: var(--ui-focus); }.ui-usermenu-item > span:first-of-type { flex: 1; }.ui-usermenu-icon { display: inline-grid; place-items: center; flex: none; width: 18px; font-size: 15px; line-height: 1; }.ui-usermenu-icon svg { width: 16px; height: 16px; }.ui-usermenu-icon + span { flex: 1; }.ui-usermenu-key { color: var(--ui-muted); font-size: 12px; white-space: nowrap; }.ui-usermenu-badge { padding: 0 7px; border-radius: 999px; background: var(--ui-accent-soft); color: var(--ui-accent); font-size: 11px; font-weight: 600; line-height: 18px; }.ui-usermenu-danger { color: var(--ui-danger); }.ui-usermenu-danger:hover, .ui-usermenu-danger:focus { background: var(--ui-danger-soft); }.ui-usermenu-sep { height: 1px; margin: 4px 6px; background: var(--ui-border); }.ui-usermenu-pop[popover]:popover-open { animation: ui-usermenu-in .12s ease-out; }@keyframes ui-usermenu-in { from { opacity: 0; transform: translateY(-4px); } }@supports (position-area: bottom) {  .ui-usermenu-pop { inset: auto; margin: 6px 0; position-area: bottom span-left; position-try-fallbacks: flip-block, flip-inline; }}@supports not (position-area: bottom) { .ui-usermenu-pop::backdrop { background: var(--ui-overlay); } }@media (prefers-reduced-motion: reduce) { .ui-usermenu-pop[popover]:popover-open { animation: none; } .ui-usermenu-chev { transition: none; } }`// The initials of a name: the first and last words' first letters, or one word's first two.fn user_menu_initials(name: str) -> str {    let words = split(trim(name), " ") | filter((w) => w != "")    len(words) == 0 ? "?" : len(words) == 1 ? upper(slice(words[0], 0, 2)) : upper(slice(words[0], 0, 1) + slice(words[-1], 0, 1))}// A hue of the name's own, the same every time.fn user_menu_hue(name: str) -> int = reduce(range(0, len(name)), 0, (h, i) => (h * 31 + len(url_encode(slice(name, i, i + 1))) * 7 + i * 13) % 360)fn user_menu_avatar(name: str, src: str?) =    <span.ui-usermenu-avatar style={`--h: hsl(${user_menu_hue(name)} 60% 50%)`} aria-hidden="true">        {user_menu_initials(name)}        {src != null && <img src={src} alt=""/>}    </span>component UserMenu(id: str, name: str, email: str? = null, src: str? = null, items: list = [], sign_out_href: str? = null,                   sign_out: str? = "Sign out", label: str = "Account menu", size: str = "md", show_name: bool = false,                   on_select = null, on_sign_out = null, class: str? = null) {    style global user_menu_css    state pop = null    let all = () => pop.querySelectorAll("[role=menuitem]")    let back = () => {        pop.hidePopover()        pop.parentElement.querySelector(".ui-usermenu-btn").focus()    }    let move = (e, step: int) => {        let xs = all()        let n = xs.length        let at = index_of(for i in range(0, n) => xs[i] == e.target, true) ?? -1        let next = step == 0 ? 0 : step == 99 ? n - 1 : (at + step + n) % n        xs[next].focus()    }    let jump = (e, ch: str) => {        let xs = all()        let n = xs.length        let at = index_of(for i in range(0, n) => xs[i] == e.target, true) ?? -1        let hit = find(for k in range(1, n + 1) => (at + k) % n, (i) => starts_with(lower(xs[i].dataset.label ?? ""), ch))        if hit != null { xs[hit].focus() }    }    let cls = (it) => ui_cls(["ui-usermenu-item", get(it, "tone") == "danger" && "ui-usermenu-danger"])    let body = (it) => [        get(it, "icon") != null && <span.ui-usermenu-icon aria-hidden="true">{starts_with(it.icon, "M") && len(it.icon) > 2            ? <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d={it.icon}/></svg>            : it.icon}</span>,        <span>{it.label}</span>,        get(it, "badge") != null && <span.ui-usermenu-badge>{it.badge}</span>,        get(it, "shortcut") != null && <span.ui-usermenu-key aria-hidden="true">{it.shortcut}</span>,    ]    <div class={ui_cls(["ui-usermenu", size != "md" && `ui-usermenu-${size}`, class])}>        <button type="button" class={ui_cls(["ui-usermenu-btn", show_name && "ui-usermenu-named"])} popovertarget={id}                aria-label={show_name ? null : `${label}: ${name}`} aria-haspopup="menu" style={`anchor-name: --${id}`}>            {user_menu_avatar(name, src)}            {show_name && <span>{name}</span>}            {show_name && <svg.ui-usermenu-chev viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"                 stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>}        </button>        <div.ui-usermenu-pop id={id} popover="auto" role="menu" aria-label={label} style={`position-anchor: --${id}`}             @mount={(el) => { pop = el }}             @toggle={(e) => { if e.newState == "open" { let first = pop.querySelector("[role=menuitem]"); first && first.focus() } }}             @keydown={(e) => {                 let k = e.key                 if k == "ArrowDown" { e.preventDefault(); move(e, 1) }                 if k == "ArrowUp" { e.preventDefault(); move(e, -1) }                 if k == "Home" { e.preventDefault(); move(e, 0) }                 if k == "End" { e.preventDefault(); move(e, 99) }                 if k == "Escape" || k == "Tab" { e.preventDefault(); back() }                 if len(k) == 1 && k != " " && !e.ctrlKey && !e.metaKey { jump(e, lower(k)) }             }}>            <div.ui-usermenu-who>                {user_menu_avatar(name, src)}                <span style="min-width: 0">                    <span.ui-usermenu-name style="display: block">{name}</span>                    {email != null && <span.ui-usermenu-email style="display: block">{email}</span>}                </span>            </div>            <div.ui-usermenu-sep role="separator"></div>            {for [i, it] in enumerate(items) => get(it, "divider") == true                ? <div.ui-usermenu-sep key={i} role="separator"></div>                : get(it, "href") != null                ? <a key={i} class={cls(it)} role="menuitem" tabindex="-1" href={it.href} data-label={it.label} @click={() => { pop.hidePopover() }}>{body(it)}</a>                : <button key={i} type="button" class={cls(it)} role="menuitem" tabindex="-1" data-label={it.label}                          @click={() => {                              back()                              on_select && on_select(it)                          }}>{body(it)}</button>}            {sign_out != null && <div.ui-usermenu-sep role="separator"></div>}            {sign_out != null && (sign_out_href != null                ? <a.ui-usermenu-item role="menuitem" tabindex="-1" href={sign_out_href} data-label={sign_out} @click={() => { pop.hidePopover() }}>                      <span.ui-usermenu-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="M10 17l5-5-5-5M15 12H3M21 4v16"/></svg></span>                      <span>{sign_out}</span>                  </a>                : <button.ui-usermenu-item type="button" role="menuitem" tabindex="-1" data-label={sign_out}                          @click={() => {                              back()                              on_sign_out && on_sign_out()                          }}>                      <span.ui-usermenu-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="M10 17l5-5-5-5M15 12H3M21 4v16"/></svg></span>                      <span>{sign_out}</span>                  </button>)}        </div>    </div>}
Thread
56Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.