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
119 changes: 119 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,119 @@
/* 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;
}
}

/* Collapsed affordance: a handle docked flush to the top-right edge. */
.side-handle {
position: absolute;
top: 0;
right: 0;
width: 30px;
height: 56px;
padding: 0;
border: 1px solid var(--color-divider);
border-right: none;
border-radius: 10px 0 0 10px;
background: var(--color-background-info);
color: var(--color-text-primary);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.side-handle:hover {
background: #383838;
}
.side-handle:focus-visible {
outline: 2px solid var(--color-text-primary);
outline-offset: -2px;
}
.side-handle mat-icon {
width: 26px;
height: 26px;
font-size: 26px;
line-height: 26px;
}

/* Open panel: docked flush to the right edge, top-aligned, hugs its chip(s). */
.panel {
position: absolute;
top: 0;
right: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10px;
padding: 10px 12px 14px;
background: var(--color-background-info);
border: 1px solid var(--color-divider);
border-right: none;
border-radius: 12px 0 0 12px;
box-shadow: -6px 6px 18px rgba(0, 0, 0, 0.45);
}

/* Respect reduced-motion: only slide the panel in when motion is welcome. */
@media (prefers-reduced-motion: no-preference) {
.panel {
animation: sidebar-in 0.2s ease;
}
}

/* Header: small title on the left, close chevron on the right. */
.panel-header {
align-self: stretch;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.panel-title {
font-size: var(--font-size-sm, 12px);
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--color-text-subtitle);
padding-left: 2px;
}

.panel-collapse {
flex: 0 0 auto;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: 6px;
background: transparent;
color: var(--color-text-subtitle);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.panel-collapse:hover {
background: rgba(255, 255, 255, 0.08);
}
.panel-collapse:focus-visible {
outline: 2px solid var(--color-text-primary);
outline-offset: 2px;
}
.panel-collapse mat-icon {
width: 22px;
height: 22px;
font-size: 22px;
line-height: 22px;
}
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,48 @@
.card {
display: inline-flex;
align-items: center;
gap: 10px;
max-width: 25vw;
padding: 6px 14px;
border-radius: 8px;
background: var(--color-background-page);
border: 1px solid var(--color-divider);
}
.icon {
width: 26px;
height: 26px;
flex: 0 0 auto;
color: var(--color-text-subtitle);
}
.col {
display: flex;
flex-direction: column;
line-height: 1.1;
}
.top {
display: flex;
align-items: baseline;
gap: 3px;
}
.value {
font-size: var(--font-size-lg, 20px);
font-weight: 700;
color: var(--color-text-primary);
}
.unit {
font-size: var(--font-size-sm, 12px);
color: var(--color-text-subtitle);
}
.subtitle {
font-size: var(--font-size-sm, 12px);
color: var(--color-text-subtitle);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex: 0 0 auto;
margin-left: 4px;
}
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 class="top">
<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');
});
});
Loading