Created
April 21, 2015 10:04
-
-
Save Skateside/c6d6a50daf5d0be9c8e9 to your computer and use it in GitHub Desktop.
Watch for attribute changes
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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