UserMenu
An avatar button that opens the account menu: name and email, links, and sign out; the arrow keys move through it.
Demo
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.qwemain.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>}Related
LanguageSwitcherA button showing the page's language that opens the others, each in its own name, as links to the same page in it.5 views · 6 downloadsCollapsibleNavSide navigation in groups that fold under their titles, the browser remembering which are open.6 views · 7 downloadsDirectoryNavA long list of names grouped under sticky letter headers, with a row of letters at the top that jumps to each group.5 views · 5 downloadsMegaMenuA top bar whose items open wide panels of grouped links under the whole bar, on hover and from the keyboard, with no script.5 views · 7 downloadsMenuA vertical list of links and actions in titled sections, with icons, shortcuts, counts, disabled items and the active one marked.6 views · 6 downloadsMobileNavA menu button that opens the site's navigation over the whole screen, as a dialog: groups, the current page, actions at the bottom.5 views · 6 downloads
Thread
Thread 0 posts
On UserMenu: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.