mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
fix(sidebar): restore keyboard focus after dialogs
This commit is contained in:
@@ -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__";
|
||||
|
||||
+33
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user