fix(grid): improve transposed selection and navigation

This commit is contained in:
二丫讲梵 2026-07-31 14:48:07 +08:00 committed by GitHub
parent 2ec9328eea
commit 96038d2b8a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 169 additions and 37 deletions

View File

@ -5890,6 +5890,18 @@ function currentSelectedCellPosition() {
}
function scrollCellIntoView(rowIndex: number, colIndex: number) {
if (isTransposeMode.value) {
nextTick(() => {
const scroller = transposeScrollRef.value;
if (scroller && !(scroller instanceof HTMLElement)) {
(scroller as { scrollToItem?: (index: number) => void }).scrollToItem?.(colIndex);
} else if (scroller instanceof HTMLElement) {
scroller.scrollTop = colIndex * 30;
}
scrollTransposeRecordIntoView(rowIndex);
});
return;
}
nextTick(() => {
scrollGridColumnIntoView(colIndex);
if (useCanvasGridRows.value) {
@ -5961,9 +5973,22 @@ function scrollGridRowIntoView(rowIndex: number) {
});
}
function currentTransposeRequestedRowIndex(): number {
function selectedTransposeRowIndex(): number | null {
const position = currentSelectedCellPosition();
if (position) return position.rowIndex;
const lastSelectedRowIndex = selection.lastClickedRowIndex.value;
if (lastSelectedRowIndex !== null) {
const item = displayItemAt(lastSelectedRowIndex);
if (item && selectedRowIds.value.has(item.id)) return lastSelectedRowIndex;
}
const selectedRowIndex = displayRowRefs.value.findIndex((row) => selectedRowIds.value.has(row.id));
if (selectedRowIndex >= 0) return selectedRowIndex;
return null;
}
function currentTransposeRequestedRowIndex(): number {
const selectedRowIndex = selectedTransposeRowIndex();
if (selectedRowIndex !== null) return selectedRowIndex;
if (transposeRowIndex.value !== null) return transposeRowIndex.value;
return 0;
}
@ -6191,13 +6216,23 @@ async function onGridKeydown(event: KeyboardEvent) {
event.preventDefault();
return;
}
if (event.key === "ArrowLeft" && moveTransposeRecordSelection(-1)) {
event.preventDefault();
return;
}
if (event.key === "ArrowRight" && moveTransposeRecordSelection(1)) {
event.preventDefault();
return;
if (isTransposeMode.value) {
if (event.key === "ArrowUp" && moveSelectedCell(0, -1)) {
event.preventDefault();
return;
}
if (event.key === "ArrowDown" && moveSelectedCell(0, 1)) {
event.preventDefault();
return;
}
if (event.key === "ArrowLeft" && (moveSelectedCell(-1, 0) || moveTransposeRecordSelection(-1))) {
event.preventDefault();
return;
}
if (event.key === "ArrowRight" && (moveSelectedCell(1, 0) || moveTransposeRecordSelection(1))) {
event.preventDefault();
return;
}
}
if (event.key === "ArrowUp" && moveSelectedCell(-1, 0)) {
event.preventDefault();
@ -6374,6 +6409,13 @@ function copyColumnDetailFieldValue(field: DataGridCellDetail) {
const transposeRecordWidths = ref<number[]>([]);
const transposeManualRecordWidthIndexes = ref(new Set<number>());
const transposeRecordOffsets = computed(() => {
const offsets = [0];
for (let index = 0; index < displayRowCount.value; index += 1) {
offsets.push(offsets[index] + getTransposeRecordWidth(index));
}
return offsets;
});
function calcTransposeRecordWidth(recordIndex: number): number {
const item = displayItemAt(recordIndex);
@ -6424,6 +6466,7 @@ const transposeRecordWindow = computed(() =>
viewportWidth: transposeViewportWidth.value,
pinnedWidth: transposePinnedWidth.value,
recordWidth: estimatedTransposeRecordWidth(),
recordOffsets: transposeRecordOffsets.value,
overscan: 2,
}),
);
@ -6487,6 +6530,8 @@ function scrollTransposeRecordIntoView(rowIndex: number) {
viewportWidth: el.clientWidth,
pinnedWidth: transposePinnedWidth.value,
recordWidth: estimatedTransposeRecordWidth(),
recordOffsets: transposeRecordOffsets.value,
currentScrollLeft: el.scrollLeft,
});
updateTransposeViewport();
});
@ -6594,10 +6639,12 @@ function openContextTranspose() {
return;
}
if (!contextCell.value) return;
const selectedRowIndex = selectedTransposeRowIndex();
const requestedRowIndex = selectedRowIds.value.size === 1 && selectedRowIndex !== null ? selectedRowIndex : contextCell.value.rowIndex;
const next = nextContextTransposeState({
showTranspose: showTranspose.value,
transposeRowIndex: transposeRowIndex.value,
requestedRowIndex: contextCell.value.rowIndex,
requestedRowIndex,
rowIds: displayRowRefs.value.map((ref) => ref.id),
selectedRowIds: selectedRowIds.value,
selectedRange: selectedRange.value,

View File

@ -1,5 +1,16 @@
import { describe, expect, it } from "vitest";
import { averageTransposeRecordWidth, calculateTransposeRecordWidth, defaultTransposeRecordWidth, minTransposeFieldWidth, shouldAutoTransposeSingleRow, transposeFieldWidth, transposeRecordWidthsForDensity, visibleTransposeRecordWindow } from "@/lib/dataGrid/dataGridTranspose";
import {
averageTransposeRecordWidth,
calculateTransposeRecordWidth,
defaultTransposeRecordWidth,
minTransposeFieldWidth,
shouldAutoTransposeSingleRow,
transposeAnchorRowIndex,
transposeFieldWidth,
transposeScrollLeftForRecord,
transposeRecordWidthsForDensity,
visibleTransposeRecordWindow,
} from "@/lib/dataGrid/dataGridTranspose";
describe("single-row automatic transpose", () => {
it("only opens for enabled multi-column results that are not preserving a manual transpose", () => {
@ -12,6 +23,21 @@ describe("single-row automatic transpose", () => {
});
});
describe("transpose row anchor", () => {
it("keeps the requested row when multiple rows or cells are selected", () => {
const rowIds = [1, 2, 3, 4];
expect(
transposeAnchorRowIndex({
requestedRowIndex: 3,
rowIds,
selectedRowIds: new Set([1, 4]),
selectedRange: { startRow: 0, endRow: 3, startCol: 0, endCol: 1 },
}),
).toBe(3);
});
});
describe("dataGridTranspose density widths", () => {
it("uses the shared density preset for record and field widths", () => {
const values = ["x".repeat(40)];
@ -78,3 +104,46 @@ describe("dataGridTranspose density widths", () => {
expect(averageTransposeRecordWidth([], "compact")).toBe(defaultTransposeRecordWidth("compact"));
});
});
describe("transpose record scrolling", () => {
it("uses the scroll position after the sticky field for virtualization", () => {
expect(
visibleTransposeRecordWindow({
totalRecords: 3,
scrollLeft: 500,
viewportWidth: 300,
pinnedWidth: 100,
recordWidth: 230,
recordOffsets: [0, 500, 596, 692],
overscan: 0,
}),
).toEqual({ start: 1, end: 3, beforeWidth: 500, afterWidth: 0 });
});
it("uses actual record widths and nearest scrolling", () => {
const recordOffsets = [0, 500, 596, 692];
expect(
transposeScrollLeftForRecord({
recordIndex: 2,
totalRecords: 3,
viewportWidth: 300,
pinnedWidth: 100,
recordWidth: 230,
recordOffsets,
currentScrollLeft: 0,
}),
).toBe(492);
expect(
transposeScrollLeftForRecord({
recordIndex: 1,
totalRecords: 3,
viewportWidth: 300,
pinnedWidth: 100,
recordWidth: 230,
recordOffsets,
currentScrollLeft: 450,
}),
).toBe(450);
});
});

View File

@ -50,6 +50,7 @@ export interface TransposeRecordWindowOptions {
viewportWidth: number;
pinnedWidth: number;
recordWidth: number;
recordOffsets?: readonly number[];
overscan?: number;
}
@ -159,6 +160,8 @@ export interface TransposeScrollLeftOptions {
viewportWidth: number;
pinnedWidth: number;
recordWidth: number;
recordOffsets?: readonly number[];
currentScrollLeft?: number;
}
export interface TransposeRecordIndexesForModeOptions {
@ -269,8 +272,25 @@ export function visibleTransposeRecordWindow(options: TransposeRecordWindowOptio
}
const overscan = options.overscan ?? 2;
const recordScrollLeft = Math.max(0, options.scrollLeft - options.pinnedWidth);
const recordScrollLeft = Math.max(0, options.scrollLeft);
const recordViewportWidth = Math.max(0, options.viewportWidth - options.pinnedWidth);
if (options.recordOffsets?.length === options.totalRecords + 1) {
const offsets = options.recordOffsets;
const firstVisible = Math.max(
0,
offsets.findIndex((_offset, index) => index < options.totalRecords && offsets[index + 1] > recordScrollLeft),
);
const firstAfterViewport = offsets.findIndex((offset, index) => index > firstVisible && offset >= recordScrollLeft + recordViewportWidth);
const start = Math.max(0, firstVisible - overscan);
const end = Math.min(options.totalRecords, (firstAfterViewport < 0 ? options.totalRecords : firstAfterViewport) + overscan);
const totalWidth = offsets[options.totalRecords];
return {
start,
end,
beforeWidth: offsets[start],
afterWidth: Math.max(0, totalWidth - offsets[end]),
};
}
const start = Math.max(0, Math.floor(recordScrollLeft / options.recordWidth) - overscan);
const end = Math.min(options.totalRecords, Math.ceil((recordScrollLeft + recordViewportWidth) / options.recordWidth) + overscan + 1);
@ -290,18 +310,7 @@ export function transposeRecordIndexesForMode(options: TransposeRecordIndexesFor
}
export function transposeAnchorRowIndex(options: TransposeAnchorOptions): number {
const requestedRowId = options.rowIds[options.requestedRowIndex];
if (requestedRowId !== undefined && options.selectedRowIds.size > 1 && options.selectedRowIds.has(requestedRowId)) {
const firstSelectedIndex = options.rowIds.findIndex((rowId) => options.selectedRowIds.has(rowId));
if (firstSelectedIndex >= 0) return firstSelectedIndex;
}
const range = options.selectedRange;
if (range && range.startRow !== range.endRow && options.requestedRowIndex >= range.startRow && options.requestedRowIndex <= range.endRow) {
return range.startRow;
}
return options.requestedRowIndex;
return Math.max(0, Math.min(options.rowIds.length - 1, options.requestedRowIndex));
}
export function transposeFieldWidth(columns: string[], options: TransposeFieldWidthOptions = {}): number {
@ -317,8 +326,15 @@ export function transposeFieldWidth(columns: string[], options: TransposeFieldWi
export function transposeScrollLeftForRecord(options: TransposeScrollLeftOptions): number {
if (options.recordWidth <= 0 || options.totalRecords <= 0) return 0;
const desired = Math.max(0, options.recordIndex) * options.recordWidth;
const totalWidth = options.pinnedWidth + options.totalRecords * options.recordWidth;
const recordIndex = Math.max(0, Math.min(options.totalRecords - 1, options.recordIndex));
const offsets = options.recordOffsets?.length === options.totalRecords + 1 ? options.recordOffsets : undefined;
const recordStart = offsets ? offsets[recordIndex] : recordIndex * options.recordWidth;
const recordEnd = offsets ? offsets[recordIndex + 1] : recordStart + options.recordWidth;
const recordsWidth = offsets ? offsets[options.totalRecords] : options.totalRecords * options.recordWidth;
const recordViewportWidth = Math.max(0, options.viewportWidth - options.pinnedWidth);
const currentScrollLeft = Math.max(0, options.currentScrollLeft ?? recordStart);
const desired = recordStart < currentScrollLeft ? recordStart : recordEnd > currentScrollLeft + recordViewportWidth ? recordEnd - recordViewportWidth : currentScrollLeft;
const totalWidth = options.pinnedWidth + recordsWidth;
const maxScrollLeft = Math.max(0, totalWidth - options.viewportWidth);
return Math.min(desired, maxScrollLeft);
return Math.max(0, Math.min(desired, maxScrollLeft));
}

View File

@ -36,7 +36,7 @@ test("row number double click closes transpose for the same row", () => {
});
});
test("context menu transpose closes when invoked for the current anchor row", () => {
test("context menu transpose keeps the requested row when multiple rows are selected", () => {
assert.deepEqual(
nextContextTransposeState({
showTranspose: true,
@ -47,8 +47,8 @@ test("context menu transpose closes when invoked for the current anchor row", ()
selectedRange: null,
}),
{
showTranspose: false,
transposeRowIndex: null,
showTranspose: true,
transposeRowIndex: 3,
},
);
});
@ -256,15 +256,15 @@ test("calculates a horizontal record window with spacer widths", () => {
overscan: 1,
}),
{
start: 1,
end: 7,
beforeWidth: 160,
afterWidth: 14880,
start: 3,
end: 9,
beforeWidth: 480,
afterWidth: 14560,
},
);
});
test("uses the first selected row as the transpose anchor when context row is inside row selection", () => {
test("keeps the requested row as the transpose anchor when context row is inside row selection", () => {
assert.equal(
transposeAnchorRowIndex({
requestedRowIndex: 3,
@ -272,11 +272,11 @@ test("uses the first selected row as the transpose anchor when context row is in
selectedRowIds: new Set([12, 13, 14]),
selectedRange: null,
}),
2,
3,
);
});
test("uses the first selected cell range row as the transpose anchor when context row is inside range", () => {
test("keeps the requested row as the transpose anchor when context row is inside range", () => {
assert.equal(
transposeAnchorRowIndex({
requestedRowIndex: 5,
@ -284,7 +284,7 @@ test("uses the first selected cell range row as the transpose anchor when contex
selectedRowIds: new Set(),
selectedRange: { startRow: 2, endRow: 5, startCol: 0, endCol: 2 },
}),
2,
5,
);
});