Skip to content

Commit

Permalink
[IMP] web: HOOT - DOM rect helpers
Browse files Browse the repository at this point in the history
This commit introduces several helpers:

- a new `toHaveRect` matcher: it checks if the target's DOM rect matches
the given rect;

- 2 new helpers `queryRect` and `queryAllRects` that return the DOM rect
of the target element(s).

Part-of: #158916
  • Loading branch information
Arcasias committed Mar 28, 2024
1 parent a3ecc42 commit 9319d5b
Show file tree
Hide file tree
Showing 5 changed files with 173 additions and 77 deletions.
107 changes: 69 additions & 38 deletions addons/web/static/lib/hoot-dom/helpers/dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ import { HootDomError, getTag, isFirefox, isIterable, parseRegExp } from "../hoo
* }} QueryOptions
*
* @typedef {{
* trimPadding?: boolean;
* }} QueryRectOptions
*
* @typedef {{
* raw?: boolean;
* }} QueryTextOptions
*
Expand Down Expand Up @@ -235,7 +239,7 @@ const isNodeVisible = (node) => {

// Check size (width & height)
if (typeof element.getBoundingClientRect === "function") {
const { width, height } = getRect(element);
const { width, height } = getNodeRect(element);
visible = width > 0 && height > 0;
}

Expand Down Expand Up @@ -945,6 +949,36 @@ export function getNodeValue(node) {
return node.value;
}

/**
* @param {Node} node
* @param {QueryRectOptions} [options]
*/
export function getNodeRect(node, options) {
if (!isElement(node)) {
return new DOMRect();
}

const rect = node.getBoundingClientRect();
const parentFrame = getParentFrame(node);
if (parentFrame) {
const parentRect = getNodeRect(parentFrame);
rect.x -= parentRect.x;
rect.y -= parentRect.y;
}

if (!options?.trimPadding) {
return rect;
}

const style = getStyle(node);
const { x, y, width, height } = rect;
const [pl, pr, pt, pb] = ["left", "right", "top", "bottom"].map((side) =>
pixelValueToNumber(style.getPropertyValue(`padding-${side}`))
);

return new DOMRect(x + pl, y + pt, width - (pl + pr), height - (pt + pb));
}

/**
* @param {Node} node
* @param {QueryTextOptions} [options]
Expand Down Expand Up @@ -999,43 +1033,6 @@ export function getPreviousFocusableElement(parent) {
return index < 0 ? focusableEls.at(-1) : focusableEls[index - 1] || null;
}

/**
* Returns the bounding {@link DOMRect} of a given node (or an empty one if none is given).
* This helper is a bit different than the native {@link Element.getBoundingClientRect}:
* - rects take their positions relative to the top window element (instead of their
* parent `<iframe>` if any);
* - they can be trimmed to remove padding with the `trimPadding` option.
*
* @param {Node} node
* @param {{ trimPadding?: boolean }} [options]
* @returns {DOMRect}
*/
export function getRect(node, options) {
if (!isElement(node)) {
return new DOMRect();
}

const rect = node.getBoundingClientRect();
const parentFrame = getParentFrame(node);
if (parentFrame) {
const parentRect = getRect(parentFrame);
rect.x -= parentRect.x;
rect.y -= parentRect.y;
}

if (!options?.trimPadding) {
return rect;
}

const style = getStyle(node);
const { x, y, width, height } = rect;
const [pl, pr, pt, pb] = ["left", "right", "top", "bottom"].map((side) =>
pixelValueToNumber(style.getPropertyValue(`padding-${side}`))
);

return new DOMRect(x + pl, y + pt, width - (pl + pr), height - (pt + pb));
}

/**
* @template {Node} T
* @param {T} node
Expand Down Expand Up @@ -1557,6 +1554,23 @@ export function queryAllProperties(target, property, options) {
return queryAll(target, options).map((node) => node[property]);
}

/**
* Performs a {@link queryAll} with the given arguments and returns a list of the
* {@link DOMRect} of the matching nodes.
*
* There are a few differences with the native {@link Element.getBoundingClientRect}:
* - rects take their positions relative to the top window element (instead of their
* parent `<iframe>` if any);
* - they can be trimmed to remove padding with the `trimPadding` option.
*
* @param {Target} target
* @param {QueryOptions & QueryRectOptions} [options]
* @returns {DOMRect[]}
*/
export function queryAllRects(target, options) {
return queryAll(target, options).map(getNodeRect);
}

/**
* Performs a {@link queryAll} with the given arguments and returns a list of the
* *texts* of the matching nodes.
Expand Down Expand Up @@ -1627,6 +1641,23 @@ export function queryOne(target, options) {
return queryAll(target, { exact: 1, ...options })[0];
}

/**
* Performs a {@link queryOne} with the given arguments and returns the {@link DOMRect}
* of the matching node.
*
* There are a few differences with the native {@link Element.getBoundingClientRect}:
* - rects take their positions relative to the top window element (instead of their
* parent `<iframe>` if any);
* - they can be trimmed to remove padding with the `trimPadding` option.
*
* @param {Target} target
* @param {QueryOptions & QueryRectOptions} [options]
* @returns {DOMRect}
*/
export function queryRect(target, options) {
return getNodeRect(queryOne(target, options), options);
}

/**
* Performs a {@link queryOne} with the given arguments and returns the *text* of
* the matching node.
Expand Down
4 changes: 2 additions & 2 deletions addons/web/static/lib/hoot-dom/helpers/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
getNextFocusableElement,
getNodeValue,
getPreviousFocusableElement,
getRect,
getNodeRect,
getWindow,
isCheckable,
isEditable,
Expand Down Expand Up @@ -304,7 +304,7 @@ const getPosition = (element, options) => {
return toEventPosition(posX, posY, position);
}

const { x, y, width, height } = getRect(element);
const { x, y, width, height } = getNodeRect(element);
let clientX = x;
let clientY = y;

Expand Down
5 changes: 4 additions & 1 deletion addons/web/static/lib/hoot-dom/hoot-dom.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
/** @odoo-module alias=@odoo/hoot-dom default=false */

/**
* @typedef {import("./helpers/dom").Dimensions} Dimensions
* @typedef {import("./helpers/dom").Position} Position
* @typedef {import("./helpers/dom").QueryOptions} QueryOptions
* @typedef {import("./helpers/dom").QueryRectOptions} QueryRectOptions
* @typedef {import("./helpers/dom").QueryTextOptions} QueryTextOptions
* @typedef {import("./helpers/dom").Target} Target
*
Expand All @@ -19,7 +21,6 @@ export {
getFocusableElements,
getNextFocusableElement,
getPreviousFocusableElement,
getRect,
isDisplayed,
isEditable,
isEventTarget,
Expand All @@ -31,12 +32,14 @@ export {
queryAll,
queryAllAttributes,
queryAllProperties,
queryAllRects,
queryAllTexts,
queryAllValues,
queryAttribute,
queryFirst,
queryLast,
queryOne,
queryRect,
queryText,
queryValue,
registerPseudoClass,
Expand Down
60 changes: 60 additions & 0 deletions addons/web/static/lib/hoot/core/expect.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@ import {
getNodeAttribute,
getNodeText,
getNodeValue,
getNodeRect,
getStyle,
isCheckable,
isDisplayed,
isEmpty,
isNode,
isVisible,
queryAll,
queryRect,
} from "@web/../lib/hoot-dom/helpers/dom";
import { isFirefox, isIterable } from "@web/../lib/hoot-dom/hoot_dom_utils";
import {
Expand Down Expand Up @@ -48,6 +51,7 @@ import { Test } from "./test";
*
* @typedef {import("@odoo/hoot-dom").Dimensions} Dimensions
* @typedef {import("@odoo/hoot-dom").Position} Position
* @typedef {import("@odoo/hoot-dom").QueryRectOptions} QueryRectOptions
* @typedef {import("@odoo/hoot-dom").QueryTextOptions} QueryTextOptions
* @typedef {import("@odoo/hoot-dom").Target} Target
*/
Expand Down Expand Up @@ -1537,6 +1541,62 @@ export class Matchers {
});
}

/**
* Expects the {@link DOMRect} of the received {@link Target} to match the given
* `rect` object.
*
* The `rect` object can either be:
* - a {@link DOMRect} object,
* - a CSS selector string (to get the rect of the *only* matching element),
* - a node.
*
* If the resulting `rect` value is a node, then both nodes' rects will be compared.
*
* @param {Partial<DOMRect> | Target} rect
* @param {ExpectOptions & QueryRectOptions} options
* @example
* expect("button").toHaveRect({ x: 20, width: 100, height: 50 });
* @example
* expect("button").toHaveRect(".container");
*/
toHaveRect(rect, options) {
this.#saveStack();

ensureArguments([
[rect, ["object", "string", "node", "node[]"]],
[options, ["object", null]],
]);

let refRect;
if (typeof rect === "string" || isNode(rect)) {
refRect = queryRect(rect, options);
} else {
refRect = rect;
}

const entries = $entries(refRect);
return this.#resolve({
name: "toHaveRect",
acceptedType: ["string", "node", "node[]"],
transform: queryAll,
predicate: each((node) => {
const nodeRect = getNodeRect(node, options);
return entries.every(([key, value]) => strictEqual(nodeRect[key], value));
}),
message: (pass) =>
options?.message ||
(pass
? `%elements% have the expected DOM rect of ${formatHumanReadable(rect)}`
: `expected %elements% to have the given DOM rect`),
details: (actual) => {
return [
[Markup.green("Expected:"), rect],
[Markup.red("Received:"), getNodeRect(actual[0], options)],
];
},
});
}

/**
* Expects the received {@link Target} to have the given class name(s).
*
Expand Down

0 comments on commit 9319d5b

Please sign in to comment.