fix(grid): stop transpose cell clicks from scrolling field to top

This commit is contained in:
Unclechan97 authored and GitHub committed 2026-10-15 10:43:13 +08:00
1 parent 3def78fc5a
commit da9040ede5
4 files changed
+278 -4

No files matched your search

+14 -4
View File
@@ -153,6 +153,7 @@ import {
transposeEndAlignmentSpacerWidth,
transposeFieldWidth,
transposeScrollLeftForRecord,
transposeScrollTopForField,
visibleTransposeRecordWindow,
type TransposeScrollAlignment,
} from "@/lib/dataGrid/dataGridTranspose";
@@ -5640,15 +5641,22 @@ function scrollToCurrentMatch() {
if (rowEl) rowEl.scrollIntoView({ block: "center" });
}
function scrollTransposeFieldIntoView(visibleFieldIndex: number) {
function scrollTransposeFieldIntoView(visibleFieldIndex: number, alignment: TransposeScrollAlignment = "start") {
const actualColumnIndex = visibleColumnIndexes.value[visibleFieldIndex];
const sortedIndex = transposeRows.value.findIndex((item) => item.columnIndex === actualColumnIndex);
if (sortedIndex < 0) return;
const scroller = transposeScrollRef.value;
if (scroller && !(scroller instanceof HTMLElement)) {
(scroller as { scrollToItem?: (index: number) => void }).scrollToItem?.(sortedIndex);
(scroller as { scrollToItem?: (index: number, options?: { align?: TransposeScrollAlignment }) => void }).scrollToItem?.(sortedIndex, { align: alignment });
} else if (scroller instanceof HTMLElement) {
scroller.scrollTop = sortedIndex * transposeRowHeight.value;
scroller.scrollTop = transposeScrollTopForField({
fieldIndex: sortedIndex,
totalFields: transposeRows.value.length,
rowHeight: transposeRowHeight.value,
viewportHeight: scroller.clientHeight,
currentScrollTop: scroller.scrollTop,
alignment,
});
}
}
@@ -10196,7 +10204,9 @@ const DOM_DATA_GRID_ROW_HEIGHT = 26;
function scrollCellIntoView(rowIndex: number, colIndex: number, block: DataGridScrollAlignment = "nearest", previousPageRowIndex?: number) {
if (isTransposeMode.value) {
nextTick(() => {
scrollTransposeFieldIntoView(colIndex);
// Clicks and keyboard steps pass "nearest" so an already-visible field stays put;
// only explicit jump callers still align the field to the top.
scrollTransposeFieldIntoView(colIndex, block === "nearest" ? "nearest" : "start");
scrollTransposeRecordIntoView(rowIndex);
});
return;
@@ -0,0 +1,149 @@
// @vitest-environment happy-dom
//
// Regression test for #12128: clicking a cell in the transposed view must not
// yank the clicked field row to the top of the viewport. Cell clicks route
// through applyCellNavigation with block "nearest", so the transpose field
// scroll must ask the scroller for nearest alignment instead of the default
// start alignment.
import { createApp, defineComponent, h, markRaw, nextTick, ref, type App, type PropType } from "vue";
import { createPinia, setActivePinia } from "pinia";
import { afterEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import type { QueryResult } from "@/types/database";
import { TooltipProvider } from "@/components/ui/tooltip";
const scrollToItem = vi.fn();
vi.mock("vue-virtual-scroller", () => ({
RecycleScroller: defineComponent({
props: {
items: {
type: Array as PropType<unknown[]>,
default: () => [],
},
},
setup(props, { attrs, slots, expose }) {
expose({ scrollToItem });
return () => h("div", attrs, [slots.before?.(), props.items.map((item, index) => slots.default?.({ item, index }))]);
},
}),
}));
vi.mock("@/composables/useDataGridColumnResize", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/composables/useDataGridColumnResize")>();
const { ref } = await import("vue");
return {
...actual,
useDataGridColumnResize: () => ({
initColumnWidths: vi.fn(),
onResizeStart: vi.fn(),
autoFitColumn: vi.fn(),
renderedColumnWidths: ref([120, 120, 120]),
totalWidth: ref(360),
columnVars: ref({ "--total-w": "360px" }),
getIsResizing: () => false,
}),
};
});
import DataGrid from "../DataGrid.vue";
import { useSettingsStore } from "@/stores/settingsStore";
const mountedApps: Array<{ app: App; host: HTMLElement }> = [];
async function settle() {
await nextTick();
await Promise.resolve();
await nextTick();
}
function mountGrid() {
const pinia = createPinia();
setActivePinia(pinia);
const settingsStore = useSettingsStore();
settingsStore.updateEditorSettings({ dataGridRenderMode: "dom", dataGridMultiRowTranspose: false });
const result = ref<QueryResult>(
markRaw({
columns: ["id", "name", "notes"],
column_types: ["int", "varchar", "varchar"],
rows: [
["1", "Ada", "first"],
["2", "Grace", "second"],
["3", "Linus", "third"],
],
affected_rows: 0,
execution_time_ms: 0,
}),
);
const host = document.createElement("div");
document.body.append(host);
const Root = defineComponent({
setup() {
return () =>
h(
TooltipProvider,
{ delayDuration: 0 },
{
default: () =>
h(DataGrid, {
result: result.value,
databaseType: "mysql",
context: "table-data",
tableMeta: {
tableName: "people",
columns: [
{ name: "id", data_type: "int", is_nullable: false, column_default: null, is_primary_key: true, extra: null },
{ name: "name", data_type: "varchar", is_nullable: true, column_default: null, is_primary_key: false, extra: null },
{ name: "notes", data_type: "varchar", is_nullable: true, column_default: null, is_primary_key: false, extra: null },
],
primaryKeys: ["id"],
},
}),
},
);
},
});
const app = createApp(Root);
app.use(pinia);
app.use(i18n);
app.mount(host);
mountedApps.push({ app, host });
return { host };
}
async function openTranspose(host: HTMLElement) {
const firstRowNumber = host.querySelector<HTMLElement>(".data-grid-row-number");
if (!firstRowNumber) throw new Error("Row number not found");
firstRowNumber.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0 }));
window.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true, button: 0 }));
firstRowNumber.dispatchEvent(new MouseEvent("dblclick", { bubbles: true, cancelable: true, button: 0 }));
await settle();
}
afterEach(() => {
for (const { app, host } of mountedApps.splice(0)) {
app.unmount();
host.remove();
}
scrollToItem.mockClear();
});
describe("DataGrid transpose cell click scrolling", () => {
it("asks for nearest alignment so the clicked field stays in place", async () => {
const { host } = mountGrid();
await settle();
await openTranspose(host);
const fieldRows = host.querySelectorAll<HTMLElement>(".data-grid-transpose-row");
const secondFieldCell = fieldRows[1]?.querySelector<HTMLElement>("[data-grid-transpose-cell]");
if (!secondFieldCell) throw new Error("Transpose cell not found");
secondFieldCell.click();
await settle();
expect(scrollToItem).toHaveBeenLastCalledWith(1, { align: "nearest" });
});
});
@@ -368,3 +368,24 @@ export function transposeScrollLeftForRecord(options: TransposeScrollLeftOptions
const maxScrollLeft = Math.max(0, totalWidth - options.viewportWidth);
return Math.max(0, Math.min(desired, maxScrollLeft));
}
export interface TransposeScrollTopForFieldOptions {
fieldIndex: number;
totalFields: number;
rowHeight: number;
viewportHeight: number;
currentScrollTop?: number;
alignment?: TransposeScrollAlignment;
}
export function transposeScrollTopForField(options: TransposeScrollTopForFieldOptions): number {
if (options.rowHeight <= 0 || options.totalFields <= 0) return 0;
const fieldIndex = Math.max(0, Math.min(options.totalFields - 1, options.fieldIndex));
const fieldStart = fieldIndex * options.rowHeight;
const fieldEnd = fieldStart + options.rowHeight;
const viewportHeight = Math.max(0, options.viewportHeight);
const currentScrollTop = Math.max(0, options.currentScrollTop ?? fieldStart);
const desired = options.alignment === "start" ? fieldStart : fieldStart < currentScrollTop ? fieldStart : fieldEnd > currentScrollTop + viewportHeight ? fieldEnd - viewportHeight : currentScrollTop;
const maxScrollTop = Math.max(0, options.totalFields * options.rowHeight - viewportHeight);
return Math.max(0, Math.min(desired, maxScrollTop));
}
+94
View File
@@ -13,6 +13,7 @@ import {
transposeEndAlignmentSpacerWidth,
transposeFieldWidth,
transposeScrollLeftForRecord,
transposeScrollTopForField,
visibleTransposeRecordWindow,
} from "../../apps/desktop/src/lib/dataGrid/dataGridTranspose.ts";
@@ -341,3 +342,96 @@ test("start alignment uses trailing space to place the final variable-width reco
596,
);
});
test("nearest alignment keeps an already visible transpose field in place", () => {
assert.equal(
transposeScrollTopForField({
fieldIndex: 4,
totalFields: 20,
rowHeight: 30,
viewportHeight: 300,
currentScrollTop: 90,
alignment: "nearest",
}),
90,
);
});
test("nearest alignment scrolls a field above the viewport back to its start", () => {
assert.equal(
transposeScrollTopForField({
fieldIndex: 1,
totalFields: 20,
rowHeight: 30,
viewportHeight: 300,
currentScrollTop: 900,
alignment: "nearest",
}),
30,
);
});
test("nearest alignment scrolls a field below the viewport just into view", () => {
assert.equal(
transposeScrollTopForField({
fieldIndex: 15,
totalFields: 20,
rowHeight: 30,
viewportHeight: 300,
currentScrollTop: 90,
alignment: "nearest",
}),
180,
);
});
test("start alignment places the transpose field at the top of the viewport", () => {
assert.equal(
transposeScrollTopForField({
fieldIndex: 7,
totalFields: 20,
rowHeight: 30,
viewportHeight: 300,
currentScrollTop: 0,
alignment: "start",
}),
210,
);
});
test("clamps the transpose field scroll to the end of the field list", () => {
assert.equal(
transposeScrollTopForField({
fieldIndex: 19,
totalFields: 20,
rowHeight: 30,
viewportHeight: 300,
currentScrollTop: 0,
alignment: "start",
}),
300,
);
});
test("ignores transpose field scrolling when there is nothing to scroll", () => {
assert.equal(
transposeScrollTopForField({
fieldIndex: 0,
totalFields: 0,
rowHeight: 30,
viewportHeight: 300,
currentScrollTop: 0,
}),
0,
);
assert.equal(
transposeScrollTopForField({
fieldIndex: 0,
totalFields: 20,
rowHeight: 0,
viewportHeight: 300,
currentScrollTop: 0,
}),
0,
);
});