fix(sidebar): restore keyboard focus after dialogs

This commit is contained in:
Xsyl.way
2026-10-19 15:17:34 +08:00
committed by GitHub
parent d51b92998c
commit 5a183a707c
5 changed files with 129 additions and 16 deletions
+8 -4
View File
@@ -3185,13 +3185,17 @@ async function openPendingPluginInstallLinks() {
}
}
function setConnectionDialogOpen(value: boolean) {
function setConnectionDialogOpen(value: boolean, options?: { restoreFocus?: boolean }) {
showConnectionDialog.value = value;
if (!value) {
connectionDialogPrefill.value = null;
connectionDialogUpdate.value = null;
connectionPluginProvider.value = null;
connectionDialogInitialTab.value = undefined;
// Handlers that immediately open another surface (driver store page,
// settings, or a reopened connection dialog) opt out: the delayed tree
// refocus would steal focus back from the new surface's autofocus.
if (options?.restoreFocus !== false) void appSidebarRef.value?.focusSelectedNode();
}
}
@@ -5687,15 +5691,15 @@ onUnmounted(() => {
)
"
@open-driver-store="
setConnectionDialogOpen(false);
setConnectionDialogOpen(false, { restoreFocus: false });
openDriverStorePage($event);
"
@open-tunnel-profile-settings="
setConnectionDialogOpen(false);
setConnectionDialogOpen(false, { restoreFocus: false });
openSettings('tunnels');
"
@open-connection-settings="
setConnectionDialogOpen(false);
setConnectionDialogOpen(false, { restoreFocus: false });
openConnectionSettings($event, 'advanced');
"
@open-lineage-target="openLineageTarget"
@@ -220,7 +220,11 @@ function openTableStructureForSelection(): boolean {
return connectionTreeRef.value?.openTableStructureForSelection?.() ?? false;
}
defineExpose({ focusSearch, locateTabInSidebar, disconnectAllActiveConnections, openTableStructureForSelection });
async function focusSelectedNode() {
await connectionTreeRef.value?.focusSelectedNode?.();
}
defineExpose({ focusSearch, locateTabInSidebar, disconnectAllActiveConnections, focusSelectedNode, openTableStructureForSelection });
</script>
<template>
@@ -1650,6 +1650,22 @@ async function focusSidebarTreeNode(nodeId: string) {
row.focus({ preventScroll: true });
}
function sidebarDialogOpen(): boolean {
return sidebarDdlOpen.value || sidebarElasticsearchIndexMetadataOpen.value || sidebarObjectSourceOpen.value || sidebarProcedureOpen.value || sidebarVisibleDatabasesOpen.value || sidebarVisibleSchemasOpen.value || sidebarVisibleNacosNamespacesOpen.value || sidebarTableNameFilterOpen.value;
}
function restoreSidebarTreeFocusAfterDialog(nodeId: string | null | undefined) {
if (!nodeId) return;
void nextTick(() => {
if (!sidebarDialogOpen()) void focusSidebarTreeNode(nodeId);
});
}
async function focusSelectedNode() {
const nodeId = store.selectedTreeNodeId;
if (nodeId) await focusSidebarTreeNode(nodeId);
}
function clearSidebarSelection() {
// Clicking the blank area of the tree clears the current selection. Row
// clicks call event.stopPropagation(), so this only fires for blank clicks
@@ -2372,27 +2388,51 @@ watch(sidebarElasticsearchIndexMetadataOpen, (open) => {
});
watch(sidebarObjectSourceOpen, (open) => {
if (!open) sidebarObjectSourceTarget.value = null;
if (!open) {
const nodeId = sidebarObjectSourceTarget.value?.node.id;
sidebarObjectSourceTarget.value = null;
restoreSidebarTreeFocusAfterDialog(nodeId);
}
});
watch(sidebarProcedureOpen, (open) => {
if (!open) sidebarProcedureTarget.value = null;
if (!open) {
const nodeId = sidebarProcedureTarget.value?.id;
sidebarProcedureTarget.value = null;
restoreSidebarTreeFocusAfterDialog(nodeId);
}
});
watch(sidebarVisibleDatabasesOpen, (open) => {
if (!open) sidebarVisibleDatabasesTarget.value = null;
if (!open) {
const nodeId = sidebarVisibleDatabasesTarget.value?.id;
sidebarVisibleDatabasesTarget.value = null;
restoreSidebarTreeFocusAfterDialog(nodeId);
}
});
watch(sidebarVisibleSchemasOpen, (open) => {
if (!open) sidebarVisibleSchemasTarget.value = null;
if (!open) {
const nodeId = sidebarVisibleSchemasTarget.value?.id;
sidebarVisibleSchemasTarget.value = null;
restoreSidebarTreeFocusAfterDialog(nodeId);
}
});
watch(sidebarVisibleNacosNamespacesOpen, (open) => {
if (!open) sidebarVisibleNacosNamespacesTarget.value = null;
if (!open) {
const nodeId = sidebarVisibleNacosNamespacesTarget.value?.id;
sidebarVisibleNacosNamespacesTarget.value = null;
restoreSidebarTreeFocusAfterDialog(nodeId);
}
});
watch(sidebarTableNameFilterOpen, (open) => {
if (!open) sidebarTableNameFilterTarget.value = null;
if (!open) {
const nodeId = sidebarTableNameFilterTarget.value?.id;
sidebarTableNameFilterTarget.value = null;
restoreSidebarTreeFocusAfterDialog(nodeId);
}
});
function collapseAllTreeNodes() {
@@ -2705,7 +2745,15 @@ onUnmounted(() => {
if (sidebarTreeContentMeasureFrame) window.cancelAnimationFrame(sidebarTreeContentMeasureFrame);
});
defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes, locateTabInSidebar, disconnectAllActiveConnections, openTableStructureForSelection: openSidebarTableStructureForSelection });
defineExpose({
focusSearch,
createNewGroup,
collapseAllTreeNodes,
locateTabInSidebar,
disconnectAllActiveConnections,
focusSelectedNode,
openTableStructureForSelection: openSidebarTableStructureForSelection,
});
</script>
<template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, inject, shallowRef, watch, onBeforeUnmount } from "vue";
import { ref, computed, inject, nextTick, shallowRef, watch, onBeforeUnmount } from "vue";
import { useI18n } from "vue-i18n";
import {
Database,
@@ -1114,6 +1114,16 @@ const renameInputRef = ref<HTMLInputElement>();
/** Snapshot of the row being renamed: reprojecting the tree mid-rename recycles activeNode. */
const renameTargetNode = shallowRef<TreeNode | null>(null);
function restoreRowFocus() {
void nextTick(() => {
// Only reclaim focus when it is genuinely lost (fell back to <body>).
// A blur triggered by the user focusing something else (editor, search
// box, Tab navigation) must keep that target focused.
if (document.activeElement && document.activeElement !== document.body) return;
rowRef.value?.focus({ preventScroll: true });
});
}
function startRenameGroup() {
renameTargetNode.value = activeNode.value;
renameInput.value = activeNode.value.label;
@@ -1172,12 +1182,17 @@ function finishRenameGroup() {
const trimmed = renameInput.value.trim();
// An empty name cancels the rename and keeps the group as-is — never delete
// here. Deleting a group is done explicitly via the context menu (issue #681).
if (!trimmed || trimmed === target.label) return;
if (!trimmed || trimmed === target.label) {
restoreRowFocus();
return;
}
if (target.type === "table-vgroup" && target.vgroupId) {
connectionStore.renameTableVGroup(target, target.vgroupId, trimmed);
restoreRowFocus();
return;
}
connectionStore.renameConnectionGroup(target.id, trimmed);
restoreRowFocus();
}
async function finishRenameSavedSql() {
@@ -1185,13 +1200,17 @@ async function finishRenameSavedSql() {
isRenamingSavedSql.value = false;
const fileId = activeNode.value.savedSqlId;
const trimmed = renameInput.value.trim();
if (!fileId || !trimmed || ensureSqlExtension(trimmed) === activeNode.value.label) return;
if (!fileId || !trimmed || ensureSqlExtension(trimmed) === activeNode.value.label) {
restoreRowFocus();
return;
}
try {
const savedSqlStore = useSavedSqlStore();
await savedSqlStore.renameFile(fileId, ensureSqlExtension(trimmed));
} catch (e: any) {
toast(t("savedSql.renameFailed", { message: savedSqlErrorMessage(e, t) }), 5000);
}
restoreRowFocus();
}
async function finishRenameConnection() {
@@ -1199,12 +1218,16 @@ async function finishRenameConnection() {
isRenamingConnection.value = false;
const connectionId = activeNode.value.connectionId;
const trimmed = renameInput.value.trim();
if (!connectionId || !trimmed || trimmed === activeNode.value.label) return;
if (!connectionId || !trimmed || trimmed === activeNode.value.label) {
restoreRowFocus();
return;
}
try {
await connectionStore.renameConnection(connectionId, trimmed);
} catch (e: any) {
toast(t("connection.saveFailed", { message: e?.message || String(e) }), 5000);
}
restoreRowFocus();
}
function finishRename() {
@@ -1217,6 +1240,7 @@ function cancelRename() {
isRenamingGroup.value = false;
isRenamingSavedSql.value = false;
isRenamingConnection.value = false;
restoreRowFocus();
}
const PINNED_TREE_NODE_DRAG_TYPE = "__pinned-tree-node__";
@@ -160,6 +160,39 @@ describe("TreeItem connection quick rename", () => {
expect(renameConnection).toHaveBeenCalledTimes(1);
});
it.each([
["Enter", connection.name],
["Escape", "Reporting EU"],
])("restores focus to the connection row after %s dismisses rename", async (key, value) => {
const { container, input } = await mountConnectionRename();
typeName(input, value);
press(input, key);
const row = container.querySelector<HTMLElement>(`[data-node-id="${connection.id}"]`);
await vi.waitFor(() => expect(document.activeElement).toBe(row));
});
it("keeps focus on the element the user clicked when a blur commits rename", async () => {
const { container, input } = await mountConnectionRename();
typeName(input, "Reporting EU");
const outside = document.createElement("button");
container.append(outside);
outside.focus();
input.dispatchEvent(new FocusEvent("blur"));
await nextTick();
await nextTick();
// The blur commit must not steal focus back to the tree row: the user
// deliberately moved focus elsewhere before the commit landed.
const row = container.querySelector<HTMLElement>(`[data-node-id="${connection.id}"]`);
expect(document.activeElement).toBe(outside);
expect(document.activeElement).not.toBe(row);
expect(renameConnection).toHaveBeenCalledWith(connection.id, "Reporting EU");
});
it.each(["", " ", connection.name])("cancels blank or unchanged input %j", async (name) => {
const { input } = await mountConnectionRename();
typeName(input, name);