ryanfitzer/ViewportJS

View on GitHub
src/viewport.js

Summary

Maintainability
A
0 mins
Test Coverage
/* !ViewportJS github.com/ryanfitzer/ViewportJS/blob/master/LICENSE */
( function ( root, factory ) {

    if ( typeof define === 'function' && define.amd ) {

        // AMD. Register as an anonymous module.
        define( [], factory );

    }
    else if ( typeof module === 'object' && module.exports ) {

        // Node. Does not work with strict CommonJS, but
        // only CommonJS-like environments that support module.exports,
        // like Node.
        module.exports = factory();

    }
    else {

        // Browser globals (root is window)
        root.viewport = factory();

    }

}
( this, function () {

    var exposedAPI = [
        'state',
        'remove',
        'matches',
        'current',
        'previous'
    ];

    var undefinedVP = {
        name: undefined,
        matches: false,
        current: false
    };

    function getLogMessage( label, sub ) {

        sub = sub || {};

        var msg = {
            subNoSupport: '[viewportjs] Subscribing in this environment can cause memory leaks.',
            subNoConfig: '[viewportjs] Subscriber failed to be added. Instance does not have a configuration.',
            queryNoConfig: '[viewportjs] The `' + sub + '()` method failed. Instance does not have a configuration.',
            noHandler: '[viewportjs] The `' + sub.method + '()` method failed. Instance for query `' + sub.query + '`, was configured without a `handler`.',
            subNoName: '[viewportjs] Subscriber failed to be added. The name `' + sub + '` does not match any configured viewports.',
            uniqueViewportName: '[viewportjs] Viewport configuration object overwritten. The viewport name `' + sub + '` already exists.'
        };

        return msg[ label ];

    }

    function copyViewportObject( vp ) {

        return {
            name: vp.name,
            matches: vp.matches,
            current: vp.current
        };

    }

    function ensureViewportObject( name, vps ) {

        if ( vps && vps[ name ] ) return copyViewportObject( vps[ name ] );

        return undefinedVP;

    }

    function createMediaQuery( query, listener ) {

        var mql = window.matchMedia( query );

        mql.addListener( listener );

        return mql;

    }

    function createUnsubscribe( token, channel ) {

        return function () {

            channel = channel.filter( function ( subscriber ) {

                subscriber.token !== token;

            } );

            return token;

        };

    }

    function staticSubscribe( query, handler ) {

        var mql = window.matchMedia( query );
        var api = {
            remove: function () {

                console.warn( getLogMessage( 'noHandler', {
                    query: query,
                    method: 'remove'
                } ) );

            },
            matches: function () {

                return mql.matches;

            }
        };

        if ( !handler ) return api;

        var listener = function ( event ) {

            handler( {
                matches: event.matches
            }, api );

        };

        mql.addListener( listener );

        if ( mql.matches ) listener( mql.matches, api );

        api.remove = mql.removeListener.bind( mql, listener );

        return api;

    }

    function Viewport( viewports ) {

        this.api = null;
        this.viewports = viewports;
        this.store = {
            vps: {},
            tokenUid: -1,
            channels: {},
            channelAll: [],
            current: undefined,
            previous: undefined
        };

        this.viewports.forEach( function ( vp ) {

            vp.listener = this.setState.bind( this );
            vp.mql = createMediaQuery( vp.query, vp.listener );

            this.store.channels[ vp.name ] = [];

            console.assert( !this.store.vps[ vp.name ], getLogMessage( 'uniqueViewportName', vp.name ) );

            this.store.vps[ vp.name ] = {
                name: vp.name,
                matches: false,
                current: false
            };

        }, this );

        this.setState();

    }

    Viewport.prototype = {

        getMatches: function () {

            return ( this.viewports || [] ).filter( function ( vp ) {

                return vp.mql.matches;

            } ).map( copyViewportObject );

        },

        getCurrent: function () {

            var match = this.getMatches().pop();

            return match ? match.name : undefined;

        },

        getChanges: function ( viewport, current ) {

            var name = viewport.name;
            var state = this.store.vps[ name ];
            var props = {
                matches: viewport.mql.matches,
                current: current.name === name
            };

            return Object.keys( props ).reduce( function ( accum, label ) {

                if ( state[ label ] !== props[ label ] ) {

                    accum.push( {
                        key: label,
                        value: props[ label ]
                    } );

                }

                return accum;

            }, [] );

        },

        setState: function () {

            var changed = [];
            var current = ensureViewportObject( this.getCurrent(), this.store.vps );

            this.viewports.forEach( function ( viewport ) {

                var vp = this.store.vps[ viewport.name ];
                var changes = this.getChanges( viewport, current );

                changes.forEach( function ( change ) {

                    vp[ change.key ] = change.value;

                } );

                if ( changes.length ) changed.push( vp.name );

            }, this );

            if ( current.name !== this.store.current ) {

                this.store.previous = this.store.current;
                this.store.current = current.name;

            }

            changed.forEach( this.publish, this );

        },

        addSubscriber: function ( opts ) {

            var token = this.store.tokenUid = this.store.tokenUid + 1;

            opts.channel.push( {
                token: token,
                handler: opts.handler
            } );

            opts.handler( opts.vp, this.api );

            return createUnsubscribe( token, opts.channel );

        },

        subscribe: function ( name, handler ) {

            return this.addSubscriber( {
                handler: handler,
                channel: this.store.channels[ name ],
                vp: this.store.vps[ name ]
            } );

        },

        subscribeAll: function ( handler ) {

            return this.addSubscriber( {
                handler: handler,
                channel: this.store.channelAll,
                vp: this.current()
            } );

        },

        publish: function ( name ) {

            this.store.channels[ name ].forEach( function ( subscriber ) {

                subscriber.handler( this.store.vps[ name ], this.api );

            }, this );

            this.store.channelAll.forEach( function ( subscriber ) {

                subscriber.handler( this.store.vps[ name ], this.api );

            }, this );

        },

        state: function ( name ) {

            console.assert( this.viewports, getLogMessage( 'queryNoConfig', 'state' ) );

            if ( name ) return ensureViewportObject( name, this.store.vps );

            return Object.keys( this.store.vps || {} ).map( function ( label ) {

                return ensureViewportObject( label, this.store.vps );

            }, this );

        },

        matches: function ( name ) {

            console.assert( this.viewports, getLogMessage( 'queryNoConfig', 'matches' ) );

            if ( name ) return ensureViewportObject( name, this.store.vps ).matches;

            return this.getMatches();

        },

        current: function ( name ) {

            console.assert( this.viewports, getLogMessage( 'queryNoConfig', 'current' ) );

            var current = ensureViewportObject( this.store.current, this.store.vps );

            if ( name ) return current.name === name;

            return current;

        },

        previous: function ( name ) {

            console.assert( this.viewports, getLogMessage( 'queryNoConfig', 'previous' ) );

            if ( name ) return this.store.previous === name;

            return ensureViewportObject( this.store.previous, this.store.vps );

        },

        remove: function () {

            console.assert( this.viewports, getLogMessage( 'queryNoConfig', 'remove' ) );

            this.viewports.forEach( function ( viewport ) {

                viewport.mql.removeListener( viewport.listener );

            } );

            return this.viewports = this.store.vps = this.store.current = this.store.previous = null;

        }

    };

    function module( config, handler ) {

        // Subscribe to a single media query
        if ( typeof config === 'string' ) return staticSubscribe( config, handler );

        var instance = new Viewport( config );

        instance.api = function ( first, second ) {

            console.assert( instance.viewports, getLogMessage( 'subNoConfig' ) );

            // Subscribe to a single configured viewport
            if ( typeof first === 'string' ) {

                console.assert( instance.store.vps[ first ], getLogMessage( 'subNoName', first ) );

                return instance.subscribe.call( instance, first, second );

            }

            // Subscribe to all configured viewports
            return instance.subscribeAll.call( instance, first );

        };

        return exposedAPI.reduce( function ( accum, method ) {

            accum[ method ] = instance[ method ].bind( instance );

            return accum;

        }, instance.api );

    }

    // Create noop API for use in Node
    if ( typeof window === 'undefined' || typeof window.matchMedia === 'undefined' ) {

        return function ( config ) {

            console.assert( Array.isArray( config ), getLogMessage( 'subNoSupport' ) );

            var noop = function () {};

            if ( typeof config === 'string' ) {

                console.warn( getLogMessage( 'subNoSupport' ) );

                return {
                    remove: noop,
                    matches: noop
                };

            }

            var instance = function () {

                console.error( getLogMessage( 'subNoSupport' ) );

            };

            return exposedAPI.reduce( function ( api, method ) {

                if ( /remove/.test( method ) ) {

                    api[ method ] = function () {};

                }
                else {

                    api[ method ] = function ( arg ) {

                        if ( typeof arg === 'string' ) return false;

                        if ( /state|matches/.test( method ) ) return [];

                        return undefinedVP;

                    };

                }

                return api;

            }, instance );

        };

    }

    return module;

} ) );