diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 492a0c6a8224..c0550ee9c28b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -135,6 +135,7 @@ /src/cdk-experimental/combobox/** @jelbourn /src/cdk-experimental/popover-edit/** @andrewseguin /src/cdk-experimental/scrolling/** @mmalerba +/src/cdk-experimental/table/** @michaeljamesparsons @andrewseguin /src/cdk-experimental/table-scroll-container/** @andrewseguin /src/cdk-experimental/listbox/** @jelbourn /src/cdk-experimental/selection/** @andrewseguin diff --git a/src/cdk-experimental/config.bzl b/src/cdk-experimental/config.bzl index e749bef03d74..a12f8c34c6f8 100644 --- a/src/cdk-experimental/config.bzl +++ b/src/cdk-experimental/config.bzl @@ -6,6 +6,7 @@ CDK_EXPERIMENTAL_ENTRYPOINTS = [ "popover-edit", "scrolling", "selection", + "table", "table-scroll-container", ] diff --git a/src/cdk-experimental/table/BUILD.bazel b/src/cdk-experimental/table/BUILD.bazel new file mode 100644 index 000000000000..dfe56df683c4 --- /dev/null +++ b/src/cdk-experimental/table/BUILD.bazel @@ -0,0 +1,22 @@ +load( + "//tools:defaults.bzl", + "ng_module", +) + +package(default_visibility = ["//visibility:public"]) + +ng_module( + name = "table", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + deps = [ + "//src/cdk/bidi", + "//src/cdk/platform", + "//src/cdk/table", + "@npm//@angular/common", + "@npm//@angular/core", + "@npm//rxjs", + ], +) diff --git a/src/cdk-experimental/table/index.ts b/src/cdk-experimental/table/index.ts new file mode 100644 index 000000000000..676ca90f1ffa --- /dev/null +++ b/src/cdk-experimental/table/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export * from './public-api'; diff --git a/src/cdk-experimental/table/public-api.ts b/src/cdk-experimental/table/public-api.ts new file mode 100644 index 000000000000..3fa4d50707d3 --- /dev/null +++ b/src/cdk-experimental/table/public-api.ts @@ -0,0 +1,10 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export * from './table-virtual-scroll'; +export * from './table-module'; diff --git a/src/cdk-experimental/table/table-module.ts b/src/cdk-experimental/table/table-module.ts new file mode 100644 index 000000000000..68163af15391 --- /dev/null +++ b/src/cdk-experimental/table/table-module.ts @@ -0,0 +1,23 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {NgModule} from '@angular/core'; +import {CdkTableModule as TableModule} from '@angular/cdk/table'; + +import {CdkTableVirtualScroll} from './table-virtual-scroll'; + + + +@NgModule({ + declarations: [CdkTableVirtualScroll], + exports: [CdkTableVirtualScroll], + imports: [ + TableModule, + ], +}) +export class CdkTableModule {} diff --git a/src/cdk-experimental/table/table-virtual-scroll.ts b/src/cdk-experimental/table/table-virtual-scroll.ts new file mode 100644 index 000000000000..4debfd60cd88 --- /dev/null +++ b/src/cdk-experimental/table/table-virtual-scroll.ts @@ -0,0 +1,270 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import { + Directive, + Inject, + Input, + OnDestroy, + SkipSelf, +} from '@angular/core'; +import { + _RecycleViewRepeaterStrategy, + _VIEW_REPEATER_STRATEGY, + ListRange +} from '@angular/cdk/collections'; +import { + _TABLE_VIEW_CHANGE_STRATEGY, + CdkTable, + RenderRow, + RowContext, + STICKY_POSITIONING_LISTENER, + StickyPositioningListener, + StickyUpdate +} from '@angular/cdk/table'; +import { + BehaviorSubject, + combineLatest, + Observable, + ReplaySubject, + Subject, +} from 'rxjs'; +import { + shareReplay, + takeUntil +} from 'rxjs/operators'; +import { + CdkVirtualScrollRepeater, + CdkVirtualScrollViewport, +} from '@angular/cdk/scrolling'; + +/** + * An implementation of {@link StickyPositioningListener} that forwards sticky updates to another + * listener. + * + * The {@link CdkTableVirtualScroll} directive cannot provide itself as a + * {@link StickyPositioningListener} because the providers for both entities would point to the same + * instance. The {@link CdkTable} depends on the sticky positioning listener and the table virtual + * scroll depends on the table. Since the sticky positioning listener and table virtual scroll would + * be the same instance, this would create a circular dependency. + * + * The {@link CdkTableVirtualScroll} instead provides this class and attaches itself as the + * receiving listener so {@link StickyPositioningListener} and {@link CdkTableVirtualScroll} are + * provided as separate instances. + * + * @docs-private + */ +export class _PositioningListenerProxy implements StickyPositioningListener { + private _listener?: StickyPositioningListener; + + setListener(listener: StickyPositioningListener) { + this._listener = listener; + } + + stickyColumnsUpdated(update: StickyUpdate): void { + this._listener?.stickyColumnsUpdated(update); + } + + stickyEndColumnsUpdated(update: StickyUpdate): void { + this._listener?.stickyEndColumnsUpdated(update); + } + + stickyFooterRowsUpdated(update: StickyUpdate): void { + this._listener?.stickyFooterRowsUpdated(update); + } + + stickyHeaderRowsUpdated(update: StickyUpdate): void { + this._listener?.stickyHeaderRowsUpdated(update); + } +} + +/** @docs-private */ +export const _TABLE_VIRTUAL_SCROLL_COLLECTION_VIEWER_FACTORY = + () => new BehaviorSubject({start: 0, end: 0}); + + +/** + * A directive that enables virtual scroll for a {@link CdkTable}. + */ +@Directive({ + selector: 'cdk-table[virtualScroll], table[cdk-table][virtualScroll]', + exportAs: 'cdkVirtualScroll', + providers: [ + {provide: _VIEW_REPEATER_STRATEGY, useClass: _RecycleViewRepeaterStrategy}, + // The directive cannot provide itself as the sticky positions listener because it introduces + // a circular dependency. Use an intermediate listener as a proxy. + {provide: STICKY_POSITIONING_LISTENER, useClass: _PositioningListenerProxy}, + // Initially emit an empty range. The virtual scroll viewport will update the range after it is + // initialized. + { + provide: _TABLE_VIEW_CHANGE_STRATEGY, + useFactory: _TABLE_VIRTUAL_SCROLL_COLLECTION_VIEWER_FACTORY, + }, + ], + host: { + 'class': 'cdk-table-virtual-scroll', + }, +}) +export class CdkTableVirtualScroll + implements CdkVirtualScrollRepeater, OnDestroy, StickyPositioningListener { + /** Emits when the component is destroyed. */ + private _destroyed = new ReplaySubject(1); + + /** Emits when the header rows sticky state changes. */ + private readonly _headerRowStickyUpdates = new Subject(); + + /** Emits when the footer rows sticky state changes. */ + private readonly _footerRowStickyUpdates = new Subject(); + + /** + * Observable that emits the data source's complete data set. This exists to implement + * {@link CdkVirtualScrollRepeater}. + */ + get dataStream(): Observable { + return this._dataStream; + } + private _dataStream = this._table._dataStream.pipe(shareReplay(1)); + + /** + * The size of the cache used to store unused views. Setting the cache size to `0` will disable + * caching. + */ + @Input() + get viewCacheSize(): number { + return this._viewRepeater.viewCacheSize; + } + set viewCacheSize(size: number) { + this._viewRepeater.viewCacheSize = size; + } + + constructor( + private readonly _table: CdkTable, + @Inject(_TABLE_VIEW_CHANGE_STRATEGY) private readonly _viewChange: BehaviorSubject, + @Inject(STICKY_POSITIONING_LISTENER) positioningListener: _PositioningListenerProxy, + @Inject(_VIEW_REPEATER_STRATEGY) + private readonly _viewRepeater: _RecycleViewRepeaterStrategy, RowContext>, + @SkipSelf() private readonly _viewport: CdkVirtualScrollViewport) { + positioningListener.setListener(this); + + // Force the table to enable `fixedLayout` to prevent column widths from changing as the user + // scrolls. This also enables caching in the table's sticky styler which reduces calls to + // expensive DOM APIs, such as `getBoundingClientRect()`, and improves overall performance. + if (!this._table.fixedLayout && (typeof ngDevMode === 'undefined' || ngDevMode)) { + throw Error('[virtualScroll] requires input `fixedLayout` to be set on the table.'); + } + + // Update sticky styles for header rows when either the render range or sticky state change. + combineLatest([this._viewport._renderedContentOffsetRendered, this._headerRowStickyUpdates]) + .pipe(takeUntil(this._destroyed)) + .subscribe(([offset, update]) => { + this._stickHeaderRows(offset, update); + }); + + // Update sticky styles for footer rows when either the render range or sticky state change. + combineLatest([this._viewport._renderedContentOffsetRendered, this._footerRowStickyUpdates]) + .pipe(takeUntil(this._destroyed)) + .subscribe(([offset, update]) => { + this._stickFooterRows(offset, update); + }); + + // Forward the rendered range computed by the virtual scroll viewport to the table. + this._viewport.renderedRangeStream.pipe(takeUntil(this._destroyed)).subscribe(this._viewChange); + this._viewport.attach(this); + } + + ngOnDestroy() { + this._destroyed.next(); + this._destroyed.complete(); + } + + /** + * Measures the combined size (width for horizontal orientation, height for vertical) of all items + * in the specified range. + */ + measureRangeSize(range: ListRange, orientation: 'horizontal' | 'vertical'): number { + // TODO(michaeljamesparsons) Implement method so virtual tables can use the `autosize` virtual + // scroll strategy. + if ((typeof ngDevMode === 'undefined' || ngDevMode)) { + throw new Error('autoSize is not supported for tables with virtual scroll enabled.'); + } + return 0; + } + + stickyColumnsUpdated(update: StickyUpdate): void { + // no-op + } + + stickyEndColumnsUpdated(update: StickyUpdate): void { + // no-op + } + + stickyHeaderRowsUpdated(update: StickyUpdate): void { + this._headerRowStickyUpdates.next(update); + } + + stickyFooterRowsUpdated(update: StickyUpdate): void { + this._footerRowStickyUpdates.next(update); + } + + /** + * The {@link StickyStyler} sticks elements by applying a `top` position offset to them. However, + * the virtual scroll viewport applies a `translateY` offset to a container div that + * encapsulates the table. The translation causes the header rows to also be offset by the + * distance from the top of the scroll viewport in addition to their `top` offset. This method + * negates the translation to move the header rows to their correct positions. + * + * @param offsetFromTop The distance scrolled from the top of the container. + * @param update Metadata about the sticky headers that changed in the last sticky update. + * @private + */ + private _stickHeaderRows(offsetFromTop: number, update: StickyUpdate) { + if (!update.sizes || !update.offsets || !update.elements) { + return; + } + + for (let i = 0; i < update.elements.length; i++) { + if (!update.elements[i]) { + continue; + } + let offset = offsetFromTop !== 0 + ? Math.max(offsetFromTop - update.offsets[i]!, update.offsets[i]!) + : -update.offsets[i]!; + + this._stickCells(update.elements[i]!, 'top', -offset); + } + } + + /** + * The {@link StickyStyler} sticks elements by applying a `bottom` position offset to them. + * However, the virtual scroll viewport applies a `translateY` offset to a container div that + * encapsulates the table. The translation causes the footer rows to also be offset by the + * distance from the top of the scroll viewport in addition to their `bottom` offset. This method + * negates the translation to move the footer rows to their correct positions. + * + * @param offsetFromTop The distance scrolled from the top of the container. + * @param update Metadata about the sticky footers that changed in the last sticky update. + * @private + */ + private _stickFooterRows(offsetFromTop: number, update: StickyUpdate) { + if (!update.sizes || !update.offsets || !update.elements) { + return; + } + + for (let i = 0; i < update.elements.length; i++) { + if (!update.elements[i]) { + continue; + } + this._stickCells(update.elements[i]!, 'bottom', offsetFromTop + update.offsets[i]!); + } + } + + private _stickCells(cells: HTMLElement[], position: 'bottom'|'top', offset: number) { + for (const cell of cells) { + cell.style[position] = `${offset}px`; + } + } +} diff --git a/src/cdk/collections/recycle-view-repeater-strategy.ts b/src/cdk/collections/recycle-view-repeater-strategy.ts index 9018bb59e8d4..95b96df9d857 100644 --- a/src/cdk/collections/recycle-view-repeater-strategy.ts +++ b/src/cdk/collections/recycle-view-repeater-strategy.ts @@ -37,8 +37,8 @@ export class _RecycleViewRepeaterStrategy { /** - * The size of the cache used to store unused views. - * Setting the cache size to `0` will disable caching. Defaults to 20 views. + * The size of the cache used to store unused views. Setting the cache size to `0` will disable + * caching. Defaults to 20 views. */ viewCacheSize: number = 20; diff --git a/src/cdk/scrolling/virtual-scroll-viewport.ts b/src/cdk/scrolling/virtual-scroll-viewport.ts index d9181fbf8569..6d586d91cb95 100644 --- a/src/cdk/scrolling/virtual-scroll-viewport.ts +++ b/src/cdk/scrolling/virtual-scroll-viewport.ts @@ -29,9 +29,9 @@ import { Observable, Subject, Observer, - Subscription, + Subscription, OperatorFunction, } from 'rxjs'; -import {auditTime, startWith, takeUntil} from 'rxjs/operators'; +import {auditTime, distinctUntilChanged, filter, startWith, takeUntil} from 'rxjs/operators'; import {ScrollDispatcher} from './scroll-dispatcher'; import {CdkScrollable, ExtendedScrollToOptions} from './scrollable'; import {VIRTUAL_SCROLL_STRATEGY, VirtualScrollStrategy} from './virtual-scroll-strategy'; @@ -78,6 +78,14 @@ export class CdkVirtualScrollViewport extends CdkScrollable implements OnInit, O /** Emits when the rendered range changes. */ private readonly _renderedRangeSubject = new Subject(); + /** + * Emits the offset from the start of the viewport to the start of the rendered data (in pixels). + */ + private readonly _renderedContentOffsetRenderedSubject = new Subject(); + readonly _renderedContentOffsetRendered = this._renderedContentOffsetRenderedSubject.pipe( + filter(offset => offset !== null) as OperatorFunction, + distinctUntilChanged()); + /** The direction the viewport scrolls. */ @Input() get orientation() { @@ -444,6 +452,11 @@ export class CdkVirtualScrollViewport extends CdkScrollable implements OnInit, O // string literals, a variable that can only be 'X' or 'Y', and user input that is run through // the `Number` function first to coerce it to a numeric value. this._contentWrapper.nativeElement.style.transform = this._renderedContentTransform; + + // Emit the offset to rendered content start when it is in sync with what is rendered in the + // DOM. + this._renderedContentOffsetRenderedSubject.next(this.getOffsetToRenderedContentStart()); + // Apply changes to Angular bindings. Note: We must call `markForCheck` to run change detection // from the root, since the repeated items are content projected in. Calling `detectChanges` // instead does not properly check the projected content. diff --git a/src/cdk/table/table-module.ts b/src/cdk/table/table-module.ts index e048b5b3e10f..06681efd3421 100644 --- a/src/cdk/table/table-module.ts +++ b/src/cdk/table/table-module.ts @@ -13,7 +13,7 @@ import { CdkTable, CdkRecycleRows, FooterRowOutlet, - NoDataRowOutlet, + NoDataRowOutlet } from './table'; import { CdkCellOutlet, diff --git a/src/cdk/table/table.ts b/src/cdk/table/table.ts index 03306ba36817..bf09fdc212af 100644 --- a/src/cdk/table/table.ts +++ b/src/cdk/table/table.ts @@ -18,7 +18,7 @@ import { _ViewRepeater, _ViewRepeaterItemChange, _ViewRepeaterItemInsertArgs, - _ViewRepeaterOperation, + _ViewRepeaterOperation, ListRange, } from '@angular/cdk/collections'; import {Platform} from '@angular/cdk/platform'; import {ViewportRuler} from '@angular/cdk/scrolling'; @@ -36,6 +36,7 @@ import { EmbeddedViewRef, EventEmitter, Inject, + InjectionToken, Input, IterableChangeRecord, IterableDiffer, @@ -55,6 +56,7 @@ import { } from '@angular/core'; import { BehaviorSubject, + combineLatest, isObservable, Observable, of as observableOf, @@ -104,6 +106,25 @@ export interface RowOutlet { /** Possible types that can be set as the data source for a `CdkTable`. */ export type CdkTableDataSourceInput = readonly T[] | DataSource | Observable; +/** A strategy that implements the behavior for the table's `viewChange` observable. */ +interface TableViewChangeStrategy { + /** + * A stream that emits whenever the table starts rendering a subset of the data. The `start` index + * is inclusive, while the `end` is exclusive. + */ + viewChange: BehaviorSubject; +} + +/** + * Injection token for the `CdkTable` view change strategy. + * + * The table will emit a `viewChange` range that spans the entire data set. This provider overrides + * its `viewChange` observable so its behavior can be overridden from another component or + * directive. + */ +export const _TABLE_VIEW_CHANGE_STRATEGY = new InjectionToken( + 'TABLE_VIEW_CHANGE_STRATEGY'); + /** * Provides a handle for the table to grab the view container's ng-container to insert data rows. * @docs-private @@ -226,6 +247,9 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes /** Latest data provided by the data source. */ protected _data: readonly T[]; + /** Latest range of data rendered. */ + protected _renderedRange?: ListRange; + /** Subject that emits when the component has been destroyed. */ private readonly _onDestroy = new Subject(); @@ -364,6 +388,8 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes /** Whether the no data row is currently showing anything. */ private _isShowingNoDataRow = false; + private readonly _stickyPositioningListener?: StickyPositioningListener; + /** * Tracking function that will be used to check the differences in data changes. Used similarly * to `ngFor` `trackBy` function. Optimize row operations by identifying a row based on its data @@ -409,9 +435,14 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes set dataSource(dataSource: CdkTableDataSourceInput) { if (this._dataSource !== dataSource) { this._switchDataSource(dataSource); + this._changeDetectorRef.markForCheck(); } } private _dataSource: CdkTableDataSourceInput; + /** Emits when the data source changes. */ + readonly _dataSourceChanges = new Subject>(); + /** Observable that emits the data source's complete data set. */ + readonly _dataStream = new Subject(); /** * Whether to allow multiple rows per data object by evaluating which rows evaluate their 'when' @@ -459,18 +490,13 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes @Output() readonly contentChanged = new EventEmitter(); - // TODO(andrewseguin): Remove max value as the end index - // and instead calculate the view on init and scroll. /** * Stream containing the latest information on what rows are being displayed on screen. * Can be used by the data source to as a heuristic of what data should be provided. * * @docs-private */ - readonly viewChange = new BehaviorSubject<{start: number; end: number}>({ - start: 0, - end: Number.MAX_VALUE, - }); + readonly viewChange: BehaviorSubject; // Outlets in the table's template where the header, data rows, and footer will be inserted. @ViewChild(DataRowOutlet, {static: true}) _rowOutlet: DataRowOutlet; @@ -503,33 +529,43 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes @ContentChild(CdkNoDataRow) _noDataRow: CdkNoDataRow; constructor( - protected readonly _differs: IterableDiffers, - protected readonly _changeDetectorRef: ChangeDetectorRef, - protected readonly _elementRef: ElementRef, - @Attribute('role') role: string, - @Optional() protected readonly _dir: Directionality, - @Inject(DOCUMENT) _document: any, - private _platform: Platform, - @Inject(_VIEW_REPEATER_STRATEGY) - protected readonly _viewRepeater: _ViewRepeater, RowContext>, - @Inject(_COALESCED_STYLE_SCHEDULER) - protected readonly _coalescedStyleScheduler: _CoalescedStyleScheduler, - private readonly _viewportRuler: ViewportRuler, - /** - * @deprecated `_stickyPositioningListener` parameter to become required. - * @breaking-change 13.0.0 - */ - @Optional() - @SkipSelf() - @Inject(STICKY_POSITIONING_LISTENER) - protected readonly _stickyPositioningListener: StickyPositioningListener, - /** - * @deprecated `_ngZone` parameter to become required. - * @breaking-change 14.0.0 - */ - @Optional() - protected readonly _ngZone?: NgZone, - ) { + protected readonly _differs: IterableDiffers, + protected readonly _changeDetectorRef: ChangeDetectorRef, + protected readonly _elementRef: ElementRef, + @Attribute('role') role: string, + @Optional() protected readonly _dir: Directionality, + @Inject(DOCUMENT) _document: any, + private _platform: Platform, + @Inject(_VIEW_REPEATER_STRATEGY) + protected readonly _viewRepeater: _ViewRepeater, RowContext>, + @Inject(_COALESCED_STYLE_SCHEDULER) + protected readonly _coalescedStyleScheduler: _CoalescedStyleScheduler, + private readonly _viewportRuler: ViewportRuler, + /** + * @deprecated `_stickyPositioningListener` parameter to become required. + * @breaking-change 13.0.0 + */ + @Optional() + @SkipSelf() + @Inject(STICKY_POSITIONING_LISTENER) + protected readonly _parentPositioningListener?: StickyPositioningListener, + /** + * @deprecated `_ngZone` parameter to become required. + * @breaking-change 14.0.0 + */ + @Optional() + protected readonly _ngZone?: NgZone, + @Optional() @Inject(STICKY_POSITIONING_LISTENER) + protected readonly _positioningListener?: StickyPositioningListener, + @Optional() @Inject(_TABLE_VIEW_CHANGE_STRATEGY) viewChange?: BehaviorSubject) { + // The table will override the StickyPositioningListener provider to `null` to prevent child + // tables from inheriting it. However, if a directive on the table configures a new provider, it + // should be used instead. Therefore, when a directive on the table configures a positioning + // listener provider, the provider will be inherited by child tables. + this._stickyPositioningListener = this._positioningListener ?? this._parentPositioningListener; + this.viewChange = viewChange ?? new BehaviorSubject( + {start: 0, end: Number.MAX_VALUE}); + if (!role) { this._elementRef.nativeElement.setAttribute('role', 'table'); } @@ -646,6 +682,7 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes renderRows() { this._renderRows = this._getAllRenderRows(); const changes = this._dataDiffer.diff(this._renderRows); + if (!changes) { this._updateNoDataRow(); this.contentChanged.next(); @@ -857,6 +894,9 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes * so that the differ equates their references. */ private _getAllRenderRows(): RenderRow[] { + const dataWithinRange = this._renderedRange + ? (this._data || []).slice(this._renderedRange.start, this._renderedRange.end) + : []; const renderRows: RenderRow[] = []; // Store the cache and create a new one. Any re-used RenderRow objects will be moved into the @@ -866,8 +906,8 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes // For each data object, get the list of rows that should be rendered, represented by the // respective `RenderRow` object which is the pair of `data` and `CdkRowDef`. - for (let i = 0; i < this._data.length; i++) { - let data = this._data[i]; + for (let i = 0; i < dataWithinRange.length; i++) { + let data = dataWithinRange[i]; const renderRowsForData = this._getRenderRowsForData(data, i, prevCachedRenderRows.get(data)); if (!this._cachedRenderRowsMap.has(data)) { @@ -1034,10 +1074,12 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes throw getTableUnknownDataSourceError(); } - this._renderChangeSubscription = dataStream! - .pipe(takeUntil(this._onDestroy)) - .subscribe(data => { + this._renderChangeSubscription = combineLatest([dataStream!, this.viewChange]).pipe( + takeUntil(this._onDestroy)) + .subscribe(([data, range]) => { this._data = data || []; + this._renderedRange = range; + this._dataStream.next(data); this.renderRows(); }); } @@ -1166,7 +1208,6 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes CdkCellOutlet.mostRecentCellOutlet._viewContainer.createEmbeddedView(cellTemplate, context); } } - this._changeDetectorRef.markForCheck(); } diff --git a/src/components-examples/BUILD.bazel b/src/components-examples/BUILD.bazel index 9b2dc84ad147..0869a030fff5 100644 --- a/src/components-examples/BUILD.bazel +++ b/src/components-examples/BUILD.bazel @@ -5,6 +5,72 @@ load(":config.bzl", "ALL_EXAMPLES") package(default_visibility = ["//visibility:public"]) +<<<<<<< a5fb8f85006eab1472a84771533327c90115aeb0 +======= +ALL_EXAMPLES = [ + # TODO(devversion): try to have for each entry-point a bazel package so that + # we can automate this using the "package.bzl" variables. Currently generated + # with "bazel query 'kind("ng_module", //src/components-examples/...:*)' --output="label" + "//src/components-examples/material/tree", + "//src/components-examples/material/tooltip", + "//src/components-examples/material/toolbar", + "//src/components-examples/material/tabs", + "//src/components-examples/material/table", + "//src/components-examples/material/stepper", + "//src/components-examples/material/sort", + "//src/components-examples/material/snack-bar", + "//src/components-examples/material/slider", + "//src/components-examples/material/slide-toggle", + "//src/components-examples/material/sidenav", + "//src/components-examples/material/select", + "//src/components-examples/material/radio", + "//src/components-examples/material/progress-spinner", + "//src/components-examples/material/progress-bar", + "//src/components-examples/material/paginator", + "//src/components-examples/material/menu", + "//src/components-examples/material/list", + "//src/components-examples/material/input", + "//src/components-examples/material/icon", + "//src/components-examples/material/grid-list", + "//src/components-examples/material/form-field", + "//src/components-examples/material/expansion", + "//src/components-examples/material/divider", + "//src/components-examples/material/dialog", + "//src/components-examples/material/datepicker", + "//src/components-examples/material/core", + "//src/components-examples/material/chips", + "//src/components-examples/material/checkbox", + "//src/components-examples/material/card", + "//src/components-examples/material/button-toggle", + "//src/components-examples/material/button", + "//src/components-examples/material/bottom-sheet", + "//src/components-examples/material/badge", + "//src/components-examples/material/autocomplete", + "//src/components-examples/material-experimental/column-resize", + "//src/components-examples/material-experimental/popover-edit", + "//src/components-examples/material-experimental/mdc-card", + "//src/components-examples/material-experimental/mdc-form-field", + "//src/components-examples/material-experimental/selection", + "//src/components-examples/cdk/tree", + "//src/components-examples/cdk/text-field", + "//src/components-examples/cdk/table", + "//src/components-examples/cdk/stepper", + "//src/components-examples/cdk/scrolling", + "//src/components-examples/cdk/portal", + "//src/components-examples/cdk/accordion", + "//src/components-examples/cdk/platform", + "//src/components-examples/cdk/drag-drop", + "//src/components-examples/cdk/clipboard", + "//src/components-examples/cdk/a11y", + "//src/components-examples/cdk/layout", + "//src/components-examples/cdk/overlay", + "//src/components-examples/cdk-experimental/menu", + "//src/components-examples/cdk-experimental/popover-edit", + "//src/components-examples/cdk-experimental/selection", + "//src/components-examples/cdk-experimental/table", +] + +>>>>>>> feat(cdk/table): Virtual scroll directive for tables ng_module( name = "components-examples", srcs = glob(["*.ts"]) + [":example-module.ts"], diff --git a/src/components-examples/cdk-experimental/table/BUILD.bazel b/src/components-examples/cdk-experimental/table/BUILD.bazel new file mode 100644 index 000000000000..d1931d58e84b --- /dev/null +++ b/src/components-examples/cdk-experimental/table/BUILD.bazel @@ -0,0 +1,26 @@ +load("//tools:defaults.bzl", "ng_module") + +package(default_visibility = ["//visibility:public"]) + +ng_module( + name = "table", + srcs = glob(["**/*.ts"]), + assets = glob([ + "**/*.html", + "**/*.css", + ]), + deps = [ + "//src/cdk-experimental/table", + "//src/cdk/scrolling", + "//src/cdk/table", + ], +) + +filegroup( + name = "source-files", + srcs = glob([ + "**/*.html", + "**/*.css", + "**/*.ts", + ]), +) diff --git a/src/components-examples/cdk-experimental/table/cdk-virtual-flex-table/cdk-virtual-flex-table-example.css b/src/components-examples/cdk-experimental/table/cdk-virtual-flex-table/cdk-virtual-flex-table-example.css new file mode 100644 index 000000000000..19c236324221 --- /dev/null +++ b/src/components-examples/cdk-experimental/table/cdk-virtual-flex-table/cdk-virtual-flex-table-example.css @@ -0,0 +1,40 @@ +.example-container { + height: 600px; + max-width: 1000px; + overflow: auto; +} + +.example-virtual-table { + width: 1200px; +} + +.example-virtual-table .cdk-header-cell, +.example-virtual-table .cdk-footer-cell { + align-items: center; + background: #3f51b5; + color: white; + display: flex; + font-weight: bold; + justify-content: center; +} + +.example-virtual-table .cdk-cell, +.example-virtual-table .cdk-footer-cell, +.example-virtual-table .cdk-header-cell { + height: 48px; +} + +/** + * Add basic flex styling so that the cells evenly space themselves in the row. + */ +.example-virtual-table cdk-row, +.example-virtual-table cdk-header-row, +.example-virtual-table cdk-footer-row { + display: flex; +} + +.example-virtual-table cdk-cell, +.example-virtual-table cdk-header-cell, +.example-virtual-table cdk-footer-cell { + flex: 1; +} diff --git a/src/components-examples/cdk-experimental/table/cdk-virtual-flex-table/cdk-virtual-flex-table-example.html b/src/components-examples/cdk-experimental/table/cdk-virtual-flex-table/cdk-virtual-flex-table-example.html new file mode 100644 index 000000000000..fa6cf06cdf62 --- /dev/null +++ b/src/components-examples/cdk-experimental/table/cdk-virtual-flex-table/cdk-virtual-flex-table-example.html @@ -0,0 +1,37 @@ + + + + + No. + {{element.position}} + No. + + + + + Name + {{element.name}} + Name + + + + + Weight + {{element.weight}} + Weight + + + + + Symbol + {{element.symbol}} + Symbol + + + + + + + + + diff --git a/src/components-examples/cdk-experimental/table/cdk-virtual-flex-table/cdk-virtual-flex-table-example.ts b/src/components-examples/cdk-experimental/table/cdk-virtual-flex-table/cdk-virtual-flex-table-example.ts new file mode 100644 index 000000000000..73ba70e84d07 --- /dev/null +++ b/src/components-examples/cdk-experimental/table/cdk-virtual-flex-table/cdk-virtual-flex-table-example.ts @@ -0,0 +1,42 @@ +import {Component} from '@angular/core'; + +export interface PeriodicElement { + name: string; + position: number; + weight: number; + symbol: string; +} + +const ELEMENT_DATA: PeriodicElement[] = [ + {position: 1, name: 'Hydrogen', weight: 1.0079, symbol: 'H'}, + {position: 2, name: 'Helium', weight: 4.0026, symbol: 'He'}, + {position: 3, name: 'Lithium', weight: 6.941, symbol: 'Li'}, + {position: 4, name: 'Beryllium', weight: 9.0122, symbol: 'Be'}, + {position: 5, name: 'Boron', weight: 10.811, symbol: 'B'}, + {position: 6, name: 'Carbon', weight: 12.0107, symbol: 'C'}, + {position: 7, name: 'Nitrogen', weight: 14.0067, symbol: 'N'}, + {position: 8, name: 'Oxygen', weight: 15.9994, symbol: 'O'}, + {position: 9, name: 'Fluorine', weight: 18.9984, symbol: 'F'}, + {position: 10, name: 'Neon', weight: 20.1797, symbol: 'Ne'}, +]; + +const EXPANDED_ELEMENT_DATA: PeriodicElement[] = []; +for (let x = 0; x < 100; x++) { + for (const entry of ELEMENT_DATA) { + EXPANDED_ELEMENT_DATA.push({...entry, position: entry.position + (10 * x)}); + } +} + +/** + * @title Example of a flex table with virtual scroll enabled. + */ +@Component({ + selector: 'cdk-virtual-flex-table-example', + styleUrls: ['cdk-virtual-flex-table-example.css'], + templateUrl: 'cdk-virtual-flex-table-example.html', +}) +export class CdkVirtualFlexTableExample { + displayedColumns: string[] = ['position', 'name', 'weight', 'symbol']; + dataSource = EXPANDED_ELEMENT_DATA; + trackBy = (index: number, el: PeriodicElement) => el.position; +} diff --git a/src/components-examples/cdk-experimental/table/cdk-virtual-table/cdk-virtual-table-example.css b/src/components-examples/cdk-experimental/table/cdk-virtual-table/cdk-virtual-table-example.css new file mode 100644 index 000000000000..3f0597b64cd2 --- /dev/null +++ b/src/components-examples/cdk-experimental/table/cdk-virtual-table/cdk-virtual-table-example.css @@ -0,0 +1,30 @@ +.example-container { + height: 600px; + max-width: 1000px; + overflow: auto; +} + +.example-virtual-table { + width: 1200px; +} + +.example-virtual-table td, +.example-virtual-table th { + height: 48px; + padding: 0; +} + +.example-virtual-table th.cdk-header-cell, +.example-virtual-table .cdk-footer-row th { + background: #3f51b5; + color: white; +} + +.example-virtual-table th.mat-column-position, +.example-virtual-table td.mat-column-position { + padding-left: 8px; +} + +.example-virtual-table .cdk-cell.cdk-table-sticky { + background: #f0f0f0; +} diff --git a/src/components-examples/cdk-experimental/table/cdk-virtual-table/cdk-virtual-table-example.html b/src/components-examples/cdk-experimental/table/cdk-virtual-table/cdk-virtual-table-example.html new file mode 100644 index 000000000000..42cbf73ff6f6 --- /dev/null +++ b/src/components-examples/cdk-experimental/table/cdk-virtual-table/cdk-virtual-table-example.html @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
No. {{element.position}} No. Name {{element.name}} Name Weight {{element.weight}} Weight Symbol {{element.symbol}} Symbol
+
diff --git a/src/components-examples/cdk-experimental/table/cdk-virtual-table/cdk-virtual-table-example.ts b/src/components-examples/cdk-experimental/table/cdk-virtual-table/cdk-virtual-table-example.ts new file mode 100644 index 000000000000..6ec91208fde2 --- /dev/null +++ b/src/components-examples/cdk-experimental/table/cdk-virtual-table/cdk-virtual-table-example.ts @@ -0,0 +1,43 @@ +import {ChangeDetectionStrategy, Component} from '@angular/core'; + +export interface PeriodicElement { + name: string; + position: number; + weight: number; + symbol: string; +} + +const ELEMENT_DATA: PeriodicElement[] = [ + {position: 1, name: 'Hydrogen', weight: 1.0079, symbol: 'H'}, + {position: 2, name: 'Helium', weight: 4.0026, symbol: 'He'}, + {position: 3, name: 'Lithium', weight: 6.941, symbol: 'Li'}, + {position: 4, name: 'Beryllium', weight: 9.0122, symbol: 'Be'}, + {position: 5, name: 'Boron', weight: 10.811, symbol: 'B'}, + {position: 6, name: 'Carbon', weight: 12.0107, symbol: 'C'}, + {position: 7, name: 'Nitrogen', weight: 14.0067, symbol: 'N'}, + {position: 8, name: 'Oxygen', weight: 15.9994, symbol: 'O'}, + {position: 9, name: 'Fluorine', weight: 18.9984, symbol: 'F'}, + {position: 10, name: 'Neon', weight: 20.1797, symbol: 'Ne'}, +]; + +const EXPANDED_ELEMENT_DATA: PeriodicElement[] = []; +for (let x = 0; x < 100; x++) { + for (const entry of ELEMENT_DATA) { + EXPANDED_ELEMENT_DATA.push({...entry, position: entry.position + (10 * x)}); + } +} + +/** + * @title Example of a native table with virtual scroll enabled. + */ +@Component({ + selector: 'cdk-virtual-table-example', + styleUrls: ['cdk-virtual-table-example.css'], + templateUrl: 'cdk-virtual-table-example.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CdkVirtualTableExample { + displayedColumns: string[] = ['position', 'name', 'weight', 'symbol']; + dataSource = EXPANDED_ELEMENT_DATA; + trackBy = (index: number, el: PeriodicElement) => el.position; +} diff --git a/src/components-examples/cdk-experimental/table/index.ts b/src/components-examples/cdk-experimental/table/index.ts new file mode 100644 index 000000000000..28952a77af5e --- /dev/null +++ b/src/components-examples/cdk-experimental/table/index.ts @@ -0,0 +1,28 @@ +import {NgModule} from '@angular/core'; +import {CdkTableModule} from '@angular/cdk/table'; +import {CdkTableModule as CdkExperimentalTableModule} from '@angular/cdk-experimental/table'; +import {ScrollingModule} from '@angular/cdk/scrolling'; + +import {CdkVirtualTableExample} from './cdk-virtual-table/cdk-virtual-table-example'; +import {CdkVirtualFlexTableExample} from './cdk-virtual-flex-table/cdk-virtual-flex-table-example'; + +export { + CdkVirtualTableExample, + CdkVirtualFlexTableExample, +}; + +const EXAMPLES = [ + CdkVirtualTableExample, + CdkVirtualFlexTableExample, +]; + +@NgModule({ + imports: [ + CdkTableModule, + CdkExperimentalTableModule, + ScrollingModule, + ], + declarations: EXAMPLES, + exports: EXAMPLES, +}) +export class CdkTableExamplesModule {} diff --git a/src/dev-app/table/BUILD.bazel b/src/dev-app/table/BUILD.bazel index 3b26d2c4ffa8..077063950962 100644 --- a/src/dev-app/table/BUILD.bazel +++ b/src/dev-app/table/BUILD.bazel @@ -7,6 +7,7 @@ ng_module( srcs = glob(["**/*.ts"]), assets = ["table-demo.html"], deps = [ + "//src/components-examples/cdk-experimental/table", "//src/components-examples/cdk/table", "//src/components-examples/material/table", "//src/dev-app/example", diff --git a/src/dev-app/table/table-demo-module.ts b/src/dev-app/table/table-demo-module.ts index 603c3a75bdb9..e3fa5c13fc72 100644 --- a/src/dev-app/table/table-demo-module.ts +++ b/src/dev-app/table/table-demo-module.ts @@ -8,6 +8,9 @@ import {NgModule} from '@angular/core'; import {CdkTableExamplesModule} from '@angular/components-examples/cdk/table'; +import { + CdkTableExamplesModule as CdkExperimentalTableExamplesModule +} from '@angular/components-examples/cdk-experimental/table'; import {TableExamplesModule} from '@angular/components-examples/material/table'; import {RouterModule} from '@angular/router'; import {TableDemo} from './table-demo'; @@ -15,6 +18,7 @@ import {TableDemo} from './table-demo'; @NgModule({ imports: [ CdkTableExamplesModule, + CdkExperimentalTableExamplesModule, TableExamplesModule, RouterModule.forChild([{path: '', component: TableDemo}]), ], diff --git a/src/dev-app/table/table-demo.html b/src/dev-app/table/table-demo.html index 4b85241a1952..8ef63cfcc13b 100644 --- a/src/dev-app/table/table-demo.html +++ b/src/dev-app/table/table-demo.html @@ -78,3 +78,9 @@

Table wrapped in reusable component

Table wrapped re-orderable columns

+ +

Cdk virtual table

+ + +

Cdk virtual flex table

+ diff --git a/src/material-experimental/mdc-table/table.ts b/src/material-experimental/mdc-table/table.ts index 5930ade745b4..a4bff7351f82 100644 --- a/src/material-experimental/mdc-table/table.ts +++ b/src/material-experimental/mdc-table/table.ts @@ -37,6 +37,7 @@ import { }) export class MatRecycleRows {} + @Component({ selector: 'mat-table, table[mat-table]', exportAs: 'matTable', diff --git a/tools/public_api_guard/cdk/scrolling.md b/tools/public_api_guard/cdk/scrolling.md index 3b11cd941653..d1696b7b9245 100644 --- a/tools/public_api_guard/cdk/scrolling.md +++ b/tools/public_api_guard/cdk/scrolling.md @@ -168,6 +168,8 @@ export class CdkVirtualScrollViewport extends CdkScrollable implements OnInit, O ngOnInit(): void; get orientation(): 'horizontal' | 'vertical'; set orientation(orientation: 'horizontal' | 'vertical'); + // (undocumented) + readonly _renderedContentOffsetRendered: Observable; readonly renderedRangeStream: Observable; readonly scrolledIndexChange: Observable; scrollToIndex(index: number, behavior?: ScrollBehavior): void; diff --git a/tools/public_api_guard/cdk/table.md b/tools/public_api_guard/cdk/table.md index ddf4167a1538..d58f91f8e478 100644 --- a/tools/public_api_guard/cdk/table.md +++ b/tools/public_api_guard/cdk/table.md @@ -20,6 +20,7 @@ import { InjectionToken } from '@angular/core'; import { IterableChanges } from '@angular/core'; import { IterableDiffer } from '@angular/core'; import { IterableDiffers } from '@angular/core'; +import { ListRange } from '@angular/cdk/collections'; import { NgZone } from '@angular/core'; import { Observable } from 'rxjs'; import { OnChanges } from '@angular/core'; @@ -28,6 +29,7 @@ import { OnInit } from '@angular/core'; import { Platform } from '@angular/cdk/platform'; import { QueryList } from '@angular/core'; import { SimpleChanges } from '@angular/core'; +import { Subject } from 'rxjs'; import { TemplateRef } from '@angular/core'; import { TrackByFunction } from '@angular/core'; import { ViewContainerRef } from '@angular/core'; @@ -288,9 +290,14 @@ export class CdkRowDef extends BaseRowDef { // @public export class CdkTable implements AfterContentChecked, CollectionViewer, OnDestroy, OnInit { +<<<<<<< fb4e395bb37b1a10f3fc8af49ae63200b0619c7f constructor(_differs: IterableDiffers, _changeDetectorRef: ChangeDetectorRef, _elementRef: ElementRef, role: string, _dir: Directionality, _document: any, _platform: Platform, _viewRepeater: _ViewRepeater, RowContext>, _coalescedStyleScheduler: _CoalescedStyleScheduler, _viewportRuler: ViewportRuler, _stickyPositioningListener: StickyPositioningListener, _ngZone?: NgZone | undefined); +======= + constructor(_differs: IterableDiffers, _changeDetectorRef: ChangeDetectorRef, _elementRef: ElementRef, role: string, _dir: Directionality, _document: any, _platform: Platform, _viewRepeater: _ViewRepeater, RowContext>, _coalescedStyleScheduler: _CoalescedStyleScheduler, + _parentPositioningListener?: StickyPositioningListener | undefined, _viewportRuler?: ViewportRuler | undefined, _positioningListener?: StickyPositioningListener | undefined, viewChange?: BehaviorSubject); +>>>>>>> feat(cdk/table): Virtual scroll directive for tables addColumnDef(columnDef: CdkColumnDef): void; addFooterRowDef(footerRowDef: CdkFooterRowDef): void; addHeaderRowDef(headerRowDef: CdkHeaderRowDef): void; @@ -307,6 +314,8 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes protected _data: readonly T[]; get dataSource(): CdkTableDataSourceInput; set dataSource(dataSource: CdkTableDataSourceInput); + readonly _dataSourceChanges: Subject>; + readonly _dataStream: Subject; // (undocumented) protected readonly _differs: IterableDiffers; // (undocumented) @@ -338,32 +347,36 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes _noDataRow: CdkNoDataRow; // (undocumented) _noDataRowOutlet: NoDataRowOutlet; + // @deprecated (undocumented) + protected readonly _parentPositioningListener?: StickyPositioningListener | undefined; + // (undocumented) + protected readonly _positioningListener?: StickyPositioningListener | undefined; removeColumnDef(columnDef: CdkColumnDef): void; removeFooterRowDef(footerRowDef: CdkFooterRowDef): void; removeHeaderRowDef(headerRowDef: CdkHeaderRowDef): void; removeRowDef(rowDef: CdkRowDef): void; + protected _renderedRange?: ListRange; renderRows(): void; // (undocumented) _rowOutlet: DataRowOutlet; setNoDataRow(noDataRow: CdkNoDataRow | null): void; protected stickyCssClass: string; - // @deprecated (undocumented) - protected readonly _stickyPositioningListener: StickyPositioningListener; get trackBy(): TrackByFunction; set trackBy(fn: TrackByFunction); updateStickyColumnStyles(): void; updateStickyFooterRowStyles(): void; updateStickyHeaderRowStyles(): void; - readonly viewChange: BehaviorSubject<{ - start: number; - end: number; - }>; + readonly viewChange: BehaviorSubject; // (undocumented) protected readonly _viewRepeater: _ViewRepeater, RowContext>; // (undocumented) static ɵcmp: i0.ɵɵComponentDeclaration, "cdk-table, table[cdk-table]", ["cdkTable"], { "trackBy": "trackBy"; "dataSource": "dataSource"; "multiTemplateDataRows": "multiTemplateDataRows"; "fixedLayout": "fixedLayout"; }, { "contentChanged": "contentChanged"; }, ["_noDataRow", "_contentColumnDefs", "_contentRowDefs", "_contentHeaderRowDefs", "_contentFooterRowDefs"], ["caption", "colgroup, col"], false>; // (undocumented) +<<<<<<< fb4e395bb37b1a10f3fc8af49ae63200b0619c7f static ɵfac: i0.ɵɵFactoryDeclaration, [null, null, null, { attribute: "role"; }, { optional: true; }, null, null, null, null, null, { optional: true; skipSelf: true; }, { optional: true; }]>; +======= + static ɵfac: i0.ɵɵFactoryDeclaration, [null, null, null, { attribute: "role"; }, { optional: true; }, null, null, null, null, { optional: true; skipSelf: true; }, { optional: true; }, { optional: true; }, { optional: true; }]>; +>>>>>>> feat(cdk/table): Virtual scroll directive for tables } // @public @@ -562,6 +575,9 @@ export interface StickyUpdate { sizes: StickySize[]; } +// @public +export const _TABLE_VIEW_CHANGE_STRATEGY: InjectionToken; + // @public export const TEXT_COLUMN_OPTIONS: InjectionToken>;