diff --git a/packages/main/cypress/specs/TableVirtualizer.cy.ts b/packages/main/cypress/specs/TableVirtualizer.cy.ts
new file mode 100644
index 000000000000..7a9ee167d6ba
--- /dev/null
+++ b/packages/main/cypress/specs/TableVirtualizer.cy.ts
@@ -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`
+
+
+ Column
+
+
+
+ `);
+
+ cy.get("[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) {
+ 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 = `${i}`;
+ 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");
+ });
+ });
+});
diff --git a/packages/main/src/Table.hbs b/packages/main/src/Table.hbs
index 554bb45e9539..4688ba22e56c 100644
--- a/packages/main/src/Table.hbs
+++ b/packages/main/src/Table.hbs
@@ -3,10 +3,15 @@
-
+
{{#unless rows.length}}
diff --git a/packages/main/src/Table.ts b/packages/main/src/Table.ts
index 4ef5af1314a3..6fc906a5dc15 100644
--- a/packages/main/src/Table.ts
+++ b/packages/main/src/Table.ts
@@ -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 features
slot of the ui5-table
.
@@ -42,7 +43,7 @@ interface ITableFeature extends UI5Element {
/**
* Called when the table finished rendering.
*/
- onTableRendered?(): void;
+ onTableAfterRendering?(): void;
}
/**
@@ -195,7 +196,7 @@ class Table extends UI5Element {
type: HTMLElement,
"default": true,
invalidateOnChildChange: {
- properties: ["navigated"],
+ properties: ["navigated", "position"],
slots: false,
},
})
@@ -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(feature, "TableSelection")) as TableSelection;
}
+ _getVirtualizer(): TableVirtualizer | undefined {
+ return this.features.find(feature => isFeature(feature, "TableVirtualizer")) as TableVirtualizer;
+ }
+
_onEvent(e: Event) {
const composedPath = e.composedPath();
const eventOrigin = composedPath[0] as HTMLElement;
@@ -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");
}
@@ -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) {
@@ -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;
@@ -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,
+ },
};
}
@@ -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";
}
@@ -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;
@@ -558,7 +587,7 @@ class Table extends UI5Element {
}
get _scrollContainer() {
- return findVerticalScrollContainer(this._tableElement);
+ return findVerticalScrollContainer(this);
}
get isTable() {
diff --git a/packages/main/src/TableGrowing.ts b/packages/main/src/TableGrowing.ts
index 60edcf93e1b0..26c3e339e414 100644
--- a/packages/main/src/TableGrowing.ts
+++ b/packages/main/src/TableGrowing.ts
@@ -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;
diff --git a/packages/main/src/TableNavigation.ts b/packages/main/src/TableNavigation.ts
index afda84d60e37..d963d33b150b 100644
--- a/packages/main/src/TableNavigation.ts
+++ b/packages/main/src/TableNavigation.ts
@@ -115,7 +115,11 @@ class TableNavigation extends TableExtension {
}
this._ignoreFocusIn = ignoreFocusIn;
- element.focus();
+ if (element === this._table._beforeElement || element === this._table._afterElement) {
+ element.focus({ preventScroll: true });
+ } else {
+ element.focus();
+ }
if (element instanceof HTMLInputElement) {
element.select();
}
@@ -210,6 +214,11 @@ class TableNavigation extends TableExtension {
this._gridWalker.setCurrent(eventOrigin);
}
+ this._table._getVirtualizer()?._onKeyDown(e);
+ if (e.defaultPrevented) {
+ return;
+ }
+
const keydownHandlerName = `_handle${e.code}` as keyof TableNavigation;
const keydownHandler = this[keydownHandlerName] as (e: KeyboardEvent, eventOrigin: HTMLElement) => void | false;
if (typeof keydownHandler === "function" && keydownHandler.call(this, e, eventOrigin) === undefined) {
@@ -284,6 +293,7 @@ class TableNavigation extends TableExtension {
if (this._table.loading) {
this._table._loadingElement.focus();
} else {
+ this._getNavigationItemsOfGrid();
this._gridWalker.setColPos(0);
this._focusCurrentItem();
}
diff --git a/packages/main/src/TableRow.ts b/packages/main/src/TableRow.ts
index 9a02f6043209..cfafe418a377 100644
--- a/packages/main/src/TableRow.ts
+++ b/packages/main/src/TableRow.ts
@@ -60,6 +60,15 @@ class TableRow extends TableRowBase {
@property()
rowKey = "";
+ /**
+ * Defines the position of the row respect to the total number of rows within the table when the ui5-table-virtualizer
feature is used.
+ *
+ * @default -1
+ * @public
+ */
+ @property({ type: Number })
+ position = -1;
+
/**
* Defines the interactive state of the row.
*
@@ -84,6 +93,9 @@ class TableRow extends TableRowBase {
onBeforeRendering() {
super.onBeforeRendering();
this.toggleAttribute("_interactive", this._isInteractive);
+ if (this.position !== -1) {
+ this.setAttribute("aria-rowindex", `${this.position + 1}`);
+ }
if (this._renderNavigated && this.navigated) {
this.setAttribute("aria-current", "true");
} else {
diff --git a/packages/main/src/TableUtils.ts b/packages/main/src/TableUtils.ts
index 21f8d0089928..4b93441f787a 100644
--- a/packages/main/src/TableUtils.ts
+++ b/packages/main/src/TableUtils.ts
@@ -77,6 +77,16 @@ const isFeature = (element: any, identifier: string): element is T => {
return element.identifier === identifier;
};
+const throttle = (callback: () => void) => {
+ let timer: number;
+ return () => {
+ cancelAnimationFrame(timer);
+ timer = requestAnimationFrame(() => {
+ callback();
+ });
+ };
+};
+
export {
isInstanceOfTable,
isSelectionCheckbox,
@@ -85,4 +95,5 @@ export {
findVerticalScrollContainer,
scrollElementIntoView,
isFeature,
+ throttle,
};
diff --git a/packages/main/src/TableVirtualizer.ts b/packages/main/src/TableVirtualizer.ts
new file mode 100644
index 000000000000..ebb67be75e17
--- /dev/null
+++ b/packages/main/src/TableVirtualizer.ts
@@ -0,0 +1,279 @@
+/* eslint-disable no-bitwise */
+import {
+ isUp,
+ isUpShift,
+ isDown,
+ isDownShift,
+ isPageUp,
+ isPageDown,
+ isHome,
+ isEnd,
+ isTabNext,
+ isTabPrevious,
+} from "@ui5/webcomponents-base/dist/Keys.js";
+import UI5Element, { type InvalidationInfo } from "@ui5/webcomponents-base/dist/UI5Element.js";
+import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js";
+import property from "@ui5/webcomponents-base/dist/decorators/property.js";
+import event from "@ui5/webcomponents-base/dist/decorators/event.js";
+import getActiveElement from "@ui5/webcomponents-base/dist/util/getActiveElement.js";
+import { getTabbableElements } from "@ui5/webcomponents-base/dist/util/TabbableElements.js";
+import { throttle } from "./TableUtils.js";
+import type { ITableFeature } from "./Table.js";
+import type Table from "./Table.js";
+
+enum TabBlocking {
+ None = 0,
+ Next = 1,
+ Previous = 2,
+ Released = 4,
+}
+
+/**
+ * Fired when the virtualizer is changed by user interaction e.g. on scrolling.
+ * @param {first} number The 0-based index of the first children currently rendered
+ * @param {last} number The 0-based index of the last children currently rendered
+ * @public
+ */
+type RangeChangeEventDetail = {
+ first: number,
+ last: number,
+};
+
+/**
+ * @class
+ *
+ * ### Overview
+ *
+ * The `ui5-table-virtualizer` component is used inside the `ui5-table` to virtualize the table rows.
+ * It is responsible for rendering only the rows that are visible in the viewport and updating them on scroll.
+ * This allows large numbers of rows to exist, but maintain high performance by only paying the cost for those that are currently visible.
+ *
+ * Note: The maximum number of virtualized rows is limited by browser constraints, specifically the maximum supported height for a DOM element.
+ *
+ * ### ES6 Module Import
+ * `import "@ui5/webcomponents/dist/TableVirtualizer.js";`
+ *
+ * @constructor
+ * @extends UI5Element
+ * @public
+ * @experimental This component is not intended to be used in a productive enviroment. The API is under development and may be changed in the future.
+ */
+@customElement({ tag: "ui5-table-virtualizer" })
+
+/**
+ * Fired when the virtualizer is changed by user interaction e.g. on scrolling.
+ *
+ * @public
+ */
+@event("range-change", {
+ detail: {
+ /**
+ * The 0-based index of the first children currently rendered.
+ * @public
+ */
+ first: { type: Number },
+ /**
+ * The 0-based index of the last children currently rendered.
+ * @public
+ */
+ last: { type: Number },
+ },
+})
+
+class TableVirtualizer extends UI5Element implements ITableFeature {
+ /**
+ * Defines the height of the rows in the table.
+ *
+ * Note: This property is mandatory for the virtualization to work properly.
+ *
+ * @default 45
+ * @public
+ */
+ @property({ type: Number })
+ rowHeight = 45;
+
+ /**
+ * Defines the total count of rows in the table.
+ *
+ * Note: This property is mandatory for the virtualization to work properly.
+ *
+ * @default 100
+ * @public
+ */
+ @property({ type: Number })
+ rowCount = 100;
+
+ /**
+ * Defines the count of extra rows to be rendered in the table.
+ *
+ * @default 0
+ * @public
+ */
+ @property({ type: Number })
+ extraRows = 0;
+
+ readonly identifier = "TableVirtualizer";
+
+ _table?: Table;
+ _lastRowPosition: number = 0;
+ _firstRowPosition: number = 0;
+ _visibleRowCount: number = 0;
+ _tabBlockingState: TabBlocking = TabBlocking.None;
+ _onRowInvalidateBound: (invalidationInfo: InvalidationInfo) => void;
+ _onScrollBound: () => void;
+
+ constructor() {
+ super();
+ this._onScrollBound = throttle(this._onScroll.bind(this));
+ this._onRowInvalidateBound = this._onRowInvalidate.bind(this);
+ }
+
+ onTableActivate(table: Table): void {
+ this._table = table;
+ this._updateRowsHeight();
+ this._scrollContainer.addEventListener("scroll", this._onScrollBound, { passive: true });
+ this._onScroll();
+ }
+
+ onAfterRendering(): void {
+ this._table && this._table._invalidate++;
+ }
+
+ onTableAfterRendering(): void {
+ if (!this._table) {
+ return;
+ }
+
+ this._updateRowsHeight();
+ if (this._tabBlockingState & TabBlocking.Released) {
+ const tabBlockingRow = this._table.rows.at(this._tabBlockingState & TabBlocking.Next ? -1 : 0) as HTMLElement;
+ const tabForwardingElement = getTabbableElements(tabBlockingRow).at(this._tabBlockingState & TabBlocking.Next ? 0 : -1);
+ this._tabBlockingState = TabBlocking.None;
+ (tabForwardingElement || tabBlockingRow).focus();
+ }
+ }
+
+ onExitDOM(): void {
+ this._scrollContainer.removeEventListener("scroll", this._onScrollBound);
+ this._table = undefined;
+ }
+
+ /**
+ * Resets the virtualizer to its initial state and triggers the range-change
event.
+ * @public
+ */
+ reset(): void {
+ this._lastRowPosition = -1;
+ this._firstRowPosition = -1;
+ if (this._scrollContainer.scrollTop > 0) {
+ this._scrollContainer.scrollTop = 0;
+ } else {
+ this._onScroll();
+ }
+ }
+
+ get _scrollContainer() {
+ return this._table!._tableElement;
+ }
+
+ get _rowsContainer() {
+ return this._table!.shadowRoot!.getElementById("rows")!;
+ }
+
+ _onScroll(): void {
+ if (!this._table) {
+ return;
+ }
+
+ const scrollTop = this._scrollContainer.scrollTop;
+ const fixHeight = this._table.headerRow[0].offsetHeight;
+ const scrollableHeight = this._scrollContainer.clientHeight - fixHeight;
+ this._visibleRowCount = Math.ceil(scrollableHeight / this.rowHeight);
+
+ let firstRowPosition = Math.floor(scrollTop / this.rowHeight) - this.extraRows;
+ firstRowPosition = Math.max(0, firstRowPosition);
+
+ let lastRowPosition = Math.max(0, firstRowPosition + this._visibleRowCount + 2 * this.extraRows);
+ lastRowPosition = Math.min(lastRowPosition, this.rowCount);
+
+ if (this._firstRowPosition === firstRowPosition && this._lastRowPosition === lastRowPosition) {
+ return;
+ }
+
+ this._lastRowPosition = lastRowPosition;
+ this._firstRowPosition = firstRowPosition;
+ this.fireDecoratorEvent("range-change", {
+ first: firstRowPosition,
+ last: lastRowPosition,
+ });
+ }
+
+ _updateRowsHeight() {
+ const rowsHeight = this.rowCount * this.rowHeight;
+ this._rowsContainer.style.minHeight = `${rowsHeight}px`;
+ }
+
+ _onRowInvalidate(invalidationInfo: InvalidationInfo) {
+ if (invalidationInfo.name === "position") {
+ invalidationInfo.target.detachInvalidate(this._onRowInvalidateBound);
+ this._tabBlockingState |= TabBlocking.Released;
+ }
+ }
+
+ _onKeyDown(e: KeyboardEvent) {
+ if (!this._table) {
+ return;
+ }
+
+ let scrollTopChange = 0;
+ const rows = this._table.rows;
+ const firstRow = rows[0];
+ const lastRow = rows[rows.length - 1];
+ const hasDataBeforeFirstRow = firstRow.position !== 0;
+ const hasDataAfterLastRow = lastRow.position !== this.rowCount - 1;
+ const tableNavigation = this._table._tableNavigation!;
+ const activeElement = getActiveElement() as HTMLElement;
+
+ if (isTabNext(e) && hasDataAfterLastRow && getTabbableElements(this._rowsContainer).pop() === activeElement) {
+ this._tabBlockingState = TabBlocking.Next;
+ lastRow.attachInvalidate(this._onRowInvalidateBound);
+ scrollTopChange = this.rowHeight;
+ } else if (isTabPrevious(e) && hasDataBeforeFirstRow && getTabbableElements(this._rowsContainer).shift() === activeElement) {
+ this._tabBlockingState = TabBlocking.Previous;
+ firstRow.attachInvalidate(this._onRowInvalidateBound);
+ scrollTopChange = this.rowHeight * -1;
+ } else if (hasDataAfterLastRow && tableNavigation._getNavigationItemsOfRow(lastRow).includes(activeElement)) {
+ if (isDown(e) || isDownShift(e)) {
+ scrollTopChange = this.rowHeight;
+ } else if (isPageDown(e)) {
+ scrollTopChange = this._visibleRowCount * this.rowHeight;
+ } else if (isEnd(e) && activeElement === lastRow) {
+ scrollTopChange = this.rowCount * this.rowHeight;
+ }
+ } else if (hasDataBeforeFirstRow && tableNavigation._getNavigationItemsOfRow(firstRow).includes(activeElement)) {
+ if (isUp(e) || isUpShift(e)) {
+ scrollTopChange = this.rowHeight * -1;
+ } else if (isPageUp(e)) {
+ scrollTopChange = this._visibleRowCount * this.rowHeight * -1;
+ } else if (isHome(e) && activeElement === firstRow) {
+ scrollTopChange = this.rowCount * this.rowHeight * -1;
+ }
+ }
+
+ if (scrollTopChange) {
+ const scrollTop = this._table.scrollTop;
+ this._scrollContainer.scrollTop += scrollTopChange;
+ if (this._scrollContainer.scrollTop !== scrollTop) {
+ e.preventDefault();
+ }
+ }
+ }
+}
+
+TableVirtualizer.define();
+
+export default TableVirtualizer;
+
+export type {
+ RangeChangeEventDetail,
+};
diff --git a/packages/main/src/bundle.esm.ts b/packages/main/src/bundle.esm.ts
index 8f67245661ac..2546061c0488 100644
--- a/packages/main/src/bundle.esm.ts
+++ b/packages/main/src/bundle.esm.ts
@@ -56,6 +56,7 @@ import TableHeaderCell from "./TableHeaderCell.js";
import TableHeaderRow from "./TableHeaderRow.js";
import TableGrowing from "./TableGrowing.js";
import TableSelection from "./TableSelection.js";
+import TableVirtualizer from "./TableVirtualizer.js";
import Icon from "./Icon.js";
import Input from "./Input.js";
import SuggestionItemCustom from "./SuggestionItemCustom.js";
diff --git a/packages/main/src/themes/Table.css b/packages/main/src/themes/Table.css
index 7bb0d299645f..e98560db5ccb 100644
--- a/packages/main/src/themes/Table.css
+++ b/packages/main/src/themes/Table.css
@@ -13,7 +13,20 @@
display: grid;
grid-auto-rows: minmax(min-content, auto);
background: var(--sapList_Background);
- height: -moz-fill, -webkit-fill-available, 100%;
+}
+
+:host([overflow-mode="Scroll"]) #table {
+ overflow-x: auto;
+ height: 100%;
+ height: -webkit-fill-available;
+ height: fill-available;
+}
+
+#rows, #spacer {
+ display: grid;
+ grid-template-rows: min-content;
+ grid-template-columns: subgrid;
+ grid-column: 1 / -1;
}
#nodata-cell {
diff --git a/packages/main/src/themes/TableCellBase.css b/packages/main/src/themes/TableCellBase.css
index 19345e623998..df6912f2afee 100644
--- a/packages/main/src/themes/TableCellBase.css
+++ b/packages/main/src/themes/TableCellBase.css
@@ -10,7 +10,7 @@
box-sizing: border-box;
}
-:host(:focus) {
+:host([tabindex]:focus) {
outline: var(--sapContent_FocusWidth) var(--sapContent_FocusStyle) var(--sapContent_FocusColor);
outline-offset: calc(-1 * var(--sapContent_FocusWidth));
}
diff --git a/packages/main/src/themes/TableRow.css b/packages/main/src/themes/TableRow.css
index 1d4257ffe777..a2bcd93fb63a 100644
--- a/packages/main/src/themes/TableRow.css
+++ b/packages/main/src/themes/TableRow.css
@@ -2,6 +2,11 @@
background: var(--sapList_Background);
}
+:host([position]) {
+ height: var(--row-height);
+ overflow: clip;
+}
+
:host([aria-selected=true]) {
background-color: var(--sapList_SelectionBackgroundColor);
border-bottom: var(--sapList_BorderWidth) solid var(--sapList_SelectionBorderColor);
diff --git a/packages/main/test/pages/TableVirtualizer.html b/packages/main/test/pages/TableVirtualizer.html
new file mode 100644
index 000000000000..5c3506881670
--- /dev/null
+++ b/packages/main/test/pages/TableVirtualizer.html
@@ -0,0 +1,99 @@
+
+
+
+
+
+
+ Table (in development)
+
+
+
+
+
+
+
+
+
+
+
+ My Selectable Products (1000)
+
+
+
+
+
+
+
+ Product
+ Supplier
+ Dimensions
+ Weight
+ Price
+
+
+
+
+
+
+
+
\ No newline at end of file