feat(shell-layout): enhance network status banner with animations and tests

- Add slide-in/slide-out CSS animations for banner enter/leave
- Add content fade animation for smooth offline→online transitions
- Refactor to declarative RxJS pattern (pairwise + switchMap + timer)
- Use computed() for derived showBanner state
- Add wifi/wifi-off SVG icons inline
- Add comprehensive unit tests (14 test cases)
- Integrate shell-layout into isa-app root component
- Add proper JSDoc documentation
- Add ARIA accessibility attributes (role, aria-live)
- Export ONLINE_BANNER_DISPLAY_DURATION_MS constant for tests
This commit is contained in:
Lorenz Hilpert
2025-12-03 15:13:45 +01:00
parent 85f1184648
commit 86b0493591
8 changed files with 493 additions and 28 deletions

View File

@@ -1 +1,3 @@
<router-outlet />
<shell-layout>
<router-outlet />
</shell-layout>

View File

@@ -1,10 +1,11 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { ShellLayoutComponent } from '@isa/shell/layout';
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrls: ['./app.css'],
imports: [RouterOutlet],
imports: [RouterOutlet, ShellLayoutComponent],
})
export class App {}

View File

@@ -1,11 +1,68 @@
:host {
@apply none fixed inset-x-0 top-0 z-50 flex justify-center p-4 text-center;
@apply fixed inset-x-0 top-0 z-fixed justify-center text-center;
}
:host.online {
@apply block bg-isa-accent-green text-white;
.banner {
@apply flex flex-row w-full justify-center p-4 text-center;
}
:host.offline {
@apply block bg-isa-accent-red text-white;
.banner--online {
@apply bg-isa-accent-green text-white;
}
.banner--offline {
@apply bg-isa-accent-red text-white;
}
.banner-content {
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* Smooth slide in from top */
@keyframes slideInDown {
from {
opacity: 0;
transform: translateY(-100%);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Smooth slide out to top */
@keyframes slideOutUp {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-100%);
}
}
.slide-in-down {
animation: slideInDown 0.3s ease-out;
}
.slide-out-up {
animation: slideOutUp 0.3s ease-in forwards;
}
/* Accessibility: respect reduced motion preferences */
@media (prefers-reduced-motion: reduce) {
.slide-in-down,
.slide-out-up {
animation-duration: 0.1s;
}
}

View File

@@ -1,12 +1,57 @@
@switch (networkStatus()) {
@case ('online') {
<div>Sie sind wieder online.</div>
}
@case ('offline') {
<div>Sie sind offline, keine Verbindung zum Netzwerk.</div>
<div>
Bereits geladene Ihnalte werden angezeigt, Interaktionen sind aktuell
nicht möglich.
</div>
}
@if (showBanner()) {
<div
class="banner"
[class.banner--online]="bannerState() === 'online'"
[class.banner--offline]="bannerState() === 'offline'"
animate.enter="slide-in-down"
animate.leave="slide-out-up"
data-what="banner"
[attr.data-which]="'network-' + bannerState()"
role="alert"
aria-live="assertive"
>
@switch (bannerState()) {
@case ('online') {
<span class="banner-content flex items-center gap-6">
<svg
xmlns="http://www.w3.org/2000/svg"
height="24px"
viewBox="0 -960 960 960"
width="24px"
fill="currentColor"
>
<path
d="M480-120q-42 0-71-29t-29-71q0-42 29-71t71-29q42 0 71 29t29 71q0 42-29 71t-71 29ZM254-346l-84-86q59-59 138.5-93.5T480-560q92 0 171.5 35T790-430l-84 84q-44-44-102-69t-124-25q-66 0-124 25t-102 69ZM84-516 0-600q92-94 215-147t265-53q142 0 265 53t215 147l-84 84q-77-77-178.5-120.5T480-680q-116 0-217.5 43.5T84-516Z"
/>
</svg>
<span class="isa-text-body-1-bold">Sie sind wieder online.</span>
</span>
}
@case ('offline') {
<div class="banner-content flex items-start gap-6">
<svg
xmlns="http://www.w3.org/2000/svg"
height="24px"
viewBox="0 -960 960 960"
width="24px"
fill="currentColor"
class="shrink-0"
>
<path
d="m278-362-84-84q24-23 51.5-41.5T302-520l-110-77q-23 14-43 30.5T109-531l-85-85q15-14 31-27.5T88-670l-58-41 57-81 786 550-57 82-392-275q-41 8-78 26t-68 47Zm147-315-135-95q46-14 93.5-21t96.5-7q128 0 246 47.5T936-616l-85 85q-75-72-171-110.5T480-680q-14 0-27.5.5T425-677Zm55 517q-33 0-56.5-23.5T400-240q0-33 23.5-56.5T480-320q33 0 56.5 23.5T560-240q0 33-23.5 56.5T480-160Z"
/>
</svg>
<div>
<div class="isa-text-body-1-bold">
Sie sind offline, keine Verbindung zum Netzwerk.
</div>
<div class="isa-text-body-2-regular">
Bereits geladene Inhalte werden angezeigt, Interaktionen sind
aktuell nicht möglich.
</div>
</div>
</div>
}
}
</div>
}

View File

@@ -0,0 +1,291 @@
import {
ComponentFixture,
fakeAsync,
flush,
TestBed,
tick,
} from '@angular/core/testing';
import { BehaviorSubject } from 'rxjs';
import {
NetworkStatusBannerComponent,
ONLINE_BANNER_DISPLAY_DURATION_MS,
} from './network-status-banner.component';
import {
NetworkStatus,
NetworkStatusService,
} from '@isa/core/connectivity';
import { provideLogging } from '@isa/core/logging';
describe('NetworkStatusBannerComponent', () => {
let component: NetworkStatusBannerComponent;
let fixture: ComponentFixture<NetworkStatusBannerComponent>;
let networkStatus$: BehaviorSubject<NetworkStatus>;
const createComponent = () => {
fixture = TestBed.createComponent(NetworkStatusBannerComponent);
component = fixture.componentInstance;
fixture.detectChanges();
};
beforeEach(async () => {
networkStatus$ = new BehaviorSubject<NetworkStatus>('online');
await TestBed.configureTestingModule({
imports: [NetworkStatusBannerComponent],
providers: [
provideLogging(),
{
provide: NetworkStatusService,
useValue: {
status$: networkStatus$.asObservable(),
},
},
],
}).compileComponents();
});
afterEach(() => {
fixture?.destroy();
});
it('should create', () => {
createComponent();
expect(component).toBeTruthy();
});
describe('initial state', () => {
it('should not show banner when initially online', () => {
createComponent();
expect(component.showBanner()).toBe(false);
expect(component.bannerState()).toBeNull();
});
it('should show offline banner when initially offline', fakeAsync(() => {
networkStatus$.next('offline');
createComponent();
tick();
fixture.detectChanges();
expect(component.showBanner()).toBe(true);
expect(component.bannerState()).toBe('offline');
}));
});
describe('offline state', () => {
it('should show offline banner when network goes offline', fakeAsync(() => {
createComponent();
networkStatus$.next('offline');
tick();
fixture.detectChanges();
expect(component.showBanner()).toBe(true);
expect(component.bannerState()).toBe('offline');
}));
it('should keep showing offline banner while offline', fakeAsync(() => {
createComponent();
networkStatus$.next('offline');
tick(5000);
fixture.detectChanges();
expect(component.showBanner()).toBe(true);
expect(component.bannerState()).toBe('offline');
flush();
}));
});
describe('online transition', () => {
it('should transition to online banner when network is restored', fakeAsync(() => {
createComponent();
// Go offline first
networkStatus$.next('offline');
tick();
fixture.detectChanges();
expect(component.bannerState()).toBe('offline');
// Go back online
networkStatus$.next('online');
tick();
fixture.detectChanges();
expect(component.showBanner()).toBe(true);
expect(component.bannerState()).toBe('online');
flush();
}));
it(`should auto-dismiss banner after ${ONLINE_BANNER_DISPLAY_DURATION_MS}ms when going online`, fakeAsync(() => {
createComponent();
// Go offline first
networkStatus$.next('offline');
tick();
fixture.detectChanges();
// Go back online
networkStatus$.next('online');
tick();
fixture.detectChanges();
expect(component.showBanner()).toBe(true);
expect(component.bannerState()).toBe('online');
// Wait for auto-dismiss timeout
tick(ONLINE_BANNER_DISPLAY_DURATION_MS);
fixture.detectChanges();
expect(component.showBanner()).toBe(false);
expect(component.bannerState()).toBeNull();
}));
it('should not show online banner if was not offline before', fakeAsync(() => {
createComponent();
// Already online, emit online again
networkStatus$.next('online');
tick();
fixture.detectChanges();
expect(component.showBanner()).toBe(false);
expect(component.bannerState()).toBeNull();
}));
});
describe('rapid state changes', () => {
it('should cancel online timer if going offline again', fakeAsync(() => {
createComponent();
// Go offline
networkStatus$.next('offline');
tick();
fixture.detectChanges();
expect(component.bannerState()).toBe('offline');
// Go online
networkStatus$.next('online');
tick();
fixture.detectChanges();
expect(component.bannerState()).toBe('online');
// Go offline again before timer expires
tick(1000);
networkStatus$.next('offline');
tick();
fixture.detectChanges();
expect(component.showBanner()).toBe(true);
expect(component.bannerState()).toBe('offline');
// Original timer should not dismiss the banner (switchMap cancelled it)
tick(2000);
fixture.detectChanges();
expect(component.showBanner()).toBe(true);
expect(component.bannerState()).toBe('offline');
flush();
}));
it('should handle multiple offline/online cycles', fakeAsync(() => {
createComponent();
// First cycle: offline → online → dismiss
networkStatus$.next('offline');
tick();
fixture.detectChanges();
expect(component.bannerState()).toBe('offline');
networkStatus$.next('online');
tick();
fixture.detectChanges();
expect(component.bannerState()).toBe('online');
// Wait for auto-dismiss
tick(ONLINE_BANNER_DISPLAY_DURATION_MS);
fixture.detectChanges();
expect(component.showBanner()).toBe(false);
expect(component.bannerState()).toBeNull();
// Second cycle: offline → online → dismiss
networkStatus$.next('offline');
tick();
fixture.detectChanges();
expect(component.bannerState()).toBe('offline');
networkStatus$.next('online');
tick();
fixture.detectChanges();
expect(component.bannerState()).toBe('online');
tick(ONLINE_BANNER_DISPLAY_DURATION_MS);
fixture.detectChanges();
expect(component.showBanner()).toBe(false);
}));
});
describe('template integration', () => {
it('should render offline banner content when offline', fakeAsync(() => {
createComponent();
networkStatus$.next('offline');
tick();
fixture.detectChanges();
const banner = fixture.nativeElement.querySelector(
'[data-which="network-offline"]'
);
expect(banner).toBeTruthy();
expect(banner.textContent).toContain('Sie sind offline');
}));
it('should render online banner content when transitioning online', fakeAsync(() => {
createComponent();
networkStatus$.next('offline');
tick();
fixture.detectChanges();
networkStatus$.next('online');
tick();
fixture.detectChanges();
const banner = fixture.nativeElement.querySelector(
'[data-which="network-online"]'
);
expect(banner).toBeTruthy();
expect(banner.textContent).toContain('Sie sind wieder online');
flush();
}));
it('should not render any banner when dismissed', fakeAsync(() => {
createComponent();
networkStatus$.next('offline');
tick();
networkStatus$.next('online');
tick(ONLINE_BANNER_DISPLAY_DURATION_MS);
fixture.detectChanges();
const banner = fixture.nativeElement.querySelector('[data-what="banner"]');
expect(banner).toBeFalsy();
}));
it('should have correct ARIA attributes', fakeAsync(() => {
createComponent();
networkStatus$.next('offline');
tick();
fixture.detectChanges();
const banner = fixture.nativeElement.querySelector('[data-what="banner"]');
expect(banner.getAttribute('role')).toBe('alert');
expect(banner.getAttribute('aria-live')).toBe('assertive');
}));
});
});

View File

@@ -1,17 +1,84 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
} from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { injectNetworkStatus } from '@isa/core/connectivity';
import { logger } from '@isa/core/logging';
import { concat, map, of, pairwise, startWith, switchMap, timer } from 'rxjs';
/** Duration in milliseconds to display the "back online" banner before auto-dismissing. */
export const ONLINE_BANNER_DISPLAY_DURATION_MS = 2500;
/**
* Displays a banner notification for network connectivity status changes.
*
* @description
* This component monitors network connectivity and displays contextual banners:
* - **Offline**: Shows a persistent red banner when the network connection is lost.
* - **Online**: When connectivity is restored, the banner transitions to green
* with a success message, then auto-dismisses after {@link ONLINE_BANNER_DISPLAY_DURATION_MS}.
*
* The banner only appears after an actual connectivity change (offline → online),
* not on initial page load when already online.
*
* @example
* ```html
* <shell-network-status-banner />
* ```
*/
@Component({
selector: 'shell-network-status-banner',
standalone: true,
templateUrl: './network-status-banner.component.html',
styleUrls: ['./network-status-banner.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [],
host: {
'[class.online]': "networkStatus() === 'online'",
'[class.offline]': "networkStatus() === 'offline'",
},
})
export class NetworkBannerStatusComponent {
export class NetworkStatusBannerComponent {
readonly #logger = logger({ component: 'NetworkStatusBannerComponent' });
/** Current network status from the connectivity service. */
readonly networkStatus = injectNetworkStatus();
/** Current visual state of the banner, determining styling and content. */
readonly bannerState = toSignal(
toObservable(this.networkStatus).pipe(
startWith(undefined),
pairwise(),
switchMap(([prev, curr]) => {
if (curr === 'offline') {
return of('offline' as const);
}
if (curr === 'online' && prev === 'offline') {
// Show online banner, then auto-dismiss after timeout
return concat(
of('online' as const),
timer(ONLINE_BANNER_DISPLAY_DURATION_MS).pipe(map(() => null))
);
}
return of(null);
})
),
{ initialValue: null }
);
/** Controls the visibility of the banner element. */
readonly showBanner = computed(() => this.bannerState() !== null);
constructor() {
// Effect only for logging (valid use case)
effect(() => {
const state = this.bannerState();
if (state === 'offline') {
this.#logger.debug('Network lost, showing offline banner');
} else if (state === 'online') {
this.#logger.debug('Network restored, showing online banner');
} else if (state === null) {
this.#logger.debug('Banner dismissed');
}
});
}
}

View File

@@ -1,2 +1,4 @@
<shell-network-status-banner />
<router-outlet />
<main>
<ng-content></ng-content>
</main>

View File

@@ -1,12 +1,12 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { NetworkBannerStatusComponent } from './components/network-status-banner.component';
import { NetworkStatusBannerComponent } from './components/network-status-banner.component';
@Component({
selector: 'shell-layout',
standalone: true,
templateUrl: './shell-layout.component.html',
styleUrls: ['./shell-layout.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterOutlet, NetworkBannerStatusComponent],
imports: [NetworkStatusBannerComponent],
})
export class ShellLayoutComponent {}