mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
✨ 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:
@@ -1 +1,3 @@
|
||||
<router-outlet />
|
||||
<shell-layout>
|
||||
<router-outlet />
|
||||
</shell-layout>
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
<shell-network-status-banner />
|
||||
<router-outlet />
|
||||
<main>
|
||||
<ng-content></ng-content>
|
||||
</main>
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
Reference in New Issue
Block a user