Monday, March 4, 2013

Polyvore Style Tips: CSS, Javascript and HTML

Shhhh! Don't tell anyone, but the engineers at Polyvore are ... nerds. Most people have the misconception that Polyvore is full of fashion models and editors, but the truth is that Polyvore's a technology company wrapped in a Gucci dress.

Our core web technology enables people to mix and match products from around the web to create works of art or whimsy.
Our front ends have always been heavy in HTML, JS, and CSS. And over the years, we've learned a ton in building those features and trying to keep up with all the latest and greatest HTML5 features in modern browsers. I wanted to share some of our favourite tricks.

Unless otherwise noted, these all work on the latest couple Safari, Chrome, and FireFox releases (obviously) but also on IE8 and higher. These are Polyvore's supported browsers and it's a decision based on traffic -- 5% or higher. YMMV if you are committed to supporting older versions.



Overflow: hidden/auto

We thought we understood what this does: it hides node content outside the regular flow or fixed size, right?
We were surprised when we found a secondary behaviour: it also seems to cause a block to determine its width according to available space, without clearing floats or wrapping.

I can't count the number of times we've done this:
CSS:
.leftImg { float: left; padding: 4px; border: 2px solid blue; }
.leftImg + .txt { margin-left: 82px; /* 50px for the img + 2*4px padding + 2*2px border + 20px actual margin -- God I hope no one changes this... */ }
HTML:

Fatback spare ribs tri-tip, corned beef andouille bresaola swine meatball biltong short ribs. Corned beef brisket tail kielbasa rump cow t-bone biltong ham. Hamburger turkey corned beef beef ribs swine drumstick.

Fatback spare ribs tri-tip, corned beef andouille bresaola swine meatball biltong short ribs. Corned beef brisket tail kielbasa rump cow t-bone biltong ham. Hamburger turkey corned beef beef ribs swine drumstick.


But with overflow:hidden ...
CSS:
.leftImg { float: left; margin-right: 20px; }
.filling-block { overflow: hidden; display: block; }
HTML:

Fatback spare ribs tri-tip, corned beef andouille bresaola swine meatball biltong short ribs. Corned beef brisket tail kielbasa rump cow t-bone biltong ham. Hamburger turkey corned beef beef ribs swine drumstick.
Fatback spare ribs tri-tip, corned beef andouille bresaola swine meatball biltong short ribs. Corned beef brisket tail kielbasa rump cow t-bone biltong ham. Hamburger turkey corned beef beef ribs swine drumstick.


As with relying on any secondary behaviour, you have to take the primary behaviour with it:
Caveat: filling-block can't have "overhanging" content (i.e. content outside the regular flow) or else it'll clip / get scrollbars.



A more sane box model

When I say 100%, I mean "including my padding and border".
Booooo


* { box-sizing: border-box; -moz-box-sizing: border-box; }
Yay



localStorage for storing locally

We often want to store things locally without enduring the network costs of cookie storage.
One example for Polyvore is storing signed-out users' current composition, potentially a relatively large (~20k) JSON string.

While localStorage may not have the scalability or flexibility as SQLite or IndexedDB, we like it for its ease-of-use (simple key-value pairs) and widespread support. We also have not seen it perform as poorly as some have experienced. Still to be safe, localStorage access shouldn't be part of an interactive operation (e.g. mouse move) or a tight loop.

One piece missing from the localStorage API is cookie-style expiration:
var LocalStorageCache = (function() {
    var WEEK = 1000 * 60 * 60 * 24 * 7;
    var metaKeyPrefix = 'meta_';
    function getMetaKey(key) {
        return metaKeyPrefix + key;
    }

    var lastCleanup = Number(localStorage.getItem('last_cleanup')) || 0;
    if (lastCleanup + WEEK < new Date().getTime()) {
        // Preventative clean once per week.                                                                            
        window.setTimeout(function() { LocalStorageCache.cleanup(); });
    }

    return {
        WEEK: WEEK,
        set: function(key, value, expires) {
            var jsonValue = '(' + JSON.stringify(value) + ')';
            try {
                LocalStorageCache._set(key, jsonValue, expires);
            } catch(e) {
                // Clean up and try again. This may still fail, but gives us a better chance.                           
                LocalStorageCache.cleanup();
                LocalStorageCache._set(key, jsonValue, expires);
            }
        },
        _set: function(key, jsonValue, expires) {
            localStorage.setItem(key, jsonValue);
            if (expires) {
                localStorage.setItem(getMetaKey(key), '(' + JSON.stringify({
                    'createdon': new Date().getTime(),
                    'expires': new Date().getTime() + expires
                }) + ')');
            } else {
                localStorage.removeItem(getMetaKey(key));
            }
        },
        get: function(key) {
            var metaDataStr = localStorage.getItem(getMetaKey(key));
            if (metaDataStr) {
                var metaData;
                try { metaData = eval(metaDataStr); } catch(e) {}
                if (!metaData) {
                    return this.remove(key);
                }

                // Check the expiration.                                                                                
                var expires = Number(metaData.expires);
                if (expires && expires < new Date().getTime()) {
                    return this.remove(key);
                }
            }

            var dataStr = localStorage.getItem(key);
            if (!dataStr) {
                return this.remove(key);
            }
            var data;
            try { data = eval(dataStr); } catch(e2) {} // parse error?                                                  
            return data ? data : this.remove(key);
        },
        remove: function(key) {
            localStorage.removeItem(key);
            localStorage.removeItem(getMetaKey(key));
            return null;
        },
        cleanup: function() {
            localStorage.setItem('last_cleanup', new Date().getTime());
            var keysToDelete = [];
            for (var i = 0, len = localStorage.length; i < len; i++) {
                var key = localStorage.key(i);
                if (key.indexOf(metaKeyPrefix) < 0) {
                    keysToDelete.push(key);
                }
            }
            // If it has an expiration & it's expired, getting the item will clear that key.                            
            keysToDelete.forEach(this.get, this);
        }
    };
})();


And using the local storage API is easy:
LocalStorageCache.set('a', { foo: 'bar' }, 1000 * 5); // Expires in 5 seconds
LocalStorageCache.get('a'); // returns { foo: 'bar' } (the JS hash, not the JSON)
Caveat: You can only store ~5MB into localStorage





Using JS to edit CSS

We typically change a node's style using node.setAttribute('style', 'width:50px') or similar. But sometimes, it would be nice to change an underlying CSS rule. Some uses we've encountered include:
  • Fluid / dynamic interfaces may need to update multiple nodes at once. If you edit CSS rules, you reflow the entire document but you only need to do so once.
  • We don't want to change every matching element's style attribute directly; just do it once and let CSS handle it -- now and for all future nodes that may be dynamically added
  • We want to dynamically add/change/remove :before/:after pseudo-elements
Here's a demo of it in action.
We do use this technique sparingly though. It can be hard to track down the source of CSS rules when the selectors are often generated dynamically (It's not easy to grep for '.' + myClass + ':before') and as mentioned above:
Caveat: Touching CSS causes a full page reflow and repaint -- don't do this during user interaction or in tight loop!




pointer-events: none

This is a CSS style that suppresses mouse and pointer interaction on an element. With this style, mouse-based CSS selectors like :hover and :active do not activate and you get no JS mouse events.

At Polyvore, we use this to avoid nodes from capturing mouse behavior simply because they have a higher z-index or node stacking. This gives us the freedom to use nodes to help with layout, without interfering with interaction.

CSS:
.controls { pointer-events: none; }
.interactive { pointer-events: auto; }

/* Only hovering over the .interactive parts will make .controls turn grey; other parts have no effect */
.controls:hover { background-color: #ccc; }
HTML:

Demo:

Caveat: not supported in IE9 & below. So it's great for polish, but not for cross-browser correctness.



Summary

At Polyvore, we're always looking for new and interesting ways to let the browser do the heavy lifting. We can't wait to see what the browser vendors will support next!

No comments: