diff --git a/dev/html/multiple-select.html b/dev/html/multiple-select.html new file mode 100644 index 0000000..13b2e41 --- /dev/null +++ b/dev/html/multiple-select.html @@ -0,0 +1,21 @@ +
+

Multiple select

+ + +
\ No newline at end of file diff --git a/dev/js/test-multiple-select.js b/dev/js/test-multiple-select.js new file mode 100644 index 0000000..d9a428a --- /dev/null +++ b/dev/js/test-multiple-select.js @@ -0,0 +1,8 @@ +import html from "../html/multiple-select.html"; + +export function testMultipleSelect(root){ + root.insertAdjacentHTML('beforeend', html); + + // Init: default layout + EasySelect.init(); +} \ No newline at end of file diff --git a/dev/script.js b/dev/script.js index 5ca610c..5bb21b8 100644 --- a/dev/script.js +++ b/dev/script.js @@ -12,6 +12,7 @@ import {testLayout} from "./js/test-layout"; import {testMethods} from "./js/test-methods"; import {testDisabled} from "./js/test-disabled"; import {testSearch} from "./js/test-search"; +import {testMultipleSelect} from "./js/test-multiple-select"; // import package info const packageInfo = require('../package.json'); @@ -34,4 +35,5 @@ testMethods(root); testSearch(root); testLayout(root); testInit(root); -testDisabled(root); \ No newline at end of file +testDisabled(root); +testMultipleSelect(root); \ No newline at end of file diff --git a/dev/style.scss b/dev/style.scss index ff43a60..0492b35 100644 --- a/dev/style.scss +++ b/dev/style.scss @@ -16,8 +16,9 @@ section { } .col-right { - width: 200px; + width: 400px; display: block; + text-align: right; } code { diff --git a/src/_index.js b/src/_index.js index 21f855f..9a1babf 100644 --- a/src/_index.js +++ b/src/_index.js @@ -1,7 +1,7 @@ import {getSelectData, val} from "./data"; import {fireOnChangeEvent, init} from "./methods"; -import {getOptionHTML, updateDropdownHTML} from "./layout"; -import {findObjectInArray, getSelectTag} from "./utils"; +import {getCurrentInnerHTML, getOptionHTML, updateDropdownHTML} from "./layout"; +import {findObjectInArray, getOptionByValue, getSelectTag} from "./utils"; import {EventsManager, getOptionsFromAttribute} from "@phucbm/os-util"; import {CLASSES, ATTRS, DEFAULTS} from './configs' @@ -114,7 +114,7 @@ class EasySelect{ * @param disabled */ disableOption(optionValue, disabled){ - const option = this.selectTag.querySelector(`option[value="${optionValue}"]`); + const option = getOptionByValue(this, optionValue); if(!option){ console.warn(`Option with value "${optionValue}" is not found.`); @@ -144,7 +144,7 @@ class EasySelect{ if(this.selectTagData.length){ // update current - this.current.innerHTML = getOptionHTML(this); + this.current.innerHTML = getCurrentInnerHTML(this); // if not native select if(!this.options.nativeSelect){ @@ -183,18 +183,52 @@ class EasySelect{ select(value){ if(this.isDisabled) return; - // skip duplicate value - if(value === val(this)) return; + // todo: create isSelectedOption() + const isSelected = val(this, 'array').includes(value); - // value exists in data object => update value - if(typeof findObjectInArray(this.selectTagData, 'value', value) !== 'undefined'){ - this.selectTag.value = value; - fireOnChangeEvent(this); + // treat selected option + if(isSelected){ + if(this.options.multiple) this.deselect(value); return; } - // warning - if(this.options.warning) console.warn(`Option[value="${value}"] is not found in this select!`); + + // value not exists in data object => update value + if(typeof findObjectInArray(this.selectTagData, 'value', value) === 'undefined'){ + // warning + if(this.options.warning) console.warn(`Option[value="${value}"] is not found in this select!`); + return; + } + + + // set selected value to select tag (single select only) + // with multi select, update select tag will lead to missing previous selected values + if(!this.options.multiple) this.selectTag.value = value; + + // make the option selected + const option = getOptionByValue(this, value); + option.selected = true; + + fireOnChangeEvent(this); + } + + + /** + * Deselect by value + * @param value + */ + deselect(value){ + // todo: bug multi deselect + if(this.isDisabled) return; + + // get option + const option = getOptionByValue(this, value); + if(!option) return; + + // deselect + option.selected = false; + + fireOnChangeEvent(this); } /** @@ -222,19 +256,31 @@ class EasySelect{ if(this.isDisabled) return; // update current HTML - this.current.innerHTML = getOptionHTML(this); + this.current.innerHTML = getCurrentInnerHTML(this); const newValue = val(this); + const newValueArray = val(this, 'array'); /** Dropdown **/ if(!this.options.nativeSelect){ - // active option - this.dropdown.querySelectorAll(`[${ATTRS.optionAttr}]`).forEach(item => { - item.classList.remove(CLASSES.active); + // todo: this.selectTagData not updated + //console.log(this.selectTagData) + + // update active class + this.selectTagData.forEach(option => { + const isSelected = newValueArray.includes(option.value); + if(isSelected){ + // activate selected values + // todo: save dropdown el to selectTagData + this.dropdown.querySelector(`[${ATTRS.optionAttr}="${option.value}"]`).classList.add(CLASSES.active); + }else{ + this.dropdown.querySelector(`[${ATTRS.optionAttr}="${option.value}"]`).classList.remove(CLASSES.active); + } }); - this.dropdown.querySelector(`[${ATTRS.optionAttr}="${newValue}"]`).classList.add(CLASSES.active); // close on change - if(this.options.closeOnChange) this.close(); + let isCloseOnChange = this.options.closeOnChange; + if(this.options.multiple) isCloseOnChange = false; // not close in multi select + if(isCloseOnChange) this.close(); } // update value attribute diff --git a/src/_style.scss b/src/_style.scss index 175be85..1d13812 100644 --- a/src/_style.scss +++ b/src/_style.scss @@ -274,3 +274,48 @@ min-height: var(--es-height); padding: 5px 20px; } + + +/**************************** + * Checkbox +****************************/ +.easy-select.es-multi-select { + .es-current-label { + max-width: var(--es-dropdown-width); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .es-dropdown { + .es-option { + position: relative; + padding-left: 45px; + + &:not(.es-active) { + .es-checkbox:before { + opacity: 0; + } + } + } + + .es-checkbox { + position: absolute; + top: .65em; + left: 20px; + width: 18px; + aspect-ratio: 1; + background-color: #e0e7ee; + border-radius: 3px; + + &:before { + content: '✓'; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: .7em; + } + } + } +} \ No newline at end of file diff --git a/src/configs.js b/src/configs.js index 97edb15..dadbdad 100644 --- a/src/configs.js +++ b/src/configs.js @@ -18,6 +18,9 @@ export const CLASSES = { searchEnabled: 'es-search-enabled', searchWrapper: 'es-search-wrapper', searchEmpty: 'es-search-empty', + + // multi select + multipleSelect: 'es-multi-select' } /** * Attributes @@ -39,6 +42,9 @@ export const DEFAULTS = { closeOnChange: true, align: "left", + multiple: false, + multipleLabel: "Select multiple options", + // show search input inside dropdown search: false, emptySearchText: "There are no options", // optional, text appear when search empty diff --git a/src/data.js b/src/data.js index a778ca5..2cdf304 100644 --- a/src/data.js +++ b/src/data.js @@ -1,4 +1,4 @@ -import {getIndex, getSelectedOption, stringToSlug} from "./utils"; +import {getIndex, getMultipleSelectedValues, getSelectedOption, stringToSlug} from "./utils"; /**************************************************** ********************** Data ********************* @@ -6,10 +6,30 @@ import {getIndex, getSelectedOption, stringToSlug} from "./utils"; /** * Get value + * @param context + * @param type * @returns {*} */ -export function val(context){ - context.value = context.selectTag.value; +export function val(context, type = 'string'){ + const valueArray = getMultipleSelectedValues(context.selectTag); + let value; + + switch(type){ + case "array": + value = valueArray; + break; + default: + // string + if(valueArray.length === 1){ + value = valueArray[0]; // => "value" + }else if(valueArray.length > 1){ + value = valueArray.join(','); // => "value1,value2" + }else{ + value = ''; // => "" + } + } + + context.value = value; return context.value; } @@ -27,7 +47,9 @@ export function getSelectData(context){ /** * Get option data - * @returns {{isSelected: boolean, index: *, id: string, label: *, value: (*|string|number|string[])}} + * @param context + * @param option + * @returns {{el: *, isSelected: *, index: *, id: string, label: string, isDisabled: (*|(() => boolean)|((setting: string) => boolean)|string|boolean), value: *}} */ export function getOptionData(context, option = undefined){ if(typeof option === 'undefined'){ @@ -35,13 +57,13 @@ export function getOptionData(context, option = undefined){ option = getSelectedOption(context.selectTag); } - const label = option.innerText; - const value = option.value; + const label = option?.innerText; + const value = option?.value; const index = getIndex(option); const id = stringToSlug(value) + '-' + index; - const isSelected = value === val(context); + const isSelected = val(context, 'array').includes(value); // tested with multi select const el = option; - const isDisabled = option.disabled; + const isDisabled = option?.disabled; return {id, label, value, isSelected, isDisabled, index, el}; } \ No newline at end of file diff --git a/src/layout.js b/src/layout.js index 5649c9e..37713df 100644 --- a/src/layout.js +++ b/src/layout.js @@ -1,3 +1,4 @@ +import {getOptionByValue} from "./utils"; import {getOptionData, val} from "./data"; import {CLASSES, ATTRS} from "./configs" @@ -10,10 +11,30 @@ import {CLASSES, ATTRS} from "./configs" * @returns {string} */ export function getCurrentHTML(context){ + return `
${getCurrentInnerHTML(context)}
`; +} + +export function getCurrentInnerHTML(context){ let html = ''; - html += `
`; - html += getOptionHTML(context); - html += `
`; + + if(context.options.multiple){ + // current multiple + const selectedValues = val(context, 'array'); + const labels = []; + selectedValues.forEach(value => { + const option = getOptionData(context, getOptionByValue(context, value)); + labels.push(option.label); + }); + html += `
`; + html += ``; + html += labels.length ? labels.join(', ') : context.options.multipleLabel; + html += ``; + html += `
`; + }else{ + // current single + html += getOptionHTML(context); + } + return html; } @@ -75,7 +96,8 @@ export function getDropdownHTML(context){ */ export function getOptionHTML(context, option = undefined){ // is active - const isActive = typeof option !== 'undefined' && option['value'] === val(context); + // tested with multi select + const isActive = typeof option !== 'undefined' && val(context, 'array').includes(option['value']); // return selected option if(typeof option === 'undefined'){ @@ -100,11 +122,22 @@ export function getOptionHTML(context, option = undefined){ * @returns {string} */ export function getOptionInnerHTML(context, option){ - let html = context.options.customDropDownOptionHTML(option); + // return custom HTML if any + let customHTML = context.options.customDropDownOptionHTML(option); + if(customHTML) return customHTML; - if(typeof html === 'undefined'){ - html = `${option['label']}`; + let html = ''; + + // multiple select + if(context.options.multiple){ + // option + html += ``; + html += `${option['label']}`; + return html; } + // single select + html = `${option['label']}`; + return html; } \ No newline at end of file diff --git a/src/methods.js b/src/methods.js index c40d96b..237d9d0 100644 --- a/src/methods.js +++ b/src/methods.js @@ -1,3 +1,4 @@ +import {initMultiSelect} from "./multi-select"; import {createEl, insertAfter, wrapAll} from "./utils"; import {getCurrentHTML, updateDropdownHTML} from "./layout"; import {val} from "./data"; @@ -25,7 +26,13 @@ export function init(context){ initSearchDropdown(context); } + // init multi select + if(context.options.multiple && !context.options.nativeSelect){ + initMultiSelect(context); + } + // update value attribute + // tested with multi select context.selectTag.setAttribute(ATTRS.value, val(context)); // Event: onInit diff --git a/src/multi-select.js b/src/multi-select.js new file mode 100644 index 0000000..59710d8 --- /dev/null +++ b/src/multi-select.js @@ -0,0 +1,9 @@ +import {CLASSES} from "./configs"; + +export function initMultiSelect(context){ + // add wrapper class + context.wrapper.classList.add(CLASSES.multipleSelect); + + // set multiple attribute + context.selectTag.setAttribute('multiple', 'true'); +} \ No newline at end of file diff --git a/src/utils.js b/src/utils.js index 0a0a4aa..926a68e 100644 --- a/src/utils.js +++ b/src/utils.js @@ -149,4 +149,34 @@ export function removeAccents(str){ return str.normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .replace(/đ/g, 'd').replace(/Đ/g, 'D'); +} + + +/** + * Get selected values from a select tag (support multiple select) + * @param selectTag + * @returns {*[]} + */ +export function getMultipleSelectedValues(selectTag){ + let result = []; + let options = selectTag && selectTag.options; + + for(const option of options){ + if(option.selected){ + result.push(option.value || option.text); + } + } + + return result; +} + + +/** + * Get option element by value + * @param context + * @param optionValue + * @returns {Element} + */ +export function getOptionByValue(context, optionValue){ + return context.selectTag.querySelector(`option[value="${optionValue}"]`); } \ No newline at end of file