mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-23 18:20:01 +00:00
fix(grid): restore keyboard focus after tab switches
This commit is contained in:
@@ -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']");
|
||||
}
|
||||
Reference in New Issue
Block a user