import './css/dropdown.scss'; import { r as lang } from "../utility/lgres"; import { contains, nullOrEmpty } from "../utility/strings"; import { global, isPositive, throttle } from "../utility"; import { createElement } from "../functions"; import { createCheckbox } from "./checkbox"; import { createIcon } from "./icon" const SymbolDropdown = Symbol.for('ui-dropdown'); const DropdownItemHeight = 30; let r = lang; let dropdownGlobal = global[SymbolDropdown]; if (dropdownGlobal == null) { // init dropdownGlobal = {}; Object.defineProperty(dropdownGlobal, 'clear', { writable: false, configurable: false, enumerable: false, value: function () { const panels = document.querySelectorAll('.ui-drop-box.active'); for (let panel of [...panels]) { if (panel == null) { continue; } panel.classList.remove('active'); const dropId = panel.parentElement.dataset.dropId; if (dropId == null) { continue; } const dropdown = this[dropId]; if (dropdown?.multiSelect && typeof dropdown.onCollapsed === 'function') { dropdown.onCollapsed(); } } } }) global[SymbolDropdown] = dropdownGlobal; document.addEventListener('mousedown', e => { let parent = e.target; while (parent != null) { if (parent.classList.contains('ui-drop-box')) { e.stopPropagation(); return; } parent = parent.parentElement; } dropdownGlobal.clear(); }); } function selectItems(label, itemlist, template, htmlkey, textkey) { let htmls; if (typeof template === 'function') { htmls = itemlist.map(it => template.call(this, it)); } else { htmls = itemlist.map(it => it[htmlkey]); } if (htmls.some(it => it instanceof HTMLElement)) { label.replaceChildren(...htmls.filter(it => it != null).map(it => it.cloneNode(true))); } else { let text = itemlist.map(it => it[textkey]).join(', '); if (nullOrEmpty(text)) { text = r('noneItem', '( None )'); } label.innerText = text; } } function filterSource(searchkeys, textkey, key, source) { if (!Array.isArray(searchkeys) || searchkeys.length === 0) { searchkeys = [textkey]; } if (key.length > 0) { source = source.filter(it => { for (let k of searchkeys) { if (contains(it[k].toLowerCase(), key)) { return true; } } return false; }); } return source; } function getValue(it, valuekey, textkey) { if (it == null) { return null; } const value = it[valuekey]; if (value == null || value === '') { return it[textkey]; } return value; } /** * 下拉列表参数对象 * @typedef DropdownOptions * @property {string} [textKey=text] - 文本关键字 * @property {string} [valueKey=value] - 值关键字 * @property {string} [htmlKey=html] - 源码显示的关键字 * @property {Function} [htmlTemplate] - 模板创建函数 * @property {number} [maxLength=500] - 最大输入长度 * @property {boolean} [multiSelect] - 是否允许多选 * @property {string} [selected] - 选中值 * @property {string[]} [selectedList] - 选中的数组 * @property {boolean} [disabled] - 是否禁用 * @property {boolean} [input] - 是否支持输入 * @property {boolean} [search] - 是否支持搜索 * @property {string[]} [searchKeys] - 搜索的关键字数组 * @property {string} [searchPlaceholder] - 搜索提示文本,默认值取语言资源 `searchHolder` "Search..." * @property {number} [tabIndex] - 焦点索引 * @property {string} [placeholder] - 输入框的提示文本 * @property {boolean} [slideFixed] - 是否固定为向下展开 * @property {HTMLElement} [wrapper] - 父元素,默认添加到头元素之后 */ export class Dropdown { _var = {}; // _var.options; // _var.wrapper; // _var.container; // _var.label; // _var.allChecked; // _var.source; // _var.lastSelected; // _var.selected; // _var.selectedList; sourceFilter; onSelectedList; onSelected; onExpanded; onCollapsed; constructor(options = {}) { options.textKey ??= 'text'; options.valueKey ??= 'value'; options.htmlKey ??= 'html'; options.maxLength ??= 500; this._var.options = options; const getText = options.getText; if (typeof getText === 'function') { r = getText; } else if (typeof GetTextByKey === 'function') { r = GetTextByKey; } options.searchPlaceholder ??= r('searchHolder', 'Search...'); } create() { const options = this._var.options; // wrapper const wrapper = createElement('div', 'ui-drop-wrapper'); const dropId = String(Math.random()).substring(2); if (options.wrapper instanceof HTMLElement) { options.wrapper.dataset.dropId = dropId; } wrapper.dataset.dropId = dropId; dropdownGlobal[dropId] = this; this._var.wrapper = wrapper; // header const header = createElement('div', 'ui-drop-header'); header.addEventListener('keypress', e => { if (e.key === ' ' || e.key === 'Enter') { header.dispatchEvent(new MouseEvent('click')); } }); header.addEventListener('keydown', e => { const up = e.key === 'ArrowUp'; const down = e.key === 'ArrowDown'; if (up || down) { const source = this.source; const count = source.length; const valuekey = this._var.options.valueKey; const textkey = this._var.options.textKey; let index = source?.indexOf(this._var.selected); if (isNaN(index) || index < -1) { index = -1; } else if (index >= count) { index = count - 1; } if (up) { if (index > 0) { index--; } else { index = 0; } } else if (down) { if (index < 0) { index = 0; } else if (index < count) { index++; } else { index = count - 1; } } const target = getValue(source[index], valuekey, textkey); if (target != null) { this.select(target); } } else if (e.key === 'Tab') { this._dropdown(false); } }); header.addEventListener('click', () => { if (this.disabled) { return; } const active = this._expanded; const label = this._var.label; if (active && label.ownerDocument.activeElement === label) { return; } this._dropdown(!active); if (!active && typeof this.onExpanded === 'function') { setTimeout(() => this.onExpanded(), 120); } }); // label or input let label; if (options.input) { label = createElement('input', 'ui-drop-text'); label.type = 'text'; label.autocomplete = 'off'; label.draggable = false; options.placeholder && label.setAttribute('placeholder', options.placeholder); isPositive(options.maxLength) && label.setAttribute('maxlength', options.maxLength); isPositive(options.tabIndex) && label.setAttribute('tabindex', options.tabIndex); label.addEventListener('input', e => { const key = e.target.value.toLowerCase(); const source = filterSource(options.searchKeys, options.textKey, key, this.source); this._filllist(source); this._var.container.classList.add('active'); }); label.addEventListener('blur', e => this.select(e.target.value)); label.addEventListener('mousedown', e => this._expanded && e.stopPropagation()); } else { isPositive(options.tabIndex) && header.setAttribute('tabindex', options.tabIndex); label = createElement('label', 'ui-drop-text'); } this._var.label = label; if (options.multiSelect) { if (Array.isArray(options.selectedList)) { this.selectlist(options.selectedList, true); } else { this._var.allChecked = true; label.innerText = r('allItem', '( All )'); } } else if (options.selected != null) { this.select(options.selected, true); } header.append(label, createElement('label', 'ui-drop-caret')); wrapper.appendChild(header); this.disabled = options.disabled || false; return wrapper; } get multiSelect() { return this._var.options.multiSelect } get ignoreAll() { return this._var.options.ignoreAll } get disabled() { return this._var.wrapper == null || this._var.wrapper.querySelector('.ui-drop-header.disabled') != null } set disabled(flag) { if (this._var.wrapper == null) { return; } if (flag) { this._var.wrapper.querySelector('.ui-drop-header').classList.add('disabled'); } else { this._var.wrapper.querySelector('.ui-drop-header').classList.remove('disabled'); } } get source() { let source = this._var.source; if (source == null || !Array.isArray(source)) { if (typeof this.sourceFilter === 'function') { source = this.sourceFilter(); } if (!Array.isArray(source)) { source = []; } this._var.source = source; } return source; } set source(list) { if (!Array.isArray(list)) { return; } this._var.source = list; if (this._expanded) { setTimeout(() => this._dropdown(), 120); } } get selected() { return this._var.selected } get selectedList() { return this._var.selectedList || [] } select(selected, silence, ignoreCase) { if (typeof selected !== 'string') { selected = String(selected); } if (ignoreCase) { selected = selected.toLowerCase(); } if (this._var.lastSelected === selected) { return; } this._var.lastSelected = selected; const valuekey = this._var.options.valueKey; const textkey = this._var.options.textKey; const template = this._var.options.htmlTemplate; const htmlkey = this._var.options.htmlKey; let item = this.source.find(it => (ignoreCase ? String(getValue(it, valuekey, textkey)).toLowerCase() : String(getValue(it, valuekey, textkey))) === selected); if (this._var.options.input) { if (item == null) { item = { [valuekey]: selected }; } this._var.label.value = selected; } else { const expanded = this._expanded; if (expanded) { this._var.container.querySelectorAll('li[data-value].selected').forEach(li => li.classList.remove('selected')); } if (item == null) { this._var.selected = null; this._var.label.innerText = ' '; return false; } let html; if (typeof template === 'function') { html = template.call(this, item); } else { html = item[htmlkey]; } if (html instanceof HTMLElement) { this._var.label.replaceChildren(html.cloneNode(true)); } else if (typeof html === 'string') { this._var.label.innerHTML = html; } else { let text = item[textkey]; if (nullOrEmpty(text)) { text = ' '; } this._var.label.innerText = text; } if (expanded) { for (let li of this._var.container.querySelectorAll('li[data-value]')) { if ((ignoreCase ? li.dataset.value.toLowerCase() : li.dataset.value) === selected) { li.classList.add('selected'); break; } } // const val = selected.replace(/"/g, '\\"'); // const li = this._var.container.querySelector(`li[data-value="${val}"]`); // if (li != null) { // li.classList.add('selected'); // } } } this._var.selected = item; if (!silence && typeof this.onSelected === 'function') { this.onSelected(item); } return true; } selectlist(selectedlist, silence) { const source = this.source; const valuekey = this._var.options.valueKey; const textkey = this._var.options.textKey; const template = this._var.options.htmlTemplate; const htmlkey = this._var.options.htmlKey; const itemlist = selectedlist.map(a => { const v = typeof a === 'string' ? a : String(a); let item = source.find(it => String(getValue(it, valuekey, textkey)) === v); if (item == null) { item = { [valuekey]: v, [textkey]: v }; } return item; }); if (itemlist.length === 0) { this._var.selectedList = null; this._var.label.innerText = r('none', '( None )'); return false; } selectItems(this._var.label, itemlist, template, htmlkey, textkey); this._var.selectedList = itemlist; if (!silence && typeof this.onSelectedList === 'function') { this.onSelectedList(itemlist); } } get _expanded() { return this._var.container?.classList?.contains('active') } _dropdown(flag = true) { const options = this._var.options; let panel = this._var.container; if (panel == null) { panel = createElement('div', 'ui-drop-box'); // search box if (!options.input && options.search) { const search = createElement('div', 'ui-drop-search'); const input = createElement('input'); input.type = 'text'; isPositive(options.tabIndex) && input.setAttribute('tabindex', options.tabIndex); !nullOrEmpty(options.searchPlaceholder) && input.setAttribute('placeholder', options.searchPlaceholder); input.addEventListener('input', e => { const key = e.target.value.toLowerCase(); const source = filterSource(options.searchKeys, options.textKey, key, this.source); this._filllist(source); }) search.append(input, createIcon('fa-light', 'search')); panel.appendChild(search); } // list const list = createElement('div', 'ui-drop-list'); list.addEventListener('scroll', e => throttle(this._onlistscroll, 10, this, list, e.target.scrollTop), { passive: true }); if (!this.multiSelect) { list.addEventListener('click', e => { let li = e.target; while (li.tagName !== 'LI') { li = li.parentElement; if (li == null) { return; } } const value = li.dataset.value; if (this.select(value) !== false) { dropdownGlobal.clear(); } }); } panel.appendChild(list); this._var.container = panel; if (options.wrapper instanceof HTMLElement) { options.wrapper.appendChild(panel); } else { this._var.wrapper.appendChild(panel); } } if (flag) { let source = this.source; if (!options.input && options.search) { const search = panel.querySelector('.ui-drop-search > input'); if (!nullOrEmpty(search?.value)) { source = filterSource(options.searchKeys, options.textKey, search.value, source); } } this._filllist(source); // slide direction if (!options.slideFixed) { const parent = options.wrapper ?? document.body; let p = this._var.wrapper; panel.style.minWidth = `${p.offsetWidth}px`; const headerHeight = p.offsetHeight; let top = p.offsetTop + headerHeight; let left = p.offsetLeft; if (p !== parent) { while ((p = p.parentElement) != null && p !== parent) { top -= p.scrollTop; left -= p.scrollLeft; } } p = this._var.wrapper; if (p !== parent) { while ((p = p.offsetParent) != null && p !== parent) { top += p.offsetTop; left += p.offsetLeft; } } const slideUp = top - parent.scrollTop + panel.offsetHeight >= parent.offsetHeight; if (options.wrapper instanceof HTMLElement) { if (slideUp) { panel.style.top = ''; panel.style.bottom = `${parent.offsetHeight - top + headerHeight - 4}px`; } else { panel.style.top = `${top}px`; panel.style.bottom = ''; } panel.style.left = `${left}px`; } if (slideUp) { panel.classList.add('slide-up'); } else { panel.classList.remove('slide-up'); } } panel.classList.add('active'); this._var.dropTop = 0; panel.querySelector('.ui-drop-list').dispatchEvent(new Event('scroll')); } else { panel.classList.remove('active'); } } _onlistscroll(list, top) { const offset = (this.multiSelect && !this.ignoreAll) ? DropdownItemHeight : 0; top -= (top % (DropdownItemHeight * 2)) + offset; if (top < 0) { top = 0; } else { let bottomTop = this._var.dropHeight - (20 * DropdownItemHeight); if (bottomTop < 0) { bottomTop = 0; } if (top > bottomTop) { top = bottomTop; } } if (this._var.dropTop !== top) { this._var.dropTop = top; const startIndex = top / DropdownItemHeight; let array = this._var.currentSource; if (startIndex + 20 < array.length) { array = array.slice(startIndex, startIndex + 20); } else { array = array.slice(-20); } const content = list.querySelector('.drop-content'); content.replaceChildren(); this._dofilllist(content, array); content.style.top = `${top + offset}px`; } } _filllist(source) { const list = this._var.container.querySelector('.ui-drop-list'); list.replaceChildren(); const height = source.length * DropdownItemHeight; this._var.dropHeight = height; this._var.currentSource = source; const holder = createElement('div', 'drop-holder'); holder.style.height = `${height}px`; const content = createElement('div', 'drop-content'); if (this.multiSelect && !this.ignoreAll) { list.appendChild( createElement('li', null, createCheckbox({ label: r('allItem', '( All )'), checked: this._var.allChecked, customAttributes: { 'isall': '1' }, onchange: e => this._triggerselect(e.target) }) ) ); content.style.top = `${DropdownItemHeight}px`; } else { content.style.top = '0px'; } const multiselect = this.multiSelect; const valuekey = this._var.options.valueKey; const textkey = this._var.options.textKey; const allchecked = this._var.allChecked; const selectedlist = this.selectedList; source.forEach((item, i) => { let val = getValue(item, valuekey, textkey); if (typeof val !== 'string') { val = String(val); } if (multiselect) { const selected = selectedlist.some(s => String(getValue(s, valuekey, textkey)) === val); item.__checked = allchecked || selected; } }); if (source.length > 20) { source = source.slice(0, 20); } const scrolled = this._dofilllist(content, source); list.append(holder, content); if (scrolled != null) { setTimeout(() => list.scrollTop = scrolled, 10); } } _dofilllist(content, array) { const multiselect = this.multiSelect; const valuekey = this._var.options.valueKey; const textkey = this._var.options.textKey; const template = this._var.options.htmlTemplate; const htmlkey = this._var.options.htmlKey; const selected = this.selected; let scrolled; array.forEach((item, i) => { let val = getValue(item, valuekey, textkey); if (typeof val !== 'string') { val = String(val); } const li = createElement('li'); li.dataset.value = val; li.title = item[textkey]; let label; let html; if (typeof template === 'function') { html = template.call(this, item); } else { html = item[htmlkey]; } if (html instanceof HTMLElement) { label = html; } else if (typeof html === 'string') { label = createElement('span'); label.innerHTML = html; } if (multiselect) { if (label == null) { label = createElement('span'); label.innerText = item[textkey]; } const box = createCheckbox({ label, checked: item.__checked, customAttributes: { 'class': 'dataitem', 'data-value': val }, onchange: e => this._triggerselect(e.target, item) }); li.appendChild(box); } else { if (label == null) { li.innerText = item[textkey]; } else { li.appendChild(label); } if (selected != null && String(selected[valuekey]) === val) { scrolled = DropdownItemHeight * i; li.classList.add('selected'); } } content.appendChild(li); }); return scrolled; } _triggerselect(checkbox, item) { let list; const valuekey = this._var.options.valueKey; const textkey = this._var.options.textKey; const template = this._var.options.htmlTemplate; const htmlkey = this._var.options.htmlKey; if (checkbox.getAttribute('isall') === '1') { const allchecked = this._var.allChecked = checkbox.checked; const boxes = this._var.container.querySelectorAll('input.dataitem'); boxes.forEach(box => box.checked = allchecked); list = []; } else { item.__checked = checkbox.checked; const all = this._var.container.querySelector('input[isall="1"]'); if (checkbox.checked) { const source = this.source; if (source.some(it => it.__checked) == null) { this._var.allChecked = true; if (all != null) { all.checked = true; } list = []; } else { list = source.filter(it => it.__checked); } } else { const val = checkbox.dataset.value; if (this._var.allChecked) { this._var.allChecked = false; if (all != null) { all.checked = false; } list = this.source.filter(it => String(getValue(it, valuekey, textkey)) !== val); } else { list = this.selectedList.filter(it => String(getValue(it, valuekey, textkey)) !== val); } } } if (this._var.allChecked) { this._var.label.innerText = r('allItem', '( All )'); } else { selectItems(this._var.label, list, template, htmlkey, textkey); } this._var.selectedList = list; if (typeof this.onSelectedList === 'function') { this.onSelectedList(itemlist); } } static resolve(dom = document.body, trigger) { const selects = dom.querySelectorAll('select'); for (let sel of selects) { const source = [...sel.children].map(it => { return { value: it.value, text: it.innerText } }); const drop = new Dropdown({ selected: sel.value, disabled: sel.disabled, tabIndex: sel.tabIndex }); drop.source = source; if (typeof trigger === 'function') { drop.onSelected = item => trigger.call(drop, item); } sel.parentElement.replaceChild(drop.create(), sel); } return dom; } }