feat(grid): persist column layouts and improve drag-to-reorder
This commit is contained in:
1 parent
f5c29f08f1
commit
44f748d3a1
24 files changed
+1532
-276
No files matched your search
@@ -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>
|
||||
+323
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user