mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
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:
1 parent
04327e63b6
commit
ab4c42601d
3 files changed
+122
-4
No files matched your search
@@ -510,7 +510,35 @@ function isEnabled(element: Element): boolean {
|
|||||||
function deepestActiveElement(): Element | null {
|
function deepestActiveElement(): Element | null {
|
||||||
let element = document.activeElement;
|
let element = document.activeElement;
|
||||||
while (element?.shadowRoot?.activeElement) element = element.shadowRoot.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(
|
function elementHandle(
|
||||||
@@ -831,6 +859,12 @@ function isEditableElement(element: HTMLElement): boolean {
|
|||||||
return false;
|
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
|
// isScrollable mirrors the native `scrollable` accessibility attribute: the
|
||||||
// container can actually scroll, i.e. its content overflows its box. 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
|
// 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.
|
// stays expressed in CSS, as it always was.
|
||||||
function collectTargets(): TargetElement[] {
|
function collectTargets(): TargetElement[] {
|
||||||
const clickable = new Set<Element>(deepQueryAll(TAPPABLE_SELECTOR, document));
|
const clickable = new Set<Element>(deepQueryAll(TAPPABLE_SELECTOR, document));
|
||||||
const editable = new Set<Element>(
|
const editable = editableElements();
|
||||||
(deepQueryAll(EDITABLE_SELECTOR, document) as HTMLElement[]).filter(isEditableElement),
|
|
||||||
);
|
|
||||||
const elements = targetElements();
|
const elements = targetElements();
|
||||||
const selectors = selectorsFor(elements);
|
const selectors = selectorsFor(elements);
|
||||||
return elements.map((element, index) => ({
|
return elements.map((element, index) => ({
|
||||||
|
|||||||
@@ -54,6 +54,12 @@ export interface FakeElementSpec {
|
|||||||
// host decides an element is scrollable.
|
// host decides an element is scrollable.
|
||||||
overflows?: boolean;
|
overflows?: boolean;
|
||||||
focused?: boolean;
|
focused?: boolean;
|
||||||
|
// customProperties are DECLARED on this element and INHERITED by everything
|
||||||
|
// under it, which is the whole reason the runtime reads a computed style and
|
||||||
|
// not the style attribute: Compose declares the caret's box on the container
|
||||||
|
// that positions its backing input, and only the input's COMPUTED style
|
||||||
|
// carries it.
|
||||||
|
customProperties?: Record<string, string>;
|
||||||
children?: FakeElementSpec[];
|
children?: FakeElementSpec[];
|
||||||
shadow?: FakeElementSpec[];
|
shadow?: FakeElementSpec[];
|
||||||
}
|
}
|
||||||
@@ -163,6 +169,14 @@ function activeElementIn(nodes: FakeElement[]): FakeElement | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function inheritedProperty(element: FakeElement, name: string): string {
|
||||||
|
for (let node: FakeElement | null = element; node; node = node.parentElement) {
|
||||||
|
const declared = node.customProperties?.[name];
|
||||||
|
if (declared !== undefined) return declared;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
function queryScope(scope: { children: FakeElement[] }, selector: string): FakeElement[] {
|
function queryScope(scope: { children: FakeElement[] }, selector: string): FakeElement[] {
|
||||||
const found: FakeElement[] = [];
|
const found: FakeElement[] = [];
|
||||||
const walk = (nodes: FakeElement[]): void => {
|
const walk = (nodes: FakeElement[]): void => {
|
||||||
@@ -303,9 +317,13 @@ export function withFakeDocument(elements: FakeElement[], run: () => void): void
|
|||||||
const global = globalThis as Record<string, unknown>;
|
const global = globalThis as Record<string, unknown>;
|
||||||
const originalDocument = global.document;
|
const originalDocument = global.document;
|
||||||
const originalWindow = global.window;
|
const originalWindow = global.window;
|
||||||
|
const originalComputedStyle = global.getComputedStyle;
|
||||||
const document: FakeRoot = fakeRoot(elements);
|
const document: FakeRoot = fakeRoot(elements);
|
||||||
global.document = document;
|
global.document = document;
|
||||||
global.window = {};
|
global.window = {};
|
||||||
|
global.getComputedStyle = (element: FakeElement) => ({
|
||||||
|
getPropertyValue: (name: string) => inheritedProperty(element, name),
|
||||||
|
});
|
||||||
__testing__.resetTargetCache();
|
__testing__.resetTargetCache();
|
||||||
try {
|
try {
|
||||||
run();
|
run();
|
||||||
@@ -313,5 +331,6 @@ export function withFakeDocument(elements: FakeElement[], run: () => void): void
|
|||||||
__testing__.resetTargetCache();
|
__testing__.resetTargetCache();
|
||||||
global.document = originalDocument;
|
global.document = originalDocument;
|
||||||
global.window = originalWindow;
|
global.window = originalWindow;
|
||||||
|
global.getComputedStyle = originalComputedStyle;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1023,6 +1023,73 @@ test("ax.find reports focus on the field inside the shadow root, not on its host
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Descending the shadow roots is still not enough on Compose for Web: it takes
|
||||||
|
// keystrokes on a 1px input pinned to the caret, a SIBLING of the accessibility
|
||||||
|
// tree, so DOM focus never reaches the semantics element carrying the test tag
|
||||||
|
// and every field read unfocused. internal/driver/chrome/driver.go re-attributes
|
||||||
|
// focus to the field the caret sits in, and TestElementState_FocusFollowsTheCaretToItsField
|
||||||
|
// pins it there over a real Compose-shaped page.
|
||||||
|
//
|
||||||
|
// Both fields are focused in turn, because answering with the first editable in
|
||||||
|
// the tree would satisfy the email half and still name the wrong field. The
|
||||||
|
// password caret overhangs its box, as it does whenever the text style is taller
|
||||||
|
// than the field's layout box, so requiring the caret to be CONTAINED rather
|
||||||
|
// than to have its centre inside would drop that field back to unfocused.
|
||||||
|
test("focus follows the caret to the field it types into, not the input it is", () => {
|
||||||
|
const carets = [
|
||||||
|
{ field: "EmailField", y: 78, height: 17.578125 },
|
||||||
|
{ field: "PasswordField", y: 157, height: 20 },
|
||||||
|
];
|
||||||
|
for (const caret of carets) {
|
||||||
|
const app = fakeElement({
|
||||||
|
tag: "div", x: 0, y: 0, width: 760, height: 800, id: "app",
|
||||||
|
shadow: [
|
||||||
|
{
|
||||||
|
tag: "div", x: 0, y: 0, width: 0, height: 0, id: "caret-holder",
|
||||||
|
customProperties: {
|
||||||
|
"--compose-internal-web-backing-input-left": "34",
|
||||||
|
"--compose-internal-web-backing-input-top": String(caret.y),
|
||||||
|
"--compose-internal-web-backing-input-width": "1",
|
||||||
|
"--compose-internal-web-backing-input-height": String(caret.height),
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
tag: "input", x: 34, y: caret.y, width: 1, height: caret.height,
|
||||||
|
id: "caret-input", editable: true, focused: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: "div", x: 0, y: 0, width: 760, height: 800, id: "a11y-root",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
tag: "div", x: 34, y: 78, width: 688, height: 18, id: "EmailField",
|
||||||
|
attrs: { role: "textbox", contenteditable: "true" }, editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: "div", x: 34, y: 158, width: 688, height: 18, id: "PasswordField",
|
||||||
|
attrs: { role: "textbox", contenteditable: "true" }, editable: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
withFakeDocument([app], () => {
|
||||||
|
__testing__.extractors.length = 0;
|
||||||
|
const ids = ["EmailField", "PasswordField", "caret-input", "caret-holder", "a11y-root", "app"];
|
||||||
|
for (const id of ids) {
|
||||||
|
__testing__.runtime.extract((state) => {
|
||||||
|
const ax = (state as { ax: { find(s: unknown): Record<string, unknown> | undefined } }).ax;
|
||||||
|
return ax.find(`id:${id}`)?.focused;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const values = __testing__.evaluateExtractors();
|
||||||
|
const focused = ids.filter((_, index) => readingOf(values, index) === true);
|
||||||
|
assert.deepEqual(focused, [caret.field]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// A nested undefined is the one reading shape the two hosts do NOT encode
|
// A nested undefined is the one reading shape the two hosts do NOT encode
|
||||||
// alike, and this pins the split instead of hiding it. JSON has no undefined,
|
// alike, and this pins the split instead of hiding it. JSON has no undefined,
|
||||||
// so the key goes with the value here; goja marshals the same member as null,
|
// so the key goes with the value here; goja marshals the same member as null,
|
||||||
|
|||||||
Reference in new issue
Block a user