fix(grid): stop transpose cell clicks from scrolling field to top
This commit is contained in:
1 parent
3def78fc5a
commit
da9040ede5
4 files changed
+278
-4
No files matched your search
@@ -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;
|
||||
|
||||
+149
@@ -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
@@ -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,
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user