Skip to content

Commit

Permalink
feat(ui5-table): table virtualization
Browse files Browse the repository at this point in the history
  • Loading branch information
aborjinik committed Nov 15, 2024
1 parent b59d718 commit b322a2a
Show file tree
Hide file tree
Showing 13 changed files with 713 additions and 10 deletions.
239 changes: 239 additions & 0 deletions packages/main/cypress/specs/TableVirtualizer.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
import { html } from "lit";
import "../../src/Table.js";
import "../../src/TableHeaderRow.js";
import "../../src/TableHeaderCell.js";
import "../../src/TableRow.js";
import "../../src/TableCell.js";
import "../../src/TableVirtualizer.js";

import type Table from "../../src/Table.js";
import type TableVirtualizer from "../../src/TableVirtualizer.js";
import type { RangeChangeEventDetail } from "../../src/TableVirtualizer.js";

describe("TableVirtualizer", () => {
function mountTable(rowHeight = 50, rowCount = 100, tableHeight = 250) {
cy.mount(html`
<ui5-table style="height: ${tableHeight}px">
<ui5-table-header-row slot="headerRow" hidden>
<ui5-table-header-cell>Column</ui5-table-header-cell>
</ui5-table-header-row>
<ui5-table-virtualizer slot="features" row-height="${rowHeight}" row-count="${rowCount}"></ui5-table-virtualizer>
</ui5-table>
`);

cy.get<TableVirtualizer>("[ui5-table-virtualizer]").as("virtualizer").then($virtualizer => {
$virtualizer[0].addEventListener("range-change", updateRows as EventListener);
$virtualizer[0].reset();
});

cy.get("[ui5-table]").shadow().find("#table").as("innerTable");
cy.get("[ui5-table]").children("ui5-table-row").as("rows");
cy.get("@rows").first().as("firstRow");
cy.get("@rows").last().as("lastRow");
}

function updateRows(e: CustomEvent<RangeChangeEventDetail>) {
const table = document.querySelector("ui5-table") as Table;
for (let i = e.detail.first; i < e.detail.last; i++) {
const row = table.rows[i - e.detail.first];
if (row) {
row.position = i;
row.cells[0].textContent = `${i}`;
} else {
const newRow = `<ui5-table-row position="${i}"><ui5-table-cell>${i}</ui5-table-cell></ui5-table-row>`;
table.insertAdjacentHTML("beforeend", newRow);
}
}
}

function testRows(start: number, end: number) {
for (let i = start; i < end; i++) {
cy.get("[ui5-table-row]")
.eq(i - start)
.should("have.attr", "position", `${i}`)
.should("have.attr", "aria-rowindex", `${i + 1}`)
.find("ui5-table-cell")
.should("have.text", `${i}`);
}

cy.get("[ui5-table]")
.shadow()
.find("#spacer")
.then($spacer => {
const transform = getComputedStyle($spacer[0]).transform;
return new DOMMatrix(transform);
})
.its("f")
.should("equal", start * 50);
}

describe("Rendering", () => {
it("should render initially 5 rows", () => {
mountTable();

cy.get("@innerTable")
.should("have.attr", "aria-rowcount", "100")
.should("have.css", "overflow-y", "auto")
.then($innerTable => window.getComputedStyle($innerTable[0]))
.invoke("getPropertyValue", "--row-height")
.and("equal", "50px");

cy.get("@innerTable")
.children("#rows")
.should("have.css", "min-height", "5000px");

cy.get("@innerTable")
.find("#spacer")
.should("have.css", "will-change", "transform");

testRows(0, 5);
});

it("should react to rowHeight changes", () => {
mountTable();

cy.get("@virtualizer").invoke("attr", "row-height", "60");

cy.get("@innerTable")
.then($innerTable => window.getComputedStyle($innerTable[0]))
.invoke("getPropertyValue", "--row-height")
.and("equal", "60px");

cy.get("@innerTable")
.children("#rows")
.should("have.css", "min-height", "6000px");
});

it("should react to rowCount changes", () => {
mountTable();

cy.get("@virtualizer").invoke("attr", "row-count", "200");

cy.get("@innerTable")
.should("have.attr", "aria-rowcount", "200")
.children("#rows")
.should("have.css", "min-height", "10000px");
});

it("should update rows on scroll", () => {
mountTable();

cy.get("@innerTable").scrollTo(0, 250);
testRows(5, 10);

cy.get("@innerTable").scrollTo("bottom");
testRows(95, 100);

cy.get("@innerTable").scrollTo(0, 4000);
testRows(80, 85);

cy.get("@innerTable").scrollTo("top");
testRows(0, 5);
});

it("should update rows via keyboard while focus is on the row", () => {
mountTable();

cy.get("@firstRow").realClick();
cy.get("@firstRow").should("have.focus");

cy.realPress("PageDown");
cy.get("@lastRow").should("have.focus");

cy.realPress("PageDown");
testRows(5, 10);

cy.realPress("ArrowDown");
testRows(6, 11);

cy.realPress("PageUp");
cy.get("@firstRow").should("have.focus");

cy.realPress("PageUp");
testRows(1, 6);

cy.realPress("ArrowUp");
testRows(0, 5);

cy.realPress("End");
cy.get("@lastRow").should("have.focus");

cy.realPress("End");
testRows(95, 100);

cy.realPress("Home");
cy.get("@firstRow").should("have.focus");

cy.realPress("Home");
testRows(0, 5);
});

it("should update rows via keyboard while focus is on the cell", () => {
mountTable();

cy.get("@firstRow").find("ui5-table-cell").first().as("firstRowFirstCell");
cy.get("@lastRow").find("ui5-table-cell").first().as("lastRowFirstCell");

cy.get("@firstRow").realClick().realPress("ArrowRight");
cy.get("@firstRowFirstCell").should("have.focus");

cy.realPress("PageDown");
cy.get("@lastRowFirstCell").should("have.focus");

cy.realPress("PageDown");
testRows(5, 10);

cy.realPress("ArrowDown");
testRows(6, 11);

cy.realPress("PageUp");
cy.get("@firstRowFirstCell").should("have.focus");

cy.realPress("PageUp");
testRows(1, 6);

cy.realPress("ArrowUp");
testRows(0, 5);

cy.realPress("End");
cy.get("@firstRow").should("have.focus");

cy.realPress("End");
cy.get("@lastRow").should("have.focus");

cy.realPress("End");
testRows(95, 100);

cy.get("@lastRow").realPress("ArrowRight");
cy.get("@lastRowFirstCell").should("have.focus");

cy.realPress("Home");
cy.get("@lastRow").should("have.focus");

cy.realPress("Home");
cy.get("@firstRow").should("have.focus");

cy.realPress("Home");
testRows(0, 5);
});

it("should have the reset() API", () => {
mountTable();

cy.get("@virtualizer").then($virtualizer => {
$virtualizer[0].addEventListener("range-change", cy.stub().as("rangeChange"));
});

cy.get("@innerTable").scrollTo("bottom");
cy.get("@rangeChange").should("have.been.calledOnce");
testRows(95, 100);

cy.get("@virtualizer").invoke("get", 0).invoke("reset");
cy.get("@rangeChange").should("have.been.calledTwice");
testRows(0, 5);

cy.get("@virtualizer").invoke("get", 0).invoke("reset");
cy.get("@rangeChange").should("have.been.calledThrice");
});
});
});
7 changes: 6 additions & 1 deletion packages/main/src/Table.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,15 @@
<div id="table" role="grid"
style="{{styles.table}}"
aria-label="{{_ariaLabel}}"
aria-rowcount="{{_ariaRowCount}}"
aria-multiselectable="{{_ariaMultiSelectable}}"
>
<slot name="headerRow"></slot>
<slot></slot>
<div id="rows">
<div id="spacer" style="{{styles.spacer}}">
<slot></slot>
</div>
</div>
{{#unless rows.length}}
<ui5-table-row id="nodata-row">
<ui5-table-cell id="nodata-cell" excluded-from-navigation>
Expand Down
39 changes: 34 additions & 5 deletions packages/main/src/Table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
import BusyIndicator from "./BusyIndicator.js";
import TableCell from "./TableCell.js";
import { findVerticalScrollContainer, scrollElementIntoView, isFeature } from "./TableUtils.js";
import type TableVirtualizer from "./TableVirtualizer.js";

/**
* Interface for components that can be slotted inside the <code>features</code> slot of the <code>ui5-table</code>.
Expand All @@ -42,7 +43,7 @@ interface ITableFeature extends UI5Element {
/**
* Called when the table finished rendering.
*/
onTableRendered?(): void;
onTableAfterRendering?(): void;
}

/**
Expand Down Expand Up @@ -195,7 +196,7 @@ class Table extends UI5Element {
type: HTMLElement,
"default": true,
invalidateOnChildChange: {
properties: ["navigated"],
properties: ["navigated", "position"],
slots: false,
},
})
Expand Down Expand Up @@ -347,13 +348,17 @@ class Table extends UI5Element {
}

onAfterRendering(): void {
this.features.forEach(feature => feature.onTableRendered?.());
this.features.forEach(feature => feature.onTableAfterRendering?.());
}

_getSelection(): TableSelection | undefined {
return this.features.find(feature => isFeature<TableSelection>(feature, "TableSelection")) as TableSelection;
}

_getVirtualizer(): TableVirtualizer | undefined {
return this.features.find(feature => isFeature<TableVirtualizer>(feature, "TableVirtualizer")) as TableVirtualizer;
}

_onEvent(e: Event) {
const composedPath = e.composedPath();
const eventOrigin = composedPath[0] as HTMLElement;
Expand Down Expand Up @@ -404,6 +409,10 @@ class Table extends UI5Element {
}

_onfocusin(e: FocusEvent) {
if (e.target === this) {
return;
}

// Handles focus in the table, when the focus is below a sticky element
scrollElementIntoView(this._scrollContainer, e.target as HTMLElement, this._stickyElements, this.effectiveDir === "rtl");
}
Expand Down Expand Up @@ -452,7 +461,7 @@ class Table extends UI5Element {
}

_isFeature(feature: any) {
return Boolean(feature.onTableActivate && feature.onTableRendered);
return Boolean(feature.onTableActivate && feature.onTableAfterRendering);
}

_isGrowingFeature(feature: any) {
Expand All @@ -464,6 +473,7 @@ class Table extends UI5Element {
}

get styles() {
const virtualizer = this._getVirtualizer();
const headerStyleMap = this.headerRow?.[0]?.cells?.reduce((headerStyles, headerCell) => {
if (headerCell.horizontalAlign !== undefined && !headerCell._popin) {
headerStyles[`--horizontal-align-${headerCell._individualSlot}`] = headerCell.horizontalAlign;
Expand All @@ -473,8 +483,13 @@ class Table extends UI5Element {
return {
table: {
"grid-template-columns": this._gridTemplateColumns,
"--row-height": virtualizer ? `${virtualizer.rowHeight}px` : "auto",
...headerStyleMap,
},
spacer: {
"transform": this._spacerTransform,
"will-change": this._getVirtualizer() ? "transform" : undefined,
},
};
}

Expand All @@ -501,6 +516,16 @@ class Table extends UI5Element {
return widths.join(" ");
}

get _spacerTransform() {
const firstRow = this.rows[0];
const virtualizer = this._getVirtualizer();

if (virtualizer && firstRow && firstRow.position > -1) {
const transform = firstRow.position * virtualizer.rowHeight;
return `translateY(${transform}px)`;
}
}

get _tableOverflowX() {
return (this.overflowMode === TableOverflowMode.Popin) ? "clip" : "auto";
}
Expand Down Expand Up @@ -537,6 +562,10 @@ class Table extends UI5Element {
return getEffectiveAriaLabelText(this) || undefined;
}

get _ariaRowCount() {
return this._getVirtualizer()?.rowCount || undefined;
}

get _ariaMultiSelectable() {
const selection = this._getSelection();
return (selection?.isSelectable() && this.rows.length) ? selection.isMultiSelect() : undefined;
Expand All @@ -558,7 +587,7 @@ class Table extends UI5Element {
}

get _scrollContainer() {
return findVerticalScrollContainer(this._tableElement);
return findVerticalScrollContainer(this);
}

get isTable() {
Expand Down
2 changes: 1 addition & 1 deletion packages/main/src/TableGrowing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ class TableGrowing extends UI5Element implements ITableGrowing {
this._shouldFocusRow = false;
}

onTableRendered(): void {
onTableAfterRendering(): void {
// Focus the first row after growing, when the growing button is used
if (this._shouldFocusRow) {
this._shouldFocusRow = false;
Expand Down
Loading

0 comments on commit b322a2a

Please sign in to comment.