coral-spectrum/coral-utils/src/scripts/Commons.js
/**
* 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 ResizeObserver from 'resize-observer-polyfill/dist/ResizeObserver';
// Used for unique IDs
let nextID = 0;
// Remove namespace from global options
const cleanOption = (name) => {
name = name.replace('coral', '');
return name.charAt(0).toLowerCase() + name.slice(1);
};
// Threshold time in milliseconds that the setTimeout will wait for the transitionEnd event to be triggered.
const TRANSITION_DURATION_THRESHOLD = 100;
// Based on jQuery's :focusable selector
const FOCUSABLE_ELEMENTS = [
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'button:not([disabled])',
'a[href]',
'area[href]',
'summary',
'iframe',
'object',
'embed',
'audio[controls]',
'video[controls]',
'[contenteditable]',
'[tabindex]'
];
// To support Coral.commons.ready and differentiate lightweight tags from defined elements
const CORAL_COMPONENTS = [];
/**
Converts CSS time to milliseconds. It supports both s and ms units. If the provided value has an unrecogenized unit,
zero will be returned.
@private
@param {String} time
The time string to convert to milliseconds.
@returns {Number} the time in milliseconds.
*/
function cssTimeToMilliseconds(time) {
const num = parseFloat(time, 10);
let unit = time.match(/m?s/);
if (unit) {
unit = unit[0];
}
if (unit === 's') {
return num * 1000;
} else if (unit === 'ms') {
return num;
}
// unrecognized unit, so we return 0
return 0;
}
/**
@private
@param first
@param second
@return {Function}
*/
function returnFirst(first, second) {
// eslint-disable-next-line func-names
return function (...args) {
const ret = first.apply(this, args);
second.apply(this, args);
return ret;
};
}
/**
Check if the provided object is a function
@ignore
@param {*} object
The object to test
@returns {Boolean} Whether the provided object is a function.
*/
function isFunction(object) {
return typeof object === 'function';
}
/**
Utility belt.
*/
class Commons {
/** @ignore */
constructor() {
// Create a Map to link elements to observe to their resize event callbacks
this._resizeObserverMap = new WeakMap();
this._resizeObserver = new ResizeObserver((entries) => {
for (let i = 0 ; i < entries.length ; i++) {
const observedElement = entries[i].target;
const allCallbacks = this._resizeObserverMap.get(observedElement);
if (allCallbacks) {
for (let j = 0 ; j < allCallbacks.length ; j++) {
allCallbacks[j].call(observedElement);
}
}
}
});
const focusableElements = FOCUSABLE_ELEMENTS.slice();
this._focusableElementsSelector = focusableElements.join(',');
focusableElements[focusableElements.length - 1] += ':not([tabindex="-1"])';
this._tabbableElementsSelector = focusableElements.join(':not([tabindex="-1"]),');
this._coralSelector = '';
// @IE11
if (!document.currentScript) {
const scripts = document.getElementsByTagName('script');
this._script = scripts[scripts.length - 1];
} else {
this._script = document.currentScript;
}
}
/**
Returns Coral global options retrieved on the <code><script></code> data attributes including:
- <code>[data-coral-icons]</code>: source folder of the SVG icons. If the icon collections have a custom name,
they have to be loaded manually using {@link Icon.load}.
- <code>[data-coral-icons-external]</code>: Whether SVG icons are always referenced as external resource. Possible values are "on" (default), "off" or "js" to load icons from a script.
- <code>[data-coral-typekit]</code>: custom typekit id used to load the fonts.
- <code>[data-coral-logging]</code>: defines logging level. Possible values are "on" (default) or "off".
@returns {Object}
The global options object.
*/
get options() {
const options = {};
const props = this._script.dataset;
for (const key in props) {
// Detect Coral namespaced options
if (key.indexOf('coral') === 0) {
options[cleanOption(key)] = props[key];
}
}
return options;
}
/**
Utility function for logging.
@param {String} level
Logging level
@param {String} args
Logging message
*/
_log(level, ...args) {
if (console[level] && this.options.logging !== 'off') {
console[level].apply(null, args);
}
}
/**
Copy the properties from all provided objects into the first object.
@param {Object} dest
The object to copy properties to
@param {...Object} source
An object to copy properties from. Additional objects can be passed as subsequent arguments.
@returns {Object}
The destination object, <code>dest</code>
*/
extend(...args) {
const dest = args[0];
for (let i = 1, ni = args.length ; i < ni ; i++) {
const source = args[i];
for (const prop in source) {
dest[prop] = source[prop];
}
}
return dest;
}
/**
Copy the properties from the source object to the destination object, but calls the callback if the property is
already present on the destination object.
@param {Object} dest
The object to copy properties to
@param {...Object} source
An object to copy properties from. Additional objects can be passed as subsequent arguments.
@param {CommonsHandleCollision} [handleCollision]
Called if the property being copied is already present on the destination.
The return value will be used as the property value.
@returns {Object}
The destination object, <code>dest</code>
*/
augment(...args) {
const dest = args[0];
let handleCollision;
let argCount = args.length;
const lastArg = args[argCount - 1];
if (typeof lastArg === 'function') {
handleCollision = lastArg;
// Don't attempt to augment using the last argument
argCount--;
}
for (let i = 1 ; i < argCount ; i++) {
const source = args[i];
for (const prop in source) {
if (typeof dest[prop] !== 'undefined') {
if (typeof handleCollision === 'function') {
// Call the handleCollision callback if the property is already present
const ret = handleCollision(dest[prop], source[prop], prop, dest, source);
if (typeof ret !== 'undefined') {
dest[prop] = ret;
}
}
// Otherwise, do nothing
} else {
dest[prop] = source[prop];
}
}
}
return dest;
}
/**
Return a new object with the swapped keys and values of the provided object.
@param {Object} obj
The object to copy.
@returns {Object}
An object with its keys as the values and values as the keys of the source object.
*/
swapKeysAndValues(obj) {
const map = {};
for (const key in obj) {
map[obj[key]] = key;
}
return map;
}
/**
Execute the provided callback on the next animation frame.
@param {Function} onNextFrame
The callback to execute.
*/
nextFrame(onNextFrame) {
return window.requestAnimationFrame(() => {
if (typeof onNextFrame === 'function') {
onNextFrame();
}
});
}
/**
Execute the provided callback once a CSS transition has ended. This method listens for the next transitionEnd event
on the given DOM element. In case the provided element does not have a transition defined, the callback will be
called in the next macrotask to allow a normal application execution flow. It cannot be used to listen continuously
on transitionEnd events.
@param {HTMLElement} element
The DOM element that is affected by the CSS transition.
@param {CommonsTransitionEndCallback} onTransitionEndCallback
The callback to execute.
*/
transitionEnd(element, onTransitionEndCallback) {
let propertyName;
let hasTransitionEnded = false;
let transitionEndEventName = null;
const transitions = {
transition: 'transitionend',
WebkitTransition: 'webkitTransitionEnd',
MozTransition: 'transitionend',
MSTransition: 'msTransitionEnd'
};
let transitionEndTimeout = null;
const onTransitionEnd = (event) => {
const transitionStoppedByTimeout = typeof event === 'undefined';
if (!hasTransitionEnded) {
hasTransitionEnded = true;
clearTimeout(transitionEndTimeout);
// Remove event listener (if any was used by the current browser)
element.removeEventListener(transitionEndEventName, onTransitionEnd);
// Call callback with specified element
onTransitionEndCallback({
target: element,
cssTransitionSupported: true,
transitionStoppedByTimeout: transitionStoppedByTimeout
});
}
};
// Find transitionEnd event name used by browser
for (propertyName in transitions) {
if (element.style[propertyName] !== undefined) {
transitionEndEventName = transitions[propertyName];
break;
}
}
if (transitionEndEventName !== null) {
let timeoutDelay = 0;
// Gets the animation time (in milliseconds) using the computed style
const transitionDuration = cssTimeToMilliseconds(window.getComputedStyle(element).transitionDuration);
// We only setup the event listener if there is a valid transition
if (transitionDuration !== 0) {
// Register on transitionEnd event
element.addEventListener(transitionEndEventName, onTransitionEnd);
// As a fallback we use the transitionDuration plus a threshold. This can happen in IE10/11 where
// transitionEnd events are sometimes skipped
timeoutDelay = transitionDuration + TRANSITION_DURATION_THRESHOLD;
}
// Fallback in case the event does not trigger (IE10/11) or if the element does not have a valid transition
transitionEndTimeout = window.setTimeout(onTransitionEnd, timeoutDelay);
}
}
/**
Register a Coral component as Custom Element V1
@param {String} name
Custom element namespace
@param {Function} constructor
Constructor for the custom element
@param {Object} options
E.g for built-in custom elements
*/
_define(name, constructor, options) {
window.customElements.define(name, constructor, options);
CORAL_COMPONENTS.push(name);
}
/**
Checks if Coral components and all nested Coral components are defined as Custom Elements.
@param {HTMLElement} element
The element that should be watched.
@param {CommonsReadyCallback} onDefined
The callback to call when all components are ready.
@see https://developer.mozilla.org/en-US/docs/Web/Web_Components/Custom_Elements
*/
ready(element, onDefined) {
let root = element;
if (typeof element === 'function') {
onDefined = element;
root = document.body;
}
if (!root) {
root = document.body;
}
if (!(root instanceof HTMLElement)) {
// commons.ready should not be blocking by default
onDefined(root);
return;
}
// @todo use ':not(:defined)' once supported ?
this._coralSelector = this._coralSelector || CORAL_COMPONENTS.join(',');
const elements = root.querySelectorAll(this._coralSelector);
// Holds promises that resolve when the elements is defined
const promises = [];
// Don't forget to check root
if (root !== document.body && !root._componentReady && root.matches(this._coralSelector)) {
const name = (root.getAttribute('is') || root.tagName).toLowerCase();
promises.push(window.customElements.whenDefined(name));
}
// Check all descending elements
for (let i = 0 ; i < elements.length ; i++) {
const el = elements[i];
if (!el._componentReady) {
const name = (el.getAttribute('is') || el.tagName).toLowerCase();
promises.push(window.customElements.whenDefined(name));
}
}
// Call callback once all defined
if (promises.length) {
Promise.all(promises)
.then(() => {
onDefined(element instanceof HTMLElement && element || window);
})
.catch((err) => {
console.error(err);
});
} else {
// Call callback by default if all defined already
onDefined(element instanceof HTMLElement && element || window);
}
}
/**
Assign an object given a nested path
@param {Object} root
The root object on which the path should be traversed.
@param {String} path
The path at which the object should be assignment.
@param {String} obj
The object to assign at path.
@throws Will throw an error if the path is not present on the object.
*/
setSubProperty(root, path, obj) {
const nsParts = path.split('.');
let curObj = root;
if (nsParts.length === 1) {
// Assign immediately
curObj[path] = obj;
return;
}
// Make sure we can assign at the requested location
while (nsParts.length > 1) {
const part = nsParts.shift();
if (curObj[part]) {
curObj = curObj[part];
} else {
throw new Error(`Coral.commons.setSubProperty: could not set ${path}, part ${part} not found`);
}
}
// Do the actual assignment
curObj[nsParts.shift()] = obj;
}
/**
Get the value of the property at the given nested path.
@param {Object} root
The root object on which the path should be traversed.
@param {String} path
The path of the sub-property to return.
@returns {*}
The value of the provided property.
@throws Will throw an error if the path is not present on the object.
*/
getSubProperty(root, path) {
const nsParts = path.split('.');
let curObj = root;
if (nsParts.length === 1) {
// Return property immediately
return curObj[path];
}
// Make sure we can assign at the requested location
while (nsParts.length) {
const part = nsParts.shift();
// The property might be undefined, and that's OK if it's the last part
if (nsParts.length === 0 || typeof curObj[part] !== 'undefined') {
curObj = curObj[part];
} else {
throw new Error(`Coral.commons.getSubProperty: could not get ${path}, part ${part} not found`);
}
}
return curObj;
}
/**
Apply a mixin to the given object.
@param {Object} target
The object to apply the mixin to.
@param {Object|Function} mixin
The mixin to apply.
@param {Object} options
An object to pass to functional mixins.
*/
_applyMixin(target, mixin, options) {
const mixinType = typeof mixin;
if (mixinType === 'function') {
mixin(target, options);
} else if (mixinType === 'object' && mixin !== null) {
this.extend(target, mixin);
} else {
throw new Error(`Coral.commons.mixin: Cannot mix in ${mixinType} to ${target.toString()}`);
}
}
/**
Mix a set of mixins to a target object.
@private
@param {Object} target
The target prototype or instance on which to apply mixins.
@param {Object|CoralMixin|Array<Object|CoralMixin>} mixins
A mixin or set of mixins to apply.
@param {Object} options
An object that will be passed to functional mixins as the second argument (options).
*/
mixin(target, mixins, options) {
if (Array.isArray(mixins)) {
for (let i = 0 ; i < mixins.length ; i++) {
this._applyMixin(target, mixins[i], options);
}
} else {
this._applyMixin(target, mixins, options);
}
}
/**
Get a unique ID.
@returns {String} unique identifier.
*/
getUID() {
return `coral-id-${nextID++}`;
}
/**
Call all of the provided functions, in order, returning the return value of the specified function.
@param {...Function} func
A function to call
@param {Number} [nth=0]
A zero-based index indicating the noth argument to return the value of.
If the nth argument is not a function, <code>null</code> will be returned.
@returns {Function} The aggregate function.
*/
callAll(...args) {
let nth = args[args.length - 1];
if (typeof nth !== 'number') {
nth = 0;
}
// Get the function whose value we should return
let funcToReturn = args[nth];
// Only use arguments that are functions
const functions = Array.prototype.filter.call(args, isFunction);
if (functions.length === 2 && nth === 0) {
// Most common usecase: two valid functions passed
return returnFirst(functions[0], functions[1]);
} else if (functions.length === 1) {
// Common usecase: one valid function passed
return functions[0];
} else if (functions.length === 0) {
return () => {
// Fail case: no valid functions passed
};
}
if (typeof funcToReturn !== 'function') {
// If the argument at the provided index wasn't a function, just return the value of the first valid function
funcToReturn = functions[0];
}
// eslint-disable-next-line func-names
return function () {
let finalRet;
let ret;
let func;
// Skip first arg
for (let i = 0 ; i < functions.length ; i++) {
func = functions[i];
ret = func.apply(this, args);
// Store return value of desired function
if (func === funcToReturn) {
finalRet = ret;
}
}
return finalRet;
};
}
/**
Adds a resize listener to the given element.
@param {HTMLElement} element
The element to add the resize event to.
@param {Function} onResize
The resize callback.
*/
// eslint-disable-next-line func-names
addResizeListener(element, onResize) {
// Map callback to element
if (!this._resizeObserverMap.has(element)) {
this._resizeObserverMap.set(element, []);
}
this._resizeObserverMap.get(element).push(onResize);
// Observe element resize events
this._resizeObserver.observe(element);
}
/**
Removes a resize listener from the given element.
@param {HTMLElement} element
The element to remove the resize event from.
@param {Function} onResize
The resize callback.
*/
// eslint-disable-next-line func-names
removeResizeListener(element, onResize) {
// Stop observing element resize events
this._resizeObserver.unobserve(element);
this._resizeObserver.disconnect(element);
// Remove event from map
const onResizeEvents = this._resizeObserverMap.get(element);
if (onResizeEvents) {
const index = onResizeEvents.indexOf(onResize);
if (index !== -1) {
onResizeEvents.splice(index, 1);
}
}
}
/**
Caution: the selector doesn't verify if elements are visible.
@type {String}
@readonly
@see https://www.w3.org/TR/html5/editing.html#focus-management
*/
get FOCUSABLE_ELEMENT_SELECTOR() {
return this._focusableElementsSelector;
}
/**
Caution: the selector doesn't verify if elements are visible.
@type {String}
@readonly
@see https://www.w3.org/TR/html5/editing.html#sequential-focus-navigation-and-the-tabindex-attribute
*/
get TABBABLE_ELEMENT_SELECTOR() {
return this._tabbableElementsSelector;
}
}
/**
Called when a property already exists on the destination object.
@typedef {function} CommonsHandleCollision
@param {*} oldValue
The value currently present on the destination object.
@param {*} newValue
The value on the destination object.
@param {*} prop
The property that collided.
@param {*} dest
The destination object.
@param {*} source
The source object.
@returns {*} The value to use. If <code>undefined</code>, the old value will be used.
*/
/**
Execute the callback once a CSS transition has ended.
@typedef {function} CommonsTransitionEndCallback
@param event
The event passed to the callback.
@param {HTMLElement} event.target
The DOM element that was affected by the CSS transition.
@param {Boolean} event.cssTransitionSupported
Whether CSS transitions are supported by the browser.
@param {Boolean} event.transitionStoppedByTimeout
Whether the CSS transition has been ended by a timeout (should only happen as a fallback).
*/
/**
Execute the callback once a component and sub-components are ready. See {@link Commons.ready}.
@typedef {function} CommonsReadyCallback
@param {HTMLElement} element
The element that is ready.
*/
/**
A functional mixin.
@typedef {Object} CoralMixin
@private
@param {Object} target
The target prototype or instance to apply the mixin to.
@param {Object} options
Options for this mixin.
@param {Coral~PropertyDescriptor.properties} options.properties
The properties object as passed to <code>Coral.register</code>. This can be modified in place.
*/
/**
A utility belt.
@type {Commons}
*/
const commons = new Commons();
export default commons;