AI Implementation feature(857): Authenticated Shell: Header, Quick Tabs and Change Password (#14)
This commit was merged in pull request #14.
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-blog-page',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="page-blog">
|
||||
<h2>Blog</h2>
|
||||
<p>Blog posts will appear here.</p>
|
||||
</section>
|
||||
`,
|
||||
})
|
||||
export class BlogPage {}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-challenges-page',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="page-challenges">
|
||||
<h2>Challenges</h2>
|
||||
<p>Challenge list will appear here.</p>
|
||||
</section>
|
||||
`,
|
||||
})
|
||||
export class ChallengesPage {}
|
||||
@@ -1,47 +1,198 @@
|
||||
import { Component, computed, inject } from '@angular/core';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
DestroyRef,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterLink, RouterOutlet } from '@angular/router';
|
||||
import { NavigationEnd, Router, RouterOutlet } from '@angular/router';
|
||||
import { filter } from 'rxjs/operators';
|
||||
import { BootstrapService } from '../../core/services/bootstrap.service';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { shouldShowAdminNav } from './home.shell';
|
||||
import { EventStatusStore } from '../../core/services/event-status.store';
|
||||
import { UserStore } from '../../core/services/user.store';
|
||||
import { ShellHeaderComponent } from '../shell/header/shell-header.component';
|
||||
import {
|
||||
ChangePasswordModalComponent,
|
||||
ChangePasswordSubmitPayload,
|
||||
} from '../shell/change-password/change-password-modal.component';
|
||||
import { ChangePasswordFailure, ChangePasswordResult } from '../../core/services/auth.service';
|
||||
import { QuickTabsComponent, ShellTab } from '../shell/tabs/quick-tabs.component';
|
||||
import { deriveActiveSectionFromUrl, shouldShowAdminNav } from './home.shell';
|
||||
import { formatChangePasswordError } from '../shell/change-password/password-feedback';
|
||||
|
||||
export { shouldShowAdminNav } from './home.shell';
|
||||
const TABS: ShellTab[] = [
|
||||
{ id: 'challenges', label: 'Challenges' },
|
||||
{ id: 'scoreboard', label: 'Scoreboard' },
|
||||
{ id: 'blog', label: 'Blog' },
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: 'app-home',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink, RouterOutlet],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterOutlet,
|
||||
ShellHeaderComponent,
|
||||
ChangePasswordModalComponent,
|
||||
QuickTabsComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="container">
|
||||
<header class="shell-header">
|
||||
<h1 class="shell-title">{{ bootstrap.payload()?.pageTitle ?? 'HIPCTF' }}</h1>
|
||||
<div class="shell-user" *ngIf="auth.isAuthenticated()">
|
||||
<span>Signed in as <b>{{ auth.currentUser()?.username }}</b> ({{ auth.currentUser()?.role }})</span>
|
||||
</div>
|
||||
</header>
|
||||
<app-shell-header
|
||||
[pageTitle]="bootstrap.payload()?.pageTitle ?? 'HIPCTF'"
|
||||
[activeSection]="activeSection()"
|
||||
[eventState]="eventStatus.state()"
|
||||
[countdownText]="eventStatus.countdownText()"
|
||||
[username]="userStore.user()?.username ?? ''"
|
||||
[rankText]="userStore.rankText()"
|
||||
[canAccessAdmin]="canAccessAdmin()"
|
||||
[userMenuOpen]="userMenuOpen()"
|
||||
(titleClick)="goHome()"
|
||||
(usernameMenuToggle)="toggleUserMenu()"
|
||||
(rankClick)="navigateToSection('scoreboard')"
|
||||
(changePasswordClick)="openChangePassword()"
|
||||
(adminClick)="goAdmin()"
|
||||
(logoutClick)="onLogout()"
|
||||
/>
|
||||
|
||||
<nav class="shell-nav" *ngIf="showAdminNav()">
|
||||
<a routerLink="/admin" data-testid="nav-admin">Admin</a>
|
||||
</nav>
|
||||
<app-quick-tabs
|
||||
[tabs]="TABS"
|
||||
[active]="activeTabId()"
|
||||
(tabChange)="navigateToSection($event.id)"
|
||||
/>
|
||||
|
||||
<section class="shell-body">
|
||||
<div *ngIf="!auth.isAuthenticated()">
|
||||
<p>{{ bootstrap.payload()?.welcomeMarkdown }}</p>
|
||||
<a href="/login">Sign in</a>
|
||||
</div>
|
||||
<router-outlet></router-outlet>
|
||||
</section>
|
||||
|
||||
<app-change-password-modal
|
||||
[open]="changePasswordOpen()"
|
||||
[mode]="'self'"
|
||||
[policy]="bootstrap.passwordPolicy()"
|
||||
[errorMessage]="changePasswordError()"
|
||||
[submitting]="changePasswordSubmitting()"
|
||||
(cancel)="closeChangePassword()"
|
||||
(submit)="submitChangePassword($event)"
|
||||
/>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class HomeComponent {
|
||||
bootstrap = inject(BootstrapService);
|
||||
auth = inject(AuthService);
|
||||
export class HomeComponent implements OnInit, OnDestroy {
|
||||
readonly TABS = TABS;
|
||||
readonly bootstrap = inject(BootstrapService);
|
||||
readonly auth = inject(AuthService);
|
||||
readonly eventStatus = inject(EventStatusStore);
|
||||
readonly userStore = inject(UserStore);
|
||||
private readonly router = inject(Router);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
readonly showAdminNav = computed(() =>
|
||||
readonly userMenuOpen = signal(false);
|
||||
readonly changePasswordOpen = signal(false);
|
||||
readonly changePasswordSubmitting = signal(false);
|
||||
readonly changePasswordError = signal<string | null>(null);
|
||||
|
||||
private readonly currentUrl = signal<string>(this.router.url || '/');
|
||||
|
||||
readonly activeSection = computed(() => deriveActiveSectionFromUrl(this.currentUrl()));
|
||||
readonly activeTabId = computed(() => {
|
||||
const url = this.currentUrl();
|
||||
if (url.startsWith('/scoreboard')) return 'scoreboard';
|
||||
if (url.startsWith('/blog')) return 'blog';
|
||||
if (url.startsWith('/admin')) return 'admin';
|
||||
return 'challenges';
|
||||
});
|
||||
|
||||
readonly canAccessAdmin = computed(() =>
|
||||
shouldShowAdminNav({
|
||||
isAuthenticated: this.auth.isAuthenticated(),
|
||||
role: this.auth.currentUser()?.role,
|
||||
}),
|
||||
);
|
||||
|
||||
constructor() {
|
||||
this.router.events
|
||||
.pipe(filter((e): e is NavigationEnd => e instanceof NavigationEnd))
|
||||
.subscribe((e) => this.currentUrl.set(e.urlAfterRedirects || e.url));
|
||||
this.destroyRef.onDestroy(() => {
|
||||
this.eventStatus.stop();
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.userStore.hydrateFromAuth();
|
||||
void this.userStore.loadMe();
|
||||
if (typeof window !== 'undefined' && typeof (window as any).EventSource !== 'undefined') {
|
||||
const Ctor = (window as any).EventSource as new (url: string, init?: any) => any;
|
||||
this.eventStatus.start(() => {
|
||||
return new Ctor('/api/v1/events/status', { withCredentials: true }) as any;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.eventStatus.stop();
|
||||
}
|
||||
|
||||
goHome(): void {
|
||||
void this.router.navigateByUrl('/challenges');
|
||||
this.userMenuOpen.set(false);
|
||||
}
|
||||
|
||||
goAdmin(): void {
|
||||
void this.router.navigateByUrl('/admin');
|
||||
this.userMenuOpen.set(false);
|
||||
}
|
||||
|
||||
toggleUserMenu(): void {
|
||||
this.userMenuOpen.update((v) => !v);
|
||||
}
|
||||
|
||||
navigateToSection(id: string): void {
|
||||
if (id === 'admin') {
|
||||
void this.router.navigateByUrl('/admin');
|
||||
} else {
|
||||
void this.router.navigateByUrl('/' + id);
|
||||
}
|
||||
this.userMenuOpen.set(false);
|
||||
}
|
||||
|
||||
openChangePassword(): void {
|
||||
this.changePasswordError.set(null);
|
||||
this.changePasswordOpen.set(true);
|
||||
this.userMenuOpen.set(false);
|
||||
}
|
||||
|
||||
closeChangePassword(): void {
|
||||
if (this.changePasswordSubmitting()) return;
|
||||
this.changePasswordOpen.set(false);
|
||||
this.changePasswordError.set(null);
|
||||
}
|
||||
|
||||
async submitChangePassword(payload: ChangePasswordSubmitPayload): Promise<void> {
|
||||
this.changePasswordSubmitting.set(true);
|
||||
this.changePasswordError.set(null);
|
||||
const result: ChangePasswordResult = await this.auth.changePassword(payload);
|
||||
this.changePasswordSubmitting.set(false);
|
||||
if (result.ok === true) {
|
||||
this.changePasswordOpen.set(false);
|
||||
return;
|
||||
}
|
||||
const failure = result as ChangePasswordFailure;
|
||||
this.changePasswordError.set(
|
||||
formatChangePasswordError({ code: failure.code, message: failure.message }),
|
||||
);
|
||||
}
|
||||
|
||||
async onLogout(): Promise<void> {
|
||||
await this.auth.logout();
|
||||
this.userMenuOpen.set(false);
|
||||
this.userStore.reset();
|
||||
await this.router.navigateByUrl('/login');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
export function deriveActiveSectionFromUrl(url: string): string {
|
||||
const trimmed = (url || '').split('?')[0].split('#')[0];
|
||||
if (trimmed.startsWith('/scoreboard')) return 'Scoreboard';
|
||||
if (trimmed.startsWith('/blog')) return 'Blog';
|
||||
if (trimmed.startsWith('/admin')) return 'Admin';
|
||||
return 'Challenges';
|
||||
}
|
||||
|
||||
export function shouldShowAdminNav(input: {
|
||||
isAuthenticated: boolean;
|
||||
role: 'admin' | 'player' | undefined;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-scoreboard-page',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="page-scoreboard">
|
||||
<h2>Scoreboard</h2>
|
||||
<p>Live scoreboard will appear here.</p>
|
||||
</section>
|
||||
`,
|
||||
})
|
||||
export class ScoreboardPage {}
|
||||
@@ -0,0 +1,163 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
HostListener,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import {
|
||||
passwordMatchValidator,
|
||||
PasswordPolicy,
|
||||
} from './change-password-modal.validators';
|
||||
|
||||
export type ChangePasswordMode = 'self' | 'admin-reset';
|
||||
|
||||
export interface ChangePasswordSubmitPayload {
|
||||
oldPassword: string;
|
||||
newPassword: string;
|
||||
confirmNewPassword: string;
|
||||
}
|
||||
|
||||
const DEFAULT_POLICY: PasswordPolicy = {
|
||||
minLength: 12,
|
||||
requireMixed: true,
|
||||
description: 'At least 12 characters with upper, lower, digit and symbol',
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-change-password-modal',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, ReactiveFormsModule],
|
||||
template: `
|
||||
@if (open()) {
|
||||
<div class="modal-overlay" data-testid="change-password-overlay" (click)="onCancel()">
|
||||
<div
|
||||
class="modal-card"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="cp-title"
|
||||
data-testid="change-password-modal"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<h2 id="cp-title">Change password</h2>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
||||
@if (mode() === 'self') {
|
||||
<label class="field">
|
||||
<span>Old password</span>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
formControlName="oldPassword"
|
||||
data-testid="cp-old"
|
||||
/>
|
||||
</label>
|
||||
}
|
||||
|
||||
<label class="field">
|
||||
<span>New password</span>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
formControlName="newPassword"
|
||||
data-testid="cp-new"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Confirm new password</span>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
formControlName="confirmNewPassword"
|
||||
data-testid="cp-confirm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
@if (errorMessage()) {
|
||||
<p class="error" data-testid="cp-error">{{ errorMessage() }}</p>
|
||||
}
|
||||
@if (formError()) {
|
||||
<p class="error" data-testid="cp-form-error">{{ formError() }}</p>
|
||||
}
|
||||
<p class="hint" data-testid="cp-policy">{{ policy().description }}</p>
|
||||
|
||||
<div class="actions">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="cp-cancel"
|
||||
(click)="onCancel()"
|
||||
[disabled]="submitting()"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="cp-ok"
|
||||
[disabled]="submitting()"
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class ChangePasswordModalComponent {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
|
||||
readonly open = input<boolean>(false);
|
||||
readonly mode = input<ChangePasswordMode>('self');
|
||||
readonly policy = input<PasswordPolicy>(DEFAULT_POLICY);
|
||||
readonly errorMessage = input<string | null>(null);
|
||||
readonly submitting = input<boolean>(false);
|
||||
|
||||
readonly cancel = output<void>();
|
||||
readonly submit = output<ChangePasswordSubmitPayload>();
|
||||
|
||||
readonly form = this.fb.nonNullable.group(
|
||||
{
|
||||
oldPassword: this.fb.nonNullable.control(''),
|
||||
newPassword: this.fb.nonNullable.control('', [Validators.required]),
|
||||
confirmNewPassword: this.fb.nonNullable.control('', [Validators.required]),
|
||||
},
|
||||
{ validators: passwordMatchValidator },
|
||||
);
|
||||
|
||||
readonly formError = computed<string | null>(() => {
|
||||
const errs = this.form.errors;
|
||||
if (errs?.['passwordMismatch']) return 'Passwords do not match';
|
||||
return null;
|
||||
});
|
||||
|
||||
@HostListener('document:keydown.escape')
|
||||
onEscape(): void {
|
||||
if (this.open()) this.onCancel();
|
||||
}
|
||||
|
||||
onCancel(): void {
|
||||
this.form.reset({ oldPassword: '', newPassword: '', confirmNewPassword: '' });
|
||||
this.cancel.emit();
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.submitting()) return;
|
||||
const v = this.form.getRawValue();
|
||||
if (this.mode() === 'self' && !v.oldPassword) return;
|
||||
if (!v.newPassword || !v.confirmNewPassword) return;
|
||||
if (this.form.invalid) return;
|
||||
this.submit.emit({
|
||||
oldPassword: v.oldPassword,
|
||||
newPassword: v.newPassword,
|
||||
confirmNewPassword: v.confirmNewPassword,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const PasswordPolicySchema = z.object({
|
||||
minLength: z.number().int().nonnegative(),
|
||||
requireMixed: z.boolean(),
|
||||
description: z.string(),
|
||||
});
|
||||
export type PasswordPolicy = z.infer<typeof PasswordPolicySchema>;
|
||||
|
||||
export function passwordMatchValidator(group: { get: (k: string) => any; errors?: any }): { passwordMismatch?: boolean } | null {
|
||||
const newP = group.get('newPassword')?.value;
|
||||
const conf = group.get('confirmNewPassword')?.value;
|
||||
if (!newP || !conf) return null;
|
||||
return newP === conf ? null : { passwordMismatch: true };
|
||||
}
|
||||
|
||||
export function buildPasswordPolicyMessage(policy: PasswordPolicy): string {
|
||||
return policy.description;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export function formatChangePasswordError(input: {
|
||||
code: string | null | undefined;
|
||||
message?: string | null;
|
||||
}): string {
|
||||
const code = (input.code ?? '').toString();
|
||||
const message = (input.message ?? '').toString();
|
||||
switch (code) {
|
||||
case 'INVALID_OLD_PASSWORD':
|
||||
return 'Old password is incorrect';
|
||||
case 'PASSWORDS_DO_NOT_MATCH':
|
||||
return 'New password and confirmation do not match';
|
||||
case 'PASSWORD_POLICY':
|
||||
return message || 'New password does not meet the policy requirements';
|
||||
case 'UNAUTHORIZED':
|
||||
return 'You must be signed in to change your password';
|
||||
default:
|
||||
return message || 'Failed to change password';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { EventState } from '../../../core/services/event-status.store';
|
||||
|
||||
@Component({
|
||||
selector: 'app-shell-header',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<header class="shell-header" data-testid="shell-header">
|
||||
<button
|
||||
type="button"
|
||||
class="shell-title"
|
||||
data-testid="shell-title"
|
||||
(click)="titleClick.emit()"
|
||||
>
|
||||
{{ pageTitle() }}
|
||||
</button>
|
||||
|
||||
<div class="shell-section" data-testid="shell-active-section">
|
||||
{{ activeSection() }}
|
||||
</div>
|
||||
|
||||
<div class="shell-right">
|
||||
<span
|
||||
class="led"
|
||||
[class.led-running]="eventState() === 'running'"
|
||||
[class.led-countdown]="eventState() === 'countdown'"
|
||||
[class.led-stopped]="eventState() === 'stopped'"
|
||||
[class.led-unconfigured]="eventState() === 'unconfigured'"
|
||||
data-testid="shell-led"
|
||||
[attr.aria-label]="'Event status: ' + eventState()"
|
||||
></span>
|
||||
<span class="countdown" data-testid="shell-countdown">{{ countdownText() }}</span>
|
||||
|
||||
<div class="user-menu-wrapper">
|
||||
<button
|
||||
type="button"
|
||||
class="user-menu-trigger"
|
||||
data-testid="user-menu-trigger"
|
||||
(click)="usernameMenuToggle.emit()"
|
||||
>
|
||||
{{ username() }} <span aria-hidden="true">▾</span>
|
||||
</button>
|
||||
|
||||
@if (userMenuOpen()) {
|
||||
<div class="user-menu" role="menu" data-testid="user-menu">
|
||||
<button
|
||||
type="button"
|
||||
class="user-menu-item"
|
||||
data-testid="user-menu-rank"
|
||||
(click)="rankClick.emit()"
|
||||
>
|
||||
{{ rankText() }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="user-menu-item"
|
||||
data-testid="user-menu-change-password"
|
||||
(click)="changePasswordClick.emit()"
|
||||
>
|
||||
Change password
|
||||
</button>
|
||||
@if (canAccessAdmin()) {
|
||||
<button
|
||||
type="button"
|
||||
class="user-menu-item"
|
||||
data-testid="user-menu-admin"
|
||||
(click)="adminClick.emit()"
|
||||
>
|
||||
Admin area
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="user-menu-item"
|
||||
data-testid="user-menu-logout"
|
||||
(click)="logoutClick.emit()"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
`,
|
||||
})
|
||||
export class ShellHeaderComponent {
|
||||
readonly pageTitle = input<string>('HIPCTF');
|
||||
readonly activeSection = input<string>('Challenges');
|
||||
readonly eventState = input<EventState>('unconfigured');
|
||||
readonly countdownText = input<string>('');
|
||||
readonly username = input<string>('');
|
||||
readonly rankText = input<string>('');
|
||||
readonly canAccessAdmin = input<boolean>(false);
|
||||
readonly userMenuOpen = input<boolean>(false);
|
||||
|
||||
readonly titleClick = output<void>();
|
||||
readonly usernameMenuToggle = output<void>();
|
||||
readonly rankClick = output<void>();
|
||||
readonly changePasswordClick = output<void>();
|
||||
readonly adminClick = output<void>();
|
||||
readonly logoutClick = output<void>();
|
||||
|
||||
readonly ledClass = computed(() => `led led-${this.eventState()}`);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
export interface ShellTab {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-quick-tabs',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<nav class="quick-tabs" data-testid="quick-tabs">
|
||||
@for (tab of tabs(); track tab.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="quick-tab"
|
||||
[class.active]="active() === tab.id"
|
||||
[attr.data-testid]="'quick-tab-' + tab.id"
|
||||
(click)="onTabClick(tab)"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
}
|
||||
</nav>
|
||||
`,
|
||||
})
|
||||
export class QuickTabsComponent {
|
||||
readonly tabs = input.required<ShellTab[]>();
|
||||
readonly active = input.required<string>();
|
||||
|
||||
readonly tabChange = output<{ id: string }>();
|
||||
|
||||
onTabClick(tab: ShellTab): void {
|
||||
this.tabChange.emit({ id: tab.id });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user