@adobe/coral-spectrum
Version:
Coral Spectrum is a JavaScript library of Web Components following Spectrum design patterns.
692 lines (583 loc) • 23.6 kB
JavaScript
/**
* 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 {commons, validate} from '../../coral-utils';
import Component from './Component';
/**
A property descriptor.
@typedef {Object} Coral~PropertyDescriptor
@property {Function} [transform=null]
The value transformation function. Values passed to setters will be ran through this function first.
@property {Function} [attributeTransform=transform]
The value transformation function for attribute. The value given by <code>attributeChangedCallback</code> will be
ran through this function first before passed to setters.
@property {Coral.validate~validationFunction} [validate={@link Coral.validate.valueMustChange}]
The value validation function. A validation function that takes two arguments, <code>newValue</code> and
<code>oldValue</code>, returning true if the setter should run or false if not.
@property {String|Function} [trigger=null]
The name of the event to trigger after this property changes, or a {Function} to call that will trigger the event.
The function is passed the <code>newValue</code> and <code>oldValue</code>.
@property {String|Function} [triggerBefore=null]
The name of the event to trigger before this property changes, a {Function} to call that will trigger the event,
or <code>true</code> to set the name automatically. The function is passed the <code>newValue</code> and
<code>oldValue</code> and must return the Event object for <code>preventDefault()</code> to work within handlers. If
set to <code>true</code>, {@link Coral~PropertyDescriptor} must be a string with a colon in it, such as
<code>coral-component:change</code>, which results in <code>coral-component:beforechange</code>. If set to
<code>false</code>, no event will be triggered before the setter is ran.
@property {String} [attribute=propName]
The name of the attribute corresponding to this property. If not provided, the property name itself will be used.
If <code>null</code> is provided, the property will not be set by a corresponding attribute.
@property {Boolean} [reflectAttribute=false]
Whether this property should be reflected as an attribute when changed. This is useful when you want to style CSS
according to the property's existence or value.
@property {Function} [sync=null]
The method to be called when this property's value should be synced to the DOM.
@property {String|Array.<String>} [alsoSync=null]
A property or list of properties that should be synced after this property is synced.
@property {Function} [set={@link Coral~defaultSet}]
The setter for this property.
@property {Function} [get={@link Coral~defaultGet}]
The getter for this property.
@property {Boolean} [override=false]
Whether this property descriptor should completely override. If <code>false</code>, this descriptor will augment
the existing descriptor. See {@link Coral.register.augmentProperties} for details.
@property {Boolean} [contentZone=false]
Whether this property represents a content zone. Content zones are treated differently when set() is invoked such
that the provided value is passed to the content zone's set() method.
*/
// These properties won't be treated as methods
var specialProperties = {
extend: true,
properties: true,
events: true,
_elements: true,
name: true,
namespace: true,
tagName: true,
baseTagName: true,
className: true
};
function noop() {
}
function passThrough(value) {
return value;
}
/**
Creates an array with validation functions for the given properties. If no validate is specified, then the default
validator is used.
@ignore
*/
function makeValidate(descriptor) {
if (!descriptor.validate) {
return [validate.valueMustChange];
}
if (Array.isArray(descriptor.validate)) {
return descriptor.validate;
}
return [descriptor.validate];
}
/**
Make a attribute reflect function for the given property. If the property is not reflected, return a noop.
@ignore
*/
function makeReflect(propName, descriptor) {
if (!descriptor.reflectAttribute) {
return noop;
}
var attrName = descriptor.attribute || propName;
return function doReflect(value, silent) {
// Reflect the property
if (value === false || value === null) {
// Non-truthy attributes should be destroyed
this.removeAttribute(attrName);
} else {
// Boolean true for a value just means the property should exist
if (value === true) {
value = '';
}
// Only perform the set if the attribute is of a different value
// This avoids triggering mutation observers unnecessarily
if (this.getAttribute(attrName) !== value) {
this.setAttribute(attrName, value);
}
}
};
}
/**
Make an event trigger function for the given property. If no event should be triggered, return a noop.
@ignore
*/
function makeTrigger(trigger) {
if (!trigger) {
return noop;
}
if (typeof trigger === 'function') {
return trigger;
}
var eventName = trigger;
return function doTrigger(newValue, oldValue) {
// Trigger an event that has the new and old values under detail
return this.trigger(eventName, {
oldValue: oldValue,
value: newValue
});
};
}
/**
Make a queue sync function for the given property. If nothing needs to be synced, return a noop.
@ignore
*/
function makeQueueSync(propName, descriptor) {
var propList = descriptor.alsoSync;
var sync = descriptor.sync;
if (!sync && !propList) {
return noop;
}
if (propList) {
// Other properties in addition to ours
if (Array.isArray(propList)) {
propList.unshift(propName);
} else {
propList = [propName, propList];
}
return function doMultiSync(value) {
// Sync the list of properties
this._queueSync.apply(this, propList);
};
}
return function doSync(value) {
// Sync the property
this._queueSync(propName);
};
}
/**
Create and store the methods back to the property descriptor, then store the descriptor on the prototype.
This enables overriding descriptor parts.
@ignore
*/
function storeDescriptor(proto, propName, descriptor) {
// triggerBefore can be function, boolean, or string
var triggerBeforeValue;
if (typeof descriptor.triggerBefore === 'function' || typeof descriptor.triggerBefore === 'string') {
// Directly use string or function, makeTrigger will do the rest
triggerBeforeValue = descriptor.triggerBefore;
} else if (descriptor.triggerBefore === true) {
// Automatically set name based on descriptor.trigger
if (typeof descriptor.trigger === 'string' && descriptor.trigger.indexOf(':') !== -1) {
triggerBeforeValue = descriptor.trigger.replace(':', ':before');
} else {
throw new Error('Coral.register: Cannot automatically set "before" event name unless descriptor.trigger ' +
'is a string that conatins a colon');
}
}
// Use provided setter, or make a setter that sets a "private" underscore-prefixed variable
descriptor.set = descriptor.set || makeBasicSetter(propName);
// Use provided getter, or make a getter that returns a "private" underscore-prefixed variable
descriptor.get = descriptor.get || makeBasicGetter(propName);
// Store methods
var inheritedMethods = descriptor._methods;
descriptor._methods = {};
// store references to inherited methods in descriptor._methods
if (inheritedMethods) {
for (var methodName in inheritedMethods) {
descriptor._methods[methodName] = inheritedMethods[methodName];
}
}
descriptor._methods.triggerBefore = makeTrigger(triggerBeforeValue);
descriptor._methods.trigger = makeTrigger(descriptor.trigger);
descriptor._methods.transform = descriptor.transform || passThrough;
descriptor._methods.attributeTransform = descriptor.attributeTransform || passThrough;
descriptor._methods.reflectAttribute = makeReflect(propName, descriptor);
descriptor._methods.queueSync = makeQueueSync(propName, descriptor);
// We need to store the list of validators back on the descriptor as we modify this inside of makeValidate
descriptor._methods.validate = makeValidate(descriptor);
// Store reverse mapping of attribute -> property
if (descriptor.attribute) {
proto._attributes[descriptor.attribute] = propName;
} else {
// Remove the mapping in case it was overridden
proto._attributes[descriptor.attribute] = null;
}
// Store the descriptor
proto._properties[propName] = descriptor;
}
/**
Create a generic getter.
@param {String} propName
The property name whose getter should be invoked.
@ignore
*/
function makeGetter(propName) {
return function getter() {
// Invoke the original getter
return this._properties[propName].get.call(this);
};
}
/**
Create a genertic setter.
@param {String} propName
The name of the property.
@alias register.makeSetter
@returns {Function} The setter function.
*/
function makeSetter(propName) {
return function setter(value, silent) {
var descriptor = this._properties[propName];
var methods = descriptor._methods;
// Transform the value, passing the default
// The default value cannot be cached in the outer closure as that would prevent monkey-patching
var newValue = methods.transform.call(this, value, this._properties[propName].default);
// Store the old value before the setter is invoked
var oldValue = this[propName];
// Performs all the validations until one of them fails
var self = this;
var failed = methods.validate.some(function (validator) {
return !validator.call(self, newValue, oldValue);
});
// If a validation failed then we return
if (failed) {
return;
}
if (!silent) {
var event = methods.triggerBefore.call(this, newValue, oldValue);
if (event && event.defaultPrevented) {
// Allow calls to preventDefault() to stop events
return;
}
}
// Invoke the original setter
descriptor.set.call(this, newValue, silent);
// Reflect the attribute
methods.reflectAttribute.call(this, newValue);
// Queue property sync. Do this before trigger, in case an event listener wants to unroll the sync queue
methods.queueSync.call(this);
// Trigger an event
if (!silent) {
methods.trigger.call(this, newValue, oldValue);
}
// Store that this prop has been set
// This is used during initialization when deciding whether to apply default values
this._setProps[propName] = true;
};
}
function makeBasicGetter(propName) {
var tempVarName = '_' + propName;
/**
Gets the corresponding underscore prefixed "private" property by the same name.
@function Coral~defaultGet
@returns The prefixed property
*/
return function getter() {
return this[tempVarName];
};
}
function makeBasicSetter(propName) {
var tempVarName = '_' + propName;
/**
Sets the corresponding underscore prefixed "private" property by the same name.
@param {*} value The value to set
@function Coral~defaultSet
*/
return function setter(value) {
this[tempVarName] = value;
};
}
/**
Define a set of {@link Coral~PropertyDescriptors} on the passed object
@param {Object} proto
The object to define properties on, usually a prototype.
@param {Object.<String, Coral~PropertyDescriptor>} properties
A map of property names to their corresponding descriptors.
@alias register.defineProperties
*/
function defineProperties(proto, properties) {
// Loop over properties and define them on the prototype
for (var propName in properties) {
if (!properties[propName]) {
// Skip properties that were removed to avoid redefinition
continue;
}
defineProperty(proto, propName, properties[propName]);
}
}
/**
Define a single {@link Coral~PropertyDescriptors} on the passed object
@param {Object} proto
The object to define properties on, usually a prototype.
@param {String} propName
The name of the property.
@param {Coral~PropertyDescriptor} descriptor
A property descriptor
@alias register.defineProperty
*/
function defineProperty(proto, propName, descriptor) {
// Handle mixin case
if (typeof descriptor === 'function') {
// Let descriptor apply itself to the prototype
// This allows it to add methods
// Use its return value as the actual descriptor
descriptor = descriptor(proto, propName);
// If nothing is returned, we're done with this property
if (!descriptor) {
throw new Error('Coral.register.defineProperty: Property function did not return a descriptor for ' + propName);
}
}
// Store the associated methods
storeDescriptor(proto, propName, descriptor);
// Create the generic setters and getters for this property
// Store them back so we can access them for silent sets
// These do not need to be overridden as they delegate to this._properties._methods
var actualSetter = descriptor._methods.set = makeSetter(propName);
var actualGetter = descriptor._methods.get = makeGetter(propName);
// Define the property
Object.defineProperty(proto, propName, {
// All properties are enumerable
enumerable: true,
// No properties are configurable
configurable: false,
set: actualSetter,
get: actualGetter
});
}
var tagPrototypes = {};
/**
Memoized getProtoTypeOf for HTML tags
@ignore
*/
function getPrototypeOfTag(tagName) {
tagPrototypes[tagName] = tagPrototypes[tagName] || Object.getPrototypeOf(document.createElement(tagName));
return tagPrototypes[tagName];
}
/**
Register a Coral component, setting up inheritance, mixins, properties, and the associated custom element.
@memberof Coral
@static
@param {Object} options
Component options.
@param {Object} options.namespace
Namespace where to store the constructor.
@param {String} options.name
Name of the constructor (i.e. 'Accordion.Item'). The constructor will be available under 'Coral' at the path
specified by the name.
@param {String} options.tagName
Name of the new element (i.e 'coral-component').
@param {String} [options.baseTagName = (none)]
Name of the tag to extend (i.e. 'button'). This is only required when extending an existing HTML element such that
the <code><button is="custom-element"></code> style will be used.
@param {Object} [options.extend = Coral.Component]
Base class of the component. When extending an existing HTML element, this should match the interface implemented
by the tag -- that is, for <code>baseTagName: 'button'</code> you should pass
<code>extend: HTMLButtonElement</code>.
@param {Array.<Object|Coral~mixin>} [options.mixins]
Mixin or {Array} of mixins to add. Mixins can be an {Object} or a {Coral~mixin}.
@param {Object.<String, Coral~PropertyDescriptor>} [options.properties]
A map of property names to their corresponding descriptors.
@param {Object} [options.events]
Map of the events and their handler.
@param {Object} [options._elements]
Map of elements and their locations used for caching.
*/
const register = function (options) {
// Throw away options.extend if baseTagName provided and the prototype isn't part of Coral.Component
if (options.extend && !options.extend.prototype._methods) {
options.extend = Component;
}
// Extend Coral.Component if nothing is provided
var extend = options.extend || Component;
// We'll use the prototype of the argument passed constructor we're extending
var baseComponentProto = extend.prototype;
var actualPrototype = baseComponentProto;
// Use passed or be an empty object so mixins can add properties to components that don't define any
// Don't modify the passed properties object directly
var properties = options.properties ? commons.extend({}, options.properties) : {};
if (options.baseTagName) {
// If we're extending a base tag, we need to use its prototype, not the Component's
actualPrototype = getPrototypeOfTag(options.baseTagName);
}
// Setup the prototype chain
var proto = Object.create(
actualPrototype
);
// Store a reference to the next component's prototype in the chain
// This allows us to crawl up the component prototype chain later
proto._proto = baseComponentProto;
if (options.baseTagName) {
var protoChain = [];
// Build the prototype chain
var curBaseProto = baseComponentProto;
while (curBaseProto && curBaseProto._methods) {
protoChain.unshift(curBaseProto);
curBaseProto = curBaseProto._proto;
}
// Iterate over the prototype chain and mix all the methods in
while (curBaseProto = protoChain.shift()) {
for (var methodName in curBaseProto._methods) {
proto[methodName] = curBaseProto[methodName];
}
}
// Note that we'll already get a flattened list of properties from _properties
// So we don't have to do something similar there
}
// Create attribute -> property mappings and the property descriptor map
// Do this before we mixin/override properties as storeDescriptor() will write back to _attributes and _properties
proto._attributes = commons.extend({}, baseComponentProto._attributes);
proto._properties = {};
// Define and inherit events from parent class
proto._events = commons.extend({}, baseComponentProto._events, options.events);
// Define and inherit sub-elements from parent class
proto._elements = commons.extend({}, baseComponentProto._elements, options._elements);
// Store the name and namespace on the prototype
// the toString method of Coral.Component uses this
proto._componentName = options.name;
proto._namespace = options.namespace || window.Coral;
// CSS className
proto._className = options.className;
// Add methods to the prototype, and store them in an object for easy access
// We'll use this object when extending base tagnames later
var _methods = proto._methods = {};
for (var method in options) {
if (!specialProperties[method]) {
proto[method] = _methods[method] = options[method];
}
}
// Add mixins to the prototype
// Do this before combining properties to allow seemless modification of properties overridden by mixins
if (options.mixins) {
commons.mixin(
proto,
// A single Object, Function, or Array thereof
options.mixins,
{
// Pass properties so functional mixins can augment them
properties: properties
}
);
}
// Store and override property descriptors
commons.augment(proto._properties, baseComponentProto._properties, properties, function (existingDesc, newDesc, propName) {
// Drop properties that are not defined
if (!newDesc) {
return null;
}
// The child component (newDesc) determines whether to ignore the base component's descriptor
if (newDesc.override === true) {
// The new component wants to ignore the base component's descriptor
return newDesc;
}
// Combine and override as necessary
// The order of arguments seems backwards because we use this method in Coral.register.augmentProperties
// This makes it so the existing setter is called first
// It also makes it so the new descriptor will override other properties
var combinedDesc = commons.augment(
// Don't modify the existing descriptor
{},
newDesc,
existingDesc,
handleAugmentPropertyCollision
);
// Store the new methods and descriptor
storeDescriptor(proto, propName, combinedDesc);
// The property is already defined, so tell defineProperties not to define it again
properties[propName] = undefined;
// storeDescriptor() already stored the descriptor, but we have to return it anyway
return combinedDesc;
});
// Removed properties that have been removed by inheriting components
for (var propName in proto._properties) {
if (!proto._properties[propName]) {
delete proto._properties[propName];
}
}
// Define properties last
// This allows mixins to merge and modify properties
if (options.baseTagName) {
// Define ALL properties as we don't pick up any from the prototype
defineProperties(proto, proto._properties);
} else {
// Define just the new properties
defineProperties(proto, properties);
}
// The options to be passed to registerElement
var registrationOptions = {
prototype: proto
};
if (options.baseTagName) {
// When a base tag is provided, we need to tell registerElement
registrationOptions.extends = options.baseTagName;
}
// Register the element
// This returns a constructor
var Constructor = document.registerElement(options.tagName, registrationOptions);
// Assign the constructor at the correct location
commons.setSubProperty(options.namespace || window.Coral || window, options.name, Constructor);
return Constructor;
};
// Expose globally
register.defineProperties = defineProperties;
register.defineProperty = defineProperty;
/**
Augment a set of property descriptors with another set.
The <code>dest</code> property descriptors map is modified in place.
The individual property descriptors (values of <code>dest</code>) are not modified.
@param {Object<String,Coral~PropertyDescriptor>} dest
The set of property descriptors to agument.
@param {Object<String,Coral~PropertyDescriptor>} source
The set of property descriptors to use.
@param {Coral.commons~handleCollision} [handleCollision]
Called if the descriptor property being copied is already present on the destination.
The return value will be used as the property value.
By default, if <code>sync</code> or <code>set</code> collides, both provided methods will be called.
By default, if any other descriptor property collides, the destination's value will be used.
*/
register.augmentProperties = function (dest, source, handleCollision) {
commons.augment(dest, source, function (existingDesc, newDesc, propName) {
// The mixin target (dest) determines whether to ignore the mixin's properties
if (existingDesc.override === true) {
// The mixin target (dest) wants to ignore the mixin's descriptor
return existingDesc;
}
// Deep-augment individual property descriptor properties
var combinedDesc = commons.augment(
// Don't modify the existing descriptor
{},
existingDesc,
newDesc,
handleCollision || handleAugmentPropertyCollision
);
return combinedDesc;
});
};
/**
Default collision handler when augmenting properties
@ignore
*/
function handleAugmentPropertyCollision(destValue, sourceValue, descPropName) {
switch (descPropName) {
case 'sync':
case 'set':
// Use both methods
return callBoth(sourceValue, destValue);
default:
// Use component's value
return destValue;
}
}
/**
Return a function that calls both functions and ignores their return values
@ignore
*/
function callBoth(first, second) {
return function () {
first.apply(this, arguments);
second.apply(this, arguments);
};
}
export default register;