feat(grid): decompose data grid interaction surfaces
This commit is contained in:
1 parent
71497f0851
commit
8825b6335f
56 files changed
+6446
-4051
No files matched your search
@@ -0,0 +1,31 @@
|
||||
import { effectScope, nextTick, ref } from "vue";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { useDataGridAsyncSurface } from "@/composables/useDataGridAsyncSurface";
|
||||
|
||||
describe("useDataGridAsyncSurface", () => {
|
||||
it("defers mounting until first open and keeps the surface mounted afterward", async () => {
|
||||
const open = ref(false);
|
||||
const scope = effectScope();
|
||||
const mounted = scope.run(() => useDataGridAsyncSurface(open))!;
|
||||
|
||||
expect(mounted.value).toBe(false);
|
||||
|
||||
open.value = true;
|
||||
await nextTick();
|
||||
expect(mounted.value).toBe(true);
|
||||
|
||||
open.value = false;
|
||||
await nextTick();
|
||||
expect(mounted.value).toBe(true);
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it("mounts immediately when restored with an already-open surface", () => {
|
||||
const scope = effectScope();
|
||||
const mounted = scope.run(() => useDataGridAsyncSurface(ref(true)))!;
|
||||
|
||||
expect(mounted.value).toBe(true);
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { computed, nextTick } from "vue";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { useDataGridAutoRefresh } from "@/composables/useDataGridAutoRefresh";
|
||||
|
||||
describe("useDataGridAutoRefresh", () => {
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("refreshes on the configured interval when enabled", async () => {
|
||||
vi.useFakeTimers();
|
||||
const refresh = vi.fn();
|
||||
const autoRefresh = useDataGridAutoRefresh({ canRefresh: computed(() => true), refresh, initialIntervalSeconds: 5 });
|
||||
|
||||
autoRefresh.toggle();
|
||||
vi.advanceTimersByTime(5000);
|
||||
await nextTick();
|
||||
|
||||
expect(refresh).toHaveBeenCalledOnce();
|
||||
autoRefresh.stop();
|
||||
});
|
||||
|
||||
it("does not refresh while the operation is unavailable", () => {
|
||||
vi.useFakeTimers();
|
||||
const refresh = vi.fn();
|
||||
const autoRefresh = useDataGridAutoRefresh({ canRefresh: computed(() => false), refresh, initialIntervalSeconds: 1 });
|
||||
|
||||
autoRefresh.toggle();
|
||||
vi.advanceTimersByTime(1000);
|
||||
|
||||
expect(refresh).not.toHaveBeenCalled();
|
||||
autoRefresh.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { effectScope } from "vue";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { useDataGridCanvasRuntime, type DataGridAnimationFrameDriver } from "@/composables/useDataGridCanvasRuntime";
|
||||
import { useDataGridScrollbars } from "@/composables/useDataGridScrollbars";
|
||||
|
||||
function createFrameDriver() {
|
||||
let nextId = 0;
|
||||
const callbacks = new Map<number, FrameRequestCallback>();
|
||||
const driver: DataGridAnimationFrameDriver = {
|
||||
request(callback) {
|
||||
const id = nextId++;
|
||||
callbacks.set(id, callback);
|
||||
return id;
|
||||
},
|
||||
cancel(id) {
|
||||
callbacks.delete(id);
|
||||
},
|
||||
};
|
||||
return { driver, callbacks, run: (id: number) => callbacks.get(id)?.(0) };
|
||||
}
|
||||
|
||||
function createObserver() {
|
||||
const observe = vi.fn();
|
||||
const disconnect = vi.fn();
|
||||
return { observer: { observe, disconnect } as unknown as ResizeObserver, observe, disconnect };
|
||||
}
|
||||
|
||||
describe("data grid visual runtimes", () => {
|
||||
it("coalesces canvas frames and cancels all resources on dispose", () => {
|
||||
const frames = createFrameDriver();
|
||||
const resize = createObserver();
|
||||
const draw = vi.fn();
|
||||
const viewport = { children: [] } as unknown as Element;
|
||||
const runtime = useDataGridCanvasRuntime({
|
||||
draw,
|
||||
syncViewport: vi.fn(),
|
||||
getViewport: () => viewport,
|
||||
refreshPixelRatio: vi.fn(),
|
||||
frameDriver: frames.driver,
|
||||
createResizeObserver: () => resize.observer,
|
||||
});
|
||||
|
||||
runtime.observeViewport();
|
||||
runtime.scheduleDraw();
|
||||
runtime.scheduleDraw();
|
||||
runtime.schedulePixelRatioRefresh();
|
||||
expect(frames.callbacks.size).toBe(2);
|
||||
|
||||
runtime.dispose();
|
||||
expect(frames.callbacks.size).toBe(0);
|
||||
expect(resize.disconnect).toHaveBeenCalledOnce();
|
||||
expect(draw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps a large scroll burst within one scheduled draw frame", () => {
|
||||
const frames = createFrameDriver();
|
||||
const draw = vi.fn();
|
||||
const runtime = useDataGridCanvasRuntime({
|
||||
draw,
|
||||
syncViewport: vi.fn(),
|
||||
getViewport: () => ({ children: [] }) as unknown as Element,
|
||||
frameDriver: frames.driver,
|
||||
});
|
||||
|
||||
for (let index = 0; index < 10_000; index++) runtime.scheduleDraw();
|
||||
|
||||
expect(frames.callbacks.size).toBe(1);
|
||||
const frameId = [...frames.callbacks.keys()][0];
|
||||
frames.run(frameId);
|
||||
expect(draw).toHaveBeenCalledOnce();
|
||||
runtime.dispose();
|
||||
});
|
||||
|
||||
it("keeps the latest drag coordinate and disconnects observed scroll content", () => {
|
||||
const frames = createFrameDriver();
|
||||
const resize = createObserver();
|
||||
const horizontalDrag = vi.fn();
|
||||
const child = { children: [] } as unknown as Element;
|
||||
const scroller = { children: [child] } as unknown as Element;
|
||||
const runtime = useDataGridScrollbars({
|
||||
update: vi.fn(),
|
||||
getScroller: () => scroller,
|
||||
applyHorizontalDrag: horizontalDrag,
|
||||
applyVerticalDrag: vi.fn(),
|
||||
frameDriver: frames.driver,
|
||||
createResizeObserver: () => resize.observer,
|
||||
});
|
||||
|
||||
runtime.observeScroller();
|
||||
runtime.scheduleHorizontalDrag(10);
|
||||
runtime.scheduleHorizontalDrag(25);
|
||||
runtime.flushHorizontalDrag();
|
||||
|
||||
expect(horizontalDrag).toHaveBeenCalledWith(25);
|
||||
expect(resize.observe).toHaveBeenCalledTimes(2);
|
||||
runtime.dispose();
|
||||
expect(resize.disconnect).toHaveBeenCalledOnce();
|
||||
expect(frames.callbacks.size).toBe(0);
|
||||
});
|
||||
|
||||
it("tears down pending canvas work when its Vue scope stops", () => {
|
||||
const frames = createFrameDriver();
|
||||
const resize = createObserver();
|
||||
const draw = vi.fn();
|
||||
const syncViewport = vi.fn();
|
||||
const scope = effectScope();
|
||||
const runtime = scope.run(() =>
|
||||
useDataGridCanvasRuntime({
|
||||
draw,
|
||||
syncViewport,
|
||||
getViewport: () => ({ children: [] }) as unknown as Element,
|
||||
frameDriver: frames.driver,
|
||||
createResizeObserver: () => resize.observer,
|
||||
}),
|
||||
)!;
|
||||
|
||||
runtime.observeViewport();
|
||||
runtime.scheduleDraw();
|
||||
const lateDraw = [...frames.callbacks.values()][0];
|
||||
scope.stop();
|
||||
|
||||
expect(runtime.disposed).toBe(true);
|
||||
expect(runtime.active).toBe(false);
|
||||
expect(frames.callbacks.size).toBe(0);
|
||||
expect(resize.disconnect).toHaveBeenCalledOnce();
|
||||
lateDraw(0);
|
||||
expect(draw).not.toHaveBeenCalled();
|
||||
expect(syncViewport).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not revive scroll work after disposal", () => {
|
||||
const frames = createFrameDriver();
|
||||
const resize = createObserver();
|
||||
const update = vi.fn();
|
||||
const verticalDrag = vi.fn();
|
||||
const runtime = useDataGridScrollbars({
|
||||
update,
|
||||
getScroller: () => ({ children: [] }) as unknown as Element,
|
||||
applyHorizontalDrag: vi.fn(),
|
||||
applyVerticalDrag: verticalDrag,
|
||||
frameDriver: frames.driver,
|
||||
createResizeObserver: () => resize.observer,
|
||||
});
|
||||
|
||||
runtime.observeScroller();
|
||||
runtime.scheduleVerticalDrag(42);
|
||||
const lateCallbacks = [...frames.callbacks.values()];
|
||||
runtime.dispose();
|
||||
runtime.resume();
|
||||
runtime.scheduleUpdate();
|
||||
runtime.flushVerticalDrag();
|
||||
lateCallbacks.forEach((callback) => callback(0));
|
||||
|
||||
expect(runtime.disposed).toBe(true);
|
||||
expect(runtime.active).toBe(false);
|
||||
expect(frames.callbacks.size).toBe(0);
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
expect(verticalDrag).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { effectScope, ref } from "vue";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { dataGridColumnOffsets, dataGridHorizontalColumnWindow, useDataGridColumnLayout, useDataGridColumnLayoutState } from "@/composables/useDataGridColumnLayout";
|
||||
|
||||
describe("useDataGridColumnLayout", () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
it("builds cumulative offsets", () => {
|
||||
expect(dataGridColumnOffsets([80, 120, 60])).toEqual([0, 80, 200, 260]);
|
||||
});
|
||||
|
||||
it("windows columns while preserving spacer widths", () => {
|
||||
const widths = [100, 100, 100, 100, 100];
|
||||
const offsets = dataGridColumnOffsets(widths);
|
||||
expect(dataGridHorizontalColumnWindow({ widths, offsets, columnCount: 5, scrollLeft: 250, viewportWidth: 100, rowNumberWidth: 40, bufferPx: 0 })).toEqual({ start: 2, end: 4, beforeWidth: 200, afterWidth: 100 });
|
||||
});
|
||||
|
||||
it("returns an empty window without columns", () => {
|
||||
expect(dataGridHorizontalColumnWindow({ widths: [], offsets: [0], columnCount: 0, scrollLeft: 0, viewportWidth: 0, rowNumberWidth: 40, bufferPx: 900 })).toEqual({
|
||||
start: 0,
|
||||
end: 0,
|
||||
beforeWidth: 0,
|
||||
afterWidth: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("owns visibility, null-column toggles, and persisted ordering", () => {
|
||||
const scope = effectScope();
|
||||
const state = scope.run(() =>
|
||||
useDataGridColumnLayoutState({
|
||||
columns: ref(["id", "name", "empty"]),
|
||||
sourceColumns: ref(undefined),
|
||||
commentByColumn: ref(new Map()),
|
||||
displayableColumnIndexes: ref([0, 1, 2]),
|
||||
allNullColumnIndexes: ref([2]),
|
||||
columnOrderKeys: ref(["id\0\0", "name\0\0", "empty\0\0"]),
|
||||
layoutScopeKey: ref("test-layout"),
|
||||
tableScopeKey: ref(""),
|
||||
}),
|
||||
)!;
|
||||
|
||||
state.toggleColumnVisibility(1);
|
||||
expect(state.visibleColumnIndexes.value).toEqual([0, 2]);
|
||||
state.toggleAllNullColumns();
|
||||
expect(state.visibleColumnIndexes.value).toEqual([0]);
|
||||
state.showAllColumns();
|
||||
expect(state.visibleColumnIndexes.value).toEqual([0, 1, 2]);
|
||||
state.persistColumnOrder([1, 0, 2]);
|
||||
expect(state.orderedDisplayableColumnIndexes.value).toEqual([1, 0, 2]);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it("keeps a new resize active when the previous resize completion frame is pending", () => {
|
||||
const frames = new Map<number, FrameRequestCallback>();
|
||||
let nextFrame = 1;
|
||||
vi.stubGlobal(
|
||||
"requestAnimationFrame",
|
||||
vi.fn((callback: FrameRequestCallback) => {
|
||||
const frame = nextFrame++;
|
||||
frames.set(frame, callback);
|
||||
return frame;
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal(
|
||||
"cancelAnimationFrame",
|
||||
vi.fn((frame: number) => frames.delete(frame)),
|
||||
);
|
||||
|
||||
const scope = effectScope();
|
||||
const layout = scope.run(() =>
|
||||
useDataGridColumnLayout({
|
||||
columnNames: ref(["id"]),
|
||||
visibleColumnIndexes: ref([0]),
|
||||
renderedColumnWidths: ref([100]),
|
||||
scrollLeft: ref(0),
|
||||
viewportWidth: ref(400),
|
||||
rowNumberWidth: 40,
|
||||
}),
|
||||
)!;
|
||||
|
||||
layout.startColumnHeaderResize(0, new MouseEvent("mousedown"));
|
||||
window.dispatchEvent(new MouseEvent("mouseup"));
|
||||
expect(frames.size).toBe(1);
|
||||
|
||||
layout.startColumnHeaderResize(0, new MouseEvent("mousedown"));
|
||||
expect(frames.size).toBe(0);
|
||||
expect(layout.columnHeaderResizeActive.value).toBe(true);
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it("commits column drag order and removes global interaction state on disposal", () => {
|
||||
vi.stubGlobal(
|
||||
"requestAnimationFrame",
|
||||
vi.fn((callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("cancelAnimationFrame", vi.fn());
|
||||
|
||||
const first = document.createElement("div");
|
||||
first.dataset.visibleColIndex = "0";
|
||||
first.getBoundingClientRect = () => ({ left: 0, width: 100, right: 100, top: 0, bottom: 20, height: 20, x: 0, y: 0, toJSON: () => ({}) });
|
||||
const second = document.createElement("div");
|
||||
second.dataset.visibleColIndex = "1";
|
||||
second.getBoundingClientRect = () => ({ left: 100, width: 100, right: 200, top: 0, bottom: 20, height: 20, x: 100, y: 0, toJSON: () => ({}) });
|
||||
const header = document.createElement("div");
|
||||
header.append(first, second);
|
||||
const persist = vi.fn();
|
||||
|
||||
const scope = effectScope();
|
||||
const layout = scope.run(() =>
|
||||
useDataGridColumnLayout({
|
||||
columnNames: ref(["id", "name"]),
|
||||
visibleColumnIndexes: ref([0, 1]),
|
||||
renderedColumnWidths: ref([100, 100]),
|
||||
scrollLeft: ref(0),
|
||||
viewportWidth: ref(400),
|
||||
rowNumberWidth: 40,
|
||||
headerRef: ref(header),
|
||||
onPersistColumnOrder: persist,
|
||||
}),
|
||||
)!;
|
||||
|
||||
layout.startColumnHeaderDrag(0, new PointerEvent("pointerdown", { button: 0, clientX: 20, clientY: 10 }));
|
||||
window.dispatchEvent(new PointerEvent("pointermove", { clientX: 180, clientY: 10 }));
|
||||
expect(document.body.style.userSelect).toBe("none");
|
||||
window.dispatchEvent(new PointerEvent("pointerup", { clientX: 180, clientY: 10 }));
|
||||
expect(persist).toHaveBeenCalledWith([1, 0]);
|
||||
expect(document.body.style.userSelect).toBe("");
|
||||
|
||||
layout.startColumnHeaderDrag(0, new PointerEvent("pointerdown", { button: 0, clientX: 20, clientY: 10 }));
|
||||
window.dispatchEvent(new PointerEvent("pointermove", { clientX: 180, clientY: 10 }));
|
||||
scope.stop();
|
||||
expect(document.body.style.userSelect).toBe("");
|
||||
window.dispatchEvent(new PointerEvent("pointerup", { clientX: 180, clientY: 10 }));
|
||||
expect(persist).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { computed, nextTick, ref } from "vue";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DATA_GRID_COL_AUTO_FIT_MAX_WIDTH, DATA_GRID_COL_MAX_WIDTH, DATA_GRID_COL_MIN_WIDTH } from "@/lib/dataGrid/dataGridColumnWidth";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DATA_GRID_COL_AUTO_FIT_MAX_WIDTH, DATA_GRID_COL_MIN_WIDTH } from "@/lib/dataGrid/dataGridColumnWidth";
|
||||
import { DATA_GRID_ROW_NUM_WIDTH, resizeDataGridColumnWidth, useDataGridColumnResize } from "@/composables/useDataGridColumnResize";
|
||||
|
||||
function createResizeState(options: { columns: string[]; rows: Array<Array<string | number | boolean | null>>; columnIndexes?: number[]; density?: "compact" | "standard" | "comfortable"; compactColumnHeaderActions?: boolean }) {
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { effectScope, nextTick, ref } from "vue";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useDataGridConditionEditor } from "@/composables/useDataGridConditionEditor";
|
||||
import { rememberDataGridConditionHistory } from "@/lib/dataGrid/dataGridConditionHistory";
|
||||
|
||||
const storage = new Map<string, string>();
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => storage.set(key, value),
|
||||
removeItem: (key: string) => storage.delete(key),
|
||||
clear: () => storage.clear(),
|
||||
});
|
||||
|
||||
function keyboardEvent(key: string) {
|
||||
return { key, shiftKey: false, preventDefault: vi.fn() } as unknown as KeyboardEvent;
|
||||
}
|
||||
|
||||
describe("useDataGridConditionEditor", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("replaces the active token and supports clamped keyboard navigation", async () => {
|
||||
const value = ref("status = cus");
|
||||
const editor = useDataGridConditionEditor({
|
||||
kind: "where",
|
||||
value,
|
||||
columns: ["customer_id", "customer_name"],
|
||||
historyScope: {},
|
||||
});
|
||||
|
||||
value.value = "status = cust";
|
||||
await nextTick();
|
||||
await vi.waitFor(() => expect(editor.suggestions.value.map((item) => item.value)).toEqual(["customer_id", "customer_name"]));
|
||||
expect(editor.navigate(1)).toBe(true);
|
||||
expect(editor.highlightedIndex.value).toBe(1);
|
||||
expect(editor.navigate(1)).toBe(true);
|
||||
expect(editor.highlightedIndex.value).toBe(1);
|
||||
expect(editor.accept()).toBe(true);
|
||||
expect(value.value).toBe("status = customer_name");
|
||||
});
|
||||
|
||||
it("ignores stale asynchronous suggestion responses", async () => {
|
||||
vi.useFakeTimers();
|
||||
const value = ref("");
|
||||
const resolvers = new Map<string, (values: string[]) => void>();
|
||||
const editor = useDataGridConditionEditor({
|
||||
kind: "where",
|
||||
value,
|
||||
historyScope: {},
|
||||
suggestionProvider: ({ token }) => new Promise((resolve) => resolvers.set(token, resolve)),
|
||||
suggestionDebounceMs: 10,
|
||||
});
|
||||
|
||||
value.value = "cus";
|
||||
await nextTick();
|
||||
vi.advanceTimersByTime(10);
|
||||
await nextTick();
|
||||
value.value = "ord";
|
||||
await nextTick();
|
||||
vi.advanceTimersByTime(10);
|
||||
await nextTick();
|
||||
|
||||
resolvers.get("ord")?.(["order_id"]);
|
||||
await Promise.resolve();
|
||||
expect(editor.suggestions.value.map((item) => item.value)).toEqual(["order_id"]);
|
||||
resolvers.get("cus")?.(["customer_id"]);
|
||||
await Promise.resolve();
|
||||
expect(editor.suggestions.value.map((item) => item.value)).toEqual(["order_id"]);
|
||||
});
|
||||
|
||||
it("loads, filters, accepts, and deletes scoped history", () => {
|
||||
const scope = { connectionId: "connection", database: "db", tableName: "users" };
|
||||
rememberDataGridConditionHistory("where", scope, "status = 'active'");
|
||||
rememberDataGridConditionHistory("where", scope, "customer_id > 10");
|
||||
const value = ref("status");
|
||||
const editor = useDataGridConditionEditor({ kind: "where", value, historyScope: scope });
|
||||
|
||||
editor.openHistory();
|
||||
expect(editor.suggestions.value.map((item) => item.value)).toEqual(["status = 'active'"]);
|
||||
expect(editor.accept(0)).toBe(true);
|
||||
expect(value.value).toBe("status = 'active'");
|
||||
|
||||
editor.openHistory();
|
||||
editor.deleteHistory("status = 'active'");
|
||||
expect(editor.suggestions.value).toEqual([]);
|
||||
expect(editor.historyOpen.value).toBe(true);
|
||||
});
|
||||
|
||||
it("aborts pending suggestion work when its scope is disposed", async () => {
|
||||
vi.useFakeTimers();
|
||||
const value = ref("");
|
||||
const aborted = vi.fn();
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
useDataGridConditionEditor({
|
||||
kind: "orderBy",
|
||||
value,
|
||||
historyScope: {},
|
||||
suggestionDebounceMs: 10,
|
||||
suggestionProvider: ({ signal }) => {
|
||||
signal.addEventListener("abort", aborted);
|
||||
return new Promise(() => {});
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
value.value = "created";
|
||||
await nextTick();
|
||||
vi.advanceTimersByTime(10);
|
||||
scope.stop();
|
||||
expect(aborted).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("maps Enter, Tab, arrows, and Escape without applying stale selections", async () => {
|
||||
const value = ref("");
|
||||
const editor = useDataGridConditionEditor({ kind: "orderBy", value, columns: ["name"], historyScope: {} });
|
||||
value.value = "na";
|
||||
await nextTick();
|
||||
await vi.waitFor(() => expect(editor.suggestions.value).toHaveLength(1));
|
||||
|
||||
const down = keyboardEvent("ArrowDown");
|
||||
expect(editor.handleKeydown(down)).toBe("navigate");
|
||||
expect(down.preventDefault).toHaveBeenCalledOnce();
|
||||
const tab = keyboardEvent("Tab");
|
||||
expect(editor.handleKeydown(tab)).toBe("accept");
|
||||
expect(value.value).toBe("name");
|
||||
|
||||
const enter = keyboardEvent("Enter");
|
||||
expect(editor.handleKeydown(enter)).toBe("apply");
|
||||
editor.openHistory();
|
||||
const escape = keyboardEvent("Escape");
|
||||
expect(editor.handleKeydown(escape)).toBe("dismiss");
|
||||
expect(editor.dropdownOpen.value).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildDataGridStructuredWhere, useDataGridFilterBuilder, type DataGridStructuredFilterRule } from "@/composables/useDataGridFilterBuilder";
|
||||
|
||||
describe("useDataGridFilterBuilder", () => {
|
||||
it("normalizes values when modes change", () => {
|
||||
const builder = useDataGridFilterBuilder({ columns: ["id"], createId: () => "rule-1", isComplete: () => true, buildCondition: async () => "id = 1" });
|
||||
builder.ensureRule();
|
||||
builder.updateRule("rule-1", { rawValue: "1", rawEndValue: "2", mode: "is-null" });
|
||||
expect(builder.rules.value[0]).toMatchObject({ rawValue: "", rawEndValue: "" });
|
||||
});
|
||||
|
||||
it("skips disabled rules and applies conjunctions", async () => {
|
||||
let nextId = 0;
|
||||
const builder = useDataGridFilterBuilder({
|
||||
columns: ["id", "name"],
|
||||
createId: () => `rule-${++nextId}`,
|
||||
isComplete: (rule) => !!rule.rawValue,
|
||||
buildCondition: async (rule) => `${rule.columnName} = '${rule.rawValue}'`,
|
||||
});
|
||||
builder.ensureRule();
|
||||
builder.updateRule("rule-1", { rawValue: "1" });
|
||||
builder.addRule();
|
||||
builder.updateRule("rule-2", { columnName: "name", rawValue: "Alice", conjunction: "OR" });
|
||||
expect(await builder.apply()).toBe("(id = '1') OR (name = 'Alice')");
|
||||
});
|
||||
|
||||
it("groups conditions in rule order", () => {
|
||||
const rule = (id: string, conjunction: "AND" | "OR"): DataGridStructuredFilterRule => ({ id, columnName: id, mode: "equals", rawValue: id, rawEndValue: "", conjunction });
|
||||
expect(
|
||||
buildDataGridStructuredWhere([
|
||||
{ rule: rule("a", "AND"), condition: "a" },
|
||||
{ rule: rule("b", "AND"), condition: "b" },
|
||||
{ rule: rule("c", "OR"), condition: "c" },
|
||||
]),
|
||||
).toBe("((a) AND (b)) OR (c)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { nextTick, ref } from "vue";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { useDataGridResultLifecycle } from "@/composables/useDataGridResultLifecycle";
|
||||
|
||||
describe("useDataGridResultLifecycle", () => {
|
||||
it("invalidates operations when the result identity changes", async () => {
|
||||
const result = ref<object>({ id: 1 });
|
||||
const lifecycle = useDataGridResultLifecycle({ resultKey: result });
|
||||
const operation = lifecycle.beginOperation();
|
||||
|
||||
result.value = { id: 2 };
|
||||
await nextTick();
|
||||
|
||||
expect(lifecycle.isCurrent(operation)).toBe(false);
|
||||
lifecycle.cleanup();
|
||||
});
|
||||
|
||||
it("invalidates operations explicitly and cleanup is idempotent", () => {
|
||||
const lifecycle = useDataGridResultLifecycle({ resultKey: ref({ id: 1 }) });
|
||||
const operation = lifecycle.beginOperation();
|
||||
|
||||
lifecycle.invalidate();
|
||||
lifecycle.cleanup();
|
||||
lifecycle.cleanup();
|
||||
|
||||
expect(lifecycle.isCurrent(operation)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { nextTick, ref } from "vue";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { useDataGridSearch } from "@/composables/useDataGridSearch";
|
||||
|
||||
describe("useDataGridSearch", () => {
|
||||
it("debounces matching across columns and cells", async () => {
|
||||
vi.useFakeTimers();
|
||||
const search = useDataGridSearch({ columns: ["id", "name"], rows: [[1, "Alice"]], getCellText: (row, column) => String(row[column] ?? "") });
|
||||
search.searchText.value = "ali";
|
||||
await nextTick();
|
||||
expect(search.matches.value).toEqual([]);
|
||||
vi.advanceTimersByTime(150);
|
||||
await nextTick();
|
||||
expect(search.matches.value).toEqual([{ kind: "cell", displayRow: 0, col: 1 }]);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("suggests columns and replaces only the active token", async () => {
|
||||
const columns = ref(["customer_id", "created_at"]);
|
||||
const search = useDataGridSearch({ columns, rows: [], getCellText: () => "" });
|
||||
search.searchText.value = "status = cus";
|
||||
await nextTick();
|
||||
expect(search.suggestions.value).toEqual(["customer_id"]);
|
||||
expect(search.acceptSuggestion()).toBe(true);
|
||||
expect(search.searchText.value).toBe("status = customer_id");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { useDataGridSort } from "@/composables/useDataGridSort";
|
||||
|
||||
describe("useDataGridSort", () => {
|
||||
it("stores the selected database or local sort state", () => {
|
||||
const sort = useDataGridSort();
|
||||
|
||||
sort.setSort("name", 2, "desc", "local");
|
||||
|
||||
expect(sort.sortColumn.value).toBe("name");
|
||||
expect(sort.sortColumnIndex.value).toBe(2);
|
||||
expect(sort.sortDirection.value).toBe("desc");
|
||||
expect(sort.sortMode.value).toBe("local");
|
||||
});
|
||||
|
||||
it("resets to the default unsorted state", () => {
|
||||
const sort = useDataGridSort();
|
||||
sort.setSort("name", 2, "desc", "local");
|
||||
|
||||
sort.clearSort();
|
||||
|
||||
expect(sort.sortColumn.value).toBeNull();
|
||||
expect(sort.sortColumnIndex.value).toBeNull();
|
||||
expect(sort.sortDirection.value).toBe("asc");
|
||||
expect(sort.sortMode.value).toBe("database");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ref, toValue, watch, type MaybeRefOrGetter } from "vue";
|
||||
|
||||
export function useDataGridAsyncSurface(open: MaybeRefOrGetter<boolean>) {
|
||||
const mounted = ref(false);
|
||||
|
||||
watch(
|
||||
() => toValue(open),
|
||||
(value) => {
|
||||
// Preserve the existing dialog instance after its chunk loads so closing it does not reset local UI state.
|
||||
if (value) mounted.value = true;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
return mounted;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { onBeforeUnmount, ref, type ComputedRef } from "vue";
|
||||
|
||||
export interface UseDataGridAutoRefreshOptions {
|
||||
canRefresh: ComputedRef<boolean>;
|
||||
refresh: () => void | Promise<void>;
|
||||
initialIntervalSeconds?: number;
|
||||
}
|
||||
|
||||
export function useDataGridAutoRefresh(options: UseDataGridAutoRefreshOptions) {
|
||||
const intervalSeconds = ref(options.initialIntervalSeconds ?? 10);
|
||||
const enabled = ref(false);
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
function stop() {
|
||||
if (timer !== undefined) clearInterval(timer);
|
||||
timer = undefined;
|
||||
}
|
||||
|
||||
function tick() {
|
||||
if (!enabled.value || !options.canRefresh.value) return;
|
||||
void options.refresh();
|
||||
}
|
||||
|
||||
function start() {
|
||||
stop();
|
||||
if (!enabled.value) return;
|
||||
timer = setInterval(tick, intervalSeconds.value * 1000);
|
||||
}
|
||||
|
||||
function setIntervalSeconds(seconds: number) {
|
||||
intervalSeconds.value = seconds;
|
||||
if (enabled.value) start();
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
enabled.value = !enabled.value;
|
||||
if (enabled.value) start();
|
||||
else stop();
|
||||
}
|
||||
|
||||
onBeforeUnmount(stop);
|
||||
|
||||
return {
|
||||
intervalSeconds,
|
||||
enabled,
|
||||
start,
|
||||
stop,
|
||||
setIntervalSeconds,
|
||||
toggle,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { getCurrentScope, onScopeDispose } from "vue";
|
||||
|
||||
export interface DataGridAnimationFrameDriver {
|
||||
request(callback: FrameRequestCallback): number;
|
||||
cancel(frameId: number): void;
|
||||
}
|
||||
|
||||
export interface DataGridCanvasRuntimeOptions {
|
||||
draw(): void;
|
||||
syncViewport(): void;
|
||||
getViewport(): Element | null;
|
||||
refreshPixelRatio?: () => void;
|
||||
frameDriver?: DataGridAnimationFrameDriver;
|
||||
createResizeObserver?: (callback: ResizeObserverCallback) => ResizeObserver | null;
|
||||
startPaused?: boolean;
|
||||
}
|
||||
|
||||
export interface DataGridCanvasRuntime {
|
||||
readonly active: boolean;
|
||||
readonly disposed: boolean;
|
||||
scheduleDraw(): void;
|
||||
drawNow(): void;
|
||||
schedulePixelRatioRefresh(): void;
|
||||
observeViewport(): void;
|
||||
pause(): void;
|
||||
resume(): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
function defaultFrameDriver(): DataGridAnimationFrameDriver {
|
||||
return {
|
||||
request: (callback) => requestAnimationFrame(callback),
|
||||
cancel: (frameId) => cancelAnimationFrame(frameId),
|
||||
};
|
||||
}
|
||||
|
||||
function defaultResizeObserverFactory(callback: ResizeObserverCallback): ResizeObserver | null {
|
||||
return typeof ResizeObserver === "undefined" ? null : new ResizeObserver(callback);
|
||||
}
|
||||
|
||||
export function useDataGridCanvasRuntime(options: DataGridCanvasRuntimeOptions): DataGridCanvasRuntime {
|
||||
const frameDriver = options.frameDriver ?? defaultFrameDriver();
|
||||
const createResizeObserver = options.createResizeObserver ?? defaultResizeObserverFactory;
|
||||
let active = !options.startPaused;
|
||||
let disposed = false;
|
||||
let drawFrame: number | null = null;
|
||||
let pixelRatioFrame: number | null = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
const cancelFrames = () => {
|
||||
if (drawFrame !== null) frameDriver.cancel(drawFrame);
|
||||
if (pixelRatioFrame !== null) frameDriver.cancel(pixelRatioFrame);
|
||||
drawFrame = null;
|
||||
pixelRatioFrame = null;
|
||||
};
|
||||
|
||||
const runtime: DataGridCanvasRuntime = {
|
||||
get active() {
|
||||
return active;
|
||||
},
|
||||
get disposed() {
|
||||
return disposed;
|
||||
},
|
||||
scheduleDraw() {
|
||||
if (!active || disposed || drawFrame !== null) return;
|
||||
drawFrame = frameDriver.request(() => {
|
||||
drawFrame = null;
|
||||
if (active && !disposed) options.draw();
|
||||
});
|
||||
},
|
||||
drawNow() {
|
||||
if (!active || disposed) return;
|
||||
if (drawFrame !== null) frameDriver.cancel(drawFrame);
|
||||
drawFrame = null;
|
||||
options.draw();
|
||||
},
|
||||
schedulePixelRatioRefresh() {
|
||||
if (!active || disposed || pixelRatioFrame !== null || !options.refreshPixelRatio) return;
|
||||
pixelRatioFrame = frameDriver.request(() => {
|
||||
pixelRatioFrame = null;
|
||||
if (active && !disposed) options.refreshPixelRatio?.();
|
||||
});
|
||||
},
|
||||
observeViewport() {
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = null;
|
||||
if (!active || disposed) return;
|
||||
options.syncViewport();
|
||||
const viewport = options.getViewport();
|
||||
if (!viewport) return;
|
||||
resizeObserver = createResizeObserver(() => {
|
||||
if (active && !disposed) options.syncViewport();
|
||||
});
|
||||
resizeObserver?.observe(viewport);
|
||||
},
|
||||
pause() {
|
||||
active = false;
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = null;
|
||||
cancelFrames();
|
||||
},
|
||||
resume() {
|
||||
if (disposed) return;
|
||||
active = true;
|
||||
runtime.observeViewport();
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
runtime.pause();
|
||||
disposed = true;
|
||||
},
|
||||
};
|
||||
|
||||
if (getCurrentScope()) onScopeDispose(runtime.dispose);
|
||||
return runtime;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { nextTick, ref, watch, type Ref } from "vue";
|
||||
import { useCellDetailEditor, type UseCellDetailEditorReturn } from "@/composables/useCellDetailEditor";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { renderWktOnCanvas } from "@/lib/dataGrid/geometryPreview";
|
||||
import type { DataGridCellDetail } from "@/lib/dataGrid/dataGridDetail";
|
||||
|
||||
export function useDataGridCellDetail(options: { detail: Ref<DataGridCellDetail>; editValue: Ref<string>; onCancel: () => void }) {
|
||||
const settingsStore = useSettingsStore();
|
||||
const { isDark, themePalette } = useTheme();
|
||||
const geometryPreviewOpen = ref(false);
|
||||
const geometryCanvas = ref<HTMLCanvasElement | null>(null);
|
||||
const detailsEditorContainer = ref<HTMLElement>();
|
||||
const sideJsonPreviewContainer = ref<HTMLElement>();
|
||||
let detailsEditor: UseCellDetailEditorReturn | null = null;
|
||||
let sideJsonEditor: UseCellDetailEditorReturn | null = null;
|
||||
|
||||
const editorOptions = () => ({
|
||||
editorTheme: () => settingsStore.editorSettings.theme,
|
||||
appAppearance: () => (isDark.value ? "dark" : "light") as import("@/lib/app/appTheme").AppThemeAppearance,
|
||||
appPalette: () => themePalette.value,
|
||||
fontSize: () => settingsStore.editorSettings.fontSize,
|
||||
fontFamily: () => settingsStore.editorSettings.fontFamily,
|
||||
});
|
||||
|
||||
watch(geometryPreviewOpen, async (open) => {
|
||||
if (!open) return;
|
||||
await nextTick();
|
||||
const canvas = geometryCanvas.value;
|
||||
const detail = options.detail.value;
|
||||
if (canvas && detail.value !== null && detail.value !== undefined) renderWktOnCanvas(canvas, String(detail.value));
|
||||
});
|
||||
|
||||
watch(detailsEditorContainer, async (element) => {
|
||||
if (element && !detailsEditor) {
|
||||
detailsEditor = useCellDetailEditor({ onChange: (value) => (options.editValue.value = value), onEscape: options.onCancel, ...editorOptions() });
|
||||
await detailsEditor.create(element, options.editValue.value, options.detail.value.type);
|
||||
} else if (!element && detailsEditor) {
|
||||
detailsEditor.destroy();
|
||||
detailsEditor = null;
|
||||
}
|
||||
});
|
||||
|
||||
watch(sideJsonPreviewContainer, async (element) => {
|
||||
if (element && !sideJsonEditor) {
|
||||
sideJsonEditor = useCellDetailEditor({ language: "json", readOnly: true, ...editorOptions() });
|
||||
await sideJsonEditor.create(element, options.detail.value.formattedJson ?? "", "json");
|
||||
} else if (!element && sideJsonEditor) {
|
||||
sideJsonEditor.destroy();
|
||||
sideJsonEditor = null;
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => options.detail.value.formattedJson ?? "",
|
||||
(value) => sideJsonEditor?.setValue(value, "json"),
|
||||
);
|
||||
watch(options.editValue, (value) => detailsEditor?.setValue(value, options.detail.value.type));
|
||||
|
||||
return {
|
||||
geometryPreviewOpen,
|
||||
geometryCanvas,
|
||||
detailsEditorContainer,
|
||||
sideJsonPreviewContainer,
|
||||
openSearch: () => detailsEditor?.openSearch() ?? sideJsonEditor?.openSearch() ?? false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
import { computed, nextTick, onScopeDispose, ref, toValue, watch, type MaybeRefOrGetter } from "vue";
|
||||
import { columnOrderKeysForIndexes, isDefaultColumnOrder, moveVisibleColumnIndex, orderedColumnIndexes } from "@/lib/dataGrid/dataGridColumnOrder";
|
||||
import { columnHeaderCanvasPointerDisabled, columnHeaderClickShouldBeSuppressed, columnHeaderPreviewOffsetForColumn, columnHeaderTooltipDisabled } from "@/lib/dataGrid/dataGridColumnHeaderInteraction";
|
||||
import {
|
||||
loadDataGridColumnOrder,
|
||||
loadTableDataGridColumnOrder,
|
||||
notifyTableDataGridColumnOrderChanged,
|
||||
removeDataGridColumnOrder,
|
||||
removeTableDataGridColumnOrder,
|
||||
saveDataGridColumnOrder,
|
||||
saveTableDataGridColumnOrder,
|
||||
type TableDataGridColumnOrderChangedDetail,
|
||||
} from "@/lib/dataGrid/dataGridColumnLayoutStorage";
|
||||
import { filterColumnVisibilityOptions, hiddenColumnIndexesWithAllNullColumns, invertedHiddenColumnIndexes, nextHiddenColumnIndexes, removeAutoHiddenColumnIndexes, visibleColumnIndexesForFilter } from "@/lib/dataGrid/dataGridColumnVisibility";
|
||||
|
||||
export type RenderedDataGridColumn = {
|
||||
visibleColIdx: number;
|
||||
actualColIdx: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type DataGridHorizontalColumnWindow = {
|
||||
start: number;
|
||||
end: number;
|
||||
beforeWidth: number;
|
||||
afterWidth: number;
|
||||
};
|
||||
|
||||
type ColumnHeaderDragState = {
|
||||
sourceVisibleIndex: number;
|
||||
targetVisibleIndex: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
currentX: number;
|
||||
columnRects: { visibleIndex: number; left: number; width: number }[];
|
||||
dragging: boolean;
|
||||
};
|
||||
|
||||
export function dataGridColumnOffsets(widths: readonly number[]): number[] {
|
||||
const offsets = Array.from({ length: widths.length + 1 }, () => 0);
|
||||
for (let index = 0; index < widths.length; index++) offsets[index + 1] = offsets[index] + (widths[index] ?? 0);
|
||||
return offsets;
|
||||
}
|
||||
|
||||
export function dataGridHorizontalColumnWindow(options: { widths: readonly number[]; offsets: readonly number[]; columnCount: number; scrollLeft: number; viewportWidth: number; rowNumberWidth: number; bufferPx: number }): DataGridHorizontalColumnWindow {
|
||||
const { widths, offsets, columnCount } = options;
|
||||
if (columnCount === 0 || widths.length === 0) return { start: 0, end: 0, beforeWidth: 0, afterWidth: 0 };
|
||||
|
||||
const viewportStart = Math.max(0, options.scrollLeft - options.rowNumberWidth - options.bufferPx);
|
||||
const viewportEnd = Math.max(options.viewportWidth, 1) + Math.max(0, options.scrollLeft - options.rowNumberWidth) + options.bufferPx;
|
||||
let low = 0;
|
||||
let high = columnCount - 1;
|
||||
while (low < high) {
|
||||
const mid = Math.floor((low + high) / 2);
|
||||
if ((offsets[mid + 1] ?? 0) < viewportStart) low = mid + 1;
|
||||
else high = mid;
|
||||
}
|
||||
const start = low;
|
||||
let end = start;
|
||||
while (end < columnCount && (offsets[end] ?? 0) < viewportEnd) end++;
|
||||
|
||||
const columnsWidth = offsets[columnCount] ?? 0;
|
||||
const visibleWidth = offsets[end] ?? offsets[start] ?? 0;
|
||||
return { start, end, beforeWidth: offsets[start] ?? 0, afterWidth: Math.max(0, columnsWidth - visibleWidth) };
|
||||
}
|
||||
|
||||
export function useDataGridColumnLayoutState(options: {
|
||||
columns: MaybeRefOrGetter<readonly string[]>;
|
||||
sourceColumns?: MaybeRefOrGetter<readonly (string | undefined)[] | undefined>;
|
||||
commentByColumn?: MaybeRefOrGetter<ReadonlyMap<string, string>>;
|
||||
displayableColumnIndexes: MaybeRefOrGetter<readonly number[]>;
|
||||
allNullColumnIndexes: MaybeRefOrGetter<readonly number[]>;
|
||||
columnOrderKeys: MaybeRefOrGetter<readonly string[]>;
|
||||
layoutScopeKey: MaybeRefOrGetter<string>;
|
||||
tableScopeKey: MaybeRefOrGetter<string>;
|
||||
onRefreshMetrics?: () => void;
|
||||
}) {
|
||||
const hiddenColumnIndexes = ref<Set<number>>(new Set());
|
||||
const nullColumnsHidden = ref(false);
|
||||
const autoHiddenNullColumnIndexes = ref<Set<number>>(new Set());
|
||||
const persistedColumnOrderKeys = ref<string[]>([]);
|
||||
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);
|
||||
const hiddenColumnCount = computed(() => displayableColumnCount.value - visibleColumnIndexes.value.length);
|
||||
const allNullColumnCount = computed(() => toValue(options.allNullColumnIndexes).length);
|
||||
const hasCustomColumnOrder = computed(() => !isDefaultColumnOrder(toValue(options.displayableColumnIndexes), orderedDisplayableColumnIndexes.value));
|
||||
const canToggleAllNullColumns = computed(() => nullColumnsHidden.value || (toValue(options.allNullColumnIndexes).length > 0 && displayableColumnCount.value > 1));
|
||||
|
||||
function filteredColumnVisibilityOptions(query: string) {
|
||||
const displayable = new Set(toValue(options.displayableColumnIndexes));
|
||||
return filterColumnVisibilityOptions(toValue(options.columns), query, { sourceColumns: toValue(options.sourceColumns), commentByColumn: toValue(options.commentByColumn) }).filter((option) => displayable.has(option.index));
|
||||
}
|
||||
function isColumnVisible(columnIndex: number) {
|
||||
return !hiddenColumnIndexes.value.has(columnIndex);
|
||||
}
|
||||
function toggleColumnVisibility(columnIndex: number) {
|
||||
hiddenColumnIndexes.value = nextHiddenColumnIndexes({ columnIndex, hiddenIndexes: hiddenColumnIndexes.value, totalColumns: displayableColumnCount.value });
|
||||
}
|
||||
function showAllColumns() {
|
||||
hiddenColumnIndexes.value = new Set();
|
||||
}
|
||||
function invertColumnVisibility() {
|
||||
hiddenColumnIndexes.value = invertedHiddenColumnIndexes([...toValue(options.displayableColumnIndexes)], hiddenColumnIndexes.value);
|
||||
}
|
||||
function loadColumnOrder() {
|
||||
const tableScopeKey = toValue(options.tableScopeKey);
|
||||
const tableOrder = tableScopeKey ? loadTableDataGridColumnOrder(tableScopeKey) : [];
|
||||
persistedColumnOrderKeys.value = tableOrder.length ? tableOrder : loadDataGridColumnOrder(toValue(options.layoutScopeKey), toValue(options.columnOrderKeys));
|
||||
}
|
||||
function persistColumnOrder(indexes: number[]) {
|
||||
const tableScopeKey = toValue(options.tableScopeKey);
|
||||
if (isDefaultColumnOrder(toValue(options.displayableColumnIndexes), indexes)) {
|
||||
removeDataGridColumnOrder(toValue(options.layoutScopeKey));
|
||||
if (tableScopeKey) {
|
||||
removeTableDataGridColumnOrder(tableScopeKey);
|
||||
notifyTableDataGridColumnOrderChanged(tableScopeKey);
|
||||
}
|
||||
persistedColumnOrderKeys.value = [];
|
||||
return;
|
||||
}
|
||||
const keys = columnOrderKeysForIndexes(indexes, toValue(options.columnOrderKeys));
|
||||
persistedColumnOrderKeys.value = keys;
|
||||
saveDataGridColumnOrder(toValue(options.layoutScopeKey), toValue(options.columnOrderKeys), keys);
|
||||
if (tableScopeKey) {
|
||||
saveTableDataGridColumnOrder(tableScopeKey, keys);
|
||||
notifyTableDataGridColumnOrderChanged(tableScopeKey);
|
||||
}
|
||||
}
|
||||
function resetColumnOrder() {
|
||||
removeDataGridColumnOrder(toValue(options.layoutScopeKey));
|
||||
const tableScopeKey = toValue(options.tableScopeKey);
|
||||
if (tableScopeKey) {
|
||||
removeTableDataGridColumnOrder(tableScopeKey);
|
||||
notifyTableDataGridColumnOrderChanged(tableScopeKey);
|
||||
}
|
||||
persistedColumnOrderKeys.value = [];
|
||||
if (options.onRefreshMetrics) nextTick(options.onRefreshMetrics);
|
||||
}
|
||||
function showAllNullColumns() {
|
||||
hiddenColumnIndexes.value = removeAutoHiddenColumnIndexes(hiddenColumnIndexes.value, autoHiddenNullColumnIndexes.value);
|
||||
autoHiddenNullColumnIndexes.value = new Set();
|
||||
nullColumnsHidden.value = false;
|
||||
}
|
||||
function hideAllNullColumns() {
|
||||
const next = hiddenColumnIndexesWithAllNullColumns({ availableIndexes: [...toValue(options.displayableColumnIndexes)], hiddenIndexes: hiddenColumnIndexes.value, allNullIndexes: new Set(toValue(options.allNullColumnIndexes)) });
|
||||
hiddenColumnIndexes.value = next.hiddenIndexes;
|
||||
autoHiddenNullColumnIndexes.value = next.autoHiddenIndexes;
|
||||
nullColumnsHidden.value = next.autoHiddenIndexes.size > 0;
|
||||
}
|
||||
function toggleAllNullColumns() {
|
||||
if (nullColumnsHidden.value) showAllNullColumns();
|
||||
else hideAllNullColumns();
|
||||
}
|
||||
function onTableDataGridColumnOrderChanged(event: Event) {
|
||||
if (!(event instanceof CustomEvent)) return;
|
||||
const detail = event.detail as TableDataGridColumnOrderChangedDetail | undefined;
|
||||
if (!detail || detail.scopeKey !== toValue(options.tableScopeKey)) return;
|
||||
loadColumnOrder();
|
||||
if (options.onRefreshMetrics) nextTick(options.onRefreshMetrics);
|
||||
}
|
||||
|
||||
function resetColumnVisibility() {
|
||||
hiddenColumnIndexes.value = new Set();
|
||||
autoHiddenNullColumnIndexes.value = new Set();
|
||||
nullColumnsHidden.value = false;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [...toValue(options.allNullColumnIndexes)],
|
||||
() => {
|
||||
if (!nullColumnsHidden.value) return;
|
||||
hiddenColumnIndexes.value = removeAutoHiddenColumnIndexes(hiddenColumnIndexes.value, autoHiddenNullColumnIndexes.value);
|
||||
autoHiddenNullColumnIndexes.value = new Set();
|
||||
hideAllNullColumns();
|
||||
},
|
||||
);
|
||||
watch([() => toValue(options.layoutScopeKey), () => toValue(options.tableScopeKey)], loadColumnOrder, { immediate: true });
|
||||
|
||||
return {
|
||||
hiddenColumnIndexes,
|
||||
nullColumnsHidden,
|
||||
orderedDisplayableColumnIndexes,
|
||||
visibleColumnIndexes,
|
||||
displayableColumnCount,
|
||||
hiddenColumnCount,
|
||||
allNullColumnCount,
|
||||
hasCustomColumnOrder,
|
||||
canToggleAllNullColumns,
|
||||
filteredColumnVisibilityOptions,
|
||||
isColumnVisible,
|
||||
toggleColumnVisibility,
|
||||
showAllColumns,
|
||||
invertColumnVisibility,
|
||||
persistColumnOrder,
|
||||
resetColumnOrder,
|
||||
toggleAllNullColumns,
|
||||
resetColumnVisibility,
|
||||
onTableDataGridColumnOrderChanged,
|
||||
};
|
||||
}
|
||||
|
||||
export function useDataGridColumnLayout(options: {
|
||||
columnNames: MaybeRefOrGetter<readonly string[]>;
|
||||
visibleColumnIndexes: MaybeRefOrGetter<readonly number[]>;
|
||||
renderedColumnWidths: MaybeRefOrGetter<readonly number[]>;
|
||||
scrollLeft: MaybeRefOrGetter<number>;
|
||||
viewportWidth: MaybeRefOrGetter<number>;
|
||||
rowNumberWidth: number;
|
||||
bufferPx?: number;
|
||||
headerRef?: MaybeRefOrGetter<HTMLElement | null | undefined>;
|
||||
orderedColumnIndexes?: MaybeRefOrGetter<readonly number[]>;
|
||||
hiddenColumnIndexes?: MaybeRefOrGetter<ReadonlySet<number>>;
|
||||
getIsResizing?: () => boolean;
|
||||
onResizeStart?: (visibleColIdx: number, event: MouseEvent) => void;
|
||||
onCanvasMouseLeave?: () => void;
|
||||
onCanvasDrawSchedule?: () => void;
|
||||
onRefreshMetrics?: () => void;
|
||||
onPersistColumnOrder?: (indexes: number[]) => void;
|
||||
}) {
|
||||
const renderedColumnOffsets = computed(() => dataGridColumnOffsets(toValue(options.renderedColumnWidths)));
|
||||
const horizontalColumnWindow = computed(() =>
|
||||
dataGridHorizontalColumnWindow({
|
||||
widths: toValue(options.renderedColumnWidths),
|
||||
offsets: renderedColumnOffsets.value,
|
||||
columnCount: toValue(options.visibleColumnIndexes).length,
|
||||
scrollLeft: toValue(options.scrollLeft),
|
||||
viewportWidth: toValue(options.viewportWidth),
|
||||
rowNumberWidth: options.rowNumberWidth,
|
||||
bufferPx: options.bufferPx ?? 900,
|
||||
}),
|
||||
);
|
||||
const renderedGridColumns = computed<RenderedDataGridColumn[]>(() => {
|
||||
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] ?? "",
|
||||
}));
|
||||
});
|
||||
|
||||
function renderedColumnStyle(visibleColIdx: number) {
|
||||
return { width: `var(--col-w-${visibleColIdx})` };
|
||||
}
|
||||
|
||||
function columnContentOffsetLeft(visibleColIdx: number): number {
|
||||
return options.rowNumberWidth + (renderedColumnOffsets.value[visibleColIdx] ?? 0);
|
||||
}
|
||||
|
||||
const columnHeaderDragState = ref<ColumnHeaderDragState | null>(null);
|
||||
const columnHeaderResizeActive = ref(false);
|
||||
let columnHeaderDragClickGuardUntil = 0;
|
||||
let columnHeaderSuppressNextClick = false;
|
||||
let columnHeaderSuppressClickTimer = 0;
|
||||
let columnHeaderDragFrame = 0;
|
||||
let columnHeaderResizeFrame = 0;
|
||||
let columnHeaderPendingClientX = 0;
|
||||
let columnHeaderResizeListenersCleanup: (() => void) | null = null;
|
||||
|
||||
const columnHeaderTooltipsDisabled = computed(() =>
|
||||
columnHeaderTooltipDisabled({
|
||||
columnDragActive: columnHeaderDragState.value !== null,
|
||||
columnResizeActive: columnHeaderResizeActive.value,
|
||||
}),
|
||||
);
|
||||
|
||||
function columnHeaderPointerInteractionActive(): boolean {
|
||||
return columnHeaderCanvasPointerDisabled({
|
||||
columnDragActive: columnHeaderDragState.value !== null,
|
||||
columnResizeActive: columnHeaderResizeActive.value,
|
||||
});
|
||||
}
|
||||
|
||||
function clearColumnHeaderResizeListeners() {
|
||||
columnHeaderResizeListenersCleanup?.();
|
||||
columnHeaderResizeListenersCleanup = null;
|
||||
}
|
||||
|
||||
function clearColumnHeaderClickGuard() {
|
||||
columnHeaderSuppressNextClick = false;
|
||||
columnHeaderDragClickGuardUntil = 0;
|
||||
if (columnHeaderSuppressClickTimer) {
|
||||
window.clearTimeout(columnHeaderSuppressClickTimer);
|
||||
columnHeaderSuppressClickTimer = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function armColumnHeaderClickGuard() {
|
||||
clearColumnHeaderClickGuard();
|
||||
columnHeaderSuppressNextClick = true;
|
||||
columnHeaderDragClickGuardUntil = Date.now() + 800;
|
||||
columnHeaderSuppressClickTimer = window.setTimeout(clearColumnHeaderClickGuard, 800);
|
||||
}
|
||||
|
||||
function finishColumnHeaderResizeInteraction() {
|
||||
clearColumnHeaderResizeListeners();
|
||||
if (columnHeaderResizeFrame) cancelAnimationFrame(columnHeaderResizeFrame);
|
||||
columnHeaderResizeFrame = requestAnimationFrame(() => {
|
||||
columnHeaderResizeFrame = 0;
|
||||
columnHeaderResizeActive.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function startColumnHeaderResize(visibleColIdx: number, event: MouseEvent) {
|
||||
clearColumnHeaderResizeListeners();
|
||||
if (columnHeaderResizeFrame) {
|
||||
cancelAnimationFrame(columnHeaderResizeFrame);
|
||||
columnHeaderResizeFrame = 0;
|
||||
}
|
||||
columnHeaderResizeActive.value = true;
|
||||
armColumnHeaderClickGuard();
|
||||
options.onCanvasMouseLeave?.();
|
||||
const finishResize = () => {
|
||||
armColumnHeaderClickGuard();
|
||||
finishColumnHeaderResizeInteraction();
|
||||
};
|
||||
columnHeaderResizeListenersCleanup = () => {
|
||||
window.removeEventListener("mouseup", finishResize, true);
|
||||
window.removeEventListener("blur", finishResize, true);
|
||||
};
|
||||
window.addEventListener("mouseup", finishResize, true);
|
||||
window.addEventListener("blur", finishResize, true);
|
||||
options.onResizeStart?.(visibleColIdx, event);
|
||||
}
|
||||
|
||||
function columnHeaderInteractiveTarget(target: EventTarget | null): boolean {
|
||||
return target instanceof HTMLElement && !!target.closest("button, input, textarea, select, [contenteditable='true'], [role='button'], [data-column-resize-handle]");
|
||||
}
|
||||
|
||||
function columnHeaderDropTargetVisibleIndex(clientX: number): number {
|
||||
const state = columnHeaderDragState.value;
|
||||
if (!state || state.columnRects.length === 0) return state?.sourceVisibleIndex ?? 0;
|
||||
for (const rect of state.columnRects) {
|
||||
if (clientX < rect.left + rect.width / 2) return rect.visibleIndex;
|
||||
}
|
||||
return toValue(options.visibleColumnIndexes).length;
|
||||
}
|
||||
|
||||
function applyColumnHeaderDragPreview() {
|
||||
columnHeaderDragFrame = 0;
|
||||
const state = columnHeaderDragState.value;
|
||||
if (!state?.dragging) return;
|
||||
state.currentX = columnHeaderPendingClientX;
|
||||
state.targetVisibleIndex = columnHeaderDropTargetVisibleIndex(columnHeaderPendingClientX);
|
||||
options.onCanvasDrawSchedule?.();
|
||||
}
|
||||
|
||||
function scheduleColumnHeaderDragPreview(clientX: number) {
|
||||
columnHeaderPendingClientX = clientX;
|
||||
if (columnHeaderDragFrame) return;
|
||||
columnHeaderDragFrame = requestAnimationFrame(applyColumnHeaderDragPreview);
|
||||
}
|
||||
|
||||
function flushColumnHeaderDragPreview() {
|
||||
if (columnHeaderDragFrame) cancelAnimationFrame(columnHeaderDragFrame);
|
||||
applyColumnHeaderDragPreview();
|
||||
}
|
||||
|
||||
function cancelColumnHeaderDragPreview() {
|
||||
if (!columnHeaderDragFrame) return;
|
||||
cancelAnimationFrame(columnHeaderDragFrame);
|
||||
columnHeaderDragFrame = 0;
|
||||
}
|
||||
|
||||
function columnHeaderLayoutRects() {
|
||||
const header = toValue(options.headerRef);
|
||||
return Array.from(header?.querySelectorAll<HTMLElement>("[data-visible-col-index]") ?? [])
|
||||
.map((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return { visibleIndex: Number(element.dataset.visibleColIndex), left: rect.left, width: rect.width };
|
||||
})
|
||||
.filter((rect) => Number.isFinite(rect.visibleIndex));
|
||||
}
|
||||
|
||||
function stopColumnHeaderDrag(commit: boolean) {
|
||||
const state = columnHeaderDragState.value;
|
||||
if (!state) return;
|
||||
const hadCanvasPreview = state.dragging;
|
||||
window.removeEventListener("pointermove", onColumnHeaderPointerMove, true);
|
||||
window.removeEventListener("pointerup", onColumnHeaderPointerUp, true);
|
||||
window.removeEventListener("pointercancel", onColumnHeaderPointerCancel, true);
|
||||
cancelColumnHeaderDragPreview();
|
||||
document.body.style.userSelect = "";
|
||||
columnHeaderDragState.value = null;
|
||||
if (hadCanvasPreview) options.onCanvasDrawSchedule?.();
|
||||
if (state.dragging) armColumnHeaderClickGuard();
|
||||
if (!commit || !state.dragging || state.sourceVisibleIndex === state.targetVisibleIndex) return;
|
||||
const next = moveVisibleColumnIndex({
|
||||
orderedIndexes: toValue(options.orderedColumnIndexes ?? options.visibleColumnIndexes),
|
||||
hiddenIndexes: toValue(options.hiddenColumnIndexes ?? (() => new Set<number>())),
|
||||
fromVisibleIndex: state.sourceVisibleIndex,
|
||||
toVisibleIndex: state.targetVisibleIndex,
|
||||
});
|
||||
options.onPersistColumnOrder?.(next);
|
||||
options.onRefreshMetrics?.();
|
||||
}
|
||||
|
||||
function onColumnHeaderPointerMove(event: PointerEvent) {
|
||||
const state = columnHeaderDragState.value;
|
||||
if (!state) return;
|
||||
const moved = Math.abs(event.clientX - state.startX) > 5 || Math.abs(event.clientY - state.startY) > 5;
|
||||
if (!state.dragging && moved) {
|
||||
state.dragging = true;
|
||||
document.body.style.userSelect = "none";
|
||||
options.onCanvasMouseLeave?.();
|
||||
}
|
||||
if (!state.dragging) return;
|
||||
event.preventDefault();
|
||||
scheduleColumnHeaderDragPreview(event.clientX);
|
||||
}
|
||||
|
||||
function onColumnHeaderPointerUp(event: PointerEvent) {
|
||||
columnHeaderPendingClientX = event.clientX;
|
||||
flushColumnHeaderDragPreview();
|
||||
stopColumnHeaderDrag(true);
|
||||
}
|
||||
|
||||
function onColumnHeaderPointerCancel() {
|
||||
stopColumnHeaderDrag(false);
|
||||
}
|
||||
|
||||
function startColumnHeaderDrag(visibleColIdx: number, event: PointerEvent) {
|
||||
if (event.button !== 0 || options.getIsResizing?.() || columnHeaderInteractiveTarget(event.target)) return;
|
||||
columnHeaderDragState.value = {
|
||||
sourceVisibleIndex: visibleColIdx,
|
||||
targetVisibleIndex: visibleColIdx,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
currentX: event.clientX,
|
||||
columnRects: columnHeaderLayoutRects(),
|
||||
dragging: false,
|
||||
};
|
||||
columnHeaderPendingClientX = event.clientX;
|
||||
window.addEventListener("pointermove", onColumnHeaderPointerMove, true);
|
||||
window.addEventListener("pointerup", onColumnHeaderPointerUp, true);
|
||||
window.addEventListener("pointercancel", onColumnHeaderPointerCancel, true);
|
||||
}
|
||||
|
||||
function suppressHeaderClickIfNeeded(event: MouseEvent): boolean {
|
||||
if (!columnHeaderClickShouldBeSuppressed({ now: Date.now(), guardUntil: columnHeaderDragClickGuardUntil, suppressNextClick: columnHeaderSuppressNextClick })) return false;
|
||||
clearColumnHeaderClickGuard();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
return true;
|
||||
}
|
||||
|
||||
function columnHeaderDragClass(visibleColIdx: number) {
|
||||
const state = columnHeaderDragState.value;
|
||||
return { "z-30 shadow-lg ring-1 ring-primary/40 bg-background dark:bg-muted pointer-events-none": state?.dragging && state.sourceVisibleIndex === visibleColIdx };
|
||||
}
|
||||
|
||||
function columnHeaderPreviewOffset(visibleColIdx: number): number {
|
||||
const state = columnHeaderDragState.value;
|
||||
if (!state) return 0;
|
||||
return columnHeaderPreviewOffsetForColumn({
|
||||
columnDragActive: state.dragging,
|
||||
visibleColIdx,
|
||||
sourceVisibleIndex: state.sourceVisibleIndex,
|
||||
targetVisibleIndex: state.targetVisibleIndex,
|
||||
startX: state.startX,
|
||||
currentX: state.currentX,
|
||||
sourceWidth: toValue(options.renderedColumnWidths)[state.sourceVisibleIndex] ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
function columnHeaderStyle(visibleColIdx: number) {
|
||||
const style = renderedColumnStyle(visibleColIdx);
|
||||
const offset = columnHeaderPreviewOffset(visibleColIdx);
|
||||
if (!offset) return style;
|
||||
return { ...style, transform: `translateX(${offset}px)`, transition: columnHeaderDragState.value?.sourceVisibleIndex === visibleColIdx ? undefined : "transform 120ms ease-out" };
|
||||
}
|
||||
|
||||
const columnHeaderPreviewOffsets = computed(() => toValue(options.renderedColumnWidths).map((_, visibleColIdx) => columnHeaderPreviewOffset(visibleColIdx)));
|
||||
const columnHeaderPreviewSourceVisibleIndex = computed(() => {
|
||||
const state = columnHeaderDragState.value;
|
||||
return state?.dragging ? state.sourceVisibleIndex : null;
|
||||
});
|
||||
|
||||
function disposeColumnHeaderInteractions() {
|
||||
stopColumnHeaderDrag(false);
|
||||
clearColumnHeaderResizeListeners();
|
||||
clearColumnHeaderClickGuard();
|
||||
if (columnHeaderDragFrame) cancelAnimationFrame(columnHeaderDragFrame);
|
||||
if (columnHeaderResizeFrame) cancelAnimationFrame(columnHeaderResizeFrame);
|
||||
columnHeaderResizeFrame = 0;
|
||||
document.body.style.userSelect = "";
|
||||
}
|
||||
onScopeDispose(disposeColumnHeaderInteractions);
|
||||
|
||||
return {
|
||||
renderedColumnOffsets,
|
||||
horizontalColumnWindow,
|
||||
renderedGridColumns,
|
||||
renderedColumnStyle,
|
||||
columnContentOffsetLeft,
|
||||
columnHeaderDragState,
|
||||
columnHeaderResizeActive,
|
||||
columnHeaderTooltipsDisabled,
|
||||
columnHeaderPreviewOffsets,
|
||||
columnHeaderPreviewSourceVisibleIndex,
|
||||
columnHeaderPointerInteractionActive,
|
||||
startColumnHeaderResize,
|
||||
startColumnHeaderDrag,
|
||||
suppressHeaderClickIfNeeded,
|
||||
columnHeaderDragClass,
|
||||
columnHeaderStyle,
|
||||
disposeColumnHeaderInteractions,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { computed, getCurrentScope, onScopeDispose, ref, toValue, watch, type MaybeRefOrGetter, type Ref } from "vue";
|
||||
import { forgetDataGridConditionHistory, loadDataGridConditionHistory, rememberDataGridConditionHistory, type DataGridConditionHistoryKind, type DataGridConditionHistoryScope } from "@/lib/dataGrid/dataGridConditionHistory";
|
||||
|
||||
export type DataGridConditionSuggestionKind = "column" | "history";
|
||||
|
||||
export interface DataGridConditionSuggestion {
|
||||
value: string;
|
||||
kind: DataGridConditionSuggestionKind;
|
||||
}
|
||||
|
||||
export interface DataGridConditionSuggestionContext {
|
||||
kind: DataGridConditionHistoryKind;
|
||||
value: string;
|
||||
token: string;
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
export type DataGridConditionSuggestionProvider = (context: DataGridConditionSuggestionContext) => readonly string[] | Promise<readonly string[]>;
|
||||
|
||||
export interface UseDataGridConditionEditorOptions {
|
||||
kind: DataGridConditionHistoryKind;
|
||||
value: Ref<string>;
|
||||
columns?: MaybeRefOrGetter<readonly string[] | undefined>;
|
||||
historyScope: MaybeRefOrGetter<DataGridConditionHistoryScope>;
|
||||
suggestionProvider?: DataGridConditionSuggestionProvider;
|
||||
suggestionDebounceMs?: number;
|
||||
suggestionLimit?: number;
|
||||
}
|
||||
|
||||
const WHERE_TOKEN_PATTERN = /([^\s,()><=!&|]+)$/;
|
||||
const ORDER_BY_TOKEN_PATTERN = /([^\s,()]+)$/;
|
||||
|
||||
function activeToken(kind: DataGridConditionHistoryKind, value: string): string {
|
||||
return (
|
||||
value
|
||||
.trim()
|
||||
.split(kind === "where" ? /[\s,()><=!&|]+/ : /[\s,()]+/)
|
||||
.pop() ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
function replaceActiveToken(kind: DataGridConditionHistoryKind, value: string, replacement: string): string {
|
||||
const match = value.match(kind === "where" ? WHERE_TOKEN_PATTERN : ORDER_BY_TOKEN_PATTERN);
|
||||
if (!match) return value;
|
||||
return `${value.slice(0, -match[1].length)}${replacement}`;
|
||||
}
|
||||
|
||||
export function useDataGridConditionEditor(options: UseDataGridConditionEditorOptions) {
|
||||
const suggestions = ref<DataGridConditionSuggestion[]>([]);
|
||||
const highlightedIndex = ref(-1);
|
||||
const historyOpen = ref(false);
|
||||
const suggestionsLoading = ref(false);
|
||||
let suggestionTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let suggestionRequestId = 0;
|
||||
let suggestionAbortController: AbortController | undefined;
|
||||
let suppressNextValueSuggestion = false;
|
||||
|
||||
const dropdownOpen = computed(() => suggestions.value.length > 0 || historyOpen.value);
|
||||
|
||||
function cancelSuggestionRequest() {
|
||||
if (suggestionTimer) clearTimeout(suggestionTimer);
|
||||
suggestionTimer = undefined;
|
||||
suggestionRequestId += 1;
|
||||
suggestionAbortController?.abort();
|
||||
suggestionAbortController = undefined;
|
||||
suggestionsLoading.value = false;
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
cancelSuggestionRequest();
|
||||
suggestions.value = [];
|
||||
highlightedIndex.value = -1;
|
||||
historyOpen.value = false;
|
||||
}
|
||||
|
||||
function defaultSuggestions(token: string): readonly string[] {
|
||||
const normalizedToken = token.toLowerCase();
|
||||
if (!normalizedToken) return [];
|
||||
return (toValue(options.columns) ?? []).filter((column) => column.toLowerCase().startsWith(normalizedToken) && column.toLowerCase() !== normalizedToken);
|
||||
}
|
||||
|
||||
async function loadSuggestions(value: string, requestId: number, controller: AbortController) {
|
||||
const token = activeToken(options.kind, value);
|
||||
if (!token) return;
|
||||
suggestionsLoading.value = true;
|
||||
try {
|
||||
const values = options.suggestionProvider ? await options.suggestionProvider({ kind: options.kind, value, token, signal: controller.signal }) : defaultSuggestions(token);
|
||||
// A slower request must never replace suggestions for a newer editor value.
|
||||
if (controller.signal.aborted || requestId !== suggestionRequestId || options.value.value !== value || historyOpen.value) return;
|
||||
const limit = options.suggestionLimit ?? 8;
|
||||
suggestions.value = [...new Set(values)].slice(0, limit).map((suggestion) => ({ value: suggestion, kind: "column" }));
|
||||
highlightedIndex.value = suggestions.value.length > 0 ? 0 : -1;
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted && requestId === suggestionRequestId) {
|
||||
suggestions.value = [];
|
||||
highlightedIndex.value = -1;
|
||||
console.warn("[DBX][condition-editor] Failed to load suggestions", error);
|
||||
}
|
||||
} finally {
|
||||
if (requestId === suggestionRequestId) suggestionsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleSuggestions(value: string) {
|
||||
cancelSuggestionRequest();
|
||||
suggestions.value = [];
|
||||
highlightedIndex.value = -1;
|
||||
historyOpen.value = false;
|
||||
if (!value.trim()) return;
|
||||
|
||||
const requestId = suggestionRequestId;
|
||||
const controller = new AbortController();
|
||||
suggestionAbortController = controller;
|
||||
suggestionTimer = setTimeout(() => {
|
||||
suggestionTimer = undefined;
|
||||
void loadSuggestions(value, requestId, controller);
|
||||
}, options.suggestionDebounceMs ?? 0);
|
||||
}
|
||||
|
||||
function openHistory() {
|
||||
cancelSuggestionRequest();
|
||||
if (dropdownOpen.value) {
|
||||
dismiss();
|
||||
return;
|
||||
}
|
||||
historyOpen.value = true;
|
||||
suggestions.value = loadDataGridConditionHistory(options.kind, toValue(options.historyScope), options.value.value).map((value) => ({ value, kind: "history" }));
|
||||
highlightedIndex.value = -1;
|
||||
}
|
||||
|
||||
function deleteHistory(value: string) {
|
||||
const history = forgetDataGridConditionHistory(options.kind, toValue(options.historyScope), value);
|
||||
const query = options.value.value.trim().toLowerCase();
|
||||
suggestions.value = history.filter((item) => !query || item.toLowerCase().includes(query)).map((item) => ({ value: item, kind: "history" }));
|
||||
highlightedIndex.value = suggestions.value.length > 0 ? Math.min(Math.max(highlightedIndex.value, 0), suggestions.value.length - 1) : -1;
|
||||
historyOpen.value = true;
|
||||
}
|
||||
|
||||
function rememberHistory(value = options.value.value) {
|
||||
return rememberDataGridConditionHistory(options.kind, toValue(options.historyScope), value);
|
||||
}
|
||||
|
||||
function navigate(delta: number) {
|
||||
if (suggestions.value.length === 0) return false;
|
||||
if (highlightedIndex.value < 0) {
|
||||
highlightedIndex.value = delta > 0 ? 0 : suggestions.value.length - 1;
|
||||
} else {
|
||||
highlightedIndex.value = Math.min(Math.max(highlightedIndex.value + delta, 0), suggestions.value.length - 1);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function accept(index = highlightedIndex.value) {
|
||||
const suggestion = suggestions.value[index];
|
||||
if (!suggestion) return false;
|
||||
suppressNextValueSuggestion = true;
|
||||
options.value.value = suggestion.kind === "history" ? suggestion.value : replaceActiveToken(options.kind, options.value.value, suggestion.value);
|
||||
dismiss();
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent): "accept" | "apply" | "dismiss" | "navigate" | undefined {
|
||||
if (dropdownOpen.value && event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
dismiss();
|
||||
return "dismiss";
|
||||
}
|
||||
if (suggestions.value.length > 0 && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
|
||||
event.preventDefault();
|
||||
navigate(event.key === "ArrowDown" ? 1 : -1);
|
||||
return "navigate";
|
||||
}
|
||||
if (suggestions.value.length > 0 && event.key === "Tab") {
|
||||
event.preventDefault();
|
||||
accept();
|
||||
return "accept";
|
||||
}
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
if (suggestions.value.length > 0 && highlightedIndex.value >= 0) {
|
||||
accept();
|
||||
return "accept";
|
||||
}
|
||||
return "apply";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
watch(options.value, (value) => {
|
||||
if (suppressNextValueSuggestion) {
|
||||
suppressNextValueSuggestion = false;
|
||||
return;
|
||||
}
|
||||
scheduleSuggestions(value);
|
||||
});
|
||||
if (getCurrentScope()) onScopeDispose(cancelSuggestionRequest);
|
||||
|
||||
return {
|
||||
suggestions,
|
||||
highlightedIndex,
|
||||
historyOpen,
|
||||
suggestionsLoading,
|
||||
dropdownOpen,
|
||||
scheduleSuggestions,
|
||||
openHistory,
|
||||
deleteHistory,
|
||||
rememberHistory,
|
||||
dismiss,
|
||||
navigate,
|
||||
accept,
|
||||
handleKeydown,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { computed, ref, toValue, watch, type MaybeRefOrGetter } from "vue";
|
||||
import { filterModeNeedsValue, filterModeUsesRange } from "@/lib/dataGrid/dataGridColumnFilter";
|
||||
import type { DataGridContextFilterMode } from "@/lib/dataGrid/dataGridSql";
|
||||
|
||||
export type DataGridStructuredFilterRule = {
|
||||
id: string;
|
||||
columnName: string;
|
||||
mode: DataGridContextFilterMode;
|
||||
rawValue: string;
|
||||
rawEndValue: string;
|
||||
conjunction: "AND" | "OR";
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export type UseDataGridFilterBuilderOptions = {
|
||||
columns: MaybeRefOrGetter<readonly string[]>;
|
||||
createId?: () => string;
|
||||
isComplete: (rule: DataGridStructuredFilterRule) => boolean;
|
||||
buildCondition: (rule: DataGridStructuredFilterRule) => Promise<string | undefined>;
|
||||
};
|
||||
|
||||
export function buildDataGridStructuredWhere(items: Array<{ rule: DataGridStructuredFilterRule; condition: string }>): string {
|
||||
if (!items.length) return "";
|
||||
let result = items[0].condition;
|
||||
for (let index = 1; index < items.length; index++) {
|
||||
result = `(${result}) ${items[index].rule.conjunction} (${items[index].condition})`;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function useDataGridFilterBuilder(options: UseDataGridFilterBuilderOptions) {
|
||||
const rules = ref<DataGridStructuredFilterRule[]>([]);
|
||||
const open = ref(false);
|
||||
const columnSearch = ref("");
|
||||
const appliedWhereInput = ref("");
|
||||
const filteredColumns = computed(() => {
|
||||
const query = columnSearch.value.trim().toLowerCase();
|
||||
return query ? toValue(options.columns).filter((column) => column.toLowerCase().includes(query)) : [...toValue(options.columns)];
|
||||
});
|
||||
const activeCount = computed(() => rules.value.filter((rule) => !rule.disabled && rule.columnName && options.isComplete(rule)).length);
|
||||
|
||||
function defaultRule(): DataGridStructuredFilterRule {
|
||||
return { id: options.createId?.() ?? crypto.randomUUID(), columnName: toValue(options.columns)[0] ?? "", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" };
|
||||
}
|
||||
function ensureRule() {
|
||||
if (!rules.value.length && toValue(options.columns).length) rules.value = [defaultRule()];
|
||||
}
|
||||
function addRule() {
|
||||
ensureRule();
|
||||
rules.value = [...rules.value, defaultRule()];
|
||||
}
|
||||
function removeRule(id: string) {
|
||||
rules.value = rules.value.filter((rule) => rule.id !== id);
|
||||
if (!rules.value.length) appliedWhereInput.value = "";
|
||||
}
|
||||
function updateRule(id: string, patch: Partial<DataGridStructuredFilterRule>) {
|
||||
rules.value = rules.value.map((rule) => {
|
||||
if (rule.id !== id) return rule;
|
||||
const next = { ...rule, ...patch };
|
||||
if (!filterModeNeedsValue(next.mode)) next.rawValue = next.rawEndValue = "";
|
||||
else if (!filterModeUsesRange(next.mode)) next.rawEndValue = "";
|
||||
return next;
|
||||
});
|
||||
}
|
||||
function reset() {
|
||||
appliedWhereInput.value = "";
|
||||
rules.value = toValue(options.columns).length ? [defaultRule()] : [];
|
||||
}
|
||||
async function buildWhere() {
|
||||
const items = (await Promise.all(rules.value.map(async (rule) => ({ rule, condition: !rule.disabled && rule.columnName && options.isComplete(rule) ? await options.buildCondition(rule) : undefined })))).filter(
|
||||
(item): item is { rule: DataGridStructuredFilterRule; condition: string } => !!item.condition,
|
||||
);
|
||||
return buildDataGridStructuredWhere(items);
|
||||
}
|
||||
async function apply() {
|
||||
appliedWhereInput.value = await buildWhere();
|
||||
open.value = false;
|
||||
return appliedWhereInput.value;
|
||||
}
|
||||
|
||||
watch(open, (value) => {
|
||||
if (!value) columnSearch.value = "";
|
||||
});
|
||||
watch(
|
||||
() => [...toValue(options.columns)],
|
||||
(columns) => {
|
||||
if (!columns.length) rules.value = [];
|
||||
else rules.value = rules.value.map((rule) => (columns.includes(rule.columnName) ? rule : { ...rule, columnName: columns[0] }));
|
||||
},
|
||||
);
|
||||
|
||||
return { rules, open, columnSearch, appliedWhereInput, filteredColumns, activeCount, defaultRule, ensureRule, addRule, removeRule, updateRule, reset, buildWhere, apply };
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { onUnmounted, watch, type Ref } from "vue";
|
||||
import type { DataGridRuntimeScope } from "@/lib/dataGrid/dataGridRuntime";
|
||||
|
||||
export interface UseDataGridResultLifecycleOptions {
|
||||
resultKey: Ref<unknown>;
|
||||
runtimeScope?: DataGridRuntimeScope;
|
||||
}
|
||||
|
||||
export function useDataGridResultLifecycle(options: UseDataGridResultLifecycleOptions) {
|
||||
let version = 0;
|
||||
let disposed = false;
|
||||
|
||||
const invalidate = () => {
|
||||
version += 1;
|
||||
};
|
||||
|
||||
const stopWatchingResult = watch(options.resultKey, invalidate);
|
||||
const cleanup = () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
invalidate();
|
||||
stopWatchingResult();
|
||||
};
|
||||
|
||||
if (options.runtimeScope) options.runtimeScope.addCleanup(cleanup);
|
||||
else onUnmounted(cleanup);
|
||||
|
||||
return {
|
||||
beginOperation() {
|
||||
return version;
|
||||
},
|
||||
isCurrent(operationVersion: number) {
|
||||
return !disposed && operationVersion === version;
|
||||
},
|
||||
invalidate,
|
||||
cleanup,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { getCurrentScope, onScopeDispose } from "vue";
|
||||
import type { DataGridAnimationFrameDriver } from "./useDataGridCanvasRuntime";
|
||||
|
||||
export interface DataGridScrollbarsOptions {
|
||||
update(): void;
|
||||
getScroller(): Element | null;
|
||||
applyHorizontalDrag(clientX: number): void;
|
||||
applyVerticalDrag(clientY: number): void;
|
||||
frameDriver: DataGridAnimationFrameDriver;
|
||||
createResizeObserver?: (callback: ResizeObserverCallback) => ResizeObserver | null;
|
||||
startPaused?: boolean;
|
||||
}
|
||||
|
||||
export interface DataGridScrollbarsRuntime {
|
||||
readonly active: boolean;
|
||||
readonly disposed: boolean;
|
||||
scheduleUpdate(): void;
|
||||
scheduleHorizontalDrag(clientX: number): void;
|
||||
scheduleVerticalDrag(clientY: number): void;
|
||||
flushHorizontalDrag(): void;
|
||||
flushVerticalDrag(): void;
|
||||
observeScroller(): void;
|
||||
pause(): void;
|
||||
resume(): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
function defaultResizeObserverFactory(callback: ResizeObserverCallback): ResizeObserver | null {
|
||||
return typeof ResizeObserver === "undefined" ? null : new ResizeObserver(callback);
|
||||
}
|
||||
|
||||
export function useDataGridScrollbars(options: DataGridScrollbarsOptions): DataGridScrollbarsRuntime {
|
||||
const createResizeObserver = options.createResizeObserver ?? defaultResizeObserverFactory;
|
||||
let active = !options.startPaused;
|
||||
let disposed = false;
|
||||
let updateFrame: number | null = null;
|
||||
let horizontalFrame: number | null = null;
|
||||
let verticalFrame: number | null = null;
|
||||
let pendingClientX = 0;
|
||||
let pendingClientY = 0;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
const cancelFrame = (frame: number | null) => {
|
||||
if (frame !== null) options.frameDriver.cancel(frame);
|
||||
};
|
||||
const runtime: DataGridScrollbarsRuntime = {
|
||||
get active() {
|
||||
return active;
|
||||
},
|
||||
get disposed() {
|
||||
return disposed;
|
||||
},
|
||||
scheduleUpdate() {
|
||||
if (!active || disposed || updateFrame !== null) return;
|
||||
updateFrame = options.frameDriver.request(() => {
|
||||
updateFrame = null;
|
||||
if (active && !disposed) options.update();
|
||||
});
|
||||
},
|
||||
scheduleHorizontalDrag(clientX) {
|
||||
pendingClientX = clientX;
|
||||
if (!active || disposed || horizontalFrame !== null) return;
|
||||
horizontalFrame = options.frameDriver.request(() => {
|
||||
horizontalFrame = null;
|
||||
if (active && !disposed) options.applyHorizontalDrag(pendingClientX);
|
||||
});
|
||||
},
|
||||
scheduleVerticalDrag(clientY) {
|
||||
pendingClientY = clientY;
|
||||
if (!active || disposed || verticalFrame !== null) return;
|
||||
verticalFrame = options.frameDriver.request(() => {
|
||||
verticalFrame = null;
|
||||
if (active && !disposed) options.applyVerticalDrag(pendingClientY);
|
||||
});
|
||||
},
|
||||
flushHorizontalDrag() {
|
||||
if (!active || disposed || horizontalFrame === null) return;
|
||||
options.frameDriver.cancel(horizontalFrame);
|
||||
horizontalFrame = null;
|
||||
options.applyHorizontalDrag(pendingClientX);
|
||||
},
|
||||
flushVerticalDrag() {
|
||||
if (!active || disposed || verticalFrame === null) return;
|
||||
options.frameDriver.cancel(verticalFrame);
|
||||
verticalFrame = null;
|
||||
options.applyVerticalDrag(pendingClientY);
|
||||
},
|
||||
observeScroller() {
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = null;
|
||||
if (!active || disposed) return;
|
||||
const scroller = options.getScroller();
|
||||
if (scroller) {
|
||||
resizeObserver = createResizeObserver(runtime.scheduleUpdate);
|
||||
resizeObserver?.observe(scroller);
|
||||
// Child size changes can alter scrollWidth without resizing the scroller itself.
|
||||
for (const child of Array.from(scroller.children)) resizeObserver?.observe(child);
|
||||
}
|
||||
runtime.scheduleUpdate();
|
||||
},
|
||||
pause() {
|
||||
active = false;
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = null;
|
||||
cancelFrame(updateFrame);
|
||||
cancelFrame(horizontalFrame);
|
||||
cancelFrame(verticalFrame);
|
||||
updateFrame = null;
|
||||
horizontalFrame = null;
|
||||
verticalFrame = null;
|
||||
},
|
||||
resume() {
|
||||
if (disposed) return;
|
||||
active = true;
|
||||
runtime.observeScroller();
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
runtime.pause();
|
||||
disposed = true;
|
||||
},
|
||||
};
|
||||
|
||||
if (getCurrentScope()) onScopeDispose(runtime.dispose);
|
||||
return runtime;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { computed, getCurrentScope, nextTick, onScopeDispose, ref, toValue, watch, type MaybeRefOrGetter } from "vue";
|
||||
|
||||
export type DataGridSearchMatch = {
|
||||
kind: "cell" | "column";
|
||||
displayRow: number;
|
||||
col: number;
|
||||
};
|
||||
|
||||
export type UseDataGridSearchOptions<Row> = {
|
||||
columns: MaybeRefOrGetter<readonly string[]>;
|
||||
suggestionColumns?: MaybeRefOrGetter<readonly string[]>;
|
||||
rows: MaybeRefOrGetter<readonly Row[]>;
|
||||
getCellText: (row: Row, columnIndex: number) => string;
|
||||
debounceMs?: number;
|
||||
onNavigate?: (match: DataGridSearchMatch) => void;
|
||||
};
|
||||
|
||||
const SEARCH_TOKEN_SEPARATOR = /[\s,()><=!&|]+/;
|
||||
const SEARCH_TOKEN_SUFFIX = /([^\s,()><=!&|]+)$/;
|
||||
const SEARCH_PAIRS: Record<string, string> = { "'": "'", '"': '"', "(": ")" };
|
||||
|
||||
export function useDataGridSearch<Row>(options: UseDataGridSearchOptions<Row>) {
|
||||
const searchText = ref("");
|
||||
const deferredSearchText = ref("");
|
||||
const overlayVisible = ref(false);
|
||||
const currentMatchIndex = ref(-1);
|
||||
const suggestions = ref<string[]>([]);
|
||||
const suggestionIndex = ref(-1);
|
||||
let searchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const matches = computed<DataGridSearchMatch[]>(() => {
|
||||
const query = deferredSearchText.value;
|
||||
if (!query) return [];
|
||||
const result: DataGridSearchMatch[] = [];
|
||||
const columns = toValue(options.columns);
|
||||
columns.forEach((column, col) => {
|
||||
if (column.toLowerCase().includes(query)) result.push({ kind: "column", displayRow: -1, col });
|
||||
});
|
||||
toValue(options.rows).forEach((row, displayRow) => {
|
||||
columns.forEach((_, col) => {
|
||||
if (options.getCellText(row, col).toLowerCase().includes(query)) result.push({ kind: "cell", displayRow, col });
|
||||
});
|
||||
});
|
||||
return result;
|
||||
});
|
||||
const matchSet = computed(() => new Set(matches.value.map((match) => `${match.kind}:${match.displayRow}:${match.col}`)));
|
||||
const currentMatch = computed(() => matches.value[currentMatchIndex.value] ?? null);
|
||||
|
||||
function clearTimer() {
|
||||
if (searchTimer !== undefined) clearTimeout(searchTimer);
|
||||
searchTimer = undefined;
|
||||
}
|
||||
|
||||
watch(searchText, (value) => {
|
||||
clearTimer();
|
||||
const query = value.trim().toLowerCase();
|
||||
if (!query) deferredSearchText.value = "";
|
||||
else searchTimer = setTimeout(() => (deferredSearchText.value = query), options.debounceMs ?? 150);
|
||||
|
||||
const lastToken = value.trim().split(SEARCH_TOKEN_SEPARATOR).pop()?.toLowerCase() ?? "";
|
||||
suggestions.value = lastToken
|
||||
? toValue(options.suggestionColumns ?? options.columns)
|
||||
.filter((column) => column.toLowerCase().startsWith(lastToken) && column.toLowerCase() !== lastToken)
|
||||
.slice(0, 8)
|
||||
: [];
|
||||
suggestionIndex.value = suggestions.value.length ? 0 : -1;
|
||||
});
|
||||
|
||||
watch(matches, (value) => {
|
||||
currentMatchIndex.value = value.length ? 0 : -1;
|
||||
if (value[0]) nextTick(() => options.onNavigate?.(value[0]));
|
||||
});
|
||||
|
||||
function acceptSuggestion(index = suggestionIndex.value) {
|
||||
const suggestion = suggestions.value[index];
|
||||
if (!suggestion) return false;
|
||||
const token = searchText.value.match(SEARCH_TOKEN_SUFFIX)?.[1];
|
||||
if (token) searchText.value = searchText.value.slice(0, -token.length) + suggestion;
|
||||
suggestions.value = [];
|
||||
suggestionIndex.value = -1;
|
||||
return true;
|
||||
}
|
||||
|
||||
function navigateSuggestion(delta: number) {
|
||||
if (!suggestions.value.length) return;
|
||||
suggestionIndex.value = Math.min(Math.max(suggestionIndex.value + delta, 0), suggestions.value.length - 1);
|
||||
}
|
||||
|
||||
function navigateMatch(delta: number) {
|
||||
if (!matches.value.length) return;
|
||||
currentMatchIndex.value = (currentMatchIndex.value + delta + matches.value.length) % matches.value.length;
|
||||
const match = currentMatch.value;
|
||||
if (match) options.onNavigate?.(match);
|
||||
}
|
||||
|
||||
function close() {
|
||||
clearTimer();
|
||||
overlayVisible.value = false;
|
||||
searchText.value = "";
|
||||
deferredSearchText.value = "";
|
||||
suggestions.value = [];
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
const pair = SEARCH_PAIRS[event.key];
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (pair && !event.ctrlKey && !event.metaKey && input?.setSelectionRange) {
|
||||
const start = input.selectionStart ?? 0;
|
||||
const end = input.selectionEnd ?? start;
|
||||
event.preventDefault();
|
||||
const selected = searchText.value.slice(start, end);
|
||||
searchText.value = `${searchText.value.slice(0, start)}${event.key}${selected}${pair}${searchText.value.slice(end)}`;
|
||||
nextTick(() => input.setSelectionRange(start + 1 + selected.length, start + 1 + selected.length));
|
||||
return;
|
||||
}
|
||||
if (suggestions.value.length && event.key === "Tab") {
|
||||
event.preventDefault();
|
||||
acceptSuggestion();
|
||||
} else if (suggestions.value.length && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
|
||||
event.preventDefault();
|
||||
navigateSuggestion(event.key === "ArrowDown" ? 1 : -1);
|
||||
} else if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
navigateMatch(event.shiftKey ? -1 : 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (getCurrentScope()) onScopeDispose(clearTimer);
|
||||
|
||||
return { searchText, deferredSearchText, overlayVisible, currentMatchIndex, suggestions, suggestionIndex, matches, matchSet, currentMatch, acceptSuggestion, navigateSuggestion, navigateMatch, close, onKeydown };
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ref, computed, getCurrentScope, onScopeDispose, type ComputedRef, type Ref } from "vue";
|
||||
import { allCellsSelectionRange, extractColumnsSelection, extractSelection, isCellInSelection, normalizeSelectionRange, normalizeSelectedColumnIndexes, rowSelectionRange, type CellPosition, type CellSelectionRange, type SelectionData } from "@/lib/dataGrid/gridSelection";
|
||||
import type { DataGridRuntimeScope } from "@/lib/dataGrid/dataGridRuntime";
|
||||
|
||||
type CellValue = string | number | boolean | null;
|
||||
|
||||
@@ -24,6 +25,7 @@ export interface UseDataGridSelectionOptions {
|
||||
gridRef: Ref<HTMLDivElement | undefined>;
|
||||
getScrollElement?: () => HTMLElement | null;
|
||||
cellFromClientPoint?: (clientX: number, clientY: number) => CellPosition | null;
|
||||
runtimeScope?: DataGridRuntimeScope;
|
||||
}
|
||||
|
||||
const AUTO_SCROLL_EDGE_SIZE = 40;
|
||||
@@ -341,7 +343,8 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
|
||||
document.addEventListener("mousemove", handleSelectionPointerMove);
|
||||
}
|
||||
|
||||
if (getCurrentScope()) onScopeDispose(finishCellSelection);
|
||||
if (options.runtimeScope) options.runtimeScope.addCleanup(finishCellSelection);
|
||||
else if (getCurrentScope()) onScopeDispose(finishCellSelection);
|
||||
|
||||
function extendCellSelection(rowIndex: number, colIndex: number) {
|
||||
if (!isSelectingCells.value || !selectionAnchor.value) return;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ref } from "vue";
|
||||
import type { DataGridSortDirection, DataGridSortMode } from "@/lib/dataGrid/dataGridSort";
|
||||
|
||||
export function useDataGridSort() {
|
||||
const sortColumn = ref<string | null>(null);
|
||||
const sortColumnIndex = ref<number | null>(null);
|
||||
const sortDirection = ref<DataGridSortDirection>("asc");
|
||||
const sortMode = ref<DataGridSortMode>("database");
|
||||
|
||||
function setSort(column: string | null, columnIndex: number | null, direction: DataGridSortDirection, mode: DataGridSortMode) {
|
||||
sortColumn.value = column;
|
||||
sortColumnIndex.value = columnIndex;
|
||||
sortDirection.value = direction;
|
||||
sortMode.value = mode;
|
||||
}
|
||||
|
||||
function clearSort() {
|
||||
setSort(null, null, "asc", "database");
|
||||
}
|
||||
|
||||
return {
|
||||
sortColumn,
|
||||
sortColumnIndex,
|
||||
sortDirection,
|
||||
sortMode,
|
||||
setSort,
|
||||
clearSort,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user