feat(hq-web): user chip opens a Profile/Logout menu (Phase 4 finish, D18 WS-E)

The chip was a bare link to /profile with a separate logout icon; the spec
(§5) calls for a small menu. Chip is now a button toggling a two-item menu
(Profile / Logout) that closes on outside click, Escape, and route change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 5 days ago
parent 08dda8bab3
commit 3f7f08a908

@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Navigate, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
import { LogOut, Menu, Search } from 'lucide-react'
import { LogOut, Menu, Search, UserRound } from 'lucide-react'
import {
avatarColors, Badge, CommandPalette, initials, ThemeSwitcher, type PaletteItem,
} from '@sims/ui'
@ -17,6 +17,8 @@ export function Layout() {
const [queueCounts, setQueueCounts] = useState({ queued: 0, failed: 0 })
const [healthy, setHealthy] = useState<boolean | undefined>()
const [menuOpen, setMenuOpen] = useState(false)
const [userOpen, setUserOpen] = useState(false)
const userRef = useRef<HTMLSpanElement>(null)
const [paletteOpen, setPaletteOpen] = useState(false)
const [clientHits, setClientHits] = useState<PaletteItem[]>([])
const queryTimer = useRef<number | undefined>(undefined)
@ -40,8 +42,20 @@ export function Layout() {
.catch(() => { /* badge is best-effort */ })
}, [location.pathname])
// Close the mobile drawer when the route changes.
useEffect(() => { setMenuOpen(false) }, [location.pathname])
// Close the mobile drawer and the user menu when the route changes.
useEffect(() => { setMenuOpen(false); setUserOpen(false) }, [location.pathname])
// User menu dismissal: outside click or Escape.
useEffect(() => {
if (!userOpen) return
const onDown = (e: MouseEvent) => {
if (userRef.current !== null && !userRef.current.contains(e.target as Node)) setUserOpen(false)
}
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setUserOpen(false) }
document.addEventListener('mousedown', onDown)
document.addEventListener('keydown', onKey)
return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey) }
}, [userOpen])
// Cancel any pending palette query on unmount.
useEffect(() => () => window.clearTimeout(queryTimer.current), [])
@ -164,18 +178,28 @@ export function Layout() {
</span>
)}
<ThemeSwitcher />
{/* D18 WS-E: the user chip opens the self-service profile. */}
<NavLink to="/profile" className="hq-user" title="My profile" style={{ textDecoration: 'none', color: 'inherit' }}>
{/* D18 WS-E: the user chip opens a small menu — Profile / Logout. */}
<span className="hq-user-wrap" ref={userRef}>
<button
type="button" className="hq-user" title="Account"
aria-haspopup="menu" aria-expanded={userOpen}
onClick={() => setUserOpen((v) => !v)}
>
<span className="hq-user-av" style={{ background: av.bg, color: av.fg }}>{initials(name)}</span>
<span className="hq-user-name">{name}</span>
<Badge>{role()}</Badge>
</NavLink>
<button
type="button" className="wf" title="Logout" aria-label="Logout"
onClick={() => { clearSession(); nav('/login') }}
>
<LogOut size={14} />
</button>
{userOpen && (
<div className="hq-user-menu" role="menu">
<button type="button" role="menuitem" className="hq-user-item" onClick={() => { setUserOpen(false); nav('/profile') }}>
<UserRound size={14} aria-hidden /> Profile
</button>
<button type="button" role="menuitem" className="hq-user-item" onClick={() => { setUserOpen(false); clearSession(); nav('/login') }}>
<LogOut size={14} aria-hidden /> Logout
</button>
</div>
)}
</span>
</header>
<div className="hq-content">
<Outlet />

@ -66,7 +66,27 @@
}
.hq-pill.ok { color: var(--ok); background: var(--ok-soft); border-color: color-mix(in srgb, var(--ok) 25%, transparent); }
.hq-pill.warn { color: var(--warn); background: var(--warn-soft); border-color: color-mix(in srgb, var(--warn) 25%, transparent); }
.hq-user { display: inline-flex; align-items: center; gap: 8px; }
.hq-user-wrap { position: relative; display: inline-flex; }
.hq-user {
display: inline-flex; align-items: center; gap: 8px;
background: none; border: 1px solid transparent; border-radius: 999px;
padding: 3px 8px 3px 4px; cursor: pointer; font: inherit; color: inherit;
}
.hq-user:hover, .hq-user[aria-expanded='true'] { background: var(--bg-hover); border-color: var(--border); }
.hq-user-menu {
position: absolute; top: calc(100% + 6px); right: 0; z-index: 60;
min-width: 150px; padding: 4px;
background: var(--bg-raised); border: 1px solid var(--border);
border-radius: var(--radius); box-shadow: var(--shadow-lg);
display: flex; flex-direction: column;
}
.hq-user-item {
display: flex; align-items: center; gap: 8px;
background: none; border: none; border-radius: var(--radius-sm);
padding: 7px 10px; font: 500 13px var(--font); color: var(--text);
cursor: pointer; text-align: left;
}
.hq-user-item:hover { background: var(--bg-hover); }
.hq-user-av {
width: 26px; height: 26px; border-radius: 50%;
display: inline-flex; align-items: center; justify-content: center;

Loading…
Cancel
Save