feat(grid): persist column layouts and improve drag-to-reorder

This commit is contained in:
Ashton Lin authored and GitHub committed 2026-07-31 02:01:46 +08:00
1 parent f5c29f08f1
commit 44f748d3a1
24 files changed
+1532 -276

No files matched your search

+42 -30
View File
@@ -275,6 +275,7 @@ interface DataGridProps {
database?: string;
executionDatabase?: string;
schema?: string;
columnLayoutScopeKey?: string;
context?: "results" | "table-data";
autoTransposeSingleRow?: boolean;
sourceColumns?: Array<string | undefined>;
@@ -351,7 +352,6 @@ const emit = defineEmits<{
"update:whereInput": [value: string];
"update:orderByInput": [value: string];
"local-column-filters-change": [value: Record<string, string[]>];
"hidden-column-keys-change": [value: string[]];
}>();
const autoRefresh = useDataGridAutoRefresh({ canRefresh: computed(() => !isSaving.value && !props.loading), refresh: onToolbarRefresh });
@@ -1688,18 +1688,20 @@ let highlightedColumnTimer = 0;
const goToColumnOpen = ref(false);
const goToColumnSearch = ref("");
const columnOrderKeys = computed(() => uniqueDataGridColumnOrderKeys(props.result.columns, props.sourceColumns));
const columnLayoutScopeKey = computed(() =>
dataGridColumnLayoutScopeKey({
connectionId: props.connectionId,
database: props.database,
schema: props.schema,
context: props.context,
tableSchema: props.tableMeta?.schema,
tableName: props.tableMeta?.tableName,
sql: props.sql,
columns: props.result.columns,
sourceColumns: props.sourceColumns,
}),
const resolvedColumnLayoutScopeKey = computed(
() =>
props.columnLayoutScopeKey ??
dataGridColumnLayoutScopeKey({
connectionId: props.connectionId,
database: props.database,
schema: props.schema,
context: props.context,
tableSchema: props.tableMeta?.schema,
tableName: props.tableMeta?.tableName,
sql: props.sql,
columns: props.result.columns,
sourceColumns: props.sourceColumns,
}),
);
const tableColumnOrderScopeKey = computed(() => {
if (props.context !== "table-data" || !props.connectionId || !props.database || !props.tableMeta?.tableName) return "";
@@ -1727,13 +1729,15 @@ const {
allNullColumnCount,
hasCustomColumnOrder,
canToggleAllNullColumns,
filteredColumnVisibilityOptions,
orderedColumnLayoutOptions,
filteredColumnLayoutOptions,
isColumnVisible,
toggleColumnVisibility,
showAllColumns,
invertColumnVisibility,
showColumn,
persistColumnOrder,
moveDisplayableColumn,
resetColumnOrder,
toggleAllNullColumns,
resetColumnVisibility,
@@ -1749,13 +1753,13 @@ const {
displayableColumnIndexes,
allNullColumnIndexes: allNullColumnIndexesForResult,
columnOrderKeys,
layoutScopeKey: columnLayoutScopeKey,
layoutScopeKey: resolvedColumnLayoutScopeKey,
tableScopeKey: tableColumnOrderScopeKey,
// Existing tab snapshots may still carry this field; new changes persist in the internal layout store.
initialHiddenColumnKeys: computed(() => props.result.local_hidden_column_keys),
hideNullColumns,
onHiddenColumnKeysChange: (keys) => emit("hidden-column-keys-change", keys),
onHideNullColumnsChange: (value) => settingsStore.updateEditorSettings({ dataGridHideNullColumns: value }),
onRefreshMetrics: refreshGridScrollerMetrics,
onRefreshMetrics: scheduleColumnLayoutRefresh,
});
const goToColumnItems = computed(() =>
buildDataGridColumnLookupItems({
@@ -1935,7 +1939,7 @@ const {
onResizeStart,
onCanvasMouseLeave,
onCanvasDrawSchedule: scheduleCanvasDraw,
onRefreshMetrics: () => nextTick(refreshGridScrollerMetrics),
onRefreshMetrics: scheduleColumnLayoutRefresh,
onPersistColumnOrder: persistColumnOrder,
frozenColumnCount,
});
@@ -2249,6 +2253,19 @@ function refreshGridScrollerMetrics() {
observeGridHorizontalScrollbarScroller();
}
let columnLayoutRefreshFrame = 0;
function scheduleColumnLayoutRefresh() {
if (columnLayoutRefreshFrame) return;
columnLayoutRefreshFrame = requestAnimationFrame(() => {
try {
initColumnWidths();
refreshGridScrollerMetrics();
} finally {
columnLayoutRefreshFrame = 0;
}
});
}
function syncHeaderScroll(e: Event) {
const target = e.target as HTMLElement;
updateGridScrollbarGutter(target);
@@ -2321,16 +2338,7 @@ watch(isScrolling, (scrolling) => {
});
initColumnWidths();
watch(
() => visibleColumns.value.length,
() => initColumnWidths(),
);
watch(
() => [visibleColumnCount.value, renderedColumnWidths.value.length],
() => {
nextTick(refreshGridScrollerMetrics);
},
);
watch([visibleColumnIndexes, () => renderedColumnWidths.value.length], () => scheduleColumnLayoutRefresh());
const localFilterScopeKey = computed(() =>
[
props.connectionId ?? "",
@@ -2348,7 +2356,7 @@ watch(
() => localFilterScopeKey.value,
() => {
localColumnFilters.value = {};
resetColumnVisibility(props.result.local_hidden_column_keys);
resetColumnVisibility();
closeLocalFilter();
},
);
@@ -5140,6 +5148,8 @@ onUnmounted(() => {
disconnectCellEditResizeObserver();
stopGridHorizontalScrollbarDrag();
stopGridVerticalScrollbarDrag();
if (columnLayoutRefreshFrame) cancelAnimationFrame(columnLayoutRefreshFrame);
columnLayoutRefreshFrame = 0;
if (typeof window === "undefined") return;
window.removeEventListener("resize", scheduleCanvasPixelRatioRefresh);
window.visualViewport?.removeEventListener("resize", scheduleCanvasPixelRatioRefresh);
@@ -7585,12 +7595,14 @@ defineExpose({
visibleColumnCount,
displayableColumnCount,
hiddenColumnCount,
filteredColumnVisibilityOptions,
orderedColumnLayoutOptions,
filteredColumnLayoutOptions,
isColumnVisible,
toggleColumnVisibility,
showAllColumns,
invertColumnVisibility,
hasCustomColumnOrder,
moveDisplayableColumn,
resetColumnOrder,
nullColumnsHidden,
allNullColumnCount,
@@ -0,0 +1,408 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch, type CSSProperties } from "vue";
import { Check, Columns3, GripVertical, Search } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import type { DataGridColumnLayoutOption } from "@/composables/useDataGridColumnLayout";
import { dragSortAutoScrollDelta } from "@/composables/useDragSort";
import {
DATA_GRID_COLUMN_LAYOUT_DRAG_THRESHOLD,
DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT,
DATA_GRID_COLUMN_LAYOUT_VIEWPORT_HEIGHT,
DATA_GRID_COLUMN_LAYOUT_VIRTUAL_THRESHOLD,
dataGridColumnLayoutDropTarget,
dataGridColumnLayoutVirtualWindow,
type DataGridColumnLayoutHandle,
} from "./dataGridColumnLayoutPopover";
const props = withDefaults(
defineProps<{
grid?: DataGridColumnLayoutHandle;
triggerClass?: string;
}>(),
{
triggerClass: "",
},
);
const { t } = useI18n();
const popoverOpen = ref(false);
const columnSearch = ref("");
const listRef = ref<HTMLElement>();
const listScrollTop = ref(0);
const draggedDisplayPosition = ref<number | null>(null);
const dragTargetDisplayPosition = ref<number | null>(null);
const dragInsertionIndex = ref<number | null>(null);
const dragPreviewOption = ref<DataGridColumnLayoutOption | null>(null);
const dragPreviewStyle = ref<CSSProperties>();
let columnDragAutoScrollFrame = 0;
interface ColumnDragState {
pointerId: number;
fromDisplayPosition: number;
startClientX: number;
startClientY: number;
lastClientX: number;
lastClientY: number;
active: boolean;
handle: HTMLElement;
option: DataGridColumnLayoutOption;
previewOffsetX: number;
previewOffsetY: number;
previewWidth: number;
previousBodyUserSelect: string;
}
let columnDragState: ColumnDragState | null = null;
const columnLayoutOptions = computed(() => props.grid?.filteredColumnLayoutOptions(columnSearch.value) ?? []);
const columnReorderEnabled = computed(() => columnSearch.value.trim() === "");
const virtualized = computed(() => columnLayoutOptions.value.length > DATA_GRID_COLUMN_LAYOUT_VIRTUAL_THRESHOLD);
const virtualWindow = computed(() =>
dataGridColumnLayoutVirtualWindow({
itemCount: columnLayoutOptions.value.length,
scrollTop: listScrollTop.value,
}),
);
const renderedOptions = computed(() => {
if (!virtualized.value) return columnLayoutOptions.value;
return columnLayoutOptions.value.slice(virtualWindow.value.start, virtualWindow.value.end);
});
const listContentStyle = computed<CSSProperties | undefined>(() => {
if (!virtualized.value) return undefined;
return { height: `${virtualWindow.value.totalHeight}px`, position: "relative" };
});
const renderedOptionsStyle = computed<CSSProperties | undefined>(() => {
if (!virtualized.value) return undefined;
return {
left: 0,
position: "absolute",
right: 0,
top: 0,
transform: `translateY(${virtualWindow.value.offsetTop}px)`,
};
});
function columnLayoutRowStyle(option: DataGridColumnLayoutOption): CSSProperties {
const style: CSSProperties = { height: `${DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT}px` };
const sourcePosition = draggedDisplayPosition.value;
const targetPosition = dragTargetDisplayPosition.value;
if (sourcePosition === null || targetPosition === null || option.displayPosition === sourcePosition) return style;
let offset = 0;
if (targetPosition < sourcePosition && option.displayPosition >= targetPosition && option.displayPosition < sourcePosition) offset = DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT;
if (targetPosition > sourcePosition && option.displayPosition > sourcePosition && option.displayPosition <= targetPosition) offset = -DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT;
if (!offset) return style;
return { ...style, transform: `translateY(${offset}px)`, transition: "transform 120ms ease-out" };
}
function resetListScroll() {
listScrollTop.value = 0;
void nextTick(() => {
if (listRef.value) listRef.value.scrollTop = 0;
});
}
function onListScroll(event: Event) {
listScrollTop.value = (event.currentTarget as HTMLElement).scrollTop;
}
function removeColumnDragListeners() {
window.removeEventListener("pointermove", onWindowPointerMove, true);
window.removeEventListener("pointerup", onWindowPointerUp, true);
window.removeEventListener("pointercancel", onWindowPointerCancel, true);
window.removeEventListener("blur", resetColumnDragState);
window.removeEventListener("keydown", onWindowKeyDown, true);
}
function cancelColumnDragAutoScroll() {
if (!columnDragAutoScrollFrame) return;
window.cancelAnimationFrame(columnDragAutoScrollFrame);
columnDragAutoScrollFrame = 0;
}
function resetColumnDragState() {
const state = columnDragState;
columnDragState = null;
cancelColumnDragAutoScroll();
removeColumnDragListeners();
if (state?.active) document.body.style.userSelect = state.previousBodyUserSelect;
if (state?.handle.hasPointerCapture?.(state.pointerId)) state.handle.releasePointerCapture(state.pointerId);
draggedDisplayPosition.value = null;
dragTargetDisplayPosition.value = null;
dragInsertionIndex.value = null;
dragPreviewOption.value = null;
dragPreviewStyle.value = undefined;
}
function updateColumnDragTarget(clientY: number) {
const state = columnDragState;
const list = listRef.value;
if (!state?.active || !list) return;
const target = dataGridColumnLayoutDropTarget({
clientY,
listTop: list.getBoundingClientRect().top,
scrollTop: list.scrollTop,
itemCount: columnLayoutOptions.value.length,
fromDisplayPosition: state.fromDisplayPosition,
});
dragInsertionIndex.value = target.insertionIndex;
dragTargetDisplayPosition.value = target.toDisplayPosition;
}
function columnDragAutoScrollDelta(): number {
const state = columnDragState;
const list = listRef.value;
if (!state?.active || !list) return 0;
return dragSortAutoScrollDelta({
pointerX: state.lastClientX,
pointerY: state.lastClientY,
rect: list.getBoundingClientRect(),
scrollTop: list.scrollTop,
clientHeight: list.clientHeight,
scrollHeight: list.scrollHeight,
});
}
function runColumnDragAutoScroll() {
columnDragAutoScrollFrame = 0;
const state = columnDragState;
const list = listRef.value;
const delta = columnDragAutoScrollDelta();
if (!state?.active || !list || !delta) return;
const maxScrollTop = Math.max(0, list.scrollHeight - list.clientHeight);
const previousScrollTop = list.scrollTop;
list.scrollTop = Math.min(maxScrollTop, Math.max(0, previousScrollTop + delta));
listScrollTop.value = list.scrollTop;
if (list.scrollTop !== previousScrollTop) updateColumnDragTarget(state.lastClientY);
scheduleColumnDragAutoScroll();
}
function scheduleColumnDragAutoScroll() {
if (columnDragAutoScrollFrame || !columnDragAutoScrollDelta()) return;
columnDragAutoScrollFrame = window.requestAnimationFrame(runColumnDragAutoScroll);
}
function updateColumnDragPreview(clientX: number, clientY: number) {
const state = columnDragState;
if (!state?.active) return;
dragPreviewStyle.value = {
height: `${DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT}px`,
transform: `translate3d(${clientX - state.previewOffsetX}px, ${clientY - state.previewOffsetY}px, 0)`,
width: `${state.previewWidth}px`,
};
}
function activateColumnDrag(event: PointerEvent) {
const state = columnDragState;
if (!state || state.active) return;
state.active = true;
state.previousBodyUserSelect = document.body.style.userSelect;
document.body.style.userSelect = "none";
draggedDisplayPosition.value = state.fromDisplayPosition;
dragPreviewOption.value = state.option;
updateColumnDragPreview(event.clientX, event.clientY);
updateColumnDragTarget(event.clientY);
}
function onWindowPointerMove(event: PointerEvent) {
const state = columnDragState;
if (!state || event.pointerId !== state.pointerId) return;
state.lastClientX = event.clientX;
state.lastClientY = event.clientY;
const movedPastThreshold = Math.hypot(event.clientX - state.startClientX, event.clientY - state.startClientY) >= DATA_GRID_COLUMN_LAYOUT_DRAG_THRESHOLD;
if (!state.active && !movedPastThreshold) return;
event.preventDefault();
activateColumnDrag(event);
updateColumnDragPreview(event.clientX, event.clientY);
updateColumnDragTarget(event.clientY);
scheduleColumnDragAutoScroll();
}
function onWindowPointerUp(event: PointerEvent) {
const state = columnDragState;
if (!state || event.pointerId !== state.pointerId) return;
const fromDisplayPosition = state.fromDisplayPosition;
const toDisplayPosition = dragTargetDisplayPosition.value;
const shouldCommit = state.active && toDisplayPosition !== null && toDisplayPosition !== fromDisplayPosition;
if (state.active) event.preventDefault();
resetColumnDragState();
if (shouldCommit) props.grid?.moveDisplayableColumn(fromDisplayPosition, toDisplayPosition);
}
function onWindowPointerCancel(event: PointerEvent) {
if (event.pointerId === columnDragState?.pointerId) resetColumnDragState();
}
function onWindowKeyDown(event: KeyboardEvent) {
if (event.key !== "Escape" || !columnDragState) return;
event.preventDefault();
resetColumnDragState();
}
function columnDragPreviewMetrics(handle: HTMLElement, event: PointerEvent) {
const rowRect = handle.closest<HTMLElement>("[data-column-layout-row]")?.getBoundingClientRect();
const listRect = listRef.value?.getBoundingClientRect();
const previewWidth = rowRect?.width || listRect?.width || 288;
const rowLeft = rowRect?.width ? rowRect.left : (listRect?.left ?? event.clientX);
const rowTop = rowRect?.height ? rowRect.top : event.clientY - DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT / 2;
return {
previewOffsetX: Math.min(previewWidth, Math.max(0, event.clientX - rowLeft)),
previewOffsetY: Math.min(DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT, Math.max(0, event.clientY - rowTop)),
previewWidth,
};
}
function startColumnDrag(option: DataGridColumnLayoutOption, event: PointerEvent) {
if (!columnReorderEnabled.value || event.button !== 0) return;
resetColumnDragState();
const handle = event.currentTarget as HTMLElement;
columnDragState = {
pointerId: event.pointerId,
fromDisplayPosition: option.displayPosition,
startClientX: event.clientX,
startClientY: event.clientY,
lastClientX: event.clientX,
lastClientY: event.clientY,
active: false,
handle,
option,
...columnDragPreviewMetrics(handle, event),
previousBodyUserSelect: "",
};
window.addEventListener("pointermove", onWindowPointerMove, true);
window.addEventListener("pointerup", onWindowPointerUp, true);
window.addEventListener("pointercancel", onWindowPointerCancel, true);
window.addEventListener("blur", resetColumnDragState);
window.addEventListener("keydown", onWindowKeyDown, true);
try {
handle.setPointerCapture?.(event.pointerId);
} catch (error) {
console.warn("[DBX][DataGridColumnLayoutPopover:pointer-capture]", error);
}
}
watch(columnSearch, () => {
resetColumnDragState();
resetListScroll();
});
watch(popoverOpen, (open) => {
if (!open) resetColumnDragState();
});
onBeforeUnmount(resetColumnDragState);
</script>
<template>
<Popover v-model:open="popoverOpen">
<PopoverTrigger as-child>
<Button
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 }]"
: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>
</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>
<div class="border-b bg-muted/40 px-2 py-1.5">
<div class="flex items-center justify-between gap-2">
<div class="text-xs font-semibold">{{ t("grid.columnVisibility") }}</div>
<div class="text-[10px] text-muted-foreground tabular-nums">{{ grid?.visibleColumnCount ?? 0 }}/{{ grid?.displayableColumnCount ?? 0 }}</div>
</div>
</div>
<div class="flex items-center gap-1.5 border-b px-2 py-1.5">
<Search class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<input v-model="columnSearch" autocapitalize="off" autocorrect="off" spellcheck="false" class="h-6 min-w-0 flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground" :placeholder="t('grid.searchColumns')" />
</div>
<div ref="listRef" data-column-layout-list class="max-h-72 overflow-auto" :style="{ maxHeight: `${DATA_GRID_COLUMN_LAYOUT_VIEWPORT_HEIGHT}px` }" @scroll="onListScroll">
<div class="relative" :style="listContentStyle">
<div :style="renderedOptionsStyle">
<div
v-for="option in renderedOptions"
:key="option.key"
data-column-layout-row
:data-display-position="option.displayPosition"
class="grid w-full grid-cols-[1.5rem_minmax(0,1fr)] items-center px-1 text-xs hover:bg-accent"
:class="{ 'opacity-25': draggedDisplayPosition === option.displayPosition }"
:style="columnLayoutRowStyle(option)"
>
<button
type="button"
data-column-drag-handle
class="flex h-5 w-5 touch-none items-center justify-center justify-self-center rounded border border-transparent text-muted-foreground"
:class="columnReorderEnabled ? 'cursor-move hover:border-border hover:bg-background' : 'cursor-not-allowed opacity-30'"
:disabled="!columnReorderEnabled"
:aria-label="`${columnReorderEnabled ? t('grid.columnReorderHint') : t('grid.columnReorderSearchHint')} ${option.column}`"
:title="columnReorderEnabled ? t('grid.columnReorderHint') : t('grid.columnReorderSearchHint')"
@pointerdown.stop.prevent="startColumnDrag(option, $event)"
>
<GripVertical class="h-3.5 w-3.5" />
</button>
<button type="button" data-column-visibility-toggle class="grid h-full min-w-0 grid-cols-[1.5rem_minmax(0,1fr)] items-center text-left" :aria-pressed="option.visible" @click="grid?.toggleColumnVisibility(option.index)">
<span class="flex h-4 w-4 items-center justify-center rounded border" :class="option.visible ? 'border-primary bg-primary text-primary-foreground' : 'border-border bg-background text-transparent'">
<Check class="h-3 w-3 stroke-[3]" />
</span>
<span class="flex min-w-0 items-baseline gap-1.5">
<span class="truncate font-mono text-xs" :title="option.column">{{ option.column }}</span>
<span v-if="option.comment" class="truncate text-[10px] text-muted-foreground" :title="option.comment">{{ option.comment }}</span>
</span>
</button>
</div>
</div>
<div
v-if="draggedDisplayPosition !== null && dragInsertionIndex !== null"
data-column-drop-indicator
class="pointer-events-none absolute left-1 right-1 z-10 h-0.5 -translate-y-1/2 rounded-full bg-primary shadow-sm"
:style="{ top: `${dragInsertionIndex * DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT}px` }"
/>
</div>
<div v-if="columnLayoutOptions.length === 0" class="px-2 py-6 text-center text-xs text-muted-foreground">
{{ t("grid.noSearchResults") }}
</div>
</div>
<div class="flex flex-col gap-1 border-t bg-muted/30 px-2 py-1.5">
<span class="text-[11px] leading-4 text-muted-foreground">
{{ t("grid.columnVisibilityHint") }}
{{ columnReorderEnabled ? t("grid.columnReorderHint") : t("grid.columnReorderSearchHint") }}
</span>
<div class="flex items-center justify-end gap-1">
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" :disabled="(grid?.displayableColumnCount ?? 0) <= 1" @click="grid?.invertColumnVisibility()">
{{ t("grid.invertColumnVisibility") }}
</Button>
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" :disabled="!grid?.hasCustomColumnOrder" @click="grid?.resetColumnOrder()">
{{ t("grid.resetColumnOrder") }}
</Button>
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" :disabled="(grid?.hiddenColumnCount ?? 0) === 0" @click="grid?.showAllColumns()">
{{ t("grid.showAllColumns") }}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
<Teleport to="body">
<div
v-if="dragPreviewOption"
data-column-drag-preview
aria-hidden="true"
class="pointer-events-none fixed left-0 top-0 z-[100] grid grid-cols-[1.5rem_1.5rem_minmax(0,1fr)] items-center rounded border bg-background px-1 text-xs shadow-lg ring-1 ring-primary/40 will-change-transform dark:bg-muted"
:style="dragPreviewStyle"
>
<span class="flex h-5 w-5 items-center justify-center text-muted-foreground">
<GripVertical class="h-3.5 w-3.5" />
</span>
<span class="flex h-4 w-4 items-center justify-center rounded border" :class="dragPreviewOption.visible ? 'border-primary bg-primary text-primary-foreground' : 'border-border bg-background text-transparent'">
<Check class="h-3 w-3 stroke-[3]" />
</span>
<span class="flex min-w-0 items-baseline gap-1.5">
<span class="truncate font-mono text-xs">{{ dragPreviewOption.column }}</span>
<span v-if="dragPreviewOption.comment" class="truncate text-[10px] text-muted-foreground">{{ dragPreviewOption.comment }}</span>
</span>
</div>
</Teleport>
</template>
@@ -0,0 +1,323 @@
// @vitest-environment happy-dom
import { createApp, nextTick, type App } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import type { DataGridColumnLayoutOption } from "@/composables/useDataGridColumnLayout";
import type { DataGridColumnLayoutHandle } from "../dataGridColumnLayoutPopover";
import { DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT, DATA_GRID_COLUMN_LAYOUT_VIEWPORT_HEIGHT, dataGridColumnLayoutDropTarget, dataGridColumnLayoutVirtualWindow } from "../dataGridColumnLayoutPopover";
vi.mock("@/components/ui/popover", async () => {
const { defineComponent, h, onMounted } = await import("vue");
const passthrough = defineComponent({
setup(_props, { slots }) {
return () => h("div", slots.default?.());
},
});
const popover = defineComponent({
props: { open: Boolean },
emits: ["update:open"],
setup(_props, { emit, slots }) {
onMounted(() => emit("update:open", true));
return () => h("div", { "data-test-popover-root": "" }, [h("button", { class: "test-close-popover", onClick: () => emit("update:open", false) }), slots.default?.()]);
},
});
return { Popover: popover, PopoverContent: passthrough, PopoverTrigger: passthrough };
});
vi.mock("@/components/ui/button", async () => {
const { defineComponent, h } = await import("vue");
return {
Button: defineComponent({
inheritAttrs: false,
setup(_props, { attrs, slots }) {
return () => h("button", attrs, slots.default?.());
},
}),
};
});
import DataGridColumnLayoutPopover from "../DataGridColumnLayoutPopover.vue";
const mountedApps: Array<{ app: App; host: HTMLElement }> = [];
function columnLayoutOptions(itemCount: number): DataGridColumnLayoutOption[] {
return Array.from({ length: itemCount }, (_, index) => ({
key: `column-${index}`,
column: `column_${index}`,
name: `column_${index}`,
index,
visible: true,
displayPosition: index,
}));
}
function createGrid(itemCount: number) {
const options = columnLayoutOptions(itemCount);
const toggleColumnVisibility = vi.fn();
const moveDisplayableColumn = vi.fn();
const grid: DataGridColumnLayoutHandle = {
visibleColumnCount: itemCount,
displayableColumnCount: itemCount,
hiddenColumnCount: 0,
orderedColumnLayoutOptions: options,
filteredColumnLayoutOptions: (search) => {
const normalizedSearch = search.trim().toLowerCase();
return normalizedSearch ? options.filter((option) => option.column.toLowerCase().includes(normalizedSearch)) : options;
},
toggleColumnVisibility,
showAllColumns: vi.fn(),
invertColumnVisibility: vi.fn(),
hasCustomColumnOrder: false,
moveDisplayableColumn,
resetColumnOrder: vi.fn(),
};
return { grid, moveDisplayableColumn, toggleColumnVisibility };
}
async function mountPopover(itemCount = 4) {
const gridState = createGrid(itemCount);
const host = document.createElement("div");
document.body.append(host);
const app = createApp(DataGridColumnLayoutPopover, { grid: gridState.grid });
app.use(i18n);
app.mount(host);
mountedApps.push({ app, host });
await nextTick();
await nextTick();
return { ...gridState, host, app };
}
function configureList(host: HTMLElement, itemCount: number) {
const list = host.querySelector<HTMLElement>("[data-column-layout-list]")!;
Object.defineProperties(list, {
clientHeight: { configurable: true, value: 288 },
scrollHeight: { configurable: true, value: itemCount * DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT },
});
list.getBoundingClientRect = () => ({
left: 0,
right: 288,
top: 0,
bottom: 288,
width: 288,
height: 288,
x: 0,
y: 0,
toJSON: () => ({}),
});
return list;
}
function dispatchPointer(target: EventTarget, type: string, options: { pointerId?: number; clientX?: number; clientY: number }) {
target.dispatchEvent(
new PointerEvent(type, {
bubbles: true,
cancelable: true,
button: 0,
pointerId: options.pointerId ?? 1,
clientX: options.clientX ?? 20,
clientY: options.clientY,
}),
);
}
beforeEach(() => {
i18n.global.locale.value = "en";
});
afterEach(() => {
for (const { app, host } of mountedApps.splice(0)) {
app.unmount();
host.remove();
}
document.body.innerHTML = "";
document.body.style.userSelect = "";
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("data grid column layout popover", () => {
it("renders only the visible field window plus a bounded buffer", () => {
const window = dataGridColumnLayoutVirtualWindow({
itemCount: 500,
scrollTop: 1400,
viewportHeight: 280,
});
expect(window).toEqual({
start: 44,
end: 66,
offsetTop: 44 * DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT,
totalHeight: 500 * DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT,
});
});
it("clamps the virtual field window at the list boundaries", () => {
expect(dataGridColumnLayoutVirtualWindow({ itemCount: 4, scrollTop: 9999 })).toMatchObject({
start: 0,
end: 4,
totalHeight: 4 * DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT,
});
});
it("maps row halves to symmetric insertion positions", () => {
expect(dataGridColumnLayoutDropTarget({ clientY: 2, listTop: 0, scrollTop: 0, itemCount: 4, fromDisplayPosition: 2 })).toEqual({
insertionIndex: 0,
toDisplayPosition: 0,
});
expect(dataGridColumnLayoutDropTarget({ clientY: 50, listTop: 0, scrollTop: 0, itemCount: 4, fromDisplayPosition: 0 })).toEqual({
insertionIndex: 2,
toDisplayPosition: 1,
});
});
it("separates the drag handle from the visibility toggle", async () => {
const { host, toggleColumnVisibility } = await mountPopover(3);
const handles = host.querySelectorAll<HTMLButtonElement>("[data-column-drag-handle]");
const visibilityToggles = host.querySelectorAll<HTMLButtonElement>("[data-column-visibility-toggle]");
const rows = host.querySelectorAll<HTMLElement>("[data-column-layout-row]");
expect(handles[0]?.className).toContain("cursor-move");
expect(rows[0]?.className).not.toContain("cursor-grab");
handles[1]?.click();
expect(toggleColumnVisibility).not.toHaveBeenCalled();
visibilityToggles[1]?.click();
expect(toggleColumnVisibility).toHaveBeenCalledOnce();
expect(toggleColumnVisibility).toHaveBeenCalledWith(1);
});
it("starts after five pixels, shows an insertion line, and commits once without toggling visibility", async () => {
const { host, moveDisplayableColumn, toggleColumnVisibility } = await mountPopover(4);
configureList(host, 4);
const handle = host.querySelector<HTMLButtonElement>("[data-column-drag-handle]")!;
dispatchPointer(handle, "pointerdown", { clientY: 14 });
dispatchPointer(window, "pointermove", { clientY: 18 });
await nextTick();
expect(host.querySelector("[data-column-drop-indicator]")).toBeNull();
expect(document.body.querySelector("[data-column-drag-preview]")).toBeNull();
dispatchPointer(window, "pointermove", { clientY: 50 });
await nextTick();
const dragPreview = document.body.querySelector<HTMLElement>("[data-column-drag-preview]")!;
const initialPreviewTransform = dragPreview.style.transform;
expect(host.querySelector<HTMLElement>("[data-column-drop-indicator]")?.style.top).toBe(`${2 * DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT}px`);
expect(dragPreview.textContent).toContain("column_0");
expect(initialPreviewTransform).toBe("translate3d(0px, 36px, 0)");
expect(host.querySelector<HTMLElement>('[data-display-position="1"]')?.style.transform).toBe(`translateY(-${DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT}px)`);
expect(document.body.style.userSelect).toBe("none");
dispatchPointer(window, "pointermove", { clientX: 36, clientY: 52 });
await nextTick();
expect(dragPreview.style.transform).not.toBe(initialPreviewTransform);
expect(dragPreview.style.transform).toBe("translate3d(16px, 38px, 0)");
dispatchPointer(window, "pointerup", { clientX: 36, clientY: 52 });
dispatchPointer(window, "pointerup", { clientX: 36, clientY: 52 });
await nextTick();
expect(moveDisplayableColumn).toHaveBeenCalledOnce();
expect(moveDisplayableColumn).toHaveBeenCalledWith(0, 1);
expect(toggleColumnVisibility).not.toHaveBeenCalled();
expect(document.body.querySelector("[data-column-drag-preview]")).toBeNull();
expect(document.body.style.userSelect).toBe("");
});
it("cancels without committing and disables handles while searching", async () => {
const { host, moveDisplayableColumn } = await mountPopover(4);
configureList(host, 4);
const handle = host.querySelector<HTMLButtonElement>("[data-column-drag-handle]")!;
dispatchPointer(handle, "pointerdown", { clientY: 14 });
dispatchPointer(window, "pointermove", { clientY: 50 });
dispatchPointer(window, "pointercancel", { clientY: 50 });
dispatchPointer(window, "pointerup", { clientY: 50 });
expect(moveDisplayableColumn).not.toHaveBeenCalled();
const search = host.querySelector<HTMLInputElement>("input")!;
search.value = "column_1";
search.dispatchEvent(new Event("input", { bubbles: true }));
await nextTick();
const filteredHandle = host.querySelector<HTMLButtonElement>("[data-column-drag-handle]")!;
expect(filteredHandle.disabled).toBe(true);
dispatchPointer(filteredHandle, "pointerdown", { clientY: 14 });
dispatchPointer(window, "pointermove", { clientY: 50 });
dispatchPointer(window, "pointerup", { clientY: 50 });
expect(moveDisplayableColumn).not.toHaveBeenCalled();
});
it("clears active drag state when the popover closes", async () => {
const { host, moveDisplayableColumn } = await mountPopover(4);
configureList(host, 4);
const handle = host.querySelector<HTMLButtonElement>("[data-column-drag-handle]")!;
dispatchPointer(handle, "pointerdown", { clientY: 14 });
dispatchPointer(window, "pointermove", { clientY: 50 });
await nextTick();
expect(host.querySelector("[data-column-drop-indicator]")).not.toBeNull();
host.querySelector<HTMLButtonElement>(".test-close-popover")?.click();
await nextTick();
expect(host.querySelector("[data-column-drop-indicator]")).toBeNull();
expect(document.body.querySelector("[data-column-drag-preview]")).toBeNull();
dispatchPointer(window, "pointerup", { clientY: 50 });
expect(moveDisplayableColumn).not.toHaveBeenCalled();
});
it("cancels active dragging on Escape and window blur", async () => {
const { host, moveDisplayableColumn } = await mountPopover(4);
configureList(host, 4);
const handle = host.querySelector<HTMLButtonElement>("[data-column-drag-handle]")!;
dispatchPointer(handle, "pointerdown", { pointerId: 3, clientY: 14 });
dispatchPointer(window, "pointermove", { pointerId: 3, clientY: 50 });
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }));
dispatchPointer(window, "pointerup", { pointerId: 3, clientY: 50 });
dispatchPointer(handle, "pointerdown", { pointerId: 4, clientY: 14 });
dispatchPointer(window, "pointermove", { pointerId: 4, clientY: 50 });
window.dispatchEvent(new Event("blur"));
dispatchPointer(window, "pointerup", { pointerId: 4, clientY: 50 });
expect(moveDisplayableColumn).not.toHaveBeenCalled();
expect(host.querySelector("[data-column-drop-indicator]")).toBeNull();
expect(document.body.querySelector("[data-column-drag-preview]")).toBeNull();
expect(document.body.style.userSelect).toBe("");
});
it("auto-scrolls virtualized fields and commits beyond the initial viewport", async () => {
const frames = new Map<number, FrameRequestCallback>();
let nextFrameId = 1;
vi.stubGlobal(
"requestAnimationFrame",
vi.fn((callback: FrameRequestCallback) => {
const frameId = nextFrameId++;
frames.set(frameId, callback);
return frameId;
}),
);
vi.stubGlobal(
"cancelAnimationFrame",
vi.fn((frameId: number) => frames.delete(frameId)),
);
const { host, moveDisplayableColumn } = await mountPopover(120);
const list = configureList(host, 120);
const handle = host.querySelector<HTMLButtonElement>("[data-column-drag-handle]")!;
dispatchPointer(handle, "pointerdown", { pointerId: 7, clientY: 14 });
dispatchPointer(window, "pointermove", { pointerId: 7, clientY: 286 });
for (let frameIndex = 0; frameIndex < 30; frameIndex += 1) {
const callbacks = [...frames.values()];
frames.clear();
callbacks.forEach((callback) => callback(frameIndex));
}
await nextTick();
expect(list.scrollTop).toBeGreaterThan(DATA_GRID_COLUMN_LAYOUT_VIEWPORT_HEIGHT);
expect(host.querySelector('[data-display-position="0"]')).toBeNull();
expect(document.body.querySelector("[data-column-drag-preview]")?.textContent).toContain("column_0");
dispatchPointer(window, "pointerup", { pointerId: 7, clientY: 286 });
expect(moveDisplayableColumn).toHaveBeenCalledOnce();
expect(moveDisplayableColumn.mock.calls[0]?.[1]).toBeGreaterThan(10);
});
});
@@ -0,0 +1,63 @@
import type { DataGridColumnLayoutOption } from "@/composables/useDataGridColumnLayout";
export const DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT = 28;
export const DATA_GRID_COLUMN_LAYOUT_VIEWPORT_HEIGHT = 288;
export const DATA_GRID_COLUMN_LAYOUT_VIRTUAL_THRESHOLD = 80;
export const DATA_GRID_COLUMN_LAYOUT_DRAG_THRESHOLD = 5;
const DATA_GRID_COLUMN_LAYOUT_BUFFER_ROWS = 6;
export interface DataGridColumnLayoutHandle {
visibleColumnCount: number;
displayableColumnCount: number;
hiddenColumnCount: number;
orderedColumnLayoutOptions: readonly DataGridColumnLayoutOption[];
filteredColumnLayoutOptions: (search: string) => DataGridColumnLayoutOption[];
toggleColumnVisibility: (columnIndex: number) => void;
showAllColumns: () => void;
invertColumnVisibility: () => void;
hasCustomColumnOrder: boolean;
moveDisplayableColumn: (fromDisplayableIndex: number, toDisplayableIndex: number) => void;
resetColumnOrder: () => void;
}
export interface DataGridColumnLayoutVirtualWindow {
start: number;
end: number;
offsetTop: number;
totalHeight: number;
}
export interface DataGridColumnLayoutDropTarget {
insertionIndex: number;
toDisplayPosition: number;
}
export function dataGridColumnLayoutDropTarget(options: { clientY: number; listTop: number; scrollTop: number; itemCount: number; fromDisplayPosition: number }): DataGridColumnLayoutDropTarget {
const itemCount = Math.max(0, options.itemCount);
const totalHeight = itemCount * DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT;
const relativeY = Math.min(totalHeight, Math.max(0, options.clientY - options.listTop + options.scrollTop));
const rowIndex = Math.min(Math.max(0, itemCount - 1), Math.floor(relativeY / DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT));
const offsetInRow = relativeY - rowIndex * DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT;
const insertionIndex = relativeY >= totalHeight || offsetInRow >= DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT / 2 ? Math.min(itemCount, rowIndex + 1) : rowIndex;
const targetBeforeRemoval = insertionIndex > options.fromDisplayPosition ? insertionIndex - 1 : insertionIndex;
return {
insertionIndex,
toDisplayPosition: Math.min(Math.max(0, itemCount - 1), Math.max(0, targetBeforeRemoval)),
};
}
export function dataGridColumnLayoutVirtualWindow(options: { itemCount: number; scrollTop: number; viewportHeight?: number }): DataGridColumnLayoutVirtualWindow {
const itemCount = Math.max(0, options.itemCount);
const viewportHeight = options.viewportHeight ?? DATA_GRID_COLUMN_LAYOUT_VIEWPORT_HEIGHT;
const requestedFirstVisibleRow = Math.floor(Math.max(0, options.scrollTop) / DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT);
const firstVisibleRow = Math.min(Math.max(0, itemCount - 1), requestedFirstVisibleRow);
const visibleRowCount = Math.ceil(viewportHeight / DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT);
const start = Math.max(0, firstVisibleRow - DATA_GRID_COLUMN_LAYOUT_BUFFER_ROWS);
const end = Math.min(itemCount, firstVisibleRow + visibleRowCount + DATA_GRID_COLUMN_LAYOUT_BUFFER_ROWS);
return {
start,
end,
offsetTop: start * DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT,
totalHeight: itemCount * DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT,
};
}