fix(web): harden derived shared ui components
This commit is contained in:
@@ -69,7 +69,7 @@ export type HeadingLevel = 1 | 2 | 3;
|
||||
</button>
|
||||
}
|
||||
|
||||
<ng-content select="[header-actions]"></ng-content>
|
||||
<ng-content select="[header-actions],[secondary-actions],[primary-actions]"></ng-content>
|
||||
</div>
|
||||
</header>
|
||||
`,
|
||||
|
||||
@@ -83,6 +83,28 @@ describe('MetricCardComponent', () => {
|
||||
|
||||
expect(el.querySelector('.metric-card__subtitle')).toBeNull();
|
||||
});
|
||||
|
||||
it('should recompute derived output when inputs change after first render', () => {
|
||||
fixture.componentRef.setInput('label', 'Latency');
|
||||
fixture.componentRef.setInput('value', 150);
|
||||
fixture.componentRef.setInput('delta', 5);
|
||||
fixture.componentRef.setInput('deltaDirection', 'up-is-bad');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.querySelector('.metric-card__value')?.textContent?.trim()).toBe('150');
|
||||
expect(el.querySelector('.metric-card__delta')?.textContent?.trim()).toContain('+5%');
|
||||
expect(el.querySelector('.metric-card__delta')?.classList.contains('metric-card__delta--bad')).toBe(true);
|
||||
|
||||
fixture.componentRef.setInput('value', 95);
|
||||
fixture.componentRef.setInput('delta', -3);
|
||||
fixture.componentRef.setInput('deltaDirection', 'up-is-good');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.querySelector('.metric-card__value')?.textContent?.trim()).toBe('95');
|
||||
expect(el.querySelector('.metric-card__delta')?.textContent?.trim()).toContain('-3%');
|
||||
expect(el.querySelector('.metric-card__delta')?.classList.contains('metric-card__delta--bad')).toBe(true);
|
||||
expect(el.querySelector('.metric-card')?.getAttribute('aria-label')).toContain('Latency: 95, -3%');
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* - ARIA accessibility
|
||||
*/
|
||||
|
||||
import { Component, Input, ChangeDetectionStrategy, computed, input } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Controls color semantics for delta values.
|
||||
@@ -309,7 +309,7 @@ export class MetricCardComponent {
|
||||
@Input() error?: string;
|
||||
|
||||
/** Formatted display value with locale-aware number formatting */
|
||||
readonly formattedValue = computed(() => {
|
||||
formattedValue(): string {
|
||||
const val = this.value;
|
||||
if (typeof val === 'string') return val;
|
||||
if (typeof val === 'number') {
|
||||
@@ -319,25 +319,25 @@ export class MetricCardComponent {
|
||||
return '--';
|
||||
}
|
||||
return String(val);
|
||||
});
|
||||
}
|
||||
|
||||
/** Delta display text: "+12.3%" or "-5.1%" */
|
||||
readonly deltaDisplay = computed(() => {
|
||||
deltaDisplay(): string {
|
||||
if (this.delta === undefined || this.delta === null) return '';
|
||||
const abs = Math.abs(this.delta);
|
||||
const formatted = abs % 1 === 0 ? abs.toString() : abs.toFixed(1);
|
||||
const sign = this.delta > 0 ? '+' : this.delta < 0 ? '-' : '';
|
||||
return `${sign}${formatted}%`;
|
||||
});
|
||||
}
|
||||
|
||||
/** Arrow direction based on delta sign */
|
||||
readonly deltaIcon = computed((): 'up' | 'down' | null => {
|
||||
deltaIcon(): 'up' | 'down' | null {
|
||||
if (this.delta === undefined || this.delta === null || this.delta === 0) return null;
|
||||
return this.delta > 0 ? 'up' : 'down';
|
||||
});
|
||||
}
|
||||
|
||||
/** CSS class for delta badge based on direction semantics */
|
||||
readonly deltaColorClass = computed((): string => {
|
||||
deltaColorClass(): string {
|
||||
const base = 'metric-card__delta';
|
||||
if (this.delta === undefined || this.delta === null || this.delta === 0) {
|
||||
return `${base} ${base}--neutral`;
|
||||
@@ -353,10 +353,10 @@ export class MetricCardComponent {
|
||||
(this.deltaDirection === 'up-is-bad' && !isPositive);
|
||||
|
||||
return `${base} ${isGood ? `${base}--good` : `${base}--bad`}`;
|
||||
});
|
||||
}
|
||||
|
||||
/** Composite ARIA label for the entire card */
|
||||
readonly ariaLabel = computed(() => {
|
||||
ariaLabel(): string {
|
||||
if (this.loading) return `${this.label}: loading`;
|
||||
if (this.error) return `${this.label}: error, ${this.error}`;
|
||||
if (this.empty) return `${this.label}: no data`;
|
||||
@@ -366,10 +366,10 @@ export class MetricCardComponent {
|
||||
const deltaStr = this.delta != null ? `, ${this.deltaDisplay()}` : '';
|
||||
const severityStr = this.severity ? `, ${this.severity}` : '';
|
||||
return `${this.label}: ${val}${unitStr}${deltaStr}${severityStr}`;
|
||||
});
|
||||
}
|
||||
|
||||
/** ARIA label specifically for the delta badge */
|
||||
readonly deltaAriaLabel = computed(() => {
|
||||
deltaAriaLabel(): string {
|
||||
if (this.delta === undefined || this.delta === null) return '';
|
||||
const display = this.deltaDisplay();
|
||||
if (this.deltaDirection === 'neutral') return `Change: ${display}`;
|
||||
@@ -380,5 +380,5 @@ export class MetricCardComponent {
|
||||
(this.deltaDirection === 'up-is-bad' && !isPositive);
|
||||
|
||||
return `Change: ${display}, ${isGood ? 'favorable' : 'unfavorable'}`;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { PageHeaderComponent } from './page-header.component';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [PageHeaderComponent],
|
||||
template: `
|
||||
<app-page-header title="Compatibility Title" subtitle="Compatibility Subtitle">
|
||||
<button secondary-actions data-testid="secondary-action">Secondary</button>
|
||||
<button primary-actions data-testid="primary-action">Primary</button>
|
||||
</app-page-header>
|
||||
`,
|
||||
})
|
||||
class PageHeaderHostComponent {}
|
||||
|
||||
describe('PageHeaderComponent', () => {
|
||||
let fixture: ComponentFixture<PageHeaderHostComponent>;
|
||||
let el: HTMLElement;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [PageHeaderHostComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PageHeaderHostComponent);
|
||||
el = fixture.nativeElement;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('forwards legacy action slots into the context-header action area', () => {
|
||||
const actions = el.querySelector('.context-header__actions');
|
||||
expect(actions?.querySelector('[data-testid="secondary-action"]')?.textContent?.trim()).toBe('Secondary');
|
||||
expect(actions?.querySelector('[data-testid="primary-action"]')?.textContent?.trim()).toBe('Primary');
|
||||
});
|
||||
|
||||
it('forwards title and subtitle to the canonical context header', () => {
|
||||
expect(el.querySelector('.context-header__title')?.textContent?.trim()).toBe('Compatibility Title');
|
||||
expect(el.querySelector('.context-header__subtitle')?.textContent?.trim()).toBe('Compatibility Subtitle');
|
||||
});
|
||||
});
|
||||
@@ -201,6 +201,19 @@ describe('TimelineListComponent', () => {
|
||||
expect(timeText).toMatch(/\d{4}-\d{2}-\d{2}/);
|
||||
});
|
||||
|
||||
it('should refresh relative time when flat-mode events change', () => {
|
||||
host.groupByDate = false;
|
||||
host.events = [{ id: 't-flat', timestamp: minutesAgo(5), title: 'Flat event' }];
|
||||
fixture.detectChanges();
|
||||
expect(el.querySelector('.timeline__time')!.textContent).toContain('5m ago');
|
||||
|
||||
vi.setSystemTime(new Date(NOW.getTime() + 10 * 60_000));
|
||||
host.events = [{ ...host.events[0] }];
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.querySelector('.timeline__time')!.textContent).toContain('15m ago');
|
||||
});
|
||||
|
||||
it('should show full ISO timestamp in title attribute (tooltip)', () => {
|
||||
host.events = [{ id: 't-tip', timestamp: minutesAgo(10), title: 'Tooltip event' }];
|
||||
fixture.detectChanges();
|
||||
|
||||
@@ -183,7 +183,7 @@ function toDateKey(iso: string): string {
|
||||
}
|
||||
} @else {
|
||||
<!-- Flat list -->
|
||||
@for (event of events(); track event.id; let last = $last) {
|
||||
@for (event of renderedEvents(); track event.id; let last = $last) {
|
||||
<ng-container
|
||||
*ngTemplateOutlet="eventRow; context: { $implicit: event, last: last }"
|
||||
></ng-container>
|
||||
@@ -621,14 +621,18 @@ export class TimelineListComponent {
|
||||
/** Cached "now" for consistent relative time within a render cycle. */
|
||||
private renderNow = new Date();
|
||||
|
||||
/** Flat and grouped modes share the same render clock refresh path. */
|
||||
readonly renderedEvents = computed<TimelineEvent[]>(() => {
|
||||
const evts = this.events();
|
||||
this.renderNow = new Date();
|
||||
return evts;
|
||||
});
|
||||
|
||||
/** Computed date groups for grouped display. */
|
||||
readonly dateGroups = computed<DateGroup[]>(() => {
|
||||
const evts = this.events();
|
||||
const evts = this.renderedEvents();
|
||||
if (!evts.length) return [];
|
||||
|
||||
// Refresh "now" each time events change
|
||||
this.renderNow = new Date();
|
||||
|
||||
const groups = new Map<string, TimelineEvent[]>();
|
||||
for (const evt of evts) {
|
||||
const key = toDateKey(evt.timestamp);
|
||||
|
||||
Reference in New Issue
Block a user