Saltar al contenido principal

whenScrolled

Paquete: @tiendana/ngx-core/ui

Directiva que detecta cuando un elemento ha llegado al final del scroll con threshold configurable. Ideal para infinite scroll.

Características​

✅ Detección de scroll al final con gap configurable
✅ Event y Observable para flexibilidad
✅ Configurable vía input o DI token
✅ TypeScript typing completo

API​

Inputs (Model Signal)​

InputTipoDefaultDescripción
whenScrolledGapModelSignal<number | null | undefined>SCROLL_GAP_TOKEN (DEFAULT_SCROLL_GAP, 0)Gap en px desde el final para emitir evento

Outputs​

OutputTipoDescripción
onScrolledOutputEmitterRef<ScrollEvent>Emite cuando se alcanza el threshold

Observables​

ObservableTipoDescripción
onScrolled$Observable<ScrollEvent>Versión RxJS del evento

Métodos​

MétodoDescripción
setGap(gap: number)Establece nuevo gap programáticamente

Uso Básico​

<div 
whenScrolled
(onScrolled)="loadMore()"
[whenScrolledGap]="100"
style="height: 400px; overflow-y: auto;"
>
@for (item of items; track item.id) {
<div class="item">{{ item.name }}</div>
}
</div>

Con RxJS​

import { Component, viewChild } from '@angular/core';
import { WhenScrolledDirective } from '@tiendana/ngx-core/ui';

@Component({
template: `
<div whenScrolled [whenScrolledGap]="200">
<!-- Contenido -->
</div>
`
})
export class MyComponent {
scrollDir = viewChild(WhenScrolledDirective);

ngOnInit() {
this.scrollDir()?.onScrolled$.subscribe(event => {
console.log('Scrolled to bottom!', event);
});
}
}

Configuración Global con Token​

import { SCROLL_GAP_TOKEN } from '@tiendana/ngx-core/ui';

bootstrapApplication(AppComponent, {
providers: [
{ provide: SCROLL_GAP_TOKEN, useValue: 150 }
]
});

Ahora todos los whenScrolled usarán 150px por defecto. Sin proveedor, el valor es DEFAULT_SCROLL_GAP (0), también exportado.

Infinite Scroll Pattern​

@Component({
template: `
<div
whenScrolled
(onScrolled)="loadMore()"
[whenScrolledGap]="100"
>
@for (item of items(); track item.id) {
<div class="item">{{ item.name }}</div>
}

@if (loading()) {
<div class="loader">Cargando...</div>
}
</div>
`
})
export class InfiniteListComponent {
items = signal<Item[]>([]);
loading = signal(false);
page = signal(1);

async loadMore() {
if (this.loading()) return;

this.loading.set(true);
const newItems = await this.api.getItems(this.page());
this.items.update(current => [...current, ...newItems]);
this.page.update(p => p + 1);
this.loading.set(false);
}
}

Cálculo del Threshold​

La directiva calcula la distancia al final:

const distanceToBottom = 
target.scrollHeight - (target.scrollTop + target.clientHeight);

if (distanceToBottom <= gap) {
this.onScrolled.emit(event);
}

Ejemplo con Paginación​

@Component({
template: `
<div whenScrolled (onScrolled)="nextPage()">
<h2>Página {{ currentPage() }}</h2>
@for (item of currentItems(); track item.id) {
<div>{{ item.name }}</div>
}
</div>
`
})
export class PaginatedComponent {
allItems = signal<Item[]>([]);
currentPage = signal(1);
itemsPerPage = 20;

currentItems = computed(() => {
const start = (this.currentPage() - 1) * this.itemsPerPage;
return this.allItems().slice(0, start + this.itemsPerPage);
});

nextPage() {
this.currentPage.update(p => p + 1);
}
}

Validación de Gap​

import { hasValue } from '@tiendana/utils';

onScroll(ev: Event): void {
const gap = this.whenScrolledGap();

if (!hasValue(gap)) {
return; // No hace nada si gap es null/undefined
}

// ... lógica de scroll
}

TypeScript Interface​

interface ScrollEvent extends Event {
currentTarget: HTMLElement;
}

Performance​

  • Solo emite cuando la distancia al final es menor o igual al gap
  • Emite en cada evento scroll mientras se mantenga dentro del gap: protege loadMore() con una bandera de carga, como en el patrón de infinite scroll
  • Usa signal model para reactividad eficiente

Accesibilidad​

  • Considera agregar aria-live="polite" para anunciar nuevo contenido
  • Proporciona feedback visual durante carga
  • Incluye opción para cargar más manualmente