3
\$\begingroup\$

I'm working on my first jQuery plugin for a lightweight dropdown. This dropdown behaves just like a ordinary <select>.

Before implementing new features I'd like to know if my current approach is clean and efficient (I care about readability and performance).

/**
 * Dropdown plugin.
 *
 * @license MIT (https://opensource.org/licenses/MIT)
 */
;(function($, window, document) {
    'use strict';

    var plugin   = 'dropdown',
        methods  = ['open', 'close', 'toggle'],
        defaults = {
            onOpen  : function() {},
            onClose : function() {},
            classNames : {
                dropdown : 'dropdown',
                menu     : 'dropdown__menu',
                item     : 'dropdown__item',
                open     : 'dropdown--open',
                empty    : 'dropdown--empty'
            }
        };

    // Constructor
    function Dropdown(element, options) {
        this.element  = element;
        this.settings = $.extend({}, defaults, options);
        this.init();
    }

    // Instance
    $.extend(Dropdown.prototype, {

        init: function() {
            var instance = this,
                $element = $(instance.element),
                $items   = $element.find('.' + instance.settings.classNames.item);

            // Check if there already is a selection
            if ($items.filter('.selected').length === 0) {
                $element.addClass(instance.settings.classNames.empty);
            }

            // Bind listeners
            $element
                .mousedown(function() {
                    instance.toggle();
                })
                .keydown(function(e) {
                    switch (e.which) {
                        case 13 : // enter key
                            instance.toggle();
                            break;
                        case 38 : // arrow up
                            instance.select('prev');
                            e.preventDefault(); // prevent scroll
                            break;
                        case 40 : // arrow down
                            instance.select('next');
                            e.preventDefault(); // prevent scroll
                            break;
                    }
                })
                .focusout(function() {
                    instance.close();
                });

            $items
                .mousedown(function(e) {
                    instance.select(e.target);
                });
        },

        /**
         * Check the state of the dropdown.
         *
         * @param state
         * @returns {*}
         */
        is: function(state) {
            var instance = this,
                $element = $(this.element);

            return {
                open: function() {
                    return $element.hasClass(instance.settings.classNames.open);
                },
                empty: function() {
                    return $element.hasClass(instance.settings.classNames.empty);
                }
            }[state].apply();
        },

        /**
         * Select a dropdown item.
         *
         * @param item
         */
        select: function(item) {
            var instance  = this,
                $element  = $(this.element),
                $items    = $element.find('.' + instance.settings.classNames.item),
                $selected = $items.filter('.selected'),
                $target;

            // Check if an element is passed
            if (typeof item === 'object') {
                $target = $(item);
            } else {
                // Check if we have a selection
                if ($selected && $selected.length > 0) {
                    if (item === 'next') {
                        $target = $selected.next();
                    } else if (item === 'prev') {
                        $target = $selected.prev();
                    }
                } else {
                    $target = $items.first();
                }
            }

            // Ensure the target is set and a different element the selected item
            if ( ! $target || $target.length === 0 || $target[0].isEqualNode($selected[0])) {
                return false;
            }

            // Set classes
            $element.removeClass(instance.settings.classNames.empty);
            $selected.removeClass('selected');
            $target.addClass('selected');

            // Set values
            $element.find('input, select').val( $target.data('value') );
            $element.find('span').first().text( $target.text() );
        },

        /**
         * Toggles the dropdown.
         */
        toggle: function() {
            if  (this.is('open')) this.close();
            else this.open();
        },

        /**
         * Open the dropdown.
         */
        open: function() {
            var instance = this,
                $element = $(instance.element);

            if (instance.is('open')) {
                return;
            }

            $element.addClass(instance.settings.classNames.open);

            instance.settings.onOpen.call($element[0]);
        },

        /**
         * Close the dropdown.
         */
        close: function() {
            var instance = this,
                $element = $(this.element);

            if ( ! instance.is('open')) {
                return;
            }

            $element.removeClass(instance.settings.classNames.open);

            instance.settings.onClose.call($element[0]);
        }

    });

    // Plugin definition
    $.fn.dropdown = function(options, args) {
        return this.each(function() {
            var $el  = this,
                data = $.data($el, plugin);

            // Prevent multiple instantiations
            if ( ! data) {
                $.data($el, plugin, new Dropdown($el, options));
            }
            else if (typeof options === 'string') {
                // Attempting to call public method
                if (data[options] && options.indexOf(methods) === -1) {
                    data[options].apply(data, $.isArray(args) ? args : new Array(args));
                } else {
                    console.error(plugin + ': Trying to call a undefined or inaccessible method')
                }
            }
        });
    };
})(jQuery, window, document);

See it in action: Navigational control with the arrows keys seem to not work in the fiddle for some reason. This should work tho.

/**
 * Dropdown plugin.
 *
 * @package Fundament
 * @license MIT (https://opensource.org/licenses/MIT)
 */
;(function($, window, document) {
    'use strict';

    var plugin   = 'dropdown',
        methods  = ['open', 'close', 'toggle'],
        defaults = {
            onOpen  : function() {},
            onClose : function() {},
            classNames : {
                dropdown : 'dropdown',
                menu     : 'dropdown__menu',
                item     : 'dropdown__item',
                open     : 'dropdown--open',
                empty    : 'dropdown--empty'
            }
        };

    // Constructor
    function Dropdown(element, options) {
        this.element  = element;
        this.settings = $.extend({}, defaults, options);
        this.init();
    }

    // Instance
    $.extend(Dropdown.prototype, {

        init: function() {
            var instance = this,
                $element = $(instance.element),
                $items   = $element.find('.' + instance.settings.classNames.item);

            // Check if there already is a selection
            if ($items.filter('.selected').length === 0) {
                $element.addClass(instance.settings.classNames.empty);
            }

            // Bind listeners
            $element
                .mousedown(function() {
                    instance.toggle();
                })
                .keydown(function(e) {
                    switch (e.which) {
                        case 13 : // enter key
                            instance.toggle();
                            break;
                        case 38 : // arrow up
                            instance.select('prev');
                            e.preventDefault(); // prevent scroll
                            break;
                        case 40 : // arrow down
                            instance.select('next');
                            e.preventDefault(); // prevent scroll
                            break;
                    }
                })
                .focusout(function() {
                    instance.close();
                });

            $items
                .mousedown(function(e) {
                    instance.select(e.target);
                });
        },

        /**
         * Check the state of the dropdown.
         *
         * @param state
         * @returns {*}
         */
        is: function(state) {
            var instance = this,
                $element = $(this.element);

            return {
                open: function() {
                    return $element.hasClass(instance.settings.classNames.open);
                },
                empty: function() {
                    return $element.hasClass(instance.settings.classNames.empty);
                }
            }[state].apply();
        },

        /**
         * Select a dropdown item.
         *
         * @param item
         */
        select: function(item) {
            var instance  = this,
                $element  = $(this.element),
                $items    = $element.find('.' + instance.settings.classNames.item),
                $selected = $items.filter('.selected'),
                $target;

            // Check if an element is passed
            if (typeof item === 'object') {
                $target = $(item);
            } else {
                // Check if we have a selection
                if ($selected && $selected.length > 0) {
                    if (item === 'next') {
                        $target = $selected.next();
                    } else if (item === 'prev') {
                        $target = $selected.prev();
                    }
                } else {
                    $target = $items.first();
                }
            }

            // Ensure the target is set and a different element the selected item
            if ( ! $target || $target.length === 0 || $target[0].isEqualNode($selected[0])) {
                return false;
            }

            // Set classes
            $element.removeClass(instance.settings.classNames.empty);
            $selected.removeClass('selected');
            $target.addClass('selected');

            // Set values
            $element.find('input, select').val( $target.data('value') );
            $element.find('span').first().text( $target.text() );
        },

        /**
         * Toggles the dropdown.
         */
        toggle: function() {
            if  (this.is('open')) this.close();
            else this.open();
        },

        /**
         * Open the dropdown.
         */
        open: function() {
            var instance = this,
                $element = $(instance.element);

            if (instance.is('open')) {
                return;
            }

            $element.addClass(instance.settings.classNames.open);

            instance.settings.onOpen.call($element[0]);
        },

        /**
         * Close the dropdown.
         */
        close: function() {
            var instance = this,
                $element = $(this.element);

            if ( ! instance.is('open')) {
                return;
            }

            $element.removeClass(instance.settings.classNames.open);

            instance.settings.onClose.call($element[0]);
        }

    });

    // Plugin definition
    $.fn.dropdown = function(options, args) {
        return this.each(function() {
            var $el  = this,
                data = $.data($el, plugin);

            // Prevent multiple instantiations
            if ( ! data) {
                $.data($el, plugin, new Dropdown($el, options));
            }
            else if (typeof options === 'string') {
                // Attempting to call public method
                if (data[options] && options.indexOf(methods) === -1) {
                    data[options].apply(data, $.isArray(args) ? args : new Array(args));
                } else {
                    console.error(plugin + ': Trying to call a undefined or inaccessible method')
                }
            }
        });
    };
})(jQuery, window, document);

$('.dropdown').dropdown();
.dropdown{position:relative;display:block;padding:.625rem .8125rem;padding-right:2rem;font-family:Source Sans Pro,sans-serif;font-size:16px;color:#333;line-height:1.125;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;outline:0;cursor:pointer;border:1px solid #d9d9d9;border-radius:.25rem;background-color:#fff;-webkit-transition:all .25s ease-out;transition:all .25s ease-out}.dropdown>span{pointer-events:none}.dropdown>select{display:none!important}.dropdown:focus{border-color:#49a2bd}.dropdown:after{content:" ";position:absolute;top:50%;right:1rem;margin-top:-2.5px;width:0;height:0;border-color:#d9d9d9;border-left:4px solid transparent;border-right:4px solid transparent;border-top:5px solid}.dropdown.dropdown--empty{color:#b3b3b3}.dropdown.dropdown--open{border-color:#49a2bd;border-bottom-left-radius:0;border-bottom-right-radius:0}.dropdown.dropdown--open:after{-webkit-transform:rotate(-180deg);transform:rotate(-180deg)}.dropdown.dropdown--open .dropdown__menu{display:block;border-color:#49a2bd}.dropdown__menu{display:none;position:absolute;top:100%;left:-1px;right:-1px;z-index:1;margin:0;padding:0;max-height:300px;overflow-y:auto;outline:0;list-style:none;border:1px solid #d9d9d9;border-top:0;border-radius:0 0 .25rem .25rem;background-color:#fff;box-shadow:0 3px 4px rgba(0,0,0,.15)}.dropdown__item{display:block;padding:.625rem .8125rem;color:#333;vertical-align:middle;line-height:1.125;border-top:1px solid rgba(0,0,0,.04)}.dropdown__item>.icon{margin-right:.5rem}.dropdown__item.selected{font-weight:700;background-color:rgba(0,0,0,.01)}.dropdown__item:hover{background-color:rgba(0,0,0,.03)}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>

<div class="dropdown">
  <input type="hidden" name="foo">
  <span>Placeholder</span>
  <ul class="dropdown__menu">
    <li class="dropdown__item" data-value="1">First option</li>
    <li class="dropdown__item" data-value="2">Second option</li>
    <li class="dropdown__item" data-value="3">Third option</li>
    <li class="dropdown__item" data-value="4">Fourth option</li>
    <li class="dropdown__item" data-value="5">Fifth option</li>
  </ul>
</div>

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.