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()) {
+
+ @switch (bannerState()) {
+ @case ('online') {
+
+
+ Sie sind wieder online.
+
+ }
+ @case ('offline') {
+
+
+
+
+ Sie sind offline, keine Verbindung zum Netzwerk.
+
+
+ Bereits geladene Inhalte werden angezeigt, Interaktionen sind
+ aktuell nicht möglich.
+
+
+
+ }
+ }
+
}
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 {}