feat(shared-ui): user menu dropdown + integration on portal-shell + portal-admin
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:
@@ -27,9 +27,17 @@
|
|||||||
Sign in
|
Sign in
|
||||||
</button>
|
</button>
|
||||||
} @case ('authenticated') { @if (authState(); as state) { @if (state.kind === 'authenticated') {
|
} @case ('authenticated') { @if (authState(); as state) { @if (state.kind === 'authenticated') {
|
||||||
<span class="avatar" [attr.aria-label]="state.user.displayName">{{ initials() }}</span>
|
<lib-user-menu
|
||||||
<span class="auth-name">{{ state.user.displayName }}</span>
|
[displayName]="state.user.displayName"
|
||||||
<button type="button" class="btn btn--secondary" (click)="signOut()">Sign out</button>
|
[username]="state.user.username"
|
||||||
|
[initials]="initials()"
|
||||||
|
[items]="userMenuItems"
|
||||||
|
[signedInAsLabel]="signedInAsLabel"
|
||||||
|
[signOutLabel]="signOutLabel"
|
||||||
|
[triggerAriaLabel]="triggerAriaLabel(state.user.displayName)"
|
||||||
|
(signOut)="signOut()"
|
||||||
|
data-testid="user-menu"
|
||||||
|
/>
|
||||||
} } } }
|
} } } }
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -57,6 +57,15 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
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 {
|
.auth-status--loading {
|
||||||
|
|||||||
@@ -67,12 +67,26 @@ describe('AdminHeader', () => {
|
|||||||
expect(button?.textContent?.trim()).toBe('Sign in');
|
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 { fixture } = await setup({ onBootstrap: 'authenticated' });
|
||||||
const root = fixture.nativeElement as HTMLElement;
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
expect(root.querySelector('.avatar')?.textContent?.trim()).toBe('AS');
|
const menu = root.querySelector('[data-testid="user-menu"]');
|
||||||
expect(root.querySelector('.auth-name')?.textContent).toContain('Admin Smith');
|
expect(menu).not.toBeNull();
|
||||||
expect(root.querySelector('.btn--secondary')?.textContent?.trim()).toBe('Sign out');
|
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<HTMLButtonElement>(
|
||||||
|
'[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 () => {
|
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`);
|
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' });
|
const { fixture, navigate } = await setup({ onBootstrap: 'authenticated' });
|
||||||
(fixture.nativeElement as HTMLElement)
|
const trigger = (fixture.nativeElement as HTMLElement).querySelector<HTMLButtonElement>(
|
||||||
.querySelector<HTMLButtonElement>('.btn--secondary')
|
'[data-testid="user-menu"] button[aria-haspopup="menu"]',
|
||||||
?.click();
|
);
|
||||||
|
trigger?.click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
document.body.querySelector<HTMLButtonElement>('.user-menu__item--sign-out')?.click();
|
||||||
expect(navigate).toHaveBeenCalledWith(`${BFF_BASE}/admin/auth/logout`);
|
expect(navigate).toHaveBeenCalledWith(`${BFF_BASE}/admin/auth/logout`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
import { AuthService, type CurrentUser } from 'feature-auth';
|
import { AuthService, type CurrentUser } from 'feature-auth';
|
||||||
|
import { UserMenu, type UserMenuItem } from 'shared-ui';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin-portal header. Deliberately leaner than the user-portal
|
* Admin-portal header. Deliberately leaner than the user-portal
|
||||||
@@ -26,7 +27,7 @@ import { AuthService, type CurrentUser } from 'feature-auth';
|
|||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-admin-header',
|
selector: 'app-admin-header',
|
||||||
imports: [RouterLink],
|
imports: [RouterLink, UserMenu],
|
||||||
templateUrl: './header.html',
|
templateUrl: './header.html',
|
||||||
styleUrl: './header.scss',
|
styleUrl: './header.scss',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
@@ -41,6 +42,22 @@ export class AdminHeader {
|
|||||||
return user ? initialsFor(user) : '';
|
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 {
|
protected signIn(): void {
|
||||||
this.auth.login();
|
this.auth.login();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@
|
|||||||
@switch (authState().kind) { @case ('loading') {
|
@switch (authState().kind) { @case ('loading') {
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="ml-1 inline-flex h-9 w-9 items-center justify-center rounded-full bg-gray-200 text-sm font-semibold text-gray-500 dark:bg-gray-700 dark:text-gray-300"
|
class="ml-1 inline-flex h-9 w-9 items-center justify-center rounded-md bg-gray-200 text-sm font-semibold text-gray-500 dark:bg-gray-700 dark:text-gray-300"
|
||||||
>
|
>
|
||||||
…
|
…
|
||||||
</span>
|
</span>
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="signIn()"
|
(click)="signIn()"
|
||||||
class="ml-1 inline-flex h-11 items-center gap-2 rounded-full bg-brand-primary-500 px-4 text-sm font-semibold text-white transition-colors hover:bg-brand-primary-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-primary-500"
|
class="ml-1 inline-flex h-11 items-center gap-2 rounded-md bg-brand-primary-500 px-4 text-sm font-semibold text-white transition-colors hover:bg-brand-primary-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-primary-500"
|
||||||
data-testid="sign-in-button"
|
data-testid="sign-in-button"
|
||||||
i18n="@@header.signIn"
|
i18n="@@header.signIn"
|
||||||
>
|
>
|
||||||
@@ -91,30 +91,20 @@
|
|||||||
</button>
|
</button>
|
||||||
} @case ('authenticated') { @if (authState(); as state) { @if (state.kind === 'authenticated')
|
} @case ('authenticated') { @if (authState(); as state) { @if (state.kind === 'authenticated')
|
||||||
{
|
{
|
||||||
<span
|
<lib-user-menu
|
||||||
aria-hidden="true"
|
[displayName]="state.user.displayName"
|
||||||
data-testid="user-avatar"
|
[username]="state.user.username"
|
||||||
class="ml-1 inline-flex h-9 w-9 items-center justify-center rounded-full bg-brand-primary-500 text-sm font-semibold text-white"
|
[initials]="initials()"
|
||||||
>
|
[items]="userMenuItems"
|
||||||
{{ initials() }}
|
[signedInAsLabel]="signedInAsLabel"
|
||||||
</span>
|
[signOutLabel]="signOutLabel"
|
||||||
<span
|
[triggerAriaLabel]="triggerAriaLabel(state.user.displayName)"
|
||||||
class="hidden text-sm font-medium text-gray-700 sm:inline dark:text-gray-200"
|
(signOut)="signOut()"
|
||||||
data-testid="user-displayname"
|
data-testid="user-menu"
|
||||||
>{{ state.user.displayName }}</span
|
/>
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="signOut()"
|
|
||||||
class="inline-flex h-11 items-center gap-2 rounded-full px-3 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-brand-primary-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-primary-500 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-brand-primary-300"
|
|
||||||
data-testid="sign-out-button"
|
|
||||||
i18n="@@header.signOut"
|
|
||||||
>
|
|
||||||
Sign out
|
|
||||||
</button>
|
|
||||||
} } } @case ('error') {
|
} } } @case ('error') {
|
||||||
<span
|
<span
|
||||||
class="ml-1 inline-flex h-9 items-center rounded-full bg-amber-100 px-3 text-sm font-medium text-amber-800 dark:bg-amber-900 dark:text-amber-200"
|
class="ml-1 inline-flex h-9 items-center rounded-md bg-amber-100 px-3 text-sm font-medium text-amber-800 dark:bg-amber-900 dark:text-amber-200"
|
||||||
data-testid="auth-error"
|
data-testid="auth-error"
|
||||||
role="status"
|
role="status"
|
||||||
i18n="@@header.authError"
|
i18n="@@header.authError"
|
||||||
|
|||||||
@@ -122,19 +122,33 @@ describe('Header', () => {
|
|||||||
expect(root.querySelector('[data-testid="user-avatar"]')).toBeNull();
|
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 { fixture } = await setup({ onBootstrap: 'authenticated' });
|
||||||
const root = fixture.nativeElement as HTMLElement;
|
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(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<HTMLButtonElement>(
|
||||||
|
'[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,
|
USER.displayName,
|
||||||
);
|
);
|
||||||
const signOut = root.querySelector(
|
expect(panel?.querySelector('.user-menu__item--sign-out')).not.toBeNull();
|
||||||
'[data-testid="sign-out-button"]',
|
|
||||||
) as HTMLButtonElement | null;
|
|
||||||
expect(signOut).not.toBeNull();
|
|
||||||
expect(root.querySelector('[data-testid="sign-in-button"]')).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows the error chip when /me fails with a non-401', async () => {
|
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 { fixture, httpCtrl } = await setup({ onBootstrap: 'leave' });
|
||||||
const root = fixture.nativeElement as HTMLElement;
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
expect(root.querySelector('[data-testid="sign-in-button"]')).toBeNull();
|
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.
|
// Drain the still-pending request so the controller stays clean.
|
||||||
httpCtrl.expectOne(ME_URL).flush({}, { status: 401, statusText: 'Unauthorized' });
|
httpCtrl.expectOne(ME_URL).flush({}, { status: 401, statusText: 'Unauthorized' });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
import { AuthService, type CurrentUser } from 'feature-auth';
|
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 { LayoutStateService } from 'shared-state';
|
||||||
import { ThemeSwitcher } from '../theme-switcher/theme-switcher';
|
import { ThemeSwitcher } from '../theme-switcher/theme-switcher';
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ import { ThemeSwitcher } from '../theme-switcher/theme-switcher';
|
|||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-header',
|
selector: 'app-header',
|
||||||
imports: [RouterLink, Icon, ThemeSwitcher],
|
imports: [RouterLink, Icon, ThemeSwitcher, UserMenu],
|
||||||
templateUrl: './header.html',
|
templateUrl: './header.html',
|
||||||
styleUrl: './header.scss',
|
styleUrl: './header.scss',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
@@ -45,6 +45,30 @@ export class Header {
|
|||||||
return user ? initialsFor(user) : '';
|
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 {
|
protected signIn(): void {
|
||||||
this.auth.login();
|
this.auth.login();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,30 @@
|
|||||||
<source>Checking sign-in status…</source>
|
<source>Checking sign-in status…</source>
|
||||||
<target>Vérification de la session…</target>
|
<target>Vérification de la session…</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="header.userMenu.profile" datatype="html">
|
||||||
|
<source>Profile</source>
|
||||||
|
<target>Profil</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="header.userMenu.settings" datatype="html">
|
||||||
|
<source>Settings</source>
|
||||||
|
<target>Paramètres</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="header.userMenu.signedInAs" datatype="html">
|
||||||
|
<source>Signed in as</source>
|
||||||
|
<target>Connecté en tant que</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="header.userMenu.signOut" datatype="html">
|
||||||
|
<source>Sign out</source>
|
||||||
|
<target>Se déconnecter</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="header.userMenu.trigger.aria" datatype="html">
|
||||||
|
<source>User menu — signed in as <x id="displayName" equiv-text="displayName"/></source>
|
||||||
|
<target>Menu utilisateur — connecté en tant que <x id="displayName" equiv-text="displayName"/></target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="common.badge.soon" datatype="html">
|
||||||
|
<source>Soon</source>
|
||||||
|
<target>Bientôt</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="header.search.label" datatype="html">
|
<trans-unit id="header.search.label" datatype="html">
|
||||||
<source>Search the portal</source>
|
<source>Search the portal</source>
|
||||||
<target>Rechercher dans le portail</target>
|
<target>Rechercher dans le portail</target>
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export { Icon, type IconName } from './lib/icon/icon';
|
export { Icon, type IconName } from './lib/icon/icon';
|
||||||
|
export { UserMenu, type UserMenuItem } from './lib/user-menu/user-menu';
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
Grid2x2,
|
Grid2x2,
|
||||||
House,
|
House,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
|
LogOut,
|
||||||
type LucideIconData,
|
type LucideIconData,
|
||||||
LucideAngularModule,
|
LucideAngularModule,
|
||||||
Mail,
|
Mail,
|
||||||
@@ -24,6 +25,7 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
Store,
|
Store,
|
||||||
Sun,
|
Sun,
|
||||||
|
User,
|
||||||
UsersRound,
|
UsersRound,
|
||||||
Wrench,
|
Wrench,
|
||||||
} from 'lucide-angular';
|
} from 'lucide-angular';
|
||||||
@@ -65,6 +67,7 @@ const ICON_REGISTRY = {
|
|||||||
'grid-2x2': Grid2x2,
|
'grid-2x2': Grid2x2,
|
||||||
home: House,
|
home: House,
|
||||||
'layout-dashboard': LayoutDashboard,
|
'layout-dashboard': LayoutDashboard,
|
||||||
|
'log-out': LogOut,
|
||||||
mail: Mail,
|
mail: Mail,
|
||||||
monitor: Monitor,
|
monitor: Monitor,
|
||||||
moon: Moon,
|
moon: Moon,
|
||||||
@@ -72,6 +75,7 @@ const ICON_REGISTRY = {
|
|||||||
settings: Settings,
|
settings: Settings,
|
||||||
store: Store,
|
store: Store,
|
||||||
sun: Sun,
|
sun: Sun,
|
||||||
|
user: User,
|
||||||
'users-round': UsersRound,
|
'users-round': UsersRound,
|
||||||
wrench: Wrench,
|
wrench: Wrench,
|
||||||
} satisfies Record<string, LucideIconData>;
|
} 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>();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user