UNPKG

@adobe/coral-spectrum

Version:

Coral Spectrum is a JavaScript library of Web Components following Spectrum design patterns.

872 lines (743 loc) 25.8 kB
/** * Copyright 2019 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software distributed under * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ import Vent from '@adobe/vent'; import {commons, Keys, keys, events, transform, validate, tracking as trackingUtil} from '../../../coral-utils'; // Used to split events by type/target const delegateEventSplitter = /^(\S+)\s*(.*)$/; /** Enumeration representing the tracking options. @typedef {Object} TrackingEnum @property {String} ON Enables tracking of the component interactions. @property {String} OFF Disables tracking of the component interactions. */ const tracking = { ON: 'on', OFF: 'off' }; /** Return the method corresponding to the method name or the function, if passed. @ignore */ const getListenerFromMethodNameOrFunction = function (obj, eventName, methodNameOrFunction) { // Try to get the method if (typeof methodNameOrFunction === 'function') { return methodNameOrFunction; } else if (typeof methodNameOrFunction === 'string') { if (!obj[methodNameOrFunction]) { throw new Error(`Coral.Component: Unable to add ${eventName} listener for ${obj.toString()}, method ${methodNameOrFunction} not found`); } const listener = obj[methodNameOrFunction]; if (typeof listener !== 'function') { throw new Error(`Coral.Component: Unable to add ${eventName} listener for ${obj.toString()}, listener is a ${(typeof listener)} but should be a function`); } return listener; } else if (methodNameOrFunction) { // If we're passed something that's truthy (like an object), but it's not a valid method name or a function, get // angry throw new Error(`Coral.Component: Unable to add ${eventName} listener for ${obj.toString()}, ${methodNameOrFunction} is neither a method name or a function`); } return null; }; /** Add local event and key combo listeners for this component, store global event/key combo listeners for later. @ignore */ const delegateEvents = function () { /* Add listeners to new event - Include in hash Add listeners to existing event - Override method and use super Remove existing event - Pass null */ let match; let eventName; let eventInfo; let listener; let selector; let elements; let isGlobal; let isKey; let isResize; let isCapture; for (eventInfo in this._events) { listener = this._events[eventInfo]; // Extract the event name and the selector match = eventInfo.match(delegateEventSplitter); eventName = `${match[1]}.CoralComponent`; selector = match[2]; if (selector === '') { // instead of null because the key module checks for undefined selector = undefined; } // Try to get the method corresponding to the value in the map listener = getListenerFromMethodNameOrFunction(this, eventName, listener); if (listener) { // Always execute in the context of the object // @todo is this necessary? this should be correct anyway listener = listener.bind(this); // Check if the listener is on the window isGlobal = eventName.indexOf('global:') === 0; if (isGlobal) { eventName = eventName.substr(7); } // Check if the listener is a capture listener isCapture = eventName.indexOf('capture:') === 0; if (isCapture) { // @todo Limitation: It should be possible to do capture:global:, but it isn't eventName = eventName.substr(8); } // Check if the listener is a key listener isKey = eventName.indexOf('key:') === 0; if (isKey) { if (isCapture) { throw new Error('Coral.Keys does not currently support listening to key events with capture'); } eventName = eventName.substr(4); } // Check if the listener is a resize listener isResize = eventName.indexOf('resize') === 0; if (isResize) { if (isCapture) { throw new Error('Coral.commons.addResizeListener does not currently support listening to resize event with capture'); } } if (isGlobal) { // Store for adding/removal if (isKey) { this._globalKeys = this._globalKeys || []; this._globalKeys.push({ keyCombo: eventName, selector: selector, listener: listener }); } else { this._globalEvents = this._globalEvents || []; this._globalEvents.push({eventName, selector, listener, isCapture}); } } // Events on the element itself else if (isKey) { // Create the keys instance only if its needed this._keys = this._keys || new Keys(this, { // The filter function for keyboard events. filter: this._filterKeys, // Execute key listeners in the context of the element context: this }); // Add listener locally this._keys.on(eventName, selector, listener); } else if (isResize) { if (selector) { elements = document.querySelectorAll(selector); for (let i = 0 ; i < elements.length ; ++i) { commons.addResizeListener(elements[i], listener); } } else { commons.addResizeListener(this, listener); } } else { this._vent.on(eventName, selector, listener, isCapture); } } } }; /** Attach global event listeners for this component. @ignore */ const delegateGlobalEvents = function () { let i; if (this._globalEvents) { // Remove global event listeners for (i = 0 ; i < this._globalEvents.length ; i++) { const event = this._globalEvents[i]; events.on(event.eventName, event.selector, event.listener, event.isCapture); } } if (this._globalKeys) { // Remove global key listeners for (i = 0 ; i < this._globalKeys.length ; i++) { const key = this._globalKeys[i]; keys.on(key.keyCombo, key.selector, key.listener); } } if (this._keys) { this._keys.init(true); } }; /** Remove global event listeners for this component. @ignore */ const undelegateGlobalEvents = function () { let i; if (this._globalEvents) { // Remove global event listeners for (i = 0 ; i < this._globalEvents.length ; i++) { const event = this._globalEvents[i]; events.off(event.eventName, event.selector, event.listener, event.isCapture); } } if (this._globalKeys) { // Remove global key listeners for (i = 0 ; i < this._globalKeys.length ; i++) { const key = this._globalKeys[i]; keys.off(key.keyCombo, key.selector, key.listener); } } if (this._keys) { this._keys.destroy(true); } }; // Used to find upper case characters const REG_EXP_UPPERCASE = /[A-Z]/g; /** Returns the constructor namespace @ignore */ const getConstructorName = function (constructor) { // Will contain the namespace of the constructor in reversed order const constructorName = []; // Keep a reference on the passed constructor const originalConstructor = constructor; // Traverses Coral constructors if not already done to set the namespace if (!constructor._namespace) { // Set namespace on Coral constructors until 'constructor' is found const find = (obj, constructorToFind) => { let found = false; const type = typeof obj; if (obj && type === 'object' || type === 'function') { const subObj = Object.keys(obj); for (let i = 0 ; i < subObj.length ; i++) { const key = subObj[i]; // Components are capitalized if (key[0].match(REG_EXP_UPPERCASE) !== null) { // Keep a reference of the constructor name and its parent obj[key]._namespace = { parent: obj, value: key }; found = obj[key] === constructorToFind; if (found) { break; } else { found = find(obj[key], constructorToFind); } } } } return found; }; // Look for the constructor in the Coral namespace find(window.Coral, constructor); } // Climb up the constructor namespace while (constructor) { if (constructor._namespace) { constructorName.push(constructor._namespace.value); constructor = constructor._namespace.parent; } else { constructor = false; } } // Build the full namespace string and save it for reuse originalConstructor._componentName = constructorName.reverse().join('.'); return originalConstructor._componentName; }; /** * recursively update the _ignoreConnectedCallback value * for children coral-component, if parent has ignored the callback * its child should also ignore the callback hooks * @private */ const _recursiveIgnoreConnectedCallback = function(el, value) { let children = Array.from(el.children); for (let i = 0; i < children.length; i++) { let child = children[i]; // todo better check for coral-component if(typeof child._ignoreConnectedCallback === 'boolean') { child._ignoreConnectedCallback = value; } else { _recursiveIgnoreConnectedCallback(child, value); } } }; /** @base BaseComponent @classdesc The base element for all Coral components */ const BaseComponent = (superClass) => class extends superClass { /** @ignore */ constructor() { super(); // Attach Vent this._vent = new Vent(this); this._events = {}; // Content zone MO for virtual DOM support if (this._contentZones) { this._contentZoneObserver = new MutationObserver((mutations) => { mutations.forEach((mutation) => { for (let i = 0 ; i < mutation.addedNodes.length ; i++) { const addedNode = mutation.addedNodes[i]; for (const name in this._contentZones) { const contentZone = this._contentZones[name]; if (addedNode.nodeName.toLowerCase() === name && !addedNode._contentZoned) { // Insert the content zone at the right position /** @ignore */ this[contentZone] = addedNode; } } } }); }); this._contentZoneObserver.observe(this, { childList: true, subtree: true }); } } /** Tracking of events. This provides insight on the usage of the components. It accepts "ON" and "OFF". In order to successfully track the events, {Tracking} needs to be configured. @type {String} @default TrackingEnum.ON @htmlattribute tracking */ get tracking() { return this._tracking || this.getAttribute('tracking') || tracking.ON; } set tracking(value) { value = transform.string(value).toLowerCase(); this._tracking = validate.enumeration(tracking)(value) && value || tracking.ON; } /** The string representing the feature being tracked. This provides additional context to the analytics trackers about the feature that the element enables. @type {String} @default "" @htmlattribute trackingfeature */ get trackingFeature() { return this._trackingFeature || this.getAttribute('trackingFeature') || ''; } set trackingFeature(value) { this._trackingFeature = transform.string(value); } /** The string representing the element name being tracked. This providex additional context to the trackers about the element that was interacted with. @type {String} @default "" @htmlattribute trackingelement */ get trackingElement() { return this._trackingElement || this.getAttribute('trackingElement') || ''; } set trackingElement(value) { this._trackingElement = transform.string(value); } // Constructs and returns the component name based on the constructor get _componentName() { return this.constructor._componentName || getConstructorName(this.constructor); } // The filter function for keyboard events. By default, any child element can trigger keyboard events. // You can pass {@link Keys.filterInputs} to avoid listening to key events triggered from within // inputs. _filterKeys() { return true; } // Attach event listeners including global ones _delegateEvents(eventMap) { this._events = commons.extend(this._events, eventMap); delegateEvents.call(this); // Once events are attached, we dispose them this._events = {}; } // Returns the content zone if the component is connected and contains the content zone else null // Ideally content zones will be replaced by shadow dom and <slot> elements _getContentZone(contentZone) { if (document.documentElement.contains(this)) { return this.contains(contentZone) && contentZone || null; } // Return the content zone by default return contentZone; } // Sets the value as content zone for the property given the specified options // Ideally content zones will be replaced by shadow dom and <slot> elements _setContentZone(property, value, options) { const handle = options.handle; const expectedTagName = options.tagName; const additionalSetter = options.set; const insert = options.insert; let oldNode; if (value) { if (!(value instanceof HTMLElement)) { throw new Error(`DOMException: Failed to set the "${property}" property on "${this.toString()}": The provided value is not of type "HTMLElement".`); } if (expectedTagName && value.tagName.toLowerCase() !== expectedTagName) { throw new Error(`DOMException: Failed to set the "${property}" property on "${this.toString()}": The new ${property} element is of type "${value.tagName}". It must be a "${expectedTagName.toUpperCase()}" element.`); } oldNode = this._elements[handle]; // Flag it for the content zone MO value._contentZoned = true; // Replace the existing element if (insert) { // Remove old node if (oldNode && oldNode.parentNode) { oldNode.parentNode.removeChild(oldNode); } // Insert new node insert.call(this, value); } else if (oldNode && oldNode.parentNode) { commons._log('warn', `${this._componentName} does not define an insert method for content zone ${handle}, falling back to replace.`); // Old way -- assume we have an old node this._elements[handle].parentNode.replaceChild(value, this._elements[handle]); } else { commons._log('error', `${this._componentName} does not define an insert method for content zone ${handle}, falling back to append.`); // Just append, which may introduce bugs, but at least doesn't crazy this.appendChild(value); } } else { // we need to remove the content zone if it exists oldNode = this._elements[handle]; if (oldNode && oldNode.parentNode) { oldNode.parentNode.removeChild(oldNode); } } // Re-assign the handle to the new element this._elements[handle] = value; // Invoke the setter if (typeof additionalSetter === 'function') { additionalSetter.call(this, value); } } // Handles the reflection of properties by using a flag to prevent setting the property by changing the attribute _reflectAttribute(attributeName, value) { if (typeof value === 'boolean') { if (value && !this.hasAttribute(attributeName)) { this._reflectedAttribute = true; this.setAttribute(attributeName, ''); this._reflectedAttribute = false; } else if (!value && this.hasAttribute(attributeName)) { this._reflectedAttribute = true; this.removeAttribute(attributeName); this._reflectedAttribute = false; } } else if (this.getAttribute(attributeName) !== String(value)) { this._reflectedAttribute = true; this.setAttribute(attributeName, value); this._reflectedAttribute = false; } } /** Notifies external listeners about an internal interaction. This method is used internally in every component's method that we want to track. @param {String} eventType The event type. Eg. click, select, etc. @param {String} targetType The element type being used. Eg. cyclebutton, cyclebuttonitem, etc. @param {CustomEvent} event @param {BaseComponent} childComponent - Optional, in case the event occurred on a child component. @returns {BaseComponent} */ _trackEvent(eventType, targetType, event, childComponent) { if (this.tracking === this.constructor.tracking.ON) { trackingUtil.track(eventType, targetType, event, this, childComponent); } return this; } /** Returns the component name. @return {String} */ toString() { return `Coral.${this._componentName}`; } /** Add an event listener. @param {String} eventName The event name to listen for. @param {String} [selector] The selector to use for event delegation. @param {Function} func The function that will be called when the event is triggered. @param {Boolean} [useCapture=false] Whether or not to listen during the capturing or bubbling phase. @returns {BaseComponent} this, chainable. */ on(eventName, selector, func, useCapture) { this._vent.on(eventName, selector, func, useCapture); return this; } /** Remove an event listener. @param {String} eventName The event name to stop listening for. @param {String} [selector] The selector that was used for event delegation. @param {Function} func The function that was passed to <code>on()</code>. @param {Boolean} [useCapture] Only remove listeners with <code>useCapture</code> set to the value passed in. @returns {BaseComponent} this, chainable. */ off(eventName, selector, func, useCapture) { this._vent.off(eventName, selector, func, useCapture); return this; } /** Trigger an event. @param {String} eventName The event name to trigger. @param {Object} [props] Additional properties to make available to handlers as <code>event.detail</code>. @param {Boolean} [bubbles=true] Set to <code>false</code> to prevent the event from bubbling. @param {Boolean} [cancelable=true] Set to <code>false</code> to prevent the event from being cancelable. @returns {CustomEvent} CustomEvent object */ trigger(eventName, props, bubbles, cancelable) { // When 'bubbles' is not set, then default to true: bubbles = bubbles || bubbles === undefined; // When 'cancelable' is not set, then default to true: cancelable = cancelable || cancelable === undefined; const event = new CustomEvent(eventName, { bubbles: bubbles, cancelable: cancelable, detail: props }); // Don't trigger the event if silenced if (this._silenced) { return event; } // default value in case the dispatching fails let defaultPrevented = false; try { // leads to NS_ERROR_UNEXPECTED in Firefox // https://bugzilla.mozilla.org/show_bug.cgi?id=329509 defaultPrevented = !this.dispatchEvent(event); } // eslint-disable-next-line no-empty catch (e) { } // Check if the defaultPrevented status was correctly stored back to the event object if (defaultPrevented !== event.defaultPrevented) { // dispatchEvent() doesn't correctly set event.defaultPrevented in IE 9 // However, it does return false if preventDefault() was called // Unfortunately, the returned event's defaultPrevented property is read-only // We need to work around this such that (patchedEvent instanceof Event) === true // First, we'll create an object that uses the event as its prototype // This gives us an object we can modify that is still technically an instanceof Event const patchedEvent = Object.create(event); // Next, we set the correct value for defaultPrevented on the new object // We cannot simply assign defaultPrevented, it causes a "Invalid Calling Object" error in IE 9 // For some reason, defineProperty doesn't cause this Object.defineProperty(patchedEvent, 'defaultPrevented', { value: defaultPrevented }); return patchedEvent; } return event; } /** Set multiple properties. @param {Object.<String, *>} properties An object of property/value pairs to set. @param {Boolean} silent If true, events should not be triggered as a result of this set. @returns {BaseComponent} this, chainable. */ set(propertyOrProperties, valueOrSilent, silent) { let property; let properties; let value; const isContentZone = (prop) => this._contentZones && commons.swapKeysAndValues(this._contentZones)[prop]; const updateContentZone = (prop, val) => { // If content zone exists and we only want to update properties on the content zone if (this[prop] instanceof HTMLElement && !(val instanceof HTMLElement)) { for (const contentZoneProperty in val) { /** @ignore */ this[prop][contentZoneProperty] = val[contentZoneProperty]; } } // Else assign the new value to the content zone else { /** @ignore */ this[prop] = val; } }; const setProperty = (prop, val) => { if (isContentZone(prop)) { updateContentZone(prop, val); } else { this._silenced = silent; /** @ignore */ this[prop] = val; this._silenced = false; } }; if (typeof propertyOrProperties === 'string') { // Set a single property property = propertyOrProperties; value = valueOrSilent; setProperty(property, value); } else { properties = propertyOrProperties; silent = valueOrSilent; // Set a map of properties for (property in properties) { value = properties[property]; setProperty(property, value); } } return this; } /** Get the value of a property. @param {String} property The name of the property to fetch the value of. @returns {*} Property value. */ get(property) { return this[property]; } /** Show this component. @returns {BaseComponent} this, chainable */ show() { if (!this.hidden) { return this; } /** @ignore */ this.hidden = false; return this; } /** Hide this component. @returns {BaseComponent} this, chainable */ hide() { if (this.hidden) { return this; } /** @ignore */ this.hidden = true; return this; } /** This should be executed when messenger is connect event is connected. It will add the parent as a listener in child messenger. @ignore */ _onMessengerConnected(event) { event.stopImmediatePropagation(); let handler = event.detail.handler; if(typeof handler === 'function') { handler(this); } else { throw new Error("Messenger handler should be a function"); } } /** specify whether the connected and disconnected hooks are ignore for component @returns true when ignored @private */ get _ignoreConnectedCallback() { return this.__ignoreConnectedCallback || false; } set _ignoreConnectedCallback(value) { value = transform.booleanAttr(value); if(value !== this.__ignoreConnectedCallback) { this.__ignoreConnectedCallback = value; _recursiveIgnoreConnectedCallback(this, value); } } /** Returns {@link BaseComponent} tracking options. @return {TrackingEnum} */ static get tracking() { return tracking; } static get _attributePropertyMap() { return { trackingelement: 'trackingElement', trackingfeature: 'trackingFeature' }; } /** @ignore */ static get observedAttributes() { return [ 'tracking', 'trackingelement', 'trackingfeature', 'trackingFeature' ]; } /** @ignore */ // eslint-disable-next-line no-unused-vars attributeChangedCallback(name, oldValue, value) { const self = this; if (!self._reflectedAttribute) { // Use the attribute/property mapping self[self.constructor._attributePropertyMap[name] || name] = value; } } /** called when we need to suspend state and properties, when disconnected callback are skipped. @private */ _suspendCallback() { // do nothing } /** called when we need to re-initialise state and properties, when connected callback are skipped. @private */ _resumeCallback() { // do nothing } /** @ignore */ connectedCallback() { // A component that is reattached should respond to global events again // Attach global listener when component is connected to DOM // this would avoid memory leak when element is created but never connected. delegateGlobalEvents.call(this); this._disconnected = false; if (!this._rendered) { this.render(); } } /** @ignore */ render() { this._rendered = true; } /** @ignore */ disconnectedCallback() { // A component that isn't in the DOM should not be responding to global events this._disconnected = true; undelegateGlobalEvents.call(this); } }; export default BaseComponent;