diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 2bf905c8c9..4123115140 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -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; diff --git a/apps/desktop/src/components/grid/__tests__/DataGridTransposeCellClickScroll.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridTransposeCellClickScroll.spec.ts new file mode 100644 index 0000000000..c8160c9f24 --- /dev/null +++ b/apps/desktop/src/components/grid/__tests__/DataGridTransposeCellClickScroll.spec.ts @@ -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, + 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(); + 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( + 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(".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(".data-grid-transpose-row"); + const secondFieldCell = fieldRows[1]?.querySelector("[data-grid-transpose-cell]"); + if (!secondFieldCell) throw new Error("Transpose cell not found"); + + secondFieldCell.click(); + await settle(); + + expect(scrollToItem).toHaveBeenLastCalledWith(1, { align: "nearest" }); + }); +}); diff --git a/apps/desktop/src/lib/dataGrid/dataGridTranspose.ts b/apps/desktop/src/lib/dataGrid/dataGridTranspose.ts index 5d2e402ceb..7d5a85df59 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridTranspose.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridTranspose.ts @@ -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)); +} diff --git a/packages/app-tests/dataGridTranspose.test.ts b/packages/app-tests/dataGridTranspose.test.ts index f8a31f9785..4addffb072 100644 --- a/packages/app-tests/dataGridTranspose.test.ts +++ b/packages/app-tests/dataGridTranspose.test.ts @@ -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, + ); +});