infuse.host
Version:
Infuse your HTML with dynamic content.
384 lines (323 loc) • 13.3 kB
JavaScript
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.Host = exports.clear = undefined;
exports.createContext = createContext;
exports.initializeElement = initializeElement;
exports.infuseTemplate = infuseTemplate;
exports.default = infuse;
exports.CustomHost = CustomHost;
var _Watch = require('./Watch.js');
var _Watch2 = _interopRequireDefault(_Watch);
var _utils = require('./utils.js');
var _infuseElement = require('./infuseElement.js');
var _infuseElement2 = _interopRequireDefault(_infuseElement);
var _sweep = require('./sweep.js');
var _sweep2 = _interopRequireDefault(_sweep);
var _configs = require('./configs.js');
var _configs2 = _interopRequireDefault(_configs);
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
// Export the `sweep` function as `clear`.
exports.clear = _sweep2.default;
/**
* Creates a context object for the given `element`.
*
* @function createContext
* @param {Element} element The element for which the context will be created.
* @param {Element} host The host element.
* @param {Object} data The data object.
* @param {Object} iterationData The iteration data object.
* @returns {Object} The created context object or `undefined` if the element doesn't have the
* context function id attribute.
*/
function createContext(element, host, data, iterationData) {
const tags = _configs2.default.get('tags');
const contextFnId = _configs2.default.get('contextFunctionId');
// Do not proceed if the element doesn't have the context function id attribute.
if (!element.hasAttribute(contextFnId)) {
return undefined;
}
// Get the ID of the element's context function and remove the attribute.
const ctxId = element.getAttribute(contextFnId);
element.removeAttribute(contextFnId);
// Get the element's context function.
const ctxFn = _configs.contextFunctions.get(ctxId);
// Execute the context function to create a new context object for the given `element`.
const context = ctxFn.call(element, host, data, iterationData, tags);
// Add it to `contexts` and infuse the element.
_configs.contexts.set(element, context);
// Add a function to delete the element's `context` when the `element` is removed from the DOM.
(0, _sweep.addCleanupFunction)(element, () => {
_configs.contexts.delete(element);
});
return context;
}
/**
* Creates a context for the given `element`, infuses it, and if needed, adds event listeners and
* watches.
*
* @function initializeElement
* @param {Element} element The element to infuse.
* @param {Element} host The host element.
* @param {Object} [data] The data object.
* @param {Object} [iterationData] Optional iteration data object.
*/
function initializeElement(element, host, data, iterationData) {
const context = createContext(element, host, data, iterationData);
const { constants, eventListeners, watches } = context;
(0, _infuseElement2.default)(element);
// Add event listeners.
if (eventListeners) {
eventListeners.forEach((callback, name) => {
// Add the event listener to the element.
element.addEventListener(name, callback, false);
/**
* Add a function to remove the event listener when the `element` is
* removed from the DOM.
*/
(0, _sweep.addCleanupFunction)(element, () => {
element.removeEventListener(name, callback, false);
});
});
}
// Do not continue if there are no watches.
if (!watches) {
return;
}
/**
* Keys in `watches` are names of the constants/elements to "watch" and the values are
* event maps, which can be:
* * A string: 'eventType1[ selector1][; eventType2[ selector2]]'
* * An array: [['eventType1[ selector1][; eventType2[ selector2]]', 'parts']]
* * An object: {'eventType1[ selector1][; eventType2[ selector2]]': 'parts'}
*/
Array.from(watches.keys()).forEach(name => {
const el = name === 'this' ? element : constants[name];
let eventMap = watches.get(name);
if (!(eventMap instanceof Map)) {
// Turn `eventMap` (string, array, or object) into a `Map` instance.
if (typeof eventMap === 'string') {
eventMap = [[eventMap, '*']];
} else if (!Array.isArray(eventMap)) {
eventMap = Object.keys(eventMap).map(key => [key, eventMap[key]]);
}
eventMap = new Map(eventMap);
}
Array.from(eventMap.keys()).forEach(key => {
// These are the parts that will be infused when the event is triggered.
const parts = eventMap.get(key);
key.trim().split(';').forEach(eventAndSelector => {
let selector = null;
let eventName = eventAndSelector.trim();
const i = eventName.indexOf(' ');
/**
* If `eventName` contains a space, everything before the space is the
* event name and everything after the space is the selector.
*/
if (i !== -1) {
selector = eventName.substr(i + 1);
eventName = eventName.substring(0, i);
}
// Get a `Watch` for the `eventName` on the `el` element.
const watch = _Watch2.default.for(el, eventName);
// Add `element` as watcher.
watch.addWatcher(element, { selector, parts });
});
});
});
}
/**
* Clone and infuse the given template.
*
* @param {Element} host The host element.
* @param {HTMLTemplate} template The HTML template element to clone and infuse.
* @param {Object} [data={}] Optional data object.
* @param {Object} [iterationData={}] Optional iteration data object.
* @returns {DocumentFragment} The infused document fragment.
*/
function infuseTemplate(host, template, data = {}, iterationData = {}) {
const fragment = template.content.cloneNode(true);
const selector = `[${_configs2.default.get('contextFunctionId')}]`;
// Search for, initialize, and infuse all elements that have a context function ID attribute.
for (const element of Array.from(fragment.querySelectorAll(selector))) {
initializeElement(element, host, data, iterationData);
}
const placeholderId = _configs2.default.get('placeholderId');
// Search for placeholder templates and iterate over them.
for (const placeholder of Array.from(fragment.querySelectorAll(`[${placeholderId}]`))) {
// Get the placeholder ID, which is the ID of the original template.
const tid = placeholder.getAttribute(placeholderId);
// Use the ID to get the original template.
const nestedTemplate = _configs.parsedTemplates.get(tid);
// Call `infuse` using the original template.
// eslint-disable-next-line no-use-before-define
const nestedFragment = infuse(host, nestedTemplate, data, iterationData);
// Replace the placeholder with the fragment returned by `infuse`.
placeholder.parentNode.replaceChild(nestedFragment, placeholder);
}
return fragment;
}
/**
* Clone and infuse the given template. If the template has an "each" attribute with a valid
* expression, it will evaluate the expression, iterate over the result, clone and infuse the
* template once for each iteration, and join the generated fragments together.
*
* @function infuse
* @param {Element} host The host element.
* @param {HTMLTemplate} template The HTML template.
* @param {Object} [data={}] Optional data object.
* @param {Object} [iterationData={}] Optional iteration data object.
* @returns {DocumentFragment} The infused document fragment.
*/
function infuse(host, template, data = {}, iterationData = {}) {
// Create a context (if one can be created).
const context = createContext(template, host, data, iterationData);
// If the context couldn't be created, call infuseTemplate.
if (!context) {
return infuseTemplate(host, template, data, iterationData);
}
const { forVariableNames, parts } = context;
const fn = parts.get('each');
if (typeof fn !== 'function') {
throw new TypeError('The template attribute "each" is either invalid or missing.');
}
// Evaluate the expression of the "each" attribute.
const collection = fn();
if (!collection || typeof collection.forEach !== 'function') {
throw new TypeError('Evaluating the "each" expression resulted in an invalid value. The expression must return a value that has a "forEach" method, for instance: an array, a Map, or a Set.');
}
/**
* Iterate over the result of the "each" expression, calling `infuseTemplate` for each
* iteration, and adding the generated fragments to `fragments`.
*/
const fragments = [];
collection.forEach((...args) => {
// Create a new `iteration` data object containing the same attributes as `iterationData`.
const iteration = { ...iterationData };
/**
* Add the values of this iteration (`args` contains [value, key, collection]) to the
* `iteration` object using the variable names specified in the `forVariableNames` array.
*/
for (let i = 0; i < forVariableNames.length; i++) {
const name = forVariableNames[i];
iteration[name] = args[i];
}
// Call `infuseTemplate`, using the `iteration` object, and add `fragment` to `fragments`.
const fragment = infuseTemplate(host, template, data, iteration);
fragments.push(fragment);
});
// Return empty fragment if the iteration didn't generate any fragments.
if (fragments.length === 0) {
return document.createDocumentFragment();
}
// Join the fragments generated during the iteration and return the resulting fragment.
return fragments.reduce((accumulator, fragment) => {
accumulator.appendChild(fragment);
return accumulator;
});
}
/**
* Closed Shadow DOMs will be stored here.
*/
const closedShadowRoots = new WeakMap();
/**
* Returns the root of the given element. The root can be a "closed" Shadow DOM, which is stored in
* `closedShadowRoots`, an "open" Shadow DOM, which is stored in `element.shadowRoot`, or the
* `element` itself.
*
* @function getRoot
* @param {Element} element
* @returns {(ShadowRoot|Element)}
*/
function getRoot(element) {
return closedShadowRoots.get(element) || element.shadowRoot || element;
}
/**
* Uses (extends) the given element class to define a custom element class that uses the `infuse`
* function to generate its contents. The `template` getter must be overwritten to return a
* template, which would be cloned and infused when the element is added to the DOM (when
* `connectedCallback` is called). When the element is removed from the DOM (and
* `disconnectedCallback` is called) all memory allocated by infuse process (associated with the
* element **and any of its descendants**) will be cleared.
*
* This function defines a class that extends the given `ElementClass`. The returned class can be
* extended to define custom elements. This function can be used to define [custom
* element](https://developers.google.com/web/fundamentals/web-components/customelements) classes,
* by using `HTMLElement`, or [customized built-in
* element](https://developers.google.com/web/fundamentals/web-components/customelements#extendhtml)
* classes, by using the class of the native element that you want to extend (for instance use
* `HTMLLIElement` if you want to extend the native `<li>` element).
*
* @function CustomHost
* @param ElementClass The element class to extend.
* @returns The custom element class.
*/
function CustomHost(ElementClass) {
return class extends ElementClass {
/**
* If `this.shadowRootMode` is set, this constructor creates a shadow root and renders
* `this.template` into the shadow root.
*/
constructor() {
super();
const mode = (0, _utils.result)(this, 'shadowRootMode');
// If `this.shadowRootMode` is set...
if (mode) {
// Create a Shadow DOM.
const shadowRoot = this.attachShadow({ mode });
// If it's a "closed" Shadow DOM, add it to `closedShadowRoots`.
if (mode === 'closed') {
closedShadowRoots.set(this, shadowRoot);
}
// Render the element's template into the Shadow DOM.
this.render();
}
}
/**
* Clones and infuses `this.template` and appends the resulting fragment to the element's
* root (a Shadow DOM or directly to the element in the regular DOM).
*
* @method render
*/
render() {
const template = (0, _utils.result)(this, 'template');
if (template instanceof HTMLTemplateElement) {
const root = getRoot(this);
// Clone and infuse the `template` and append the resulting fragment to `root`.
root.appendChild(infuse(this, template));
}
}
/**
* Uses the template provided by the `template` getter to generate the contents of this
* element when the element is added to the DOM. Performs no action if the `template` getter
* returns a falsy value.
*
* @method connectedCallback
*/
connectedCallback() {
// Call `this.render` if this element doesn't use a Shadow DOM.
if (!this.shadowRootMode) {
this.render();
}
}
/**
* When the element is removed from the DOM, this method clears all memory associated with
* this element, **and any of its descendants**, that was allocated by infuse process.
*
* @method disconnectedCallback
*/
disconnectedCallback() {
(0, _sweep2.default)(this, getRoot(this));
closedShadowRoots.delete(this);
}
};
}
/**
* This class extends the `HTMLElement` class and can be used to define [custom
* elements](https://developers.google.com/web/fundamentals/web-components/customelements).
*
* @class
*/
class Host extends CustomHost(HTMLElement) {}
exports.Host = Host;
;