feat(operator): avatar dropdown context menu in topbar

New UserMenu component owns its own trigger + dropdown + dismissal so the
topbar stays simple. Menu contents: identity row (name + email), theme
toggle (reuses useTweaks so the floating panel and menu stay in sync),
link to /settings, Sign out (calls useOidcAuth().logout).

Dismissal: outside click via a transparent Teleport scrim, Escape, and
route change (watch on route.path → close).

Drops the now-unused useOidcAuth import from OpTopbar.
This commit is contained in:
Ronni Baslund
2026-05-24 16:45:11 +02:00
parent 885aa65219
commit 3f4be27bd9
2 changed files with 164 additions and 2 deletions
+163
View File
@@ -0,0 +1,163 @@
<script setup lang="ts">
// Avatar dropdown shown in the topbar. Replaces the inert sidebar profile
// card that used to live in OpSidebar. Owns its own open/close state plus
// outside-click + Escape + route-change dismissal so the parent topbar stays
// dumb.
const { user, logout } = useOidcAuth()
const { state: tweaks, setTheme } = useTweaks()
const route = useRoute()
const open = ref(false)
const displayName = computed(() => user.value?.userInfo?.name || user.value?.userName || 'operator')
const email = computed(() => (user.value?.userInfo as { email?: string } | undefined)?.email ?? '')
function toggle() {
open.value = !open.value
}
function close() {
open.value = false
}
function flipTheme() {
setTheme(tweaks.value.theme === 'dark' ? 'light' : 'dark')
}
async function signOut() {
close()
await logout()
}
watch(() => route.path, close)
onMounted(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && open.value) close()
}
document.addEventListener('keydown', onKey)
onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
})
</script>
<template>
<div class="usermenu">
<button class="trigger" :class="{ on: open }" type="button" :title="displayName" @click="toggle">
<Avatar :name="displayName" :size="26" />
</button>
<Teleport to="body">
<div v-if="open" class="scrim" @click="close" />
</Teleport>
<Transition name="menu">
<div v-if="open" class="menu" role="menu" aria-label="User menu">
<div class="ident">
<Avatar :name="displayName" :size="32" />
<div class="ident-meta">
<div class="ident-name">{{ displayName }}</div>
<Mono dim>{{ email }}</Mono>
</div>
</div>
<div class="divider" />
<button class="item" type="button" role="menuitem" @click="flipTheme">
<UiIcon :name="tweaks.theme === 'dark' ? 'shield' : 'shield'" :size="13" />
<span class="label">Theme · {{ tweaks.theme === 'dark' ? 'dark' : 'light' }}</span>
<Mono dim>{{ tweaks.theme === 'dark' ? 'switch to light' : 'switch to dark' }}</Mono>
</button>
<NuxtLink class="item" to="/settings" role="menuitem" @click="close">
<UiIcon name="shield" :size="13" />
<span class="label">Settings</span>
</NuxtLink>
<div class="divider" />
<button class="item danger" type="button" role="menuitem" @click="signOut">
<UiIcon name="logout" :size="13" />
<span class="label">Sign out</span>
</button>
</div>
</Transition>
</div>
</template>
<style scoped>
.usermenu { position: relative; }
.trigger {
appearance: none;
background: transparent;
border: 1px solid transparent;
border-radius: 999px;
padding: 0;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.trigger:hover { border-color: var(--border); }
.trigger.on { border-color: var(--border-hi); }
/* Transparent full-screen scrim that catches outside clicks. Sits below the
menu since the menu is positioned in the same .usermenu container above. */
.scrim {
position: fixed;
inset: 0;
z-index: 90;
background: transparent;
}
.menu {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 100;
width: 240px;
background: var(--elevated);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.32);
padding: 8px;
display: flex;
flex-direction: column;
gap: 2px;
}
.ident {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
}
.ident-meta { min-width: 0; flex: 1; }
.ident-name { font-size: 13px; font-weight: 500; }
.divider { height: 1px; background: var(--border); margin: 4px 0; }
.item {
appearance: none;
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 10px;
background: transparent;
border: 0;
border-radius: 6px;
color: var(--text);
font-family: inherit;
font-size: 13px;
text-align: left;
text-decoration: none;
cursor: pointer;
}
.item:hover { background: var(--surface); }
.item .label { flex: 1; }
.item.danger { color: var(--bad); }
.item.danger:hover { background: rgba(240, 88, 88, 0.08); }
.menu-enter-active, .menu-leave-active { transition: opacity 0.12s, transform 0.12s; }
.menu-enter-from, .menu-leave-to { opacity: 0; transform: translateY(-4px); }
</style>