mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-04 20:17:09 +00:00
fix(web-runtime): focus descends into the shadow root here too
The Go driver already descends the boundary; the V8 host did not, so the two enumerations disagreed about focus on any shadow-mounted app. The harness now answers activeElement the way a real root does: a root names a node of its own tree, so only the shadow root itself names the field.
This commit is contained in:
1 parent
e432af7828
commit
6706461b91
3 files changed
+76
-6
No files matched your search
@@ -503,7 +503,21 @@ function isEnabled(element: Element): boolean {
|
|||||||
return element.getAttribute("aria-disabled") !== "true";
|
return element.getAttribute("aria-disabled") !== "true";
|
||||||
}
|
}
|
||||||
|
|
||||||
function elementHandle(element: Element, selector: unknown): Record<string, unknown> {
|
// document.activeElement stops at a shadow boundary and names the HOST, so a
|
||||||
|
// Compose for Web page reported focus on its mount element and never on the
|
||||||
|
// field. selectAllScript in internal/driver/chrome/driver.go carries the rest of
|
||||||
|
// it; buildAx descends once per pass and hands the answer down.
|
||||||
|
function deepestActiveElement(): Element | null {
|
||||||
|
let element = document.activeElement;
|
||||||
|
while (element?.shadowRoot?.activeElement) element = element.shadowRoot.activeElement;
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
function elementHandle(
|
||||||
|
element: Element,
|
||||||
|
selector: unknown,
|
||||||
|
focusedElement: Element | null,
|
||||||
|
): Record<string, unknown> {
|
||||||
const state = element as Partial<HTMLInputElement & HTMLOptionElement>;
|
const state = element as Partial<HTMLInputElement & HTMLOptionElement>;
|
||||||
const rect = element.getBoundingClientRect();
|
const rect = element.getBoundingClientRect();
|
||||||
const x = Math.round(rect.left + rect.width / 2);
|
const x = Math.round(rect.left + rect.width / 2);
|
||||||
@@ -534,7 +548,7 @@ function elementHandle(element: Element, selector: unknown): Record<string, unkn
|
|||||||
clickable: element.matches(TAPPABLE_SELECTOR),
|
clickable: element.matches(TAPPABLE_SELECTOR),
|
||||||
enabled: isEnabled(element),
|
enabled: isEnabled(element),
|
||||||
editable: isEditableElement(element as HTMLElement),
|
editable: isEditableElement(element as HTMLElement),
|
||||||
focused: document.activeElement === element,
|
focused: focusedElement === element,
|
||||||
// Checkbox and option state lives in the DOM PROPERTY: the markup attribute
|
// Checkbox and option state lives in the DOM PROPERTY: the markup attribute
|
||||||
// records only what the page started with, so a handle reading it reports a
|
// records only what the page started with, so a handle reading it reports a
|
||||||
// box's initial state however often the user ticks it.
|
// box's initial state however often the user ticks it.
|
||||||
@@ -555,25 +569,26 @@ function elementHandle(element: Element, selector: unknown): Record<string, unkn
|
|||||||
[SELECTOR_TAG]: selectorTagFor(element, selector),
|
[SELECTOR_TAG]: selectorTagFor(element, selector),
|
||||||
find(childSelector: unknown): unknown {
|
find(childSelector: unknown): unknown {
|
||||||
const child = queryElement(element, childSelector);
|
const child = queryElement(element, childSelector);
|
||||||
return child ? elementHandle(child, childSelector) : undefined;
|
return child ? elementHandle(child, childSelector, focusedElement) : undefined;
|
||||||
},
|
},
|
||||||
findAll(childSelector: unknown): unknown[] {
|
findAll(childSelector: unknown): unknown[] {
|
||||||
return queryAllElements(element, childSelector).map((child) =>
|
return queryAllElements(element, childSelector).map((child) =>
|
||||||
elementHandle(child, childSelector),
|
elementHandle(child, childSelector, focusedElement),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildAx(): unknown {
|
function buildAx(): unknown {
|
||||||
|
const focusedElement = deepestActiveElement();
|
||||||
return {
|
return {
|
||||||
find(selector: unknown): unknown {
|
find(selector: unknown): unknown {
|
||||||
const element = queryElement(document, selector);
|
const element = queryElement(document, selector);
|
||||||
return element ? elementHandle(element, selector) : undefined;
|
return element ? elementHandle(element, selector, focusedElement) : undefined;
|
||||||
},
|
},
|
||||||
findAll(selector: unknown): unknown[] {
|
findAll(selector: unknown): unknown[] {
|
||||||
return queryAllElements(document, selector).map((element) =>
|
return queryAllElements(document, selector).map((element) =>
|
||||||
elementHandle(element, selector),
|
elementHandle(element, selector, focusedElement),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -53,12 +53,14 @@ export interface FakeElementSpec {
|
|||||||
// overflows makes the element's content taller than its box, which is how the
|
// overflows makes the element's content taller than its box, which is how the
|
||||||
// host decides an element is scrollable.
|
// host decides an element is scrollable.
|
||||||
overflows?: boolean;
|
overflows?: boolean;
|
||||||
|
focused?: boolean;
|
||||||
children?: FakeElementSpec[];
|
children?: FakeElementSpec[];
|
||||||
shadow?: FakeElementSpec[];
|
shadow?: FakeElementSpec[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FakeRoot {
|
export interface FakeRoot {
|
||||||
children: FakeElement[];
|
children: FakeElement[];
|
||||||
|
activeElement: FakeElement | null;
|
||||||
querySelectorAll(selector: string): FakeElement[];
|
querySelectorAll(selector: string): FakeElement[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,11 +141,28 @@ export function fakeElement(spec: FakeElementSpec): FakeElement {
|
|||||||
function fakeRoot(children: FakeElement[]): FakeRoot {
|
function fakeRoot(children: FakeElement[]): FakeRoot {
|
||||||
const root: FakeRoot = {
|
const root: FakeRoot = {
|
||||||
children,
|
children,
|
||||||
|
get activeElement(): FakeElement | null {
|
||||||
|
return activeElementIn(children);
|
||||||
|
},
|
||||||
querySelectorAll: (selector: string) => queryScope(root, selector),
|
querySelectorAll: (selector: string) => queryScope(root, selector),
|
||||||
};
|
};
|
||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A root answers activeElement with a node of its OWN tree, as the browser
|
||||||
|
// does: focus inside a shadow root names the host, and only that root's own
|
||||||
|
// activeElement names the field. Reporting the field from both roots would let
|
||||||
|
// a runtime that never descends still pass.
|
||||||
|
function activeElementIn(nodes: FakeElement[]): FakeElement | null {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.focused) return node;
|
||||||
|
if (node.shadowRoot?.activeElement) return node;
|
||||||
|
const inside = activeElementIn(node.children);
|
||||||
|
if (inside) return inside;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
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 => {
|
||||||
|
|||||||
@@ -967,6 +967,42 @@ test("ax.find resolves the shadow-hosted match the hierarchy dump reaches first"
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// document.activeElement stops at every shadow boundary it meets, so a Compose
|
||||||
|
// for Web page, which mounts its whole tree in a shadow root, answered `focused`
|
||||||
|
// on the mount element and never on the field the user was typing into.
|
||||||
|
// internal/driver/chrome/driver.go descends the same chain for the dump the goja
|
||||||
|
// host reads, so a handle that compares against the host alone puts the two
|
||||||
|
// hosts on opposite answers for one page.
|
||||||
|
test("ax.find reports focus on the field inside the shadow root, not on its hosts", () => {
|
||||||
|
const app = fakeElement({
|
||||||
|
tag: "div", x: 0, y: 0, width: 400, height: 800, id: "app",
|
||||||
|
shadow: [
|
||||||
|
{
|
||||||
|
tag: "div", x: 0, y: 0, width: 400, height: 100, id: "form",
|
||||||
|
shadow: [
|
||||||
|
{
|
||||||
|
tag: "input", x: 0, y: 0, width: 200, height: 40, id: "amount",
|
||||||
|
editable: true, focused: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
withFakeDocument([app], () => {
|
||||||
|
__testing__.extractors.length = 0;
|
||||||
|
for (const id of ["amount", "form", "app"]) {
|
||||||
|
__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();
|
||||||
|
assert.equal(readingOf(values, 0), true);
|
||||||
|
assert.equal(readingOf(values, 1), false);
|
||||||
|
assert.equal(readingOf(values, 2), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// 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