fix(chrome): focus follows the caret to the field it types into

Compose for wasm never focuses the semantics node carrying the testTag.
It proxies keystrokes through a hidden 1px backing input that is a
sibling of the a11y tree, so the node the runner tapped never held focus
and confirmFocus refused to type into every Compose text field.

Focus is re-attributed to the smallest editable whose box holds the
caret's centre. Centre-point rather than full containment because the
caret's height comes from the text style and the field's from its layout
box, so a taller font would silently drop back to refusing.
This commit is contained in:
pj committed 2026-08-18 00:00:24 +05:30
1 parent 176b495245
commit 5fc91fdca1
3 files changed
+173

No files matched your search

+30
View File
@@ -649,6 +649,36 @@ func (d *Driver) Hierarchy(ctx context.Context) (string, error) {
while (focusedElement && focusedElement.shadowRoot && focusedElement.shadowRoot.activeElement) { while (focusedElement && focusedElement.shadowRoot && focusedElement.shadowRoot.activeElement) {
focusedElement = focusedElement.shadowRoot.activeElement; focusedElement = focusedElement.shadowRoot.activeElement;
} }
// Descending is still not enough on Compose for Web: it takes keystrokes on a
// 1px transparent input pinned to the caret, and that input is a SIBLING of
// the accessibility tree rather than a node in it. DOM focus therefore never
// reaches the semantics element carrying the test tag, so confirmFocus in
// internal/runner/runner.go saw an unnamed element hold focus after every
// focus tap and refused to type. Compose declares the caret's box in these
// custom properties, which the input inherits from the container that
// positions it, so the field being typed into is the innermost editable box
// that caret sits in.
const CARET_ORIGIN_PROPERTY = '--compose-internal-web-backing-input-left';
function fieldBehindTheCaret(caretInput) {
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 = null;
let fieldArea = Infinity;
for (const candidate of editableSet) {
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;
}
focusedElement = fieldBehindTheCaret(focusedElement) || focusedElement;
function buildTree(el, isRoot) { function buildTree(el, isRoot) {
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
// Every attribute the markup wrote, keyed as written, which is what attrs // Every attribute the markup wrote, keyed as written, which is what attrs
@@ -225,6 +225,69 @@ func TestElementState_FocusDescendsIntoTheShadowRoot(t *testing.T) {
} }
} }
// Focus belongs to the field the caret sits in, not to the input the caret is.
//
// Compose for Web takes keystrokes on a 1px transparent input pinned to the
// caret, and that input is a sibling of the accessibility tree rather than a
// node in it. Descending activeElement through the shadow roots therefore lands
// on a node no selector can name, and every semantics element reads unfocused,
// so confirmFocus in internal/runner/runner.go rejected each focus tap with "an
// unnamed element holds focus" and no InputText step ever ran.
//
// Both fields are tapped, because reporting the first editable in the tree
// would satisfy the email half of this and still type into the wrong field.
func TestElementState_FocusFollowsTheCaretToItsField(t *testing.T) {
server := httptest.NewServer(http.FileServer(http.Dir("testdata")))
defer server.Close()
d := New()
defer d.Terminate(context.Background())
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
if err := d.Launch(ctx, server.URL+"/compose-backing-input.html", false, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
for _, field := range []string{"EmailField", "PasswordField"} {
tapped := elementInHierarchyDump(ctx, t, d, "id:"+field)
x, y := tapped.Bounds.Center()
if err := d.Tap(ctx, x, y); err != nil {
t.Fatalf("Tap %s: %v", field, err)
}
if focused := elementInHierarchyDump(ctx, t, d, "id:"+field); !focused.Focused {
t.Errorf("%s reports no focus after being tapped", field)
}
if caret := elementInHierarchyDump(ctx, t, d, "id:caret-input"); caret.Focused {
t.Errorf("the hidden caret input reports focus after tapping %s, "+
"and no selector can name it", field)
}
if held := focusedElements(ctx, t, d); len(held) != 1 {
t.Errorf("after tapping %s the dump reports %d focused elements %v, want 1",
field, len(held), held)
}
}
}
func focusedElements(ctx context.Context, t *testing.T, d *Driver) []string {
t.Helper()
dump, err := d.Hierarchy(ctx)
if err != nil {
t.Fatalf("Hierarchy: %v", err)
}
tree, err := hierarchy.Parse(dump)
if err != nil {
t.Fatalf("parse hierarchy: %v", err)
}
var held []string
for _, element := range tree.Elements {
if element.Focused {
held = append(held, element.ResourceID+"/"+element.Attributes["tag"])
}
}
return held
}
func elementInHierarchyDump( func elementInHierarchyDump(
ctx context.Context, ctx context.Context,
t *testing.T, t *testing.T,
@@ -0,0 +1,80 @@
<!doctype html>
<html id="page">
<head id="page-head">
<meta id="page-charset" charset="utf-8" />
<title id="page-title">compose backing input</title>
</head>
<body id="page-body">
<!-- Shaped like Compose for Web: keystrokes go through a 1px transparent
input pinned to the caret, a sibling of the accessibility tree rather
than a node in it, so DOM focus never lands on the semantics element
carrying the test tag. The custom properties are declared on the
container and read by the input, exactly as Compose writes them. -->
<div id="app"></div>
<script id="page-script">
const root = document.getElementById("app").attachShadow({ mode: "open" });
root.innerHTML = `
<div
id="caret-holder"
style="
position: absolute;
top: 0px;
left: 0px;
--compose-internal-web-backing-input-left: 0;
--compose-internal-web-backing-input-top: 0;
--compose-internal-web-backing-input-width: 1;
--compose-internal-web-backing-input-height: 17.578125;
"
>
<input
id="caret-input"
style="
position: absolute;
top: calc(var(--compose-internal-web-backing-input-top) * 1px);
left: calc(var(--compose-internal-web-backing-input-left) * 1px);
width: calc(var(--compose-internal-web-backing-input-width) * 1px);
height: calc(var(--compose-internal-web-backing-input-height) * 1px);
padding: 0;
color: transparent;
background: transparent;
caret-color: transparent;
border: none;
outline: none;
z-index: -1;
"
/>
</div>
<div id="a11y-root" role="presentation">
<div
id="EmailField"
role="textbox"
contenteditable="true"
style="position: absolute; left: 34px; top: 78px; width: 688px; height: 18px;"
></div>
<div
id="PasswordField"
role="textbox"
contenteditable="true"
style="position: absolute; left: 34px; top: 158px; width: 688px; height: 18px;"
></div>
</div>`;
const holder = root.getElementById("caret-holder");
const caret = root.getElementById("caret-input");
for (const field of root.querySelectorAll('[role="textbox"]')) {
field.addEventListener("mousedown", (event) => {
event.preventDefault();
const box = field.getBoundingClientRect();
holder.style.setProperty(
"--compose-internal-web-backing-input-left",
String(Math.round(event.clientX)),
);
holder.style.setProperty(
"--compose-internal-web-backing-input-top",
String(Math.round(box.top)),
);
caret.focus();
});
}
</script>
</body>
</html>