UNPKG

@adobe/coral-spectrum

Version:

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

692 lines (583 loc) 23.6 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 {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>&lt;button is="custom-element"&gt;</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;