Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions angular-client/src/components/status-bar/status-bar.component.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/* Block host so the sticky container's containing block spans the whole page. */
:host {
display: block;
position: sticky;
top: 0;
z-index: 20;
}

@keyframes sidebar-in {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}

/* Both surfaces dock flush to the top-right edge. */
.side-handle,
.panel {
position: absolute;
top: 0;
right: 0;
background: var(--color-background-info);
border: 1px solid var(--color-divider);
border-right: none;
border-radius: var(--border-radius-panel) 0 0 var(--border-radius-panel);
}

.side-handle,
.panel-collapse {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
cursor: pointer;
color: var(--color-text-subtitle);
}
.side-handle:hover,
.panel-collapse:hover {
background: rgba(255, 255, 255, 0.08);
}
.side-handle:focus-visible,
.panel-collapse:focus-visible {
outline: 2px solid var(--color-text-primary);
}
.side-handle mat-icon,
.panel-collapse mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
line-height: 1;
}

.side-handle {
width: 30px;
height: 56px;
color: var(--color-text-primary);
outline-offset: -2px;
}

.panel {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10px;
padding: 10px 12px 14px;
box-shadow: -6px 6px 18px rgba(0, 0, 0, 0.45);
animation: sidebar-in 0.2s ease;
}
@media (prefers-reduced-motion: reduce) {
.panel {
animation: none;
}
}

.panel-header {
align-self: stretch;
display: flex;
align-items: center;
justify-content: space-between;
}
.panel-title {
font-size: var(--font-size-xs);
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--color-text-subtitle);
}

.panel-collapse {
width: 28px;
height: 28px;
border: none;
border-radius: 6px;
background: transparent;
outline-offset: 2px;
}
22 changes: 22 additions & 0 deletions angular-client/src/components/status-bar/status-bar.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
@if (collapsed()) {
<button #handle type="button" class="side-handle" (click)="toggle()" aria-expanded="false" aria-label="Show status bar">
<mat-icon aria-hidden="true">chevron_left</mat-icon>
</button>
} @else {
<div class="panel">
<div class="panel-header">
<span class="panel-title">{{ title() }}</span>
<button
#collapseButton
type="button"
class="panel-collapse"
(click)="toggle()"
aria-expanded="true"
aria-label="Hide status bar"
>
<mat-icon aria-hidden="true">chevron_right</mat-icon>
</button>
</div>
<ng-content />
</div>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { Component, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import StatusBarComponent from './status-bar.component';

// A host exercises projection and the title input exactly as a consumer would.
// title is a signal so updates integrate with zoneless change detection.
// Zoneless change detection is registered globally in src/test-setup.ts.
@Component({
template: `<status-bar [title]="title()"><span class="projected">chip</span></status-bar>`,
imports: [StatusBarComponent]
})
class HostComponent {
title = signal('Quick view');
}

describe('StatusBarComponent', () => {
let fixture: ComponentFixture<HostComponent>;
let host: HTMLElement;

const q = (selector: string): HTMLElement | null => host.querySelector(selector);

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [HostComponent]
}).compileComponents();

fixture = TestBed.createComponent(HostComponent);
host = fixture.nativeElement as HTMLElement;
fixture.detectChanges();
});

it('auto-opens with the panel and projected chips, and no edge handle', () => {
expect(q('.panel')).toBeTruthy();
expect(q('.side-handle')).toBeFalsy();
expect(q('.projected')?.textContent).toContain('chip');
});

it('renders the title input in the header', () => {
expect(q('.panel-title')?.textContent?.trim()).toBe('Quick view');

fixture.componentInstance.title.set('Pinned');
fixture.detectChanges();

expect(q('.panel-title')?.textContent?.trim()).toBe('Pinned');
});

it('collapses to a handle and re-expands on toggle', () => {
(q('.panel-collapse') as HTMLButtonElement).click();
fixture.detectChanges();
expect(q('.panel')).toBeFalsy();
expect(q('.side-handle')).toBeTruthy();

(q('.side-handle') as HTMLButtonElement).click();
fixture.detectChanges();
expect(q('.panel')).toBeTruthy();
expect(q('.side-handle')).toBeFalsy();
});

it('moves keyboard focus to the successor control on toggle', async () => {
(q('.panel-collapse') as HTMLButtonElement).click();
fixture.detectChanges();
await fixture.whenStable();
expect(document.activeElement).toBe(q('.side-handle'));

(q('.side-handle') as HTMLButtonElement).click();
fixture.detectChanges();
await fixture.whenStable();
expect(document.activeElement).toBe(q('.panel-collapse'));
});
});
50 changes: 50 additions & 0 deletions angular-client/src/components/status-bar/status-bar.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import {
afterNextRender,
ChangeDetectionStrategy,
Component,
ElementRef,
inject,
Injector,
input,
signal,
viewChild
} from '@angular/core';
import { MatIcon } from '@angular/material/icon';

/**
* Reusable read-only "quick view" status bar: a right-edge sidebar pop-out, top-aligned,
* that surfaces pinned chips (projected via `<ng-content/>`). It auto-opens; a handle at the
* top-right edge re-opens it after collapse, and it stays sticky while the page scrolls —
* pinning to the top-right corner. Foundation for user-pinned, multi-chip status (see #709).
*/
@Component({
selector: 'status-bar',
templateUrl: './status-bar.component.html',
styleUrl: './status-bar.component.css',
imports: [MatIcon],
changeDetection: ChangeDetectionStrategy.OnPush
})
export default class StatusBarComponent {
private readonly injector = inject(Injector);

/** Small header label shown above the pinned chips. */
readonly title = input('Quick view');

protected readonly collapsed = signal(false);

private readonly handle = viewChild<ElementRef<HTMLButtonElement>>('handle');
private readonly collapseButton = viewChild<ElementRef<HTMLButtonElement>>('collapseButton');

protected toggle(): void {
this.collapsed.update((collapsed) => !collapsed);
// The @if/@else swaps which control is on screen, so move keyboard focus to the
// successor — otherwise focus falls back to <body> and the focus-visible ring is lost.
afterNextRender(
() => {
const target = this.collapsed() ? this.handle() : this.collapseButton();
target?.nativeElement.focus();
},
{ injector: this.injector }
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
.card {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 6px 14px;
border-radius: var(--border-radius-panel);
background: var(--color-background-page);
border: 1px solid var(--color-divider);
}
.icon {
width: 26px;
height: 26px;
color: var(--color-text-subtitle);
}
.col {
display: flex;
flex-direction: column;
line-height: 1.1;
}
.unit,
.subtitle {
font-size: var(--font-size-xs);
color: var(--color-text-subtitle);
}
.value {
font-size: var(--font-size-lg);
font-weight: 700;
color: var(--color-text-primary);
}
.unit {
margin-left: 3px;
}
.subtitle {
text-transform: uppercase;
letter-spacing: 0.04em;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<div class="card">
<mat-icon class="icon" svgIcon="battery_charging_2" aria-hidden="true" />
<div class="col">
<div>
<span class="value">{{ value() }}</span
><span class="unit">V</span>
</div>
<div class="subtitle">LV Battery</div>
</div>
<span class="dot" role="img" [style.background]="dotColor()" [attr.aria-label]="dotLabel()"></span>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import LvBatteryChipComponent from './lv-battery-chip.component';

// Presentational component: drive it through its inputs and assert the rendered DOM,
// so the tests cover external behavior rather than internal signals. Zoneless change
// detection is registered globally in src/test-setup.ts.
describe('LvBatteryChipComponent', () => {
let fixture: ComponentFixture<LvBatteryChipComponent>;

const text = (selector: string): string =>
(fixture.nativeElement as HTMLElement).querySelector(selector)?.textContent?.trim() ?? '';

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [LvBatteryChipComponent]
}).compileComponents();

fixture = TestBed.createComponent(LvBatteryChipComponent);
fixture.detectChanges();
});

it('should create', () => {
expect(fixture.componentInstance).toBeTruthy();
});

it('shows a placeholder until a voltage arrives', () => {
// voltage input defaults to undefined
expect(text('.value')).toBe('–');
});

it('formats the voltage to two decimals', () => {
fixture.componentRef.setInput('voltage', 24.5);
fixture.detectChanges();

expect(text('.value')).toBe('24.50');
});

it('shows the placeholder for a non-finite voltage', () => {
fixture.componentRef.setInput('voltage', NaN);
fixture.detectChanges();

expect(text('.value')).toBe('–');
});

it('labels the status dot for accessibility, driven by the fault flag', () => {
const dot = (): HTMLElement => (fixture.nativeElement as HTMLElement).querySelector('.dot')!;

expect(dot().getAttribute('aria-label')).toBe('LV battery nominal');

fixture.componentRef.setInput('faulted', true);
fixture.detectChanges();

expect(dot().getAttribute('aria-label')).toBe('LV battery fault');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import { MatIcon } from '@angular/material/icon';

/** Dot color while the LV low-voltage fault is active (shared battery-low token). */
const LV_FAULT_COLOR = 'var(--color-battery-low)';
/** Dot color while no LV low-voltage fault is active (shared battery-high token). */
const LV_NORMAL_COLOR = 'var(--color-battery-high)';

/**
* LV Battery chip for the eFuses status bar: the live voltage over a "LV Battery" subtitle,
* with a status dot that is green normally and red on fault.
*
* Presentational — the host supplies the live voltage and the firmware low-voltage fault flag.
* The warning is driven purely by the fault flag, never a hardcoded voltage cutoff. This is the
* physical LV battery (VCU/LV/voltage), distinct from the LV eFuse card (VCU/eFuses/LV/Voltage).
*/
@Component({
selector: 'lv-battery-chip',
templateUrl: './lv-battery-chip.component.html',
styleUrl: './lv-battery-chip.component.css',
imports: [MatIcon],
changeDetection: ChangeDetectionStrategy.OnPush
})
export default class LvBatteryChipComponent {
/** Live LV battery voltage (V); undefined until the first reading arrives. */
readonly voltage = input<number>();
/** Whether the firmware LV low-voltage fault flag is active. */
readonly faulted = input(false);

protected readonly value = computed(() => {
const voltage = this.voltage();
return voltage !== undefined && isFinite(voltage) ? voltage.toFixed(2) : '–';
});

protected readonly dotColor = computed(() => (this.faulted() ? LV_FAULT_COLOR : LV_NORMAL_COLOR));

/** Accessible label so the fault state is not conveyed by color alone (WCAG 1.4.1). */
protected readonly dotLabel = computed(() => (this.faulted() ? 'LV battery fault' : 'LV battery nominal'));
}
Loading