UNPKG

conditioner-core

Version:

Conditioner - Frizz free, context-aware, JavaScript modules

362 lines (279 loc) 11.6 kB
// links the module to the element and exposes a callback api object const bindModule = (element, unbind) => { // gets the name of the module from the element, we assume the name is an alias const 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 const name = chainPlugins('moduleSetName', alias); // internal state const state = { destruct: null, // holder for unload method (function returned by module constructor) mounting: false }; // api wrapped around module object const boundModule = { // original name as found on the element alias, // transformed name name, // reference to the element the module is bound to element, // is the module currently mounted? mounted: false, // unmounts the module 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: () => { // 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(module => { // initialise the module, module can return a destroy mehod state.destruct = runPlugin( 'moduleGetDestructor', runPlugin('moduleGetConstructor', module)( ...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(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() { // 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() {}, // called when the module is loaded, receives the state object, scope is set to element onmount: function() {}, // called when the module is unloaded, scope is set to element onunmount: function() {}, // called when the module is destroyed ondestroy: function() {} }; // done! return boundModule; }; const queryParamsRegex = /(was)? ?(not)? ?@([a-z]+) ?(.*)?/; const 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 const toContextValue = value => typeof value === 'undefined' || value === 'true' ? true : value === 'false' ? false : value; const extractParams = query => { const [, retain, invert, name, value] = query.match(queryParamsRegex); // 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] ] const parseQuery = query => query.match(queryRegex).map(extractParams); // add intert and retain properties to monitor const 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 const getContextMonitor = (element, name, context) => { const monitor = getPlugins('monitor').find(monitor => 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 const matchMonitors = monitors => monitors.reduce( (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 const 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 ); export const monitor = (query, element) => { // setup monitor api const contextMonitor = { matches: false, active: false, onchange: function() {}, start: () => { // cannot be activated when already active if (contextMonitor.active) return; // now activating contextMonitor.active = true; // listen for context changes monitorSets.forEach(monitorSet => monitorSet.forEach(monitor => monitor.addListener(onMonitorEvent)) ); // get initial state onMonitorEvent(); }, stop: () => { // disable the monitor contextMonitor.active = false; // disable monitorSets.forEach(monitorSet => monitorSet.forEach(monitor => { // stop listening (if possible) if (!monitor.removeListener) return; monitor.removeListener(onMonitorEvent); }) ); }, destroy: () => { contextMonitor.stop(); monitorSets.length = 0; } }; // get different monitor sets (each 'or' creates a separate monitor set) > get monitors for each query const monitorSets = query .split(' or ') .map(subQuery => parseQuery(subQuery).map(params => decorateMonitor(getContextMonitor(element, ...params), ...params.splice(2)) ) ); // if all monitors return true for .matches getter, we mount the module const onMonitorEvent = () => { // will keep returning false if one of the monitors does not match, else checks matches property const matches = monitorSets.reduce((matches, monitorSet) => // 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 const createContextualModule = (query, boundModule) => { // setup query monitor const moduleMonitor = monitor(query, boundModule.element); moduleMonitor.onchange = matches => 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 const createModule = element => { // called when the module is destroyed const unbindModule = () => monitor && monitor.destroy(); // bind the module to the element and receive the module wrapper API const boundModule = bindModule(element, unbindModule); // get context requirements for this module (if any have been defined) const query = runPlugin('moduleGetContext', element); // wait for the right context or load the module immidiately if no context supplied const monitor = query && createContextualModule(query, boundModule); // return module return query ? boundModule : boundModule.mount(); }; // parse a certain section of the DOM and load bound modules export const hydrate = context => [...runPlugin('moduleSelector', context)].map(createModule); // all registered plugins const plugins = []; // array includes 'polyfill', Array.prototype.includes was the only feature not supported on Edge const includes = (arr, value) => arr.indexOf(value) > -1; // plugins are stored in an array as multiple plugins can subscribe to one hook export const addPlugin = plugin => 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 const getPlugins = type => plugins.filter(plugin => includes(Object.keys(plugin), type)).map(plugin => plugin[type]); // run for each of the registered plugins const eachPlugins = (type, ...args) => getPlugins(type).forEach(plugin => plugin(...args)); // run registered plugins but chain input -> output (sync) const chainPlugins = (type, ...args) => getPlugins(type) .reduce((args, plugin) => [plugin(...args)], args) .shift(); // run on last registered plugin const runPlugin = (type, ...args) => getPlugins(type).pop()(...args); // default plugin configuration addPlugin({ // select all elements that have modules assigned to them moduleSelector: context => context.querySelectorAll('[data-module]'), // returns the context query as defined on the element moduleGetContext: element => element.dataset.context, // load the referenced module, by default searches global scope for module name moduleImport: name => new Promise((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: module => module, // returns the module destrutor, by default we assume the constructor exports a function moduleGetDestructor: moduleExports => moduleExports, // arguments to pass to the module constructor as array moduleSetConstructorArguments: (name, element) => [element], // where to get name of module moduleGetName: element => element.dataset.module, // default media query monitor monitor: { name: 'media', create: context => self.matchMedia(context) } });