fix(web-runtime): focus follows the caret to the field it types into

Mirrors the driver, so the two hosts agree about focus on a Compose page.
The harness inherits custom properties down the parent chain the way CSS
does, so an implementation matching the inline style attribute fails.
This commit is contained in:
pj committed 2026-08-18 00:16:52 +05:30
1 parent 04327e63b6
commit ab4c42601d
3 files changed
+122 -4

No files matched your search

+36 -4
View File
@@ -510,7 +510,35 @@ function isEnabled(element: Element): boolean {
function deepestActiveElement(): Element | null {
let element = document.activeElement;
while (element?.shadowRoot?.activeElement) element = element.shadowRoot.activeElement;
return element;
return fieldBehindTheCaret(element) ?? element;
}
// Compose for Web takes keystrokes on a 1px input pinned to the caret, a SIBLING
// of the accessibility tree, so descending the shadow roots lands on a node no
// selector can name and the field carrying the test tag reads unfocused.
// selectAllScript in internal/driver/chrome/driver.go re-attributes focus the
// same way for the dump the goja host reads, and carries the reasoning,
// including why the caret's CENTRE decides rather than its whole box.
const CARET_ORIGIN_PROPERTY = "--compose-internal-web-backing-input-left";
function fieldBehindTheCaret(caretInput: Element | null): Element | null {
if (!caretInput || caretInput.tagName !== "INPUT") return null;
if (!getComputedStyle(caretInput).getPropertyValue(CARET_ORIGIN_PROPERTY).trim()) return null;
const caret = caretInput.getBoundingClientRect();
const x = (caret.left + caret.right) / 2;
const y = (caret.top + caret.bottom) / 2;
let field: Element | null = null;
let fieldArea = Infinity;
for (const candidate of editableElements()) {
if (candidate === caretInput) continue;
const box = candidate.getBoundingClientRect();
const area = box.width * box.height;
if (area <= 0 || area >= fieldArea) continue;
if (x < box.left || x > box.right || y < box.top || y > box.bottom) continue;
field = candidate;
fieldArea = area;
}
return field;
}
function elementHandle(
@@ -831,6 +859,12 @@ function isEditableElement(element: HTMLElement): boolean {
return false;
}
function editableElements(): Set<Element> {
return new Set<Element>(
(deepQueryAll(EDITABLE_SELECTOR, document) as HTMLElement[]).filter(isEditableElement),
);
}
// isScrollable mirrors the native `scrollable` accessibility attribute: the
// container can actually scroll, i.e. its content overflows its box. The
// document scrolling root is not special-cased in: when the page does not
@@ -936,9 +970,7 @@ function selectorsFor(elements: readonly HTMLElement[]): Array<string | undefine
// stays expressed in CSS, as it always was.
function collectTargets(): TargetElement[] {
const clickable = new Set<Element>(deepQueryAll(TAPPABLE_SELECTOR, document));
const editable = new Set<Element>(
(deepQueryAll(EDITABLE_SELECTOR, document) as HTMLElement[]).filter(isEditableElement),
);
const editable = editableElements();
const elements = targetElements();
const selectors = selectorsFor(elements);
return elements.map((element, index) => ({