From 86b0493591a7763d20d4e589faf81d8f634b342e Mon Sep 17 00:00:00 2001 From: Lorenz Hilpert Date: Wed, 3 Dec 2025 15:13:45 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(shell-layout):=20enhance=20net?= =?UTF-8?q?work=20status=20banner=20with=20animations=20and=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/isa-app/src/app/app.html | 4 +- apps/isa-app/src/app/app.ts | 3 +- .../network-status-banner.component.css | 67 +++- .../network-status-banner.component.html | 67 +++- .../network-status-banner.component.spec.ts | 291 ++++++++++++++++++ .../network-status-banner.component.ts | 79 ++++- .../src/lib/shell-layout.component.html | 4 +- .../layout/src/lib/shell-layout.component.ts | 6 +- 8 files changed, 493 insertions(+), 28 deletions(-) create mode 100644 libs/shell/layout/src/lib/components/network-status-banner.component.spec.ts diff --git a/apps/isa-app/src/app/app.html b/apps/isa-app/src/app/app.html index 67e7bd4cd..f44d03370 100644 --- a/apps/isa-app/src/app/app.html +++ b/apps/isa-app/src/app/app.html @@ -1 +1,3 @@ - + + + diff --git a/apps/isa-app/src/app/app.ts b/apps/isa-app/src/app/app.ts index 983d8ce97..f9a4a06d3 100644 --- a/apps/isa-app/src/app/app.ts +++ b/apps/isa-app/src/app/app.ts @@ -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 {} diff --git a/libs/shell/layout/src/lib/components/network-status-banner.component.css b/libs/shell/layout/src/lib/components/network-status-banner.component.css index de4b1a53a..5353edf4e 100644 --- a/libs/shell/layout/src/lib/components/network-status-banner.component.css +++ b/libs/shell/layout/src/lib/components/network-status-banner.component.css @@ -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; + } } diff --git a/libs/shell/layout/src/lib/components/network-status-banner.component.html b/libs/shell/layout/src/lib/components/network-status-banner.component.html index bebd69f15..107f349c9 100644 --- a/libs/shell/layout/src/lib/components/network-status-banner.component.html +++ b/libs/shell/layout/src/lib/components/network-status-banner.component.html @@ -1,12 +1,57 @@ -@switch (networkStatus()) { - @case ('online') { -
Sie sind wieder online.
- } - @case ('offline') { -
Sie sind offline, keine Verbindung zum Netzwerk.
-
- Bereits geladene Ihnalte werden angezeigt, Interaktionen sind aktuell - nicht möglich. -
- } +@if (showBanner()) { + } diff --git a/libs/shell/layout/src/lib/components/network-status-banner.component.spec.ts b/libs/shell/layout/src/lib/components/network-status-banner.component.spec.ts new file mode 100644 index 000000000..28b0e2f72 --- /dev/null +++ b/libs/shell/layout/src/lib/components/network-status-banner.component.spec.ts @@ -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; + let networkStatus$: BehaviorSubject; + + const createComponent = () => { + fixture = TestBed.createComponent(NetworkStatusBannerComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }; + + beforeEach(async () => { + networkStatus$ = new BehaviorSubject('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'); + })); + }); +}); diff --git a/libs/shell/layout/src/lib/components/network-status-banner.component.ts b/libs/shell/layout/src/lib/components/network-status-banner.component.ts index dbf77d7ad..f580c3336 100644 --- a/libs/shell/layout/src/lib/components/network-status-banner.component.ts +++ b/libs/shell/layout/src/lib/components/network-status-banner.component.ts @@ -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 + * + * ``` + */ @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'); + } + }); + } } diff --git a/libs/shell/layout/src/lib/shell-layout.component.html b/libs/shell/layout/src/lib/shell-layout.component.html index ecb4d3231..1b7343595 100644 --- a/libs/shell/layout/src/lib/shell-layout.component.html +++ b/libs/shell/layout/src/lib/shell-layout.component.html @@ -1,2 +1,4 @@ - +
+ +
diff --git a/libs/shell/layout/src/lib/shell-layout.component.ts b/libs/shell/layout/src/lib/shell-layout.component.ts index cec4837c6..8d1cad831 100644 --- a/libs/shell/layout/src/lib/shell-layout.component.ts +++ b/libs/shell/layout/src/lib/shell-layout.component.ts @@ -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 {}