UNPKG

coveo-search-ui

Version:

Coveo JavaScript Search Framework

913 lines (817 loc) • 27.4 kB
import { each, isString, isArray, contains } from 'underscore'; import { Assert } from '../misc/Assert'; import { Logger } from '../misc/Logger'; import { IStringMap } from '../rest/GenericParam'; import { JQueryUtils } from '../utils/JQueryutils'; import { Utils } from '../utils/Utils'; import { DeviceUtils } from './DeviceUtils'; export interface IOffset { left: number; top: number; } /** * This is essentially a helper class for dom manipulation.<br/> * This is intended to provide some basic functionality normally offered by jQuery.<br/> * To minimize the multiple jQuery conflict we have while integrating in various system, we implemented the very small subset that the framework needs.<br/> * See {@link $$}, which is a function that wraps this class constructor, for less verbose code. */ export class Dom { private static CLASS_NAME_REGEX = /-?[_a-zA-Z]+[_a-zA-Z0-9-]*/g; private static ONLY_WHITE_SPACE_REGEX = /^\s*$/; /** * Whether to always register, remove, and trigger events using standard JavaScript rather than attempting to use jQuery first. * @type boolean */ public static useNativeJavaScriptEvents = false; public el: HTMLElement; /** * Create a new Dom object with the given HTMLElement * @param el The HTMLElement to wrap in a Dom object */ constructor(el: HTMLElement) { Assert.exists(el); this.el = el; } private static handlers: WeakMap<(evt: Event, data: any) => void, (e: CustomEvent) => void> = new WeakMap(); /** * Helper function to quickly create an HTMLElement * @param type The type of the element (e.g. div, span) * @param props The props (id, className, attributes) of the element<br/> * Can be either specified in dashed-case strings ('my-attribute') or camelCased keys (myAttribute), * the latter of which will automatically get replaced to dash-case. * @param innerHTML The contents of the new HTMLElement, either in string form or as another HTMLElement */ static createElement(type: string, props?: Object, ...children: Array<string | HTMLElement | Dom>): HTMLElement { const elem: HTMLElement = document.createElement(type); for (const key in props) { if (key === 'className') { elem.className = props['className']; } else { const attr = key.indexOf('-') !== -1 ? key : Utils.toDashCase(key); elem.setAttribute(attr, props[key]); } } each(children, (child: string | HTMLElement | Dom) => { if (child instanceof HTMLElement) { elem.appendChild(child); } else if (isString(child)) { elem.innerHTML += child; } else if (child instanceof Dom) { elem.appendChild(child.el); } }); return elem; } /** * Adds the element to the children of the current element * @param element The element to append * @returns {string} */ public append(element: HTMLElement) { this.el.appendChild(element); } /** * Get the css value of the specified property.<br/> * @param property The property * @returns {string} */ public css(property: string): string { if (this.el.style[property]) { return this.el.style[property]; } return window.getComputedStyle(this.el).getPropertyValue(property); } /** * Get or set the text content of the HTMLElement.<br/> * @param txt Optional. If given, this will set the text content of the element. If not, will return the text content. * @returns {string} */ public text(txt?: string): string { if (Utils.isUndefined(txt)) { return this.el.innerText || this.el.textContent; } else { if (this.el.innerText != undefined) { this.el.innerText = txt; } else if (this.el.textContent != undefined) { this.el.textContent = txt; } } } /** * Performant way to transform a NodeList to an array of HTMLElement, for manipulation<br/> * http://jsperf.com/nodelist-to-array/72 * @param nodeList a {NodeList} to convert to an array * @returns {HTMLElement[]} */ public static nodeListToArray(nodeList: NodeList): HTMLElement[] { let i = nodeList.length; const arr: HTMLElement[] = new Array(i); while (i--) { arr[i] = <HTMLElement>nodeList.item(i); } return arr; } /** * Focuses on an element. * @param preserveScroll Whether or not to scroll the page to the focused element. */ public focus(preserveScroll: boolean) { if (DeviceUtils.getDeviceName() === 'IE') { const { pageXOffset, pageYOffset } = window; this.el.focus(); if (preserveScroll) { window.scrollTo(pageXOffset, pageYOffset); } } else { (this.el as any).focus({ preventScroll: preserveScroll }); } } /** * Empty (remove all child) from the element; */ public empty(): void { while (this.el.firstChild) { this.removeChild(this.el.firstChild); } } public removeChild(child: Node) { const oldParent = child.parentNode; try { this.el.removeChild(child); } catch (e) { if ((e as Error).name !== 'NotFoundError') { throw e; } if (oldParent === child.parentNode) { throw e; } } } /** * Empty the element and all childs from the dom; */ public remove(): void { if (this.el.parentNode) { this.el.parentNode.removeChild(this.el); } } /** * Show the element by setting display to block; */ public show(): void { this.el.style.display = 'block'; $$(this.el).setAttribute('aria-hidden', 'false'); } /** * Hide the element; */ public hide(): void { this.el.style.display = 'none'; $$(this.el).setAttribute('aria-hidden', 'true'); } /** * Show the element by setting display to an empty string. */ public unhide(): void { this.el.style.display = ''; $$(this.el).setAttribute('aria-hidden', 'false'); } /** * Toggle the element visibility.<br/> * Optional visible parameter, if specified will set the element visibility * @param visible Optional parameter to display or hide the element */ public toggle(visible?: boolean): void { if (visible === undefined) { if (this.el.style.display == 'block') { this.hide(); } else { this.show(); } } else { if (visible) { this.show(); } else { this.hide(); } } } /** * Tries to determine if an element is "visible", in a generic manner. * * This is not meant to be a "foolproof" method, but only a superficial "best effort" detection is performed. */ public isVisible() { if (this.css('display') === 'none') { return false; } if (this.css('visibility') === 'hidden') { return false; } if (this.hasClass('coveo-tab-disabled')) { return false; } if (this.hasClass('coveo-hidden')) { return false; } if (this.hasClass('coveo-hidden-dependant-facet')) { return false; } return true; } /** * Returns the value of the specified attribute. * @param name The name of the attribute */ public getAttribute(name: string): string { return this.el.getAttribute(name); } /** * Sets the value of the specified attribute. * @param name The name of the attribute * @param value The value to set */ public setAttribute(name: string, value: string) { this.el.setAttribute(name, value); } /** * Find a child element, given a CSS selector * @param selector A CSS selector, can be a .className or #id * @returns {HTMLElement} */ public find(selector: string): HTMLElement { return <HTMLElement>this.el.querySelector(selector); } /** * Check if the element match the selector.<br/> * The selector can be a class, an id or a tag.<br/> * Eg : .is('.foo') or .is('#foo') or .is('div'). */ public is(selector: string): boolean { if (this.el.tagName.toLowerCase() == selector.toLowerCase()) { return true; } if (selector[0] == '.') { if (this.hasClass(selector.substr(1))) { return true; } } if (selector[0] == '#') { if (this.el.getAttribute('id') == selector.substr(1)) { return true; } } return false; } /** * Get the first element that matches the classname by testing the element itself and traversing up through its ancestors in the DOM tree. * * Stops at the body of the document * @param className A CSS classname */ public closest(className: string): HTMLElement { return this.traverseAncestorForClass(this.el, className); } /** * Get the first element that matches the classname by testing the element itself and traversing up through its ancestors in the DOM tree. * * Stops at the body of the document * @returns {any} */ public parent(className: string): HTMLElement { if (this.el.parentElement == undefined) { return undefined; } return this.traverseAncestorForClass(this.el.parentElement, className); } /** * Get all the ancestors of the current element that match the given className * * Return an empty array if none found. * @param className * @returns {HTMLElement[]} */ public parents(className: string): HTMLElement[] { const parentsFound = []; let parentFound = this.parent(className); while (parentFound) { parentsFound.push(parentFound); parentFound = new Dom(parentFound).parent(className); } return parentsFound; } /** * Return all children * @returns {HTMLElement[]} */ public children(): HTMLElement[] { return Dom.nodeListToArray(this.el.children); } /** * Return all siblings * @returns {HTMLElement[]} */ public siblings(selector: string): HTMLElement[] { const sibs = []; let currentElement = <HTMLElement>this.el.parentNode.firstChild; for (; currentElement; currentElement = <HTMLElement>currentElement.nextSibling) { if (currentElement != this.el) { if (this.matches(currentElement, selector) || !selector) { sibs.push(currentElement); } } } return sibs; } private matches(element: HTMLElement, selector: string) { const all = document.querySelectorAll(selector); for (let i = 0; i < all.length; i++) { if (all[i] === element) { return true; } } return false; } /** * Find all children that match the given CSS selector * @param selector A CSS selector, can be a .className * @returns {HTMLElement[]} */ public findAll(selector: string): HTMLElement[] { return Dom.nodeListToArray(this.el.querySelectorAll(selector)); } /** * Find the child elements using a className * @param className Class of the childs elements to find * @returns {HTMLElement[]} */ public findClass(className: string): HTMLElement[] { if ('getElementsByClassName' in this.el) { return Dom.nodeListToArray(this.el.getElementsByClassName(className)); } } /** * Find an element using an ID * @param id ID of the element to find * @returns {HTMLElement} */ public findId(id: string): HTMLElement { return document.getElementById(id); } /** * Add a class to the element. Takes care of not adding the same class if the element already has it. * @param className Classname to add to the element */ public addClass(classNames: string[]): void; public addClass(className: string): void; public addClass(className: any): void { if (isArray(className)) { each(className, (name: string) => { this.addClass(name); }); } else { if (!this.hasClass(className)) { if (this.el.className) { this.el.className += ' ' + className; } else { this.el.className = className; } } } } /** * Remove the class on the element. Works even if the element does not possess the class. * @param className Classname to remove on the the element */ public removeClass(className: string): void { this.el.className = this.el.className.replace(new RegExp(`(^|\\s)${className}(\\s|$)`, 'g'), '$1').trim(); } /** * Toggle the class on the element. * @param className Classname to toggle * @param swtch If true, add the class regardless and if false, remove the class */ public toggleClass(className: string, swtch?: boolean): void { if (Utils.isNullOrUndefined(swtch)) { if (this.hasClass(className)) { this.removeClass(className); } else { this.addClass(className); } } else { if (swtch) { this.addClass(className); } else { this.removeClass(className); } } } /** * Sets the inner html of the element * @param html The html to set */ public setHtml(html: string) { this.el.innerHTML = html; } /** * Return an array with all the classname on the element. Empty array if the element has not classname * @returns {any|Array} */ public getClass(): string[] { // SVG elements got a className property, but it's not a string, it's an object const className = this.getAttribute('class'); if (className && className.match) { return className.match(Dom.CLASS_NAME_REGEX) || []; } else { return []; } } /** * Check if the element has the given class name * @param className Classname to verify * @returns {boolean} */ public hasClass(className: string): boolean { return contains(this.getClass(), className); } /** * Detach the element from the DOM. */ public detach(): void { this.el.parentElement && this.el.parentElement.removeChild(this.el); } /** * Insert the current node after the given reference node * @param refNode */ public insertAfter(refNode: HTMLElement): void { refNode.parentNode && refNode.parentNode.insertBefore(this.el, refNode.nextSibling); } /** * Insert the current node before the given reference node * @param refNode */ public insertBefore(refNode: HTMLElement): void { refNode.parentNode && refNode.parentNode.insertBefore(this.el, refNode); } /** * Insert the given node as the first child of the current node * @param toPrepend */ public prepend(toPrepend: HTMLElement) { if (this.el.firstChild) { new Dom(toPrepend).insertBefore(<HTMLElement>this.el.firstChild); } else { this.el.appendChild(toPrepend); } } /** * Bind an event handler on the element. Accepts either one (a string) or multiple (Array<String>) event type.<br/> * @param types The {string} or {Array<String>} of types on which to bind an event handler * @param eventHandle The function to execute when the event is triggered */ public on(types: string[], eventHandle: (evt: Event, data: any) => void): void; public on(type: string, eventHandle: (evt: Event, data: any) => void): void; public on(type: any, eventHandle: (evt: Event, data: any) => void): void { if (isArray(type)) { each(type, (t: string) => { this.on(t, eventHandle); }); } else { const modifiedType = this.processEventTypeToBeJQueryCompatible(type); const jq = JQueryUtils.getJQuery(); if (this.shouldUseJQueryEvent()) { jq(this.el).on(modifiedType, eventHandle); } else if (this.el.addEventListener) { const fn = (e: CustomEvent) => { eventHandle(e, e.detail); }; Dom.handlers.set(eventHandle, fn); // Mark touch events as passive for performance reasons: // https://github.com/WICG/EventListenerOptions/blob/gh-pages/explainer.md if (modifiedType && modifiedType.indexOf('touch') != -1) { this.el.addEventListener(modifiedType, fn, { passive: true }); } else { this.el.addEventListener(modifiedType, fn, false); } } else if (this.el['on']) { this.el['on']('on' + modifiedType, eventHandle); } } } /** * Bind an event handler on the element. Accepts either one (a string) or multiple (Array<String>) event type.<br/> * The event handler will execute only ONE time. * @param types The {string} or {Array<String>} of types on which to bind an event handler * @param eventHandle The function to execute when the event is triggered */ public one(types: string[], eventHandle: (evt: Event, args?: any) => void): void; public one(type: string, eventHandle: (evt: Event, args?: any) => void): void; public one(type: any, eventHandle: (evt: Event, args?: any) => void): void { if (isArray(type)) { each(type, (t: string) => { this.one(t, eventHandle); }); } else { const modifiedType = this.processEventTypeToBeJQueryCompatible(type); const once = (e: Event, args: any) => { this.off(modifiedType, once); return eventHandle(e, args); }; this.on(modifiedType, once); } } /** * Remove an event handler on the element. Accepts either one (a string) or multiple (Array<String>) event type.<br/> * @param types The {string} or {Array<String>} of types on which to remove an event handler * @param eventHandle The function to remove on the element */ public off(types: string[], eventHandle: (evt: Event, arg?: any) => void): void; public off(type: string, eventHandle: (evt: Event, arg?: any) => void): void; public off(type: any, eventHandle: (evt: Event, arg?: any) => void): void { if (isArray(type)) { each(type, (t: string) => { this.off(t, eventHandle); }); } else { const modifiedType = this.processEventTypeToBeJQueryCompatible(type); const jq = JQueryUtils.getJQuery(); if (this.shouldUseJQueryEvent()) { jq(this.el).off(modifiedType, eventHandle); } else if (this.el.removeEventListener) { const handler = Dom.handlers.get(eventHandle); if (handler) { this.el.removeEventListener(modifiedType, handler, false); } } else if (this.el['off']) { this.el['off']('on' + modifiedType, eventHandle); } } } /** * Trigger an event on the element. * @param type The event type to trigger * @param data */ public trigger(type: string, data?: { [key: string]: any }): void { const modifiedType = this.processEventTypeToBeJQueryCompatible(type); if (this.shouldUseJQueryEvent()) { JQueryUtils.getJQuery()(this.el).trigger(modifiedType, data); } else if (window['CustomEvent'] !== undefined) { const event = new CustomEvent(modifiedType, { detail: data, bubbles: true }); this.el.dispatchEvent(event); } else { try { this.el.dispatchEvent(this.buildIE11CustomEvent(modifiedType, data)); } catch { this.oldBrowserError(); } } } /** * Check if the element is "empty" (has no innerHTML content). Whitespace is considered empty</br> * @returns {boolean} */ public isEmpty(): boolean { return Dom.ONLY_WHITE_SPACE_REGEX.test(this.el.innerHTML); } /** * Check if the element is not a locked node (`{ toString(): string }`) and thus have base element properties. * @returns {boolean} */ public isValid(): boolean { return this.el != null && this.el.getAttribute != undefined; } /** * Check if the element is a descendant of parent * @param other */ public isDescendant(parent: HTMLElement): boolean { let node = this.el.parentNode; while (node != null) { if (node == parent) { return true; } node = node.parentNode; } return false; } /** * Replace the current element with the other element, then detach the current element * @param otherElem */ public replaceWith(otherElem: HTMLElement): void { const parent = this.el.parentNode; if (parent) { new Dom(otherElem).insertAfter(this.el); } this.detach(); } // based on http://api.jquery.com/position/ /** * Return the position relative to the offset parent. */ public position(): IOffset { const offsetParent = this.offsetParent(); const offset = this.offset(); let parentOffset: IOffset = { top: 0, left: 0 }; if (!$$(offsetParent).is('html')) { parentOffset = $$(offsetParent).offset(); } let borderTopWidth = parseInt($$(offsetParent).css('borderTopWidth')); let borderLeftWidth = parseInt($$(offsetParent).css('borderLeftWidth')); borderTopWidth = isNaN(borderTopWidth) ? 0 : borderTopWidth; borderLeftWidth = isNaN(borderLeftWidth) ? 0 : borderLeftWidth; parentOffset = { top: parentOffset.top + borderTopWidth, left: parentOffset.left + borderLeftWidth }; let marginTop = parseInt(this.css('marginTop')); let marginLeft = parseInt(this.css('marginLeft')); marginTop = isNaN(marginTop) ? 0 : marginTop; marginLeft = isNaN(marginLeft) ? 0 : marginLeft; return { top: offset.top - parentOffset.top - marginTop, left: offset.left - parentOffset.left - marginLeft }; } // based on https://api.jquery.com/offsetParent/ /** * Returns the offset parent. The offset parent is the closest parent that is positioned. * An element is positioned when its position property is not 'static', which is the default. */ public offsetParent(): HTMLElement { let offsetParent = this.el.offsetParent; while (offsetParent instanceof HTMLElement && $$(offsetParent).css('position') === 'static') { // Will break out if it stumbles upon an non-HTMLElement and return documentElement offsetParent = (<HTMLElement>offsetParent).offsetParent; } if (!(offsetParent instanceof HTMLElement)) { return document.documentElement; } return <HTMLElement>offsetParent; } // based on http://api.jquery.com/offset/ /** * Return the position relative to the document. */ public offset(): IOffset { // In <=IE11, calling getBoundingClientRect on a disconnected node throws an error if (!this.el.getClientRects().length) { return { top: 0, left: 0 }; } const rect = this.el.getBoundingClientRect(); if (rect.width || rect.height) { let doc = this.el.ownerDocument; let docElem = doc.documentElement; return { top: rect.top + window.pageYOffset - docElem.clientTop, left: rect.left + window.pageXOffset - docElem.clientLeft }; } return rect; } /** * Returns the offset width of the element */ public width(): number { return this.el.offsetWidth; } /** * Returns the offset height of the element */ public height(): number { return this.el.offsetHeight; } /** * Clone the node * @param deep true if the children of the node should also be cloned, or false to clone only the specified node. * @returns {Dom} */ public clone(deep = false): Dom { return $$(<HTMLElement>this.el.cloneNode(deep)); } /** * Determine if an element support a particular native DOM event. * @param eventName The event to evaluate. Eg: touchstart, touchend, click, scroll. */ public canHandleEvent(eventName: string): boolean { const eventToEvaluate = `on${eventName}`; let isSupported = eventToEvaluate in this.el; // This is a protection against false negative. // Some browser will incorrectly report that the event is not supported at this point // To make sure, we need to try and set a fake function as a property on the element, // and then check if it got hooked properly as a 'function' or as something else, meaning // the property is really not defined on the element. if (!isSupported && this.el.setAttribute) { this.el.setAttribute(eventToEvaluate, 'return;'); isSupported = typeof this.el[eventToEvaluate] == 'function'; this.el.removeAttribute(eventToEvaluate); } return isSupported; } private buildIE11CustomEvent(type: string, data?: { [key: string]: any }) { const event = document.createEvent('CustomEvent'); event.initCustomEvent(type, true, true, data); return event; } private shouldUseJQueryEvent() { return JQueryUtils.getJQuery() && !Dom.useNativeJavaScriptEvents; } private processEventTypeToBeJQueryCompatible(event: string): string { // From https://api.jquery.com/on/ // [...] // > In addition, the .trigger() method can trigger both standard browser event names and custom event names to call attached handlers. Event names should only contain alphanumerics, underscore, and colon characters. if (event) { return event.replace(/[^a-zA-Z0-9\:\_]/g, ''); } return event; } private traverseAncestorForClass(current = this.el, className: string): HTMLElement { if (className.indexOf('.') == 0) { className = className.substr(1); } let found = false; while (!found) { if ($$(current).hasClass(className)) { found = true; } if (current.tagName.toLowerCase() == 'body') { break; } if (current.parentElement == null) { break; } if (!found) { current = current.parentElement; } } if (found) { return current; } return undefined; } private oldBrowserError() { new Logger(this).error('CANNOT TRIGGER EVENT FOR OLDER BROWSER'); } } export class Win { constructor(public win: Window) {} public height(): number { return this.win.innerHeight; } public width(): number { return this.win.innerWidth; } public scrollY(): number { return this.supportPageOffset() ? this.win.pageYOffset : this.isCSS1Compat() ? this.win.document.documentElement.scrollTop : this.win.document.body.scrollTop; } public scrollX(): number { return this.supportPageOffset() ? window.pageXOffset : this.isCSS1Compat() ? document.documentElement.scrollLeft : document.body.scrollLeft; } private isCSS1Compat() { return (this.win.document.compatMode || '') === 'CSS1Compat'; } private supportPageOffset() { return this.win.pageXOffset !== undefined; } } export class Doc { constructor(public doc: Document) {} public height(): number { const body = this.doc.body; return Math.max(body.scrollHeight, body.offsetHeight); } public width(): number { const body = this.doc.body; return Math.max(body.scrollWidth, body.offsetWidth); } } /** * Convenience wrapper for the {@link Dom} class. Used to do $$(element).<br/> * If passed with an argument which is not an HTMLElement, it will call {@link Dom.createElement}. * @param el The HTMLElement to wrap in a Dom object * @param type See {@link Dom.createElement} * @param props See {@link Dom.createElement} * @param ...children See {@link Dom.createElement} */ export function $$(dom: Dom): Dom; export function $$(html: HTMLElement): Dom; export function $$(type: string, props?: IStringMap<any>, ...children: Array<string | HTMLElement | Dom>): Dom; export function $$(...args: any[]): Dom { if (args.length === 1 && args[0] instanceof Dom) { return args[0]; } else if (args.length === 1 && !isString(args[0])) { return new Dom(<HTMLElement>args[0]); } else { return new Dom(Dom.createElement.apply(Dom, args)); } }