npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@libs-ui/components-scroll-measure-items-direction-horizontal

v0.2.357-2

Published

> Directive ảo hóa danh sách theo chiều ngang (horizontal virtual scrolling) với kích thước item động (dynamic width).

Readme

@libs-ui/components-scroll-measure-items-direction-horizontal

Directive ảo hóa danh sách theo chiều ngang (horizontal virtual scrolling) với kích thước item động (dynamic width).

Giới thiệu

LibsUiScrollMeasureItemDirectionHorizontalDirective là directive Angular giúp tối ưu hiệu năng khi render danh sách lớn theo chiều ngang. Thay vì render toàn bộ DOM, directive chỉ giữ trong DOM các item đang nằm trong viewport (cộng thêm một số buffer), đồng thời dùng một element ảo để duy trì kích thước scroll chính xác. Directive hỗ trợ item có chiều rộng không đồng đều thông qua hàm đo width bất đồng bộ do consumer cung cấp.

Tính năng

  • Virtual Scrolling chiều ngang: Chỉ render item trong vùng nhìn thấy, giảm số lượng DOM node đáng kể.
  • Dynamic Width: Hỗ trợ mỗi item có chiều rộng khác nhau thông qua hàm functionGetWidthItem bất đồng bộ.
  • Buffer ahead: Tự động render thêm 3 item ngoài viewport để tránh khoảng trắng khi scroll nhanh.
  • Scroll Control API: Cung cấp scrollInto, scrollToIndex, scrollToPosition, reCalculatorViewPort qua output outFunctionControl.
  • ResizeObserver tích hợp: Tự động tính lại viewport khi kích thước container thay đổi.
  • Đo width song song: Dùng Promise.all để đo width toàn bộ item cùng lúc — tối ưu cho list hàng nghìn cột.
  • Tự cleanup: Hủy observer, xóa element ảo và timer khi directive bị destroy.

Khi nào sử dụng

  • Hiển thị danh sách ảnh, thẻ, tab, cột bảng theo chiều ngang với số lượng lớn (hàng trăm đến hàng nghìn item).
  • Kích thước các item không đều nhau (ví dụ: tên cột dài ngắn khác nhau trong bảng dữ liệu lớn).
  • Cần cuộn đến một item hoặc index cụ thể theo chương trình (scrollToIndex, scrollInto).
  • Cần tính lại viewport khi dữ liệu hoặc kích thước container thay đổi động (reCalculatorViewPort).

Cài đặt

npm install @libs-ui/components-scroll-measure-items-direction-horizontal

Import

import {
  LibsUiScrollMeasureItemDirectionHorizontalDirective,
  IScrollMeasureItemDirectionHorizontalFunctionsControl,
  IScrollMeasureStoreItemConvert,
} from '@libs-ui/components-scroll-measure-items-direction-horizontal';

@Component({
  standalone: true,
  imports: [LibsUiScrollMeasureItemDirectionHorizontalDirective],
  // ...
})
export class MyComponent {}

Ví dụ sử dụng

Ví dụ 1 — Virtual scroll cơ bản (1000 item, chiều rộng đồng đều)

// my-list.component.ts
import { Component, signal } from '@angular/core';
import {
  LibsUiScrollMeasureItemDirectionHorizontalDirective,
  IScrollMeasureItemDirectionHorizontalFunctionsControl,
} from '@libs-ui/components-scroll-measure-items-direction-horizontal';

interface T_Column {
  id: number;
  label: string;
}

@Component({
  selector: 'app-my-list',
  standalone: true,
  imports: [LibsUiScrollMeasureItemDirectionHorizontalDirective],
  templateUrl: './my-list.component.html',
})
export class MyListComponent {
  protected columns = signal<T_Column[]>(
    Array.from({ length: 1000 }, (_, i) => ({ id: i + 1, label: `Cột ${i + 1}` }))
  );
  protected viewPortColumns = signal<T_Column[]>([]);
  protected scrollControl: IScrollMeasureItemDirectionHorizontalFunctionsControl | null = null;

  protected measureColumnWidth = async (_item: T_Column): Promise<number> => {
    // Chiều rộng cố định 120px mỗi cột
    return 120;
  };

  protected handlerViewPortItem(items: T_Column[]): void {
    this.viewPortColumns.set(items);
  }

  protected handlerFunctionControl(control: IScrollMeasureItemDirectionHorizontalFunctionsControl): void {
    this.scrollControl = control;
  }

  protected handlerScrollToFirst(): void {
    this.scrollControl?.scrollToIndex(0);
  }

  protected handlerScrollToLast(): void {
    this.scrollControl?.scrollToIndex(this.columns().length - 1);
  }
}
<!-- my-list.component.html -->
<div class="flex gap-2 mb-4">
  <button (click)="handlerScrollToFirst()">Đến đầu</button>
  <button (click)="handlerScrollToLast()">Đến cuối</button>
</div>

<!-- Outer container: overflow-x-auto, có thanh cuộn ngang -->
<div
  #scrollContainer
  class="w-full h-[80px] overflow-x-auto border border-gray-300 rounded relative">

  <!-- Inner container: gắn directive, nằm bên trong outer -->
  <div
    class="flex items-center h-full"
    LibsUiScrollMeasureItemDirectionHorizontalDirective
    [elementScroll]="scrollContainer"
    [items]="columns()"
    [functionGetWidthItem]="measureColumnWidth"
    (outViewPortItem)="handlerViewPortItem($event)"
    (outFunctionControl)="handlerFunctionControl($event)">

    @for (col of viewPortColumns(); track col.id) {
      <div class="shrink-0 w-[120px] h-[60px] flex items-center justify-center border-r border-gray-200 text-sm">
        {{ col.label }}
      </div>
    }
  </div>
</div>

Ví dụ 2 — Item có chiều rộng động (tính từ nội dung)

// dynamic-width-list.component.ts
import { Component, signal } from '@angular/core';
import {
  LibsUiScrollMeasureItemDirectionHorizontalDirective,
  IScrollMeasureItemDirectionHorizontalFunctionsControl,
} from '@libs-ui/components-scroll-measure-items-direction-horizontal';

interface T_Tag {
  id: number;
  name: string;
}

@Component({
  selector: 'app-dynamic-width-list',
  standalone: true,
  imports: [LibsUiScrollMeasureItemDirectionHorizontalDirective],
  templateUrl: './dynamic-width-list.component.html',
})
export class DynamicWidthListComponent {
  protected tags = signal<T_Tag[]>([
    { id: 1, name: 'Angular' },
    { id: 2, name: 'Virtual Scroll' },
    { id: 3, name: 'Performance Optimization' },
    { id: 4, name: 'UI' },
    { id: 5, name: 'Horizontal Scrolling with Dynamic Items' },
    { id: 6, name: 'TypeScript' },
    { id: 7, name: 'RxJS' },
    { id: 8, name: 'Signal' },
  ]);
  protected viewPortTags = signal<T_Tag[]>([]);
  protected scrollControl: IScrollMeasureItemDirectionHorizontalFunctionsControl | null = null;

  /**
   * Ước tính chiều rộng dựa trên số ký tự.
   * Cộng thêm 32px padding trái/phải (16px mỗi bên) và 8px gap.
   */
  protected measureTagWidth = async (item: T_Tag): Promise<number> => {
    const charWidth = 8;
    return item.name.length * charWidth + 32 + 8;
  };

  protected handlerViewPortItem(items: T_Tag[]): void {
    this.viewPortTags.set(items);
  }

  protected handlerFunctionControl(control: IScrollMeasureItemDirectionHorizontalFunctionsControl): void {
    this.scrollControl = control;
  }

  protected handlerScrollIntoTag(tag: T_Tag): void {
    this.scrollControl?.scrollInto(tag);
  }
}
<!-- dynamic-width-list.component.html -->
<div
  #tagScrollContainer
  class="w-full h-[60px] overflow-x-auto relative">

  <div
    class="flex items-center gap-2 h-full"
    LibsUiScrollMeasureItemDirectionHorizontalDirective
    [elementScroll]="tagScrollContainer"
    [items]="tags()"
    [functionGetWidthItem]="measureTagWidth"
    (outViewPortItem)="handlerViewPortItem($event)"
    (outFunctionControl)="handlerFunctionControl($event)">

    @for (tag of viewPortTags(); track tag.id) {
      <span
        class="shrink-0 px-4 py-1 bg-blue-100 text-blue-800 rounded-full text-sm whitespace-nowrap cursor-pointer"
        (click)="handlerScrollIntoTag(tag)">
        {{ tag.name }}
      </span>
    }
  </div>
</div>

Ví dụ 3 — Cuộn theo chương trình và tính lại viewport

// programmatic-scroll.component.ts
import { Component, signal } from '@angular/core';
import {
  LibsUiScrollMeasureItemDirectionHorizontalDirective,
  IScrollMeasureItemDirectionHorizontalFunctionsControl,
} from '@libs-ui/components-scroll-measure-items-direction-horizontal';

interface T_Photo {
  id: number;
  width: number;
  src: string;
}

@Component({
  selector: 'app-programmatic-scroll',
  standalone: true,
  imports: [LibsUiScrollMeasureItemDirectionHorizontalDirective],
  templateUrl: './programmatic-scroll.component.html',
})
export class ProgrammaticScrollComponent {
  protected photos = signal<T_Photo[]>(
    Array.from({ length: 500 }, (_, i) => ({
      id: i + 1,
      width: 100 + (i % 4) * 50,   // 100, 150, 200, 250 lặp lại
      src: `https://picsum.photos/id/${i + 1}/${100 + (i % 4) * 50}/80`,
    }))
  );
  protected viewPortPhotos = signal<T_Photo[]>([]);
  protected scrollControl: IScrollMeasureItemDirectionHorizontalFunctionsControl | null = null;

  protected measurePhotoWidth = async (item: T_Photo): Promise<number> => {
    // Cộng thêm 8px gap giữa các ảnh
    return item.width + 8;
  };

  protected handlerViewPortItem(items: T_Photo[]): void {
    this.viewPortPhotos.set(items);
  }

  protected handlerFunctionControl(control: IScrollMeasureItemDirectionHorizontalFunctionsControl): void {
    this.scrollControl = control;
  }

  protected handlerScrollToIndex(index: number): void {
    this.scrollControl?.scrollToIndex(index);
  }

  protected handlerScrollToPosition(px: number): void {
    this.scrollControl?.scrollToPosition(px);
  }

  protected handlerRecalculate(): void {
    this.scrollControl?.reCalculatorViewPort();
  }
}
<!-- programmatic-scroll.component.html -->
<div class="flex gap-2 mb-3">
  <button (click)="handlerScrollToIndex(0)">Đầu (0)</button>
  <button (click)="handlerScrollToIndex(249)">Giữa (249)</button>
  <button (click)="handlerScrollToIndex(499)">Cuối (499)</button>
  <button (click)="handlerScrollToPosition(1000)">Vị trí 1000px</button>
  <button (click)="handlerRecalculate()">Tính lại viewport</button>
</div>

<div
  #photoScroll
  class="w-full h-[100px] overflow-x-auto relative border border-gray-200 rounded">

  <div
    class="flex items-center gap-2 h-full"
    LibsUiScrollMeasureItemDirectionHorizontalDirective
    [elementScroll]="photoScroll"
    [items]="photos()"
    [functionGetWidthItem]="measurePhotoWidth"
    (outViewPortItem)="handlerViewPortItem($event)"
    (outFunctionControl)="handlerFunctionControl($event)">

    @for (photo of viewPortPhotos(); track photo.id) {
      <img
        class="shrink-0 h-[80px] rounded object-cover"
        [style.width.px]="photo.width"
        [src]="photo.src"
        [alt]="'Photo ' + photo.id" />
    }
  </div>
</div>

@Input()

| Input | Type | Default | Mô tả | Ví dụ | |---|---|---|---|---| | [elementScroll] | HTMLElement | required | Element cha có overflow-x: auto chứa thanh cuộn ngang. Thường dùng template variable #scrollContainer. | [elementScroll]="scrollContainer" | | [items] | Array<any> | required | Toàn bộ mảng dữ liệu nguồn. Directive tự tính toán item nào cần render dựa trên viewport. | [items]="columns()" | | [functionGetWidthItem] | (item: any) => Promise<number> | required | Hàm bất đồng bộ trả về chiều rộng (px) của một item, bao gồm cả padding và margin/gap. Được gọi song song cho toàn bộ mảng khi items thay đổi. | [functionGetWidthItem]="measureWidth" |

@Output()

| Output | Type | Mô tả | Handler TS | Binding HTML | |---|---|---|---|---| | (outViewPortItem) | Array<any> | Emit danh sách item cần render trong viewport (kèm buffer 3 item). Consumer dùng mảng này cho @for. | handlerViewPortItem(items: T_Item[]): void { items.stopPropagation?.(); this.viewPortItems.set(items); } | (outViewPortItem)="handlerViewPortItem($event)" | | (outFunctionControl) | IScrollMeasureItemDirectionHorizontalFunctionsControl | Emit object chứa các hàm điều khiển scroll. Lưu lại để gọi scrollToIndex, scrollInto,... theo chương trình. | handlerFunctionControl(ctrl: IScrollMeasureItemDirectionHorizontalFunctionsControl): void { this.scrollControl = ctrl; } | (outFunctionControl)="handlerFunctionControl($event)" | | (outDivVirtual) | HTMLDivElement | Emit element ảo (invisible) được chèn vào container để duy trì tổng chiều rộng scroll. Ít khi cần dùng trực tiếp. | handlerDivVirtual(el: HTMLDivElement): void { this.virtualEl = el; } | (outDivVirtual)="handlerDivVirtual($event)" | | (outPaddingLeft) | number | Emit giá trị paddingLeft (px) hiện tại của inner container — bù cho các item đã scroll qua khỏi viewport bên trái. | handlerPaddingLeft(value: number): void { this.currentPaddingLeft = value; } | (outPaddingLeft)="handlerPaddingLeft($event)" |

Types & Interfaces

import {
  IScrollMeasureItemDirectionHorizontalFunctionsControl,
  IScrollMeasureStoreItemConvert,
} from '@libs-ui/components-scroll-measure-items-direction-horizontal';

/**
 * Object điều khiển scroll được emit qua (outFunctionControl).
 * Lưu lại reference sau khi nhận để gọi các phương thức theo chương trình.
 */
interface IScrollMeasureItemDirectionHorizontalFunctionsControl {
  /** Scroll container đến vị trí bắt đầu của item được truyền vào. */
  scrollInto: (item: any) => Promise<void>;

  /** Scroll container đến vị trí pixel chỉ định (scrollLeft). */
  scrollToPosition: (position: number) => Promise<void>;

  /** Scroll container đến vị trí bắt đầu của item tại index chỉ định. */
  scrollToIndex: (index: number) => Promise<void>;

  /**
   * Gọi lại hàm đo width toàn bộ item và tính lại vùng hiển thị.
   * Dùng khi kích thước item thay đổi sau khi render (ví dụ: font load xong, dữ liệu cập nhật).
   */
  reCalculatorViewPort: () => Promise<void>;

  /** Trả về mảng item đang hiển thị trong viewport tại thời điểm gọi. */
  getViewPortItems: () => Array<any>;
}

/**
 * Cấu trúc nội bộ lưu trữ thông tin vị trí từng item.
 * Được emit qua (outDivVirtual) nếu consumer cần debug layout.
 */
interface IScrollMeasureStoreItemConvert {
  /** Tham chiếu đến item gốc trong mảng [items]. */
  ref: any;
  /** Chiều rộng tính toán (px) của item này. */
  itemWidth: number;
  /** Vị trí bắt đầu (scrollLeft) của item, tính từ đầu container. */
  start: number;
  /** Vị trí kết thúc (scrollLeft + itemWidth) của item. */
  end: number;
}

Lưu ý quan trọng

⚠️ Kiến trúc 2 lớp bắt buộc: Directive phải được gắn vào inner container (thẻ flex bên trong), KHÔNG phải outer container có overflow-x: auto. Input [elementScroll] phải trỏ đến outer container — đây là element có thanh cuộn thực sự.

⚠️ Hàm functionGetWidthItem phải tính cả margin/gap: Chiều rộng trả về phải bao gồm toàn bộ không gian mà item chiếm (bao gồm margin, gap). Nếu thiếu, tổng chiều rộng ảo sẽ sai và scroll sẽ bị lệch vị trí.

⚠️ Directive thêm position: relative và điều chỉnh paddingLeft vào inner container: Không đặt các style này thủ công trên element gắn directive vì sẽ bị override. Thay vào đó, dùng output (outPaddingLeft) nếu cần biết giá trị hiện tại.

⚠️ Element ảo divVirtual được chèn vào outer container: Directive tự tạo và quản lý một <div> ẩn bên trong elementScroll để giữ đúng tổng chiều rộng scroll. Không cần tạo thủ công. Element này tự xóa khi directive destroy.

⚠️ items thay đổi reference mới kích hoạt đo lại: Mỗi khi items signal thay đổi, toàn bộ width được đo lại song song qua Promise.all. Với list rất lớn (> 5000 item) và hàm đo nặng, cân nhắc debounce ở phía consumer trước khi cập nhật items.

Demo

npx nx serve core-ui

Truy cập: http://localhost:4500/components/scroll-measure-items/direction-horizontal