From 5f5e6d8e35923c7ae8b4e394f353c67f125e65ee Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Fri, 15 May 2026 15:02:42 +0200 Subject: [PATCH] feat(shared-ui): user menu dropdown + integration on portal-shell + portal-admin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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"). --- .../src/app/components/header/header.html | 14 +- .../src/app/components/header/header.scss | 9 + .../src/app/components/header/header.spec.ts | 34 ++- .../src/app/components/header/header.ts | 19 +- .../src/app/components/header/header.html | 38 ++-- .../src/app/components/header/header.spec.ts | 32 ++- .../src/app/components/header/header.ts | 28 ++- apps/portal-shell/src/locale/messages.fr.xlf | 24 +++ libs/shared/ui/src/index.ts | 1 + libs/shared/ui/src/lib/icon/icon.ts | 4 + .../ui/src/lib/user-menu/user-menu.html | 76 +++++++ .../ui/src/lib/user-menu/user-menu.scss | 194 ++++++++++++++++++ .../ui/src/lib/user-menu/user-menu.spec.ts | 136 ++++++++++++ libs/shared/ui/src/lib/user-menu/user-menu.ts | 76 +++++++ 14 files changed, 638 insertions(+), 47 deletions(-) create mode 100644 libs/shared/ui/src/lib/user-menu/user-menu.html create mode 100644 libs/shared/ui/src/lib/user-menu/user-menu.scss create mode 100644 libs/shared/ui/src/lib/user-menu/user-menu.spec.ts create mode 100644 libs/shared/ui/src/lib/user-menu/user-menu.ts diff --git a/apps/portal-admin/src/app/components/header/header.html b/apps/portal-admin/src/app/components/header/header.html index 9c5a49a..fd304d4 100644 --- a/apps/portal-admin/src/app/components/header/header.html +++ b/apps/portal-admin/src/app/components/header/header.html @@ -27,9 +27,17 @@ Sign in } @case ('authenticated') { @if (authState(); as state) { @if (state.kind === 'authenticated') { - {{ initials() }} - {{ state.user.displayName }} - + } } } } diff --git a/apps/portal-admin/src/app/components/header/header.scss b/apps/portal-admin/src/app/components/header/header.scss index 2df1e4a..062c91d 100644 --- a/apps/portal-admin/src/app/components/header/header.scss +++ b/apps/portal-admin/src/app/components/header/header.scss @@ -57,6 +57,15 @@ display: flex; align-items: center; gap: 0.5rem; + + // The admin header sits on `--color-brand-primary-600`; a brand + // primary avatar inside the user menu trigger would barely read. + // Override the shared avatar tokens to a translucent white block + // matching the inline avatar this widget replaced. + lib-user-menu { + --user-menu-avatar-bg: rgba(255, 255, 255, 0.2); + --user-menu-avatar-fg: #ffffff; + } } .auth-status--loading { diff --git a/apps/portal-admin/src/app/components/header/header.spec.ts b/apps/portal-admin/src/app/components/header/header.spec.ts index d18f83f..5c31fc2 100644 --- a/apps/portal-admin/src/app/components/header/header.spec.ts +++ b/apps/portal-admin/src/app/components/header/header.spec.ts @@ -67,12 +67,26 @@ describe('AdminHeader', () => { expect(button?.textContent?.trim()).toBe('Sign in'); }); - it('shows the user widget with initials when authenticated', async () => { + it('shows the user menu trigger with initials when authenticated', async () => { const { fixture } = await setup({ onBootstrap: 'authenticated' }); const root = fixture.nativeElement as HTMLElement; - expect(root.querySelector('.avatar')?.textContent?.trim()).toBe('AS'); - expect(root.querySelector('.auth-name')?.textContent).toContain('Admin Smith'); - expect(root.querySelector('.btn--secondary')?.textContent?.trim()).toBe('Sign out'); + const menu = root.querySelector('[data-testid="user-menu"]'); + expect(menu).not.toBeNull(); + expect(menu?.querySelector('.user-menu__avatar')?.textContent?.trim()).toBe('AS'); + }); + + it('opens the menu and exposes displayName + Sign out inside the panel', async () => { + const { fixture } = await setup({ onBootstrap: 'authenticated' }); + const trigger = (fixture.nativeElement as HTMLElement).querySelector( + '[data-testid="user-menu"] button[aria-haspopup="menu"]', + ); + trigger?.click(); + fixture.detectChanges(); + await fixture.whenStable(); + const panel = document.body.querySelector('.user-menu__panel'); + expect(panel).not.toBeNull(); + expect(panel?.querySelector('.user-menu__header-name')?.textContent).toContain('Admin Smith'); + expect(panel?.querySelector('.user-menu__item--sign-out')).not.toBeNull(); }); it('navigates to the admin login URL on Sign in click', async () => { @@ -83,11 +97,15 @@ describe('AdminHeader', () => { expect(navigate).toHaveBeenCalledWith(`${BFF_BASE}/admin/auth/login`); }); - it('navigates to the admin logout URL on Sign out click', async () => { + it('navigates to the admin logout URL on Sign out click inside the menu panel', async () => { const { fixture, navigate } = await setup({ onBootstrap: 'authenticated' }); - (fixture.nativeElement as HTMLElement) - .querySelector('.btn--secondary') - ?.click(); + const trigger = (fixture.nativeElement as HTMLElement).querySelector( + '[data-testid="user-menu"] button[aria-haspopup="menu"]', + ); + trigger?.click(); + fixture.detectChanges(); + await fixture.whenStable(); + document.body.querySelector('.user-menu__item--sign-out')?.click(); expect(navigate).toHaveBeenCalledWith(`${BFF_BASE}/admin/auth/logout`); }); }); diff --git a/apps/portal-admin/src/app/components/header/header.ts b/apps/portal-admin/src/app/components/header/header.ts index aa6f4a1..9eaf2ea 100644 --- a/apps/portal-admin/src/app/components/header/header.ts +++ b/apps/portal-admin/src/app/components/header/header.ts @@ -1,6 +1,7 @@ import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; import { RouterLink } from '@angular/router'; import { AuthService, type CurrentUser } from 'feature-auth'; +import { UserMenu, type UserMenuItem } from 'shared-ui'; /** * Admin-portal header. Deliberately leaner than the user-portal @@ -26,7 +27,7 @@ import { AuthService, type CurrentUser } from 'feature-auth'; */ @Component({ selector: 'app-admin-header', - imports: [RouterLink], + imports: [RouterLink, UserMenu], templateUrl: './header.html', styleUrl: './header.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -41,6 +42,22 @@ export class AdminHeader { return user ? initialsFor(user) : ''; }); + // Static menu rows. Same shape as the portal-shell menu so a user + // who learned one finds the other familiar; the cross-app + // "Open Portal Shell" entry lands with PR 3 once the role + + // environment plumbing is in place. + protected readonly userMenuItems: readonly UserMenuItem[] = [ + { label: 'Profile', icon: 'user', routerLink: '/profile' }, + { label: 'Settings', icon: 'settings', disabled: true, badge: 'Soon' }, + ]; + + protected readonly signedInAsLabel = 'Signed in as'; + protected readonly signOutLabel = 'Sign out'; + + protected triggerAriaLabel(displayName: string): string { + return `User menu — signed in as ${displayName}`; + } + protected signIn(): void { this.auth.login(); } diff --git a/apps/portal-shell/src/app/components/header/header.html b/apps/portal-shell/src/app/components/header/header.html index 9f7621f..65748f7 100644 --- a/apps/portal-shell/src/app/components/header/header.html +++ b/apps/portal-shell/src/app/components/header/header.html @@ -74,7 +74,7 @@ @switch (authState().kind) { @case ('loading') { @@ -83,7 +83,7 @@ } @case ('authenticated') { @if (authState(); as state) { @if (state.kind === 'authenticated') { - - - + } } } @case ('error') { { expect(root.querySelector('[data-testid="user-avatar"]')).toBeNull(); }); - it('renders the user avatar + display name + Sign out button when authenticated', async () => { + it('renders the user menu trigger with initials when authenticated', async () => { const { fixture } = await setup({ onBootstrap: 'authenticated' }); const root = fixture.nativeElement as HTMLElement; - const avatar = root.querySelector('[data-testid="user-avatar"]'); + // The avatar + dropdown live inside the shared `lib-user-menu`; + // we assert at the trigger boundary and let the shared spec + // cover panel internals (UserMenu.spec.ts). + const menu = root.querySelector('[data-testid="user-menu"]'); + expect(menu).not.toBeNull(); + const avatar = menu?.querySelector('.user-menu__avatar'); expect(avatar?.textContent?.trim()).toBe('JD'); - expect(root.querySelector('[data-testid="user-displayname"]')?.textContent?.trim()).toBe( + expect(root.querySelector('[data-testid="sign-in-button"]')).toBeNull(); + }); + + it('opens the menu and exposes displayName + Sign out inside the panel', async () => { + const { fixture } = await setup({ onBootstrap: 'authenticated' }); + const trigger = (fixture.nativeElement as HTMLElement).querySelector( + '[data-testid="user-menu"] button[aria-haspopup="menu"]', + ); + trigger?.click(); + fixture.detectChanges(); + await fixture.whenStable(); + const panel = document.body.querySelector('.user-menu__panel'); + expect(panel).not.toBeNull(); + expect(panel?.querySelector('.user-menu__header-name')?.textContent).toContain( USER.displayName, ); - const signOut = root.querySelector( - '[data-testid="sign-out-button"]', - ) as HTMLButtonElement | null; - expect(signOut).not.toBeNull(); - expect(root.querySelector('[data-testid="sign-in-button"]')).toBeNull(); + expect(panel?.querySelector('.user-menu__item--sign-out')).not.toBeNull(); }); it('shows the error chip when /me fails with a non-401', async () => { @@ -149,7 +163,7 @@ describe('Header', () => { const { fixture, httpCtrl } = await setup({ onBootstrap: 'leave' }); const root = fixture.nativeElement as HTMLElement; expect(root.querySelector('[data-testid="sign-in-button"]')).toBeNull(); - expect(root.querySelector('[data-testid="user-avatar"]')).toBeNull(); + expect(root.querySelector('[data-testid="user-menu"]')).toBeNull(); // Drain the still-pending request so the controller stays clean. httpCtrl.expectOne(ME_URL).flush({}, { status: 401, statusText: 'Unauthorized' }); }); diff --git a/apps/portal-shell/src/app/components/header/header.ts b/apps/portal-shell/src/app/components/header/header.ts index 8150d7a..d6f3519 100644 --- a/apps/portal-shell/src/app/components/header/header.ts +++ b/apps/portal-shell/src/app/components/header/header.ts @@ -1,7 +1,7 @@ import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; import { RouterLink } from '@angular/router'; import { AuthService, type CurrentUser } from 'feature-auth'; -import { Icon } from 'shared-ui'; +import { Icon, UserMenu, type UserMenuItem } from 'shared-ui'; import { LayoutStateService } from 'shared-state'; import { ThemeSwitcher } from '../theme-switcher/theme-switcher'; @@ -27,7 +27,7 @@ import { ThemeSwitcher } from '../theme-switcher/theme-switcher'; */ @Component({ selector: 'app-header', - imports: [RouterLink, Icon, ThemeSwitcher], + imports: [RouterLink, Icon, ThemeSwitcher, UserMenu], templateUrl: './header.html', styleUrl: './header.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -45,6 +45,30 @@ export class Header { return user ? initialsFor(user) : ''; }); + // Static menu rows for the authenticated user. Sign out is NOT in + // this list — it's the dedicated row at the bottom of the panel, + // emitted via the menu's `signOut` output. + protected readonly userMenuItems: readonly UserMenuItem[] = [ + { + label: $localize`:@@header.userMenu.profile:Profile`, + icon: 'user', + routerLink: '/profile', + }, + { + label: $localize`:@@header.userMenu.settings:Settings`, + icon: 'settings', + disabled: true, + badge: $localize`:@@common.badge.soon:Soon`, + }, + ]; + + protected readonly signedInAsLabel = $localize`:@@header.userMenu.signedInAs:Signed in as`; + protected readonly signOutLabel = $localize`:@@header.userMenu.signOut:Sign out`; + + protected triggerAriaLabel(displayName: string): string { + return $localize`:@@header.userMenu.trigger.aria:User menu — signed in as ${displayName}:displayName:`; + } + protected signIn(): void { this.auth.login(); } diff --git a/apps/portal-shell/src/locale/messages.fr.xlf b/apps/portal-shell/src/locale/messages.fr.xlf index 9d81cf3..23e0e41 100644 --- a/apps/portal-shell/src/locale/messages.fr.xlf +++ b/apps/portal-shell/src/locale/messages.fr.xlf @@ -64,6 +64,30 @@ Checking sign-in status… Vérification de la session… + + Profile + Profil + + + Settings + Paramètres + + + Signed in as + Connecté en tant que + + + Sign out + Se déconnecter + + + User menu — signed in as + Menu utilisateur — connecté en tant que + + + Soon + Bientôt + Search the portal Rechercher dans le portail diff --git a/libs/shared/ui/src/index.ts b/libs/shared/ui/src/index.ts index e648a49..3ff53f5 100644 --- a/libs/shared/ui/src/index.ts +++ b/libs/shared/ui/src/index.ts @@ -1 +1,2 @@ export { Icon, type IconName } from './lib/icon/icon'; +export { UserMenu, type UserMenuItem } from './lib/user-menu/user-menu'; diff --git a/libs/shared/ui/src/lib/icon/icon.ts b/libs/shared/ui/src/lib/icon/icon.ts index 394267b..7bb2063 100644 --- a/libs/shared/ui/src/lib/icon/icon.ts +++ b/libs/shared/ui/src/lib/icon/icon.ts @@ -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; diff --git a/libs/shared/ui/src/lib/user-menu/user-menu.html b/libs/shared/ui/src/lib/user-menu/user-menu.html new file mode 100644 index 0000000..c7f32be --- /dev/null +++ b/libs/shared/ui/src/lib/user-menu/user-menu.html @@ -0,0 +1,76 @@ + + + + + diff --git a/libs/shared/ui/src/lib/user-menu/user-menu.scss b/libs/shared/ui/src/lib/user-menu/user-menu.scss new file mode 100644 index 0000000..8b3025b --- /dev/null +++ b/libs/shared/ui/src/lib/user-menu/user-menu.scss @@ -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; + } +} diff --git a/libs/shared/ui/src/lib/user-menu/user-menu.spec.ts b/libs/shared/ui/src/lib/user-menu/user-menu.spec.ts new file mode 100644 index 0000000..14303bf --- /dev/null +++ b/libs/shared/ui/src/lib/user-menu/user-menu.spec.ts @@ -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[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( + '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( + '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( + 'button[aria-haspopup="menu"]', + ); + trigger?.click(); + fixture.detectChanges(); + await fixture.whenStable(); + + const signOut = document.body.querySelector('.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( + 'button[aria-haspopup="menu"]', + ); + trigger?.click(); + fixture.detectChanges(); + await fixture.whenStable(); + + const link = document.body.querySelector( + '.user-menu__item[href="https://example.test/"]', + ); + expect(link).not.toBeNull(); + expect(link?.textContent).toContain('Open elsewhere'); + }); +}); diff --git a/libs/shared/ui/src/lib/user-menu/user-menu.ts b/libs/shared/ui/src/lib/user-menu/user-menu.ts new file mode 100644 index 0000000..7d0e81e --- /dev/null +++ b/libs/shared/ui/src/lib/user-menu/user-menu.ts @@ -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(); + readonly username = input.required(); + readonly initials = input.required(); + readonly items = input.required(); + + /** Localised label for the "Signed in as {username}" panel header. */ + readonly signedInAsLabel = input('Signed in as'); + /** Localised label for the trailing Sign out row. */ + readonly signOutLabel = input('Sign out'); + /** Localised aria-label for the avatar trigger button. */ + readonly triggerAriaLabel = input('User menu'); + + readonly signOut = output(); +}