diff --git a/pkg/spec/src/web-runtime.ts b/pkg/spec/src/web-runtime.ts index 6b1bbd4..a8b5764 100644 --- a/pkg/spec/src/web-runtime.ts +++ b/pkg/spec/src/web-runtime.ts @@ -177,6 +177,8 @@ const KNOWN_KEY_TO_STATE: Record< focused: focusedMatcher, checked: stateMatcher(isChecked), selected: stateMatcher(isSelected), + editable: stateMatcher(isEditable), + scrollable: scrollableMatcher, }; // A value that is neither true nor false can match nothing, the way a CSS part @@ -191,6 +193,16 @@ function stateMatcher( }; } +// scrollable is stated only where it holds: buildTree in +// internal/driver/chrome/driver.go writes the attribute on the containers whose +// content overflows and on nothing else, as the ios map does, so `false` names +// no element rather than every element that does not scroll, the way an element +// that is no field at all answers to neither value of secure. +function scrollableMatcher(value: string): (element: Element) => boolean { + if (value !== "true") return () => false; + return isScrollable; +} + // The focused element is resolved once per selector rather than once per // element: finding it descends every shadow root and, on a Compose page, sweeps // the editable fields for the box the caret sits in. @@ -711,8 +723,7 @@ function elementHandle( const y = Math.round(rect.top + rect.height / 2); const ariaLabel = element.getAttribute("aria-label") ?? ""; const text = (element.textContent ?? "").trim().slice(0, 200); - const editable = - element.matches(EDITABLE_SELECTOR) && isEditableElement(element as HTMLElement); + const editable = isEditable(element); const datasetCopy: Record = {}; const dataset = (element as HTMLElement).dataset ?? {}; for (const key of Object.keys(dataset)) { @@ -736,10 +747,6 @@ function elementHandle( // container a spec reached through state.ax claimed to be a tap target. clickable: isClickable(element), enabled: isEnabled(element), - // isContentEditable is inherited, so reading it alone made every span inside - // a contenteditable container typeable here while collectTargets and the - // hierarchy dump, which both require the element ITSELF to match - // EDITABLE_SELECTOR, called the same span inert. editable, focused: focusedElement === element, checked: isChecked(element), @@ -1021,17 +1028,25 @@ function isEditableElement(element: HTMLElement): boolean { return false; } +// isContentEditable is inherited, so reading it alone makes every span inside a +// contenteditable container typeable, where the hierarchy dump in +// internal/driver/chrome/driver.go requires the element ITSELF to match +// EDITABLE_SELECTOR. The handle, the picker's target list and the `editable` +// selector all derive the fact here, so none of the three can call an element +// something the other two do not. +function isEditable(element: Element): boolean { + return element.matches(EDITABLE_SELECTOR) && isEditableElement(element as HTMLElement); +} + function editableElements(): Set { - return new Set( - (deepQueryAll(EDITABLE_SELECTOR, document) as HTMLElement[]).filter(isEditableElement), - ); + return new Set(deepQueryAll(EDITABLE_SELECTOR, document).filter(isEditable)); } // 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 // overflow there is no scroll to perform, and native would offer none either. -function isScrollable(element: HTMLElement): boolean { +function isScrollable(element: Element): boolean { return element.scrollHeight > element.clientHeight || element.scrollWidth > element.clientWidth; } diff --git a/pkg/spec/test/web-runtime.test.ts b/pkg/spec/test/web-runtime.test.ts index 93e87f2..caa56c9 100644 --- a/pkg/spec/test/web-runtime.test.ts +++ b/pkg/spec/test/web-runtime.test.ts @@ -998,6 +998,50 @@ test("elementType names the elements class names", () => { }); }); +// editable and scrollable are derived from the live element the way the other +// boolean states are, and were reached the same wrong way: as markup +// attributes, which build [editable="true"] and match nothing on any page. Both +// resolve against the dump on the goja host, so a spec naming a field or a +// scroll container that way found it there and no element at all here. +test("editable selects what this host reports editable", () => { + const note = fakeElement({ + tag: "input", x: 0, y: 0, width: 120, height: 20, id: "note", editable: true, + }); + const remember = fakeElement({ + tag: "input", x: 0, y: 20, width: 20, height: 20, id: "remember", + attrs: { type: "checkbox" }, editable: true, + }); + const heading = fakeElement({ tag: "h1", x: 0, y: 40, width: 120, height: 20, id: "title" }); + withFakeDocument([note, remember, heading], () => { + assert.deepEqual(statesMatched({ editable: true }, "editable"), [["note", true]]); + assert.deepEqual(statesMatched({ editable: false }, "editable"), [ + ["remember", false], + ["title", false], + ]); + }); +}); + +// The selector reads the same overflow test the picker's target list is built +// with, so a container this host offers a scroll on is the container a spec can +// name. `false` names nothing: the producers state the fact only where it +// holds, so the elements that do not scroll answer to neither value, the way an +// element that is no field at all answers to neither value of secure. +test("scrollable selects the containers this host offers a scroll on", () => { + const feed = fakeElement({ + tag: "div", x: 0, y: 0, width: 120, height: 40, id: "feed", overflows: true, + }); + const row = fakeElement({ tag: "div", x: 0, y: 40, width: 120, height: 20, id: "row" }); + withFakeDocument([feed, row], () => { + const scrolls = host + .queryTargets() + .filter((target) => target.scrollable) + .map((target) => target.selector); + assert.deepEqual(scrolls, ["id:feed"]); + assert.deepEqual(matchedIDs({ scrollable: true }), ["feed"]); + assert.deepEqual(matchedIDs({ scrollable: false }), []); + }); +}); + test("attrs carries every other attribute alongside tag and aria-label", () => { const attrs = attrsOf( domElement({ tag: "input", attributes: { id: "txn-note", placeholder: "What's this for?" } }),