1
1
# GitHub Copilot Instructions for ng-in-viewport
2
2
3
3
** Angular Development Guidelines**
4
- * Ignore current project patterns - use only latest Angular v20+ standards and best practices *
4
+ _ Ignore current project patterns - use only latest Angular v20+ standards and best practices _
5
5
6
6
## Project Overview
7
7
@@ -89,6 +89,7 @@ npm run serve:example # Example app - localhost:4300
89
89
```
90
90
91
91
** Type Standards** :
92
+
92
93
- NEVER use ` any ` - use ` unknown ` for uncertain types
93
94
- Use ` satisfies ` operator for type checking with inference
94
95
- Prefer ` readonly ` for all data that shouldn't be mutated
@@ -147,12 +148,12 @@ export class ViewportElementComponent {
147
148
protected readonly isInViewport = signal <boolean >(false );
148
149
protected readonly visibilityRatio = signal <number >(0 );
149
150
private readonly lastEntry = signal <IntersectionObserverEntry | null >(null );
150
-
151
+
151
152
// Computed values - derived state
152
- protected readonly opacity = computed (() =>
153
- this .visibilityRatio () * 0.8 + 0.2
153
+ protected readonly opacity = computed (
154
+ () => this .visibilityRatio () * 0.8 + 0.2
154
155
);
155
-
156
+
156
157
protected readonly viewportState = computed (() =>
157
158
this .isInViewport () ? ' visible' : ' hidden'
158
159
);
@@ -179,18 +180,15 @@ export class ViewportElementComponent {
179
180
180
181
private setupViewportObserver(): void {
181
182
// Implementation with Intersection Observer
182
- this .viewportService .observe (
183
- this .elementRef .nativeElement ,
184
- {
185
- threshold: this .threshold (),
186
- rootMargin: this .rootMargin (),
187
- callback : (entry ) => {
188
- this .isInViewport .set (entry .isIntersecting );
189
- this .visibilityRatio .set (entry .intersectionRatio * 100 );
190
- this .lastEntry .set (entry );
191
- },
192
- }
193
- );
183
+ this .viewportService .observe (this .elementRef .nativeElement , {
184
+ threshold: this .threshold (),
185
+ rootMargin: this .rootMargin (),
186
+ callback : (entry ) => {
187
+ this .isInViewport .set (entry .isIntersecting );
188
+ this .visibilityRatio .set (entry .intersectionRatio * 100 );
189
+ this .lastEntry .set (entry );
190
+ },
191
+ });
194
192
}
195
193
}
196
194
```
@@ -218,7 +216,7 @@ interface ViewportGlobalConfig {
218
216
export class ViewportService {
219
217
private readonly document = inject (DOCUMENT );
220
218
private readonly platformId = inject (PLATFORM_ID );
221
-
219
+
222
220
// Signal-based state management
223
221
private readonly _elements = signal <Map <Element , ViewportConfig >>(new Map ());
224
222
private readonly _globalConfig = signal <ViewportGlobalConfig >({
@@ -246,8 +244,8 @@ export class ViewportService {
246
244
}
247
245
248
246
const fullConfig = { ... this ._globalConfig (), ... config } as ViewportConfig ;
249
-
250
- this ._elements .update (elements => {
247
+
248
+ this ._elements .update (( elements ) => {
251
249
const newElements = new Map (elements );
252
250
newElements .set (element , fullConfig );
253
251
return newElements ;
@@ -260,7 +258,7 @@ export class ViewportService {
260
258
}
261
259
262
260
unobserve(element : Element ): void {
263
- this ._elements .update (elements => {
261
+ this ._elements .update (( elements ) => {
264
262
const newElements = new Map (elements );
265
263
newElements .delete (element );
266
264
return newElements ;
@@ -283,8 +281,8 @@ export class ViewportService {
283
281
284
282
private handleIntersection(entries : IntersectionObserverEntry []): void {
285
283
const elements = this ._elements ();
286
-
287
- entries .forEach (entry => {
284
+
285
+ entries .forEach (( entry ) => {
288
286
const config = elements .get (entry .target );
289
287
if (config ?.callback ) {
290
288
config .callback (entry );
@@ -301,70 +299,65 @@ export class ViewportService {
301
299
``` html
302
300
<!-- Conditional rendering -->
303
301
@if (isLoading()) {
304
- <loading-spinner />
302
+ <loading-spinner />
305
303
} @else if (hasError()) {
306
- <error-message [error] =" error()" />
304
+ <error-message [error] =" error()" />
307
305
} @else {
308
- <content-display [data] =" data()" />
306
+ <content-display [data] =" data()" />
309
307
}
310
308
311
309
<!-- Iteration -->
312
310
@for (item of items(); track item.id) {
313
- <item-card
314
- [item] =" item"
315
- [index] =" $index"
316
- [isLast] =" $last"
317
- (action) =" handleAction($event, item)" />
311
+ <item-card
312
+ [item] =" item"
313
+ [index] =" $index"
314
+ [isLast] =" $last"
315
+ (action) =" handleAction($event, item)" />
318
316
} @empty {
319
- <empty-state message =" No items found" />
317
+ <empty-state message =" No items found" />
320
318
}
321
319
322
320
<!-- Switch statements -->
323
- @switch (status()) {
324
- @case ('loading') { <loading-state /> }
325
- @case ('error') { <error-state [error] =" error()" /> }
326
- @case ('success') { <success-state [data] =" data()" /> }
327
- @default { <unknown-state /> }
328
- }
321
+ @switch (status()) { @case ('loading') { <loading-state /> } @case ('error') {
322
+ <error-state [error] =" error()" /> } @case ('success') {
323
+ <success-state [data] =" data()" /> } @default { <unknown-state /> } }
329
324
```
330
325
331
326
** Binding Patterns** : Use direct property and class bindings:
332
327
333
328
``` html
334
329
<!-- Property bindings -->
335
- <div
330
+ <div
336
331
[class.active] =" isActive()"
337
332
[class.disabled] =" isDisabled()"
338
333
[attr.aria-expanded] =" isExpanded()"
339
334
[style.opacity] =" opacity()"
340
335
[style.transform] =" transform()" >
341
-
342
- <!-- Event bindings with proper typing -->
343
- <button
344
- (click) =" handleClick($event)"
345
- (keydown.enter) =" handleEnter()"
346
- (keydown.space) =" handleSpace()" >
347
- {{ buttonText() }}
348
- </button >
349
-
350
- <!-- Signal-based input binding -->
351
- <input
352
- [value] =" searchTerm()"
353
- (input) =" searchTerm.set($event.target.value)" />
336
+ <!-- Event bindings with proper typing -->
337
+ <button
338
+ (click) =" handleClick($event)"
339
+ (keydown.enter) =" handleEnter()"
340
+ (keydown.space) =" handleSpace()" >
341
+ {{ buttonText() }}
342
+ </button >
343
+
344
+ <!-- Signal-based input binding -->
345
+ <input [value] =" searchTerm()" (input) =" searchTerm.set($event.target.value)"
346
+ /></div >
354
347
```
355
348
356
349
### Directive Patterns
357
350
358
351
``` typescript
359
- import {
360
- Directive ,
361
- effect ,
362
- inject ,
363
- input ,
352
+ import {
353
+ Directive ,
354
+ effect ,
355
+ inject ,
356
+ input ,
364
357
output ,
365
358
signal ,
366
359
ElementRef ,
367
- OnDestroy
360
+ OnDestroy ,
368
361
} from ' @angular/core' ;
369
362
370
363
@Directive ({
@@ -414,18 +407,15 @@ export class ViewportObserverDirective implements OnDestroy {
414
407
415
408
private setupObserver(): void {
416
409
this .cleanup ?.();
417
-
418
- this .cleanup = this .viewportService .observe (
419
- this .elementRef .nativeElement ,
420
- {
421
- threshold: this .threshold (),
422
- rootMargin: this .rootMargin (),
423
- callback : (entry ) => {
424
- this .isInViewport .set (entry .isIntersecting );
425
- this .lastEntry .set (entry );
426
- },
427
- }
428
- );
410
+
411
+ this .cleanup = this .viewportService .observe (this .elementRef .nativeElement , {
412
+ threshold: this .threshold (),
413
+ rootMargin: this .rootMargin (),
414
+ callback : (entry ) => {
415
+ this .isInViewport .set (entry .isIntersecting );
416
+ this .lastEntry .set (entry );
417
+ },
418
+ });
429
419
}
430
420
431
421
private readonly lastEntry = signal <IntersectionObserverEntry | null >(null );
@@ -456,31 +446,31 @@ describe('ViewportElementComponent', () => {
456
446
457
447
it (' should emit visibility changes when signal updates' , () => {
458
448
const emitSpy = jest .spyOn (component .visibilityChange , ' emit' );
459
-
449
+
460
450
// Update signal inputs directly
461
451
fixture .componentRef .setInput (' threshold' , 0.8 );
462
452
fixture .detectChanges ();
463
-
453
+
464
454
// Test signal-based state changes
465
455
component [' isInViewport' ].set (true );
466
456
component [' visibilityRatio' ].set (75 );
467
-
457
+
468
458
expect (component [' opacity' ]()).toBe (0.8 ); // 0.75 * 0.8 + 0.2
469
459
});
470
460
471
461
it (' should compute opacity based on visibility ratio' , () => {
472
462
// Test computed signals
473
463
component [' visibilityRatio' ].set (50 );
474
464
expect (component [' opacity' ]()).toBe (0.6 ); // 0.5 * 0.8 + 0.2
475
-
465
+
476
466
component [' visibilityRatio' ].set (100 );
477
467
expect (component [' opacity' ]()).toBe (1.0 ); // 1.0 * 0.8 + 0.2
478
468
});
479
469
480
470
it (' should update viewport state based on visibility' , () => {
481
471
component [' isInViewport' ].set (true );
482
472
expect (component [' viewportState' ]()).toBe (' visible' );
483
-
473
+
484
474
component [' isInViewport' ].set (false );
485
475
expect (component [' viewportState' ]()).toBe (' hidden' );
486
476
});
@@ -500,9 +490,7 @@ describe('ViewportService', () => {
500
490
501
491
beforeEach (() => {
502
492
TestBed .configureTestingModule ({
503
- providers: [
504
- { provide: PLATFORM_ID , useValue: ' browser' },
505
- ],
493
+ providers: [{ provide: PLATFORM_ID , useValue: ' browser' }],
506
494
});
507
495
service = TestBed .inject (ViewportService );
508
496
mockElement = document .createElement (' div' );
@@ -511,11 +499,11 @@ describe('ViewportService', () => {
511
499
it (' should track elements correctly' , () => {
512
500
expect (service .trackedElementsCount ()).toBe (0 );
513
501
expect (service .isActive ()).toBe (false );
514
-
502
+
515
503
const cleanup = service .observe (mockElement );
516
504
expect (service .trackedElementsCount ()).toBe (1 );
517
505
expect (service .isActive ()).toBe (true );
518
-
506
+
519
507
cleanup ();
520
508
expect (service .trackedElementsCount ()).toBe (0 );
521
509
expect (service .isActive ()).toBe (false );
@@ -524,14 +512,12 @@ describe('ViewportService', () => {
524
512
it (' should handle SSR gracefully' , () => {
525
513
TestBed .resetTestingModule ();
526
514
TestBed .configureTestingModule ({
527
- providers: [
528
- { provide: PLATFORM_ID , useValue: ' server' },
529
- ],
515
+ providers: [{ provide: PLATFORM_ID , useValue: ' server' }],
530
516
});
531
-
517
+
532
518
const ssrService = TestBed .inject (ViewportService );
533
519
const cleanup = ssrService .observe (mockElement );
534
-
520
+
535
521
// Should return no-op cleanup function
536
522
expect (typeof cleanup ).toBe (' function' );
537
523
expect (ssrService .trackedElementsCount ()).toBe (0 );
@@ -542,11 +528,13 @@ describe('ViewportService', () => {
542
528
### Performance Optimization
543
529
544
530
** Signal Optimization** :
531
+
545
532
- Use ` computed() ` for derived state - automatically optimized
546
533
- Prefer ` effect() ` over manual subscriptions
547
534
- Use ` untracked() ` to break signal dependencies when needed
548
535
549
536
** Intersection Observer Optimization** :
537
+
550
538
``` typescript
551
539
// Efficient observer configuration
552
540
private readonly observerConfig = computed (() => ({
@@ -562,11 +550,12 @@ effect(() => {
562
550
```
563
551
564
552
** Memory Management** :
553
+
565
554
``` typescript
566
555
// Automatic cleanup with effect cleanup
567
556
effect ((onCleanup ) => {
568
557
const cleanup = this .setupObserver ();
569
-
558
+
570
559
onCleanup (() => {
571
560
cleanup ();
572
561
});
@@ -581,14 +570,14 @@ private readonly error = signal<Error | null>(null);
581
570
private readonly isLoading = signal <boolean >(false );
582
571
583
572
protected readonly hasError = computed (() => this .error () !== null );
584
- protected readonly canRetry = computed (() =>
573
+ protected readonly canRetry = computed (() =>
585
574
this .hasError () && ! this .isLoading ()
586
575
);
587
576
588
577
async performOperation (): Promise < void > {
589
578
this.isLoading.set(true);
590
579
this.error.set(null);
591
-
580
+
592
581
try {
593
582
await this .operation ();
594
583
} catch (error) {
@@ -620,7 +609,7 @@ import { PLATFORM_ID, inject } from '@angular/core';
620
609
621
610
constructor () {
622
611
const platformId = inject (PLATFORM_ID );
623
-
612
+
624
613
if (isPlatformBrowser (platformId )) {
625
614
this .initializeObserver ();
626
615
}
@@ -630,6 +619,7 @@ constructor() {
630
619
### Migration Strategy
631
620
632
621
** From Angular 17 to v20+** :
622
+
633
623
1 . Replace all ` @Input() ` with ` input() `
634
624
2 . Replace all ` @Output() ` with ` output() `
635
625
3 . Convert component state to signals
@@ -639,6 +629,7 @@ constructor() {
639
629
7 . Replace manual subscriptions with ` effect() `
640
630
641
631
** Breaking Changes to Expect** :
632
+
642
633
- Remove all structural directives (` *ngIf ` , ` *ngFor ` )
643
634
- Remove ` ngClass ` and ` ngStyle ` - use direct bindings
644
635
- Remove ` async ` pipe for signals (not needed)
@@ -649,7 +640,7 @@ constructor() {
649
640
### Manual Testing Protocol
650
641
651
642
1 . ** Demo App** : Verify all examples work with signal-based updates
652
- 2 . ** Example App** : Test performance with rapid viewport changes
643
+ 2 . ** Example App** : Test performance with rapid viewport changes
653
644
3 . ** Responsive Testing** : Validate across viewport sizes
654
645
4 . ** Memory Testing** : Check for leaks during rapid scroll/resize
655
646
0 commit comments