Skip to content

Instantly share code, notes, and snippets.

@Skateside
Created April 21, 2015 10:04
Show Gist options
  • Save Skateside/c6d6a50daf5d0be9c8e9 to your computer and use it in GitHub Desktop.
Save Skateside/c6d6a50daf5d0be9c8e9 to your computer and use it in GitHub Desktop.
Watch for attribute changes
/**
* watch(element, attribute, handler)
* - element (Element): Element whose attribute should be watched for changes.
* - attribute (String): Attribute to watch.
* - handler (Function): Function to execute when the attribute changes.
*
* `watch` allows DOM Nodes to have their attributes watched for changes.
* Internally, this function uses the most efficient mean possible to watch for
* the change (more in the **Configuring** section).
*
*
* ## Example
*
* To better understand how `watch` works, consider this DOM Node:
*
* <div class="one"></div>
*
* ... and assume that this variable exists:
*
* var div = document.querySelector('.one');
*
* In that situation, `watch` would be used like this:
*
* watch(div, 'class', function (newClass, oldClass) {
*
* console.log(
* 'Element %o changed it\'s class to "%s" (it was "%s")',
* this,
* newClass,
* oldClass
* );
*
* });
*
* Now if the class is changed:
*
* div.classList.add('two');
*
* ... then this will appear in the console:
*
* Element <div> changed it's class to "one two" (it was "one")
*
* Usually old and current attributes will be strings, but some attributes are
* booleans, so the variables may be `true` or `false`. Also be aware that
* sometimes a browser will return `null` for the old value if the attribute
* did not exist previously.
*
*
* ## Configuring
*
* The `watch` function attempts to use the most efficient means possible
* before resorting to attribute polling. There may be situations where a new
* process is required (such as native attribute watchers being invented) and
* in that situation, [[watch.addProcess]] can be used to add a new process.
* Processes are tried in "Last In, First Out" (LIFO) order, so the most recent
* process provided will be tried before any of the others. Not all browsers
* can handle all processes - a check created with [[watch.addCheck]] will add
* a check to ensure that only available processes are use. Finally, some
* attributes require special attention (such as the `disabled` attribute) so
* only certain processes are available. For those situations,
* [[watch.addLimiter]] will define the allowed processes for the given
* attribute.
**/
var watch = (function () {
'use strict';
/**
* watch.Util
*
* Utilities passed to process factories and checks. The `Util`
* constructor itself is **private** - methods should be added using
* [[watch.addUtil]].
**/
var Util = function () {
/**
* watch.Util#attrMap -> Object
*
* List of possibly-given attribute to DOM-required attributes.
* This property should not be access directly, instead the
* attribute should be accessed through the [[watch.Util#mapAttr]]
* method.
**/
this.attrMap = {
'class': 'className',
'for': 'htmlFor'
};
},
// processes, numeric keys for LIFO ordering
processes = {},
// simple map of names to process keys, for easy looking up.
names = {},
// Limiters allow allow certain processes to be used
limiter = {},
// Object.defineProperty allows constants to be made. Fallback for older
// browsers further below.
defineProperty = Object.defineProperty,
// Object.keys and fallback.
keys = Object.keys || function (object) {
var objKeys = [];
forIn(object, function (key) {
objKeys.push(key);
});
return objKeys;
},
// watch function is returned at the end. Just a wrapper for the
// _process method for future-proofing.
watch = function (element, attribute, handler) {
return watch._process(element, attribute, handler);
};
// Checks to see if Object.defineProperty works on the given object.
// Necessary because IE8 has Object.defineProperty, but it only works on
// DOM nodes.
// https://github.com/es-shims/es5-shim/blob/master/es5-sham.js
function doesDPwork(object) {
try {
Object.defineProperty(object, 'sentinel', {});
return 'sentinel' in object;
} catch (ignore) {
}
}
if (!doesDPwork({}) || !doesDPwork(document.createElement('div'))) {
defineProperty = function (object, property, descriptor) {
object[property] = descriptor.value;
};
}
/**
* watch.Util#mapAttr(attribute) -> String
* - attribute (String): Attribute to possibly map.
*
* Maps attributes to DOM-required attribute. Attributes that do not
* require mapping are returned unaltered.
*
* var util = new watch.Util();
* util.mapAttr('class'); // -> 'className'
* util.mapAttr('disabled'); // -> 'disabled'
*
**/
Util.prototype.mapAttr = function (attribute) {
return this.attrMap[attribute] || attribute;
};
// Helper function for iterating over object properties. The handler
// argument is passed the key then value.
function forIn(object, handler, context) {
var property = '',
hasOwn = Object.prototype.hasOwnProperty;
for (property in object) {
if (hasOwn.call(object, property)) {
handler.call(context, property, object[property]);
}
}
}
// Extends the source object with properties of the extra one.
function extend(source, extra) {
forIn(extra, function (key, value) {
source[key] = value;
});
return source;
}
/**
* watch.addUtil(name, value)
* - name (String): Name of the utility to add.
* - value (?): Value of the utility, preferably a `function`.
*
* Adds a utility that all processes and checks will be able to access.
**/
function addUtil(name, value) {
Util.prototype[name] = value;
}
/**
* watch.addProcess(name, factory)
* - name (String): Name of the process.
* - factory (Function): Function to execute to generate the process.
*
* Processes handle the observing of DOM attributes. Processes are checked
* for availability in LIFO order, meaning that any process you define will
* be checked before the default ones.
*
* The `factory` argument will be passed an instance of [[watch.Util]],
* giving it access to useful utilities. It should return a function that
* can take an element, an attribute and a handler. The handler should be
* called with the element as the context, and be passed the new value of
* the attribute and the old value. For a better understanding, consider
* this example:
*
* watch.addProcess('poller', function (util) {
*
* return function (element, attribute, handler) {
*
* var old = element[attribute];
*
* window.setInterval(function () {
*
* if (element[attribute] !== old) {
* handler.call(element, element[attribute], old);
* old = element[attribute];
* }
*
* }, 1000);
*
* };
*
* });
*
* The example above will register a "poller" process for checking
* attributes. When the attribute changes, the handler is called and the
* old value is stored for future checking. As well as defining internal
* properties for the "poller" process, `addProcess` will also create a
* constant for `watch`:
*
* watch.POLLER; // -> something like 16
*
* The constant is the name of the process in capital letters and is set to
* a positive integer (a power of 2). The actual value may change depending
* on when the "poller" process is defined, but the name will remain
* consistent. The constant is used for creating limiters using
* [[watch.addLimiter]].
*
* Processes may be replaced, re-defining the "poller" process would
* replace the old one. Be aware that the constant `watch.POLLER`
* would be updated to the next power of 2 (32 in this case) and this may
* affect process ordering. It is best not to replace processes unless
* necessary.
**/
function addProcess(name, factory) {
var setting = Math.pow(2, keys(processes).length),
upper = String(name).toUpperCase();
processes[setting] = {
available: true,
name: name,
process: factory(new Util()),
value: setting
};
names[name] = setting;
if (watch[upper]) {
delete watch[upper];
}
defineProperty(watch, upper, {
configurable: false,
enumerable: true,
value: setting,
writable: false
});
}
/**
* watch.addCheck(name, check)
* - name (String|Number): Name of the process to which a check should be
* added.
* - check (Function): Check for the process.
*
* Adds a check for the process. The checks exist to ensure that the
* process is available in the current environment. A good example of a
* check is one that ensures that `MutationObserver`s exist in order to use
* them. The `check` should return a boolean; if any checks relating to the
* given `name` return `false`, the process will be disabled for the
* duration of the web page's lifetime.
*
* To better understand a check, consider the following example (which
* assumes that the "poller" process from [[watch.addProcess]] was
* created):
*
* watch.addCheck('poller', function (util) {
* return window && ('setInterval' in window);
* });
*
* In most environments, the check will return `true`. If it returns
* `false`, the "poller" process will not be available.
*
* The `name` argument is **case-sensitive** and a `ReferenceError` will be
* thrown if the process mentioned is not found. To help avoid this, the
* constant can be passed instead of the name:
*
* watch.addCheck(watch.POLLER, function (util) {
* return window && ('setInterval' in window);
* });
*
**/
function addCheck(name, check) {
var setting = typeof name === 'number' ? name : names[name],
process = processes[setting];
if (!process) {
throw new ReferenceError('watch.addCheck() unrecognised process "' +
name + '"');
}
if (!check(new Util())) {
process.available = false;
}
}
/**
* watch.addLimiter(attribute, allowed)
* - attribute (String): DOM attribute to limit.
* - allowed (Number): Processes that may be used to handle the attribute.
*
* A limiter defines the processes that may be used to watch the defined
* `attribute`, if they are available. Any process not mentioned will
* **not** be used.
*
* To better understand this, consider the following example:
*
* watch.addLimiter(
* 'disabled',
* watch.TIMEOUT | watch.MUTATIONOBSERVER
* );
*
* In this example, the "disabled" attribute may only check checked with
* the "timeout" process and the "MutationObserver" process; other
* processes, such as "onpropertychange" and "MutationEvent" (and "poller"
* from [[watch.addProcess]]), will not be used. The bitwise OR
* operator (`|`) is used to create a bitmask of the available processes;
* this should not be confused with the logical OR operator (`||`) which
* would simply return the first constant.
*
* Because processes may be added at run-time, limiters can be replaced
* without consequence. To completely remove a limiter, pass `0` or `null`
* as the `allowed` argument.
**/
function addLimiter(attribute, allowed) {
limiter[attribute] = allowed;
}
// Internal function that checks the given attribute to get the process to
// use. The first available process is chosen (based on the highest constant
// value) and possibly limited by the processes available for the given
// attribute. If no available processes are found, null is returned.
function getProcess(attribute) {
var settings = keys(processes).sort(),
limit = limiter[attribute] || 0,
temp = null,
value = 0,
process = null,
length = settings.length;
while (length) {
length -= 1;
temp = processes[settings[length]];
value = temp.value;
// double double single
if (temp.available && (limit === 0 || (limit & value) === value)) {
process = temp;
break;
}
}
return process;
}
// Add the process for a simple timeout. DOM attribute polling. Least
// efficient process available but should always work.
addProcess('timeout', function (util) {
var pollTime = 1000 / 10; // 10 times a second.
return function (element, attribute, handler) {
var attr = util.mapAttr(attribute),
old = element[attr];
function poll() {
var value = element[attr];
if (value !== old) {
handler.call(element, value, old);
old = value;
}
window.setTimeout(poll, pollTime);
}
poll();
};
});
// Add process for MSIE's onpropertychange - precursor to the
// DOMAttrModified event and works in legacy MSIE.
addProcess('onpropertychange', function (util) {
return function (element, attribute, handler) {
var attr = util.mapAttr(attribute),
old = element[attr];
element.attachEvent('onpropertychange', function () {
if (window.event.propertyName === attr) {
handler.call(element, element[attr], old);
old = element[attr];
}
});
};
});
// Add check to ensure that onpropertychange can be used in the current
// browser.
addCheck('onpropertychange', function () {
return ('onpropertychange' in document.createElement('div')) &&
typeof document.attachEvent !== 'undefined';
});
// Add process for MutationEvents.
addProcess('MutationEvent', function (util) {
return function (element, attribute, handler) {
element.addEventListener('DOMAttrModified', function (e) {
if (e.attrName === attribute) {
handler.call(element, e.newValue, e.prevValue);
}
}, false);
};
});
// Add check for MutationEvent support and check a bug - if the bug exists,
// disable the process.
addCheck('MutationEvent', function () {
// http://engineering.silk.co/post/31921750832/mutation-events-what-happens?
// https://bugs.webkit.org/show_bug.cgi?id=8191
function isMutationEventsWorking() {
var attrModifiedWorks = false,
documentElement = document.documentElement,
eventName = 'DOMAttrModified',
attribute = '___TEST___',
listener = function () {
attrModifiedWorks = true;
};
documentElement.addEventListener(eventName, listener, false);
documentElement.setAttribute(attribute, true);
documentElement.removeAttribute(attribute, true);
documentElement.removeEventListener(eventName, listener, false);
return attrModifiedWorks;
}
return ('MutationEvent' in window) &&
typeof document.addEventListener === 'function' &&
isMutationEventsWorking();
});
// Add process for MutationObserver. Most efficient checking process
// available and works with disabled attribute, but only available in modern
// browsers.
addProcess('MutationObserver', function (util) {
var MutationObserver = window.MutationObserver,
prefixes = ['Ms', 'O', 'Moz', 'Webkit'],
i = 0,
il = prefixes.length,
temp = null;
while (!MutationObserver && i < il) {
temp = window[prefixes[i] + 'MutationObserver'];
if (typeof temp === 'function') {
MutationObserver = temp;
}
i += 1;
}
return function (element, attribute, handler) {
var observer = new MutationObserver(function (mutations) {
var i = 0,
il = mutations.length,
m = null;
while (i < il) {
m = mutations[i];
if (m.type === 'attributes' &&
m.attributeName === attribute) {
handler.call(
element,
element[util.mapAttr(attribute)],
m.oldValue
);
}
i += 1;
}
});
observer.observe(element, {
attributes: true,
childList: true,
characterData: true,
attributeOldValue: true
});
};
});
// Add check for MutationObserver support.
addCheck('MutationObserver', function (util) {
return typeof window.MutationObserver === 'function' ||
typeof window.OMutationObserver === 'function' ||
typeof window.MsMutationObserver === 'function' ||
typeof window.MozMutationObserver === 'function' ||
typeof window.WebkitMutationObserver === 'function';
});
// Add limiter for the "disabled" attribute - only attribute polling and a
// MutationObserver will work because disabled elements do not trigger
// events.
addLimiter('disabled', watch.TIMEOUT | watch.MUTATIONOBSERVER);
// Add limiter for the "value" attribute - only attribute polling seems to
// detect it. This is only for times when element.value = newValue,
// listening to the element changing value should still be done with event
// listeners.
addLimiter('value', watch.TIMEOUT);
// Add properties to the returned watch variable.
extend(watch, {
/**
* watch._process(element, attribute, handler)
* - element (Element): Element whose attribute should be observed.
* - attribute (String): Attribute to observe.
* - handler (Function): Handler to execute when the attribute changes.
*
* The method handles a call to [[watch]] allowing the process to be
* overridden if necessary. It will work out the appropriate process to
* use and use it to observe changes in the attribute. This method
* should **never** be called directly - use `watch()` instead.
**/
_process: function (element, attribute, handler) {
// limit by attribute and by browser
var process = getProcess(attribute);
if (!process) {
throw new Error('watch() No available processes to handle "' +
attribute + '" attribute');
}
process.process(element, attribute, handler);
},
addCheck: addCheck,
addLimiter: addLimiter,
addProcess: addProcess,
addUtil: addUtil
});
// Expose the watch variable.
return watch;
}());
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment