Skip to content

Commit

Permalink
Table virtualization (#966)
Browse files Browse the repository at this point in the history
# Pull Request

## 🀨 Rationale

Related Issue:
- #865 

## πŸ‘©β€πŸ’» Implementation

Uses TanStack Virtualizer as mentioned in the spec, and as done in [the
prototype
branch](https://github.com/ni/nimble/tree/table-custom-columns/packages/nimble-components/src/table).
Major differences from the prototype:
- Prototype and TanStack Virtual examples set the virtualizer's "total
size" (height of all rows) on the row container, and added translate
transforms on each individual row. We noticed some flashing during
scrolling with that approach in Firefox. Instead, this version sets the
height of all rows on a sibling div, and applies a single translate
transform on the row container. This is more similar to what
Perspective/regular-table do, and it got rid of the flashing in Firefox.
- Prototype used a `ResizeObserver` in the Nimble table code. TanStack
Virtualizer is already using a ResizeObserver internally (due to us
using its provided `observeElementOffset` and `observeElementRect`
functions as inputs in our `VirtualizerOptions` object), so I concluded
we didn't need a separate resize observer.

## πŸ§ͺ Testing

- Added a couple of autotests dealing with virtualization (that the # of
rows rendered is less than the total; that we can scroll the element to
view the end rows; that the # of rendered rows increases when the
element height increases)
- Updated [table Storybook to add a 2nd option for table
data](https://60e89457a987cf003efc0a5b-wjfhtkxgzq.chromatic.com/?path=/story/table--table&args=data:LargeDataSet),
a data set of 10,000 rows

## βœ… Checklist

- [x] I have updated the project documentation to reflect my changes or
determined no changes are needed.
  • Loading branch information
msmithNI authored Jan 27, 2023
1 parent 9c8b6fa commit c334239
Show file tree
Hide file tree
Showing 14 changed files with 314 additions and 30 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ import { HeaderComponent } from './header/header.component';
RouterModule.forRoot(
[
{ path: '', redirectTo: '/customapp', pathMatch: 'full' },
{ path: 'customapp', component: CustomAppComponent }
{ path: 'customapp', component: CustomAppComponent, title: 'Nimble components demo' }
],
{ useHash: true }
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ nimble-drawer {
}
}

nimble-table {
height: auto;
max-height: 480px;
}

.add-table-row-button {
margin-top: $ni-nimble-standard-padding;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "Add virtualization to table",
"packageName": "@ni/nimble-components",
"email": "20709258+msmithNI@users.noreply.github.com",
"dependentChangeType": "patch"
}
16 changes: 16 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/nimble-components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"@microsoft/fast-web-utilities": "^5.4.1",
"@ni/nimble-tokens": "^4.3.2",
"@tanstack/table-core": "^8.7.0",
"@tanstack/virtual-core": "^3.0.0-beta.34",
"@types/d3": "^7.4.0",
"@types/d3-scale": "^4.0.2",
"@types/d3-zoom": "^3.0.0",
Expand Down
22 changes: 22 additions & 0 deletions packages/nimble-components/src/table/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { TableValidator } from './models/table-validator';
import { styles } from './styles';
import { template } from './template';
import type { TableRecord, TableRowState, TableValidity } from './types';
import { Virtualizer } from './models/virtualizer';

declare global {
interface HTMLElementTagNameMap {
Expand Down Expand Up @@ -49,6 +50,16 @@ export class Table<
return this.tableValidator.getValidity();
}

/**
* @internal
*/
public readonly viewport!: HTMLElement;

/**
* @internal
*/
public readonly virtualizer: Virtualizer<TData>;

private readonly table: TanStackTable<TData>;
private options: TanStackTableOptionsResolved<TData>;
private readonly tableValidator = new TableValidator();
Expand All @@ -65,6 +76,7 @@ export class Table<
autoResetAll: false
};
this.table = tanStackCreateTable(this.options);
this.virtualizer = new Virtualizer(this);
}

public setData(newData: readonly TData[]): void {
Expand All @@ -81,6 +93,15 @@ export class Table<
this.setTableData(this.table.options.data);
}

public override connectedCallback(): void {
super.connectedCallback();
this.virtualizer.connectedCallback();
}

public override disconnectedCallback(): void {
this.virtualizer.disconnectedCallback();
}

public checkValidity(): boolean {
return this.tableValidator.isValid();
}
Expand Down Expand Up @@ -110,6 +131,7 @@ export class Table<
};
return rowState;
});
this.virtualizer.dataChanged();
}

private updateTableOptions(
Expand Down
114 changes: 114 additions & 0 deletions packages/nimble-components/src/table/models/virtualizer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { observable } from '@microsoft/fast-element';
import {
Virtualizer as TanStackVirtualizer,
VirtualizerOptions,
elementScroll,
observeElementOffset,
observeElementRect,
VirtualItem
} from '@tanstack/virtual-core';
import { controlHeight } from '../../theme-provider/design-tokens';
import type { Table } from '..';
import type { TableRecord } from '../types';

/**
* Helper class for the nimble-table for row virtualization.
*
* @internal
*/
export class Virtualizer<TData extends TableRecord = TableRecord> {
@observable
public visibleItems: VirtualItem[] = [];

@observable
public allRowsHeight = 0;

@observable
public headerContainerMarginRight = 0;

@observable
public rowContainerYOffset = 0;

private readonly table: Table<TData>;
private readonly viewportResizeObserver: ResizeObserver;
private virtualizer?: TanStackVirtualizer<HTMLElement, HTMLElement>;

public constructor(table: Table<TData>) {
this.table = table;
this.viewportResizeObserver = new ResizeObserver(entries => {
const borderBoxSize = entries[0]?.borderBoxSize[0];
if (borderBoxSize) {
// If we have enough rows that a vertical scrollbar is shown, we need to offset the header widths
// by the same margin so the column headers align with the corresponding rendered cells
const viewportBoundingWidth = borderBoxSize.inlineSize;
this.headerContainerMarginRight = viewportBoundingWidth - this.table.viewport.scrollWidth;
}
});
}

public connectedCallback(): void {
this.viewportResizeObserver.observe(this.table.viewport);
this.updateVirtualizer();
}

public disconnectedCallback(): void {
this.viewportResizeObserver.disconnect();
}

public dataChanged(): void {
if (this.table.$fastController.isConnected) {
this.updateVirtualizer();
}
}

private updateVirtualizer(): void {
const options = this.createVirtualizerOptions();
if (this.virtualizer) {
this.virtualizer.setOptions(options);
} else {
this.virtualizer = new TanStackVirtualizer(options);
}
this.virtualizer._willUpdate();
this.handleVirtualizerChange();
}

private createVirtualizerOptions(): VirtualizerOptions<
HTMLElement,
HTMLElement
> {
const rowHeight = parseFloat(controlHeight.getValueFor(this.table));
return {
count: this.table.tableData.length,
getScrollElement: () => {
return this.table.viewport;
},
estimateSize: (_: number) => rowHeight,
enableSmoothScroll: true,
overscan: 3,
scrollingDelay: 5,
scrollToFn: elementScroll,
observeElementOffset,
observeElementRect,
onChange: () => this.handleVirtualizerChange()
} as VirtualizerOptions<HTMLElement, HTMLElement>;
}

private handleVirtualizerChange(): void {
const virtualizer = this.virtualizer!;
this.visibleItems = virtualizer.getVirtualItems();
this.allRowsHeight = virtualizer.getTotalSize();
// We're using a separate div ('table-scroll') to represent the full height of all rows, and
// the row container's height is only big enough to hold the virtualized rows. So we don't
// use the TanStackVirtual-provided 'start' offset (which is in terms of the full height)
// to translate every individual row, we just translate the row container.
let rowContainerYOffset = 0;
if (this.visibleItems.length > 0) {
const firstItem = this.visibleItems[0]!;
const lastItem = this.visibleItems[this.visibleItems.length - 1]!;
if (lastItem.end < this.allRowsHeight) {
rowContainerYOffset = firstItem.start - virtualizer.scrollOffset;
}
}
this.rowContainerYOffset = rowContainerYOffset;
}
}
10 changes: 8 additions & 2 deletions packages/nimble-components/src/table/specs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,13 +219,19 @@ We will be using TanStack Table to manage all of the table state related to data

TanStack Virtual provides various pieces of state to enable simple, efficient virtualization of table data. The Nimble Table will provide certain state/objects to the TanStack Virtual API for it to then provide the needed state that we can virtualize the table rows with. Namely:

- The element that will serve as the scollable element
- The element that will serve as the scrollable element
- An estimated height for each row
- The total count of rows in the data

With this set of information, the Nimble Table will be able to register a callback to the TanStack Virtual `onChange` which will happen any time the scrollable element scrolls. In that handler the Nimble Table can retrieve the set of virtual items from TanStack Virtual (i.e. `getVirtualItems()`), which represent the total set of rows that should be displayed, and contain the state information that allows the Nimble Table to retrieve the appropriate data from the TanStack Table model to apply to each rendered row, as well as the position each row should be rendered.

_Placeholder for other implementation details_
Our implementation has some differences from the TanStack Virtual examples:

- The scrollable element is the parent of 2 containers (the 1st has its `height` set to the height of all rows, and the 2nd is the row container)
- Rather than doing a `translate` `transform` on each individual row in the row container, we have one `translate` `transform` on the row container itself, which is never larger than the height of a couple of rows.
- The rows always render at the top of their container (which has `position: sticky` applied to it)

The changes above result in better rendering performance (notably in Firefox which sometimes had flickering otherwise).

### States

Expand Down
26 changes: 25 additions & 1 deletion packages/nimble-components/src/table/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,37 @@ import { themeBehavior } from '../utilities/style/theme';
export const styles = css`
${display('flex')}
:host {
height: 480px;
}
.table-container {
display: flex;
flex-direction: column;
width: 100%;
font: ${bodyFont};
color: ${bodyFontColor};
overflow: auto;
}
.table-viewport {
overflow-y: auto;
display: block;
height: 100%;
position: relative;
}
.table-scroll {
pointer-events: none;
position: absolute;
top: 0px;
width: 100%;
}
.table-row-container {
width: 100%;
position: sticky;
overflow: hidden;
top: 0px;
}
.header-container {
Expand Down
33 changes: 19 additions & 14 deletions packages/nimble-components/src/table/template.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import {
ElementsFilter,
html,
ref,
repeat,
slotted,
when
} from '@microsoft/fast-element';
import { DesignSystem } from '@microsoft/fast-foundation';
import type { VirtualItem } from '@tanstack/virtual-core';
import type { Table } from '.';
import type { TableRowState } from './types';
import { TableHeader } from './components/header';
import { TableRow } from './components/row';
import { TableColumn } from '../table-column/base';
Expand All @@ -27,7 +28,7 @@ const isTableColumn = (): ElementsFilter => {
export const template = html<Table>`
<template role="table">
<div class="table-container">
<div role="rowgroup" class="header-container">
<div role="rowgroup" class="header-container" style="margin-right: ${x => x.virtualizer.headerContainerMarginRight}px;">
<div class="header-row" role="row">
${repeat(x => x.columns, html<TableColumn>`
<${DesignSystem.tagFor(TableHeader)} class="header">
Expand All @@ -36,18 +37,22 @@ export const template = html<Table>`
`)}
</div>
</div>
<div class="table-viewport" role="rowgroup">
${when(x => x.columns.length > 0 && x.canRenderRows, html<Table>`
${repeat(x => x.tableData, html<TableRowState>`
<${DesignSystem.tagFor(TableRow)}
class="row"
record-id="${x => x.id}"
:dataRecord="${x => x.record}"
:columns="${(_, c) => (c.parent as Table).columns}"
>
</${DesignSystem.tagFor(TableRow)}>
`)}
`)}
<div class="table-viewport" ${ref('viewport')}>
<div class="table-scroll" style="height: ${x => x.virtualizer.allRowsHeight}px;"></div>
<div class="table-row-container" role="rowgroup" style="transform: ${x => (x.virtualizer.rowContainerYOffset === 0 ? 'none' : `translateY(${x.virtualizer.rowContainerYOffset}px)`)};">
${when(x => x.columns.length > 0 && x.canRenderRows, html<Table>`
${repeat(x => x.virtualizer.visibleItems, html<VirtualItem, Table>`
<${DesignSystem.tagFor(TableRow)}
class="row"
record-id="${(x, c) => c.parent.tableData[x.index]?.id}"
:dataRecord="${(x, c) => c.parent.tableData[x.index]?.record}"
:columns="${(_, c) => c.parent.columns}"
style="height: ${x => x.size}px;"
>
</${DesignSystem.tagFor(TableRow)}>
`)}
`)}
</div>
</div>
</div>
<slot ${slotted({ property: 'columns', filter: isTableColumn() })}></slot>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Table } from '..';
import type { TableRecord } from '../types';
import { waitForUpdatesAsync } from '../../testing/async-helpers';

/**
* Page object for the `nimble-table` component to provide consistent ways
Expand Down Expand Up @@ -66,4 +67,10 @@ export class TablePageObject<T extends TableRecord> {

return rows.item(rowIndex).recordId;
}

public async scrollToLastRowAsync(): Promise<void> {
const scrollElement = this.tableElement.viewport;
scrollElement.scrollTop = scrollElement.scrollHeight;
await waitForUpdatesAsync();
}
}
Loading

0 comments on commit c334239

Please sign in to comment.