fix(grid): improve column drag scrolling and selection
This commit is contained in:
1 parent
7eac5dd610
commit
e4b9e55883
9 files changed
+539
-16
No files matched your search
@@ -2123,6 +2123,16 @@ const gridViewportWidth = ref(0);
|
||||
let gridScrollLeftBeforeTranspose = 0;
|
||||
let gridScrollTopBeforeKeyboardTranspose: number | null = null;
|
||||
let restoreGridScrollTopAfterTranspose = false;
|
||||
|
||||
function persistDraggedColumnOrder(indexes: number[]) {
|
||||
const previousVisibleColumnIndexes = [...visibleColumnIndexes.value];
|
||||
persistColumnOrder(indexes);
|
||||
selection.remapColumnSelection(
|
||||
previousVisibleColumnIndexes,
|
||||
indexes.filter((index) => !hiddenColumnIndexes.value.has(index)),
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
renderedColumnOffsets,
|
||||
horizontalColumnWindow,
|
||||
@@ -2147,14 +2157,19 @@ const {
|
||||
viewportWidth: gridViewportWidth,
|
||||
rowNumberWidth,
|
||||
headerRef,
|
||||
getScrollElement: gridScrollerElement,
|
||||
orderedColumnIndexes: orderedDisplayableColumnIndexes,
|
||||
hiddenColumnIndexes,
|
||||
getIsResizing,
|
||||
onResizeStart,
|
||||
onCanvasMouseLeave,
|
||||
onCanvasDrawSchedule: scheduleCanvasDraw,
|
||||
onHorizontalScroll: (scroller) => {
|
||||
updateGridHorizontalViewport(scroller);
|
||||
if (headerRef.value) headerRef.value.scrollLeft = scroller.scrollLeft;
|
||||
},
|
||||
onRefreshMetrics: scheduleColumnLayoutRefresh,
|
||||
onPersistColumnOrder: persistColumnOrder,
|
||||
onPersistColumnOrder: persistDraggedColumnOrder,
|
||||
frozenColumnCount,
|
||||
});
|
||||
|
||||
|
||||
@@ -20,9 +20,11 @@ const props = withDefaults(
|
||||
defineProps<{
|
||||
grid?: DataGridColumnLayoutHandle;
|
||||
triggerClass?: string;
|
||||
compact?: boolean;
|
||||
}>(),
|
||||
{
|
||||
triggerClass: "",
|
||||
compact: false,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -299,14 +301,14 @@ onBeforeUnmount(resetColumnDragState);
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-5 shrink-0 gap-1 px-1.5 text-xs text-foreground hover:bg-accent"
|
||||
:class="[triggerClass, { 'bg-accent text-foreground': (grid?.hiddenColumnCount ?? 0) > 0 }]"
|
||||
:class="[triggerClass, compact ? 'w-6 gap-0 px-0' : '', { 'bg-accent text-foreground': (grid?.hiddenColumnCount ?? 0) > 0 }]"
|
||||
:disabled="!grid"
|
||||
:title="t('grid.columnVisibility')"
|
||||
:aria-label="t('grid.columnVisibility')"
|
||||
>
|
||||
<Columns3 class="h-3.5 w-3.5" />
|
||||
{{ t("grid.columnVisibility") }}
|
||||
<span v-if="(grid?.hiddenColumnCount ?? 0) > 0" class="tabular-nums"> {{ grid?.visibleColumnCount }}/{{ grid?.displayableColumnCount }} </span>
|
||||
<span v-if="!compact">{{ t("grid.columnVisibility") }}</span>
|
||||
<span v-if="!compact && (grid?.hiddenColumnCount ?? 0) > 0" class="tabular-nums"> {{ grid?.visibleColumnCount }}/{{ grid?.displayableColumnCount }} </span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" class="w-72 max-w-[calc(100vw-2rem)] gap-0 overflow-hidden rounded-md border bg-popover p-0 text-popover-foreground shadow-xl" @click.stop @keydown.stop>
|
||||
|
||||
+21
@@ -1,5 +1,7 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createApp, nextTick, type App } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "@/i18n";
|
||||
@@ -39,6 +41,8 @@ vi.mock("@/components/ui/button", async () => {
|
||||
|
||||
import DataGridColumnLayoutPopover from "../DataGridColumnLayoutPopover.vue";
|
||||
|
||||
const contentAreaSource = readFileSync(path.resolve(process.cwd(), "apps/desktop/src/components/layout/ContentArea.vue"), "utf8");
|
||||
|
||||
const mountedApps: Array<{ app: App; host: HTMLElement }> = [];
|
||||
|
||||
function columnLayoutOptions(itemCount: number): DataGridColumnLayoutOption[] {
|
||||
@@ -137,6 +141,23 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("data grid column layout popover", () => {
|
||||
it("is available from the query result toolbar and keeps a labelled compact trigger", async () => {
|
||||
expect(contentAreaSource).toContain('<DataGridColumnLayoutPopover :grid="dataGridRef" :compact="compact" />');
|
||||
|
||||
const gridState = createGrid(4);
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const app = createApp(DataGridColumnLayoutPopover, { grid: gridState.grid, compact: true });
|
||||
app.use(i18n);
|
||||
app.mount(host);
|
||||
mountedApps.push({ app, host });
|
||||
await nextTick();
|
||||
|
||||
const trigger = [...host.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.getAttribute("aria-label") === i18n.global.t("grid.columnVisibility"));
|
||||
expect(trigger?.className).toContain("w-6");
|
||||
expect(trigger?.textContent?.trim()).toBe("");
|
||||
});
|
||||
|
||||
it("renders only the visible field window plus a bounded buffer", () => {
|
||||
const window = dataGridColumnLayoutVirtualWindow({
|
||||
itemCount: 500,
|
||||
|
||||
@@ -1622,6 +1622,7 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
|
||||
</template>
|
||||
</template>
|
||||
<template #result-toolbar-actions="{ compact }">
|
||||
<DataGridColumnLayoutPopover :grid="dataGridRef" :compact="compact" />
|
||||
<QueryResultToolbarActions
|
||||
:active-view="activeOutputView"
|
||||
:can-show-explain="canShowExplainOutput"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { effectScope, nextTick, ref } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { dataGridColumnOffsets, dataGridHorizontalColumnWindow, useDataGridColumnLayout, useDataGridColumnLayoutState } from "@/composables/useDataGridColumnLayout";
|
||||
import { columnHeaderDragAutoScrollDelta, columnHeaderDropTargetIndex } from "@/lib/dataGrid/dataGridColumnHeaderInteraction";
|
||||
import { loadDataGridColumnLayout, saveDataGridColumnLayout } from "@/lib/dataGrid/dataGridColumnLayoutStorage";
|
||||
|
||||
describe("useDataGridColumnLayout", () => {
|
||||
@@ -15,7 +16,11 @@ describe("useDataGridColumnLayout", () => {
|
||||
removeItem: (key: string) => values.delete(key),
|
||||
});
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
afterEach(() => {
|
||||
window.dispatchEvent(new Event("blur"));
|
||||
document.querySelectorAll("[data-column-header-drag-preview]").forEach((element) => element.remove());
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
it("builds cumulative offsets", () => {
|
||||
expect(dataGridColumnOffsets([80, 120, 60])).toEqual([0, 80, 200, 260]);
|
||||
});
|
||||
@@ -35,6 +40,47 @@ describe("useDataGridColumnLayout", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("accelerates column drag scrolling toward the viewport edges", () => {
|
||||
expect(columnHeaderDragAutoScrollDelta({ clientX: 100, viewportLeft: 0, viewportRight: 400 })).toBe(0);
|
||||
expect(columnHeaderDragAutoScrollDelta({ clientX: 32, viewportLeft: 0, viewportRight: 400 })).toBeLessThan(0);
|
||||
expect(columnHeaderDragAutoScrollDelta({ clientX: -10, viewportLeft: 0, viewportRight: 400 })).toBe(-24);
|
||||
expect(columnHeaderDragAutoScrollDelta({ clientX: 368, viewportLeft: 0, viewportRight: 400 })).toBeGreaterThan(0);
|
||||
expect(columnHeaderDragAutoScrollDelta({ clientX: 410, viewportLeft: 0, viewportRight: 400 })).toBe(24);
|
||||
expect(columnHeaderDragAutoScrollDelta({ clientX: 410, viewportLeft: 0, viewportRight: 400, maxStep: 0 })).toBe(0);
|
||||
expect(columnHeaderDragAutoScrollDelta({ clientX: 85, viewportLeft: 50, viewportRight: 100 })).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("calculates drag targets after excluding the source column", () => {
|
||||
const state = {
|
||||
columnWidths: [100, 100, 100, 100],
|
||||
columnOffsets: [0, 100, 200, 300, 400],
|
||||
};
|
||||
|
||||
expect(columnHeaderDropTargetIndex({ ...state, sourceVisibleIndex: 0, currentTargetIndex: 0, direction: 1, pointerContentX: 150 })).toBe(0);
|
||||
expect(columnHeaderDropTargetIndex({ ...state, sourceVisibleIndex: 0, currentTargetIndex: 0, direction: 1, pointerContentX: 151 })).toBe(1);
|
||||
expect(columnHeaderDropTargetIndex({ ...state, sourceVisibleIndex: 0, currentTargetIndex: 0, direction: 1, pointerContentX: 251 })).toBe(2);
|
||||
expect(columnHeaderDropTargetIndex({ ...state, sourceVisibleIndex: 3, currentTargetIndex: 3, direction: -1, pointerContentX: 250 })).toBe(3);
|
||||
expect(columnHeaderDropTargetIndex({ ...state, sourceVisibleIndex: 3, currentTargetIndex: 3, direction: -1, pointerContentX: 249 })).toBe(2);
|
||||
});
|
||||
|
||||
it("uses the shifted column midpoint when reversing direction", () => {
|
||||
const state = {
|
||||
sourceVisibleIndex: 0,
|
||||
columnWidths: [100, 100, 100, 100],
|
||||
columnOffsets: [0, 100, 200, 300, 400],
|
||||
};
|
||||
|
||||
expect(columnHeaderDropTargetIndex({ ...state, currentTargetIndex: 2, direction: -1, pointerContentX: 151 })).toBe(2);
|
||||
expect(columnHeaderDropTargetIndex({ ...state, currentTargetIndex: 2, direction: -1, pointerContentX: 150 })).toBe(2);
|
||||
expect(columnHeaderDropTargetIndex({ ...state, currentTargetIndex: 2, direction: -1, pointerContentX: 149 })).toBe(1);
|
||||
expect(columnHeaderDropTargetIndex({ ...state, currentTargetIndex: 1, direction: -1, pointerContentX: 51 })).toBe(1);
|
||||
expect(columnHeaderDropTargetIndex({ ...state, currentTargetIndex: 1, direction: -1, pointerContentX: 49 })).toBe(0);
|
||||
|
||||
expect(columnHeaderDropTargetIndex({ ...state, sourceVisibleIndex: 3, currentTargetIndex: 1, direction: 1, pointerContentX: 249 })).toBe(1);
|
||||
expect(columnHeaderDropTargetIndex({ ...state, sourceVisibleIndex: 3, currentTargetIndex: 1, direction: 1, pointerContentX: 250 })).toBe(1);
|
||||
expect(columnHeaderDropTargetIndex({ ...state, sourceVisibleIndex: 3, currentTargetIndex: 1, direction: 1, pointerContentX: 251 })).toBe(2);
|
||||
});
|
||||
|
||||
it("owns visibility, null-column toggles, and persisted ordering", () => {
|
||||
const scope = effectScope();
|
||||
const state = scope.run(() =>
|
||||
@@ -253,7 +299,7 @@ describe("useDataGridColumnLayout", () => {
|
||||
"requestAnimationFrame",
|
||||
vi.fn((callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
return 0;
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("cancelAnimationFrame", vi.fn());
|
||||
@@ -297,6 +343,244 @@ describe("useDataGridColumnLayout", () => {
|
||||
expect(persist).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("delays rightward sibling previews until the dragged column reaches the target slot", () => {
|
||||
vi.stubGlobal(
|
||||
"requestAnimationFrame",
|
||||
vi.fn((callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 0;
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("cancelAnimationFrame", vi.fn());
|
||||
|
||||
const header = document.createElement("div");
|
||||
for (let index = 0; index < 3; index++) {
|
||||
const column = document.createElement("div");
|
||||
column.dataset.visibleColIndex = String(index);
|
||||
column.getBoundingClientRect = () => ({ left: index * 100, width: 100, right: (index + 1) * 100, top: 0, bottom: 20, height: 20, x: index * 100, y: 0, toJSON: () => ({}) });
|
||||
header.append(column);
|
||||
}
|
||||
|
||||
const scope = effectScope();
|
||||
const layout = scope.run(() =>
|
||||
useDataGridColumnLayout({
|
||||
columnNames: ref(["a", "b", "c"]),
|
||||
visibleColumnIndexes: ref([0, 1, 2]),
|
||||
renderedColumnWidths: ref([100, 100, 100]),
|
||||
scrollLeft: ref(0),
|
||||
viewportWidth: ref(300),
|
||||
rowNumberWidth: 0,
|
||||
headerRef: ref(header),
|
||||
}),
|
||||
)!;
|
||||
|
||||
layout.startColumnHeaderDrag(0, new PointerEvent("pointerdown", { button: 0, clientX: 50, clientY: 10 }));
|
||||
window.dispatchEvent(new PointerEvent("pointermove", { clientX: 120, clientY: 10 }));
|
||||
expect(layout.columnHeaderPreviewOffsets.value).toEqual([70, 0, 0]);
|
||||
|
||||
window.dispatchEvent(new PointerEvent("pointermove", { clientX: 150, clientY: 10 }));
|
||||
expect(layout.columnHeaderPreviewOffsets.value).toEqual([100, 0, 0]);
|
||||
|
||||
window.dispatchEvent(new PointerEvent("pointermove", { clientX: 151, clientY: 10 }));
|
||||
expect(layout.columnHeaderPreviewOffsets.value).toEqual([101, -100, 0]);
|
||||
|
||||
window.dispatchEvent(new PointerEvent("pointermove", { clientX: 150, clientY: 10 }));
|
||||
expect(layout.columnHeaderPreviewOffsets.value).toEqual([100, -100, 0]);
|
||||
|
||||
window.dispatchEvent(new PointerEvent("pointermove", { clientX: 51, clientY: 10 }));
|
||||
expect(layout.columnHeaderPreviewOffsets.value).toEqual([1, -100, 0]);
|
||||
|
||||
window.dispatchEvent(new PointerEvent("pointermove", { clientX: 49, clientY: 10 }));
|
||||
expect(layout.columnHeaderPreviewOffsets.value).toEqual([-1, 0, 0]);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it("keeps a detached drag preview after the source header leaves the virtual window", () => {
|
||||
vi.stubGlobal(
|
||||
"requestAnimationFrame",
|
||||
vi.fn((callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 0;
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("cancelAnimationFrame", vi.fn());
|
||||
|
||||
const header = document.createElement("div");
|
||||
const source = document.createElement("div");
|
||||
source.dataset.visibleColIndex = "0";
|
||||
source.textContent = "source_column";
|
||||
source.getBoundingClientRect = () => ({ left: 40, width: 120, right: 160, top: 20, bottom: 50, height: 30, x: 40, y: 20, toJSON: () => ({}) });
|
||||
header.append(source);
|
||||
|
||||
const scope = effectScope();
|
||||
const layout = scope.run(() =>
|
||||
useDataGridColumnLayout({
|
||||
columnNames: ref(["source_column", "target_column"]),
|
||||
visibleColumnIndexes: ref([0, 1]),
|
||||
renderedColumnWidths: ref([120, 120]),
|
||||
scrollLeft: ref(0),
|
||||
viewportWidth: ref(240),
|
||||
rowNumberWidth: 40,
|
||||
headerRef: ref(header),
|
||||
}),
|
||||
)!;
|
||||
|
||||
layout.startColumnHeaderDrag(0, new PointerEvent("pointerdown", { button: 0, clientX: 80, clientY: 30 }));
|
||||
window.dispatchEvent(new PointerEvent("pointermove", { clientX: 100, clientY: 30 }));
|
||||
const preview = document.body.querySelector<HTMLElement>("[data-column-header-drag-preview]");
|
||||
expect(preview?.textContent).toBe("source_column");
|
||||
expect(preview?.style.width).toBe("120px");
|
||||
|
||||
source.remove();
|
||||
window.dispatchEvent(new PointerEvent("pointermove", { clientX: 360, clientY: 30 }));
|
||||
expect(document.body.querySelector("[data-column-header-drag-preview]")).toBe(preview);
|
||||
expect(preview?.style.transform).toBe("translateX(280px)");
|
||||
|
||||
window.dispatchEvent(new PointerEvent("pointerup", { clientX: 360, clientY: 30 }));
|
||||
expect(document.body.querySelector("[data-column-header-drag-preview]")).toBeNull();
|
||||
|
||||
header.append(source);
|
||||
layout.startColumnHeaderDrag(0, new PointerEvent("pointerdown", { button: 0, clientX: 80, clientY: 30 }));
|
||||
window.dispatchEvent(new PointerEvent("pointermove", { clientX: 100, clientY: 30 }));
|
||||
expect(document.body.querySelector("[data-column-header-drag-preview]")).not.toBeNull();
|
||||
window.dispatchEvent(new Event("blur"));
|
||||
expect(document.body.querySelector("[data-column-header-drag-preview]")).toBeNull();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it("auto-scrolls while dragging a column to the first and last positions", () => {
|
||||
const frames = new Map<number, FrameRequestCallback>();
|
||||
let nextFrame = 1;
|
||||
vi.stubGlobal(
|
||||
"requestAnimationFrame",
|
||||
vi.fn((callback: FrameRequestCallback) => {
|
||||
const frame = nextFrame++;
|
||||
frames.set(frame, callback);
|
||||
return frame;
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal(
|
||||
"cancelAnimationFrame",
|
||||
vi.fn((frame: number) => frames.delete(frame)),
|
||||
);
|
||||
|
||||
const scroller = document.createElement("div");
|
||||
Object.defineProperties(scroller, {
|
||||
clientWidth: { configurable: true, value: 220 },
|
||||
scrollWidth: { configurable: true, value: 500 },
|
||||
});
|
||||
scroller.getBoundingClientRect = () => ({ left: 0, width: 220, right: 220, top: 20, bottom: 200, height: 180, x: 0, y: 20, toJSON: () => ({}) });
|
||||
|
||||
const header = document.createElement("div");
|
||||
for (let index = 0; index < 5; index++) {
|
||||
const column = document.createElement("div");
|
||||
column.dataset.visibleColIndex = String(index);
|
||||
column.getBoundingClientRect = () => ({ left: index * 100 - scroller.scrollLeft, width: 100, right: (index + 1) * 100 - scroller.scrollLeft, top: 0, bottom: 20, height: 20, x: index * 100 - scroller.scrollLeft, y: 0, toJSON: () => ({}) });
|
||||
header.append(column);
|
||||
}
|
||||
const persist = vi.fn();
|
||||
const syncScroll = vi.fn();
|
||||
const scope = effectScope();
|
||||
const layout = scope.run(() =>
|
||||
useDataGridColumnLayout({
|
||||
columnNames: ref(["a", "b", "c", "d", "e"]),
|
||||
visibleColumnIndexes: ref([0, 1, 2, 3, 4]),
|
||||
renderedColumnWidths: ref([100, 100, 100, 100, 100]),
|
||||
scrollLeft: ref(0),
|
||||
viewportWidth: ref(220),
|
||||
rowNumberWidth: 0,
|
||||
headerRef: ref(header),
|
||||
getScrollElement: () => scroller,
|
||||
onHorizontalScroll: syncScroll,
|
||||
onPersistColumnOrder: persist,
|
||||
}),
|
||||
)!;
|
||||
|
||||
layout.startColumnHeaderDrag(0, new PointerEvent("pointerdown", { button: 0, clientX: 50, clientY: 10 }));
|
||||
window.dispatchEvent(new PointerEvent("pointermove", { clientX: 218, clientY: 10 }));
|
||||
for (let iteration = 0; iteration < 30 && frames.size > 0; iteration++) {
|
||||
const [frame, callback] = frames.entries().next().value!;
|
||||
frames.delete(frame);
|
||||
callback(iteration * 16);
|
||||
}
|
||||
|
||||
expect(scroller.scrollLeft).toBe(280);
|
||||
expect(syncScroll).toHaveBeenCalled();
|
||||
window.dispatchEvent(new PointerEvent("pointerup", { clientX: 218, clientY: 10 }));
|
||||
expect(persist).toHaveBeenCalledWith([1, 2, 3, 4, 0]);
|
||||
expect(frames.size).toBe(0);
|
||||
|
||||
scroller.scrollLeft = 280;
|
||||
layout.startColumnHeaderDrag(4, new PointerEvent("pointerdown", { button: 0, clientX: 170, clientY: 10 }));
|
||||
window.dispatchEvent(new PointerEvent("pointermove", { clientX: 2, clientY: 10 }));
|
||||
for (let iteration = 0; iteration < 30 && frames.size > 0; iteration++) {
|
||||
const [frame, callback] = frames.entries().next().value!;
|
||||
frames.delete(frame);
|
||||
callback(iteration * 16);
|
||||
}
|
||||
|
||||
expect(scroller.scrollLeft).toBe(0);
|
||||
window.dispatchEvent(new PointerEvent("pointerup", { clientX: 2, clientY: 10 }));
|
||||
expect(persist).toHaveBeenLastCalledWith([4, 0, 1, 2, 3]);
|
||||
expect(frames.size).toBe(0);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it("uses the edge after frozen columns as the left auto-scroll trigger", () => {
|
||||
const frames = new Map<number, FrameRequestCallback>();
|
||||
let nextFrame = 1;
|
||||
vi.stubGlobal(
|
||||
"requestAnimationFrame",
|
||||
vi.fn((callback: FrameRequestCallback) => {
|
||||
const frame = nextFrame++;
|
||||
frames.set(frame, callback);
|
||||
return frame;
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal(
|
||||
"cancelAnimationFrame",
|
||||
vi.fn((frame: number) => frames.delete(frame)),
|
||||
);
|
||||
|
||||
const scroller = document.createElement("div");
|
||||
scroller.scrollLeft = 200;
|
||||
Object.defineProperties(scroller, {
|
||||
clientWidth: { configurable: true, value: 400 },
|
||||
scrollWidth: { configurable: true, value: 800 },
|
||||
});
|
||||
scroller.getBoundingClientRect = () => ({ left: 0, width: 400, right: 400, top: 20, bottom: 200, height: 180, x: 0, y: 20, toJSON: () => ({}) });
|
||||
|
||||
const header = document.createElement("div");
|
||||
const column = document.createElement("div");
|
||||
column.dataset.visibleColIndex = "2";
|
||||
column.getBoundingClientRect = () => ({ left: 240, width: 100, right: 340, top: 0, bottom: 20, height: 20, x: 240, y: 0, toJSON: () => ({}) });
|
||||
header.append(column);
|
||||
|
||||
const scope = effectScope();
|
||||
const layout = scope.run(() =>
|
||||
useDataGridColumnLayout({
|
||||
columnNames: ref(["a", "b", "c", "d", "e", "f", "g"]),
|
||||
visibleColumnIndexes: ref([0, 1, 2, 3, 4, 5, 6]),
|
||||
renderedColumnWidths: ref([100, 100, 100, 100, 100, 100, 100]),
|
||||
scrollLeft: ref(200),
|
||||
viewportWidth: ref(400),
|
||||
rowNumberWidth: 40,
|
||||
frozenColumnCount: ref(2),
|
||||
headerRef: ref(header),
|
||||
getScrollElement: () => scroller,
|
||||
}),
|
||||
)!;
|
||||
|
||||
layout.startColumnHeaderDrag(2, new PointerEvent("pointerdown", { button: 0, clientX: 260, clientY: 10 }));
|
||||
window.dispatchEvent(new PointerEvent("pointermove", { clientX: 235, clientY: 10 }));
|
||||
const [frame, callback] = frames.entries().next().value!;
|
||||
frames.delete(frame);
|
||||
callback(0);
|
||||
|
||||
expect(scroller.scrollLeft).toBeLessThan(200);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
describe("frozen columns", () => {
|
||||
it("starts with frozenColumnCount of 0", () => {
|
||||
const scope = effectScope();
|
||||
|
||||
@@ -40,6 +40,19 @@ function rowEvent(options: { meta?: boolean; shift?: boolean } = {}): MouseEvent
|
||||
}
|
||||
|
||||
describe("useDataGridSelection", () => {
|
||||
it("keeps selected columns and the range anchor attached to their columns after reordering", () => {
|
||||
const selection = createSelection();
|
||||
|
||||
selection.selectColumn(0);
|
||||
selection.selectColumn(2, rowEvent({ meta: true }));
|
||||
selection.remapColumnSelection([0, 1, 2], [1, 2, 0]);
|
||||
|
||||
expect(selection.selectedColumnIndexes.value).toEqual(new Set([1, 2]));
|
||||
|
||||
selection.selectColumn(0, rowEvent({ shift: true }));
|
||||
expect(selection.selectedColumnIndexes.value).toEqual(new Set([0, 1, 2]));
|
||||
});
|
||||
|
||||
it("invalidates synthetic context state for ordinary, Ctrl, and Cmd cell selection", () => {
|
||||
const originalDocument = globalThis.document;
|
||||
const fakeDocument = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { computed, nextTick, onScopeDispose, ref, toValue, watch, type MaybeRefOrGetter } from "vue";
|
||||
import { columnOrderKeysForIndexes, isDefaultColumnOrder, mergeUnavailableColumnOrderKeys, moveDisplayableColumnIndex, moveVisibleColumnIndex, orderedColumnIndexes } from "@/lib/dataGrid/dataGridColumnOrder";
|
||||
import { columnHeaderCanvasPointerDisabled, columnHeaderClickShouldBeSuppressed, columnHeaderPreviewOffsetForColumn, columnHeaderTooltipDisabled } from "@/lib/dataGrid/dataGridColumnHeaderInteraction";
|
||||
import { columnHeaderCanvasPointerDisabled, columnHeaderClickShouldBeSuppressed, columnHeaderDragAutoScrollDelta, columnHeaderDropTargetIndex, columnHeaderPreviewOffsetForColumn, columnHeaderTooltipDisabled } from "@/lib/dataGrid/dataGridColumnHeaderInteraction";
|
||||
import {
|
||||
loadDataGridColumnLayout,
|
||||
loadDataGridColumnFrozenState,
|
||||
@@ -42,7 +42,13 @@ type ColumnHeaderDragState = {
|
||||
startX: number;
|
||||
startY: number;
|
||||
currentX: number;
|
||||
startScrollLeft: number;
|
||||
currentScrollLeft: number;
|
||||
dragCenterClientOffsetX: number;
|
||||
lastClientX: number;
|
||||
direction: -1 | 0 | 1;
|
||||
columnRects: { visibleIndex: number; left: number; width: number }[];
|
||||
previewElement: HTMLElement | null;
|
||||
dragging: boolean;
|
||||
};
|
||||
|
||||
@@ -408,12 +414,14 @@ export function useDataGridColumnLayout(options: {
|
||||
rowNumberWidth: MaybeRefOrGetter<number>;
|
||||
bufferPx?: number;
|
||||
headerRef?: MaybeRefOrGetter<HTMLElement | null | undefined>;
|
||||
getScrollElement?: () => HTMLElement | null;
|
||||
orderedColumnIndexes?: MaybeRefOrGetter<readonly number[]>;
|
||||
hiddenColumnIndexes?: MaybeRefOrGetter<ReadonlySet<number>>;
|
||||
getIsResizing?: () => boolean;
|
||||
onResizeStart?: (visibleColIdx: number, event: MouseEvent) => void;
|
||||
onCanvasMouseLeave?: () => void;
|
||||
onCanvasDrawSchedule?: () => void;
|
||||
onHorizontalScroll?: (element: HTMLElement) => void;
|
||||
onRefreshMetrics?: () => void;
|
||||
onPersistColumnOrder?: (indexes: number[]) => void;
|
||||
frozenColumnCount?: MaybeRefOrGetter<number>;
|
||||
@@ -561,13 +569,75 @@ export function useDataGridColumnLayout(options: {
|
||||
return target instanceof HTMLElement && !!target.closest("button, input, textarea, select, [contenteditable='true'], [role='button'], [data-column-resize-handle]");
|
||||
}
|
||||
|
||||
function columnHeaderDropTargetVisibleIndex(clientX: number): number {
|
||||
function columnHeaderPointerContentX(clientX: number, scroller?: HTMLElement | null): number {
|
||||
if (!scroller) return clientX;
|
||||
const viewport = scroller.getBoundingClientRect();
|
||||
const frozen = frozenColumnCount.value;
|
||||
const pointerViewportX = clientX - viewport.left;
|
||||
const frozenRight = toValue(options.rowNumberWidth) + (renderedColumnOffsets.value[frozen] ?? 0);
|
||||
return frozen > 0 && pointerViewportX < frozenRight ? pointerViewportX - toValue(options.rowNumberWidth) : scroller.scrollLeft + pointerViewportX - toValue(options.rowNumberWidth);
|
||||
}
|
||||
|
||||
function columnHeaderDropTargetVisibleIndex(clientX: number, scroller = options.getScrollElement?.()): number {
|
||||
const state = columnHeaderDragState.value;
|
||||
if (!state || state.columnRects.length === 0) return state?.sourceVisibleIndex ?? 0;
|
||||
for (const rect of state.columnRects) {
|
||||
if (clientX < rect.left + rect.width / 2) return rect.visibleIndex;
|
||||
if (!state) return 0;
|
||||
const visibleColumnCount = toValue(options.visibleColumnIndexes).length;
|
||||
const movement = clientX - state.lastClientX;
|
||||
if (Math.abs(movement) >= 0.5) state.direction = movement < 0 ? -1 : 1;
|
||||
state.lastClientX = clientX;
|
||||
const dragCenterClientX = clientX + state.dragCenterClientOffsetX;
|
||||
const dragCenterContentX = columnHeaderPointerContentX(dragCenterClientX, scroller);
|
||||
if (scroller) {
|
||||
const viewport = scroller.getBoundingClientRect();
|
||||
const maxScrollLeft = Math.max(0, scroller.scrollWidth - scroller.clientWidth);
|
||||
if (state.direction < 0 && scroller.scrollLeft <= 0 && clientX <= viewport.left + 64) return 0;
|
||||
if (state.direction > 0 && scroller.scrollLeft >= maxScrollLeft - 0.5 && clientX >= viewport.right - 64) return Math.max(0, visibleColumnCount - 1);
|
||||
|
||||
const widths = toValue(options.renderedColumnWidths);
|
||||
return columnHeaderDropTargetIndex({ pointerContentX: dragCenterContentX, sourceVisibleIndex: state.sourceVisibleIndex, currentTargetIndex: state.targetVisibleIndex, direction: state.direction, columnWidths: widths, columnOffsets: renderedColumnOffsets.value });
|
||||
}
|
||||
return toValue(options.visibleColumnIndexes).length;
|
||||
if (state.columnRects.length === 0) return state.sourceVisibleIndex;
|
||||
const widths = state.columnRects.map((rect) => rect.width);
|
||||
const offsets = state.columnRects.map((rect) => rect.left);
|
||||
return Math.min(Math.max(0, visibleColumnCount - 1), columnHeaderDropTargetIndex({ pointerContentX: dragCenterContentX, sourceVisibleIndex: state.sourceVisibleIndex, currentTargetIndex: state.targetVisibleIndex, direction: state.direction, columnWidths: widths, columnOffsets: offsets }));
|
||||
}
|
||||
|
||||
function createColumnHeaderDragPreview(state: ColumnHeaderDragState) {
|
||||
if (state.previewElement) return;
|
||||
const header = toValue(options.headerRef);
|
||||
const source = header?.querySelector<HTMLElement>(`[data-visible-col-index="${state.sourceVisibleIndex}"]`);
|
||||
if (!source) return;
|
||||
const rect = source.getBoundingClientRect();
|
||||
const preview = source.cloneNode(true) as HTMLElement;
|
||||
preview.dataset.columnHeaderDragPreview = "";
|
||||
preview.removeAttribute("data-visible-col-index");
|
||||
preview.setAttribute("aria-hidden", "true");
|
||||
preview.inert = true;
|
||||
Object.assign(preview.style, {
|
||||
position: "fixed",
|
||||
left: `${rect.left}px`,
|
||||
top: `${rect.top}px`,
|
||||
width: `${rect.width}px`,
|
||||
height: `${rect.height}px`,
|
||||
margin: "0",
|
||||
transform: "translateX(0)",
|
||||
transition: "none",
|
||||
zIndex: "100",
|
||||
pointerEvents: "none",
|
||||
});
|
||||
preview.classList.add("shadow-lg", "ring-1", "ring-primary/40");
|
||||
document.body.append(preview);
|
||||
state.previewElement = preview;
|
||||
}
|
||||
|
||||
function updateColumnHeaderDragPreview(state: ColumnHeaderDragState) {
|
||||
if (!state.previewElement) return;
|
||||
state.previewElement.style.transform = `translateX(${state.currentX - state.startX}px)`;
|
||||
}
|
||||
|
||||
function removeColumnHeaderDragPreview(state: ColumnHeaderDragState) {
|
||||
state.previewElement?.remove();
|
||||
state.previewElement = null;
|
||||
}
|
||||
|
||||
function applyColumnHeaderDragPreview() {
|
||||
@@ -575,8 +645,28 @@ export function useDataGridColumnLayout(options: {
|
||||
const state = columnHeaderDragState.value;
|
||||
if (!state?.dragging) return;
|
||||
state.currentX = columnHeaderPendingClientX;
|
||||
state.targetVisibleIndex = columnHeaderDropTargetVisibleIndex(columnHeaderPendingClientX);
|
||||
updateColumnHeaderDragPreview(state);
|
||||
const scroller = options.getScrollElement?.();
|
||||
if (scroller) state.currentScrollLeft = scroller.scrollLeft;
|
||||
state.targetVisibleIndex = columnHeaderDropTargetVisibleIndex(columnHeaderPendingClientX, scroller);
|
||||
let keepScrolling = false;
|
||||
if (scroller) {
|
||||
const viewport = scroller.getBoundingClientRect();
|
||||
const frozenWidth = renderedColumnOffsets.value[frozenColumnCount.value] ?? 0;
|
||||
const scrollViewportLeft = Math.min(viewport.right, viewport.left + toValue(options.rowNumberWidth) + frozenWidth);
|
||||
const scrollDelta = columnHeaderDragAutoScrollDelta({ clientX: columnHeaderPendingClientX, viewportLeft: scrollViewportLeft, viewportRight: viewport.right });
|
||||
const maxScrollLeft = Math.max(0, scroller.scrollWidth - scroller.clientWidth);
|
||||
const nextScrollLeft = Math.max(0, Math.min(maxScrollLeft, scroller.scrollLeft + scrollDelta));
|
||||
if (Math.abs(nextScrollLeft - scroller.scrollLeft) >= 0.5) {
|
||||
scroller.scrollLeft = nextScrollLeft;
|
||||
state.currentScrollLeft = scroller.scrollLeft;
|
||||
options.onHorizontalScroll?.(scroller);
|
||||
state.targetVisibleIndex = columnHeaderDropTargetVisibleIndex(columnHeaderPendingClientX, scroller);
|
||||
keepScrolling = true;
|
||||
}
|
||||
}
|
||||
options.onCanvasDrawSchedule?.();
|
||||
if (keepScrolling) columnHeaderDragFrame = requestAnimationFrame(applyColumnHeaderDragPreview);
|
||||
}
|
||||
|
||||
function scheduleColumnHeaderDragPreview(clientX: number) {
|
||||
@@ -613,7 +703,9 @@ export function useDataGridColumnLayout(options: {
|
||||
window.removeEventListener("pointermove", onColumnHeaderPointerMove, true);
|
||||
window.removeEventListener("pointerup", onColumnHeaderPointerUp, true);
|
||||
window.removeEventListener("pointercancel", onColumnHeaderPointerCancel, true);
|
||||
window.removeEventListener("blur", onColumnHeaderPointerCancel, true);
|
||||
cancelColumnHeaderDragPreview();
|
||||
removeColumnHeaderDragPreview(state);
|
||||
document.body.style.userSelect = "";
|
||||
columnHeaderDragState.value = null;
|
||||
if (hadCanvasPreview) options.onCanvasDrawSchedule?.();
|
||||
@@ -637,6 +729,7 @@ export function useDataGridColumnLayout(options: {
|
||||
state.dragging = true;
|
||||
document.body.style.userSelect = "none";
|
||||
options.onCanvasMouseLeave?.();
|
||||
createColumnHeaderDragPreview(state);
|
||||
}
|
||||
if (!state.dragging) return;
|
||||
event.preventDefault();
|
||||
@@ -655,19 +748,31 @@ export function useDataGridColumnLayout(options: {
|
||||
|
||||
function startColumnHeaderDrag(visibleColIdx: number, event: PointerEvent) {
|
||||
if (event.button !== 0 || options.getIsResizing?.() || columnHeaderInteractiveTarget(event.target)) return;
|
||||
const scroller = options.getScrollElement?.();
|
||||
const scrollLeft = scroller?.scrollLeft ?? 0;
|
||||
const columnRects = columnHeaderLayoutRects();
|
||||
const sourceRect = columnRects.find((rect) => rect.visibleIndex === visibleColIdx);
|
||||
const dragCenterClientOffsetX = sourceRect ? sourceRect.left + sourceRect.width / 2 - event.clientX : 0;
|
||||
columnHeaderDragState.value = {
|
||||
sourceVisibleIndex: visibleColIdx,
|
||||
targetVisibleIndex: visibleColIdx,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
currentX: event.clientX,
|
||||
columnRects: columnHeaderLayoutRects(),
|
||||
startScrollLeft: scrollLeft,
|
||||
currentScrollLeft: scrollLeft,
|
||||
dragCenterClientOffsetX,
|
||||
lastClientX: event.clientX,
|
||||
direction: 0,
|
||||
columnRects,
|
||||
previewElement: null,
|
||||
dragging: false,
|
||||
};
|
||||
columnHeaderPendingClientX = event.clientX;
|
||||
window.addEventListener("pointermove", onColumnHeaderPointerMove, true);
|
||||
window.addEventListener("pointerup", onColumnHeaderPointerUp, true);
|
||||
window.addEventListener("pointercancel", onColumnHeaderPointerCancel, true);
|
||||
window.addEventListener("blur", onColumnHeaderPointerCancel, true);
|
||||
}
|
||||
|
||||
function suppressHeaderClickIfNeeded(event: MouseEvent): boolean {
|
||||
@@ -681,19 +786,20 @@ export function useDataGridColumnLayout(options: {
|
||||
|
||||
function columnHeaderDragClass(visibleColIdx: number) {
|
||||
const state = columnHeaderDragState.value;
|
||||
return { "z-30 shadow-lg ring-1 ring-primary/40 bg-background dark:bg-muted pointer-events-none": state?.dragging && state.sourceVisibleIndex === visibleColIdx };
|
||||
return { "opacity-0 pointer-events-none": state?.dragging && state.sourceVisibleIndex === visibleColIdx };
|
||||
}
|
||||
|
||||
function columnHeaderPreviewOffset(visibleColIdx: number): number {
|
||||
const state = columnHeaderDragState.value;
|
||||
if (!state) return 0;
|
||||
const scrollCompensation = state.sourceVisibleIndex < frozenColumnCount.value ? 0 : state.currentScrollLeft - state.startScrollLeft;
|
||||
return columnHeaderPreviewOffsetForColumn({
|
||||
columnDragActive: state.dragging,
|
||||
visibleColIdx,
|
||||
sourceVisibleIndex: state.sourceVisibleIndex,
|
||||
targetVisibleIndex: state.targetVisibleIndex,
|
||||
startX: state.startX,
|
||||
currentX: state.currentX,
|
||||
currentX: state.currentX + scrollCompensation,
|
||||
sourceWidth: toValue(options.renderedColumnWidths)[state.sourceVisibleIndex] ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -247,6 +247,15 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
|
||||
lastClickedColumnIndex.value = colIndex;
|
||||
}
|
||||
|
||||
function remapColumnSelection(previousColumnIndexes: readonly number[], nextColumnIndexes: readonly number[]) {
|
||||
const selectedColumns = new Set([...selectedColumnIndexes.value].map((index) => previousColumnIndexes[index]).filter((index): index is number => index !== undefined));
|
||||
selectedColumnIndexes.value = new Set(nextColumnIndexes.flatMap((columnIndex, index) => (selectedColumns.has(columnIndex) ? [index] : [])));
|
||||
|
||||
const lastClickedColumn = lastClickedColumnIndex.value === null ? undefined : previousColumnIndexes[lastClickedColumnIndex.value];
|
||||
const nextLastClickedColumnIndex = lastClickedColumn === undefined ? -1 : nextColumnIndexes.indexOf(lastClickedColumn);
|
||||
lastClickedColumnIndex.value = nextLastClickedColumnIndex >= 0 ? nextLastClickedColumnIndex : null;
|
||||
}
|
||||
|
||||
function selectAllCells() {
|
||||
const range = allCellsSelectionRange(displayItems.value.length, columns.value.length);
|
||||
if (!range) return;
|
||||
@@ -579,6 +588,7 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
|
||||
selectRow,
|
||||
selectColumn,
|
||||
selectColumns,
|
||||
remapColumnSelection,
|
||||
selectAllCells,
|
||||
extendCellSelectionTo,
|
||||
finishCellSelection,
|
||||
|
||||
@@ -26,6 +26,77 @@ export function columnHeaderClickShouldBeSuppressed(state: ColumnHeaderClickGuar
|
||||
return state.suppressNextClick || state.now < state.guardUntil;
|
||||
}
|
||||
|
||||
export interface ColumnHeaderDragAutoScrollState {
|
||||
clientX: number;
|
||||
viewportLeft: number;
|
||||
viewportRight: number;
|
||||
threshold?: number;
|
||||
maxStep?: number;
|
||||
}
|
||||
|
||||
export function columnHeaderDragAutoScrollDelta(state: ColumnHeaderDragAutoScrollState): number {
|
||||
const threshold = Math.max(1, state.threshold ?? 64);
|
||||
const maxStep = Math.max(0, state.maxStep ?? 24);
|
||||
if (maxStep === 0) return 0;
|
||||
const leftDistance = state.clientX - state.viewportLeft;
|
||||
const rightDistance = state.viewportRight - state.clientX;
|
||||
const leftActive = leftDistance < threshold;
|
||||
const rightActive = rightDistance < threshold;
|
||||
const direction = leftActive && (!rightActive || leftDistance <= rightDistance) ? -1 : rightActive ? 1 : 0;
|
||||
const edgeDistance = direction < 0 ? leftDistance : direction > 0 ? rightDistance : null;
|
||||
if (edgeDistance === null) return 0;
|
||||
|
||||
const intensity = Math.min(1, Math.max(0, (threshold - edgeDistance) / threshold));
|
||||
return direction * Math.max(1, Math.round(maxStep * intensity * intensity));
|
||||
}
|
||||
|
||||
export interface ColumnHeaderDropTargetState {
|
||||
pointerContentX: number;
|
||||
sourceVisibleIndex: number;
|
||||
currentTargetIndex: number;
|
||||
direction: -1 | 0 | 1;
|
||||
columnWidths: readonly number[];
|
||||
columnOffsets: readonly number[];
|
||||
}
|
||||
|
||||
export function columnHeaderDropTargetIndex(state: ColumnHeaderDropTargetState): number {
|
||||
const otherColumnIndexes = state.columnWidths.map((_, visibleIndex) => visibleIndex).filter((visibleIndex) => visibleIndex !== state.sourceVisibleIndex);
|
||||
const maxTargetIndex = otherColumnIndexes.length;
|
||||
let targetIndex = Math.max(0, Math.min(state.currentTargetIndex, maxTargetIndex));
|
||||
const sourceWidth = state.columnWidths[state.sourceVisibleIndex] ?? 0;
|
||||
|
||||
function previewOffset(columnIndex: number): number {
|
||||
return columnHeaderPreviewOffsetForColumn({
|
||||
columnDragActive: true,
|
||||
visibleColIdx: columnIndex,
|
||||
sourceVisibleIndex: state.sourceVisibleIndex,
|
||||
targetVisibleIndex: targetIndex,
|
||||
startX: 0,
|
||||
currentX: 0,
|
||||
sourceWidth,
|
||||
});
|
||||
}
|
||||
|
||||
if (state.direction > 0) {
|
||||
while (targetIndex < maxTargetIndex) {
|
||||
const columnIndex = otherColumnIndexes[targetIndex];
|
||||
const width = state.columnWidths[columnIndex] ?? 0;
|
||||
const threshold = (state.columnOffsets[columnIndex] ?? 0) + width / 2 + previewOffset(columnIndex);
|
||||
if (state.pointerContentX <= threshold) break;
|
||||
targetIndex++;
|
||||
}
|
||||
} else if (state.direction < 0) {
|
||||
while (targetIndex > 0) {
|
||||
const columnIndex = otherColumnIndexes[targetIndex - 1];
|
||||
const width = state.columnWidths[columnIndex] ?? 0;
|
||||
const threshold = (state.columnOffsets[columnIndex] ?? 0) + width / 2 + previewOffset(columnIndex);
|
||||
if (state.pointerContentX >= threshold) break;
|
||||
targetIndex--;
|
||||
}
|
||||
}
|
||||
return targetIndex;
|
||||
}
|
||||
|
||||
export interface ColumnHeaderPreviewState {
|
||||
columnDragActive: boolean;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user