multiple-select
Version:
Multiple select is a jQuery plugin to select multiple elements with checkboxes :).
1,097 lines (922 loc) • 28.5 kB
JavaScript
import Constants from './constants/index.js'
import VirtualScroll from './virtual-scroll/index.js'
import {
compareObjects,
removeDiacritics,
findByParam,
setDataKeys,
removeUndefined,
getDocumentClickEvent
} from './utils/index.js'
class MultipleSelect {
constructor ($el, options) {
this.$el = $el
this.options = $.extend({}, Constants.DEFAULTS, options)
}
init () {
this.initLocale()
this.initContainer()
this.initData()
this.initSelected(true)
this.initFilter()
this.initDrop()
this.initView()
this.options.onAfterCreate()
}
initLocale () {
if (this.options.locale) {
const { locales } = $.fn.multipleSelect
const parts = this.options.locale.split(/-|_/)
parts[0] = parts[0].toLowerCase()
if (parts[1]) {
parts[1] = parts[1].toUpperCase()
}
if (locales[this.options.locale]) {
$.extend(this.options, locales[this.options.locale])
} else if (locales[parts.join('-')]) {
$.extend(this.options, locales[parts.join('-')])
} else if (locales[parts[0]]) {
$.extend(this.options, locales[parts[0]])
}
}
}
initContainer () {
const el = this.$el[0]
const name = el.getAttribute('name') || this.options.name || ''
if (this.options.classes) {
this.$el.addClass(this.options.classes)
}
if (this.options.classPrefix) {
this.$el.addClass(this.options.classPrefix)
if (this.options.size) {
this.$el.addClass(`${this.options.classPrefix}-${this.options.size}`)
}
}
// hide select element
this.$el.hide()
// label element
this.$label = this.$el.closest('label')
if (!this.$label.length && this.$el.attr('id')) {
this.$label = $(`label[for="${this.$el.attr('id')}"]`)
}
if (this.$label.find('>input').length) {
this.$label = null
}
// single or multiple
if (typeof this.options.single === 'undefined') {
this.options.single = el.getAttribute('multiple') === null
}
// restore class and title from select element
this.$parent = $(`
<div class="ms-parent ${el.getAttribute('class') || ''} ${this.options.classes}"
title="${el.getAttribute('title') || ''}" />
`)
// add placeholder to choice button
this.options.placeholder = this.options.placeholder ||
el.getAttribute('placeholder') || ''
this.tabIndex = el.getAttribute('tabindex')
let tabIndex = ''
if (this.tabIndex !== null) {
tabIndex = this.tabIndex && `tabindex="${this.tabIndex}"`
}
this.$el.attr('tabindex', -1)
this.$choice = $(`
<button type="button" class="ms-choice"${tabIndex}>
<span class="placeholder">${this.options.placeholder}</span>
${this.options.showClear ? '<div class="icon-close"></div>' : ''}
<div class="icon-caret"></div>
</button>
`)
// default position is bottom
this.$drop = $(`<div class="ms-drop ${this.options.position}" />`)
this.$close = this.$choice.find('.icon-close')
if (this.options.dropWidth) {
this.$drop.css('width', this.options.dropWidth)
}
this.$el.after(this.$parent)
this.$parent.append(this.$choice)
this.$parent.append(this.$drop)
if (el.disabled) {
this.$choice.addClass('disabled')
}
this.selectAllName = `data-name="selectAll${name}"`
this.selectGroupName = `data-name="selectGroup${name}"`
this.selectItemName = `data-name="selectItem${name}"`
if (!this.options.keepOpen) {
const clickEvent = getDocumentClickEvent(this.$el.attr('id'))
$(document).off(clickEvent).on(clickEvent, e => {
if (
$(e.target)[0] === this.$choice[0] ||
$(e.target).parents('.ms-choice')[0] === this.$choice[0]
) {
return
}
if (
($(e.target)[0] === this.$drop[0] ||
$(e.target).parents('.ms-drop')[0] !== this.$drop[0] &&
e.target !== el) &&
this.options.isOpen
) {
this.close()
}
})
}
}
initData () {
const data = []
if (this.options.data) {
if (Array.isArray(this.options.data)) {
this.data = this.options.data.map(it => {
if (typeof it === 'string' || typeof it === 'number') {
return {
text: it,
value: it
}
}
return it
})
} else if (typeof this.options.data === 'object') {
for (const [value, text] of Object.entries(this.options.data)) {
data.push({
value,
text
})
}
this.data = data
}
} else {
$.each(this.$el.children(), (i, elm) => {
const row = this.initRow(i, elm)
if (row) {
data.push(this.initRow(i, elm))
}
})
this.options.data = data
this.data = data
this.fromHtml = true
}
this.dataTotal = setDataKeys(this.data)
}
initRow (i, elm, groupDisabled) {
const row = {}
const $elm = $(elm)
if ($elm.is('option')) {
row.type = 'option'
row.text = this.options.textTemplate($elm)
row.value = elm.value
row.visible = true
row.selected = !!elm.selected
row.disabled = groupDisabled || elm.disabled
row.classes = elm.getAttribute('class') || ''
row.title = elm.getAttribute('title') || ''
if ($elm.data('value')) {
row._value = $elm.data('value') // value for object
}
if (Object.keys($elm.data()).length) {
row._data = $elm.data()
if (row._data.divider) {
row.divider = row._data.divider
}
}
return row
}
if ($elm.is('optgroup')) {
row.type = 'optgroup'
row.label = this.options.labelTemplate($elm)
row.visible = true
row.selected = !!elm.selected
row.disabled = elm.disabled
row.children = []
if (Object.keys($elm.data()).length) {
row._data = $elm.data()
}
$.each($elm.children(), (j, elem) => {
row.children.push(this.initRow(j, elem, row.disabled))
})
return row
}
return null
}
initSelected (ignoreTrigger) {
let selectedTotal = 0
for (const row of this.data) {
if (row.type === 'optgroup') {
const selectedCount = row.children.filter(child => {
return child.selected && !child.disabled && child.visible
}).length
if (row.children.length) {
row.selected = !this.options.single && selectedCount && selectedCount ===
row.children.filter(child => !child.disabled && child.visible && !child.divider).length
}
selectedTotal += selectedCount
} else {
selectedTotal += row.selected && !row.disabled && row.visible ? 1 : 0
}
}
this.allSelected = this.data.filter(row => {
return row.selected && !row.disabled && row.visible
}).length === this.data.filter(row => !row.disabled && row.visible && !row.divider).length
if (!ignoreTrigger) {
if (this.allSelected) {
this.options.onCheckAll()
} else if (selectedTotal === 0) {
this.options.onUncheckAll()
}
}
}
initFilter () {
this.filterText = ''
if (this.options.filter || !this.options.filterByDataLength) {
return
}
let length = 0
for (const option of this.data) {
if (option.type === 'optgroup') {
length += option.children.length
} else {
length += 1
}
}
this.options.filter = length > this.options.filterByDataLength
}
initDrop () {
this.initList()
this.update(true)
if (this.options.isOpen) {
setTimeout(() => {
this.open()
}, 50)
}
if (this.options.openOnHover) {
this.$parent.hover(() => {
this.open()
}, () => {
this.close()
})
}
}
initList () {
const html = []
if (this.options.filter) {
html.push(`
<div class="ms-search">
<input type="text" autocomplete="off" autocorrect="off"
autocapitalize="off" spellcheck="false"
placeholder="${this.options.filterPlaceholder}">
</div>
`)
}
html.push('<ul></ul>')
this.$drop.html(html.join(''))
this.$ul = this.$drop.find('>ul')
this.initListItems()
}
initListItems () {
const rows = this.getListRows()
let offset = 0
if (this.options.selectAll && !this.options.single) {
offset = -1
}
if (rows.length > Constants.BLOCK_ROWS * Constants.CLUSTER_BLOCKS) {
if (this.virtualScroll) {
this.virtualScroll.destroy()
}
const dropVisible = this.$drop.is(':visible')
if (!dropVisible) {
this.$drop.css('left', -10000).show()
}
const updateDataOffset = () => {
this.updateDataStart = this.virtualScroll.dataStart + offset
this.updateDataEnd = this.virtualScroll.dataEnd + offset
if (this.updateDataStart < 0) {
this.updateDataStart = 0
}
if (this.updateDataEnd > this.data.length) {
this.updateDataEnd = this.data.length
}
}
this.virtualScroll = new VirtualScroll({
rows,
scrollEl: this.$ul[0],
contentEl: this.$ul[0],
callback: () => {
updateDataOffset()
this.events()
}
})
updateDataOffset()
if (!dropVisible) {
this.$drop.css('left', 0).hide()
}
} else {
this.$ul.html(rows.join(''))
this.updateDataStart = 0
this.updateDataEnd = this.updateData.length
this.virtualScroll = null
}
this.events()
}
getListRows () {
const rows = []
if (this.options.selectAll && !this.options.single) {
rows.push(`
<li class="ms-select-all" tabindex="0">
<label>
<input type="checkbox" ${this.selectAllName}${this.allSelected ? ' checked="checked"' : ''} tabindex="-1" />
<span>${this.options.formatSelectAll()}</span>
</label>
</li>
`)
}
this.updateData = []
this.data.forEach(row => {
rows.push(...this.initListItem(row))
})
rows.push(`<li class="ms-no-results">${this.options.formatNoMatchesFound()}</li>`)
return rows
}
initListItem (row, level = 0) {
const title = row.title ? `title="${row.title}"` : ''
const multiple = this.options.multiple ? 'multiple' : ''
const type = this.options.single ? 'radio' : 'checkbox'
let classes = ''
if (!row.visible) {
return []
}
this.updateData.push(row)
if (this.options.single && !this.options.singleRadio) {
classes = 'hide-radio '
}
if (row.selected) {
classes += 'selected '
}
if (row.type === 'optgroup') {
const customStyle = this.options.styler(row)
const style = customStyle ? `style="${customStyle}"` : ''
const html = []
const group = this.options.hideOptgroupCheckboxes || this.options.single ?
`<span ${this.selectGroupName} data-key="${row._key}"></span>` :
`<input type="checkbox"
${this.selectGroupName}
data-key="${row._key}"
${row.selected ? ' checked="checked"' : ''}
${row.disabled ? ' disabled="disabled"' : ''}
tabindex="-1"
>`
if (
!classes.includes('hide-radio') &&
(this.options.hideOptgroupCheckboxes || this.options.single)
) {
classes += 'hide-radio '
}
html.push(`
<li class="group ${classes}" ${style} tabindex="${classes.includes('hide-radio') || row.disabled ? -1 : 0}">
<label class="optgroup${this.options.single || row.disabled ? ' disabled' : ''}">
${group}${row.label}
</label>
</li>
`)
row.children.forEach(child => {
html.push(...this.initListItem(child, 1))
})
return html
}
const customStyle = this.options.styler(row)
const style = customStyle ? `style="${customStyle}"` : ''
classes += row.classes || ''
if (level && this.options.single) {
classes += `option-level-${level} `
}
if (row.divider) {
return '<li class="option-divider"/>'
}
return [`
<li class="${multiple} ${classes}" ${title} ${style} tabindex="${row.disabled ? -1 : 0}">
<label class="${row.disabled ? 'disabled' : ''}">
<input type="${type}"
value="${row.value}"
data-key="${row._key}"
${this.selectItemName}
${row.selected ? ' checked="checked"' : ''}
${row.disabled ? ' disabled="disabled"' : ''}
tabindex="-1"
>
<span>${row.text}</span>
</label>
</li>
`]
}
events () {
this.$searchInput = this.$drop.find('.ms-search input')
this.$selectAll = this.$drop.find(`input[${this.selectAllName}]`)
this.$selectGroups = this.$drop.find(`input[${this.selectGroupName}],span[${this.selectGroupName}]`)
this.$selectItems = this.$drop.find(`input[${this.selectItemName}]:enabled`)
this.$disableItems = this.$drop.find(`input[${this.selectItemName}]:disabled`)
this.$noResults = this.$drop.find('.ms-no-results')
const toggleOpen = e => {
e.preventDefault()
if ($(e.target).hasClass('icon-close')) {
return
}
this[this.options.isOpen ? 'close' : 'open']()
}
if (this.$label && this.$label.length) {
this.$label.off('click').on('click', e => {
if (e.target.nodeName.toLowerCase() !== 'label') {
return
}
toggleOpen(e)
if (!this.options.filter || !this.options.isOpen) {
this.focus()
}
e.stopPropagation() // Causes lost focus otherwise
})
}
this.$choice.off('click').on('click', toggleOpen)
.off('focus').on('focus', this.options.onFocus)
.off('blur').on('blur', this.options.onBlur)
this.$parent.off('keydown').on('keydown', e => {
// esc key
if (e.which === 27 && !this.options.keepOpen) {
this.close()
this.$choice.focus()
}
})
this.$close.off('click').on('click', e => {
e.preventDefault()
this._checkAll(false, true)
this.initSelected(false)
this.updateSelected()
this.update()
this.options.onClear()
})
this.$searchInput.off('keydown').on('keydown', e => {
// Ensure shift-tab causes lost focus from filter as with clicking away
if (e.keyCode === 9 && e.shiftKey) {
this.close()
}
}).off('keyup').on('keyup', e => {
// enter or space
// Avoid selecting/deselecting if no choices made
if (
this.options.filterAcceptOnEnter &&
[13, 32].includes(e.which) &&
this.$searchInput.val()
) {
if (this.options.single) {
const $items = this.$selectItems.closest('li').filter(':visible')
if ($items.length) {
this.setSelects([$items.first().find(`input[${this.selectItemName}]`).val()])
}
} else {
this.$selectAll.click()
}
this.close()
this.focus()
return
}
this.filter()
})
this.$selectAll.off('click').on('click', e => {
this._checkAll($(e.currentTarget).prop('checked'))
})
this.$selectGroups.off('click').on('click', e => {
const $this = $(e.currentTarget)
const checked = $this.prop('checked')
const group = findByParam(this.data, '_key', $this.data('key'))
this._checkGroup(group, checked)
this.options.onOptgroupClick(removeUndefined({
label: group.label,
selected: group.selected,
data: group._data,
children: group.children.map(child => {
return removeUndefined({
text: child.text,
value: child.value,
selected: child.selected,
disabled: child.disabled,
data: child._data
})
})
}))
})
this.$selectItems.off('click').on('click', e => {
const $this = $(e.currentTarget)
const checked = $this.prop('checked')
const option = findByParam(this.data, '_key', $this.data('key'))
const close = () => {
if (this.options.single && this.options.isOpen && !this.options.keepOpen) {
this.close()
}
}
if (this.options.onBeforeClick(option) === false) {
close()
return
}
this._check(option, checked)
this.options.onClick(removeUndefined({
text: option.text,
value: option.value,
selected: option.selected,
data: option._data
}))
close()
})
this.$ul.find('li').off('keydown').on('keydown', e => {
const $this = $(e.currentTarget)
let $divider
let $li
switch (e.key) {
case 'ArrowUp':
e.preventDefault()
$divider = $this.prev('li.option-divider')
$li = $divider.length ? $divider : $this
$li.prev().trigger('focus')
break
case 'ArrowDown':
e.preventDefault()
$divider = $this.next('li.option-divider')
$li = $divider.length ? $divider : $this
$li.next().trigger('focus')
break
case 'Enter':
e.preventDefault()
$this.find('input').trigger('click')
if (this.options.single) {
this.$choice.trigger('focus')
}
break
default:
// ignore
}
})
}
initView () {
let computedWidth
if (window.getComputedStyle) {
computedWidth = window.getComputedStyle(this.$el[0]).width
if (computedWidth === 'auto') {
computedWidth = this.$drop.outerWidth() + 20
}
} else {
computedWidth = this.$el.outerWidth() + 20
}
this.$parent.css('width', this.options.width || computedWidth)
this.$el.show().addClass('ms-offscreen')
}
open () {
if (this.$choice.hasClass('disabled')) {
return
}
this.options.isOpen = true
this.$parent.addClass('ms-parent-open')
this.$choice.find('>div').addClass('open')
this.$drop[this.animateMethod('show')]()
// fix filter bug: no results show
this.$selectAll.parent().show()
this.$noResults.hide()
// Fix #77: 'All selected' when no options
if (!this.data.length) {
this.$selectAll.parent().hide()
this.$noResults.show()
}
if (this.options.container) {
const offset = this.$drop.offset()
this.$drop.appendTo($(this.options.container))
this.$drop.offset({
top: offset.top,
left: offset.left
})
.css('min-width', 'auto')
.outerWidth(this.$parent.outerWidth())
}
let maxHeight = this.options.maxHeight
if (this.options.maxHeightUnit === 'row') {
maxHeight = this.$drop.find('>ul>li').first().outerHeight() *
this.options.maxHeight
}
this.$drop.find('>ul').css('max-height', `${maxHeight}px`)
this.$drop.find('.multiple').css('width', `${this.options.multipleWidth}px`)
if (this.data.length && this.options.filter) {
this.$searchInput.val('')
this.$searchInput.focus()
this.filter(true)
}
this.options.onOpen()
}
close () {
this.options.isOpen = false
this.$parent.removeClass('ms-parent-open')
this.$choice.find('>div').removeClass('open')
this.$drop[this.animateMethod('hide')]()
if (this.options.container) {
this.$parent.append(this.$drop)
this.$drop.css({
top: 'auto',
left: 'auto'
})
}
this.options.onClose()
}
animateMethod (method) {
const methods = {
show: {
fade: 'fadeIn',
slide: 'slideDown'
},
hide: {
fade: 'fadeOut',
slide: 'slideUp'
}
}
return methods[method][this.options.animate] || method
}
update (ignoreTrigger) {
const valueSelects = this.getSelects()
let textSelects = this.getSelects('text')
if (this.options.displayValues) {
textSelects = valueSelects
}
const $span = this.$choice.find('>span')
const sl = valueSelects.length
let html = ''
if (sl === 0) {
$span.addClass('placeholder').html(this.options.placeholder)
} else if (sl < this.options.minimumCountSelected) {
html = textSelects.join(this.options.displayDelimiter)
} else if (this.options.formatAllSelected() && sl === this.dataTotal) {
html = this.options.formatAllSelected()
} else if (this.options.ellipsis && sl > this.options.minimumCountSelected) {
html = `${textSelects.slice(0, this.options.minimumCountSelected)
.join(this.options.displayDelimiter)}...`
} else if (this.options.formatCountSelected() && sl > this.options.minimumCountSelected) {
html = this.options.formatCountSelected(sl, this.dataTotal)
} else {
html = textSelects.join(this.options.displayDelimiter)
}
if (html) {
$span.removeClass('placeholder').html(html)
}
if (this.options.displayTitle) {
$span.prop('title', this.getSelects('text'))
}
// set selects to select
this.$el.val(this.getSelects())
// trigger <select> change event
if (!ignoreTrigger) {
this.$el.trigger('change')
}
}
updateSelected () {
for (let i = this.updateDataStart; i < this.updateDataEnd; i++) {
const row = this.updateData[i]
this.$drop.find(`input[data-key=${row._key}]`).prop('checked', row.selected)
.closest('li').toggleClass('selected', row.selected)
}
const noResult = this.data.filter(row => row.visible).length === 0
if (this.$selectAll.length) {
this.$selectAll.prop('checked', this.allSelected)
.closest('li').toggle(!noResult)
}
this.$noResults.toggle(noResult)
if (this.virtualScroll) {
this.virtualScroll.rows = this.getListRows()
}
}
getData () {
return this.options.data
}
getOptions () {
// deep copy and remove data
const options = $.extend({}, this.options)
delete options.data
return $.extend(true, {}, options)
}
refreshOptions (options) {
// If the objects are equivalent then avoid the call of destroy / init methods
if (compareObjects(this.options, options, true)) {
return
}
this.options = $.extend(this.options, options)
this.destroy()
this.init()
}
// value html, or text, default: 'value'
getSelects (type = 'value') {
const values = []
for (const row of this.data) {
if (row.type === 'optgroup') {
const selectedChildren = row.children.filter(child => child.selected)
if (!selectedChildren.length) {
continue
}
if (type === 'value' || this.options.single) {
values.push(...selectedChildren.map(child => {
return type === 'value' ? child._value || child[type] : child[type]
}))
} else {
const value = []
value.push('[')
value.push(row.label)
value.push(`: ${selectedChildren.map(child => child[type]).join(', ')}`)
value.push(']')
values.push(value.join(''))
}
} else if (row.selected) {
values.push(type === 'value' ? row._value || row[type] : row[type])
}
}
return values
}
setSelects (values, type = 'value', ignoreTrigger = false) {
let hasChanged = false
const _setSelects = rows => {
for (const row of rows) {
let selected = false
if (type === 'text') {
selected = values.includes($('<div>').html(row.text).text().trim())
} else {
selected = values.includes(row._value || row.value)
if (!selected && row.value === `${+row.value}`) {
selected = values.includes(+row.value)
}
}
if (row.selected !== selected) {
hasChanged = true
}
row.selected = selected
}
}
for (const row of this.data) {
if (row.type === 'optgroup') {
_setSelects(row.children)
} else {
_setSelects([row])
}
}
if (hasChanged) {
this.initSelected(ignoreTrigger)
this.updateSelected()
this.update(ignoreTrigger)
}
}
enable () {
this.$choice.removeClass('disabled')
}
disable () {
this.$choice.addClass('disabled')
}
check (value) {
const option = findByParam(this.data, 'value', value)
if (!option) {
return
}
this._check(option, true)
}
uncheck (value) {
const option = findByParam(this.data, 'value', value)
if (!option) {
return
}
this._check(option, false)
}
_check (option, checked) {
if (this.options.single) {
this._checkAll(false, true)
}
option.selected = checked
this.initSelected()
this.updateSelected()
this.update()
}
checkAll () {
this._checkAll(true)
}
uncheckAll () {
this._checkAll(false)
}
_checkAll (checked, ignoreUpdate) {
for (const row of this.data) {
if (row.type === 'optgroup') {
this._checkGroup(row, checked, true)
} else if (!row.disabled && !row.divider && (ignoreUpdate || row.visible)) {
row.selected = checked
}
}
if (!ignoreUpdate) {
this.initSelected()
this.updateSelected()
this.update()
}
}
_checkGroup (group, checked, ignoreUpdate) {
group.selected = checked
group.children.forEach(row => {
if (!row.disabled && !row.divider && (ignoreUpdate || row.visible)) {
row.selected = checked
}
})
if (!ignoreUpdate) {
this.initSelected()
this.updateSelected()
this.update()
}
}
checkInvert () {
if (this.options.single) {
return
}
for (const row of this.data) {
if (row.type === 'optgroup') {
for (const child of row.children) {
if (!child.divider) {
child.selected = !child.selected
}
}
} else if (!row.divider) {
row.selected = !row.selected
}
}
this.initSelected()
this.updateSelected()
this.update()
}
focus () {
this.$choice.focus()
this.options.onFocus()
}
blur () {
this.$choice.blur()
this.options.onBlur()
}
refresh () {
this.destroy()
this.init()
}
filter (ignoreTrigger) {
const originalSearch = this.$searchInput.val().trim()
const search = originalSearch.toLowerCase()
if (this.filterText === search) {
return
}
this.filterText = search
for (const row of this.data) {
if (row.type === 'optgroup') {
if (this.options.filterGroup) {
const visible = this.options.customFilter({
label: removeDiacritics(row.label.toString().toLowerCase()),
search: removeDiacritics(search),
originalLabel: row.label,
originalSearch,
row
})
row.visible = visible
for (const child of row.children) {
child.visible = visible
}
} else {
for (const child of row.children) {
child.visible = this.options.customFilter({
text: removeDiacritics(child.text.toString().toLowerCase()),
search: removeDiacritics(search),
originalText: child.text,
originalSearch,
row: child,
parent: row
})
}
row.visible = row.children.filter(child => child.visible).length > 0
}
} else {
row.visible = this.options.customFilter({
text: removeDiacritics(row.text.toString().toLowerCase()),
search: removeDiacritics(search),
originalText: row.text,
originalSearch,
row
})
}
}
this.initListItems()
this.initSelected(ignoreTrigger)
this.updateSelected()
if (!ignoreTrigger) {
this.options.onFilter(originalSearch)
}
}
destroy () {
if (!this.$parent) {
return
}
this.$el.before(this.$parent).removeClass('ms-offscreen')
if (this.tabIndex !== null) {
this.$el.attr('tabindex', this.tabIndex)
}
this.$parent.remove()
if (this.fromHtml) {
delete this.options.data
this.fromHtml = false
}
}
}
export default MultipleSelect