West Wind Hero Image

Rick Strahl's Weblog

Wind, waves, code and everything in between...
.NET • C# • Markdown • WPF • All things Web
Contact   •   Articles   •   Products   •   Support   •  
Sponsored by:
Markdown Monster - The Markdown Editor for Windows
On this page:

Here's a scenario I've run into on a few occasions: I need to be able to monitor certain CSS properties on an HTML element and know when that CSS element changes. The need for this arose out of wanting to build generic components that could 'attach' themselves to other objects and monitor changes on the ‘parent’ object so the dependent object can adjust itself accordingly.

What I wanted to create is a jQuery plug-in that allows me to specify a list of CSS properties to monitor and have a function fire in response to any change to any of those CSS properties. The result are the .watch() and .unwatch() jQuery plug-ins. Here’s a simple example page of this plug-in that demonstrates tracking changes to an element being moved with draggable and closable behavior:

http://www.west-wind.com/WestWindWebToolkit/samples/Ajax/jQueryPluginSamples/WatcherPlugin.htm

Try it with different browsers – IE and FireFox use the DOM event handlers and Chrome, Safari and Opera use setInterval handlers to manage this behavior. It should work in all of them but all but IE and FireFox will show a bit of lag between the changes in the main element and the shadow.

The relevant HTML for this example is this fragment of a main <div> (#notebox) and an element that is to mimic a shadow (#shadow).

<div class="containercontent">   <div id="notebox" style="width: 200px; height: 150px;position: absolute; 
z-index: 20; padding: 20px; background-color: lightsteelblue;"> Go ahead drag me around and close me! </div> <div id="shadow" style="background-color: Gray; z-index: 19;position:absolute;display: none;"> </div></div>

The watcher plug in is then applied to the main <div> which keeps the shadow in sync with the main element. The following plug-in code demonstrates:

<script type="text/javascript">    $(document).ready(function () {        var counter = 0;        $("#notebox").watch("top,left,height,width,display,opacity", function (data, i) {            var el = $(this);            var sh = $("#shadow");            var propChanged = data.props[i];            var valChanged = data.vals[i];            counter++;            showStatus("Prop: " + propChanged + "  value: " + valChanged + "  " + counter);            var pos = el.position();            var w = el.outerWidth();            var h = el.outerHeight();            sh.css({                width: w,                height: h,                left: pos.left + 5,                top: pos.top + 5,                display: el.css("display"),                opacity: el.css("opacity")            });        })        .draggable()        .closable()        .css("left", 10);    });</script>

When you run this page as you drag the #notebox element the #shadow element will maintain and stay pinned underneath the #notebox element effectively keeping the shadow attached to the main element. Likewise, if you hide or fadeOut() the #notebox element the shadow will also go away – show the #notebox element and the shadow also re-appears because we are assigning the display property from the parent on the shadow.

Note we’re attaching the .watch() plug-in to the #notebox element and have it fire whenever top,left,height,width,opacity or display CSS properties are changed. The passed data element contains a props[] and vals[] array that holds the properties monitored and their current values. An index passed as the second parm tells you which property has changed and what its current value is (propChanged/valChanged in the code above). The rest of the watcher handler code then deals with figuring out the main element’s position and recalculating and setting the shadow’s position using the jQuery .css() function.

Note that this is just an example to demonstrate the watch() behavior here – this is not the best way to create a shadow. If you’re interested in a more efficient and cleaner way to handle shadows with a plug-in check out the .shadow() plug-in in ww.jquery.js (code search for fn.shadow) which uses native CSS features when available but falls back to a tracked shadow element on browsers that don’t support it, which is how this watch() plug-in came about in the first place :-)

How does it work?

The plug-in works by letting the user specify a list of properties to monitor as a comma delimited string and a handler function:

el.watch("top,left,height,width,display,opacity", function (data, i) {}, 100, id)

You can also specify an interval (if no DOM event monitoring isn’t available in the browser) and an ID that identifies the event handler uniquely.

The watch plug-in works by hooking up to DOMAttrModified in FireFox, to onPropertyChanged in Internet Explorer, or by using a timer with setInterval to handle the detection of changes for other browsers. Unfortunately WebKit doesn’t support DOMAttrModified consistently at the moment so Safari and Chrome currently have to use the slower setInterval mechanism. In response to a changed property (or a setInterval timer hit) a JavaScript handler is fired which then runs through all the properties monitored and determines if and which one has changed. The DOM events fire on all property/style changes so the intermediate plug-in handler filters only those hits we’re interested in. If one of our monitored properties has changed the specified event handler function is called along with a data object and an index that identifies the property that’s changed in the data.props/data.vals arrays.

The jQuery plugin to implement this functionality looks like this:

(function($){
$.fn.watch = function (props, func, interval, id) {    /// <summary>    /// Allows you to monitor changes in a specific    /// CSS property of an element by polling the value.    /// when the value changes a function is called.    /// The function called is called in the context    /// of the selected element (ie. this)    /// </summary>        /// <param name="prop" type="String">CSS Properties to watch sep. by commas</param>        /// <param name="func" type="Function">    /// Function called when the value has changed.    /// </param>        /// <param name="interval" type="Number">    /// Optional interval for browsers that don't support DOMAttrModified or propertychange events.    /// Determines the interval used for setInterval calls.    /// </param>    /// <param name="id" type="String">A unique ID that identifies this watch instance on this element</param>      /// <returns type="jQuery" />     if (!interval)        interval = 100;    if (!id)        id = "_watcher";    return this.each(function () {        var _t = this;        var el$ = $(this);        var fnc = function () { __watcher.call(_t, id) };        var data = { id: id,            props: props.split(","),            vals: [props.split(",").length],            func: func,            fnc: fnc,            origProps: props,            interval: interval,            intervalId: null        };        // store initial props and values        $.each(data.props, function (i) { data.vals[i] = el$.css(data.props[i]); });        el$.data(id, data);        hookChange(el$, id, data);    });    function hookChange(el$, id, data) {        el$.each(function () {            var el = $(this);            if (typeof (el.get(0).onpropertychange) == "object")                el.bind("propertychange." + id, data.fnc);            else if ($.browser.mozilla)                el.bind("DOMAttrModified." + id, data.fnc);            else                data.intervalId = setInterval(data.fnc, interval);        });    }    function __watcher(id) {        var el$ = $(this);        var w = el$.data(id);        if (!w) return;        var _t = this;        if (!w.func)            return;        // must unbind or else unwanted recursion may occur        el$.unwatch(id);        var changed = false;        var i = 0;        for (i; i < w.props.length; i++) {            var newVal = el$.css(w.props[i]);            if (w.vals[i] != newVal) {                w.vals[i] = newVal;                changed = true;                break;            }        }        if (changed)            w.func.call(_t, w, i);        // rebind event        hookChange(el$, id, w);    }}$.fn.unwatch = function (id) {    this.each(function () {        var el = $(this);        var data = el.data(id);        try {            if (typeof (this.onpropertychange) == "object")                el.unbind("propertychange." + id, data.fnc);            else if ($.browser.mozilla)                el.unbind("DOMAttrModified." + id, data.fnc);            else                clearInterval(data.intervalId);        }        // ignore if element was already unbound        catch (e) { }    });    return this;}
})(jQuery);

Note that there’s a corresponding .unwatch() plug-in that can be used to stop monitoring properties. The ID parameter is optional both on watch() and unwatch() – a standard name is used if you don’t specify one, but it’s a good idea to use unique names for each element watched to avoid overlap in event ids especially if you’re monitoring many elements.

The syntax is:

$.fn.watch = function(props, func, interval, id)

props
A comma delimited list of CSS style properties that are to be watched for changes. If any of the specified properties changes the function specified in the second parameter is fired.

func
The function fired in response to a changed styles. Receives this as the element changed and an object parameter that represents the watched properties and their respective values. The first parameter is passed in this structure:

{ id: watcherId, props: [], vals: [], func: thisFunc, fnc: internalHandler, origProps: strPropertyListOnWatcher };

A second parameter is the index of the changed property so data.props[i] or data.vals[i] gets the property and changed value.

interval
The interval for setInterval() for those browsers that don't support property watching in the DOM. In milliseconds.

id
An optional id that identifies this watcher. Required only if multiple watchers might be hooked up to the same element. The default is _watcher if not specified.

It’s been a Journey

I started building this plug-in about two years ago and had to make many modifications to it in response to changes in jQuery and also in browser behaviors. I think the latest round of changes made should make this plug-in fairly future proof going forward (although I hope there will be better cross-browser change event notifications in the future).

One of the big problems I ran into had to do with recursive change notifications – it looks like starting with jQuery 1.44 and later, jQuery internally modifies element properties on some calls to some .css()  property retrievals and things like outerHeight/Width(). In IE this would cause nasty lock up issues at times. In response to this I changed the code to unbind the events when the handler function is called and then rebind when it exits. This also makes user code less prone to stack overflow recursion as you can actually change properties on the base element. It also means though that if you change one of the monitors properties in the handler the watch() handler won’t fire in response – you need to resort to a setTimeout() call instead to force the code to run outside of the handler:

$("#notebox")el.watch("top,left,height,width,display,opacity", function (data, i) {    var el = $(this);
// this makes el changes work setTimeout(function () { el.css("top", 10) },10);})

Since I’ve built this component I’ve had a lot of good uses for it. The .shadow() fallback functionality is one of them.

Resources

The watch() plug-in is part of ww.jquery.js and the West Wind West Wind Web Toolkit. You’re free to use this code here or the code from the toolkit.

    Posted in: ASP.NET  JavaScript  jQuery  

    The Voices of Reason


    August 15, 2008

    # re: Monitoring Html Element CSS Changes in JavaScript

    Could you not make a wrapper object which can wrap any html element. So create methods on the wrapper obect so it fires an event before changing its object ( which it wraps) property?

    Could work
    August 15, 2008

    # re: Monitoring Html Element CSS Changes in JavaScript

    Why not create a wrapper html element that contains both your element and it's shadow. Style the shadow element to be positioned appropriately. Then apply the draggable properties to the wrapper element. When the wrapper is dragged, the shadow will move with it perfectly.
    August 15, 2008

    # re: Monitoring Html Element CSS Changes in JavaScript

    Yes I can add a wrapper which works for a one time manual type arrangement, but the idea is that this has to be generic. For example if I apply a draggable in dependently I can apply it against any element and there's no way for the draggable to know that it has to drag the container rather than the 'actual' dragged element. Same if you call a generic function like .centerInClient().

    If I were to do this manually each time for dragging I get events that can be handled for movement that give me position as the element is dragged. But this required explicit hookup of events and it wouldn't be generic.
    josip
    August 15, 2008

    # re: Monitoring Html Element CSS Changes in JavaScript

    Using "DOMAttrModified" event would be more appropriate then "polling" for changes on element attrributes:

    (function ($) {
      $.fn.watch = function (property, id, fn) {
        id += "Watcher";
    
        return this
          .data(id, {val: null, fn: watcher(property, id)})
          .bind("DOMAttrModified", this.data(id).fn);
      };
    
      $.fn.unwatch = function (id) {
        id += "Watcher";
        var data = this.data(id);
        if(!data)
          return this;
    
        return this
          .unbind("DOMAttrModified", data.fn)
          .removeData(id);
      };
    
      function watcher (property, id) {
        return function (event) {
          // First statement "filters" events from child nodes
          if(this !== event.originalTarget || event.attrName !== "style")
            return false;
    
          var $this = $(this),
              new_val = $this.css(property),
              data = $this.data(id);
          if(!data)
            return false;
    
          if(new_val !== data.val)
            return data.fn.call(this, data.val = new_val);
        }
      }
    })(jQuery)
    
    // Example:
    $(".scrollbar").watch("top", "offset", function (top) { console.log(this, top) });
    $(".scrollbar").unwatch("offset");


    The (only) downside of this method is that "DOMAttrModifed" event isn't supported by all browsers.
    August 15, 2008

    # re: Monitoring Html Element CSS Changes in JavaScript

    Yeah this seems pretty weird if you ask me :|, at least for a shadow...

    Any good draggable implementation should be publishing events for you to subscribe to.

    For a shadow, why don't you position the shadow relative to the draggable in CSS. That way it will just follow along? Or have the shadow be part of the draggable itself?
    August 15, 2008

    # re: Monitoring Html Element CSS Changes in JavaScript

    @Ricky - yes no doubt events are the way to deal with this. But you can't make that assumption that a draggable will be used. What if you re-position the element via code? No event is going to let you track that. The point it has to be generic and apply to ANY DOM element.

    One offs where you can hook events and you have full control is no problem. A bit of work and tedious, but doable. It's to this generically when it gets ugly.

    Hmmm... relative positioning relative to the object - how would you do that unless you have a wrapper element (which as discussed above isn't really doable generically).
    August 16, 2008

    # re: Monitoring Html Element CSS Changes in JavaScript

    What if the draggable implementation automatically wrapped the element you want to drag with another div. Then you could append your shadow to that wrapper div and position it relative to it (both would have position:absolute;).

    Is there something I am missing? Or is it that you are just trying to use the plugins you have out of the box with minimal custom coding/modifications?
    August 16, 2008

    # re: Monitoring Html Element CSS Changes in JavaScript

    LiveQuery is a pretty cool JQuery plugin if you haven't checked it out: http://brandonaaron.net/docs/livequery/

    I'm not sure if it would quite work in this case, but it re-executes your jquery statement when the DOM changes from at least an AJAX query, if not other dom changing events.
    August 16, 2008

    # re: Monitoring Html Element CSS Changes in JavaScript

    @josip - DOMAttrModified would work great if it was supported cross browser. IE has a propertyChange that can also be used, but Safari nor Opera seem to have anything comparable.

    Bummer, but that would definitely solve the problem nicely.
    November 23, 2010

    # re: Monitoring Html Element CSS Changes in JavaScript

    small fix for the top comment:
    you have:
    /// <param name="func" type="Function">
    needs to be:
    /// <param name="id" type="String">
    David Hutchings
    December 16, 2010

    # re: Monitoring Html Element CSS Changes in JavaScript

    (Yes; I know this was an old post. The problem is still relevant, however... <grin>)

    First; thanks for posting this code; it gave me a great place to start when I needed to solve a similar problem. I've used your code above as a base and extended it in several ways.

    - "prop" can now be sent in as an array (actual or string). This lets you monitor several properties under a single id.

    - "interval" was moved to the 4th position. Since more browsers now support the event natively than they did before, there should be less need to define the interval compared to the "id". It could easily be switch if you need backword compatability.

    - The "data" object sent to the callback now uses an array and index for the "props" and "vals" properties. Also added in a "prevVals" property that will contain what the value was before the event fired. For example:
    $( 'input' ).watch( ['display','value'], function( data, i ) {
        alert( data.props[ i ] + ' changed from ' + data.prevVals[ i ] + ' to ' + data.vals[ i ] );
    });
    


    - $.fn.watchPause( prop, id ) and $.fn.watchResume( prop, id ) have been added. These two functions allow you to pause and resume a previously bound watcher. Among other things, these functions can also be used to "cancel" a property change using the "prevVals" property:
    $( 'div' ).watch( 'width', function( data, i ) { // Prevent the width from changing
        $( this )
            .watchPause()
            .width( data.prevVals[ i ] )
            .watchResume();
    });
    


    It's not gone through much testing yet, but hopefully it'll hold up... ;-)
    (function( $, undefined ) {
        $.fn.watch = function(props, func, id, interval) {
            /// <summary>
            /// Allows you to monitor changes in a specific
            /// CSS property of an element by polling the value.
            /// when the value changes a function is called.
            /// The function called is called in the context
            /// of the selected element (ie. this)
            /// </summary>
            /// <param name="prop" type="String">CSS Properties to watch.</param>
            /// <param name="func" type="Function">Function called when the value has changed.</param>
            /// <param name="id" type="String">A unique ID that identifies this watch instance on this element</param>
            /// <param name="interval" type="Int">A unique ID that identifies this watch instance on this element</param>
            /// <returns type="jQuery" /> 
            if ( !$.isFunction( func ) || ! props ) {
                return this;
            }
            if ( !id ) {
                id = '_watcher';
            }
            if ( typeof props == 'string' ) {
                props = props.split( ',' );
            }
            if ( !$.isArray( props ) ) {
                return this;
            }
            if ( !interval ) {
                interval = 200;
            }
     
            function __watcher( id, i ) {
                var el = $(this),
                    changed = false,
                    w = $.data( this, id ),
                    newVal = el.css( w.props[i] );
                if (w.vals[i] != newVal) {
                    w.prevVals[i] = w.vals[i];
                    w.vals[i] = newVal;
                    if ( w.pause == false || ( w.pause != true && $.inArray( w.props[i], w.pause ) == -1 ) ) {
                        w.func.call( this, w, i );
                    }
                }
            }
     
            return this.each(function() {
                var _t = this,
                    el = $( this ),
                    fnc = function() {
                        var _prop = window.event.attrChange ? window.event.attrName : window.event.propertyName;
                        if ( _prop != undefined ) {
                            _prop = _prop.indexOf( '.' ) > -1 ? _prop.split( '.' )[ 1 ] : _prop;
                            var w = $.data( _t, id );
                            var index = $.inArray( _prop, w.props );
                            if ( index > -1 ) {
                                __watcher.call( _t, id, index );
                            }
                        }
                    },
                    data = {
                        props: props,
                        func: func,
                        vals: [],
                        prevVals: [],
                        _fnc: fnc,
                        interval: interval,
                        itId: false,
                        pause: false
                    };
                
                $.each( props, function(i) {
                    data.prevVals[ i ] = data.vals[ i ] = el.css( props[ i ] );
                });
     
                if ( typeof( this.onpropertychange ) == 'object' ) {
                    el.bind( 'propertychange.' + id, fnc);
                } else if ( $.browser.mozilla ) {
                    el.bind( 'DOMAttrModified.' + id, fnc);
                } else {
                    data.itId = setInterval( fnc, interval );
                }
                $.data( this, id, data );
            });
        };
        $.fn.unwatch = function( props, id ) {
            if ( !id ) {
                id = '_watcher';
            }
            if ( props ) {
                if ( typeof props == 'string' ) {
                    props = props.split( ',' );
                }
                if ( !$.isArray( props ) ) {
                    props = false;
                }
            }
     
            return this.each(function() {
                var el = $( this ),
                    w = $.data( this, id );
                if ( $.isPlainObject( w ) && w.props ) {
                    if ( props ) {
                        w.props = $.grep( w.props, function( _prop ) {
                            return $.inArray( _prop, props ) == -1;
                        });
                    }
                    if ( w.props.length == 0 || !props ) {
                        if ( typeof( this.onpropertychange ) == 'object' ) {
                            el.unbind( 'propertychange.' + id );
                        } else if ($.browser.mozilla) {
                            el.unbind( 'DOMAttrModified.' + id );
                        } else {
                            clearInterval( w.itId );
                        }
                        $.removeData( this, id );
                    } else {
                        $.data( this, id, w );
                    }
                }
            });
        };
        $.fn.watchPause = function( props, id ) {
            if ( !id ) {
                id = '_watcher';
            }
            if ( props ) {
                if ( typeof props == 'string' ) {
                    props = props.split( ',' );
                }
                if ( !$.isArray( props ) ) {
                    props = true;
                }
            } else {
                props = true;
            }
            return this.each(function() {
                var w = $.data( this, id );
                if ( $.isPlainObject( w ) ) {
                    if ( props != true ) {
                        var p = w.pause || [];
                        w.pause = p.concat( props );
                    } else {
                        w.pause = true;
                        if ( w.itId ) {
                            clearInterval( w.itId );
                            w.itId = true;
                        }
                    }
                    $.data( this, id, w );
                }
            });
        };
        $.fn.watchResume = function( props, id ) {
            if ( !id ) {
                id = '_watcher';
            }
            if ( props ) {
                if ( typeof props == 'string' ) {
                    props = props.split( ',' );
                }
                if ( !$.isArray( props ) ) {
                    props = false;
                }
            } else {
                props = false;
            }
            return this.each(function() {
                var w = $.data( this, id );
                if ( $.isPlainObject( w ) ) {
                    if ( props != false ) {
                        var p = w.pause;
                        if ( p != true ) {
                            p = $.grep( p, function( _prop ) {
                                return $.inArray( _prop, props ) == -1;
                            });
                            if ( p.length == 0 ) {
                                props = false;
                            }
                        }
                    }
                    w.pause = props;
                    if ( w.itId == true ) {
                        w.itId = setInterval( w._fnc, w.interval );
                    }
                    $.data( this, id, w );
                }
            });
        };
     
    }( jQuery ));
    


    Thanks again!
    - Dave
    February 15, 2011

    # re: Monitoring Html Element CSS Changes in JavaScript

    Rick,

    The kind of problem you run into seems to be similar to the one I exposed on Stack Overflow a while ago.

    Basically there is a plugin called Livequery, which was created by a former jQuery Core Team member : Brandon Aaron.

    This plugin works as if it implemented the Observable pattern.

    More on this here if you want a basic illustration:

    http://stackoverflow.com/questions/3367698/jquery-capturing-an-event-the-current-element-has-a-specific-class-but-change

    Hope this help :p

    Roland
    February 20, 2011

    # re: Monitoring Html Element CSS Changes in JavaScript

    @Roland - I don't think livequery addresses the same thing as this monitoring plug-in monitors style properties as opposed to document element changes. I haven't looked to see if CSS changes also trigger livequery events for a given selector though - check it out in a few days.
    Robert
    February 21, 2011

    # re: Monitoring Html Element CSS Changes in JavaScript

    Hey Rick

    How long did it take you to write the plugin?
    February 21, 2011

    # re: Monitoring Html Element CSS Changes in JavaScript

    @Robert - writing the initial plug-in didn't take long. However, debugging it after jQuery 1.44 released and fixing a recursion bug that crept up in Internet Explorer causing IE to lock up, took me days to fix. :-)
    Hussain
    March 16, 2011

    # re: A jQuery Plug-in to monitor Html Element CSS Changes

    Thank you for this wonderful plugin...

    It is very helpful, I use it for equal height column layouts to catch any change in the height of adjacent columns to resize them again and works so great with only 1 problem and it is IE, both 7 and 8, the page loads very slow when I retrieve all children and sub children of a selector, the code I have looks like this:

    jQuery(document).ready( function() {
        jQuery('#Col1 ,#Col2 ,#col3').find('div, dd, dt, ul, ol, td').watch('display, height', function(){
        jQuery('#Col1 ,#Col2 ,#col3').removeAttr('style');
        jQuery('#Col1 ,#Col2 ,#col3').matchHeights();
    });
    


    It is very very fast in all other browsers like Safari, Chrome, FF and Opera except as normal in IE... I wonder if there is a solution to speed it up in IE? I will be appreciative for your kind answer.

    Best regards
    /Hussain
    Hussain
    March 16, 2011

    # re: A jQuery Plug-in to monitor Html Element CSS Changes

    Hi again

    I discovered another issue, I have a form and use a jQuery plugin to validate it, the validator inserts label tags as error message and I have label tags in my style sheet declared as block, means display:block and this makes the height of my form wrapper DIV to grow but the watch plugin does not catch the change in the height... Again the code looks like this:

        jQuery('#JMRightColInner ,#JMCenterColInner ,#JMLeftColInner, .pane-slider').find('*').watch('display, height', function(){
            jQuery('#JMRightColInner ,#JMCenterColInner ,#JMLeftColInner').removeAttr('style');
            jQuery('#JMRightColInner ,#JMCenterColInner ,#JMLeftColInner').equalHeightColumns();
        });
    


    But this time I retrieve all children and sub-children of my root elements. I use both mootools and jquery libs in my design and have noticed watch plugin catches modifications done by mootools but not those done by jQuery as in example.... Have any idea why it is like that dear Rick?

    Many thanks in advance
    /Hussain
    Jean M
    July 02, 2011

    # re: A jQuery Plug-in to monitor Html Element CSS Changes

    @David Hutchings

    While your proposed changes sound interesting, your implementation fails for me. It keeps returning "TypeError: Result of expression 'window.event' [undefined] is not an object. (line 49)" every x milliseconds. This is on Safari 5.
    Andre
    August 05, 2011

    # re: A jQuery Plug-in to monitor Html Element CSS Changes

    you shouldn't detect "DOMAttrModified" through browser check: $.browser.mozilla. DOMAttrModified is also supported in Opera and IE9. This simple check here would work better:

    http://stackoverflow.com/questions/4562354/javascript-detect-if-event-lister-is-supported