feat(grid): add frozen columns
This commit is contained in:
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,
|
||||
|
||||
Reference in new issue
Block a user