fix(app): broaden keyboard focus scope resolution to check multiple candidates

Check target, parentElement, and document.activeElement as focus
candidates instead of relying solely on the direct event target.
Fixes scope misdetection when the keyboard event target is a text
node or non-element.
This commit is contained in:
Mohamed Boudra
2026-04-03 16:53:14 +07:00
parent df617a4c8f
commit a2b1498c3f
2 changed files with 132 additions and 12 deletions
@@ -0,0 +1,80 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { resolveKeyboardFocusScope } from "./focus-scope";
class FakeNode {
parentElement: FakeElement | null = null;
}
class FakeElement extends FakeNode {
tagName: string;
isContentEditable = false;
private selectors: Set<string>;
constructor(input?: { tagName?: string; selectors?: string[]; isContentEditable?: boolean }) {
super();
this.tagName = (input?.tagName ?? "div").toUpperCase();
this.selectors = new Set(input?.selectors ?? []);
if (input?.isContentEditable) {
this.isContentEditable = true;
}
}
closest(selector: string): FakeElement | null {
if (this.selectors.has(selector)) {
return this;
}
return this.parentElement?.closest(selector) ?? null;
}
}
describe("resolveKeyboardFocusScope", () => {
const globalRef = globalThis as {
Element?: unknown;
Node?: unknown;
document?: { activeElement?: unknown };
};
const originalElement = globalRef.Element;
const originalNode = globalRef.Node;
const originalDocument = globalRef.document;
beforeEach(() => {
globalRef.Element = FakeElement;
globalRef.Node = FakeNode;
globalRef.document = { activeElement: null };
});
afterEach(() => {
globalRef.Element = originalElement;
globalRef.Node = originalNode;
globalRef.document = originalDocument;
});
it("resolves terminal scope from the direct keyboard event target", () => {
const target = new FakeElement({ selectors: [".xterm"] });
const scope = resolveKeyboardFocusScope({
target: target as unknown as EventTarget,
commandCenterOpen: false,
});
expect(scope).toBe("terminal");
});
it("falls back to activeElement when target is not an Element", () => {
const activeElement = new FakeElement({ selectors: [".xterm"] });
globalRef.document = { activeElement };
const scope = resolveKeyboardFocusScope({
target: null,
commandCenterOpen: false,
});
expect(scope).toBe("terminal");
});
it("detects editable scope from activeElement fallback", () => {
const activeElement = new FakeElement({ tagName: "input" });
globalRef.document = { activeElement };
const scope = resolveKeyboardFocusScope({
target: null,
commandCenterOpen: false,
});
expect(scope).toBe("editable");
});
});
+52 -12
View File
@@ -1,37 +1,77 @@
import type { KeyboardFocusScope } from "@/keyboard/actions";
function isElement(value: unknown): value is Element {
return typeof Element !== "undefined" && value instanceof Element;
}
function getFocusCandidateElements(target: EventTarget | null): Element[] {
const candidates: Element[] = [];
const pushUnique = (element: Element | null) => {
if (!element || candidates.includes(element)) {
return;
}
candidates.push(element);
};
if (isElement(target)) {
pushUnique(target);
}
if (typeof Node !== "undefined" && target instanceof Node) {
pushUnique(isElement(target.parentElement) ? target.parentElement : null);
}
if (typeof document !== "undefined" && isElement(document.activeElement)) {
pushUnique(document.activeElement);
}
return candidates;
}
export function resolveKeyboardFocusScope(input: {
target: EventTarget | null;
commandCenterOpen: boolean;
}): KeyboardFocusScope {
const { target, commandCenterOpen } = input;
if (!(target instanceof Element)) {
const candidates = getFocusCandidateElements(target);
if (candidates.length === 0) {
return commandCenterOpen ? "command-center" : "other";
}
if (target.closest("[data-testid='terminal-surface']") || target.closest(".xterm")) {
if (
candidates.some((element) =>
Boolean(element.closest("[data-testid='terminal-surface']") || element.closest(".xterm")),
)
) {
return "terminal";
}
if (
commandCenterOpen &&
(target.closest("[data-testid='command-center-panel']") ||
target.closest("[data-testid='command-center-input']"))
candidates.some((element) =>
Boolean(
element.closest("[data-testid='command-center-panel']") ||
element.closest("[data-testid='command-center-input']"),
),
)
) {
return "command-center";
}
if (target.closest("[data-testid='message-input-root']")) {
if (candidates.some((element) => Boolean(element.closest("[data-testid='message-input-root']")))) {
return "message-input";
}
const editable = target as HTMLElement;
if (editable.isContentEditable) {
return commandCenterOpen ? "command-center" : "editable";
}
const tag = target.tagName.toLowerCase();
if (tag === "input" || tag === "textarea" || tag === "select") {
if (
candidates.some((element) => {
const editable = element as HTMLElement;
if (editable.isContentEditable) {
return true;
}
const tag = element.tagName.toLowerCase();
return tag === "input" || tag === "textarea" || tag === "select";
})
) {
return commandCenterOpen ? "command-center" : "editable";
}