fix(connection): improve connection picker interactions

This commit is contained in:
zipg
2026-07-23 22:48:38 +08:00
committed by GitHub
parent 3c04adbdd8
commit 9a5edae219
5 changed files with 134 additions and 13 deletions
@@ -48,6 +48,7 @@ import { appendConnectionErrorHints, isJdbcMissingRuntimeDependencyError } from
import { postgresTlsModeForForm } from "@/lib/connection/postgresTlsMode";
import { buildMqKafkaConnectionExtra, mqKafkaConnectionTarget, resolveMqKafkaConnectionSource, type MqKafkaConnectionSource } from "@/lib/connection/mqKafkaConnection";
import { assertCompleteDatabaseCategories, databaseSelectionForCategory } from "@/lib/connection/databaseCategoryOptions";
import { loadConnectionPickerView, saveConnectionPickerView, type DbPickerView } from "@/lib/connection/connectionPickerViewPreference";
import { normalizeRocketmqNamesrvAddr } from "@/lib/connection/rocketmqNamesrv";
import { normalizeRabbitmqAddresses } from "@/lib/connection/rabbitmqAddresses";
import { detectMqUiAuthKind, isMqAuthKindAllowedForSystem, type MqUiAuthKind } from "@/lib/connection/mqAuth";
@@ -98,7 +99,6 @@ type DbOption = { value: string; label: string };
type DbCategoryKey = "sql" | "analytics" | "domestic" | "lightweight" | "document" | "graph_ai" | "timeseries" | "mq" | "registry_config";
type DbCategory = { key: DbCategoryKey; title: string; options: DbOption[] };
type DialogStep = "select" | "config";
type DbPickerView = "icon" | "list";
export type ConfigTab = "connection" | "advanced" | "tls" | "transport";
type ProductionScope = "connection" | "databases";
type MqTokenSigningMode = "none" | "hs256" | "rs256";
@@ -508,7 +508,7 @@ const hiveJaasConfigPath = ref("");
const hiveUseSubjectCredsOnlyFalse = ref(false);
const hiveExtraJavaOptions = ref("");
const dialogStep = ref<DialogStep>("select");
const dbPickerView = ref<DbPickerView>("icon");
const dbPickerView = ref<DbPickerView>(loadConnectionPickerView());
const dbSearchQuery = ref("");
const selectedDbCategory = ref<DbCategoryKey>("sql");
const configTab = ref<ConfigTab>("connection");
@@ -2367,6 +2367,11 @@ function selectDbCategory(category: DbCategoryKey) {
if (nextSelection && nextSelection !== selectedType.value) onDbTypeChange(nextSelection);
}
function selectDbPickerView(view: DbPickerView) {
dbPickerView.value = view;
saveConnectionPickerView(view);
}
function dbCategoryForOption(value: string): DbCategoryKey | undefined {
return dbCategories.value.find((category) => category.options.some((option) => option.value === value))?.key;
}
@@ -2725,6 +2730,18 @@ function backToDatabasePicker() {
resetTestState();
}
function handleDialogOpenAutoFocus(event: Event) {
event.preventDefault();
if (!(event.currentTarget instanceof HTMLElement)) return;
event.currentTarget.querySelector<HTMLElement>("[data-connection-dialog-title]")?.focus({ preventScroll: true });
}
function handleDialogEscape(event: KeyboardEvent) {
if (dialogStep.value !== "config" || editingId.value) return;
event.preventDefault();
backToDatabasePicker();
}
watch(customDriverName, (value) => {
if (isCustomCompatibleProfile()) {
form.value.driver_label = value.trim() || selectedProfile().label;
@@ -3758,7 +3775,6 @@ function resetForm() {
connectionUrlInput.value = "";
appliedConnectionUrlInput.value = "";
dialogStep.value = "select";
dbPickerView.value = "icon";
dbSearchQuery.value = "";
selectedDbCategory.value = "sql";
configTab.value = "connection";
@@ -4445,9 +4461,9 @@ function openExternalUrl(url: string) {
<template>
<Dialog v-model:open="open">
<DialogContent class="connection-dialog-content" :class="connectionDialogContentClass" :data-wide="shouldUseWideConnectionDialog ? 'true' : undefined" @interact-outside.prevent>
<DialogContent class="connection-dialog-content" :class="connectionDialogContentClass" :data-wide="shouldUseWideConnectionDialog ? 'true' : undefined" @interact-outside.prevent @open-auto-focus="handleDialogOpenAutoFocus" @escape-key-down="handleDialogEscape">
<DialogHeader>
<DialogTitle>{{ editingId ? t("connection.editTitle") : t("connection.title") }}</DialogTitle>
<DialogTitle data-connection-dialog-title tabindex="-1">{{ editingId ? t("connection.editTitle") : t("connection.title") }}</DialogTitle>
</DialogHeader>
<template v-if="dialogStep === 'select'">
@@ -4455,10 +4471,28 @@ function openExternalUrl(url: string) {
<div class="flex flex-col gap-3 p-0.5 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center gap-2">
<div class="flex shrink-0 rounded-lg border bg-muted/40 p-0.5">
<Button type="button" size="icon-sm" :variant="dbPickerView === 'icon' ? 'secondary' : 'ghost'" :title="t('connection.iconView')" :aria-label="t('connection.iconView')" @click="dbPickerView = 'icon'">
<Button
type="button"
size="icon-sm"
variant="ghost"
:class="dbPickerView === 'icon' ? 'bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground' : undefined"
:title="t('connection.iconView')"
:aria-label="t('connection.iconView')"
:aria-pressed="dbPickerView === 'icon'"
@click="selectDbPickerView('icon')"
>
<Grid3X3 class="h-3.5 w-3.5" />
</Button>
<Button type="button" size="icon-sm" :variant="dbPickerView === 'list' ? 'secondary' : 'ghost'" :title="t('connection.listView')" :aria-label="t('connection.listView')" @click="dbPickerView = 'list'">
<Button
type="button"
size="icon-sm"
variant="ghost"
:class="dbPickerView === 'list' ? 'bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground' : undefined"
:title="t('connection.listView')"
:aria-label="t('connection.listView')"
:aria-pressed="dbPickerView === 'list'"
@click="selectDbPickerView('list')"
>
<List class="h-3.5 w-3.5" />
</Button>
</div>
@@ -4474,13 +4508,13 @@ function openExternalUrl(url: string) {
</div>
<div class="min-h-0 flex flex-1 flex-col gap-3 overflow-hidden sm:flex-row sm:gap-4">
<nav class="flex shrink-0 gap-1 overflow-x-auto border-b pb-2 sm:w-40 sm:flex-col sm:overflow-y-auto sm:border-b-0 sm:border-r sm:pb-0 sm:pr-3" :aria-label="t('connection.databaseCategories')">
<nav data-connection-category-nav class="flex shrink-0 gap-1 overflow-x-auto border-b px-0.5 pt-0.5 pb-2.5 sm:w-40 sm:flex-col sm:overflow-y-auto sm:border-b-0 sm:border-r sm:py-0.5 sm:pr-3.5" :aria-label="t('connection.databaseCategories')">
<button
v-for="category in dbCategories"
:key="category.key"
type="button"
class="shrink-0 whitespace-nowrap rounded-md px-3 py-2 text-left text-sm transition hover:bg-muted/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-full"
:class="!isDbSearchActive && selectedDbCategory === category.key ? 'bg-primary/10 font-medium text-primary' : 'text-muted-foreground'"
class="shrink-0 whitespace-nowrap rounded-[4px] px-3 py-2 text-left text-sm transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-full"
:class="!isDbSearchActive && selectedDbCategory === category.key ? 'bg-primary/10 font-medium text-primary hover:bg-primary/10' : 'text-muted-foreground hover:bg-muted/70'"
:aria-current="!isDbSearchActive && selectedDbCategory === category.key ? 'page' : undefined"
@click="selectDbCategory(category.key)"
>
@@ -4499,7 +4533,7 @@ function openExternalUrl(url: string) {
v-for="opt in category.options"
:key="opt.value"
type="button"
class="connection-db-picker-option group flex min-h-24 flex-col items-center justify-center gap-2 rounded-xl border bg-background/70 p-3 text-center transition hover:border-primary/40 hover:bg-muted/40 hover:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
class="connection-db-picker-option group flex min-h-24 flex-col items-center justify-center gap-2 rounded-[4px] border bg-background/70 p-3 text-center transition hover:border-primary/40 hover:bg-muted/40 hover:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:class="selectedType === opt.value ? 'connection-db-picker-option--selected shadow-sm' : 'border-border'"
:aria-pressed="selectedType === opt.value"
@click="onDbTypeChange(opt.value)"
@@ -4517,7 +4551,7 @@ function openExternalUrl(url: string) {
v-for="opt in category.options"
:key="opt.value"
type="button"
class="connection-db-picker-option flex items-center gap-3 rounded-lg border bg-background px-3 py-2 text-left transition hover:border-primary/40 hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
class="connection-db-picker-option flex items-center gap-3 rounded-[4px] border bg-background px-3 py-2 text-left transition hover:border-primary/40 hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:class="selectedType === opt.value ? 'connection-db-picker-option--selected' : 'border-border'"
:aria-pressed="selectedType === opt.value"
@click="onDbTypeChange(opt.value)"
@@ -4550,7 +4584,7 @@ function openExternalUrl(url: string) {
</template>
<template v-else>
<div class="flex min-h-0 flex-1 flex-col gap-4 overflow-hidden">
<div class="connection-config-step flex min-h-0 flex-1 flex-col gap-4 overflow-hidden">
<Tabs v-model="configTab" class="flex min-h-0 flex-1 flex-col">
<div class="flex items-center justify-between border-b pb-2">
<TabsList>
@@ -6792,6 +6826,10 @@ function openExternalUrl(url: string) {
background-color: rgba(23, 23, 23, 0.12);
}
.connection-config-step :is([data-slot="input"], [data-slot="select-trigger"], [data-slot="tabs-list"], [data-slot="tabs-trigger"], textarea) {
border-radius: var(--dbx-radius-fixed-4, 4px);
}
.dark .connection-db-picker-option--selected {
border-color: rgb(208, 208, 214);
background-color: rgba(255, 255, 255, 0.08);
@@ -0,0 +1,20 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const dialogSource = readFileSync(new URL("../../../components/connection/ConnectionDialog.vue", import.meta.url), "utf8");
describe("connection dialog accessibility", () => {
it("moves initial focus to the dialog title instead of the dialog container", () => {
expect(dialogSource).toContain('<DialogTitle data-connection-dialog-title tabindex="-1">');
expect(dialogSource).toContain('querySelector<HTMLElement>("[data-connection-dialog-title]")?.focus({ preventScroll: true })');
expect(dialogSource).not.toMatch(/<DialogContent[^>]*\stabindex="-1"/);
});
it("reserves focus ring space around the scrollable category navigation", () => {
const nav = dialogSource.match(/<nav data-connection-category-nav class="([^"]+)"/)?.[1] ?? "";
expect(nav).toContain("px-0.5");
expect(nav).toContain("pt-0.5");
expect(nav).toContain("sm:py-0.5");
});
});
@@ -0,0 +1,11 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const dialogSource = readFileSync(new URL("../../../components/connection/ConnectionDialog.vue", import.meta.url), "utf8");
describe("connection dialog corner style", () => {
it("uses the shared corner radius token for configuration controls", () => {
expect(dialogSource).toContain("border-radius: var(--dbx-radius-fixed-4, 4px);");
expect(dialogSource).not.toMatch(/\.connection-config-step[\s\S]*?border-radius:\s*4px;/);
});
});
@@ -0,0 +1,35 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CONNECTION_PICKER_VIEW_STORAGE_KEY, loadConnectionPickerView, normalizeConnectionPickerView, saveConnectionPickerView } from "@/lib/connection/connectionPickerViewPreference";
const storage = new Map<string, string>();
describe("connection picker view preference", () => {
beforeEach(() => {
storage.clear();
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),
});
});
afterEach(() => vi.unstubAllGlobals());
it("defaults missing and invalid values to icon view", () => {
expect(loadConnectionPickerView()).toBe("icon");
expect(normalizeConnectionPickerView("unknown")).toBe("icon");
});
it("persists and restores list view", () => {
saveConnectionPickerView("list");
expect(storage.get(CONNECTION_PICKER_VIEW_STORAGE_KEY)).toBe("list");
expect(loadConnectionPickerView()).toBe("list");
});
it("persists and restores icon view", () => {
saveConnectionPickerView("icon");
expect(loadConnectionPickerView()).toBe("icon");
});
});
@@ -0,0 +1,17 @@
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
export type DbPickerView = "icon" | "list";
export const CONNECTION_PICKER_VIEW_STORAGE_KEY = "dbx-connection-picker-view";
export function normalizeConnectionPickerView(value: unknown): DbPickerView {
return value === "list" ? "list" : "icon";
}
export function loadConnectionPickerView(): DbPickerView {
return normalizeConnectionPickerView(safeLocalStorageGet(CONNECTION_PICKER_VIEW_STORAGE_KEY));
}
export function saveConnectionPickerView(view: DbPickerView) {
safeLocalStorageSet(CONNECTION_PICKER_VIEW_STORAGE_KEY, view);
}