feat(grid): add frozen columns

This commit is contained in:
gggaiitx authored and GitHub committed 2026-07-22 15:07:47 +08:00
1 parent 6e5f582320
commit dfab2d09ed
16 files changed
+809 -28

No files matched your search

@@ -187,4 +187,223 @@ describe("useDataGridColumnLayout", () => {
window.dispatchEvent(new PointerEvent("pointerup", { clientX: 180, clientY: 10 }));
expect(persist).toHaveBeenCalledTimes(1);
});
describe("frozen columns", () => {
it("starts with frozenColumnCount of 0", () => {
const scope = effectScope();
const state = scope.run(() =>
useDataGridColumnLayoutState({
columns: ref(["id", "name", "email"]),
sourceColumns: ref(undefined),
commentByColumn: ref(new Map()),
displayableColumnIndexes: ref([0, 1, 2]),
allNullColumnIndexes: ref([]),
columnOrderKeys: ref(["id\0\0", "name\0\0", "email\0\0"]),
layoutScopeKey: ref("frozen-test-layout"),
tableScopeKey: ref(""),
}),
)!;
expect(state.frozenColumnCount.value).toBe(0);
scope.stop();
});
it("freezeToColumn sets frozenColumnCount to visibleColIdx + 1", () => {
const scope = effectScope();
const state = scope.run(() =>
useDataGridColumnLayoutState({
columns: ref(["id", "name", "email"]),
sourceColumns: ref(undefined),
commentByColumn: ref(new Map()),
displayableColumnIndexes: ref([0, 1, 2]),
allNullColumnIndexes: ref([]),
columnOrderKeys: ref(["id\0\0", "name\0\0", "email\0\0"]),
layoutScopeKey: ref("frozen-test-layout-2"),
tableScopeKey: ref(""),
}),
)!;
state.freezeToColumn(0);
expect(state.frozenColumnCount.value).toBe(1);
state.freezeToColumn(2);
expect(state.frozenColumnCount.value).toBe(3);
scope.stop();
});
it("unfreezeAllColumns resets frozenColumnCount to 0", () => {
const scope = effectScope();
const state = scope.run(() =>
useDataGridColumnLayoutState({
columns: ref(["id", "name", "email"]),
sourceColumns: ref(undefined),
commentByColumn: ref(new Map()),
displayableColumnIndexes: ref([0, 1, 2]),
allNullColumnIndexes: ref([]),
columnOrderKeys: ref(["id\0\0", "name\0\0", "email\0\0"]),
layoutScopeKey: ref("frozen-test-layout-3"),
tableScopeKey: ref(""),
}),
)!;
state.freezeToColumn(1);
expect(state.frozenColumnCount.value).toBe(2);
state.unfreezeAllColumns();
expect(state.frozenColumnCount.value).toBe(0);
scope.stop();
});
it("persists frozenColumnCount to localStorage", () => {
const scope = effectScope();
const state = scope.run(() =>
useDataGridColumnLayoutState({
columns: ref(["id", "name", "email"]),
sourceColumns: ref(undefined),
commentByColumn: ref(new Map()),
displayableColumnIndexes: ref([0, 1, 2]),
allNullColumnIndexes: ref([]),
columnOrderKeys: ref(["id\0\0", "name\0\0", "email\0\0"]),
layoutScopeKey: ref("frozen-persist-layout"),
tableScopeKey: ref(""),
}),
)!;
state.freezeToColumn(1);
const raw = localStorage.getItem("dbx-data-grid-frozen-columns:frozen-persist-layout");
expect(raw).not.toBeNull();
expect(JSON.parse(raw!)).toEqual({ version: 1, frozenCount: 2 });
scope.stop();
});
it("removes localStorage key when unfreezing all columns", () => {
const scope = effectScope();
const state = scope.run(() =>
useDataGridColumnLayoutState({
columns: ref(["id", "name", "email"]),
sourceColumns: ref(undefined),
commentByColumn: ref(new Map()),
displayableColumnIndexes: ref([0, 1, 2]),
allNullColumnIndexes: ref([]),
columnOrderKeys: ref(["id\0\0", "name\0\0", "email\0\0"]),
layoutScopeKey: ref("frozen-remove-layout"),
tableScopeKey: ref(""),
}),
)!;
state.freezeToColumn(0);
expect(localStorage.getItem("dbx-data-grid-frozen-columns:frozen-remove-layout")).not.toBeNull();
state.unfreezeAllColumns();
expect(localStorage.getItem("dbx-data-grid-frozen-columns:frozen-remove-layout")).toBeNull();
scope.stop();
});
it("restores frozenColumnCount from localStorage on load", async () => {
localStorage.setItem("dbx-data-grid-frozen-columns:frozen-restore-layout", JSON.stringify({ version: 1, frozenCount: 2 }));
const scope = effectScope();
const state = scope.run(() =>
useDataGridColumnLayoutState({
columns: ref(["id", "name", "email"]),
sourceColumns: ref(undefined),
commentByColumn: ref(new Map()),
displayableColumnIndexes: ref([0, 1, 2]),
allNullColumnIndexes: ref([]),
columnOrderKeys: ref(["id\0\0", "name\0\0", "email\0\0"]),
layoutScopeKey: ref("frozen-restore-layout"),
tableScopeKey: ref(""),
}),
)!;
await nextTick();
expect(state.frozenColumnCount.value).toBe(2);
scope.stop();
});
it("restores the pre-freeze order after reload before unfreezing selected columns", async () => {
const options = {
columns: ref(["id", "name", "email"]),
sourceColumns: ref(undefined),
commentByColumn: ref(new Map()),
displayableColumnIndexes: ref([0, 1, 2]),
allNullColumnIndexes: ref([]),
columnOrderKeys: ref(["id\0\0", "name\0\0", "email\0\0"]),
layoutScopeKey: ref("frozen-reload-layout"),
tableScopeKey: ref(""),
};
const firstScope = effectScope();
const firstState = firstScope.run(() => useDataGridColumnLayoutState(options))!;
firstState.freezeSelectedColumns([2]);
expect(firstState.orderedDisplayableColumnIndexes.value).toEqual([2, 0, 1]);
firstScope.stop();
const reloadedScope = effectScope();
const reloadedState = reloadedScope.run(() => useDataGridColumnLayoutState(options))!;
await nextTick();
reloadedState.unfreezeAllColumns();
expect(reloadedState.frozenColumnCount.value).toBe(0);
expect(reloadedState.orderedDisplayableColumnIndexes.value).toEqual([0, 1, 2]);
reloadedScope.stop();
});
it("shrinks the persisted frozen count when visible columns are hidden", async () => {
const scope = effectScope();
const state = scope.run(() =>
useDataGridColumnLayoutState({
columns: ref(["id", "name", "email"]),
sourceColumns: ref(undefined),
commentByColumn: ref(new Map()),
displayableColumnIndexes: ref([0, 1, 2]),
allNullColumnIndexes: ref([]),
columnOrderKeys: ref(["id\0\0", "name\0\0", "email\0\0"]),
layoutScopeKey: ref("frozen-hidden-layout"),
tableScopeKey: ref(""),
}),
)!;
state.freezeToColumn(2);
state.toggleColumnVisibility(1);
await nextTick();
expect(state.frozenColumnCount.value).toBe(2);
expect(JSON.parse(localStorage.getItem("dbx-data-grid-frozen-columns:frozen-hidden-layout")!)).toMatchObject({ frozenCount: 2 });
scope.stop();
});
it("allows changing frozen count from one value to another", () => {
const scope = effectScope();
const state = scope.run(() =>
useDataGridColumnLayoutState({
columns: ref(["id", "name", "email", "phone"]),
sourceColumns: ref(undefined),
commentByColumn: ref(new Map()),
displayableColumnIndexes: ref([0, 1, 2, 3]),
allNullColumnIndexes: ref([]),
columnOrderKeys: ref(["id\0\0", "name\0\0", "email\0\0", "phone\0\0"]),
layoutScopeKey: ref("frozen-change-layout"),
tableScopeKey: ref(""),
}),
)!;
state.freezeToColumn(1);
expect(state.frozenColumnCount.value).toBe(2);
// 增加冻结列数
state.freezeToColumn(3);
expect(state.frozenColumnCount.value).toBe(4);
// 减少冻结列数
state.freezeToColumn(0);
expect(state.frozenColumnCount.value).toBe(1);
scope.stop();
});
});
});
@@ -3,11 +3,14 @@ import { columnOrderKeysForIndexes, isDefaultColumnOrder, moveVisibleColumnIndex
import { columnHeaderCanvasPointerDisabled, columnHeaderClickShouldBeSuppressed, columnHeaderPreviewOffsetForColumn, columnHeaderTooltipDisabled } from "@/lib/dataGrid/dataGridColumnHeaderInteraction";
import {
loadDataGridColumnOrder,
loadDataGridColumnFrozenState,
loadTableDataGridColumnOrder,
notifyTableDataGridColumnOrderChanged,
removeDataGridColumnOrder,
removeDataGridColumnFrozenCount,
removeTableDataGridColumnOrder,
saveDataGridColumnOrder,
saveDataGridColumnFrozenCount,
saveTableDataGridColumnOrder,
type TableDataGridColumnOrderChangedDetail,
} from "@/lib/dataGrid/dataGridColumnLayoutStorage";
@@ -82,6 +85,9 @@ export function useDataGridColumnLayoutState(options: {
const nullColumnsHidden = computed(() => (options.hideNullColumns === undefined ? localNullColumnsHidden.value : toValue(options.hideNullColumns)));
const autoHiddenNullColumnIndexes = ref<Set<number>>(new Set());
const persistedColumnOrderKeys = ref<string[]>([]);
const frozenColumnCount = ref(0);
// 冻结选中列前的列序快照,取消冻结时还原
const columnOrderSnapshotBeforeFreeze = ref<string[] | null>(null);
const orderedDisplayableColumnIndexes = computed(() => orderedColumnIndexes({ availableIndexes: toValue(options.displayableColumnIndexes), columnKeys: toValue(options.columnOrderKeys), orderedKeys: persistedColumnOrderKeys.value }));
const visibleColumnIndexes = computed(() => visibleColumnIndexesForFilter(orderedDisplayableColumnIndexes.value, hiddenColumnIndexes.value));
const displayableColumnCount = computed(() => toValue(options.displayableColumnIndexes).length);
@@ -111,6 +117,61 @@ export function useDataGridColumnLayoutState(options: {
const tableOrder = tableScopeKey ? loadTableDataGridColumnOrder(tableScopeKey) : [];
persistedColumnOrderKeys.value = tableOrder.length ? tableOrder : loadDataGridColumnOrder(toValue(options.layoutScopeKey), toValue(options.columnOrderKeys));
}
function loadFrozenColumnCount() {
const state = loadDataGridColumnFrozenState(toValue(options.layoutScopeKey));
frozenColumnCount.value = Math.min(state.frozenCount, visibleColumnIndexes.value.length);
columnOrderSnapshotBeforeFreeze.value = state.orderBeforeFreeze;
}
function setFrozenColumnCount(count: number) {
const clampedCount = Math.max(0, Math.min(count, visibleColumnIndexes.value.length));
frozenColumnCount.value = clampedCount;
if (clampedCount > 0) {
saveDataGridColumnFrozenCount(toValue(options.layoutScopeKey), clampedCount, columnOrderSnapshotBeforeFreeze.value);
} else {
removeDataGridColumnFrozenCount(toValue(options.layoutScopeKey));
}
}
function freezeToColumn(visibleColIdx: number) {
setFrozenColumnCount(visibleColIdx + 1);
}
function freezeSelectedColumns(selectedVisibleColIdxs: number[]) {
if (selectedVisibleColIdxs.length === 0) return;
const sorted = [...selectedVisibleColIdxs].sort((a, b) => a - b);
const visibleIdxs = visibleColumnIndexes.value;
// 获取选中列的 actualColIdx
const selectedActualIdxs = sorted.map((vIdx) => visibleIdxs[vIdx]).filter((idx): idx is number => idx !== undefined);
if (selectedActualIdxs.length === 0) return;
const selectedSet = new Set(selectedActualIdxs);
// 非选中列保持原序
const currentOrder = orderedDisplayableColumnIndexes.value;
const nonSelectedActualIdxs = currentOrder.filter((idx) => !selectedSet.has(idx));
// 保存冻结前的列序快照(仅在首次冻结时保存,避免连续冻结覆盖快照)
if (columnOrderSnapshotBeforeFreeze.value === null) {
columnOrderSnapshotBeforeFreeze.value = [...persistedColumnOrderKeys.value];
}
// 新列序:选中列在前 + 非选中列在后
persistColumnOrder([...selectedActualIdxs, ...nonSelectedActualIdxs]);
setFrozenColumnCount(selectedActualIdxs.length);
}
function unfreezeAllColumns() {
setFrozenColumnCount(0);
// 还原冻结前的列序
if (columnOrderSnapshotBeforeFreeze.value !== null) {
const snapshot = columnOrderSnapshotBeforeFreeze.value;
columnOrderSnapshotBeforeFreeze.value = null;
if (snapshot.length === 0) {
resetColumnOrder();
} else {
persistedColumnOrderKeys.value = snapshot;
saveDataGridColumnOrder(toValue(options.layoutScopeKey), toValue(options.columnOrderKeys), snapshot);
const tableScopeKey = toValue(options.tableScopeKey);
if (tableScopeKey) {
saveTableDataGridColumnOrder(tableScopeKey, snapshot);
notifyTableDataGridColumnOrderChanged(tableScopeKey);
}
}
}
}
function persistColumnOrder(indexes: number[]) {
const tableScopeKey = toValue(options.tableScopeKey);
if (isDefaultColumnOrder(toValue(options.displayableColumnIndexes), indexes)) {
@@ -179,7 +240,21 @@ export function useDataGridColumnLayoutState(options: {
}
watch([() => nullColumnsHidden.value, () => [...toValue(options.allNullColumnIndexes)], () => [...toValue(options.displayableColumnIndexes)]], ([hidden]) => applyNullColumnVisibility(hidden as boolean), { immediate: true });
watch([() => toValue(options.layoutScopeKey), () => toValue(options.tableScopeKey)], loadColumnOrder, { immediate: true });
watch(
() => visibleColumnIndexes.value.length,
(visibleCount) => {
if (frozenColumnCount.value > visibleCount) setFrozenColumnCount(visibleCount);
},
{ flush: "sync" },
);
watch(
[() => toValue(options.layoutScopeKey), () => toValue(options.tableScopeKey)],
() => {
loadColumnOrder();
loadFrozenColumnCount();
},
{ immediate: true },
);
return {
hiddenColumnIndexes,
@@ -201,6 +276,10 @@ export function useDataGridColumnLayoutState(options: {
toggleAllNullColumns,
resetColumnVisibility,
onTableDataGridColumnOrderChanged,
frozenColumnCount,
freezeToColumn,
freezeSelectedColumns,
unfreezeAllColumns,
};
}
@@ -221,8 +300,10 @@ export function useDataGridColumnLayout(options: {
onCanvasDrawSchedule?: () => void;
onRefreshMetrics?: () => void;
onPersistColumnOrder?: (indexes: number[]) => void;
frozenColumnCount?: MaybeRefOrGetter<number>;
}) {
const renderedColumnOffsets = computed(() => dataGridColumnOffsets(toValue(options.renderedColumnWidths)));
const frozenColumnCount = computed(() => toValue(options.frozenColumnCount ?? 0));
const horizontalColumnWindow = computed(() =>
dataGridHorizontalColumnWindow({
widths: toValue(options.renderedColumnWidths),
@@ -238,15 +319,46 @@ export function useDataGridColumnLayout(options: {
const columnNames = toValue(options.columnNames);
const visibleIndexes = toValue(options.visibleColumnIndexes);
const window = horizontalColumnWindow.value;
return visibleIndexes.slice(window.start, window.end).map((actualColIdx, offset) => ({
visibleColIdx: window.start + offset,
actualColIdx,
name: columnNames[actualColIdx] ?? "",
}));
const frozen = frozenColumnCount.value;
// 无冻结列时保持原始行为
if (frozen === 0) {
return visibleIndexes.slice(window.start, window.end).map((actualColIdx, offset) => ({
visibleColIdx: window.start + offset,
actualColIdx,
name: columnNames[actualColIdx] ?? "",
}));
}
const result: RenderedDataGridColumn[] = [];
// 冻结列始终包含在渲染窗口中(0 ~ frozen-1)
for (let i = 0; i < frozen && i < visibleIndexes.length; i++) {
result.push({ visibleColIdx: i, actualColIdx: visibleIndexes[i], name: columnNames[visibleIndexes[i]] ?? "" });
}
// 非冻结列从 max(window.start, frozen) 到 window.end
const nonFrozenStart = Math.max(window.start, frozen);
for (let i = nonFrozenStart; i < window.end && i < visibleIndexes.length; i++) {
result.push({ visibleColIdx: i, actualColIdx: visibleIndexes[i], name: columnNames[visibleIndexes[i]] ?? "" });
}
return result;
});
// 冻结列占位宽度:非冻结列的前置占位需要排除冻结列
const frozenWidth = computed(() => renderedColumnOffsets.value[frozenColumnCount.value] ?? 0);
const horizontalColumnWindowBeforeWidth = computed(() => {
const window = horizontalColumnWindow.value;
const frozen = frozenColumnCount.value;
if (frozen === 0) return window.beforeWidth ?? 0;
// 非冻结列从 max(window.start, frozen) 开始,前置占位 = 该列偏移 - 冻结列宽度
const nonFrozenStart = Math.max(window.start, frozen);
return Math.max(0, (renderedColumnOffsets.value[nonFrozenStart] ?? 0) - (renderedColumnOffsets.value[frozen] ?? 0));
});
function renderedColumnStyle(visibleColIdx: number) {
return { width: `var(--col-w-${visibleColIdx})` };
const style: Record<string, string | number> = { width: `var(--col-w-${visibleColIdx})` };
if (visibleColIdx < frozenColumnCount.value) {
style.position = "sticky";
style.left = `${columnContentOffsetLeft(visibleColIdx)}px`;
style.zIndex = 10;
}
return style;
}
function columnContentOffsetLeft(visibleColIdx: number): number {
@@ -473,6 +585,10 @@ export function useDataGridColumnLayout(options: {
function columnHeaderStyle(visibleColIdx: number) {
const style = renderedColumnStyle(visibleColIdx);
const offset = columnHeaderPreviewOffset(visibleColIdx);
// 冻结列头需要更高 z-index(与列头行号 z-20 一致)以覆盖非冻结列头
if (visibleColIdx < frozenColumnCount.value) {
(style as Record<string, string | number>).zIndex = 20;
}
if (!offset) return style;
return { ...style, transform: `translateX(${offset}px)`, transition: columnHeaderDragState.value?.sourceVisibleIndex === visibleColIdx ? undefined : "transform 120ms ease-out" };
}
@@ -500,6 +616,8 @@ export function useDataGridColumnLayout(options: {
renderedGridColumns,
renderedColumnStyle,
columnContentOffsetLeft,
frozenWidth,
horizontalColumnWindowBeforeWidth,
columnHeaderDragState,
columnHeaderResizeActive,
columnHeaderTooltipsDisabled,