Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions projects/v3/src/app/app.component.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<ion-app>
<app-snow-overlay *ngIf="isSnowEnabled"></app-snow-overlay>
<!-- Skip navigation links for WCAG 2.4.1 -->
<a href="#main-content" class="skip-link" (click)="handleSkipLink($event, 'main-content')">Skip to main content</a>
<a href="#main-navigation" class="skip-link" (click)="handleSkipLink($event, 'main-navigation')">Skip to navigation</a>
Expand Down
5 changes: 4 additions & 1 deletion projects/v3/src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ export class AppComponent implements OnInit, OnDestroy {
$unsubscribe = new Subject();
lastVisitedUrl: string;

isSnowEnabled = environment.snowAnimation?.enabled ?? false;

// urls that should not be cached for last visited tracking
// list of urls that should not be cached
noneCachedUrl = [
'devtool',
'registration',
Expand Down Expand Up @@ -80,7 +83,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();

Expand Down
2 changes: 2 additions & 0 deletions projects/v3/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -28,6 +29,7 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
prefixUrl: environment.APIEndpoint,
}),
ApolloModule,
SnowOverlayComponent,
],
providers: [
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<div class="snow-overlay" aria-hidden="true">
<div
*ngFor="let flake of snowflakes; trackBy: trackByFlakeId"
class="snowflake"
[ngStyle]="{
'--size': flake.size + 'px',
'--left': flake.left + '%',
'--delay': flake.delay + 's',
'--duration': flake.duration + 's',
'--opacity': flake.opacity
}"
></div>
</div>
Original file line number Diff line number Diff line change
@@ -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; // 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;
}
}
Original file line number Diff line number Diff line change
@@ -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<SnowOverlayComponent>;

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);
});
});
Original file line number Diff line number Diff line change
@@ -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;
}
}
4 changes: 4 additions & 0 deletions projects/v3/src/environments/environment.custom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,8 @@ export const environment = {
featureToggles: {
assessmentPagination: <CUSTOM_ENABLE_ASSESSMENT_PAGINATION>,
},
snowAnimation: {
enabled: true,
snowflakeCount: 30,
},
};
4 changes: 4 additions & 0 deletions projects/v3/src/environments/environment.local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ export const environment = {
featureToggles: {
assessmentPagination: true,
},
snowAnimation: {
enabled: true,
snowflakeCount: 30,
},
};

/*
Expand Down