diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..5fdb242c --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,686 @@ +# GitHub Copilot Instructions for ng-in-viewport + +**Angular Development Guidelines** +_Ignore current project patterns - use only latest Angular v20+ standards and best practices_ + +## Project Overview + +This is an Angular library for viewport detection using modern Angular v20+ patterns. The repository contains: + +- **Core library**: `projects/ng-in-viewport` - Signal-based viewport detection with Intersection Observer +- **Demo application**: `projects/demo` - Documentation and interactive examples +- **Example application**: `projects/example` - Real-world usage scenarios and performance testing +- **Test suites**: Comprehensive unit and E2E testing with modern Angular testing patterns + +## Environment Setup + +### Node.js Requirements + +- **Required**: Node.js >=22.0.0 (LTS) +- **Required**: npm >=10.0.0 +- **Package Manager**: Prefer `npm` with exact versions for consistency + +### Dependency Installation + +For restricted environments, use network workarounds: + +```bash +CYPRESS_INSTALL_BINARY=0 npm ci +``` + +**Execution time**: ~15 seconds +**Timeout**: Use 60+ seconds minimum, NEVER CANCEL + +## Build Pipeline + +### Complete Build Validation + +```bash +npm run format && npm run lint && npm run build:lib && npm run test:lib +``` + +**Execution time**: ~45 seconds +**Timeout**: Use 120+ seconds minimum, NEVER CANCEL + +### Individual Commands + +```bash +# Code Quality +npm run format # Check formatting (5s) +npm run format:write # Fix formatting (5s) +npm run lint # Lint all projects (15s) + +# Library Build +npm run build:lib # Production build (20s) +npm run watch:lib # Development watch mode + +# Testing +npm run test:lib # Unit tests with coverage (15s) +npm run e2e:run # E2E tests headless (60s+) + +# Development Servers +npm run serve:demo # Demo app - localhost:4200 +npm run serve:example # Example app - localhost:4300 +``` + +## Angular v20+ Development Standards + +### Core Principles + +**Signal-First Architecture**: Use signals as the primary state management pattern. Observables are reserved for streams and HTTP operations only. + +**Component Design**: All components MUST be standalone with OnPush change detection and signal-based APIs. + +**Template Syntax**: Exclusive use of modern control flow (`@if`, `@for`, `@switch`) and direct binding patterns. + +### TypeScript Configuration + +```typescript +// Use strict TypeScript with latest features +{ + "compilerOptions": { + "strict": true, + "exactOptionalPropertyTypes": true, + "noUncheckedIndexedAccess": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + } +} +``` + +**Type Standards**: + +- NEVER use `any` - use `unknown` for uncertain types +- Use `satisfies` operator for type checking with inference +- Prefer `readonly` for all data that shouldn't be mutated +- Use template literal types for string constants + +### Component Architecture + +```typescript +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + input, + output, + signal, + ElementRef, +} from '@angular/core'; + +@Component({ + selector: 'viewport-element', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @if (isInViewport()) { +
+ Content is visible ({{ visibilityRatio() }}%) +
+ } @else { + + } + `, + host: { + '[attr.data-viewport-state]': 'viewportState()', + '[class.in-viewport]': 'isInViewport()', + }, +}) +export class ViewportElementComponent { + // Signal inputs - primary pattern for v20+ + readonly threshold = input(0.5); + readonly rootMargin = input('0px'); + readonly trackVisibility = input(true); + + // Signal outputs - modern event handling + readonly visibilityChange = output<{ + isVisible: boolean; + entry: IntersectionObserverEntry; + }>(); + + // Dependency injection with inject() + private readonly elementRef = inject(ElementRef); + private readonly viewportService = inject(ViewportService); + + // Internal signals + protected readonly isInViewport = signal(false); + protected readonly visibilityRatio = signal(0); + private readonly lastEntry = signal(null); + + // Computed values - derived state + protected readonly opacity = computed( + () => this.visibilityRatio() * 0.8 + 0.2 + ); + + protected readonly viewportState = computed(() => + this.isInViewport() ? 'visible' : 'hidden' + ); + + constructor() { + // Effects for side effects and reactions + effect(() => { + if (this.trackVisibility()) { + this.setupViewportObserver(); + } + }); + + effect(() => { + // Emit events when visibility changes + const entry = this.lastEntry(); + if (entry) { + this.visibilityChange.emit({ + isVisible: this.isInViewport(), + entry, + }); + } + }); + } + + private setupViewportObserver(): void { + // Implementation with Intersection Observer + this.viewportService.observe(this.elementRef.nativeElement, { + threshold: this.threshold(), + rootMargin: this.rootMargin(), + callback: (entry) => { + this.isInViewport.set(entry.isIntersecting); + this.visibilityRatio.set(entry.intersectionRatio * 100); + this.lastEntry.set(entry); + }, + }); + } +} +``` + +### Service Design Patterns + +```typescript +import { Injectable, inject, signal, computed } from '@angular/core'; +import { DOCUMENT } from '@angular/common'; +import { isPlatformBrowser } from '@angular/common'; +import { PLATFORM_ID } from '@angular/core'; + +interface ViewportConfig { + threshold: number | number[]; + rootMargin: string; + callback: (entry: IntersectionObserverEntry) => void; +} + +interface ViewportGlobalConfig { + rootMargin: string; + threshold: number[]; +} + +@Injectable({ providedIn: 'root' }) +export class ViewportService { + private readonly document = inject(DOCUMENT); + private readonly platformId = inject(PLATFORM_ID); + + // Signal-based state management + private readonly _elements = signal>(new Map()); + private readonly _globalConfig = signal({ + rootMargin: '0px', + threshold: [0, 0.25, 0.5, 0.75, 1], + }); + + // Public computed properties + readonly trackedElementsCount = computed(() => this._elements().size); + readonly isActive = computed(() => this.trackedElementsCount() > 0); + + // Modern intersection observer setup + private observer?: IntersectionObserver; + + constructor() { + if (isPlatformBrowser(this.platformId)) { + this.initializeObserver(); + } + } + + // Public API methods + observe(element: Element, config?: Partial): () => void { + if (!isPlatformBrowser(this.platformId)) { + return () => {}; // No-op for SSR + } + + const fullConfig = { ...this._globalConfig(), ...config } as ViewportConfig; + + this._elements.update((elements) => { + const newElements = new Map(elements); + newElements.set(element, fullConfig); + return newElements; + }); + + this.observer?.observe(element); + + // Return cleanup function + return () => this.unobserve(element); + } + + unobserve(element: Element): void { + this._elements.update((elements) => { + const newElements = new Map(elements); + newElements.delete(element); + return newElements; + }); + + this.observer?.unobserve(element); + } + + private initializeObserver(): void { + if (typeof IntersectionObserver === 'undefined') { + console.warn('IntersectionObserver not supported'); + return; + } + + this.observer = new IntersectionObserver( + (entries) => this.handleIntersection(entries), + this._globalConfig() + ); + } + + private handleIntersection(entries: IntersectionObserverEntry[]): void { + const elements = this._elements(); + + entries.forEach((entry) => { + const config = elements.get(entry.target); + if (config?.callback) { + config.callback(entry); + } + }); + } +} +``` + +### Template Best Practices + +**Control Flow**: Exclusive use of Angular v20+ control flow syntax: + +```html + +@if (isLoading()) { + +} @else if (hasError()) { + +} @else { + +} + + +@for (item of items(); track item.id) { + +} @empty { + +} + + +@switch (status()) { @case ('loading') { } @case ('error') { + } @case ('success') { + } @default { } } +``` + +**Binding Patterns**: Use direct property and class bindings: + +```html + +
+ + + + +
+``` + +### Directive Patterns + +```typescript +import { + Directive, + effect, + inject, + input, + output, + signal, + ElementRef, + OnDestroy, +} from '@angular/core'; + +@Directive({ + selector: '[viewportObserver]', + standalone: true, + host: { + '[attr.data-in-viewport]': 'isInViewport()', + }, +}) +export class ViewportObserverDirective implements OnDestroy { + // Signal inputs + readonly threshold = input(0.5); + readonly rootMargin = input('0px'); + + // Signal outputs + readonly viewportChange = output<{ + isVisible: boolean; + entry: IntersectionObserverEntry; + }>(); + + // Injected dependencies + private readonly elementRef = inject(ElementRef); + private readonly viewportService = inject(ViewportService); + + // Internal state + private readonly isInViewport = signal(false); + private cleanup?: () => void; + + constructor() { + effect(() => { + // Setup observer when inputs change + this.setupObserver(); + }); + + effect(() => { + // Emit changes when viewport state changes + this.viewportChange.emit({ + isVisible: this.isInViewport(), + entry: this.lastEntry(), + }); + }); + } + + ngOnDestroy(): void { + this.cleanup?.(); + } + + private setupObserver(): void { + this.cleanup?.(); + + this.cleanup = this.viewportService.observe(this.elementRef.nativeElement, { + threshold: this.threshold(), + rootMargin: this.rootMargin(), + callback: (entry) => { + this.isInViewport.set(entry.isIntersecting); + this.lastEntry.set(entry); + }, + }); + } + + private readonly lastEntry = signal(null); +} +``` + +### Testing Patterns + +**Component Testing**: Use Angular v20+ testing utilities with signals: + +```typescript +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { signal } from '@angular/core'; +import { ViewportElementComponent } from './viewport-element.component'; + +describe('ViewportElementComponent', () => { + let component: ViewportElementComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ViewportElementComponent], // Standalone component + }).compileComponents(); + + fixture = TestBed.createComponent(ViewportElementComponent); + component = fixture.componentInstance; + }); + + it('should emit visibility changes when signal updates', () => { + const emitSpy = jest.spyOn(component.visibilityChange, 'emit'); + + // Update signal inputs directly + fixture.componentRef.setInput('threshold', 0.8); + fixture.detectChanges(); + + // Test signal-based state changes + component['isInViewport'].set(true); + component['visibilityRatio'].set(75); + + expect(component['opacity']()).toBe(0.8); // 0.75 * 0.8 + 0.2 + }); + + it('should compute opacity based on visibility ratio', () => { + // Test computed signals + component['visibilityRatio'].set(50); + expect(component['opacity']()).toBe(0.6); // 0.5 * 0.8 + 0.2 + + component['visibilityRatio'].set(100); + expect(component['opacity']()).toBe(1.0); // 1.0 * 0.8 + 0.2 + }); + + it('should update viewport state based on visibility', () => { + component['isInViewport'].set(true); + expect(component['viewportState']()).toBe('visible'); + + component['isInViewport'].set(false); + expect(component['viewportState']()).toBe('hidden'); + }); +}); +``` + +**Service Testing**: Test signal-based services: + +```typescript +import { TestBed } from '@angular/core/testing'; +import { ViewportService } from './viewport.service'; +import { PLATFORM_ID } from '@angular/core'; + +describe('ViewportService', () => { + let service: ViewportService; + let mockElement: HTMLElement; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [{ provide: PLATFORM_ID, useValue: 'browser' }], + }); + service = TestBed.inject(ViewportService); + mockElement = document.createElement('div'); + }); + + it('should track elements correctly', () => { + expect(service.trackedElementsCount()).toBe(0); + expect(service.isActive()).toBe(false); + + const cleanup = service.observe(mockElement); + expect(service.trackedElementsCount()).toBe(1); + expect(service.isActive()).toBe(true); + + cleanup(); + expect(service.trackedElementsCount()).toBe(0); + expect(service.isActive()).toBe(false); + }); + + it('should handle SSR gracefully', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [{ provide: PLATFORM_ID, useValue: 'server' }], + }); + + const ssrService = TestBed.inject(ViewportService); + const cleanup = ssrService.observe(mockElement); + + // Should return no-op cleanup function + expect(typeof cleanup).toBe('function'); + expect(ssrService.trackedElementsCount()).toBe(0); + }); +}); +``` + +### Performance Optimization + +**Signal Optimization**: + +- Use `computed()` for derived state - automatically optimized +- Prefer `effect()` over manual subscriptions +- Use `untracked()` to break signal dependencies when needed + +**Intersection Observer Optimization**: + +```typescript +// Efficient observer configuration +private readonly observerConfig = computed(() => ({ + root: this.root(), + rootMargin: this.rootMargin(), + threshold: this.threshold(), +})); + +// Single observer instance with signal-based config updates +effect(() => { + this.updateObserverConfig(this.observerConfig()); +}); +``` + +**Memory Management**: + +```typescript +// Automatic cleanup with effect cleanup +effect((onCleanup) => { + const cleanup = this.setupObserver(); + + onCleanup(() => { + cleanup(); + }); +}); +``` + +### Error Handling + +```typescript +// Signal-based error handling +private readonly error = signal(null); +private readonly isLoading = signal(false); + +protected readonly hasError = computed(() => this.error() !== null); +protected readonly canRetry = computed(() => + this.hasError() && !this.isLoading() +); + +async performOperation(): Promise { + this.isLoading.set(true); + this.error.set(null); + + try { + await this.operation(); + } catch (error) { + this.error.set(error instanceof Error ? error : new Error(String(error))); + } finally { + this.isLoading.set(false); + } +} +``` + +### Library-Specific Guidelines + +**Public API Design**: Expose signal-based APIs for consumers: + +```typescript +// Public API should use signals +export interface ViewportDetectionApi { + readonly isInViewport: Signal; + readonly visibilityRatio: Signal; + readonly observe: (element: Element) => () => void; +} +``` + +**SSR Compatibility**: Always check platform and handle SSR: + +```typescript +import { isPlatformBrowser } from '@angular/common'; +import { PLATFORM_ID, inject } from '@angular/core'; + +constructor() { + const platformId = inject(PLATFORM_ID); + + if (isPlatformBrowser(platformId)) { + this.initializeObserver(); + } +} +``` + +### Migration Strategy + +**From Angular 17 to v20+**: + +1. Replace all `@Input()` with `input()` +2. Replace all `@Output()` with `output()` +3. Convert component state to signals +4. Update templates to use `@if`, `@for`, `@switch` +5. Replace observables with signals where appropriate +6. Use `computed()` for derived state +7. Replace manual subscriptions with `effect()` + +**Breaking Changes to Expect**: + +- Remove all structural directives (`*ngIf`, `*ngFor`) +- Remove `ngClass` and `ngStyle` - use direct bindings +- Remove `async` pipe for signals (not needed) +- Update event handling to use signal outputs + +## Validation Requirements + +### Manual Testing Protocol + +1. **Demo App**: Verify all examples work with signal-based updates +2. **Example App**: Test performance with rapid viewport changes +3. **Responsive Testing**: Validate across viewport sizes +4. **Memory Testing**: Check for leaks during rapid scroll/resize + +### Automated Testing + +```bash +npm run test:lib # Unit tests with signal coverage +npm run e2e:run # E2E tests with viewport scenarios +npm run test:performance # Performance regression tests +``` + +### Expected Behavior + +- **Signal reactivity**: Immediate updates on viewport changes +- **Performance**: 60fps during scroll with hundreds of elements +- **Memory**: No leaks after component destruction +- **SSR**: Graceful degradation without browser APIs + +## Troubleshooting + +### Common Migration Issues + +**Signal Conversion**: When updating to signals, ensure all dependencies are also signals or wrapped with signal access. + +**Effect Dependencies**: Effects automatically track signal dependencies - use `untracked()` to break unwanted dependencies. + +**Testing**: Signal-based tests require `fixture.detectChanges()` after signal updates. + +### Performance Issues + +**Too Many Effects**: Combine related effects or use `computed()` for derived state. + +**Observer Thrashing**: Use debouncing for rapid viewport changes: + +```typescript +// Debounced viewport updates +private readonly debouncedUpdate = computed(() => { + const update = this.immediateUpdate(); + return debounce(update, 16); // ~60fps +}); +``` + +This guide ensures all Angular v20+ development follows the latest standards and patterns, ignoring outdated practices from earlier versions. diff --git a/.gitignore b/.gitignore index 8144bb66..32e7ec47 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,7 @@ Thumbs.db /.github/workflows/* !/.github/dependabot.yml !/.github/funding.yml +!/.github/copilot-instructions.md !/.github/actions/install-npm-deps/action.yml !/.github/workflows/main.yml !/.github/workflows/pr.yml