lib/DomObserver.js
- /*!
- * The MIT License (MIT)
- *
- * Copyright (c) 2016 Mykhailo Stadnyk <mikhus@gmail.com>
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy
- * of this software and associated documentation files (the "Software"), to deal
- * in the Software without restriction, including without limitation the rights
- * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
- * copies of the Software, and to permit persons to whom the Software is
- * furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in
- * all copies or substantial portions of the Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
- * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
- * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
- * SOFTWARE.
- */
- /**
- * @typedef {{ constructor: function(options: GenericOptions): GaugeInterface, draw: function(): GaugeInterface, destroy: function, update: function(options: GenericOptions) }} GaugeInterface
- */
- /**
- * @typedef {{parse: function, stringify: function}} JSON
- * @external {JSON} https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/JSON
- */
- /**
- * @ignore
- * @typedef {{MutationObserver: function}} ns
- */
-
- /**
- * DOM Observer.
- * It will observe DOM document for a configured element types and
- * instantiate associated Types for an existing or newly added DOM elements
- *
- * @example
- * class ProgressBar {
- * constructor(options) {}
- * draw() {}
- * }
- *
- * // It will observe DOM document for elements <div>
- * // having attribute 'data-type="progress"'
- * // and instantiate for each new instance of ProgressBar
- *
- * new DomParser({color: 'red'}, 'div', 'progress', ProgressBar);
- *
- * // assume we could have HTML like this
- * // <div data-type="progress" color="blue"></div>
- * // in this case all matching attributes names for a given options will be
- * // parsed and bypassed to an instance from HTML attributes
- */
- export default class DomObserver {
-
- /**
- * @constructor
- * @param {object} options
- * @param {string} element
- * @param {string} type
- */
- constructor(options, element, type) {
- //noinspection JSUnresolvedVariable
- /**
- * Default instantiation options for the given type
- *
- * @type {Object}
- */
- this.options = options;
-
- /**
- * Name of an element to lookup/observe
- *
- * @type {string}
- */
- this.element = element.toLowerCase();
-
- /**
- * data-type attribute value to lookup
- *
- * @type {string}
- */
- this.type = DomObserver.toDashed(type);
-
- /**
- * Actual type constructor to instantiate for each found element
- *
- * @type {Function}
- */
- this.Type = ns[type];
-
- /**
- * Signals if mutations observer for this type or not
- *
- * @type {boolean}
- */
- this.mutationsObserved = false;
-
- /**
- * Flag specifies whenever the browser supports observing
- * of DOM tree mutations or not
- *
- * @type {boolean}
- */
- this.isObservable = !!window.MutationObserver;
-
- /* istanbul ignore next: this should be tested with end-to-end tests */
- if (!window.GAUGES_NO_AUTO_INIT) {
- DomObserver.domReady(this.traverse.bind(this));
- }
- }
-
- /**
- * Checks if given node is valid node to process
- *
- * @param {Node|HTMLElement} node
- * @returns {boolean}
- */
- isValidNode(node) {
- //noinspection JSUnresolvedVariable
- return !!(
- node.tagName &&
- node.tagName.toLowerCase() === this.element &&
- node.getAttribute('data-type') === this.type
- );
- }
-
- /**
- * Traverse entire current DOM tree and process matching nodes.
- * Usually it should be called only once on document initialization.
- */
- traverse() {
- let elements = document.getElementsByTagName(this.element);
- let i = 0, s = elements.length;
-
- /* istanbul ignore next: this should be tested with end-to-end tests */
- for (; i < s; i++) {
- this.process(elements[i]);
- }
-
- if (this.isObservable && !this.mutationsObserved) {
- new MutationObserver(this.observe.bind(this))
- .observe(document.body, {
- childList: true,
- subtree: true,
- attributes: true,
- characterData: true,
- attributeOldValue: true,
- characterDataOldValue: true
- });
-
- this.mutationsObserved = true;
- }
- }
-
- /**
- * Observes given mutation records for an elements to process
- *
- * @param {MutationRecord[]} records
- */
- observe(records) {
- let i = 0;
- let s = records.length;
-
- /* istanbul ignore next: this should be tested with end-to-end tests */
- for (; i < s; i++) {
- let record = records[i];
-
- if (record.type === 'attributes' &&
- record.attributeName === 'data-type' &&
- this.isValidNode(record.target) &&
- record.oldValue !== this.type) // skip false-positive mutations
- {
- setTimeout(this.process.bind(this, record.target));
- }
-
- else if (record.addedNodes && record.addedNodes.length) {
- let ii = 0;
- let ss = record.addedNodes.length;
-
- for (; ii < ss; ii++) {
- setTimeout(this.process.bind(this, record.addedNodes[ii]));
- }
- }
- }
- }
-
- /**
- * Parses given attribute value to a proper JavaScript value.
- * For example it will parse some stringified value to a proper type
- * value, e.g. 'true' => true, 'null' => null, '{"prop": 20}' => {prop: 20}
- *
- * @param {*} value
- * @return {*}
- */
- static parse(value) {
- // parse boolean
- if (value === 'true') return true;
- if (value === 'false') return false;
-
- // parse undefined
- if (value === 'undefined') return undefined;
-
- // parse null
- if (value === 'null') return null;
-
- // Comma-separated strings to array parsing.
- // It won't match strings which contains non alphanumeric characters to
- // prevent strings like 'rgba(0,0,0,0)' or JSON-like from being parsed.
- // Typically it simply allows easily declare arrays as comma-separated
- // numbers or plain strings. If something more complicated is
- // required it can be declared using JSON format syntax
- if (/^[-+#.\w\d\s]+(?:,[-+#.\w\d\s]*)+$/.test(value)) {
- return value.split(',');
- }
-
- // parse JSON
- try { return JSON.parse(value); } catch(e) {}
-
- // plain value - no need to parse
- return value;
- }
-
- /**
- * Processes a given node, instantiating a proper type constructor for it
- *
- * @param {Node|HTMLElement} node
- * @returns {GaugeInterface|null}
- */
- process(node) {
- if (!this.isValidNode(node)) return null;
-
- let prop;
- let options = JSON.parse(JSON.stringify(this.options));
- let instance = null;
-
- for (prop in options) {
- /* istanbul ignore else: non-testable in most cases */
- if (options.hasOwnProperty(prop)) {
- let attributeName = DomObserver.toAttributeName(prop);
- let attributeValue = DomObserver.parse(
- node.getAttribute(attributeName));
-
- if (attributeValue !== null && attributeValue !== undefined) {
- options[prop] = attributeValue;
- }
- }
- }
-
- options.renderTo = node;
- instance = new (this.Type)(options);
- instance.draw && instance.draw();
-
- if (!this.isObservable) return instance;
-
- instance.observer = new MutationObserver(records => {
- records.forEach(record => {
- if (record.type === 'attributes') {
- let attr = record.attributeName.toLowerCase();
- let type = node.getAttribute(attr).toLowerCase();
-
- if (attr === 'data-type' && type && type !== this.type) {
- instance.observer.disconnect();
- delete instance.observer;
- instance.destroy && instance.destroy();
- }
-
- else if (attr.substr(0, 5) === 'data-') {
- let prop = attr.substr(5).split('-').map((part, i) => {
- return !i ? part :
- part.charAt(0).toUpperCase() + part.substr(1);
- }).join('');
- let options = {};
-
- options[prop] = DomObserver.parse(
- node.getAttribute(record.attributeName));
-
-
- if (prop === 'value') {
- instance && (instance.value = options[prop]);
- }
-
- else {
- instance.update && instance.update(options);
- }
- }
- }
- });
- });
-
- //noinspection JSCheckFunctionSignatures
- instance.observer.observe(node, { attributes: true });
-
- return instance;
- }
-
- /**
- * Transforms camelCase string to dashed string
- *
- * @static
- * @param {string} camelCase
- * @return {string}
- */
- static toDashed(camelCase) {
- let arr = camelCase.split(/(?=[A-Z])/);
- let i = 1;
- let s = arr.length;
- let str = arr[0].toLowerCase();
-
- for (; i < s; i++) {
- str += '-' + arr[i].toLowerCase();
- }
-
- return str;
- }
-
- /**
- * Transforms dashed string to CamelCase representation
- *
- * @param {string} dashed
- * @param {boolean} [capitalized]
- * @return {string}
- */
- static toCamelCase(dashed, capitalized = true) {
- let arr = dashed.split(/-/);
- let i = 0;
- let s = arr.length;
- let str = '';
-
- for (; i < s; i++) {
- if (!(i || capitalized)) {
- str += arr[i].toLowerCase();
- }
-
- else {
- str += arr[i][0].toUpperCase() + arr[i].substr(1).toLowerCase();
- }
- }
-
- return str;
- }
-
- /**
- * Transforms camel case property name to dash separated attribute name
- *
- * @static
- * @param {string} str
- * @returns {string}
- */
- static toAttributeName(str) {
- return 'data-' + DomObserver.toDashed(str);
- }
-
- /**
- * Cross-browser DOM ready handler
- *
- * @static
- * @param {Function} handler
- */
- static domReady(handler) {
- if (/comp|inter|loaded/.test((window.document || {}).readyState + ''))
- return handler();
-
- if (window.addEventListener)
- window.addEventListener('DOMContentLoaded', handler, false);
-
- else if (window.attachEvent)
- window.attachEvent('onload', handler);
- }
-
- }
-
- module.exports = DomObserver;