feat(shared-ui): user menu dropdown + integration on portal-shell + portal-admin
CI / commits (pull_request) Successful in 3m7s
CI / scan (pull_request) Successful in 3m7s
CI / check (pull_request) Successful in 4m17s
CI / a11y (pull_request) Successful in 1m39s
CI / perf (pull_request) Successful in 4m36s

PR 1 of 3 from the user-menu / profile / cross-app-link chantier.

* New `UserMenu` standalone component in `libs/shared/ui`. Avatar
  trigger + CDK menu panel (gitea / github shape) — "Signed in as"
  header, configurable rows (Profile, Settings — marked Soon),
  separator, dedicated Sign out row at the bottom. Keyboard nav,
  ARIA, and overlay portal handled by `@angular/cdk/menu`, same
  primitives the theme + locale switchers already use.

* Each app's header consumes the shared component for the
  authenticated state. The loading / anonymous / error widgets stay
  app-local because they diverge by surface (portal-shell has a
  search bar + notification cluster, portal-admin is leaner).

* Portal-shell anonymous Sign-in button moves from `rounded-full` to
  `rounded-md`. The loading + error chips follow for visual
  consistency with the new square-ish avatar.

* Portal-admin overrides the shared `--user-menu-avatar-bg` CSS
  custom property to a translucent white so the avatar reads on
  the brand-primary-600 header background.

Out of scope for this PR (covered by PR 2 + 3 of the chantier):
profile pages, real roles surfaced on portal-shell, cross-app
links between the two surfaces. The user menu's `items` input is
designed to absorb those additions without touching the component.

i18n: 6 new strings added to `messages.fr.xlf` for portal-shell.
portal-admin labels stay in English (no FR locale in the admin
app's v1 per ADR-0020 §"No locale switcher in v1").
This commit is contained in:
Julien Gautier
2026-05-15 15:02:42 +02:00
parent e3e7800ecb
commit 5f5e6d8e35
14 changed files with 638 additions and 47 deletions
+1
View File
@@ -1 +1,2 @@
export { Icon, type IconName } from './lib/icon/icon';
export { UserMenu, type UserMenuItem } from './lib/user-menu/user-menu';
+4
View File
@@ -15,6 +15,7 @@ import {
Grid2x2,
House,
LayoutDashboard,
LogOut,
type LucideIconData,
LucideAngularModule,
Mail,
@@ -24,6 +25,7 @@ import {
Settings,
Store,
Sun,
User,
UsersRound,
Wrench,
} from 'lucide-angular';
@@ -65,6 +67,7 @@ const ICON_REGISTRY = {
'grid-2x2': Grid2x2,
home: House,
'layout-dashboard': LayoutDashboard,
'log-out': LogOut,
mail: Mail,
monitor: Monitor,
moon: Moon,
@@ -72,6 +75,7 @@ const ICON_REGISTRY = {
settings: Settings,
store: Store,
sun: Sun,
user: User,
'users-round': UsersRound,
wrench: Wrench,
} satisfies Record<string, LucideIconData>;
@@ -0,0 +1,76 @@
<button
type="button"
class="user-menu__trigger"
[cdkMenuTriggerFor]="userMenu"
[attr.aria-label]="triggerAriaLabel()"
>
<span class="user-menu__avatar" aria-hidden="true">{{ initials() }}</span>
<lib-icon name="chevron-down" [size]="14" />
</button>
<ng-template #userMenu>
<div cdkMenu class="user-menu__panel" role="menu" [attr.aria-label]="triggerAriaLabel()">
<div class="user-menu__header" role="presentation">
<span class="user-menu__header-label">{{ signedInAsLabel() }}</span>
<strong class="user-menu__header-name">{{ displayName() }}</strong>
<span class="user-menu__header-username">{{ username() }}</span>
</div>
@if (items().length > 0) {
<div class="user-menu__separator" role="separator"></div>
<div class="user-menu__group" role="group">
@for (item of items(); track item.label) { @if (item.disabled) {
<span class="user-menu__item user-menu__item--disabled" role="menuitem" aria-disabled="true">
@if (item.icon) {
<span class="user-menu__icon">
<lib-icon [name]="item.icon" [size]="16" />
</span>
}
<span class="user-menu__label">{{ item.label }}</span>
@if (item.badge) {
<span class="user-menu__badge">{{ item.badge }}</span>
}
</span>
} @else if (item.href) {
<a cdkMenuItem class="user-menu__item" role="menuitem" [attr.href]="item.href">
@if (item.icon) {
<span class="user-menu__icon">
<lib-icon [name]="item.icon" [size]="16" />
</span>
}
<span class="user-menu__label">{{ item.label }}</span>
@if (item.badge) {
<span class="user-menu__badge">{{ item.badge }}</span>
}
</a>
} @else if (item.routerLink) {
<a cdkMenuItem class="user-menu__item" role="menuitem" [routerLink]="item.routerLink">
@if (item.icon) {
<span class="user-menu__icon">
<lib-icon [name]="item.icon" [size]="16" />
</span>
}
<span class="user-menu__label">{{ item.label }}</span>
@if (item.badge) {
<span class="user-menu__badge">{{ item.badge }}</span>
}
</a>
} }
</div>
}
<div class="user-menu__separator" role="separator"></div>
<button
type="button"
cdkMenuItem
class="user-menu__item user-menu__item--sign-out"
role="menuitem"
(click)="signOut.emit()"
>
<span class="user-menu__icon">
<lib-icon name="log-out" [size]="16" />
</span>
<span class="user-menu__label">{{ signOutLabel() }}</span>
</button>
</div>
</ng-template>
@@ -0,0 +1,194 @@
// Trigger — avatar (initials in a small square) + chevron. The
// outer shape mirrors gitea/github: square-ish with a small chevron
// hint. Each host app can recolor the avatar via the
// `--user-menu-avatar-bg` / `--user-menu-avatar-fg` CSS variables
// so the brand color stays consistent with the surrounding header.
.user-menu__trigger {
--user-menu-avatar-bg: var(--color-brand-primary-500, #2563eb);
--user-menu-avatar-fg: #ffffff;
display: inline-flex;
align-items: center;
gap: 0.25rem;
min-height: 2.75rem;
padding: 0.25rem 0.5rem;
margin: 0;
background: transparent;
border: 0;
border-radius: 0.375rem;
color: inherit;
cursor: pointer;
transition: background-color 0.12s ease-out;
&:hover {
background-color: rgba(0, 0, 0, 0.06);
:where(.dark) & {
background-color: rgba(255, 255, 255, 0.08);
}
}
&:focus-visible {
outline: 2px solid var(--color-brand-primary-500, #2563eb);
outline-offset: 2px;
}
}
.user-menu__avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 0.375rem;
background-color: var(--user-menu-avatar-bg);
color: var(--user-menu-avatar-fg);
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.02em;
}
// Panel — CDK Menu opens this in an overlay portal at the document
// root. ViewEncapsulation.None on the component lets these styles
// reach it.
.user-menu__panel {
min-width: 14rem;
margin-top: 0.25rem;
padding: 0.25rem 0;
background-color: #ffffff;
color: #111827;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -4px rgba(0, 0, 0, 0.1);
:where(.dark) & {
background-color: #111827;
color: #f3f4f6;
border-color: #1f2937;
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.4),
0 4px 6px -4px rgba(0, 0, 0, 0.4);
}
}
.user-menu__header {
padding: 0.5rem 0.875rem;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.user-menu__header-label {
font-size: 0.6875rem;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.05em;
:where(.dark) & {
color: #9ca3af;
}
}
.user-menu__header-name {
font-size: 0.875rem;
font-weight: 600;
color: inherit;
}
.user-menu__header-username {
font-size: 0.75rem;
color: #6b7280;
:where(.dark) & {
color: #9ca3af;
}
}
.user-menu__separator {
height: 1px;
margin: 0.25rem 0;
background-color: #e5e7eb;
:where(.dark) & {
background-color: #1f2937;
}
}
.user-menu__group {
display: flex;
flex-direction: column;
padding: 0.125rem 0;
}
.user-menu__item {
display: inline-flex;
align-items: center;
gap: 0.625rem;
width: 100%;
padding: 0.5rem 0.875rem;
background: transparent;
border: 0;
color: inherit;
text-decoration: none;
font-size: 0.875rem;
cursor: pointer;
text-align: left;
&:hover,
&:focus-visible {
background-color: rgba(0, 0, 0, 0.04);
outline: none;
:where(.dark) & {
background-color: rgba(255, 255, 255, 0.06);
}
}
}
.user-menu__item--disabled {
color: #9ca3af;
cursor: default;
&:hover {
background-color: transparent;
}
:where(.dark) & {
color: #6b7280;
}
}
.user-menu__item--sign-out {
color: #b91c1c;
:where(.dark) & {
color: #fca5a5;
}
}
.user-menu__icon {
display: inline-flex;
width: 1.125rem;
flex: 0 0 1.125rem;
}
.user-menu__label {
flex: 1 1 auto;
}
.user-menu__badge {
font-size: 0.625rem;
text-transform: uppercase;
font-weight: 700;
letter-spacing: 0.05em;
padding: 0.125rem 0.375rem;
border-radius: 999px;
background-color: rgba(0, 0, 0, 0.06);
color: #6b7280;
:where(.dark) & {
background-color: rgba(255, 255, 255, 0.08);
color: #9ca3af;
}
}
@@ -0,0 +1,136 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { UserMenu, type UserMenuItem } from './user-menu';
const ITEMS: readonly UserMenuItem[] = [
{ label: 'Profile', icon: 'user', routerLink: '/profile' },
{ label: 'Settings', icon: 'settings', disabled: true, badge: 'Soon' },
];
async function setup(overrides: Partial<Parameters<typeof renderWith>[0]> = {}) {
await TestBed.configureTestingModule({
imports: [UserMenu],
providers: [provideRouter([])],
}).compileComponents();
return renderWith({
displayName: 'Jane Doe',
username: 'jane@apf.example',
initials: 'JD',
items: ITEMS,
triggerAriaLabel: 'User menu — signed in as Jane Doe',
...overrides,
});
}
function renderWith(opts: {
displayName: string;
username: string;
initials: string;
items: readonly UserMenuItem[];
signedInAsLabel?: string;
signOutLabel?: string;
triggerAriaLabel?: string;
}) {
const fixture = TestBed.createComponent(UserMenu);
fixture.componentRef.setInput('displayName', opts.displayName);
fixture.componentRef.setInput('username', opts.username);
fixture.componentRef.setInput('initials', opts.initials);
fixture.componentRef.setInput('items', opts.items);
if (opts.signedInAsLabel) {
fixture.componentRef.setInput('signedInAsLabel', opts.signedInAsLabel);
}
if (opts.signOutLabel) {
fixture.componentRef.setInput('signOutLabel', opts.signOutLabel);
}
if (opts.triggerAriaLabel) {
fixture.componentRef.setInput('triggerAriaLabel', opts.triggerAriaLabel);
}
fixture.detectChanges();
return fixture;
}
describe('UserMenu', () => {
it('renders the avatar trigger with initials + aria-label', async () => {
const fixture = await setup();
const trigger = (fixture.nativeElement as HTMLElement).querySelector(
'button[aria-haspopup="menu"]',
) as HTMLButtonElement | null;
expect(trigger).not.toBeNull();
expect(trigger?.getAttribute('aria-label')).toBe('User menu — signed in as Jane Doe');
expect(trigger?.querySelector('.user-menu__avatar')?.textContent?.trim()).toBe('JD');
});
it('renders the menu panel via the CDK overlay on trigger click', async () => {
const fixture = await setup();
const trigger = (fixture.nativeElement as HTMLElement).querySelector<HTMLButtonElement>(
'button[aria-haspopup="menu"]',
);
trigger?.click();
fixture.detectChanges();
await fixture.whenStable();
// CDK Menu mounts the panel in an overlay portal at document
// root, not under the component's host — scope the assertion to
// the document.
const panel = document.body.querySelector('.user-menu__panel');
expect(panel).not.toBeNull();
expect(panel?.querySelector('.user-menu__header-name')?.textContent).toContain('Jane Doe');
expect(panel?.querySelector('.user-menu__header-username')?.textContent).toContain(
'jane@apf.example',
);
});
it('renders enabled items as router links and disabled items as aria-disabled rows', async () => {
const fixture = await setup();
const trigger = (fixture.nativeElement as HTMLElement).querySelector<HTMLButtonElement>(
'button[aria-haspopup="menu"]',
);
trigger?.click();
fixture.detectChanges();
await fixture.whenStable();
const panel = document.body.querySelector('.user-menu__panel');
const profile = panel?.querySelector('a.user-menu__item[href="/profile"]');
expect(profile?.textContent).toContain('Profile');
const settings = panel?.querySelector('.user-menu__item--disabled');
expect(settings?.getAttribute('aria-disabled')).toBe('true');
expect(settings?.querySelector('.user-menu__badge')?.textContent?.trim()).toBe('Soon');
});
it('emits signOut on Sign out click and uses the localised label', async () => {
const fixture = await setup({ signOutLabel: 'Se déconnecter' });
let emitted = 0;
fixture.componentInstance.signOut.subscribe(() => emitted++);
const trigger = (fixture.nativeElement as HTMLElement).querySelector<HTMLButtonElement>(
'button[aria-haspopup="menu"]',
);
trigger?.click();
fixture.detectChanges();
await fixture.whenStable();
const signOut = document.body.querySelector<HTMLButtonElement>('.user-menu__item--sign-out');
expect(signOut?.textContent).toContain('Se déconnecter');
signOut?.click();
expect(emitted).toBe(1);
});
it('renders an external link via `href` instead of routerLink when provided', async () => {
const fixture = await setup({
items: [{ label: 'Open elsewhere', href: 'https://example.test/' }],
});
const trigger = (fixture.nativeElement as HTMLElement).querySelector<HTMLButtonElement>(
'button[aria-haspopup="menu"]',
);
trigger?.click();
fixture.detectChanges();
await fixture.whenStable();
const link = document.body.querySelector<HTMLAnchorElement>(
'.user-menu__item[href="https://example.test/"]',
);
expect(link).not.toBeNull();
expect(link?.textContent).toContain('Open elsewhere');
});
});
@@ -0,0 +1,76 @@
import { CdkMenu, CdkMenuItem, CdkMenuTrigger } from '@angular/cdk/menu';
import {
ChangeDetectionStrategy,
Component,
ViewEncapsulation,
input,
output,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import { Icon, type IconName } from '../icon/icon';
/**
* Shape of a single row in the user menu — Profile, Settings,
* cross-app links, etc. Sign out is NOT modelled here; it is always
* rendered as the last row in a separate group and emits the
* dedicated `signOut` output.
*
* Use `routerLink` for internal navigation (Angular `RouterLink`),
* `href` for cross-origin links (other apps, external URLs). At most
* one of the two should be set.
*/
export interface UserMenuItem {
readonly label: string;
readonly icon?: IconName;
readonly routerLink?: string;
readonly href?: string;
readonly disabled?: boolean;
/** Right-aligned chip — e.g. `"Soon"` for not-yet-shipped entries. */
readonly badge?: string;
}
/**
* Authenticated user menu — the gitea/github-style avatar dropdown
* docked at the top right of both `portal-shell` and `portal-admin`
* headers. Rendered only when the session is `authenticated`; the
* loading / anonymous / error widgets stay in each app's header
* because they vary by surface (search bar, sign-up button, etc.).
*
* The component is layout-only: caller passes the principal
* (`displayName`, `username`, `initials`) plus a list of `items`
* for the navigation rows, and listens for `signOut` to actually
* destroy the session.
*
* Keyboard nav (arrow keys, Home/End, Escape, type-ahead) is
* provided by `@angular/cdk/menu` — same primitives the
* `ThemeSwitcher` and `LocaleSwitcher` already use, kept consistent
* to land familiar interactions across the chrome.
*/
@Component({
selector: 'lib-user-menu',
imports: [CdkMenu, CdkMenuItem, CdkMenuTrigger, RouterLink, Icon],
templateUrl: './user-menu.html',
styleUrl: './user-menu.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
// CDK Menu opens its panel in an overlay portal outside this
// component's host. Encapsulated styles would not reach the
// overlay; the BEM-prefixed class names below keep selectors
// specific enough to avoid leakage. Same pattern as
// `LocaleSwitcher` / `ThemeSwitcher`.
encapsulation: ViewEncapsulation.None,
})
export class UserMenu {
readonly displayName = input.required<string>();
readonly username = input.required<string>();
readonly initials = input.required<string>();
readonly items = input.required<readonly UserMenuItem[]>();
/** Localised label for the "Signed in as {username}" panel header. */
readonly signedInAsLabel = input<string>('Signed in as');
/** Localised label for the trailing Sign out row. */
readonly signOutLabel = input<string>('Sign out');
/** Localised aria-label for the avatar trigger button. */
readonly triggerAriaLabel = input<string>('User menu');
readonly signOut = output<void>();
}