diff --git a/packages/main/cypress/specs/TableNavigation.cy.tsx b/packages/main/cypress/specs/TableNavigation.cy.tsx
index 812679c351f58..e7ed19950a70b 100644
--- a/packages/main/cypress/specs/TableNavigation.cy.tsx
+++ b/packages/main/cypress/specs/TableNavigation.cy.tsx
@@ -4,6 +4,9 @@ import TableHeaderCell from "../../src/TableHeaderCell.js";
import TableRow from "../../src/TableRow.js";
import TableCell from "../../src/TableCell.js";
import TableGrowing from "../../src/TableGrowing.js";
+import TableSelectionMulti from "../../src/TableSelectionMulti.js";
+import TableRowAction from "../../src/TableRowAction.js";
+import TableRowActionNavigation from "../../src/TableRowActionNavigation.js";
import Bar from "../../src/Bar.js";
import Title from "../../src/Title.js";
@@ -432,4 +435,183 @@ describe("Table - Keyboard Navigation with Fixed Headers", () => {
});
}
});
+});
+
+describe("Table - Keyboard Navigation with overflowMode=Scroll", () => {
+ const sixColumnHeader = (
+
+ Column A
+ Column B
+ Column C
+ Column D
+ Column E
+ Column F
+
+ );
+
+ const wideRow = (rowId: string, keyLabel: string) => (
+
+ {keyLabel}-A
+ {keyLabel}-B
+ {keyLabel}-C
+ {keyLabel}-D
+ {keyLabel}-E
+ {keyLabel}-F
+
+ );
+
+ const focusFirstCell = (key: "ArrowRight" | "ArrowLeft", pressesToFirstCell: number) => {
+ // Cypress#focus() rejects the custom element, and .click()
+ // fail because the sticky column partially covers the row
+ cy.get("#row-1").then($row => $row[0].focus());
+ for (let p = 0; p < pressesToFirstCell; p++) {
+ cy.realPress(key);
+ }
+ cy.get("#row-1").children("ui5-table-cell").eq(0).should("be.focused");
+ };
+
+ const walkAndAssertClearOfSticky = (
+ key: "ArrowRight" | "ArrowLeft",
+ stickyCellId: string,
+ assertClear: (stickyRect: DOMRect, focusedRect: DOMRect) => void,
+ ) => {
+ for (let i = 1; i < 6; i++) {
+ cy.realPress(key);
+ cy.get("#row-1").children("ui5-table-cell").eq(i).as("focused").should("be.focused");
+ cy.get("#row-1").shadow().find(stickyCellId).then($sticky => {
+ const stickyRect = $sticky[0].getBoundingClientRect();
+ cy.get("@focused").then($f => assertClear(stickyRect, $f[0].getBoundingClientRect()));
+ });
+ }
+ };
+
+ it("horizontal arrow keys keep focus past the sticky selection column (LTR)", () => {
+ cy.mount(
+
+
+
+ {sixColumnHeader}
+ {wideRow("row-1", "R1")}
+
+
+ );
+
+ focusFirstCell("ArrowRight", 2);
+ walkAndAssertClearOfSticky("ArrowRight", "#selection-cell", (sel, focused) => {
+ expect(focused.left).to.be.at.least(sel.right - 0.5);
+ });
+ });
+
+ it("horizontal arrow keys keep focus past the sticky actions/navigated column (LTR)", () => {
+ cy.mount(
+
+
+ {sixColumnHeader}
+
+ R1-A
+ R1-B
+ R1-C
+ R1-D
+ R1-E
+ R1-F
+
+
+
+
+
+ );
+
+ focusFirstCell("ArrowRight", 1);
+ walkAndAssertClearOfSticky("ArrowRight", "#actions-cell", (act, focused) => {
+ expect(focused.right).to.be.at.most(act.left + 0.5);
+ });
+ });
+
+ it("horizontal arrow keys keep focus past sticky columns (RTL)", () => {
+ cy.mount(
+
+
+
+ {sixColumnHeader}
+ {wideRow("row-1", "R1")}
+
+
+ );
+
+ // In RTL, ArrowLeft moves to the next logical column.
+ focusFirstCell("ArrowLeft", 2);
+ walkAndAssertClearOfSticky("ArrowLeft", "#selection-cell", (sel, focused) => {
+ expect(focused.right).to.be.at.most(sel.left + 0.5);
+ });
+ });
+
+ it("cell wider than viewport is aligned to leading edge", () => {
+ cy.mount(
+
+
+
+
+ Wide Column
+ Column B
+
+
+ Wide content that is far wider than the visible viewport
+ B
+
+
+
+ );
+
+ focusFirstCell("ArrowRight", 2);
+ cy.get("#row-1").shadow().find("#selection-cell").then($sel => {
+ const selRight = $sel[0].getBoundingClientRect().right;
+ cy.get("#row-1").children("ui5-table-cell").eq(0).then($w => {
+ expect(Math.abs($w[0].getBoundingClientRect().left - selRight)).to.be.at.most(1.5);
+ });
+ });
+ });
+
+ const verticalStickyTable = (
+
+
+ Column A
+
+ {Array.from({ length: 30 }).map((_, i) => (
+
+ Cell {i}
+
+ ))}
+
+ );
+
+ // Scrolls down, navigates back up with ArrowUp and asserts the focused row is not hidden under the sticky header.
+ const assertFocusedRowNotHiddenUnderHeader = () => {
+ cy.get("#row-0").then($row => $row[0].focus());
+ for (let i = 0; i < 20; i++) {
+ cy.realPress("ArrowDown");
+ }
+ cy.get("#row-20").should("be.focused");
+
+ for (let i = 0; i < 5; i++) {
+ cy.realPress("ArrowUp");
+ }
+ cy.get("#row-15").should("be.focused");
+
+ cy.get("#table").find("[ui5-table-header-row]").then($header => {
+ const headerRect = $header[0].getBoundingClientRect();
+ cy.get("#row-15").then($row => {
+ expect($row[0].getBoundingClientRect().top).to.be.at.least(headerRect.bottom - 0.5);
+ });
+ });
+ };
+
+ it("focused row is not hidden under the sticky header (table scrolls)", () => {
+ cy.mount(verticalStickyTable);
+ assertFocusedRowNotHiddenUnderHeader();
+ });
+
+ it("focused row is not hidden under the sticky header (wrapper scrolls)", () => {
+ cy.mount({verticalStickyTable}
);
+ assertFocusedRowNotHiddenUnderHeader();
+ });
});
\ No newline at end of file
diff --git a/packages/main/src/Table.ts b/packages/main/src/Table.ts
index bcd4782878ad6..7a05fd73a7214 100644
--- a/packages/main/src/Table.ts
+++ b/packages/main/src/Table.ts
@@ -14,7 +14,7 @@ import TableDragAndDrop from "./TableDragAndDrop.js";
import TableCustomAnnouncement from "./TableCustomAnnouncement.js";
import ResizeHandler from "@ui5/webcomponents-base/dist/delegate/ResizeHandler.js";
import {
- findVerticalScrollContainer, scrollElementIntoView, isFeature, isValidColumnWidth,
+ findVerticalScrollContainer, computeAxisScrollDelta, isFeature, isValidColumnWidth,
} from "./TableUtils.js";
import { getEffectiveAriaLabelText } from "@ui5/webcomponents-base/dist/util/AccessibilityTextsHelper.js";
import type DropIndicator from "./DropIndicator.js";
@@ -562,8 +562,23 @@ class Table extends UI5Element {
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");
+ this._scrollElementIntoView(e.target as HTMLElement);
+ }
+
+ _scrollElementIntoView(element: HTMLElement) {
+ const verticalStickyElements = this.headerRow.filter(row => row.sticky);
+ if (verticalStickyElements.length) {
+ const verticalScrollContainer = findVerticalScrollContainer(this._tableElement, true);
+ const deltaY = computeAxisScrollDelta(element, verticalScrollContainer, verticalStickyElements, "y");
+ verticalScrollContainer.scrollBy({ top: deltaY });
+ }
+
+ const horizontalStickyElements = this.overflowMode === "Scroll" ? this.headerRow[0]._stickyCells : [];
+ if (horizontalStickyElements.length) {
+ const horizontalScrollContainer = this._tableElement;
+ const deltaX = computeAxisScrollDelta(element, horizontalScrollContainer, horizontalStickyElements, "x");
+ horizontalScrollContainer.scrollBy({ left: deltaX });
+ }
}
_onGrow() {
@@ -704,12 +719,6 @@ class Table extends UI5Element {
return this._getVirtualizer() ? this._tableElement : findVerticalScrollContainer(this);
}
- get _stickyElements() {
- const stickyRows = this.headerRow.filter(row => row.sticky);
- const stickyColumns = this.headerRow[0]._stickyCells as TableHeaderCell[];
- return [...stickyRows, ...stickyColumns];
- }
-
get _effectiveNoDataText() {
return this.noDataText || Table.i18nBundle.getText(TABLE_NO_DATA);
}
diff --git a/packages/main/src/TableRow.ts b/packages/main/src/TableRow.ts
index 4067db5886be3..e4a7abaa0489b 100644
--- a/packages/main/src/TableRow.ts
+++ b/packages/main/src/TableRow.ts
@@ -121,9 +121,6 @@ class TableRow extends TableRowBase {
@query("#popin-cell")
_popinCell?: TableCell;
- @query("#actions-cell")
- _actionsCell?: TableCell;
-
onBeforeRendering() {
super.onBeforeRendering();
this.ariaRowIndex = (this.role === "row") ? `${this._rowIndex + 2}` : null;
diff --git a/packages/main/src/TableRowBase.ts b/packages/main/src/TableRowBase.ts
index 254dec64fff18..0186d191132d6 100644
--- a/packages/main/src/TableRowBase.ts
+++ b/packages/main/src/TableRowBase.ts
@@ -46,6 +46,9 @@ abstract class TableRowBase extends
@query("#selection-cell")
_selectionCell?: HTMLElement;
+ @query("#actions-cell")
+ _actionsCell?: HTMLElement;
+
@query("#navigated-cell")
_navigatedCell?: HTMLElement;
@@ -162,7 +165,7 @@ abstract class TableRowBase extends
}
get _stickyCells() {
- return [this._selectionCell, ...this.cells, this._navigatedCell].filter(cell => cell?.hasAttribute("fixed"));
+ return [this._selectionCell, this._actionsCell, this._navigatedCell].filter(Boolean) as HTMLElement[];
}
get _i18nRowSelector(): string {
diff --git a/packages/main/src/TableUtils.ts b/packages/main/src/TableUtils.ts
index e6204295d0b02..f1144068cb338 100644
--- a/packages/main/src/TableUtils.ts
+++ b/packages/main/src/TableUtils.ts
@@ -16,10 +16,10 @@ const findRowInPath = (composedPath: Array) => {
return composedPath.find((el: EventTarget) => el instanceof HTMLElement && el.hasAttribute("ui5-table-row")) as TableRow;
};
-const findVerticalScrollContainer = (element: HTMLElement): HTMLElement => {
+const findVerticalScrollContainer = (element: HTMLElement, requireOverflow = false): HTMLElement => {
while (element) {
const { overflowY } = window.getComputedStyle(element);
- if (overflowY === "auto" || overflowY === "scroll") {
+ if ((overflowY === "auto" || overflowY === "scroll") && (!requireOverflow || element.scrollHeight > element.clientHeight)) {
return element;
}
@@ -33,43 +33,59 @@ const findVerticalScrollContainer = (element: HTMLElement): HTMLElement => {
return document.scrollingElement as HTMLElement || document.documentElement;
};
-const scrollElementIntoView = (scrollContainer: HTMLElement, element: HTMLElement, stickyElements: HTMLElement[], isRtl: boolean) => {
- if (stickyElements.length === 0) {
- return;
- }
-
+type Axis = "x" | "y";
+
+const AXIS_PROPS = {
+ x: { start: "left", end: "right", size: "width" },
+ y: { start: "top", end: "bottom", size: "height" },
+} as const;
+
+// Computes the scroll delta needed to bring an element into view within a scroll container, considering sticky elements that may be in the way
+const computeAxisScrollDelta = (
+ element: HTMLElement,
+ scrollContainer: HTMLElement,
+ stickyElements: HTMLElement[],
+ axis: Axis,
+): number => {
+ const { start, end, size } = AXIS_PROPS[axis];
const elementRect = element.getBoundingClientRect();
- const inline = isRtl ? "right" : "left";
+ const scrollContainerRect = scrollContainer.getBoundingClientRect();
- const { x: stickyX, y: stickyY } = stickyElements.reduce(({ x, y }, stickyElement) => {
- const { top, [inline]: inlineStart } = getComputedStyle(stickyElement);
- const stickyElementRect = stickyElement.getBoundingClientRect();
- if (top !== "auto" && stickyElementRect.bottom > elementRect.top) {
- y = Math.max(y, stickyElementRect.bottom);
- }
- if (inlineStart !== "auto") {
- if (!isRtl && stickyElementRect.right > elementRect.left) {
- x = Math.max(x, stickyElementRect.right);
- } else if (isRtl && stickyElementRect.left < elementRect.right) {
- x = Math.min(x, stickyElementRect.left);
- }
+ let pinStart = 0;
+ let pinEnd = 0;
+ stickyElements.forEach(sticky => {
+ if (sticky === element || sticky.contains(element)) {
+ return;
}
- return { x, y };
- }, { x: elementRect[inline], y: elementRect.top });
+ const stickyStyle = getComputedStyle(sticky);
+ const stickyRect = sticky.getBoundingClientRect();
+ if (stickyStyle[start] !== "auto") {
+ pinStart = Math.max(pinStart, stickyRect[end] - scrollContainerRect[start]);
+ }
+ if (stickyStyle[end] !== "auto") {
+ pinEnd = Math.max(pinEnd, scrollContainerRect[end] - stickyRect[start]);
+ }
+ });
- const scrollX = elementRect[inline] - stickyX;
- const scrollY = elementRect.top - stickyY;
+ const viewportStart = scrollContainerRect[start] + pinStart;
+ const viewportEnd = scrollContainerRect[end] - pinEnd;
- if (scrollX === 0 && scrollY === 0) {
- // avoid unnecessary scroll call, when nothing changes
- return;
+ // Element already spans the whole container
+ if (elementRect[start] <= scrollContainerRect[start] && elementRect[end] >= scrollContainerRect[end]) {
+ return 0;
}
-
- scrollContainer.scrollBy({
- top: scrollY,
- left: scrollX,
- });
+ // Element larger than the visible viewport
+ if (elementRect[size] > viewportEnd - viewportStart) {
+ return (axis === "x" && element.matches(":dir(rtl)")) ? elementRect[end] - viewportEnd : elementRect[start] - viewportStart;
+ }
+ if (elementRect[start] < viewportStart) {
+ return elementRect[start] - viewportStart;
+ }
+ if (elementRect[end] > viewportEnd) {
+ return elementRect[end] - viewportEnd;
+ }
+ return 0;
};
const isFeature = (element: any, identifier: string): element is T => {
@@ -116,7 +132,7 @@ export {
isHeaderSelectionCell,
findRowInPath,
findVerticalScrollContainer,
- scrollElementIntoView,
+ computeAxisScrollDelta,
isFeature,
throttle,
toggleAttribute,
diff --git a/packages/main/test/pages/Table.html b/packages/main/test/pages/Table.html
index 129af7806a00c..c5f718b4135a8 100644
--- a/packages/main/test/pages/Table.html
+++ b/packages/main/test/pages/Table.html
@@ -31,7 +31,7 @@
-
+