diff --git a/pkg/spec/src/web-runtime.ts b/pkg/spec/src/web-runtime.ts index fe37e49..dd158ad 100644 --- a/pkg/spec/src/web-runtime.ts +++ b/pkg/spec/src/web-runtime.ts @@ -503,7 +503,21 @@ function isEnabled(element: Element): boolean { return element.getAttribute("aria-disabled") !== "true"; } -function elementHandle(element: Element, selector: unknown): Record { +// 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 { const state = element as Partial; const rect = element.getBoundingClientRect(); const x = Math.round(rect.left + rect.width / 2); @@ -534,7 +548,7 @@ function elementHandle(element: Element, selector: unknown): Record - elementHandle(child, childSelector), + elementHandle(child, childSelector, focusedElement), ); }, }; } function buildAx(): unknown { + const focusedElement = deepestActiveElement(); return { find(selector: unknown): unknown { const element = queryElement(document, selector); - return element ? elementHandle(element, selector) : undefined; + return element ? elementHandle(element, selector, focusedElement) : undefined; }, findAll(selector: unknown): unknown[] { return queryAllElements(document, selector).map((element) => - elementHandle(element, selector), + elementHandle(element, selector, focusedElement), ); }, }; diff --git a/pkg/spec/test/web-dom-harness.ts b/pkg/spec/test/web-dom-harness.ts index 80feb09..e36faa3 100644 --- a/pkg/spec/test/web-dom-harness.ts +++ b/pkg/spec/test/web-dom-harness.ts @@ -53,12 +53,14 @@ export interface FakeElementSpec { // overflows makes the element's content taller than its box, which is how the // host decides an element is scrollable. overflows?: boolean; + focused?: boolean; children?: FakeElementSpec[]; shadow?: FakeElementSpec[]; } export interface FakeRoot { children: FakeElement[]; + activeElement: FakeElement | null; querySelectorAll(selector: string): FakeElement[]; } @@ -139,11 +141,28 @@ export function fakeElement(spec: FakeElementSpec): FakeElement { function fakeRoot(children: FakeElement[]): FakeRoot { const root: FakeRoot = { children, + get activeElement(): FakeElement | null { + return activeElementIn(children); + }, querySelectorAll: (selector: string) => queryScope(root, selector), }; 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[] { const found: FakeElement[] = []; const walk = (nodes: FakeElement[]): void => { diff --git a/pkg/spec/test/web-runtime.test.ts b/pkg/spec/test/web-runtime.test.ts index ea9bfa8..fc8d461 100644 --- a/pkg/spec/test/web-runtime.test.ts +++ b/pkg/spec/test/web-runtime.test.ts @@ -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 | 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 // 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,