From a2b1498c3f57a25bc3c0c44b4d26cdac96f90787 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 3 Apr 2026 16:46:17 +0700 Subject: [PATCH] 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. --- packages/app/src/keyboard/focus-scope.test.ts | 80 +++++++++++++++++++ packages/app/src/keyboard/focus-scope.ts | 64 ++++++++++++--- 2 files changed, 132 insertions(+), 12 deletions(-) create mode 100644 packages/app/src/keyboard/focus-scope.test.ts diff --git a/packages/app/src/keyboard/focus-scope.test.ts b/packages/app/src/keyboard/focus-scope.test.ts new file mode 100644 index 000000000..5456493c0 --- /dev/null +++ b/packages/app/src/keyboard/focus-scope.test.ts @@ -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; + + 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"); + }); +}); diff --git a/packages/app/src/keyboard/focus-scope.ts b/packages/app/src/keyboard/focus-scope.ts index 457db7e3b..340a8cd41 100644 --- a/packages/app/src/keyboard/focus-scope.ts +++ b/packages/app/src/keyboard/focus-scope.ts @@ -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"; }