diff --git a/pkg/spec/src/web-runtime.ts b/pkg/spec/src/web-runtime.ts index 833d3c6..6e96766 100644 --- a/pkg/spec/src/web-runtime.ts +++ b/pkg/spec/src/web-runtime.ts @@ -510,7 +510,35 @@ function isEnabled(element: Element): boolean { function deepestActiveElement(): Element | null { let element = document.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( @@ -831,6 +859,12 @@ function isEditableElement(element: HTMLElement): boolean { return false; } +function editableElements(): Set { + return new Set( + (deepQueryAll(EDITABLE_SELECTOR, document) as HTMLElement[]).filter(isEditableElement), + ); +} + // isScrollable mirrors the native `scrollable` accessibility attribute: 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 @@ -936,9 +970,7 @@ function selectorsFor(elements: readonly HTMLElement[]): Array(deepQueryAll(TAPPABLE_SELECTOR, document)); - const editable = new Set( - (deepQueryAll(EDITABLE_SELECTOR, document) as HTMLElement[]).filter(isEditableElement), - ); + const editable = editableElements(); const elements = targetElements(); const selectors = selectorsFor(elements); return elements.map((element, index) => ({ diff --git a/pkg/spec/test/web-dom-harness.ts b/pkg/spec/test/web-dom-harness.ts index e36faa3..3e9923b 100644 --- a/pkg/spec/test/web-dom-harness.ts +++ b/pkg/spec/test/web-dom-harness.ts @@ -54,6 +54,12 @@ export interface FakeElementSpec { // host decides an element is scrollable. overflows?: 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; children?: FakeElementSpec[]; shadow?: FakeElementSpec[]; } @@ -163,6 +169,14 @@ function activeElementIn(nodes: FakeElement[]): FakeElement | 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[] { const found: FakeElement[] = []; const walk = (nodes: FakeElement[]): void => { @@ -303,9 +317,13 @@ export function withFakeDocument(elements: FakeElement[], run: () => void): void const global = globalThis as Record; const originalDocument = global.document; const originalWindow = global.window; + const originalComputedStyle = global.getComputedStyle; const document: FakeRoot = fakeRoot(elements); global.document = document; global.window = {}; + global.getComputedStyle = (element: FakeElement) => ({ + getPropertyValue: (name: string) => inheritedProperty(element, name), + }); __testing__.resetTargetCache(); try { run(); @@ -313,5 +331,6 @@ export function withFakeDocument(elements: FakeElement[], run: () => void): void __testing__.resetTargetCache(); global.document = originalDocument; global.window = originalWindow; + global.getComputedStyle = originalComputedStyle; } } diff --git a/pkg/spec/test/web-runtime.test.ts b/pkg/spec/test/web-runtime.test.ts index f2edde7..f214033 100644 --- a/pkg/spec/test/web-runtime.test.ts +++ b/pkg/spec/test/web-runtime.test.ts @@ -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 | 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 // 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,