UNPKG

@material/web

Version:
737 lines 26.5 kB
/** * @license * Copyright 2021 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { defaultTransformPseudoClasses, getTransformedPseudoClass, transformPseudoClasses, } from './transform-pseudo-classes.js'; /** * Checks whether or not an element has a Harness attached to it on the * `element.harness` property. * * @param element The element to check. * @return True if the element has a harness property. */ export function isElementWithHarness(element) { return element.harness instanceof Harness; } /** * A test harness class that can be used to simulate interaction with an * element. * * @template E The harness's element type. */ export class Harness { /** * Creates a new harness for the given element. * * @param element The element that this harness controls. */ constructor(element) { /** * The pseudo classes that should be transformed for simulation. Component * subclasses may override this to add additional pseudo classes. */ this.transformPseudoClasses = defaultTransformPseudoClasses; /** * A set of elements that have already been patched to support transformed * pseudo classes. */ this.patchedElements = new WeakSet(); this.element = element; this.element.harness = this; } /** * Resets the element's simulated classes to the default state. */ async reset() { const element = await this.getInteractiveElement(); for (const pseudoClass of this.transformPseudoClasses) { this.forEachNodeFrom(element, (el) => { this.removePseudoClass(el, pseudoClass); }); } } /** * Hovers and clicks on an element. This will generate a `click` event. * * @param init Additional event options. */ async clickWithMouse(init = {}) { await this.startClickWithMouse(init); await this.endClickWithMouse(init); } /** * Begins a click with a mouse. Use this along with `endClickWithMouse()` to * customize the length of the click. * * @param init Additional event options. */ async startClickWithMouse(init = {}) { const element = await this.getInteractiveElement(); await this.startHover(); this.simulateMousePress(element, init); } /** * Finishes a click with a mouse. Use this along with `startClickWithMouse()` * to customize the length of the click. This will generate a `click` event. * * @param init Additional event options. */ async endClickWithMouse(init = {}) { const element = await this.getInteractiveElement(); this.simulateMouseRelease(element, init); if ((init?.button ?? 0) === 0) { // Dispatch a click for left-click only (default). this.simulateClick(element, init); } } /** * Clicks an element with the keyboard (defaults to spacebar). This will * generate a `click` event. * * @param init Additional event options. */ async clickWithKeyboard(init = {}) { const element = await this.getInteractiveElement(); await this.startClickWithKeyboard(init); await this.endClickWithKeyboard(init); this.simulateClick(element, init); } /** * Begins a click with the keyboard (defaults to spacebar). Use this along * with `endClickWithKeyboard()` to customize the length of the click. * * @param init Additional event options. */ async startClickWithKeyboard(init = {}) { const element = await this.getInteractiveElement(); await this.focusWithKeyboard(init); this.simulateKeydown(element, init.key ?? ' ', init); this.simulateClick(element, init); } /** * Finishes a click with the keyboard (defaults to spacebar). Use this along * with `startClickWithKeyboard()` to customize the length of the click. * * @param init Additional event options. */ async endClickWithKeyboard(init = {}) { const element = await this.getInteractiveElement(); this.simulateKeyup(element, init.key ?? ' ', init); this.simulateClick(element, init); } /** * Right-clicks and opens a context menu. This will generate a `contextmenu` * event. */ async rightClickWithMouse() { const element = await this.getInteractiveElement(); const rightMouseButton = { button: 2, buttons: 2 }; await this.startClickWithMouse(rightMouseButton); // Note: contextmenu right clicks do not generate the up events this.simulateContextmenu(element, rightMouseButton); } /** * Taps once on the element with a simulated touch. This will generate a * `click` event. * * @param init Additional event options. * @param touchInit Additional touch event options. */ async tap(init = {}, touchInit = {}) { const element = await this.getInteractiveElement(); this.simulateTouchPress(element, init, touchInit); this.simulateTouchRelease(element, init, touchInit); if ((init?.isPrimary ?? true) === true) { // Dispatch a click for primary touches only (default). await this.endTapClick(init); } } /** * Begins a touch tap. Use this along with `endTap()` to customize the length * or number of taps. * * @param init Additional event options. * @param touchInit Additional touch event options. */ async startTap(init = {}, touchInit = {}) { const element = await this.getInteractiveElement(); this.simulateTouchPress(element, init, touchInit); } /** * Simulates a `contextmenu` event for touch. Use this along with `startTap()` * to generate a tap-and-hold context menu interaction. * * @param init Additional event options. */ async startTapContextMenu(init = {}) { const element = await this.getInteractiveElement(); this.simulateContextmenu(element, init); } /** * Finished a touch tap. Use this along with `startTap()` to customize the * length or number of taps. * * This will NOT generate a `click` event. * * @param init Additional event options. * @param touchInit Additional touch event options. */ async endTap(init = {}, touchInit = {}) { const element = await this.getInteractiveElement(); this.simulateTouchRelease(element, init, touchInit); } /** * Simulates a `click` event for touch. Use this along with `endTap()` to * control the timing of tap and click events. * * @param init Additional event options. */ async endTapClick(init = {}) { const element = await this.getInteractiveElement(); this.simulateClick(element, { pointerType: 'touch', ...init, }); } /** * Cancels a touch tap. * * @param init Additional event options. * @param touchInit Additional touch event options. */ async cancelTap(init = {}, touchInit = {}) { const element = await this.getInteractiveElement(); this.simulateTouchCancel(element, init, touchInit); } /** * Hovers over the element with a simulated mouse. */ async startHover() { const element = await this.getInteractiveElement(); this.simulateStartHover(element); } /** * Moves the simulated mouse cursor off of the element. */ async endHover() { const element = await this.getInteractiveElement(); this.simulateEndHover(element); } /** * Simulates focusing an element with the keyboard. * * @param init Additional event options. */ async focusWithKeyboard(init = {}) { const element = await this.getInteractiveElement(); this.simulateKeyboardFocus(element); } /** * Simulates focusing an element with a pointer. */ async focusWithPointer() { const element = await this.getInteractiveElement(); await this.startHover(); this.simulatePointerFocus(element); } /** * Simulates unfocusing an element. */ async blur() { const element = await this.getInteractiveElement(); await this.endHover(); this.simulateBlur(element); } /** * Simulates a keypress on an element. * * @param key The key to press. * @param init Additional event options. */ async keypress(key, init = {}) { const element = await this.getInteractiveElement(); this.simulateKeypress(element, key, init); } /** * Simulates submitting the element's associated form element. * * @param form (Optional) form to submit, defaults to the elemnt's form. * @return The submitted form data or null if the element has no associated * form. */ submitForm(form = this.element.form) { if (!form) { return new FormData(); } return new Promise((resolve) => { const submitListener = (event) => { event.preventDefault(); const data = new FormData(form); resolve(data); return false; }; form.addEventListener('submit', submitListener, { once: true }); form.requestSubmit(); }); } /** * Returns the element that should be used for interaction simulation. * Defaults to the host element itself. * * Subclasses should override this if the interactive element is not the host. * * @return The element to use in simulation. */ async getInteractiveElement() { return this.element; } /** * Adds a pseudo class to an element. The element's shadow root styles (or * document if not in a shadow root) will be transformed to support * simulated pseudo classes. * * @param element The element to add a pseudo class to. * @param pseudoClass The pseudo class to add. */ addPseudoClass(element, pseudoClass) { if (!this.transformPseudoClasses.includes(pseudoClass)) { return; } const root = element.getRootNode(); if (element.shadowRoot) { transformPseudoClasses(element.shadowRoot.adoptedStyleSheets || [], this.transformPseudoClasses); } transformPseudoClasses(root.styleSheets, this.transformPseudoClasses); transformPseudoClasses(root.adoptedStyleSheets || [], this.transformPseudoClasses); element.classList.add(getTransformedPseudoClass(pseudoClass)); this.patchForTransformedPseudoClasses(element); } /** * Removes a pseudo class from an element. * * @param element The element to remove a pseudo class from. * @param pseudoClass The pseudo class to remove. */ removePseudoClass(element, pseudoClass) { element.classList.remove(getTransformedPseudoClass(pseudoClass)); } /** * Simulates a click event. * * @param element The element to click. * @param init Additional event options. */ simulateClick(element, init = {}) { // Firefox does not support some simulations with PointerEvents, such as // selecting an <input type="checkbox">. Use MouseEvent for browser support. element.dispatchEvent(new MouseEvent('click', { ...this.createMouseEventInit(element), ...init, })); } /** * Simulates a contextmenu event. * * @param element The element to generate an event for. * @param init Additional event options. */ simulateContextmenu(element, init = {}) { element.dispatchEvent(new MouseEvent('contextmenu', { ...this.createMouseEventInit(element), button: 2, buttons: 2, ...init, })); } /** * Simulates focusing with a keyboard. The difference between this and * `simulatePointerFocus` is that keyboard focus will include the * `:focus-visible` pseudo class. * * @param element The element to focus with a keyboard. */ simulateKeyboardFocus(element) { this.simulateKeydown(element.ownerDocument, 'Tab'); this.addPseudoClass(element, ':focus-visible'); this.simulatePointerFocus(element); this.simulateKeyup(element, 'Tab'); } /** * Simulates focusing with a pointer. * * @param element The element to focus with a pointer. */ simulatePointerFocus(element) { this.addPseudoClass(element, ':focus'); this.forEachNodeFrom(element, (el) => { this.addPseudoClass(el, ':focus-within'); }); element.dispatchEvent(new FocusEvent('focus', { composed: true })); element.dispatchEvent(new FocusEvent('focusin', { bubbles: true, composed: true })); } /** * Simulates unfocusing an element. * * @param element The element to blur. */ simulateBlur(element) { this.removePseudoClass(element, ':focus'); this.removePseudoClass(element, ':focus-visible'); this.forEachNodeFrom(element, (el) => { this.removePseudoClass(el, ':focus-within'); }); element.dispatchEvent(new FocusEvent('blur', { composed: true })); element.dispatchEvent(new FocusEvent('focusout', { bubbles: true, composed: true })); } /** * Simulates a mouse pointer hovering over an element. * * @param element The element to hover over. * @param init Additional event options. */ simulateStartHover(element, init = {}) { this.forEachNodeFrom(element, (el) => { this.addPseudoClass(el, ':hover'); }); const rect = element.getBoundingClientRect(); const mouseInit = this.createMouseEventInit(element); const mouseEnterInit = { ...mouseInit, bubbles: false, clientX: rect.left, clientY: rect.top, screenX: rect.left, screenY: rect.top, }; const pointerInit = { ...mouseInit, isPrimary: true, pointerType: 'mouse', }; const pointerEnterInit = { ...pointerInit, ...mouseEnterInit, ...init, }; element.dispatchEvent(new PointerEvent('pointerover', pointerInit)); element.dispatchEvent(new PointerEvent('pointerenter', pointerEnterInit)); element.dispatchEvent(new MouseEvent('mouseover', mouseInit)); element.dispatchEvent(new MouseEvent('mouseenter', mouseEnterInit)); } /** * Simulates a mouse pointer leaving the element. * * @param element The element to stop hovering over. * @param init Additional event options. */ simulateEndHover(element, init = {}) { this.forEachNodeFrom(element, (el) => { this.removePseudoClass(el, ':hover'); }); const rect = element.getBoundingClientRect(); const mouseInit = this.createMouseEventInit(element); const mouseLeaveInit = { ...mouseInit, bubbles: false, clientX: rect.left - 1, clientY: rect.top - 1, screenX: rect.left - 1, screenY: rect.top - 1, }; const pointerInit = { ...mouseInit, isPrimary: true, pointerType: 'mouse', ...init, }; const pointerLeaveInit = { ...pointerInit, ...mouseLeaveInit, }; element.dispatchEvent(new PointerEvent('pointerout', pointerInit)); element.dispatchEvent(new PointerEvent('pointerleave', pointerLeaveInit)); element.dispatchEvent(new MouseEvent('pointerout', mouseInit)); element.dispatchEvent(new MouseEvent('mouseleave', mouseLeaveInit)); } /** * Simulates a mouse press and hold on an element. * * @param element The element to press with a mouse. * @param init Additional event options. */ simulateMousePress(element, init = {}) { this.addPseudoClass(element, ':active'); this.forEachNodeFrom(element, (el) => { this.addPseudoClass(el, ':active'); }); const mouseInit = this.createMouseEventInit(element); const pointerInit = { ...mouseInit, isPrimary: true, pointerType: 'mouse', ...init, }; element.dispatchEvent(new PointerEvent('pointerdown', pointerInit)); element.dispatchEvent(new MouseEvent('mousedown', mouseInit)); this.simulatePointerFocus(element); } /** * Simulates a mouse press release from an element. * * @param element The element to release pressing from. * @param init Additional event options. */ simulateMouseRelease(element, init = {}) { this.removePseudoClass(element, ':active'); this.forEachNodeFrom(element, (el) => { this.removePseudoClass(el, ':active'); }); const mouseInit = this.createMouseEventInit(element); const pointerInit = { ...mouseInit, isPrimary: true, pointerType: 'mouse', ...init, }; element.dispatchEvent(new PointerEvent('pointerup', pointerInit)); element.dispatchEvent(new MouseEvent('mouseup', mouseInit)); } /** * Simulates a touch press and hold on an element. * * @param element The element to press with a touch pointer. * @param init Additional event options. */ simulateTouchPress(element, init = {}, touchInit = {}) { this.addPseudoClass(element, ':active'); this.forEachNodeFrom(element, (el) => { this.addPseudoClass(el, ':active'); }); const mouseInit = this.createMouseEventInit(element); const pointerInit = { ...mouseInit, isPrimary: true, pointerType: 'touch', ...init, }; element.dispatchEvent(new PointerEvent('pointerdown', pointerInit)); // Firefox does not support TouchEvent constructor if (window.TouchEvent) { const touch = this.createTouch(element); element.dispatchEvent(new TouchEvent('touchstart', { touches: [touch], targetTouches: [touch], changedTouches: [touch], ...touchInit, })); } this.simulatePointerFocus(element); } /** * Simulates a touch press release from an element. * * @param element The element to release pressing from. * @param init Additional event options. */ simulateTouchRelease(element, init = {}, touchInit = {}) { this.removePseudoClass(element, ':active'); this.forEachNodeFrom(element, (el) => { this.removePseudoClass(el, ':active'); }); const mouseInit = this.createMouseEventInit(element); const pointerInit = { ...mouseInit, isPrimary: true, pointerType: 'touch', ...init, }; element.dispatchEvent(new PointerEvent('pointerup', pointerInit)); // Firefox does not support TouchEvent constructor if (window.TouchEvent) { const touch = this.createTouch(element); element.dispatchEvent(new TouchEvent('touchend', { changedTouches: [touch], ...touchInit })); } } /** * Simulates a touch cancel from an element. * * @param element The element to cancel a touch for. * @param init Additional event options. */ simulateTouchCancel(element, init = {}, touchInit = {}) { this.removePseudoClass(element, ':active'); this.forEachNodeFrom(element, (el) => { this.removePseudoClass(el, ':active'); }); const mouseInit = this.createMouseEventInit(element); const pointerInit = { ...mouseInit, isPrimary: true, pointerType: 'touch', ...init, }; element.dispatchEvent(new PointerEvent('pointercancel', pointerInit)); // Firefox does not support TouchEvent constructor if (window.TouchEvent) { const touch = this.createTouch(element); element.dispatchEvent(new TouchEvent('touchcancel', { changedTouches: [touch], ...touchInit })); } } /** * Simulates a keypress on an element. * * @param element The element to press a key on. * @param key The key to press. * @param init Additional event options. */ simulateKeypress(element, key, init = {}) { this.simulateKeydown(element, key, init); this.simulateKeyup(element, key, init); } /** * Simulates a keydown press on an element. * * @param element The element to press a key on. * @param key The key to press. * @param init Additional event options. */ simulateKeydown(element, key, init = {}) { element.dispatchEvent(new KeyboardEvent('keydown', { ...init, key, bubbles: true, composed: true, cancelable: true, })); } /** * Simulates a keyup release from an element. * * @param element The element to release a key from. * @param key The key to release. * @param init Additional keyboard options. */ simulateKeyup(element, key, init = {}) { element.dispatchEvent(new KeyboardEvent('keyup', { ...init, key, bubbles: true, composed: true, cancelable: true, })); } /** * Creates a MouseEventInit for an element. The default x/y coordinates of the * event init will be in the center of the element. * * @param element The element to create a `MouseEventInit` for. * @return The init object for a `MouseEvent`. */ createMouseEventInit(element) { const rect = element.getBoundingClientRect(); return { bubbles: true, cancelable: true, composed: true, clientX: (rect.left + rect.right) / 2, clientY: (rect.top + rect.bottom) / 2, screenX: (rect.left + rect.right) / 2, screenY: (rect.top + rect.bottom) / 2, // Primary button (usually the left button) button: 0, buttons: 1, }; } /** * Creates a Touch instance for an element. The default x/y coordinates of the * touch will be in the center of the element. This can be used in the * `TouchEvent` constructor. * * @param element The element to create a touch for. * @param identifier Optional identifier for the touch. Defaults to 0 for * every touch instance. * @return The `Touch` instance. */ createTouch(element, identifier = 0) { const rect = element.getBoundingClientRect(); return new Touch({ identifier, target: element, clientX: (rect.left + rect.right) / 2, clientY: (rect.top + rect.bottom) / 2, screenX: (rect.left + rect.right) / 2, screenY: (rect.top + rect.bottom) / 2, pageX: (rect.left + rect.right) / 2, pageY: (rect.top + rect.bottom) / 2, touchType: 'direct', }); } /** * Visit each node up the parent tree from the given child until reaching the * given parent. * * This is used to perform logic such as adding/removing recursive pseudo * classes like `:hover`. * * @param child The first child element to start from. * @param callback A callback that is invoked with each `HTMLElement` node * from the child to the parent. * @param parent The last parent element to visit. */ forEachNodeFrom(child, callback, parent = this.element) { let nextNode = child; while (nextNode && nextNode !== this.element) { const currentNode = nextNode; nextNode = currentNode.parentNode || currentNode.host; if (!(currentNode instanceof HTMLElement)) { continue; } callback(currentNode); if (nextNode instanceof HTMLElement && nextNode.shadowRoot) { const slot = currentNode.getAttribute('slot'); const slotSelector = slot ? `slot[name=${slot}]` : 'slot:not([name])'; const slotElement = nextNode.shadowRoot.querySelector(slotSelector); if (slotElement) { this.forEachNodeFrom(slotElement, callback, nextNode); } } } callback(parent); } /** * Patch an element's methods, such as `querySelector` and `matches` to * handle transformed pseudo classes. * * For example, `element.matches(':focus')` will return true when the * `._focus` class is applied. * * @param element The element to patch. */ patchForTransformedPseudoClasses(element) { if (this.patchedElements.has(element)) { return; } // Patch functions to handle pseudo selectors. const getSelector = (selector) => { if (this.transformPseudoClasses.includes(selector)) { return `.${getTransformedPseudoClass(selector)}`; } return selector; }; const superMatches = this.element.matches; element.matches = (selector) => { return superMatches.call(element, getSelector(selector)); }; const superQuerySelector = this.element.querySelector; element.querySelector = (selector) => { return superQuerySelector.call(element, getSelector(selector)); }; const superQuerySelectorAll = this.element.querySelectorAll; element.querySelectorAll = (selector) => { return superQuerySelectorAll.call(element, getSelector(selector)); }; this.patchedElements.add(element); } } //# sourceMappingURL=harness.js.map