feat(grid): add go-to-column shortcut mapping

Closes #4108
This commit is contained in:
t8y2 committed 2026-08-24 20:41:29 +00:00
1 parent 5c75c2391c
commit cd18b7a3db
14 files changed
+313 -3

No files matched your search

@@ -182,7 +182,7 @@ import { temporalCellEditorConfig, type TemporalCellEditorConfig } from "@/lib/d
import { BOOLEAN_CELL_EDITOR_VALUES, booleanCellEditorValue, isBooleanCellValue, isBooleanColumnType, isPointInBooleanCheckbox, nextBooleanCellValue, normalizeBooleanCellValue, parseBooleanCellEditorValue } from "@/lib/dataGrid/dataGridBooleanColumn";
import { resolveDataGridColumnNullability, resolveDataGridColumnsByResultIndex } from "@/lib/dataGrid/dataGridColumnMetadata";
import { resolveDataGridNewRowCellPlaceholder } from "@/lib/dataGrid/dataGridDefaultPlaceholder";
import { isCancelSearchShortcut, isCopyCurrentRowShortcut, isDeleteCurrentRowShortcut, isFocusSearchShortcut, isModRShortcut, isSaveShortcut, isToggleTransposeShortcut } from "@/lib/editor/keyboardShortcuts";
import { isCancelSearchShortcut, isCopyCurrentRowShortcut, isDeleteCurrentRowShortcut, isFocusSearchShortcut, isGoToColumnShortcut, isModRShortcut, isSaveShortcut, isToggleTransposeShortcut } from "@/lib/editor/keyboardShortcuts";
import { dataGridHeaderContentWidth, scrollbarGutterWidth } from "@/lib/dataGrid/dataGridScrollGutter";
import { canFetchNextDataGridSegment, canGoNextDataGridPage, dataGridTotalRowCountLabelKey, dataGridTruncationHintKey, hasCompleteLocalDataGridResult, resolveDataGridPaginationTotal, type DataGridInexactTotalRowCountMode } from "@/lib/dataGrid/dataGridPagination";
import { dataGridCountQueryOptions } from "@/lib/dataGrid/dataGridQueryOptions";
@@ -8980,6 +8980,13 @@ function openCellDetailSearch(): boolean {
async function onGridKeydown(event: KeyboardEvent) {
if (event.defaultPrevented) return;
const targetAllowsNativeClipboard = eventTargetAllowsNativeClipboard(event);
if (!targetAllowsNativeClipboard && displayableColumnIndexes.value.length > 0 && isGoToColumnShortcut(event, settingsStore.editorSettings.shortcuts)) {
event.preventDefault();
event.stopPropagation();
goToColumnOpen.value = true;
return;
}
if (isFocusSearchShortcut(event)) {
event.preventDefault();
focusSearch();
@@ -8991,7 +8998,7 @@ async function onGridKeydown(event: KeyboardEvent) {
await onToolbarRefresh();
return;
}
if (eventTargetAllowsNativeClipboard(event)) return;
if (targetAllowsNativeClipboard) return;
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "z") {
const handled = event.shiftKey ? redoGridChange() : undoGridChange();
if (handled) {
@@ -0,0 +1,247 @@
// @vitest-environment happy-dom
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { createApp, defineComponent, h, markRaw, nextTick, type App, type PropType } from "vue";
import { createPinia, setActivePinia } from "pinia";
import { afterEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import type { QueryResult } from "@/types/database";
import { TooltipProvider } from "@/components/ui/tooltip";
vi.mock("@/composables/useDataGridColumnResize", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/composables/useDataGridColumnResize")>();
const { ref } = await import("vue");
return {
...actual,
useDataGridColumnResize: () => ({
initColumnWidths: vi.fn(),
onResizeStart: vi.fn(),
autoFitColumn: vi.fn(),
renderedColumnWidths: ref([120]),
totalWidth: ref(120),
columnVars: ref({ "--total-w": "120px" }),
getIsResizing: () => false,
}),
};
});
import DataGrid from "../DataGrid.vue";
import { useSettingsStore } from "@/stores/settingsStore";
const dataGridSource = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/grid/DataGrid.vue"), "utf8");
const mountedApps: Array<{ app: App; host: HTMLElement }> = [];
const RecycleScroller = defineComponent({
props: {
items: {
type: Array as PropType<unknown[]>,
default: () => [],
},
},
setup(props, { attrs, slots }) {
return () =>
h(
"div",
attrs,
props.items.map((item) => slots.default?.({ item })),
);
},
});
function mountGrid(options: { displayableColumns?: boolean } = {}) {
const pinia = createPinia();
setActivePinia(pinia);
const settingsStore = useSettingsStore();
settingsStore.updateEditorSettings({
dataGridRenderMode: "canvas",
shortcuts: {
...settingsStore.editorSettings.shortcuts,
goToColumn: "Mod+G",
},
});
const result = markRaw<QueryResult>({
columns: ["id"],
rows: [[1]],
affected_rows: 0,
execution_time_ms: 0,
hidden_column_indexes: options.displayableColumns === false ? [0] : undefined,
});
const host = document.createElement("div");
document.body.append(host);
const Root = defineComponent({
setup() {
return () =>
h(
TooltipProvider,
{ delayDuration: 0 },
{
default: () =>
h(DataGrid, {
result,
databaseType: "mysql",
context: "table-data",
}),
},
);
},
});
const app = createApp(Root);
app.use(pinia);
app.use(i18n);
app.component("RecycleScroller", RecycleScroller);
app.mount(host);
const mounted = { app, host };
mountedApps.push(mounted);
return mounted;
}
async function settle() {
await nextTick();
await Promise.resolve();
await nextTick();
}
function gridRoot(host: HTMLElement): HTMLElement {
const root = host.querySelector<HTMLElement>("[data-grid-root]");
if (!root) throw new Error("Data grid root not found");
return root;
}
function goToColumnButton(host: HTMLElement): HTMLButtonElement {
const button = [...host.querySelectorAll<HTMLButtonElement>("button")].find((candidate) => candidate.textContent?.trim() === "Go to column");
if (!button) throw new Error("Go-to-column button not found");
return button;
}
function goToColumnEvent(target: HTMLElement): KeyboardEvent {
const event = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ctrlKey: true, key: "g" });
target.dispatchEvent(event);
return event;
}
afterEach(() => {
for (const { app, host } of mountedApps.splice(0)) {
app.unmount();
host.remove();
}
});
function functionBody(name: string, nextName: string): string {
const start = dataGridSource.indexOf(`function ${name}`);
const end = dataGridSource.indexOf(`function ${nextName}`, start + 1);
return start >= 0 && end > start ? dataGridSource.slice(start, end) : "";
}
describe("DataGrid go-to-column shortcut", () => {
it("opens and consumes the configured shortcut when a column is displayable", async () => {
const { host } = mountGrid();
await settle();
const root = gridRoot(host);
const bubbled = vi.fn();
host.addEventListener("keydown", bubbled);
root.focus();
expect(document.activeElement).toBe(root);
const event = goToColumnEvent(root);
await settle();
expect(event.defaultPrevented).toBe(true);
expect(bubbled).not.toHaveBeenCalled();
expect(goToColumnButton(host).getAttribute("aria-expanded")).toBe("true");
expect(document.activeElement?.getAttribute("placeholder")).toBe("Search column/comment...");
});
it("does not consume the configured shortcut without a displayable column", async () => {
const { host } = mountGrid({ displayableColumns: false });
await settle();
const root = gridRoot(host);
const bubbled = vi.fn();
host.addEventListener("keydown", bubbled);
const event = goToColumnEvent(root);
await settle();
expect(event.defaultPrevented).toBe(false);
expect(bubbled).toHaveBeenCalledOnce();
expect(goToColumnButton(host).getAttribute("aria-expanded")).toBe("false");
});
it("does not trigger or consume shortcuts from editable targets", async () => {
const { host } = mountGrid();
await settle();
const root = gridRoot(host);
const bubbled = vi.fn();
host.addEventListener("keydown", bubbled);
const targets = [document.createElement("input"), document.createElement("textarea"), document.createElement("div"), document.createElement("div")];
targets[2]!.setAttribute("contenteditable", "true");
targets[3]!.setAttribute("role", "textbox");
for (const target of targets) {
root.append(target);
const event = goToColumnEvent(target);
expect(event.defaultPrevented).toBe(false);
}
await settle();
expect(bubbled).toHaveBeenCalledTimes(targets.length);
expect(goToColumnButton(host).getAttribute("aria-expanded")).toBe("false");
});
it("leaves an unmatched root event untouched", async () => {
const { host } = mountGrid();
await settle();
const root = gridRoot(host);
const bubbled = vi.fn();
host.addEventListener("keydown", bubbled);
const event = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ctrlKey: true, key: "j" });
root.dispatchEvent(event);
await settle();
expect(event.defaultPrevented).toBe(false);
expect(bubbled).toHaveBeenCalledOnce();
expect(goToColumnButton(host).getAttribute("aria-expanded")).toBe("false");
});
it("opens the existing popover only for a handled root shortcut", () => {
const keydown = functionBody("onGridKeydown", "copyDetailValue");
expect(dataGridSource).toMatch(/<div\b(?=[^>]*\bdata-grid-root)(?=[^>]*\btabindex="0")(?=[^>]*@keydown="onGridKeydown")[^>]*>/);
expect(keydown).toMatch(
/const targetAllowsNativeClipboard = eventTargetAllowsNativeClipboard\(event\);[\s\S]*?if \(!targetAllowsNativeClipboard && displayableColumnIndexes\.value\.length > 0 && isGoToColumnShortcut\(event, settingsStore\.editorSettings\.shortcuts\)\) \{[\s\S]*?event\.preventDefault\(\);[\s\S]*?event\.stopPropagation\(\);[\s\S]*?goToColumnOpen\.value = true;[\s\S]*?return;[\s\S]*?\}/,
);
});
it("leaves editable targets and unhandled events untouched by the shortcut", () => {
const keydown = functionBody("onGridKeydown", "copyDetailValue");
const shortcutStart = keydown.indexOf("if (!targetAllowsNativeClipboard && displayableColumnIndexes.value.length > 0 && isGoToColumnShortcut");
const shortcutEnd = keydown.indexOf("if (isFocusSearchShortcut", shortcutStart);
const shortcutBranch = keydown.slice(shortcutStart, shortcutEnd);
expect(shortcutStart).toBeGreaterThan(-1);
expect(shortcutBranch).not.toContain("else");
expect(shortcutBranch.match(/preventDefault/g)).toHaveLength(1);
expect(shortcutBranch.match(/stopPropagation/g)).toHaveLength(1);
});
it("keeps toolbar navigation and the existing search/filter/reveal path", () => {
const selectColumn = functionBody("scrollToColumn", "onGoToColumnKeydown");
const escape = functionBody("onGoToColumnKeydown", "matchesTableInfoColumn");
const scroll = functionBody("scrollToColumnIndex", "measureColumnHeaderText");
expect(dataGridSource.match(/<Popover v-model:open="goToColumnOpen">/g)).toHaveLength(1);
expect(dataGridSource).toContain('>{{ t("grid.goToColumn") }}</span');
expect(dataGridSource).toContain('v-model="goToColumnSearch"');
expect(dataGridSource).toContain("filterDataGridColumnLookupItems(goToColumnItems.value, goToColumnSearch.value)");
expect(selectColumn).toContain('goToColumnSearch.value = ""');
expect(selectColumn).toContain("scrollToColumnIndex(columnIndex)");
expect(escape).toMatch(/event\.key === "Escape"[\s\S]*?goToColumnOpen\.value = false;[\s\S]*?goToColumnSearch\.value = ""/);
expect(scroll).toContain("if (hiddenColumnIndexes.value.has(columnIndex))");
expect(scroll).toContain("showColumn(columnIndex)");
expect(scroll).toContain("highlightedColumnIndex.value = columnIndex");
expect(scroll).toContain("scroller.scrollLeft = targetLeft");
expect(scroll).toContain("updateGridHorizontalViewport(scroller)");
});
});