feat(data-grid): improve transpose view record location and last-column alignment
This commit is contained in:
parent
72ff141f02
commit
b1fcb23625
|
|
@ -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` }">
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue