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)
| Input | Tipo | Default | Descripción |
|---|---|---|---|
whenScrolledGap | ModelSignal<number | null | undefined> | SCROLL_GAP_TOKEN (DEFAULT_SCROLL_GAP, 0) | Gap en px desde el final para emitir evento |
Outputs
| Output | Tipo | Descripción |
|---|---|---|
onScrolled | OutputEmitterRef<ScrollEvent> | Emite cuando se alcanza el threshold |
Observables
| Observable | Tipo | Descripción |
|---|---|---|
onScrolled$ | Observable<ScrollEvent> | Versión RxJS del evento |
Métodos
| Método | Descripció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
scrollmientras se mantenga dentro del gap: protegeloadMore()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