Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions goldens/cdk/scrolling/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ export class CdkVirtualScrollViewport extends CdkVirtualScrollable implements On
ngOnInit(): void;
get orientation(): "horizontal" | "vertical";
set orientation(orientation: 'horizontal' | 'vertical');
readonly renderedContentOffset: Observable<number>;
readonly renderedRangeStream: Observable<ListRange>;
// (undocumented)
scrollable: CdkVirtualScrollable;
Expand Down
16 changes: 9 additions & 7 deletions goldens/cdk/table/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ export class CdkRowDef<T> extends BaseRowDef {
}

// @public
export class CdkTable<T> implements AfterContentInit, AfterContentChecked, CollectionViewer, OnDestroy, OnInit {
export class CdkTable<T> implements AfterContentInit, AfterContentChecked, CollectionViewer, OnDestroy, OnInit, StickyPositioningListener {
constructor(...args: unknown[]);
addColumnDef(columnDef: CdkColumnDef): void;
addFooterRowDef(footerRowDef: CdkFooterRowDef): void;
Expand All @@ -307,6 +307,8 @@ export class CdkTable<T> implements AfterContentInit, AfterContentChecked, Colle
protected _data: readonly T[] | undefined;
get dataSource(): CdkTableDataSourceInput<T>;
set dataSource(dataSource: CdkTableDataSourceInput<T>);
readonly _dataSourceChanges: Subject<CdkTableDataSourceInput<T>>;
readonly _dataStream: Subject<readonly T[]>;
// (undocumented)
protected readonly _differs: IterableDiffers;
// (undocumented)
Expand Down Expand Up @@ -352,22 +354,22 @@ export class CdkTable<T> implements AfterContentInit, AfterContentChecked, Colle
removeFooterRowDef(footerRowDef: CdkFooterRowDef): void;
removeHeaderRowDef(headerRowDef: CdkHeaderRowDef): void;
removeRowDef(rowDef: CdkRowDef<T>): void;
protected _renderedRange?: ListRange;
renderRows(): void;
// (undocumented)
_rowOutlet: DataRowOutlet;
setNoDataRow(noDataRow: CdkNoDataRow | null): void;
stickyColumnsUpdated(update: StickyUpdate): void;
protected stickyCssClass: string;
// (undocumented)
protected readonly _stickyPositioningListener: StickyPositioningListener;
stickyEndColumnsUpdated(update: StickyUpdate): void;
stickyFooterRowsUpdated(update: StickyUpdate): void;
stickyHeaderRowsUpdated(update: StickyUpdate): void;
get trackBy(): TrackByFunction<T>;
set trackBy(fn: TrackByFunction<T>);
updateStickyColumnStyles(): void;
updateStickyFooterRowStyles(): void;
updateStickyHeaderRowStyles(): void;
readonly viewChange: BehaviorSubject<{
start: number;
end: number;
}>;
readonly viewChange: BehaviorSubject<ListRange>;
// (undocumented)
protected _viewRepeater: _ViewRepeater<T, RenderRow<T>, RowContext<T>>;
// (undocumented)
Expand Down
12 changes: 11 additions & 1 deletion src/cdk/scrolling/virtual-scroll-viewport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import {
Subject,
Subscription,
} from 'rxjs';
import {auditTime, startWith, takeUntil} from 'rxjs/operators';
import {auditTime, distinctUntilChanged, filter, startWith, takeUntil} from 'rxjs/operators';
import {CdkScrollable, ExtendedScrollToOptions} from './scrollable';
import {ViewportRuler} from './viewport-ruler';
import {CdkVirtualScrollRepeater} from './virtual-scroll-repeater';
Expand Down Expand Up @@ -102,6 +102,7 @@ export class CdkVirtualScrollViewport extends CdkVirtualScrollable implements On

/** Emits when the rendered range changes. */
private readonly _renderedRangeSubject = new Subject<ListRange>();
private readonly _renderedContentOffsetSubject = new Subject<number | null>();

/** The direction the viewport scrolls. */
@Input()
Expand Down Expand Up @@ -141,6 +142,14 @@ export class CdkVirtualScrollViewport extends CdkVirtualScrollable implements On
/** A stream that emits whenever the rendered range changes. */
readonly renderedRangeStream: Observable<ListRange> = this._renderedRangeSubject;

/**
* Emits the offset from the start of the viewport to the start of the rendered data (in pixels).
*/
readonly renderedContentOffset: Observable<number> = this._renderedContentOffsetSubject.pipe(
filter(offset => offset !== null),
distinctUntilChanged(),
);

/**
* The total size of all content (in pixels), including content that is not currently rendered.
*/
Expand Down Expand Up @@ -537,6 +546,7 @@ export class CdkVirtualScrollViewport extends CdkVirtualScrollable implements On
// 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;
this._renderedContentOffsetSubject.next(this.getOffsetToRenderedContentStart());

afterNextRender(
() => {
Expand Down
2 changes: 2 additions & 0 deletions src/cdk/table/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ ng_project(
"//:node_modules/rxjs",
"//src/cdk/bidi",
"//src/cdk/collections",
"//src/cdk/scrolling",
"//src/cdk/testing/private",
],
)

Expand Down
4 changes: 3 additions & 1 deletion src/cdk/table/sticky-position-listener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
import {InjectionToken} from '@angular/core';

/** The injection token used to specify the StickyPositioningListener. */
export const STICKY_POSITIONING_LISTENER = new InjectionToken<StickyPositioningListener>('CDK_SPL');
export const STICKY_POSITIONING_LISTENER = new InjectionToken<StickyPositioningListener>(
'STICKY_POSITIONING_LISTENER',
);

export type StickySize = number | null | undefined;
export type StickyOffset = number | null | undefined;
Expand Down
2 changes: 1 addition & 1 deletion src/cdk/table/sticky-styler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export class StickyStyler {
private _isBrowser = true,
private readonly _needsPositionStickyOnElement = true,
public direction: Direction,
private readonly _positionListener: StickyPositioningListener,
private readonly _positionListener: StickyPositioningListener | null,
private readonly _tableInjector: Injector,
) {
this._borderCellCss = {
Expand Down
22 changes: 15 additions & 7 deletions src/cdk/table/table.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ top of the CDK data-table.
The first step to writing the data-table template is to define the columns.
A column definition is specified via an `<ng-container>` with the `cdkColumnDef` directive, giving
the column a name. Each column definition can contain a header-cell template
(`cdkHeaderCellDef`), data-cell template (`cdkCellDef`), and footer-cell
(`cdkHeaderCellDef`), data-cell template (`cdkCellDef`), and footer-cell
template (`cdkFooterCellDef`).

```html
Expand Down Expand Up @@ -120,9 +120,9 @@ cells that are displayed in the column `name` will be given the class `cdk-colum
columns to be given styles that will match across the header and rows.

Since columns can be given any string for its name, its possible that it cannot be directly applied
to the CSS class (e.g. `*nameColumn!`). In these cases, the special characters will be replaced by
to the CSS class (e.g. `*nameColumn!`). In these cases, the special characters will be replaced by
the `-` character. For example, cells container in a column named `*nameColumn!` will be given
the class `cdk-column--nameColumn-`.
the class `cdk-column--nameColumn-`.

#### Connecting the table to a data source

Expand Down Expand Up @@ -158,23 +158,31 @@ table how to uniquely identify rows to track how the data changes with each upda
```

##### `recycleRows`
By default, `CdkTable` creates and destroys an internal Angular view for each row. This allows rows
to participate in animations and to toggle between different row templates with `cdkRowDefWhen`. If
you don't need these features, you can instruct the table to cache and recycle rows by specifying
By default, `CdkTable` creates and destroys an internal Angular view for each row. This allows rows
to participate in animations and to toggle between different row templates with `cdkRowDefWhen`. If
you don't need these features, you can instruct the table to cache and recycle rows by specifying
`recycleRows`.

```html
<table cdk-table [dataSource]="dataSource" recycleRows>
```

### Virtual scrolling

If you're showing a large amount of data in your table, you can use virtual scrolling to ensure a
smooth experience for the user. To enable virtual scrolling, you have to wrap the CDK table in a
`cdk-virtual-scroll-viewport` element and add some CSS to make it scrollable.

<!-- example(cdk-table-virtual-scroll) -->

### Alternate HTML to using native table

The CDK table does not require that you use a native HTML table. If you want to have full control
over the style of the table, it may be easier to follow an alternative template approach that does
not use the native table element tags.

This alternative approach replaces the native table element tags with the CDK table directive
selectors. For example, `<table cdk-table>` becomes `<cdk-table>`; `<tr cdk-row`> becomes
selectors. For example, `<table cdk-table>` becomes `<cdk-table>`; `<tr cdk-row`> becomes
`<cdk-row>`. The following shows a previous example using this alternative template:

```html
Expand Down
180 changes: 171 additions & 9 deletions src/cdk/table/table.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,17 @@ import {
Type,
ViewChild,
inject,
signal,
} from '@angular/core';
import {By} from '@angular/platform-browser';
import {ComponentFixture, TestBed, fakeAsync, flush, waitForAsync} from '@angular/core/testing';
import {
ComponentFixture,
TestBed,
fakeAsync,
flush,
tick,
waitForAsync,
} from '@angular/core/testing';
import {BehaviorSubject, Observable, combineLatest, of as observableOf} from 'rxjs';
import {map} from 'rxjs/operators';
import {CdkColumnDef} from './cell';
Expand All @@ -36,6 +44,8 @@ import {
getTableUnknownDataSourceError,
} from './table-errors';
import {NgClass} from '@angular/common';
import {CdkVirtualScrollViewport, ScrollingModule} from '../scrolling';
import {dispatchFakeEvent} from '../testing/private';

describe('CdkTable', () => {
let fixture: ComponentFixture<any>;
Expand Down Expand Up @@ -1995,6 +2005,107 @@ describe('CdkTable', () => {
expect(noDataRow).toBeTruthy();
expect(noDataRow.getAttribute('colspan')).toEqual('3');
});

describe('virtual scrolling', () => {
let fixture: ComponentFixture<TableWithVirtualScroll>;
let table: HTMLTableElement;

beforeEach(fakeAsync(() => {
fixture = TestBed.createComponent(TableWithVirtualScroll);

// Init logic copied from the virtual scroll tests.
fixture.detectChanges();
flush();
fixture.detectChanges();
flush();
tick(16);
flush();
fixture.detectChanges();
table = fixture.nativeElement.querySelector('table');
}));

function triggerScroll(offset: number) {
const viewport = fixture.componentInstance.viewport;
viewport.scrollToOffset(offset);
dispatchFakeEvent(viewport.scrollable!.getElementRef().nativeElement, 'scroll');
tick(16);
}

it('should not render the full data set when using virtual scrolling', fakeAsync(() => {
expect(fixture.componentInstance.dataSource.data.length).toBeGreaterThan(2000);
expect(getRows(table).length).toBe(10);
}));

it('should maintain a limited amount of data as the user is scrolling', fakeAsync(() => {
expect(getRows(table).length).toBe(10);

triggerScroll(500);
expect(getRows(table).length).toBe(13);

triggerScroll(500);
expect(getRows(table).length).toBe(13);

triggerScroll(1000);
expect(getRows(table).length).toBe(12);
}));

it('should update the table data as the user is scrolling', fakeAsync(() => {
expectTableToMatchContent(table, [
['Column A', 'Column B', 'Column C'],
['a_1', 'b_1', 'c_1'],
['a_2', 'b_2', 'c_2'],
['a_3', 'b_3', 'c_3'],
['a_4', 'b_4', 'c_4'],
['a_5', 'b_5', 'c_5'],
['a_6', 'b_6', 'c_6'],
['a_7', 'b_7', 'c_7'],
['a_8', 'b_8', 'c_8'],
['a_9', 'b_9', 'c_9'],
['a_10', 'b_10', 'c_10'],
['Footer A', 'Footer B', 'Footer C'],
]);

triggerScroll(1000);

expectTableToMatchContent(table, [
['Column A', 'Column B', 'Column C'],
['a_18', 'b_18', 'c_18'],
['a_19', 'b_19', 'c_19'],
['a_20', 'b_20', 'c_20'],
['a_21', 'b_21', 'c_21'],
['a_22', 'b_22', 'c_22'],
['a_23', 'b_23', 'c_23'],
['a_24', 'b_24', 'c_24'],
['a_25', 'b_25', 'c_25'],
['a_26', 'b_26', 'c_26'],
['a_27', 'b_27', 'c_27'],
['a_28', 'b_28', 'c_28'],
['a_29', 'b_29', 'c_29'],
['Footer A', 'Footer B', 'Footer C'],
]);
}));

it('should update the position of sticky cells as the user is scrolling', fakeAsync(() => {
const assertStickyOffsets = (position: number) => {
getHeaderCells(table).forEach(cell => expect(cell.style.top).toBe(`${position * -1}px`));
getFooterCells(table).forEach(cell => expect(cell.style.bottom).toBe(`${position}px`));
};

assertStickyOffsets(0);
triggerScroll(1000);
assertStickyOffsets(884);
}));

it('should force tables with virtual scrolling to have a fixed layout', fakeAsync(() => {
expect(fixture.componentInstance.isFixedLayout()).toBe(true);
expect(table.classList).toContain('cdk-table-fixed-layout');

fixture.componentInstance.isFixedLayout.set(false);
fixture.detectChanges();

expect(table.classList).toContain('cdk-table-fixed-layout');
}));
});
});

interface TestData {
Expand Down Expand Up @@ -2032,15 +2143,18 @@ class FakeDataSource extends DataSource<TestData> {
this.isConnected = false;
}

addData() {
const nextIndex = this.data.length + 1;

addData(amount = 1) {
let copiedData = this.data.slice();
copiedData.push({
a: `a_${nextIndex}`,
b: `b_${nextIndex}`,
c: `c_${nextIndex}`,
});

for (let i = 0; i < amount; i++) {
const nextIndex = copiedData.length + 1;

copiedData.push({
a: `a_${nextIndex}`,
b: `b_${nextIndex}`,
c: `c_${nextIndex}`,
});
}

this.data = copiedData;
}
Expand Down Expand Up @@ -3176,6 +3290,54 @@ class WrapNativeHtmlTableAppOnPush {
dataSource = new FakeDataSource();
}

@Component({
template: `
<cdk-virtual-scroll-viewport class="scroll-container" [itemSize]="52">
<table cdk-table [dataSource]="dataSource" [fixedLayout]="isFixedLayout()">
<ng-container cdkColumnDef="column_a">
<th cdk-header-cell *cdkHeaderCellDef>Column A</th>
<td cdk-cell *cdkCellDef="let row"> {{row.a}}</td>
<td cdk-footer-cell *cdkFooterCellDef>Footer A</td>
</ng-container>
<ng-container cdkColumnDef="column_b">
<th cdk-header-cell *cdkHeaderCellDef>Column B</th>
<td cdk-cell *cdkCellDef="let row"> {{row.b}}</td>
<td cdk-footer-cell *cdkFooterCellDef>Footer B</td>
</ng-container>
<ng-container cdkColumnDef="column_c">
<th cdk-header-cell *cdkHeaderCellDef>Column C</th>
<td cdk-cell *cdkCellDef="let row"> {{row.c}}</td>
<td cdk-footer-cell *cdkFooterCellDef>Footer C</td>
</ng-container>
<tr cdk-header-row *cdkHeaderRowDef="columnsToRender; sticky: true"></tr>
<tr cdk-row *cdkRowDef="let row; columns: columnsToRender"></tr>
<tr cdk-footer-row *cdkFooterRowDef="columnsToRender; sticky: true"></tr>
</table>
</cdk-virtual-scroll-viewport>
`,
imports: [CdkTableModule, ScrollingModule],
styles: `
.scroll-container {
height: 300px;
overflow: auto;
}
`,
})
class TableWithVirtualScroll {
@ViewChild(CdkTable) table: CdkTable<TestData>;
@ViewChild(CdkVirtualScrollViewport) viewport: CdkVirtualScrollViewport;
dataSource = new FakeDataSource();
columnsToRender = ['column_a', 'column_b', 'column_c'];
isFixedLayout = signal(true);

constructor() {
this.dataSource.addData(2000);
}
}

function getElements(element: Element, query: string): HTMLElement[] {
return [].slice.call(element.querySelectorAll(query));
}
Expand Down
Loading
Loading