jquery-custom-select
Version:
Custom Select jQuery Plugin
538 lines (443 loc) • 15.1 kB
JavaScript
import $ from 'jquery';
/*!
* Custom Select jQuery Plugin
*/
const CustomSelect = (($) => {
const defaults = {
block: 'custom-select',
hideCallback: false,
includeValue: false,
keyboard: true,
modifier: false,
placeholder: false,
search: false,
showCallback: false,
transition: 0
};
class CustomSelect {
/**
* Custom Select
*
* @param {Element} select Original `<select>` DOM element to customize.
* @param {(Object|string)=} options Settings object or method name.
* @param {string=} options.block Custom select BEM block name.
* @param {Function=} options.hideCallback Fires after dropdown closes.
* @param {boolean=} options.includeValue Adds chosen value option to
* dropdown. If enabled also cancels dropdown options rerender.
* @param {boolean=} options.keyboard Enables keyboard control.
* @param {string=} options.modifier Custom select block BEM modifier.
* @param {string=} options.placeholder Placeholder hint, can be an HTML
* string (appears only if there is no explicitly selected options).
* @param {boolean=} options.search Adds input to filter options.
* @param {Function=} options.showCallback Fires after dropdown opens.
* @param {(number|string)=} options.transition jQuery slideUp/Down param.
*/
constructor(select, options) {
this._$select = $(select);
this._options = {
...defaults,
...typeof options === 'object' ? options : {}
};
// Modifiers
this._activeModifier = `${this._options.block}--active`;
this._dropupModifier = `${this._options.block}--dropup`;
this._optionSelectedModifier = `${this._options.block}__option--selected`;
// Event handlers that can be removed
this._keydown = this._keydown.bind(this);
this._dropup = this._dropup.bind(this);
this._outside = this._outside.bind(this);
this._init();
}
/**
* Resets custom select options.
*
* @public
*/
reset() {
this._$dropdown.hide().empty();
this._$value.off('click');
this._fill();
}
/**
* Renders initial state of custom select & sets
* options click event listeners.
*
* @private
*/
_init() {
this._$element = $(
`<div class="${this._options.block}">
<button class="${this._options.block}__option ${this._options.block}__option--value" type="button"></button>
<div class="${this._options.block}__dropdown" style="display: none;"></div>
</div>`
);
this._$select
.hide()
.after(this._$element);
if (this._options.modifier) {
this._$element.addClass(this._options.modifier);
}
this._$value = this._$element.find(`.${this._options.block}__option--value`);
this._$dropdown = this._$element.find(`.${this._options.block}__dropdown`);
this._fill();
}
/**
* Renders custom select options by original
* select element options.
*
* @private
*/
_fill() {
this._$values = this._$select.find('option');
this._values = [];
let placeholder = this._options.placeholder;
$.each(this._$values, (i, option) => {
const el = $(option).text().trim();
this._values.push(el);
});
if (placeholder) {
// Check explicitly selected option
if (this._$select.find('[selected]').length) {
placeholder = false;
} else {
this._$value.html(placeholder);
// Set select value to null
this._$select.prop('selectedIndex', -1);
}
}
$.each(this._values, (i, el) => {
const cssClass = this._$values.eq(i).attr('class');
const $option = $(
`<button class="${this._options.block}__option" type="button">${el}</button>`
);
const $selected = this._$select.find(':selected');
if (this._$values.eq(i).attr('disabled')) {
$option.prop('disabled', true);
}
if ((!$selected.length && i === 0) || el === $selected.text().trim()) {
if (!placeholder) {
this._$value
.text(el)
.removeClass(this._$value.data('class')).removeData('class')
.addClass(cssClass).data('class', cssClass);
}
if (this._options.includeValue || placeholder) {
$option.addClass(cssClass);
$option.toggleClass(this._optionSelectedModifier, this._$values.eq(i).is('[selected]'));
this._$dropdown.append($option);
}
} else {
$option.addClass(cssClass);
this._$dropdown.append($option);
}
});
this._$options = this._$dropdown.find(`.${this._options.block}__option`);
if (this._options.search) {
this._search();
}
this._$value.one('click', event => {
this._show(event);
});
this._$value.prop('disabled', !this._$options.length);
this._$options.on('click', event => {
this._select(event);
});
}
/**
* Shows custom select dropdown & sets outside
* click listener to hide.
*
* @param {Object} event Value click jQuery event.
* @private
*/
_show(event) {
event.preventDefault();
this._dropup();
$(window).on('resize scroll', this._dropup);
this._$element.addClass(this._activeModifier);
this._$dropdown.slideDown(this._options.transition, () => {
if (this._options.search) {
this._$input.focus();
if (this._options.includeValue) {
this._scroll();
}
}
// Open callback
if (typeof this._options.showCallback === 'function') {
this._options.showCallback.call(this._$element[0]);
}
});
setTimeout(() => {
$(document).on('touchstart click', this._outside);
}, 0);
this._$value.one('click', event => {
event.preventDefault();
this._hide();
});
if (this._options.keyboard) {
this._options.index = -1;
$(window).on('keydown', this._keydown);
}
}
/**
* Hides custom select dropdown & resets events
* listeners to initial.
*
* @private
*/
_hide() {
if (this._options.search) {
this._$input.val('').blur();
this._$options.show();
this._$wrap.scrollTop(0);
}
this._$dropdown.slideUp(this._options.transition, () => {
this._$element
.removeClass(this._activeModifier)
.removeClass(this._dropupModifier);
// Close callback
if (typeof this._options.hideCallback === 'function') {
this._options.hideCallback.call(this._$element[0]);
}
this._$value
.off('click')
.one('click', event => {
this._show(event);
});
$(document).off('touchstart click', this._outside);
$(window).off('resize scroll', this._dropup);
});
if (this._options.keyboard) {
this._$options.blur();
$(window).off('keydown', this._keydown);
}
}
/**
* Centers chosen option in scrollable element
* of dropdown.
*
* @private
*/
_scroll() {
$.each(this._$options, (i, option) => {
const $option = $(option);
if ($option.text() === this._$value.text()) {
const top = $option.position().top;
const height = this._$wrap.outerHeight();
const center = height / 2 - $option.outerHeight() / 2;
if (top > center) {
this._$wrap.scrollTop(top - center);
}
return false;
}
});
}
/**
* Changes value of custom select & `<select>`
* by chosen option.
*
* @param {Object} event Option click jQuery event.
* @private
*/
_select(event) {
event.preventDefault();
const choice = $(event.currentTarget).text().trim();
const values = [...this._values];
this._$value
.text(choice)
.removeClass(this._$value.data('class'));
this._$values.prop('selected', false);
$.each(values, (i, el) => {
if (!this._options.includeValue && el === choice) {
values.splice(i, 1);
}
$.each(this._$values, (i, option) => {
const $option = $(option);
if ($option.text().trim() === choice) {
const cssClass = $option.attr('class');
$option.prop('selected', true);
this._$value.addClass(cssClass).data('class', cssClass);
}
});
});
this._hide();
if (!this._options.includeValue) {
// Update dropdown options content
if (this._$options.length > values.length) {
const last = this._$options.eq(values.length);
last.remove();
this._$options = this._$options.not(last);
if (!this._$options.length) {
this._$value.prop('disabled', true);
}
}
$.each(this._$options, (i, option) => {
const $option = $(option);
$option.text(values[i]);
// Reset option class
$option.attr('class', `${this._options.block}__option`);
$.each(this._$values, function () {
const $this = $(this);
if ($this.text().trim() === values[i]) {
$option.addClass($this.attr('class'));
$option.prop('disabled', $this.prop('disabled'));
}
});
});
} else {
// Select chosen option
this._$options.removeClass(this._optionSelectedModifier);
$.each(this._$options, (i, option) => {
const $option = $(option);
if ($option.text().trim() === choice) {
$option.addClass(this._optionSelectedModifier);
return false;
}
});
}
if (typeof event.originalEvent !== 'undefined') {
this._$select.trigger('change');
}
}
/**
* Wraps options by wrap element, adds search
* input to dropdown.
*
* @private
*/
_search() {
this._$input = $(`<input class="${this._options.block}__input" autocomplete="off">`);
this._$dropdown.prepend(this._$input);
// Add scrollable wrap
this._$options.wrapAll(`<div class="${this._options.block}__option-wrap"></div>`);
this._$wrap = this._$element.find(`.${this._options.block}__option-wrap`);
this._$input.on('focus', () => {
this._options.index = -1;
});
this._$input.on('keyup', () => {
const query = this._$input.val().trim();
if (query.length) {
this._$wrap.scrollTop(0);
setTimeout(() => {
if (query === this._$input.val().trim()) {
$.each(this._$options, (i, option) => {
const $option = $(option);
const text = $option.text().trim().toLowerCase();
const match = text.indexOf(query.toLowerCase()) !== -1;
$option.toggle(match);
});
}
}, 300);
} else {
this._$options.show();
}
});
}
/**
* Toggles custom select dropup modifier based
* on space for dropdown below.
*
* @private
*/
_dropup() {
const bottom = this._$element[0].getBoundingClientRect().bottom;
const up = $(window).height() - bottom < this._$dropdown.height();
this._$element.toggleClass(this._dropupModifier, up);
}
/**
* Hides dropdown if target of event (e.g. click
* on `$window`) is not custom select.
*
* @param {Object} event Outside "click" jQuery event.
* @private
*/
_outside(event) {
const $target = $(event.target);
if (!$target.parents().is(this._$element) && !$target.is(this._$element)) {
this._hide();
}
}
/**
* Controls navigation from keyboard by custom
* select options.
*
* @param {Object} event Keydown jQuery event.
* @private
*/
_keydown(event) {
const $visible = this._$options.filter(':visible').not('[disabled]');
switch (event.which) {
// Down
case 40:
event.preventDefault();
const next = $visible.eq(this._options.index + 1).length;
if (next) {
this._options.index += 1;
} else {
this._options.index = 0;
}
$visible.eq(this._options.index).focus();
break;
// Up
case 38:
event.preventDefault();
const prev = $visible.eq(this._options.index - 1).length;
if (prev && this._options.index - 1 >= 0) {
this._options.index -= 1;
} else {
this._options.index = $visible.length - 1;
}
$visible.eq(this._options.index).focus();
break;
// Enter
case 13:
// Space
case 32:
if (!this._$input || !this._$input.is(':focus')) {
event.preventDefault();
const $option = this._$options.add(this._$value).filter(':focus');
$option.trigger('click');
if (!$option.is(this._$value)) {
this._$select.trigger('change');
}
this._$value.focus();
}
break;
// Esc
case 27:
event.preventDefault();
this._hide();
this._$value.focus();
break;
default:
break;
}
}
/**
* Creates jQuery plugin function.
*
* @param {(Object|string)=} config Options or method.
* @returns {Function} jQuery plugin.
*/
static _jQueryPlugin(config) {
return this.each(function () {
const $this = $(this);
let data = $this.data('custom-select');
if (!data) {
if (typeof config !== 'string') {
data = new CustomSelect(this, config);
$this.data('custom-select', data);
}
} else {
if (config === 'reset') {
data.reset();
}
}
});
}
}
$.fn['customSelect'] = CustomSelect._jQueryPlugin;
$.fn['customSelect'].noConflict = () => $.fn['customSelect'];
return CustomSelect;
})($);
export default CustomSelect;