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
33 changes: 17 additions & 16 deletions src/js/core/rendering/Renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -165,34 +165,35 @@ export default class Renderer extends CoreFeature{
//scroll to row
this.scrollToRow(row);

//align to correct position
//align to correct position. After scrollToRow the target row is
//in the rendered window but NOT necessarily at the top, so derive
//the alignment from its actual offsetTop rather than assuming the
//current scrollTop places it at the top.
switch(position){
case "middle":
case "center":

if(this.elementVertical.scrollHeight - this.elementVertical.scrollTop == this.elementVertical.clientHeight){
this.elementVertical.scrollTop = this.elementVertical.scrollTop + (rowEl.offsetTop - this.elementVertical.scrollTop) - ((this.elementVertical.scrollHeight - rowEl.offsetTop) / 2);
}else{
this.elementVertical.scrollTop = this.elementVertical.scrollTop - (this.elementVertical.clientHeight / 2);
}

this.elementVertical.scrollTop = rowEl.offsetTop - (this.elementVertical.clientHeight / 2) + (rowEl.offsetHeight / 2);
break;

case "bottom":

if(this.elementVertical.scrollHeight - this.elementVertical.scrollTop == this.elementVertical.clientHeight){
this.elementVertical.scrollTop = this.elementVertical.scrollTop - (this.elementVertical.scrollHeight - rowEl.offsetTop) + rowEl.offsetHeight;
}else{
this.elementVertical.scrollTop = this.elementVertical.scrollTop - this.elementVertical.clientHeight + rowEl.offsetHeight;
}

this.elementVertical.scrollTop = rowEl.offsetTop - this.elementVertical.clientHeight + rowEl.offsetHeight;
break;

case "top":
this.elementVertical.scrollTop = rowEl.offsetTop;
this.elementVertical.scrollTop = rowEl.offsetTop;
break;
}

//keep the virtual renderer's scroll trackers in sync with the
//manual scrollTop write, so the next user scroll doesn't compute a
//large diff against a stale tracker and trigger a spurious full
//re-render/jump. Guarded for the basic renderer, which has no
//trackers.
if(this.vDomScrollPosTop !== undefined){
this.vDomScrollPosTop = this.elementVertical.scrollTop;
this.vDomScrollPosBottom = this.elementVertical.scrollTop;
}

resolve();

}else{
Expand Down
46 changes: 46 additions & 0 deletions test/e2e/scroll-to-row-position.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>Tabulator scrollToRow position test</title>
<link rel="stylesheet" href="../../dist/css/tabulator.min.css" />
<script src="../../dist/js/tabulator.js"></script>
<style>
body { padding: 20px; font-family: Arial, sans-serif; }
#test-table { width: 500px; }
.tabulator-cell { white-space: normal !important; }
</style>
</head>
<body>
<div id="test-table"></div>
<script>
function generateData() {
const rowCount = 1500;
const data = [];
for (let i = 0; i < rowCount; i++) {
const lines = (i % 8) + 1; // 1..8 lines -> variable heights
let text = "";
for (let l = 0; l < lines; l++) {
text += "Line " + l + " of row " + i + " with padding to wrap. ";
}
data.push({ id: i + 1, name: "Row " + i, notes: text, value: (i * 37) % 1000 });
}
return data;
}

document.addEventListener("DOMContentLoaded", () => {
window.testTable = new Tabulator("#test-table", {
data: generateData(),
columns: [
{ title: "ID", field: "id", width: 120 },
{ title: "Name", field: "name", width: 200 },
{ title: "Notes", field: "notes", width: 600, formatter: "textarea" },
{ title: "Value", field: "value", width: 200, sorter: "number" },
],
height: "300px",
layout: "fitData",
});
});
</script>
</body>
</html>
74 changes: 74 additions & 0 deletions test/e2e/scroll-to-row-position.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { test, expect, Page } from "@playwright/test";
import { join } from "path";

// Regression coverage for scrollToRowPosition alignment.
//
// After scrollToRow(row) fills the virtual DOM, the target row is in the window
// but NOT necessarily at the top. The old center/bottom math derived the offset
// from the current scrollTop (assuming top placement), so for rows deep in the
// list it scrolled the wrong distance — the row could land far from the
// requested position or detach entirely. The fix reads the row's actual
// offsetTop. It also syncs vDomScrollPosTop/Bottom so a following user scroll
// doesn't see a stale tracker and jump.
//
// Metric: distance (px) between where the target row landed and where the
// requested position wants it. Lower is better; a large reading is the bug.

// A row near the bottom of the list: after scrollToRow fills the window it
// cannot sit at the window top (not enough rows below it), so the old math that
// assumed top placement misaligns it — the documented trigger.
const TARGET_ID = 1490;

async function landingMiss(page: Page, position: "top" | "center" | "bottom") {
await page.evaluate(
async ({ id, position }) => {
// @ts-expect-error test global
await window.testTable.scrollToRow(id, position, true);
},
{ id: TARGET_ID, position },
);
await page.waitForTimeout(80);

return page.evaluate(
({ id, position }) => {
const holder = document.querySelector(".tabulator-tableholder") as HTMLElement;
const rows = [...holder.querySelectorAll(".tabulator-row")];
const target = rows.find((r) => {
const cell = r.querySelector(".tabulator-cell");
return cell && cell.textContent === String(id);
});
if (!target) {
return { found: false as const };
}
const rr = target.getBoundingClientRect();
const hr = holder.getBoundingClientRect();
let miss: number;
if (position === "center") {
miss = Math.abs(rr.top + rr.height / 2 - (hr.top + hr.height / 2));
} else if (position === "bottom") {
miss = Math.abs(rr.bottom - hr.bottom);
} else {
miss = Math.abs(rr.top - hr.top);
}
return { found: true as const, miss: Math.round(miss) };
},
{ id: TARGET_ID, position },
);
}

test.describe("scrollToRow position alignment", () => {
test.beforeEach(async ({ page }) => {
await page.goto(`file://${join(__dirname, "scroll-to-row-position.html")}`);
await page.waitForSelector(".tabulator-tableholder");
});

for (const position of ["top", "center", "bottom"] as const) {
test(`scrollToRow lands the target at ${position}`, async ({ page }) => {
const res = await landingMiss(page, position);
expect(res.found).toBe(true);
// The target row (id 750 of 1500, variable heights) must land within a
// row-height of the requested position.
expect(res.miss).toBeLessThanOrEqual(6);
});
}
});
Loading