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>