feat(data-grid): improve transpose view record location and last-column alignment

This commit is contained in:
二丫讲梵 2026-08-09 14:14:08 +08:00 committed by GitHub
parent 72ff141f02
commit b1fcb23625
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 198 additions and 23 deletions

View File

@ -110,9 +110,11 @@ import {
shouldAutoTransposeSingleRow,
transposeRecordIndexesForMode,
transposeRecordWidthsForDensity,
transposeEndAlignmentSpacerWidth,
transposeFieldWidth,
transposeScrollLeftForRecord,
visibleTransposeRecordWindow,
type TransposeScrollAlignment,
} from "@/lib/dataGrid/dataGridTranspose";
import { canApplyGridSelectionValue, canDeleteGridRowItem, canEditGridCellDetail, matchesRowStatusFilter, shouldShowQuickEntryDraftRow, type RowStatus, type RowStatusFilter } from "@/lib/dataGrid/gridRowStatus";
import { displayCellValue, firstLineCellDisplayValue, limitDataGridCellDisplay, SQLSERVER_DATA_GRID_CELL_DISPLAY_MAX_LENGTH, type CellValue } from "@/lib/dataGrid/cellValue";
@ -454,7 +456,7 @@ watch(
if (!shouldAutoTransposeSingleRow({ enabled: !!props.autoTransposeSingleRow, preserveTranspose: preserveTransposeOnNextResult.value, rowCount: result.rows.length, columnCount: result.columns.length })) return;
nextTick(() => {
if (props.result !== result || !props.autoTransposeSingleRow || result.rows.length !== 1 || result.columns.length <= 1) return;
applyTransposeState({ showTranspose: true, transposeRowIndex: 0 });
applyTransposeState({ showTranspose: true, transposeRowIndex: 0 }, "start");
});
},
{ immediate: true },
@ -1728,6 +1730,11 @@ let gridVerticalScrollbarThumbHeightPercent = 100;
let gridScrollbarsRuntime: DataGridScrollbarsRuntime;
let dataGridTopbarResizeObserver: ResizeObserver | null = null;
let cellEditResizeObserver: ResizeObserver | null = null;
// vue-virtual-scroller's @resize only fires in pageMode (it observes window),
// so in container-scroll mode (transpose uses RecycleScroller without pageMode)
// container size changes from window resize / sidebar drag never reach
// updateTransposeViewport. Observe the scroller element directly instead.
let transposeViewportResizeObserver: ResizeObserver | null = null;
let resetCellEditTextareaScrollOnResize = false;
let gridHorizontalScrollbarDragState: {
scroller: HTMLElement;
@ -5637,6 +5644,7 @@ function pauseCanvasGridWork() {
disconnectCellEditResizeObserver();
dataGridTopbarResizeObserver?.disconnect();
dataGridTopbarResizeObserver = null;
disconnectTransposeViewportObserver();
canvasPixelRatioMediaQueryCleanup?.();
canvasPixelRatioMediaQueryCleanup = null;
canvasPixelRatioMediaQuery = null;
@ -5652,6 +5660,7 @@ function resumeCanvasGridWork() {
nextTick(() => {
attachCanvasResizeObserver();
observeDataGridTopbarWidth();
observeTransposeViewport();
refreshGridScrollerMetrics();
observeGridHorizontalScrollbarScroller();
});
@ -5704,6 +5713,7 @@ onUnmounted(() => {
gridScrollbarsRuntime.dispose();
dataGridTopbarResizeObserver?.disconnect();
disconnectCellEditResizeObserver();
disconnectTransposeViewportObserver();
stopGridHorizontalScrollbarDrag();
stopGridVerticalScrollbarDrag();
if (columnLayoutRefreshFrame) cancelAnimationFrame(columnLayoutRefreshFrame);
@ -6613,7 +6623,7 @@ function toggleKeyboardTranspose(): boolean {
if (next.showTranspose) {
closeCellDetails();
nextTick(updateTransposeViewport);
if (next.transposeRowIndex !== null) scrollTransposeRecordIntoView(next.transposeRowIndex);
if (next.transposeRowIndex !== null) scrollTransposeRecordIntoView(next.transposeRowIndex, "start");
} else if (!restoreGridScrollTopAfterTranspose) {
scrollGridRowIntoView(requestedRowIndex);
}
@ -7131,6 +7141,14 @@ watch(columnWidthDensity, () => {
});
const transposePinnedWidthOverride = ref<number | null>(null);
const transposePinnedWidth = computed(() => transposePinnedWidthOverride.value ?? transposeFieldWidth(visibleColumns.value, { density: columnWidthDensity.value }));
const transposeEndSpacerWidth = computed(() => {
if (!multiRowTranspose.value || displayRowCount.value <= 0) return 0;
return transposeEndAlignmentSpacerWidth({
viewportWidth: transposeViewportWidth.value,
pinnedWidth: transposePinnedWidth.value,
lastRecordWidth: getTransposeRecordWidth(displayRowCount.value - 1),
});
});
const transposeRecordWindow = computed(() =>
visibleTransposeRecordWindow({
@ -7156,7 +7174,7 @@ const activeTransposeRecordIndexes = computed(() =>
}),
);
const transposeBeforeSpacerWidth = computed(() => (multiRowTranspose.value ? transposeRecordWindow.value.beforeWidth : 0));
const transposeAfterSpacerWidth = computed(() => (multiRowTranspose.value ? transposeRecordWindow.value.afterWidth : 0));
const transposeAfterSpacerWidth = computed(() => (multiRowTranspose.value ? transposeRecordWindow.value.afterWidth + transposeEndSpacerWidth.value : 0));
const transposeRows = computed(() => {
return buildVisibleTransposeRows({
columns: visibleColumns.value,
@ -7171,7 +7189,7 @@ const transposeRows = computed(() => {
const isTransposeMode = computed(() => showTranspose.value && transposeRows.value.length > 0);
const transposeTotalWidth = computed(() => {
const recordIndexes = multiRowTranspose.value ? Array.from({ length: displayRowCount.value }, (_, i) => i) : activeTransposeRecordIndexes.value;
return transposePinnedWidth.value + recordIndexes.reduce((sum, i) => sum + getTransposeRecordWidth(i), 0);
return transposePinnedWidth.value + recordIndexes.reduce((sum, i) => sum + getTransposeRecordWidth(i), 0) + (multiRowTranspose.value ? transposeEndSpacerWidth.value : 0);
});
function transposeScrollElement(): HTMLElement | undefined {
@ -7194,6 +7212,24 @@ function updateTransposeViewport() {
transposeViewportWidth.value = el.clientWidth;
}
function disconnectTransposeViewportObserver() {
transposeViewportResizeObserver?.disconnect();
transposeViewportResizeObserver = null;
}
// Attach a ResizeObserver on the transpose scroller so clientWidth is re-measured
// when the container resizes (window resize, sidebar drag). RecycleScroller's own
// @resize event does not fire outside pageMode, so this is the only reliable hook.
function observeTransposeViewport() {
disconnectTransposeViewportObserver();
if (typeof ResizeObserver === "undefined") return;
const el = transposeScrollElement();
if (!el) return;
updateTransposeViewport();
transposeViewportResizeObserver = new ResizeObserver(updateTransposeViewport);
transposeViewportResizeObserver.observe(el);
}
function onTransposeScroll() {
updateTransposeViewport();
const el = transposeScrollElement();
@ -7201,20 +7237,38 @@ function onTransposeScroll() {
markGridScrolling();
}
function scrollTransposeRecordIntoView(rowIndex: number) {
function scrollTransposeRecordIntoView(rowIndex: number, alignment: TransposeScrollAlignment = "nearest") {
nextTick(() => {
const el = transposeScrollElement();
if (!el) return;
el.scrollLeft = transposeScrollLeftForRecord({
recordIndex: rowIndex,
totalRecords: displayRowCount.value,
viewportWidth: el.clientWidth,
pinnedWidth: transposePinnedWidth.value,
recordWidth: estimatedTransposeRecordWidth(),
recordOffsets: transposeRecordOffsets.value,
currentScrollLeft: el.scrollLeft,
});
updateTransposeViewport();
// The measured viewport determines the end spacer. Wait for that width to
// render before assigning scrollLeft, otherwise the browser clamps against
// the pre-spacer scrollWidth and the final record cannot align at the start.
nextTick(() => {
const measuredEl = transposeScrollElement();
if (!measuredEl || displayRowCount.value <= 0) return;
if (alignment === "start" && !multiRowTranspose.value) {
measuredEl.scrollLeft = 0;
updateTransposeViewport();
return;
}
const activeRecordIndex = Math.max(0, Math.min(displayRowCount.value - 1, rowIndex));
const multiRow = multiRowTranspose.value;
const recordOffsets = multiRow ? transposeRecordOffsets.value : [0, getTransposeRecordWidth(activeRecordIndex)];
measuredEl.scrollLeft = transposeScrollLeftForRecord({
recordIndex: multiRow ? activeRecordIndex : 0,
totalRecords: multiRow ? displayRowCount.value : 1,
viewportWidth: measuredEl.clientWidth,
pinnedWidth: transposePinnedWidth.value,
recordWidth: multiRow ? estimatedTransposeRecordWidth() : getTransposeRecordWidth(activeRecordIndex),
recordOffsets,
currentScrollLeft: measuredEl.scrollLeft,
alignment,
endSpacerWidth: multiRow ? transposeEndSpacerWidth.value : 0,
});
updateTransposeViewport();
});
});
}
@ -7224,7 +7278,7 @@ function setMultiRowTranspose(value: boolean) {
if (!showTranspose.value) return;
nextTick(updateTransposeViewport);
if (value && transposeRowIndex.value !== null) {
scrollTransposeRecordIntoView(transposeRowIndex.value);
scrollTransposeRecordIntoView(transposeRowIndex.value, "start");
} else {
nextTick(() => {
const el = transposeScrollElement();
@ -7239,12 +7293,12 @@ function toggleMultiRowTranspose() {
setMultiRowTranspose(!multiRowTranspose.value);
}
function applyTransposeState(next: { showTranspose: boolean; transposeRowIndex: number | null }) {
function applyTransposeState(next: { showTranspose: boolean; transposeRowIndex: number | null }, alignment: TransposeScrollAlignment = "nearest") {
showTranspose.value = next.showTranspose;
transposeRowIndex.value = next.transposeRowIndex;
if (next.showTranspose) {
nextTick(updateTransposeViewport);
if (next.transposeRowIndex !== null) scrollTransposeRecordIntoView(next.transposeRowIndex);
if (next.transposeRowIndex !== null) scrollTransposeRecordIntoView(next.transposeRowIndex, alignment);
}
}
@ -7338,18 +7392,19 @@ function openContextTranspose() {
if (next.showTranspose) {
closeCellDetails();
nextTick(updateTransposeViewport);
if (next.transposeRowIndex !== null) scrollTransposeRecordIntoView(next.transposeRowIndex);
if (next.transposeRowIndex !== null) scrollTransposeRecordIntoView(next.transposeRowIndex, "start");
}
}
function toggleTranspose(rowIndex: number) {
const wasTransposeOpen = showTranspose.value;
const next = nextTransposeState(showTranspose.value, transposeRowIndex.value, rowIndex);
transposeRowIndex.value = next.transposeRowIndex;
showTranspose.value = next.showTranspose;
if (next.showTranspose) {
closeCellDetails();
nextTick(updateTransposeViewport);
if (next.transposeRowIndex !== null) scrollTransposeRecordIntoView(next.transposeRowIndex);
if (next.transposeRowIndex !== null) scrollTransposeRecordIntoView(next.transposeRowIndex, wasTransposeOpen ? "nearest" : "start");
} else {
scrollGridRowIntoView(rowIndex);
}
@ -7408,10 +7463,16 @@ function transposeNav(delta: number) {
watch(isTransposeMode, (active) => {
if (active) {
gridScrollLeftBeforeTranspose = gridScrollerElement()?.scrollLeft ?? gridHorizontalScrollLeft.value;
nextTick(updateTransposeViewport);
nextTick(() => {
updateTransposeViewport();
observeTransposeViewport();
});
return;
}
// The transpose scroller is v-if-removed by isTransposeMode; drop the observer
// before the element unmounts so it never observes a detached node.
disconnectTransposeViewportObserver();
const scrollTopBeforeTranspose = restoreGridScrollTopAfterTranspose ? (gridScrollTopBeforeKeyboardTranspose ?? undefined) : undefined;
restoreGridScrollTopAfterTranspose = false;
gridScrollTopBeforeKeyboardTranspose = null;
@ -9074,6 +9135,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
:buffer="400"
key-field="id"
@scroll="onTransposeScroll"
@resize="updateTransposeViewport"
>
<template #before>
<div class="data-grid-transpose-header data-grid-header-shell sticky top-0 z-20 flex h-7 border-b border-border font-semibold text-muted-foreground" :style="{ width: `${transposeTotalWidth}px` }">

View File

@ -7,6 +7,7 @@ import {
minTransposeFieldWidth,
shouldAutoTransposeSingleRow,
transposeAnchorRowIndex,
transposeEndAlignmentSpacerWidth,
transposeFieldWidth,
transposeScrollLeftForRecord,
transposeRecordWidthsForDensity,
@ -107,6 +108,39 @@ describe("dataGridTranspose density widths", () => {
});
describe("transpose record scrolling", () => {
it("aligns the fifth record at the start while nearest keeps an already-visible record in place", () => {
const endSpacerWidth = transposeEndAlignmentSpacerWidth({
viewportWidth: 1200,
pinnedWidth: 104,
lastRecordWidth: 168,
});
expect(endSpacerWidth).toBe(928);
expect(
transposeScrollLeftForRecord({
recordIndex: 4,
totalRecords: 5,
viewportWidth: 1200,
pinnedWidth: 104,
recordWidth: 168,
currentScrollLeft: 0,
alignment: "start",
endSpacerWidth,
}),
).toBe(672);
expect(
transposeScrollLeftForRecord({
recordIndex: 4,
totalRecords: 5,
viewportWidth: 1200,
pinnedWidth: 104,
recordWidth: 168,
currentScrollLeft: 0,
endSpacerWidth,
}),
).toBe(0);
});
it("uses the scroll position after the sticky field for virtualization", () => {
expect(
visibleTransposeRecordWindow({
@ -147,6 +181,42 @@ describe("transpose record scrolling", () => {
}),
).toBe(450);
});
it("uses the end spacer to align a variable-width final record exactly at the start", () => {
const recordOffsets = [0, 500, 596, 692];
const endSpacerWidth = transposeEndAlignmentSpacerWidth({
viewportWidth: 300,
pinnedWidth: 100,
lastRecordWidth: 96,
});
expect(endSpacerWidth).toBe(104);
expect(
transposeScrollLeftForRecord({
recordIndex: 2,
totalRecords: 3,
viewportWidth: 300,
pinnedWidth: 100,
recordWidth: 230,
recordOffsets,
currentScrollLeft: 0,
alignment: "start",
endSpacerWidth,
}),
).toBe(596);
expect(
transposeScrollLeftForRecord({
recordIndex: 2,
totalRecords: 3,
viewportWidth: 300,
pinnedWidth: 100,
recordWidth: 230,
recordOffsets,
currentScrollLeft: 596,
endSpacerWidth,
}),
).toBe(596);
});
});
describe("dataGridTranspose field metadata", () => {

View File

@ -166,6 +166,16 @@ export interface TransposeScrollLeftOptions {
recordWidth: number;
recordOffsets?: readonly number[];
currentScrollLeft?: number;
alignment?: TransposeScrollAlignment;
endSpacerWidth?: number;
}
export type TransposeScrollAlignment = "start" | "nearest";
export interface TransposeEndAlignmentSpacerOptions {
viewportWidth: number;
pinnedWidth: number;
lastRecordWidth: number;
}
export interface TransposeRecordIndexesForModeOptions {
@ -339,6 +349,11 @@ export function transposeFieldWidth(columns: string[], options: TransposeFieldWi
return Math.min(maxWidth, Math.max(minWidth, Math.ceil(longest * charWidth + padding)));
}
export function transposeEndAlignmentSpacerWidth(options: TransposeEndAlignmentSpacerOptions): number {
const recordViewportWidth = Math.max(0, options.viewportWidth - options.pinnedWidth);
return Math.max(0, recordViewportWidth - Math.max(0, options.lastRecordWidth));
}
export function transposeScrollLeftForRecord(options: TransposeScrollLeftOptions): number {
if (options.recordWidth <= 0 || options.totalRecords <= 0) return 0;
const recordIndex = Math.max(0, Math.min(options.totalRecords - 1, options.recordIndex));
@ -348,8 +363,8 @@ export function transposeScrollLeftForRecord(options: TransposeScrollLeftOptions
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 desired = options.alignment === "start" ? recordStart : recordStart < currentScrollLeft ? recordStart : recordEnd > currentScrollLeft + recordViewportWidth ? recordEnd - recordViewportWidth : currentScrollLeft;
const totalWidth = options.pinnedWidth + recordsWidth + Math.max(0, options.endSpacerWidth ?? 0);
const maxScrollLeft = Math.max(0, totalWidth - options.viewportWidth);
return Math.max(0, Math.min(desired, maxScrollLeft));
}

View File

@ -10,6 +10,7 @@ import {
nextTransposeState,
nextTransposeStateForRecordCount,
transposeAnchorRowIndex,
transposeEndAlignmentSpacerWidth,
transposeFieldWidth,
transposeScrollLeftForRecord,
visibleTransposeRecordWindow,
@ -301,7 +302,7 @@ test("caps the transpose field column width for long field names", () => {
assert.equal(transposeFieldWidth(["a_very_long_metric_column_name"]), 220);
});
test("aligns the selected transpose record at the start of the scrollable records", () => {
test("start alignment places the selected transpose record at its exact offset", () => {
assert.equal(
transposeScrollLeftForRecord({
recordIndex: 55,
@ -309,7 +310,34 @@ test("aligns the selected transpose record at the start of the scrollable record
viewportWidth: 1200,
pinnedWidth: 104,
recordWidth: 168,
currentScrollLeft: 0,
alignment: "start",
}),
9240,
);
});
test("start alignment uses trailing space to place the final variable-width record at the left edge", () => {
const recordOffsets = [0, 500, 596, 692];
const endSpacerWidth = transposeEndAlignmentSpacerWidth({
viewportWidth: 300,
pinnedWidth: 100,
lastRecordWidth: 96,
});
assert.equal(endSpacerWidth, 104);
assert.equal(
transposeScrollLeftForRecord({
recordIndex: 2,
totalRecords: 3,
viewportWidth: 300,
pinnedWidth: 100,
recordWidth: 230,
recordOffsets,
currentScrollLeft: 0,
alignment: "start",
endSpacerWidth,
}),
596,
);
});