mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07:10 +00:00
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:
1 parent
176b495245
commit
5fc91fdca1
3 files changed
+173
No files matched your search
@@ -649,6 +649,36 @@ func (d *Driver) Hierarchy(ctx context.Context) (string, error) {
|
||||
while (focusedElement && focusedElement.shadowRoot && 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) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
// 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(
|
||||
ctx context.Context,
|
||||
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>
|
||||
Reference in new issue
Block a user