fix(diagram): stop search box swallowing keystrokes

This commit is contained in:
jearhe authored and GitHub committed 2026-09-01 01:15:37 +08:00
1 parent 49926f25f1
commit 47ddbf6bb4
4 files changed
+160 -2

No files matched your search

@@ -127,7 +127,7 @@ function connectionIconType(id: string) {
<div class="relative min-w-40 flex-1">
<Search class="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input :value="tableSearch" @input="(e: Event) => emit('update:table-search', (e.target as HTMLInputElement).value)" class="h-8 pl-7 pr-9 text-xs" :placeholder="t('diagram.searchTables')" />
<Input :model-value="tableSearch" @update:model-value="(value) => emit('update:table-search', String(value))" class="h-8 pl-7 pr-9 text-xs" :placeholder="t('diagram.searchTables')" />
<button v-if="tableSearch" type="button" class="absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 rounded-full bg-muted-foreground/20 hover:bg-muted-foreground/40 flex items-center justify-center transition-colors" @click="emit('update:table-search', '')">
<X class="h-3 w-3 text-muted-foreground" />
</button>
@@ -1794,7 +1794,7 @@ function handleKeydown(e: KeyboardEvent) {
return;
}
}
if (e.key === " " || e.key === "Spacebar") {
if (!typing && (e.key === " " || e.key === "Spacebar")) {
e.preventDefault();
isSpacePressed.value = true;
}
@@ -0,0 +1,145 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, ref, type App } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("vue-i18n", () => ({
useI18n: () => ({ t: (key: string) => key }),
}));
vi.mock("@lucide/vue", async () => {
const { defineComponent, h } = await import("vue");
const Icon = defineComponent({
inheritAttrs: false,
setup(_props, { attrs }) {
return () => h("span", attrs);
},
});
return Object.fromEntries(["Copy", "Download", "Link2", "Loader2", "Maximize2", "Minimize2", "Network", "Plus", "RefreshCw", "Search", "Table2", "Upload", "X", "ZoomIn", "ZoomOut", "LayoutGrid"].map((name) => [name, Icon]));
});
vi.mock("@/components/ui/input", async () => {
const { defineComponent, h } = await import("vue");
return {
// Keep this stub on Input's public model API so the test catches accidental
// reliance on fallthrough value/input attributes.
Input: defineComponent({
inheritAttrs: false,
props: {
modelValue: { type: [String, Number], default: "" },
},
emits: ["update:modelValue"],
setup(props, { emit }) {
return () =>
h("input", {
value: props.modelValue,
onInput: (event: Event) => emit("update:modelValue", (event.target as HTMLInputElement).value),
});
},
}),
};
});
async function passthrough(tag: string) {
const { defineComponent, h } = await import("vue");
return defineComponent({
inheritAttrs: false,
setup(_props, { attrs, slots }) {
return () => h(tag, attrs, slots.default?.());
},
});
}
vi.mock("@/components/ui/badge", async () => ({ Badge: await passthrough("span") }));
vi.mock("@/components/ui/button", async () => ({ Button: await passthrough("button") }));
vi.mock("@/components/ui/dropdown-menu", async () => ({
DropdownMenu: await passthrough("div"),
DropdownMenuContent: await passthrough("div"),
DropdownMenuItem: await passthrough("button"),
DropdownMenuTrigger: await passthrough("div"),
}));
vi.mock("@/components/ui/select", async () => ({
Select: await passthrough("div"),
SelectContent: await passthrough("div"),
SelectItem: await passthrough("div"),
SelectTrigger: await passthrough("button"),
SelectValue: await passthrough("span"),
}));
vi.mock("@/components/icons/DatabaseIcon.vue", async () => ({ default: await passthrough("span") }));
vi.mock("@/components/connection/ConnectionGroupBadge.vue", async () => ({ default: await passthrough("span") }));
import DiagramToolbar from "../DiagramToolbar.vue";
const mountedApps: Array<{ app: App; host: HTMLElement }> = [];
afterEach(() => {
for (const { app, host } of mountedApps.splice(0)) {
app.unmount();
host.remove();
}
});
function mountToolbar() {
const search = ref("");
const updates: string[] = [];
const host = document.createElement("div");
document.body.append(host);
const app = createApp(
defineComponent({
setup() {
return () =>
h(DiagramToolbar, {
connectionId: "",
database: "",
schema: "",
databases: [],
schemas: [],
sqlConnections: [],
selectedConnection: undefined,
isSchemaAware: false,
loadingDatabases: false,
loadingSchemas: false,
loadingDiagram: false,
diagramReady: false,
tablesCount: 0,
relationshipsCount: 0,
customRelationshipCount: 0,
matchRelationshipCount: 0,
diagramMode: "table",
tableSearch: search.value,
showMatchPanel: false,
showLayersPanel: false,
showAllTables: false,
focusTableName: "",
generatedJoinSql: "",
"onUpdate:table-search": (value: string) => {
updates.push(value);
search.value = value;
},
});
},
}),
);
app.mount(host);
mountedApps.push({ app, host });
return { host, search, updates };
}
describe("DiagramToolbar", () => {
it("forwards one search update per typed character through Input's model API", async () => {
const { host, search, updates } = mountToolbar();
await nextTick();
const input = host.querySelector("input") as HTMLInputElement;
for (const character of "user") {
input.value += character;
input.dispatchEvent(new Event("input", { bubbles: true }));
await nextTick();
await nextTick();
}
expect(updates).toEqual(["u", "us", "use", "user"]);
expect(search.value).toBe("user");
expect(input.value).toBe("user");
});
});
@@ -0,0 +1,13 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const dialogSource = readFileSync(new URL("../SchemaDiagramDialog.vue", import.meta.url), "utf8");
describe("SchemaDiagramDialog keyboard boundaries", () => {
it("does not consume a space typed in an editable control for canvas panning", () => {
const keydownSource = dialogSource.slice(dialogSource.indexOf("function handleKeydown"), dialogSource.indexOf("function handleKeyup"));
expect(keydownSource).toContain('const typing = target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT" || target.isContentEditable);');
expect(keydownSource).toContain('if (!typing && (e.key === " " || e.key === "Spacebar"))');
});
});