diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 17e7cef1ef..e91304b351 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -327,7 +327,7 @@ import { useConnectionStore } from "@/stores/connectionStore"; import { useQueryStore } from "@/stores/queryStore"; import { useSettingsStore } from "@/stores/settingsStore"; import { databaseSortSupportedForDatabase, simpleDataGridOrderByMatchesSort, simpleDataGridOrderByReferencesMissingColumn, type DataGridSortDirection, type DataGridSortMode } from "@/lib/dataGrid/dataGridSort"; -import { resolveGridFocusRestoreTarget } from "@/lib/dataGrid/dataGridFocusRestore"; +import { resolveGridFocusRestoreTarget, shouldRestoreDataGridFocusAfterEditCommit } from "@/lib/dataGrid/dataGridFocusRestore"; import { buildOrderedGridRows, type GridInsertRowPosition, type GridNewRowPlacement } from "@/lib/dataGrid/gridNewRowPlacement"; import { DATA_GRID_CONDITION_TOOLBAR_MIN_WIDTH, @@ -8936,7 +8936,14 @@ function currentIoTDBTimestampEditValue(): CellValue | undefined { function commitGridEdit(value?: CellValue) { if (value === undefined) value = currentIoTDBTimestampEditValue(); if (commitInlineBulkEdit(value)) return; - void commitEditAndMaybeAutoSave(value === undefined ? undefined : { explicitValue: value }).finally(() => nextTick(() => gridRef.value?.focus({ preventScroll: true }))); + void commitEditAndMaybeAutoSave(value === undefined ? undefined : { explicitValue: value }).finally(() => + nextTick(() => { + // A click outside the grid (SQL editor, WHERE bar, toolbar) already moved focus on + // purpose. Only pull it back when the grid still owns it, otherwise the element the + // user just clicked loses the caret and swallows the following keystroke (#9383). + if (shouldRestoreDataGridFocusAfterEditCommit(gridRef.value, document.activeElement)) gridRef.value?.focus({ preventScroll: true }); + }), + ); } function commitInlineBulkEdit(value?: CellValue): boolean { @@ -8945,7 +8952,9 @@ function commitInlineBulkEdit(value?: CellValue): boolean { const nextValue = value === undefined ? editValue.value : value === null ? null : String(value); cancelEdit(); fillSelectionWithValue(nextValue, { emptyStringAsNull: true }); - nextTick(() => gridRef.value?.focus({ preventScroll: true })); + nextTick(() => { + if (shouldRestoreDataGridFocusAfterEditCommit(gridRef.value, document.activeElement)) gridRef.value?.focus({ preventScroll: true }); + }); return true; } diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridFocusRestore.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridFocusRestore.spec.ts index 16300deeb7..67108ba6ff 100644 --- a/apps/desktop/src/lib/__tests__/dataGrid/dataGridFocusRestore.spec.ts +++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridFocusRestore.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { resolveGridFocusRestoreTarget } from "@/lib/dataGrid/dataGridFocusRestore"; +import { resolveGridFocusRestoreTarget, shouldRestoreDataGridFocusAfterEditCommit } from "@/lib/dataGrid/dataGridFocusRestore"; function element(overrides: { connected?: boolean; children?: HTMLElement[]; closest?: Record; ownerDocument?: { body: HTMLElement; documentElement: HTMLElement }; activeGrid?: boolean } = {}) { const children = overrides.children ?? []; @@ -66,3 +66,48 @@ describe("resolveGridFocusRestoreTarget", () => { expect(resolveGridFocusRestoreTarget(root, searchInput, activeGridInput)).toBeNull(); }); }); + +describe("shouldRestoreDataGridFocusAfterEditCommit", () => { + it("restores focus when the grid root is not mounted yet", () => { + expect(shouldRestoreDataGridFocusAfterEditCommit(null, null)).toBe(true); + expect(shouldRestoreDataGridFocusAfterEditCommit(undefined, null)).toBe(true); + }); + + it("keeps focus in the grid after a keyboard commit", () => { + const input = element(); + const root = element({ children: [input] }); + expect(shouldRestoreDataGridFocusAfterEditCommit(root, input)).toBe(true); + }); + + it("restores focus when the blur landed on body or the document element", () => { + const body = element(); + const documentElement = element(); + const root = element({ ownerDocument: { body, documentElement } }); + expect(shouldRestoreDataGridFocusAfterEditCommit(root, body)).toBe(true); + expect(shouldRestoreDataGridFocusAfterEditCommit(root, documentElement)).toBe(true); + }); + + it("restores focus from a stale removed element", () => { + const root = element(); + expect(shouldRestoreDataGridFocusAfterEditCommit(root, element({ connected: false }))).toBe(true); + }); + + it("restores focus when the tab strip took it", () => { + const root = element(); + const tabButton = element({ closest: { ".app-tab-bar, [role='tab'], [role='tablist']": element() } }); + expect(shouldRestoreDataGridFocusAfterEditCommit(root, tabButton)).toBe(true); + }); + + it("does not steal focus back from the SQL editor after an outside click", () => { + const root = element(); + const sqlEditor = element(); + expect(shouldRestoreDataGridFocusAfterEditCommit(root, sqlEditor)).toBe(false); + }); + + it("does not steal focus back from another active grid", () => { + const root = element(); + const grid = element({ activeGrid: true }); + const otherGridInput = element({ closest: { "[data-grid-root]": grid } }); + expect(shouldRestoreDataGridFocusAfterEditCommit(root, otherGridInput)).toBe(false); + }); +}); diff --git a/apps/desktop/src/lib/dataGrid/dataGridFocusRestore.ts b/apps/desktop/src/lib/dataGrid/dataGridFocusRestore.ts index a2cce1b8a4..f623d14009 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridFocusRestore.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridFocusRestore.ts @@ -32,3 +32,19 @@ function gridFocusCanTransferFrom(root: HTMLElement, activeElement: Element | nu if (activeGrid && activeGrid !== root) return activeGrid.dataset.gridActive !== "true"; return !!closest(".app-tab-bar, [role='tab'], [role='tablist']"); } + +/** + * Decide whether a committed cell edit should hand focus back to the grid. + * + * Keyboard commits (Enter, inline bulk edit) leave focus inside the grid, so + * the grid must take it back for arrow-key navigation to keep working. A commit + * that ended because an outside pointer interaction moved focus elsewhere — the + * SQL editor, the WHERE condition bar, a toolbar button — must not, otherwise + * the element the user just clicked never keeps the caret, and the next + * keystroke or paste is claimed by the grid instead (#9383). + */ +export function shouldRestoreDataGridFocusAfterEditCommit(root: HTMLElement | null | undefined, activeElement: Element | null): boolean { + if (!root) return true; + if (root.contains(activeElement)) return true; + return gridFocusCanTransferFrom(root, activeElement); +} diff --git a/docs/screenshots/issue-9383-after-editor-zoom.png b/docs/screenshots/issue-9383-after-editor-zoom.png new file mode 100644 index 0000000000..d2556164e9 Binary files /dev/null and b/docs/screenshots/issue-9383-after-editor-zoom.png differ diff --git a/docs/screenshots/issue-9383-after-one-click-typed.png b/docs/screenshots/issue-9383-after-one-click-typed.png new file mode 100644 index 0000000000..5142f61aba Binary files /dev/null and b/docs/screenshots/issue-9383-after-one-click-typed.png differ diff --git a/docs/screenshots/issue-9383-before-editor-zoom.png b/docs/screenshots/issue-9383-before-editor-zoom.png new file mode 100644 index 0000000000..d6b5f1195c Binary files /dev/null and b/docs/screenshots/issue-9383-before-editor-zoom.png differ diff --git a/docs/screenshots/issue-9383-before-one-click-no-focus.png b/docs/screenshots/issue-9383-before-one-click-no-focus.png new file mode 100644 index 0000000000..28ec45f0fa Binary files /dev/null and b/docs/screenshots/issue-9383-before-one-click-no-focus.png differ