From 3281060a212468a747d7d8fbc0b2943ea658c5ed Mon Sep 17 00:00:00 2001 From: trtshen Date: Thu, 18 Dec 2025 16:47:56 +0800 Subject: [PATCH] added snow overlay component and animation feature - implement snow overlay component with animated snowflakes - integrate snow overlay into app component - configure environment settings for snow animation - add tests for snow overlay component functionality --- projects/v3/src/app/app.component.html | 2 + projects/v3/src/app/app.component.ts | 5 +- projects/v3/src/app/app.module.ts | 2 + .../snow-overlay/snow-overlay.component.html | 14 +++++ .../snow-overlay/snow-overlay.component.scss | 49 +++++++++++++++ .../snow-overlay.component.spec.ts | 61 ++++++++++++++++++ .../snow-overlay/snow-overlay.component.ts | 63 +++++++++++++++++++ .../v3/src/environments/environment.custom.ts | 4 ++ .../v3/src/environments/environment.local.ts | 4 ++ 9 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 projects/v3/src/app/components/snow-overlay/snow-overlay.component.html create mode 100644 projects/v3/src/app/components/snow-overlay/snow-overlay.component.scss create mode 100644 projects/v3/src/app/components/snow-overlay/snow-overlay.component.spec.ts create mode 100644 projects/v3/src/app/components/snow-overlay/snow-overlay.component.ts diff --git a/projects/v3/src/app/app.component.html b/projects/v3/src/app/app.component.html index e715b846d..d44f2d8e5 100644 --- a/projects/v3/src/app/app.component.html +++ b/projects/v3/src/app/app.component.html @@ -1,4 +1,6 @@ + + diff --git a/projects/v3/src/app/app.component.ts b/projects/v3/src/app/app.component.ts index bfb27b094..3cc091d49 100644 --- a/projects/v3/src/app/app.component.ts +++ b/projects/v3/src/app/app.component.ts @@ -29,6 +29,9 @@ export class AppComponent implements OnInit, OnDestroy { $unsubscribe = new Subject(); lastVisitedUrl: string; + /** controls snow animation visibility based on environment config */ + isSnowEnabled = environment.snowAnimation?.enabled ?? false; + // list of urls that should not be cached noneCachedUrl = [ 'devtool', @@ -79,7 +82,7 @@ export class AppComponent implements OnInit, OnDestroy { ngOnInit() { this.configVerification(); this.sharedService.onPageLoad(); - + // Set initial lang attribute based on current locale (WCAG 3.1.1) this.utils.setPageLanguage(); diff --git a/projects/v3/src/app/app.module.ts b/projects/v3/src/app/app.module.ts index cf1e55c9e..a27e5b97d 100644 --- a/projects/v3/src/app/app.module.ts +++ b/projects/v3/src/app/app.module.ts @@ -11,6 +11,7 @@ import { AppComponent } from './app.component'; import { ApolloModule } from 'apollo-angular'; import { ApolloService } from './services/apollo.service'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { SnowOverlayComponent } from './components/snow-overlay/snow-overlay.component'; @NgModule({ declarations: [ @@ -28,6 +29,7 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; prefixUrl: environment.APIEndpoint, }), ApolloModule, + SnowOverlayComponent, ], providers: [ { diff --git a/projects/v3/src/app/components/snow-overlay/snow-overlay.component.html b/projects/v3/src/app/components/snow-overlay/snow-overlay.component.html new file mode 100644 index 000000000..7ee9ef1ec --- /dev/null +++ b/projects/v3/src/app/components/snow-overlay/snow-overlay.component.html @@ -0,0 +1,14 @@ + + diff --git a/projects/v3/src/app/components/snow-overlay/snow-overlay.component.scss b/projects/v3/src/app/components/snow-overlay/snow-overlay.component.scss new file mode 100644 index 000000000..e4eb2f3a7 --- /dev/null +++ b/projects/v3/src/app/components/snow-overlay/snow-overlay.component.scss @@ -0,0 +1,49 @@ +/** + * snow overlay styles + * creates a transparent layer of falling snowflakes + */ + +.snow-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; // critical: don't block user interactions + z-index: 9000; // below ionic modals (10000+) + overflow: hidden; +} + +.snowflake { + position: absolute; + top: -10px; + left: var(--left); + width: var(--size); + height: var(--size); + background: white; + border-radius: 50%; + opacity: var(--opacity); + box-shadow: + 0 0 2px rgba(0, 0, 0, 0.15), + 0 1px 3px rgba(0, 0, 0, 0.1); + animation: snowfall var(--duration) linear infinite; + animation-delay: var(--delay); + will-change: transform; // gpu acceleration hint +} + +@keyframes snowfall { + 0% { + transform: translateY(-10vh); + } + 100% { + transform: translateY(110vh); + } +} + +// wcag: respect user's motion preferences +@media (prefers-reduced-motion: reduce) { + .snowflake { + animation: none; + opacity: 0.3; + } +} diff --git a/projects/v3/src/app/components/snow-overlay/snow-overlay.component.spec.ts b/projects/v3/src/app/components/snow-overlay/snow-overlay.component.spec.ts new file mode 100644 index 000000000..4d14b7096 --- /dev/null +++ b/projects/v3/src/app/components/snow-overlay/snow-overlay.component.spec.ts @@ -0,0 +1,61 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { SnowOverlayComponent } from './snow-overlay.component'; + +describe('SnowOverlayComponent', () => { + let component: SnowOverlayComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SnowOverlayComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(SnowOverlayComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should generate snowflakes on init', () => { + expect(component.snowflakes.length).toBeGreaterThan(0); + }); + + it('should have snowflakes with valid properties', () => { + const flake = component.snowflakes[0]; + expect(flake.id).toBeDefined(); + expect(flake.size).toBeGreaterThanOrEqual(4); + expect(flake.size).toBeLessThanOrEqual(10); + expect(flake.left).toBeGreaterThanOrEqual(0); + expect(flake.left).toBeLessThanOrEqual(100); + expect(flake.delay).toBeGreaterThanOrEqual(0); + expect(flake.delay).toBeLessThanOrEqual(10); + expect(flake.duration).toBeGreaterThanOrEqual(8); + expect(flake.duration).toBeLessThanOrEqual(15); + expect(flake.opacity).toBeGreaterThanOrEqual(0.4); + expect(flake.opacity).toBeLessThanOrEqual(1); + }); + + it('should have aria-hidden on overlay container', () => { + const overlay = fixture.nativeElement.querySelector('.snow-overlay'); + expect(overlay.getAttribute('aria-hidden')).toBe('true'); + }); + + it('should have pointer-events none for non-blocking interaction', () => { + const overlay = fixture.nativeElement.querySelector('.snow-overlay'); + const styles = getComputedStyle(overlay); + expect(styles.pointerEvents).toBe('none'); + }); + + it('should render correct number of snowflake elements', () => { + const snowflakeElements = fixture.nativeElement.querySelectorAll('.snowflake'); + expect(snowflakeElements.length).toBe(component.snowflakes.length); + }); + + it('trackByFlakeId should return flake id', () => { + const flake = { id: 5 }; + expect(component.trackByFlakeId(0, flake)).toBe(5); + }); +}); diff --git a/projects/v3/src/app/components/snow-overlay/snow-overlay.component.ts b/projects/v3/src/app/components/snow-overlay/snow-overlay.component.ts new file mode 100644 index 000000000..9260e785b --- /dev/null +++ b/projects/v3/src/app/components/snow-overlay/snow-overlay.component.ts @@ -0,0 +1,63 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { environment } from '@v3/environments/environment'; + +/** + * snow overlay component that renders animated snowflakes + * as a transparent layer over the app content. + * uses pointer-events: none to allow interaction with underlying elements. + */ +@Component({ + selector: 'app-snow-overlay', + standalone: true, + imports: [CommonModule], + templateUrl: './snow-overlay.component.html', + styleUrls: ['./snow-overlay.component.scss'], +}) +export class SnowOverlayComponent implements OnInit { + snowflakes: Array<{ + id: number; + size: number; + left: number; + delay: number; + duration: number; + opacity: number; + }> = []; + + ngOnInit(): void { + this.generateSnowflakes(); + } + + /** + * generates snowflake configurations with randomized properties + * for natural variation in the animation. + */ + private generateSnowflakes(): void { + const count = environment.snowAnimation?.snowflakeCount ?? 30; + + for (let i = 0; i < count; i++) { + this.snowflakes.push({ + id: i, + size: this.randomBetween(4, 10), + left: this.randomBetween(0, 100), + delay: this.randomBetween(0, 10), + duration: this.randomBetween(8, 15), + opacity: this.randomBetween(0.4, 1), + }); + } + } + + /** + * returns a random number between min and max (inclusive). + */ + private randomBetween(min: number, max: number): number { + return Math.random() * (max - min) + min; + } + + /** + * trackby function for ngfor performance optimization. + */ + trackByFlakeId(index: number, flake: { id: number }): number { + return flake.id; + } +} diff --git a/projects/v3/src/environments/environment.custom.ts b/projects/v3/src/environments/environment.custom.ts index 222cfbe6b..60fb94eb4 100644 --- a/projects/v3/src/environments/environment.custom.ts +++ b/projects/v3/src/environments/environment.custom.ts @@ -59,4 +59,8 @@ export const environment = { newrelic: '', goMobile: false, helpline: '', + snowAnimation: { + enabled: true, + snowflakeCount: 30, + }, }; diff --git a/projects/v3/src/environments/environment.local.ts b/projects/v3/src/environments/environment.local.ts index 2f5c308ba..4c960fab9 100644 --- a/projects/v3/src/environments/environment.local.ts +++ b/projects/v3/src/environments/environment.local.ts @@ -62,6 +62,10 @@ export const environment = { newrelic: false, goMobile: false, helpline: 'help@practera.com', + snowAnimation: { + enabled: true, + snowflakeCount: 30, + }, }; /*