conditioner-core
Version:
Conditioner - Frizz free, context-aware, JavaScript modules
485 lines (390 loc) • 15.3 kB
JavaScript
/* conditioner-core 2.3.3 */
(function (global, factory) {
if (typeof define === "function" && define.amd) {
define(['exports'], factory);
} else if (typeof exports !== "undefined") {
factory(exports);
} else {
var mod = {
exports: {}
};
factory(mod.exports);
global.conditioner = mod.exports;
}
})(this, function (exports) {
'use strict';
Object.defineProperty(exports, "__esModule", {
value: true
});
var _slicedToArray = function () {
function sliceIterator(arr, i) {
var _arr = [];
var _n = true;
var _d = false;
var _e = undefined;
try {
for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) {
_arr.push(_s.value);
if (i && _arr.length === i) break;
}
} catch (err) {
_d = true;
_e = err;
} finally {
try {
if (!_n && _i["return"]) _i["return"]();
} finally {
if (_d) throw _e;
}
}
return _arr;
}
return function (arr, i) {
if (Array.isArray(arr)) {
return arr;
} else if (Symbol.iterator in Object(arr)) {
return sliceIterator(arr, i);
} else {
throw new TypeError("Invalid attempt to destructure non-iterable instance");
}
};
}();
function _toConsumableArray(arr) {
if (Array.isArray(arr)) {
for (var i = 0, arr2 = Array(arr.length); i < arr.length; i++) {
arr2[i] = arr[i];
}
return arr2;
} else {
return Array.from(arr);
}
}
// links the module to the element and exposes a callback api object
var bindModule = function bindModule(element, unbind) {
// gets the name of the module from the element, we assume the name is an alias
var alias = runPlugin('moduleGetName', element);
// sets the name of the plugin, this does nothing by default but allows devs to turn an alias into the actual module name
var name = chainPlugins('moduleSetName', alias);
// internal state
var state = {
destruct: null, // holder for unload method (function returned by module constructor)
mounting: false
};
// api wrapped around module object
var boundModule = {
// original name as found on the element
alias: alias,
// transformed name
name: name,
// reference to the element the module is bound to
element: element,
// is the module currently mounted?
mounted: false,
// unmounts the module
unmount: function unmount() {
// can't be unmounted if no destroy method has been supplied
// can't be unmounted if not mounted
if (!state.destruct || !boundModule.mounted) return;
// about to unmount the module
eachPlugins('moduleWillUnmount', boundModule);
// clean up
state.destruct();
// no longer mounted
boundModule.mounted = false;
// done unmounting the module
eachPlugins('moduleDidUnmount', boundModule);
// done unmounting
boundModule.onunmount.apply(element);
},
// requests and loads the module
mount: function mount() {
// can't mount an already mounted module
// can't mount a module that is currently mounting
if (boundModule.mounted || state.mounting) return;
// now mounting module
state.mounting = true;
// about to mount the module
eachPlugins('moduleWillMount', boundModule);
// get the module
runPlugin('moduleImport', name).then(function (module) {
// initialise the module, module can return a destroy mehod
state.destruct = runPlugin('moduleGetDestructor', runPlugin('moduleGetConstructor', module).apply(undefined, _toConsumableArray(runPlugin('moduleSetConstructorArguments', name, element))));
// no longer mounting
state.mounting = false;
// module is now mounted
boundModule.mounted = true;
// did mount the module
eachPlugins('moduleDidMount', boundModule);
// module has now loaded lets fire the onload event so everyone knows about it
boundModule.onmount.apply(element, [boundModule]);
}).catch(function (error) {
// failed to mount so no longer mounting
state.mounting = false;
// failed to mount the module
eachPlugins('moduleDidCatch', error, boundModule);
// callback for this specific module
boundModule.onmounterror.apply(element, [error, boundModule]);
// let dev know
throw new Error('Conditioner: ' + error);
});
// return state object
return boundModule;
},
// unmounts the module and destroys the attached monitors
destroy: function destroy() {
// about to destroy the module
eachPlugins('moduleWillDestroy', boundModule);
// not implemented yet
boundModule.unmount();
// did destroy the module
eachPlugins('moduleDidDestroy', boundModule);
// call public ondestroy so dev can handle it as well
boundModule.ondestroy.apply(element);
// call the destroy callback so monitor can be removed as well
unbind();
},
// called when fails to bind the module
onmounterror: function onmounterror() {},
// called when the module is loaded, receives the state object, scope is set to element
onmount: function onmount() {},
// called when the module is unloaded, scope is set to element
onunmount: function onunmount() {},
// called when the module is destroyed
ondestroy: function ondestroy() {}
};
// done!
return boundModule;
};
var queryParamsRegex = /(was)? ?(not)? ?@([a-z]+) ?(.*)?/;
var queryRegex = /(?:was )?(?:not )?@[a-z]+ ?.*?(?:(?= and (?:was )?(?:not )?@[a-z])|$)/g;
// convert context values to booleans if value is undefined or a boolean described as string
var toContextValue = function toContextValue(value) {
return typeof value === 'undefined' || value === 'true' ? true : value === 'false' ? false : value;
};
var extractParams = function extractParams(query) {
var _query$match = query.match(queryParamsRegex),
_query$match2 = _slicedToArray(_query$match, 5),
retain = _query$match2[1],
invert = _query$match2[2],
name = _query$match2[3],
value = _query$match2[4];
// extract groups, we ignore the first array index which is the entire matches string
return [name, toContextValue(value), invert === 'not', retain === 'was'];
};
// @media (min-width:30em) and was @visible true -> [ ['media', '(min-width:30em)', false, false], ['visible', 'true', false, true] ]
var parseQuery = function parseQuery(query) {
return query.match(queryRegex).map(extractParams);
};
// add intert and retain properties to monitor
var decorateMonitor = function decorateMonitor(monitor, invert, retain) {
monitor.invert = invert;
monitor.retain = retain;
monitor.matched = false;
return monitor;
};
// finds monitor plugins and calls the create method on the first found monitor
var getContextMonitor = function getContextMonitor(element, name, context) {
var monitor = getPlugins('monitor').find(function (monitor) {
return monitor.name === name;
});
// @exclude
if (!monitor) {
throw new Error('Conditioner: Cannot find monitor with name "@' + name + '". Only the "@media" monitor is always available. Custom monitors can be added with the `addPlugin` method using the `monitors` key. The name of the custom monitor should not include the "@" symbol.');
}
// @endexclude
return monitor.create(context, element);
};
// test if monitor contexts are currently valid
var matchMonitors = function matchMonitors(monitors) {
return monitors.reduce(function (matches, monitor) {
// an earlier monitor returned false, so current context will no longer be suitable
if (!matches) return false;
// get current match state, takes "not" into account
var matched = monitor.invert ? !monitor.matches : monitor.matches;
// mark monitor as has been matched in the past
if (matched) monitor.matched = true;
// if retain is enabled with "was" and the monitor has been matched in the past, there's a match
if (monitor.retain && monitor.matched) return true;
// return current match state
return matched;
},
// initial value is always match
true);
};
var monitor = exports.monitor = function monitor(query, element) {
// setup monitor api
var contextMonitor = {
matches: false,
active: false,
onchange: function onchange() {},
start: function start() {
// cannot be activated when already active
if (contextMonitor.active) return;
// now activating
contextMonitor.active = true;
// listen for context changes
monitorSets.forEach(function (monitorSet) {
return monitorSet.forEach(function (monitor) {
return monitor.addListener(onMonitorEvent);
});
});
// get initial state
onMonitorEvent();
},
stop: function stop() {
// disable the monitor
contextMonitor.active = false;
// disable
monitorSets.forEach(function (monitorSet) {
return monitorSet.forEach(function (monitor) {
// stop listening (if possible)
if (!monitor.removeListener) return;
monitor.removeListener(onMonitorEvent);
});
});
},
destroy: function destroy() {
contextMonitor.stop();
monitorSets.length = 0;
}
};
// get different monitor sets (each 'or' creates a separate monitor set) > get monitors for each query
var monitorSets = query.split(' or ').map(function (subQuery) {
return parseQuery(subQuery).map(function (params) {
return decorateMonitor.apply(undefined, [getContextMonitor.apply(undefined, [element].concat(_toConsumableArray(params)))].concat(_toConsumableArray(params.splice(2))));
});
});
// if all monitors return true for .matches getter, we mount the module
var onMonitorEvent = function onMonitorEvent() {
// will keep returning false if one of the monitors does not match, else checks matches property
var matches = monitorSets.reduce(function (matches, monitorSet) {
return (
// if one of the sets is true, it's all fine, no need to match the other sets
matches ? true : matchMonitors(monitorSet)
);
}, false);
// store new state
contextMonitor.matches = matches;
// if matches we mount the module, else we unmount
contextMonitor.onchange(matches);
};
return contextMonitor;
};
// handles contextual loading and unloading
var createContextualModule = function createContextualModule(query, boundModule) {
// setup query monitor
var moduleMonitor = monitor(query, boundModule.element);
moduleMonitor.onchange = function (matches) {
return matches ? boundModule.mount() : boundModule.unmount();
};
// start monitoring
moduleMonitor.start();
// export monitor
return moduleMonitor;
};
// pass in an element and outputs a bound module object, will wrap bound module in a contextual module if required
var createModule = function createModule(element) {
// called when the module is destroyed
var unbindModule = function unbindModule() {
return monitor && monitor.destroy();
};
// bind the module to the element and receive the module wrapper API
var boundModule = bindModule(element, unbindModule);
// get context requirements for this module (if any have been defined)
var query = runPlugin('moduleGetContext', element);
// wait for the right context or load the module immidiately if no context supplied
var monitor = query && createContextualModule(query, boundModule);
// return module
return query ? boundModule : boundModule.mount();
};
// parse a certain section of the DOM and load bound modules
var hydrate = exports.hydrate = function hydrate(context) {
return [].concat(_toConsumableArray(runPlugin('moduleSelector', context))).map(createModule);
};
// all registered plugins
var plugins = [];
// array includes 'polyfill', Array.prototype.includes was the only feature not supported on Edge
var includes = function includes(arr, value) {
return arr.indexOf(value) > -1;
};
// plugins are stored in an array as multiple plugins can subscribe to one hook
var addPlugin = exports.addPlugin = function addPlugin(plugin) {
return plugins.push(plugin);
};
// returns the plugins that match the requested type, as plugins can subscribe to multiple hooks we need to loop over the plugin keys to see if it matches
var getPlugins = function getPlugins(type) {
return plugins.filter(function (plugin) {
return includes(Object.keys(plugin), type);
}).map(function (plugin) {
return plugin[type];
});
};
// run for each of the registered plugins
var eachPlugins = function eachPlugins(type) {
for (var _len = arguments.length, args = Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) {
args[_key - 1] = arguments[_key];
}
return getPlugins(type).forEach(function (plugin) {
return plugin.apply(undefined, args);
});
};
// run registered plugins but chain input -> output (sync)
var chainPlugins = function chainPlugins(type) {
for (var _len2 = arguments.length, args = Array(_len2 > 1 ? _len2 - 1 : 0), _key2 = 1; _key2 < _len2; _key2++) {
args[_key2 - 1] = arguments[_key2];
}
return getPlugins(type).reduce(function (args, plugin) {
return [plugin.apply(undefined, _toConsumableArray(args))];
}, args).shift();
};
// run on last registered plugin
var runPlugin = function runPlugin(type) {
for (var _len3 = arguments.length, args = Array(_len3 > 1 ? _len3 - 1 : 0), _key3 = 1; _key3 < _len3; _key3++) {
args[_key3 - 1] = arguments[_key3];
}
return getPlugins(type).pop().apply(undefined, args);
};
// default plugin configuration
addPlugin({
// select all elements that have modules assigned to them
moduleSelector: function moduleSelector(context) {
return context.querySelectorAll('[data-module]');
},
// returns the context query as defined on the element
moduleGetContext: function moduleGetContext(element) {
return element.dataset.context;
},
// load the referenced module, by default searches global scope for module name
moduleImport: function moduleImport(name) {
return new Promise(function (resolve, reject) {
if (self[name]) return resolve(self[name]);
// @exclude
reject('Cannot find module with name "' + name + '". By default Conditioner will import modules from the global scope, make sure a function named "' + name + '" is defined on the window object. The scope of a function defined with `let` or `const` is limited to the <script> block in which it is defined.');
// @endexclude
});
},
// returns the module constructor, by default we assume the module returned is a factory function
moduleGetConstructor: function moduleGetConstructor(module) {
return module;
},
// returns the module destrutor, by default we assume the constructor exports a function
moduleGetDestructor: function moduleGetDestructor(moduleExports) {
return moduleExports;
},
// arguments to pass to the module constructor as array
moduleSetConstructorArguments: function moduleSetConstructorArguments(name, element) {
return [element];
},
// where to get name of module
moduleGetName: function moduleGetName(element) {
return element.dataset.module;
},
// default media query monitor
monitor: {
name: 'media',
create: function create(context) {
return self.matchMedia(context);
}
}
});
});