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:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user