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"; }