Odoo18-Base/addons/mail/static/tests/mail_test_helpers_contains.js
2025-01-06 10:57:38 +07:00

943 lines
34 KiB
JavaScript

/** @odoo-module alias=@web/../tests/utils default=false */
import { __debug__, after, afterEach, expect, getFixture } from "@odoo/hoot";
import { queryAll, queryFirst } from "@odoo/hoot-dom";
import { Deferred, tick } from "@odoo/hoot-mock";
import { asyncStep, waitForSteps } from "@web/../tests/web_test_helpers";
import { isMacOS } from "@web/core/browser/feature_detection";
import { isVisible } from "@web/core/utils/ui";
export const step = asyncStep;
export const assertSteps = waitForSteps;
/** @param {EventInit} [args] */
const mapBubblingEvent = (args) => ({ ...args, bubbles: true });
/** @param {EventInit} [args] */
const mapNonBubblingEvent = (args) => ({ ...args, bubbles: false });
/** @param {EventInit} [args={}] */
const mapBubblingPointerEvent = (args = {}) => ({
clientX: args.pageX,
clientY: args.pageY,
...args,
bubbles: true,
cancelable: true,
view: window,
});
/** @param {EventInit} [args] */
const mapNonBubblingPointerEvent = (args) => ({
...mapBubblingPointerEvent(args),
bubbles: false,
cancelable: false,
});
/** @param {EventInit} [args={}] */
const mapCancelableTouchEvent = (args = {}) => ({
...args,
bubbles: true,
cancelable: true,
composed: true,
rotation: 0.0,
touches: args.touches ? [...args.touches.map((e) => new Touch(e))] : undefined,
view: window,
zoom: 1.0,
});
/** @param {EventInit} [args] */
const mapNonCancelableTouchEvent = (args) => ({
...mapCancelableTouchEvent(args),
cancelable: false,
});
/** @param {EventInit} [args] */
const mapKeyboardEvent = (args) => ({
...args,
bubbles: true,
cancelable: true,
});
/**
* @template {typeof Event} T
* @param {EventType} eventType
* @returns {[T, (attrs: EventInit) => EventInit]}
*/
const getEventConstructor = (eventType) => {
switch (eventType) {
// Mouse events
case "auxclick":
case "click":
case "contextmenu":
case "dblclick":
case "mousedown":
case "mouseup":
case "mousemove":
case "mouseover":
case "mouseout": {
return [MouseEvent, mapBubblingPointerEvent];
}
case "mouseenter":
case "mouseleave": {
return [MouseEvent, mapNonBubblingPointerEvent];
}
// Pointer events
case "pointerdown":
case "pointerup":
case "pointermove":
case "pointerover":
case "pointerout": {
return [PointerEvent, mapBubblingPointerEvent];
}
case "pointerenter":
case "pointerleave": {
return [PointerEvent, mapNonBubblingPointerEvent];
}
// Focus events
case "focusin": {
return [FocusEvent, mapBubblingEvent];
}
case "focus":
case "blur": {
return [FocusEvent, mapNonBubblingEvent];
}
// Clipboard events
case "cut":
case "copy":
case "paste": {
return [ClipboardEvent, mapBubblingEvent];
}
// Keyboard events
case "keydown":
case "keypress":
case "keyup": {
return [KeyboardEvent, mapKeyboardEvent];
}
// Drag events
case "drag":
case "dragend":
case "dragenter":
case "dragstart":
case "dragleave":
case "dragover":
case "drop": {
return [DragEvent, mapBubblingEvent];
}
// Input events
case "input": {
return [InputEvent, mapBubblingEvent];
}
// Composition events
case "compositionstart":
case "compositionend": {
return [CompositionEvent, mapBubblingEvent];
}
// UI events
case "scroll": {
return [UIEvent, mapNonBubblingEvent];
}
// Touch events
case "touchstart":
case "touchend":
case "touchmove": {
return [TouchEvent, mapCancelableTouchEvent];
}
case "touchcancel": {
return [TouchEvent, mapNonCancelableTouchEvent];
}
// Default: base Event constructor
default: {
return [Event, mapBubblingEvent];
}
}
};
function findElement(el, selector) {
let target = el;
if (selector) {
const els = el.querySelectorAll(selector);
if (els.length === 0) {
throw new Error(`No element found (selector: ${selector})`);
}
if (els.length > 1) {
throw new Error(`Found ${els.length} elements, instead of 1 (selector: ${selector})`);
}
target = els[0];
}
return target;
}
/**
* @template {EventType} T
* @param {Element} el
* @param {string | null | undefined | false} selector
* @param {T} eventType
* @param {EventInit} [eventInit]
* @param {TriggerEventOptions} [options={}]
* @returns {GlobalEventHandlersEventMap[T] | Promise<GlobalEventHandlersEventMap[T]>}
*/
function triggerEvent(el, selector, eventType, eventInit, options = {}) {
const errors = [];
const target = findElement(el, selector);
// Error handling
if (typeof eventType !== "string") {
errors.push("event type must be a string");
}
if (!target) {
errors.push("cannot find target");
} else if (!options.skipVisibilityCheck && !isVisible(target)) {
errors.push("target is not visible");
}
if (errors.length) {
throw new Error(
`Cannot trigger event${eventType ? ` "${eventType}"` : ""}${
selector ? ` (with selector "${selector}")` : ""
}: ${errors.join(" and ")}`
);
}
// Actual dispatch
const [Constructor, processParams] = getEventConstructor(eventType);
const event = new Constructor(eventType, processParams(eventInit));
target.dispatchEvent(event);
if (__debug__.debug) {
const group = `%c[${event.type.toUpperCase()}]`;
console.groupCollapsed(group, "color: #b52c9b");
console.log(target, event);
console.groupEnd(group, "color: #b52c9b");
}
if (options.sync) {
return event;
} else {
return tick().then(() => event);
}
}
/**
* @param {Element} el
* @param {string | null | undefined | false} selector
* @param {(EventType | [EventType, EventInit])[]} [eventDefs]
* @param {TriggerEventOptions} [options={}]
*/
function _triggerEvents(el, selector, eventDefs, options = {}) {
const events = [...eventDefs].map((eventDef) => {
const [eventType, eventInit] = Array.isArray(eventDef) ? eventDef : [eventDef, {}];
return triggerEvent(el, selector, eventType, eventInit, options);
});
if (options.sync) {
return events;
} else {
return tick().then(() => events);
}
}
function _click(
el,
selector,
{ mouseEventInit = {}, skipDisabledCheck = false, skipVisibilityCheck = false } = {}
) {
if (!skipDisabledCheck && el.disabled) {
throw new Error("Can't click on a disabled button");
}
return _triggerEvents(
el,
selector,
[
"pointerdown",
"mousedown",
"focus",
"focusin",
"pointerup",
"mouseup",
["click", mouseEventInit],
],
{ skipVisibilityCheck }
);
}
export async function editInput(el, selector, value) {
const input = findElement(el, selector);
if (!(input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement)) {
throw new Error("Only 'input' and 'textarea' elements can be edited with 'editInput'.");
}
if (
!["text", "textarea", "email", "search", "color", "number", "file", "tel"].includes(
input.type
)
) {
throw new Error(`Type "${input.type}" not supported by 'editInput'.`);
}
const eventOpts = {};
if (input.type === "file") {
const files = Array.isArray(value) ? value : [value];
const dataTransfer = new DataTransfer();
for (const file of files) {
if (!(file instanceof File)) {
throw new Error(`File input value should be one or several File objects.`);
}
dataTransfer.items.add(file);
}
input.files = dataTransfer.files;
eventOpts.skipVisibilityCheck = true;
} else {
input.value = value;
}
await _triggerEvents(input, null, ["input", "change"], eventOpts);
if (input.type === "file") {
// Need to wait for the file to be loaded by the input
await tick();
await tick();
}
}
/**
* Create a fake object 'dataTransfer', linked to some files,
* which is passed to drag and drop events.
*
* @param {Object[]} files
* @returns {Object}
*/
function createFakeDataTransfer(files) {
return {
dropEffect: "all",
effectAllowed: "all",
files,
items: [],
types: ["Files"],
};
}
/**
* Waits until exactly one element matching the given `selector` is present in
* `options.target` and then clicks on it.
*
* @param {string} selector
* @param {ContainsOptions} [options] forwarded to `contains`
* @param {boolean} [options.shiftKey]
*/
export async function click(selector, options = {}) {
const { shiftKey } = options;
delete options.shiftKey;
await contains(selector, { click: { shiftKey }, ...options });
}
/**
* Waits until exactly one element matching the given `selector` is present in
* `options.target` and then dragenters `files` on it.
*
* @param {string} selector
* @param {Object[]} files
* @param {ContainsOptions} [options] forwarded to `contains`
*/
export async function dragenterFiles(selector, files, options) {
await contains(selector, { dragenterFiles: files, ...options });
}
/**
* Waits until exactly one element matching the given `selector` is present in
* `options.target` and then dragovers `files` on it.
*
* @param {string} selector
* @param {Object[]} files
* @param {ContainsOptions} [options] forwarded to `contains`
*/
export async function dragoverFiles(selector, files, options) {
await contains(selector, { dragoverFiles: files, ...options });
}
/**
* Waits until exactly one element matching the given `selector` is present in
* `options.target` and then drops `files` on it.
*
* @param {string} selector
* @param {Object[]} files
* @param {ContainsOptions} [options] forwarded to `contains`
*/
export async function dropFiles(selector, files, options) {
await contains(selector, { dropFiles: files, ...options });
}
/**
* Waits until exactly one element matching the given `selector` is present in
* `options.target` and then inputs `files` on it.
*
* @param {string} selector
* @param {Object[]} files
* @param {ContainsOptions} [options] forwarded to `contains`
*/
export async function inputFiles(selector, files, options) {
await contains(selector, { inputFiles: files, ...options });
}
/**
* Waits until exactly one element matching the given `selector` is present in
* `options.target` and then pastes `files` on it.
*
* @param {string} selector
* @param {Object[]} files
* @param {ContainsOptions} [options] forwarded to `contains`
*/
export async function pasteFiles(selector, files, options) {
await contains(selector, { pasteFiles: files, ...options });
}
/**
* Waits until exactly one element matching the given `selector` is present in
* `options.target` and then focuses on it.
*
* @param {string} selector
* @param {ContainsOptions} [options] forwarded to `contains`
*/
export async function focus(selector, options) {
await contains(selector, { setFocus: true, ...options });
}
/**
* Waits until exactly one element matching the given `selector` is present in
* `options.target` and then inserts the given `content`.
*
* @param {string} selector
* @param {string} content
* @param {ContainsOptions} [options] forwarded to `contains`
* @param {boolean} [options.replace=false]
*/
export async function insertText(selector, content, options = {}) {
const { replace = false } = options;
delete options.replace;
await contains(selector, { ...options, insertText: { content, replace } });
}
/**
* Waits until exactly one element matching the given `selector` is present in
* `options.target` and then sets its `scrollTop` to the given value.
*
* @param {string} selector
* @param {number|"bottom"} scrollTop
* @param {ContainsOptions} [options] forwarded to `contains`
*/
export async function scroll(selector, scrollTop, options) {
await contains(selector, { setScroll: scrollTop, ...options });
}
/**
* Waits until exactly one element matching the given `selector` is present in
* `options.target` and then triggers `event` on it.
*
* @param {string} selector
* @param {(import("@web/../tests/helpers/utils").EventType|[import("@web/../tests/helpers/utils").EventType, EventInit])[]} events
* @param {ContainsOptions} [options] forwarded to `contains`
*/
export async function triggerEvents(selector, events, options) {
await contains(selector, { triggerEvents: events, ...options });
}
/**
* Triggers an hotkey properly disregarding the operating system.
*
* @param {string} hotkey
* @param {boolean} addOverlayModParts
* @param {KeyboardEventInit} eventAttrs
*/
export async function triggerHotkey(hotkey, addOverlayModParts = false, eventAttrs = {}) {
eventAttrs.key = hotkey.split("+").pop();
if (/shift/i.test(hotkey)) {
eventAttrs.shiftKey = true;
}
if (/control/i.test(hotkey)) {
if (isMacOS()) {
eventAttrs.metaKey = true;
} else {
eventAttrs.ctrlKey = true;
}
}
if (/alt/i.test(hotkey) || addOverlayModParts) {
if (isMacOS()) {
eventAttrs.ctrlKey = true;
} else {
eventAttrs.altKey = true;
}
}
if (!("bubbles" in eventAttrs)) {
eventAttrs.bubbles = true;
}
const [keydownEvent, keyupEvent] = await _triggerEvents(
document.activeElement,
null,
[
["keydown", eventAttrs],
["keyup", eventAttrs],
],
{ skipVisibilityCheck: true }
);
return { keydownEvent, keyupEvent };
}
function log(ok, message) {
expect(Boolean(ok)).toBe(true, { message });
}
let hasUsedContainsPositively = false;
afterEach(() => (hasUsedContainsPositively = false));
/**
* @typedef {[string, ContainsOptions]} ContainsTuple tuple representing params of the contains
* function, where the first element is the selector, and the second element is the options param.
* @typedef {Object} ContainsOptions
* @property {ContainsTuple} [after] if provided, the found element(s) must be after the element
* matched by this param.
* @property {ContainsTuple} [before] if provided, the found element(s) must be before the element
* matched by this param.
* @property {Object} [click] if provided, clicks on the first found element
* @property {ContainsTuple|ContainsTuple[]} [contains] if provided, the found element(s) must
* contain the provided sub-elements.
* @property {number} [count=1] numbers of elements to be found to declare the contains check
* as successful. Elements are counted after applying all other filters.
* @property {Object[]} [dragenterFiles] if provided, dragenters the given files on the found element
* @property {Object[]} [dragoverFiles] if provided, dragovers the given files on the found element
* @property {Object[]} [dropFiles] if provided, drops the given files on the found element
* @property {Object[]} [inputFiles] if provided, inputs the given files on the found element
* @property {{content:string, replace:boolean}} [insertText] if provided, adds to (or replace) the
* value of the first found element by the given content.
* @property {ContainsTuple} [parent] if provided, the found element(s) must have as
* parent the node matching the parent parameter.
* @property {Object[]} [pasteFiles] if provided, pastes the given files on the found element
* @property {number|"bottom"} [scroll] if provided, the scrollTop of the found element(s)
* must match.
* Note: when using one of the scrollTop options, it is advised to ensure the height is not going
* to change soon, by checking with a preceding contains that all the expected elements are in DOM.
* @property {boolean} [setFocus] if provided, focuses the first found element.
* @property {boolean} [shadowRoot] if provided, targets the shadowRoot of the found elements.
* @property {number|"bottom"} [setScroll] if provided, sets the scrollTop on the first found
* element.
* @property {HTMLElement|OdooEnv} [target=getFixture()]
* @property {string[]} [triggerEvents] if provided, triggers the given events on the found element
* @property {string} [text] if provided, the textContent of the found element(s) or one of their
* descendants must match. Use `textContent` option for a match on the found element(s) only.
* @property {string} [textContent] if provided, the textContent of the found element(s) must match.
* Prefer `text` option for a match on the found element(s) or any of their descendants, usually
* allowing for a simpler and less specific selector.
* @property {string} [value] if provided, the input value of the found element(s) must match.
* Note: value changes are not observed directly, another mutation must happen to catch them.
* @property {boolean} [visible] if provided, the found element(s) must be (in)visible
*/
class Contains {
/**
* @param {string} selector
* @param {ContainsOptions} [options={}]
*/
constructor(selector, options = {}) {
this.selector = selector;
this.options = options;
this.options.count ??= 1;
let targetParam;
if (this.options.target?.testEnv) {
// when OdooEnv, special key `target`. See @start
targetParam = this.options.target?.target;
}
if (!targetParam) {
targetParam = this.options.target;
}
this.options.target = targetParam || getFixture();
let selectorMessage = `${this.options.count} of "${this.selector}"`;
if (this.options.visible !== undefined) {
selectorMessage = `${selectorMessage} ${
this.options.visible ? "visible" : "invisible"
}`;
}
if (targetParam) {
selectorMessage = `${selectorMessage} inside a specific target`;
}
if (this.options.parent) {
selectorMessage = `${selectorMessage} inside a specific parent`;
}
if (this.options.contains) {
selectorMessage = `${selectorMessage} with a specified sub-contains`;
}
if (this.options.text !== undefined) {
selectorMessage = `${selectorMessage} with text "${this.options.text}"`;
}
if (this.options.textContent !== undefined) {
selectorMessage = `${selectorMessage} with textContent "${this.options.textContent}"`;
}
if (this.options.value !== undefined) {
selectorMessage = `${selectorMessage} with value "${this.options.value}"`;
}
if (this.options.scroll !== undefined) {
selectorMessage = `${selectorMessage} with scroll "${this.options.scroll}"`;
}
if (this.options.after !== undefined) {
selectorMessage = `${selectorMessage} after a specified element`;
}
if (this.options.before !== undefined) {
selectorMessage = `${selectorMessage} before a specified element`;
}
this.selectorMessage = selectorMessage;
if (this.options.contains && !Array.isArray(this.options.contains[0])) {
this.options.contains = [this.options.contains];
}
if (this.options.count) {
hasUsedContainsPositively = true;
} else if (!hasUsedContainsPositively) {
throw new Error(
`Starting a test with "contains" of count 0 for selector "${this.selector}" is useless because it might immediately resolve. Start the test by checking that an expected element actually exists.`
);
}
/** @type {string} */
this.successMessage = undefined;
/** @type {function} */
this.executeError = undefined;
}
/**
* Starts this contains check, either immediately resolving if there is a
* match, or registering appropriate listeners and waiting until there is a
* match or a timeout (resolving or rejecting respectively).
*
* Success or failure messages will be logged with HOOT as well.
*
* @returns {Promise}
*/
run() {
this.done = false;
this.def = new Deferred();
this.scrollListeners = new Set();
this.onScroll = () => this.runOnce("after scroll");
if (!this.runOnce("immediately")) {
this.timer = setTimeout(
() => this.runOnce("Timeout of 3 seconds", { crashOnFail: true }),
3000
);
this.observer = new MutationObserver((mutations) => {
try {
this.runOnce("after mutations");
} catch (e) {
this.def.reject(e); // prevents infinite loop in case of programming error
}
});
this.observer.observe(document.body, {
attributes: true,
childList: true,
subtree: true,
});
after(() => {
if (!this.done) {
this.runOnce("Test ended", { crashOnFail: true });
}
});
}
return this.def;
}
/**
* Runs this contains check once, immediately returning the result (or
* undefined), and possibly resolving or rejecting the main promise
* (and printing HOOT log) depending on options.
* If undefined is returned it means the check was not successful.
*
* @param {string} whenMessage
* @param {Object} [options={}]
* @param {boolean} [options.crashOnFail=false]
* @param {boolean} [options.executeOnSuccess=true]
* @returns {HTMLElement[]|undefined}
*/
runOnce(whenMessage, { crashOnFail = false, executeOnSuccess = true } = {}) {
const res = this.select();
if ((res?.length ?? 0) === this.options.count || crashOnFail) {
// clean before doing anything else to avoid infinite loop due to side effects
this.observer?.disconnect();
clearTimeout(this.timer);
for (const el of this.scrollListeners ?? []) {
el.removeEventListener("scroll", this.onScroll);
}
this.done = true;
}
if ((res?.length ?? 0) === this.options.count) {
this.successMessage = `Found ${this.selectorMessage} (${whenMessage})`;
if (executeOnSuccess) {
this.executeAction(res[0]);
}
return res;
} else {
this.executeError = () => {
let message = `Failed to find ${this.selectorMessage} (${whenMessage}).`;
message = res
? `${message} Found ${res.length} instead.`
: `${message} Parent not found.`;
if (this.parentContains) {
if (this.parentContains.successMessage) {
log(true, this.parentContains.successMessage);
} else {
this.parentContains.executeError();
}
}
log(false, message);
this.def?.reject(new Error(message));
for (const childContains of this.childrenContains || []) {
if (childContains.successMessage) {
log(true, childContains.successMessage);
} else {
childContains.executeError();
}
}
};
if (crashOnFail) {
this.executeError();
}
}
}
/**
* Executes the action(s) given to this constructor on the found element,
* prints the success messages, and resolves the main deferred.
* @param {HTMLElement} el
*/
executeAction(el) {
let message = this.successMessage;
if (this.options.click) {
message = `${message} and clicked it`;
_click(el, undefined, {
mouseEventInit: this.options.click,
skipDisabledCheck: true,
skipVisibilityCheck: true,
});
}
if (this.options.dragenterFiles) {
message = `${message} and dragentered ${this.options.dragenterFiles.length} file(s)`;
const ev = new Event("dragenter", { bubbles: true });
Object.defineProperty(ev, "dataTransfer", {
value: createFakeDataTransfer(this.options.dragenterFiles),
});
el.dispatchEvent(ev);
}
if (this.options.dragoverFiles) {
message = `${message} and dragovered ${this.options.dragoverFiles.length} file(s)`;
const ev = new Event("dragover", { bubbles: true });
Object.defineProperty(ev, "dataTransfer", {
value: createFakeDataTransfer(this.options.dragoverFiles),
});
el.dispatchEvent(ev);
}
if (this.options.dropFiles) {
message = `${message} and dropped ${this.options.dropFiles.length} file(s)`;
const ev = new Event("drop", { bubbles: true });
Object.defineProperty(ev, "dataTransfer", {
value: createFakeDataTransfer(this.options.dropFiles),
});
el.dispatchEvent(ev);
}
if (this.options.inputFiles) {
message = `${message} and inputted ${this.options.inputFiles.length} file(s)`;
// could not use _createFakeDataTransfer as el.files assignation will only
// work with a real FileList object.
const dataTransfer = new window.DataTransfer();
for (const file of this.options.inputFiles) {
dataTransfer.items.add(file);
}
el.files = dataTransfer.files;
/**
* Changing files programatically is not supposed to trigger the event but
* it does in Chrome versions before 73 (which is on runbot), so in that
* case there is no need to make a manual dispatch, because it would lead to
* the files being added twice.
*/
const versionRaw = navigator.userAgent.match(/Chrom(e|ium)\/([0-9]+)\./);
const chromeVersion = versionRaw ? parseInt(versionRaw[2], 10) : false;
if (!chromeVersion || chromeVersion >= 73) {
el.dispatchEvent(new Event("change"));
}
}
if (this.options.insertText !== undefined) {
message = `${message} and inserted text "${this.options.insertText.content}" (replace: ${this.options.insertText.replace})`;
el.focus();
if (this.options.insertText.replace) {
el.value = "";
el.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Backspace" }));
el.dispatchEvent(new window.KeyboardEvent("keyup", { key: "Backspace" }));
el.dispatchEvent(new window.InputEvent("input"));
}
for (const char of this.options.insertText.content) {
el.value += char;
el.dispatchEvent(new window.KeyboardEvent("keydown", { key: char }));
el.dispatchEvent(new window.KeyboardEvent("keyup", { key: char }));
el.dispatchEvent(new window.InputEvent("input"));
}
el.dispatchEvent(new window.InputEvent("change"));
}
if (this.options.pasteFiles) {
message = `${message} and pasted ${this.options.pasteFiles.length} file(s)`;
const ev = new Event("paste", { bubbles: true });
Object.defineProperty(ev, "clipboardData", {
value: createFakeDataTransfer(this.options.pasteFiles),
});
el.dispatchEvent(ev);
}
if (this.options.setFocus) {
message = `${message} and focused it`;
el.focus();
}
if (this.options.setScroll !== undefined) {
message = `${message} and set scroll to "${this.options.setScroll}"`;
el.scrollTop =
this.options.setScroll === "bottom" ? el.scrollHeight : this.options.setScroll;
}
if (this.options.triggerEvents) {
message = `${message} and triggered "${this.options.triggerEvents.join(", ")}" events`;
_triggerEvents(el, null, this.options.triggerEvents, {
skipVisibilityCheck: true,
});
}
if (this.parentContains) {
log(true, this.parentContains.successMessage);
}
log(true, message);
for (const childContains of this.childrenContains) {
log(true, childContains.successMessage);
}
this.def?.resolve();
}
/**
* Returns the found element(s) according to this constructor setup.
* If undefined is returned it means the parent cannot be found
*
* @returns {HTMLElement[]|undefined}
*/
select() {
const target = this.selectParent();
if (!target) {
return;
}
let elems;
if (target === getFixture() && queryFirst(this.selector) === target) {
elems = [target];
} else {
elems = queryAll(this.selector, { root: target });
}
const baseRes = elems
.map((el) => (this.options.shadowRoot ? el.shadowRoot : el))
.filter((el) => el);
/** @type {Contains[]} */
this.childrenContains = [];
const res = baseRes.filter((el, currentIndex) => {
let condition =
(this.options.textContent === undefined ||
el.textContent.trim() === this.options.textContent) &&
(this.options.value === undefined || el.value === this.options.value) &&
(this.options.scroll === undefined ||
(this.options.scroll === "bottom"
? Math.abs(el.scrollHeight - el.clientHeight - el.scrollTop) <= 1
: Math.abs(el.scrollTop - this.options.scroll) <= 1));
if (condition && this.options.text !== undefined) {
if (
el.textContent.trim() !== this.options.text &&
[...el.querySelectorAll("*")].every(
(el) => el.textContent.trim() !== this.options.text
)
) {
condition = false;
}
}
if (condition && this.options.contains) {
for (const param of this.options.contains) {
const childContains = new Contains(param[0], { ...param[1], target: el });
if (
!childContains.runOnce(`as child of el ${currentIndex + 1})`, {
executeOnSuccess: false,
})
) {
condition = false;
}
this.childrenContains.push(childContains);
}
}
if (condition && this.options.visible !== undefined) {
if (isVisible(el) !== this.options.visible) {
condition = false;
}
}
if (condition && this.options.after) {
const afterContains = new Contains(this.options.after[0], {
...this.options.after[1],
target,
});
const afterEl = afterContains.runOnce(`as "after"`, {
executeOnSuccess: false,
})?.[0];
if (
!afterEl ||
!(el.compareDocumentPosition(afterEl) & Node.DOCUMENT_POSITION_PRECEDING)
) {
condition = false;
}
this.childrenContains.push(afterContains);
}
if (condition && this.options.before) {
const beforeContains = new Contains(this.options.before[0], {
...this.options.before[1],
target,
});
const beforeEl = beforeContains.runOnce(`as "before"`, {
executeOnSuccess: false,
})?.[0];
if (
!beforeEl ||
!(el.compareDocumentPosition(beforeEl) & Node.DOCUMENT_POSITION_FOLLOWING)
) {
condition = false;
}
this.childrenContains.push(beforeContains);
}
return condition;
});
if (
this.options.scroll !== undefined &&
this.scrollListeners &&
baseRes.length === this.options.count &&
res.length !== this.options.count
) {
for (const el of baseRes) {
if (!this.scrollListeners.has(el)) {
this.scrollListeners.add(el);
el.addEventListener("scroll", this.onScroll);
}
}
}
return res;
}
/**
* Returns the found element that should act as the target (parent) for the
* main selector.
* If undefined is returned it means the parent cannot be found.
*
* @returns {HTMLElement|undefined}
*/
selectParent() {
if (this.options.parent) {
this.parentContains = new Contains(this.options.parent[0], {
...this.options.parent[1],
target: this.options.target,
});
return this.parentContains.runOnce(`as parent`, { executeOnSuccess: false })?.[0];
}
return this.options.target;
}
}
/**
* Waits until `count` elements matching the given `selector` are present in
* `options.target`.
*
* @param {string} selector
* @param {ContainsOptions} [options]
* @returns {Promise}
*/
export async function contains(selector, options) {
await new Contains(selector, options).run();
}