fix(grid): restore keyboard focus after tab switches

This commit is contained in:
Freedom
2026-08-04 13:14:34 +08:00
committed by GitHub
parent ae87353a28
commit 8228c2af17
3 changed files with 142 additions and 1 deletions
+40 -1
View File
@@ -214,6 +214,7 @@ import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { simpleDataGridOrderByMatchesSort, simpleDataGridOrderByReferencesMissingColumn, type DataGridSortDirection, type DataGridSortMode } from "@/lib/dataGrid/dataGridSort";
import { resolveGridFocusRestoreTarget } from "@/lib/dataGrid/dataGridFocusRestore";
import { buildOrderedGridRows, type GridInsertRowPosition, type GridNewRowPlacement } from "@/lib/dataGrid/gridNewRowPlacement";
import {
DATA_GRID_CONDITION_TOOLBAR_MIN_WIDTH,
@@ -5414,6 +5415,8 @@ watch(
function pauseCanvasGridWork() {
dataGridIsActive = false;
stopLoadingElapsedTimer();
gridFocusActivationToken += 1;
gridRef.value?.setAttribute("data-grid-active", "false");
if (gridSurfaceBusy.value) finishDataGridNativeSelectionBlock(dataGridNativeSelectionBlockOwner);
canvasRuntime.pause();
gridScrollbarsRuntime.pause();
@@ -5428,6 +5431,7 @@ function pauseCanvasGridWork() {
function resumeCanvasGridWork() {
dataGridIsActive = true;
startLoadingElapsedTimer();
gridRef.value?.setAttribute("data-grid-active", "true");
if (gridSurfaceBusy.value) beginDataGridNativeSelectionBlock(dataGridNativeSelectionBlockOwner);
canvasRuntime.resume();
gridScrollbarsRuntime.resume();
@@ -5443,6 +5447,30 @@ function clearInternalClipboardCopy() {
clearDataGridClipboardCopy();
}
// Remember the last element that held focus inside the grid. Switching to
// another tab moves focus onto the tab strip (or body) and the kept-alive
// grid never gets it back on its own, which breaks arrow-key cell navigation
// after returning to the tab.
let lastFocusedWithinGrid: HTMLElement | null = null;
let gridFocusActivationToken = 0;
function onGridFocusIn(event: FocusEvent) {
if (event.target instanceof HTMLElement) lastFocusedWithinGrid = event.target;
}
function restoreGridFocusAfterActivation() {
if (!lastFocusedWithinGrid) return;
const activationToken = ++gridFocusActivationToken;
nextTick(() => {
if (!dataGridIsActive || activationToken !== gridFocusActivationToken) return;
if (editingCell.value) return; // the cell editor restores its own input focus
const target = resolveGridFocusRestoreTarget(gridRef.value, lastFocusedWithinGrid, document.activeElement);
target?.focus({ preventScroll: true });
});
}
onActivated(restoreGridFocusAfterActivation);
onMounted(resumeCanvasGridWork);
onActivated(resumeCanvasGridWork);
onMounted(() => {
@@ -8426,7 +8454,18 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
</script>
<template>
<div ref="gridRef" data-grid-root class="h-full flex flex-col overflow-hidden outline-none" :class="{ 'data-grid--editing-cell': !!editingCell, 'data-grid--dark': isDark }" :style="gridStyle" tabindex="0" @keydown="onGridKeydown" @paste="onGridPaste">
<div
ref="gridRef"
data-grid-root
data-grid-active="true"
class="h-full flex flex-col overflow-hidden outline-none"
:class="{ 'data-grid--editing-cell': !!editingCell, 'data-grid--dark': isDark }"
:style="gridStyle"
tabindex="0"
@keydown="onGridKeydown"
@paste="onGridPaste"
@focusin="onGridFocusIn"
>
<CustomContextMenu :items="gridContextMenuItems" v-slot="{ onContextMenu }">
<div v-if="hasData || canShowWhereSearch" class="flex-1 flex flex-col overflow-hidden" @contextmenu="onContextMenu">
<!-- Search bar -->
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { resolveGridFocusRestoreTarget } from "@/lib/dataGrid/dataGridFocusRestore";
function element(overrides: { connected?: boolean; children?: HTMLElement[]; closest?: Record<string, HTMLElement | null>; ownerDocument?: { body: HTMLElement; documentElement: HTMLElement }; activeGrid?: boolean } = {}) {
const children = overrides.children ?? [];
const target = {
isConnected: overrides.connected ?? true,
contains: (other: Element | null) => !!other && (children as Element[]).includes(other),
closest: (selector: string) => overrides.closest?.[selector] ?? null,
ownerDocument: overrides.ownerDocument,
dataset: overrides.activeGrid === undefined ? {} : { gridActive: String(overrides.activeGrid) },
} as unknown as HTMLElement;
return target;
}
describe("resolveGridFocusRestoreTarget", () => {
it("returns null when the grid never held focus", () => {
const root = element();
expect(resolveGridFocusRestoreTarget(root, null, null)).toBeNull();
});
it("returns null when focus is already inside the grid", () => {
const input = element();
const root = element({ children: [input] });
expect(resolveGridFocusRestoreTarget(root, input, input)).toBeNull();
});
it("restores the last focused inner element when focus was left on the tab strip", () => {
const searchInput = element();
const root = element({ children: [searchInput] });
const tabButton = element({ closest: { ".app-tab-bar, [role='tab'], [role='tablist']": element() } });
expect(resolveGridFocusRestoreTarget(root, searchInput, tabButton)).toBe(searchInput);
});
it("falls back to the grid root when the last focused element was removed", () => {
const staleEditor = element({ connected: false });
const root = element();
const tabButton = element({ closest: { ".app-tab-bar, [role='tab'], [role='tablist']": element() } });
expect(resolveGridFocusRestoreTarget(root, staleEditor, tabButton)).toBe(root);
});
it("falls back to the grid root from body when the last focused element moved", () => {
const movedElsewhere = element();
const body = element();
const documentElement = element();
const root = element({ ownerDocument: { body, documentElement } });
expect(resolveGridFocusRestoreTarget(root, movedElsewhere, body)).toBe(root);
});
it("does not steal focus from an external dialog or editor", () => {
const searchInput = element();
const root = element({ children: [searchInput] });
const dialogInput = element();
expect(resolveGridFocusRestoreTarget(root, searchInput, dialogInput)).toBeNull();
});
it("restores from an inactive grid but not from another active grid", () => {
const searchInput = element();
const root = element({ children: [searchInput] });
const inactiveGrid = element({ activeGrid: false });
const inactiveGridInput = element({ closest: { "[data-grid-root]": inactiveGrid } });
expect(resolveGridFocusRestoreTarget(root, searchInput, inactiveGridInput)).toBe(searchInput);
const activeGrid = element({ activeGrid: true });
const activeGridInput = element({ closest: { "[data-grid-root]": activeGrid } });
expect(resolveGridFocusRestoreTarget(root, searchInput, activeGridInput)).toBeNull();
});
});
@@ -0,0 +1,34 @@
/**
* Decide where focus should go when a kept-alive data grid becomes active
* again after a tab switch.
*
* Switching away from a tab moves focus to the tab strip, body, or an element
* inside the grid that is being deactivated. Focus is restored only from one
* of those states so activation never steals focus from a dialog, editor,
* input, menu, or another active grid.
*
* Returns null when no focus restore should happen: the grid never held
* focus, or focus is already inside the grid (e.g. the cell editor restored
* its own input focus first).
*/
export function resolveGridFocusRestoreTarget(root: HTMLElement | null | undefined, lastFocusedWithinGrid: HTMLElement | null | undefined, activeElement: Element | null): HTMLElement | null {
if (!root || !lastFocusedWithinGrid) return null;
if (root.contains(activeElement)) return null;
if (!gridFocusCanTransferFrom(root, activeElement)) return null;
if (lastFocusedWithinGrid.isConnected && root.contains(lastFocusedWithinGrid)) {
return lastFocusedWithinGrid;
}
return root;
}
function gridFocusCanTransferFrom(root: HTMLElement, activeElement: Element | null): boolean {
if (!activeElement || !activeElement.isConnected) return true;
const ownerDocument = root.ownerDocument;
if (activeElement === ownerDocument?.body || activeElement === ownerDocument?.documentElement) return true;
const closest = (activeElement as HTMLElement).closest?.bind(activeElement as HTMLElement);
if (!closest) return false;
const activeGrid = closest("[data-grid-root]") as HTMLElement | null;
if (activeGrid && activeGrid !== root) return activeGrid.dataset.gridActive !== "true";
return !!closest(".app-tab-bar, [role='tab'], [role='tablist']");
}