Skip to content

feat: Added virtual scroll component and sample implementation - #17281

Open
rkaraivanov wants to merge 34 commits into
masterfrom
rkaraivanov/virtual-scroll-component
Open

rkaraivanov wants to merge 34 commits into
masterfrom
rkaraivanov/virtual-scroll-component

Conversation

@rkaraivanov

@rkaraivanov rkaraivanov commented May 18, 2026

Copy link
Copy Markdown
Member

Closes #17607
Closes #17608
Closes #17610
Closes #17611
Closes #17615
Closes #17616

Description

Expose a new virtual scroll component for horizontal/vertical scrolling.

Motivation / Context

  1. Self-contained viewport — The component is the scrollable element. igxForOf requires the developer to manually wire a separate scroll container, a VirtualHelperComponent/HVirtualHelperComponent DOM helper element, and pass igxForContainerSize explicitly. That's 3–4 pieces of setup vs. one tag.
  2. Signals-first reactive model.
  3. View pooling — Active views are kept in a pool and detached/reattached instead of destroyed/recreated on every scroll. igxForOf uses ViewContainerRef.move() but always keeps a fixed chunk alive, paying create/destroy cost when chunk size changes.
  4. Clean ChangeDetectionStrategy.OnPush throughout the implementation.
  5. Better DX for remote/infinite scrolling.
  6. No auxiliary helper components in the DOM.
  7. Typed template context.

Type of Change (check all that apply):

  • Bug fix
  • New functionality
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Refactoring (no functional changes)
  • Documentation
  • Demos
  • CI/CD
  • Tests
  • Changelog
  • Skills/Agents

Component(s) / Area(s) Affected:

How Has This Been Tested?

  • Unit tests
  • Manual testing
  • Automated e2e tests

Test Configuration:

  • Angular version:
  • Browser(s):
  • OS:

Screenshots / Recordings

Checklist:

  • All relevant tags have been applied to this PR
  • This PR includes unit tests covering all the new code (test guidelines)
  • This PR includes API docs for newly added methods/properties (api docs guidelines)
  • This PR includes feature/README.MD updates for the feature docs
  • This PR includes general feature table updates in the root README.MD
  • This PR includes CHANGELOG.MD updates for newly added functionality
  • This PR contains breaking changes
  • This PR includes ng update migrations for the breaking changes (migrations guidelines)
  • This PR includes behavioral changes and the feature specification has been updated with them
  • Accessibility (ARIA, keyboard navigation, focus management) has been verified

rkaraivanov and others added 16 commits May 22, 2026 10:58
Fixed several issues with virtual coordinates mapping and the scroll engine in general.
The Binary Indexed Tree (BIT) is used to efficiently calculate the cumulative heights of items in the virtual scroll,
which allows for faster updates and smoother scrolling experience.
Copilot AI review requested due to automatic review settings July 28, 2026 14:23

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a new IgxVirtualScrollComponent entry point to Ignite UI for Angular, providing a self-contained virtualized viewport for vertical and horizontal scrolling (including variable-size items and remote/infinite loading), and adds a demo route + sample showcasing usage.

Changes:

  • Added a new igniteui-angular/virtual-scroll entry point with a virtual scroll component, item-template directive, supporting types, and a scroll-math engine.
  • Added unit tests covering the engine behavior, template context, orientations (including RTL), outputs, and basic rendering.
  • Added a demo/sample implementation and wired it into the demo app routing + navigation list.

Reviewed changes

Copilot reviewed 17 out of 17 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/app/virtual-scroll/virtual-scroll.sample.ts Demo component showcasing vertical/horizontal + remote data scenarios.
src/app/virtual-scroll/virtual-scroll.sample.html Sample template demonstrating igxVirtualItem usage for multiple scenarios.
src/app/virtual-scroll/virtual-scroll.sample.scss Styling for the new demo page.
src/app/app.routes.ts Adds a /virtual-scroll route for the demo.
src/app/app.component.ts Adds “Virtual Scroll” to the demo navigation list.
projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.ts Core virtual scroll component implementation (signals-first + view pooling).
projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.html Component template for track/content positioning.
projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.scss Component styles for vertical/horizontal layouts.
projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll-item.directive.ts Directive to capture ng-template[igxVirtualItem] as the item template.
projects/igniteui-angular/virtual-scroll/src/virtual-scroll/types.ts Typed template context + output payload types.
projects/igniteui-angular/virtual-scroll/src/virtual-scroll/scroll-engine.ts Scroll math engine (BIT-based sizing, visible range computation, coordinate compression).
projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts Unit tests for engine + component behaviors (including RTL).
projects/igniteui-angular/virtual-scroll/src/public_api.ts Public exports for the new entry point.
projects/igniteui-angular/virtual-scroll/index.ts Entry-point barrel export.
projects/igniteui-angular/virtual-scroll/ng-package.json ng-packagr entry point config placeholder (consistent with other entry points).
projects/igniteui-angular/virtual-scroll/README.md Feature documentation and usage examples for the new component.
projects/igniteui-angular/src/public_api.ts Exposes igniteui-angular/virtual-scroll from the root package exports.
Comments suppressed due to low confidence (1)

src/app/virtual-scroll/virtual-scroll.sample.html:107

  • Section numbering in the sample comments labels this as section 6 even though it is the 5th sample block. Renumber to keep the sample easier to follow.
    <!-- 6. Remote / infinite scrolling                                      -->
    <!-- ------------------------------------------------------------------ -->

Comment thread src/app/virtual-scroll/virtual-scroll.sample.ts
Comment thread src/app/virtual-scroll/virtual-scroll.sample.html Outdated
Comment thread projects/igniteui-angular/virtual-scroll/src/public_api.ts Outdated
@rkaraivanov

Copy link
Copy Markdown
Member Author

@simeonoff
Can someone from your team take a look at styling of the component? I'm asking mainly to check whether it correctly works with the new scoped styles and theming.

@simeonoff

Copy link
Copy Markdown
Member

@simeonoff Can someone from your team take a look at styling of the component? I'm asking mainly to check whether it correctly works with the new scoped styles and theming.

Restructured the CSS and sample. Should be more in line with the other component styles now and the approach we wanna use in the future for authoring component styles.

viktorkombov and others added 5 commits September 8, 2026 15:20
…#17579)

* feat(virtual-scroll): add initial viewport hint

A list inside a popup is hidden until the change detection pass that
reveals it, so it has no size to measure and renders nothing in that
pass. initialViewportSize gives that first render a size to work from;
the measured size takes over as soon as the host can be measured.

* fix(virtual-scroll): measure a laid-out host at its real size

* feat(virtual-scroll): support windowed data

* refactor(excel-filter): use virtual scroll

The search list is virtualized by IgxVirtualScrollComponent instead of
the igxFor directive. Rows are measured in the DOM rather than assumed
from a configured item and container size, and initialViewportSize gives
the list the height the menu allots it so the first window renders in
the pass that opens it.

aria-activedescendant now names only a rendered element: navigateItem
clears it while the target is off screen and restores it once the scroll
has rendered that row, and a scroll that recycles the wrappers no longer
leaves it pointing at an element that is gone.

* fix(excel-filter): clear stale active descendant

* fix(virtual-scroll): report a moved range with an unchanged loaded part

* fix(excel-filter): name the focused row after clearing a search

* feat(combo): use IgxVirtualScrollComponent for the item list

Migrates IgxComboComponent and IgxSimpleComboComponent from the igxFor
directive to IgxVirtualScrollComponent, following the Excel style
filtering search list. IgxDropDownComponent now also accepts a
content-projected igx-virtual-scroll; *igxFor keeps working as
documented, and selection and navigation behave the same either way.

A row is measured in the DOM once it renders and the measured size
replaces the estimate it started from, so the scrollable extent follows
the real height of the rows that have rendered.

* fix(drop-down): reconcile virtualized item queries

* docs(*): tighten the comments added by the virtual scroll migration

* fix(*): correct remote paging, virtualized navigation and ARIA

* fix(drop-down): select a loaded index that has no rendered row

* docs(virtual-scroll): state the per-axis viewport hint contract

* perf(combo): resolve the selection without scanning per key

* test(excel-filter): wait for the virtualized list to render

* fix(*): follow the virtual scroll's renamed row markup

* fix(combo): keep an arriving page at the index it was requested from

* test(esf): assert the search list against the pass that delivers its values

* refactor(virtual-scroll): reshape the paging API before release

A page whose `totalCount` differs from the previous one comes from
another collection, a filtered one for instance, so no measured size
is retained across it. Same-count pages keep sizes per index as before.

`initialViewportSize` is marked `@hidden @internal`. It exists because
the drop-down family reads and focuses items in the pass that opens the
list, which no measurement can serve in time. Hiding it keeps its later
removal from being a breaking change once those components render on
reveal.

---------

Co-authored-by: Radoslav Karaivanov <rkaraivanov@infragistics.com>
@rkaraivanov rkaraivanov added the squash-merge Merge PR with "Squash and Merge" option label Sep 16, 2026
this.children.changes
.pipe(takeUntil(this.destroy$))
.subscribe(() => afterNextRender(
() => this.refreshActiveDescendant(),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we do the same ESF does - it only marksForCheck if the id actually changed.

this.virtDir.igxForItemSize = this.itemSize;
this.virtDir.recalcUpdateSizes();
// Only flushes the bindings the surrounding menu changed; the list measures itself.
if (this.virtualScroll && !(this.cdr as any).destroyed) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you use the component's own destroy flag?

Comment thread CHANGELOG.md
### General

- The Excel style filtering search list, `IgxComboComponent` and `IgxSimpleComboComponent` are now virtualized by `IgxVirtualScrollComponent` instead of the `igxFor` directive. A row is measured in the DOM once it renders and the measured size replaces the estimate it started from; rows that have not rendered keep that estimate.
- The list markup changed accordingly: `igx-display-container` and the `igx-vhelper--vertical` scrollbar are replaced by the `igx-virtual-scroll` host and its `igx-vs__item` row wrappers. Applications and tests that reach into those elements directly need updating.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are these wrappers? Shouldn't this be just igx-virtual-item as children of igx-virtual-scroll

@ViewChild(IgxForOfDirective, { read: IgxForOfDirective, static: true })
protected virtDir!: IgxForOfDirective<any>;
@ViewChild('virtualScroll', { static: true })
public virtualScrollContainer!: IgxVirtualScrollComponent<any>;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the type change is a breaking one and should be documented as such and also requires a migration. Same for ESF

* A virtual scroll component for large lists. Only the items visible in the
* viewport (plus a configurable over-scan) are rendered.
*
* @igxModule IgxVirtualScrollModule

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't think we have this module right? This component is strictly stand-alone

ChronosSF and others added 2 commits September 17, 2026 18:10
… change

Two regressions in IgxVirtualScrollComponent, captured as tests that fail
on the current implementation:

- scrollToIndex with block/inline 'nearest' start-aligns an item that is
  out of view. Native scrollIntoView semantics end-align an item below or
  past the viewport, which is what drop-down, combo and the Excel-style
  filter rely on for ArrowDown navigation.

- _contentTransform reads the host direction through _isLTR() inside a
  computed(), so a runtime dir change on a horizontal list does not flip
  the translateX sign while the rendered range stays the same.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@ChronosSF

Copy link
Copy Markdown
Member

Some findings during agentic testing:

  1. nearest alignment start-aligns out-of-view items. scrollToIndex(i, { block: 'nearest' }) returns the current offset when the item is already visible, but otherwise falls through to start. Native scrollIntoView semantics, which the docs claim to match, end-align an item that is below or past the viewport. Drop-down, combo and the Excel-style filter all navigate with nearest, so ArrowDown past the bottom edge now jumps the row to the top of the list instead of revealing it at the bottom the way igxFor did. Tests: should end-align an item below the viewport for block: nearest and the horizontal inline: nearest mirror both fail with Expected 500 to be 250. The start case for an item above the viewport passes and is included for completeness.

  2. Direction is read non-reactively inside _contentTransform. The computed calls _isLTR(), which reads computed style, so it only re-evaluates when the rendered range or the engine version changes. Flipping dir at runtime on a horizontal list while the window stays put leaves the wrapper at translateX(+x) instead of translateX(-x). Tests: should flip the content transform when the direction changes to rtl at runtime and its ltr mirror fail with Expected 'translateX(400px)' to be 'translateX(-400px)' and the reverse. The tests deliberately hold the rendered range constant across the flip, since that is the only state in which the bug is observable. A fix needs the direction itself to be a tracked signal, not a DOM read inside the computed.

Both have tests added that will fail in the latest commit. We need to fix these before merge.

Three more regressions captured as tests that fail on the current code:

- Under coordinate compression the visible range at the maximum DOM
  scroll offset stops short of the last items, so the tail of a very
  long list can never be reached.

- Replacing `data` with rows of unchanged size discards their
  measurements but never re-measures the recycled wrappers, leaving the
  engine on the estimate.

- navigateFirst on a remote page that starts past index 0 lands on the
  group header once the first page arrives. The spec now asserts the
  loaded page starts past 0, so it cannot pass through the local path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@ChronosSF

Copy link
Copy Markdown
Member

Added 3 more tests that fail due to actual issues:

  1. Compressed lists cannot reach their last rows
    When the total size exceeds the browser's scroll limit, the engine maps DOM scroll to virtual space by scaling the total: virtual start = DOM scroll × totalSize ÷ domSize. At the largest scrollable offset, domSize − viewport, that lands at totalSize − viewport × ratio, and the window only spans one viewport from there. The last viewport × (ratio − 1) virtual pixels are never in the visible range, so with a million 50 px rows Firefox silently drops the final 8 to 33 rows depending on viewport height and Chrome the final 3 to 7. The content-transform clamp does not help, since it only positions rows that were selected for rendering.
    Suggested fix: compress the scrollable range, not the total. Virtual start = DOM scroll × (totalSize − viewport) ÷ (domSize − viewport), so the maximum DOM offset maps exactly to totalSize − viewport. getVisibleRange, getScrollOffsetForIndex, getAlignedScrollOffset and isIndexInView must all share that mapping, which means the offset conversions need the viewport size, not only the range query. With that mapping all three assertions in the test hold as written.

  2. Replacing data with same-size rows leaves the engine on the estimate
    Assigning a new array changes every item's identity, so the retain count is zero and every size is rebuilt from estimatedItemSize. The recycled wrappers keep their data-index, so _scheduleItemMeasurement skips re-observing them, and ResizeObserver stays silent because the element's size did not change. Rendered rows therefore keep the estimate indefinitely, and the track size, scrollToIndex targets and stateChange.totalSize are off by the difference.
    Fix direction: whenever a resize discards measurements, re-measure the wrappers currently in the DOM, either by re-registering them with the observer or by reading their border box in the after-render pass.

  3. Remote first page lands focus on a group header
    When the loaded page starts past index 0, navigateFirst calls navigateItem(0). The scroll resolves before the page for index 0 exists, so skipHeader finds no rendered item and returns. When the page then arrives, index 0 renders as the group header, aria-activedescendant names it, and focusedItem reports a header. The igxFor path always landed on the first selectable item. The test asserts the loaded page starts past 0 first, so it cannot pass through the local path.
    Fix direction: after a page arrives, re-run the header skip for the pending focused index, or defer the skip until the target index is rendered rather than until the scroll settles.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

5 participants