mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17: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) {
|
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>
|
||||||
Reference in new issue
Block a user