import './style.scss'; import ngModule from '../../module'; import Popover from '../popover'; import ArrayModel from '../array-model/array-model'; import CrudModel from '../crud-model/crud-model'; import {mergeWhere} from 'vn-loopback/util/filter'; import focus from '../../lib/focus'; /** * @event select Thrown when model item is selected * @event change Thrown when model data is ready */ export default class DropDown extends Popover { constructor($element, $, $transclude) { super($element, $, $transclude); this.valueField = 'id'; this.showField = 'name'; this._search = undefined; this._activeOption = -1; this.showLoadMore = true; this.showFilter = true; this.searchDelay = 300; } get search() { return this._search; } set search(value) { function nullify(value) { return value == '' || value == undefined ? null : value; } let oldValue = nullify(this._search); this._search = value; if (!this.shown) return; value = nullify(value); if (value === oldValue && this.modelData != null) return; if (value != null) this._activeOption = 0; this.$timeout.cancel(this.searchTimeout); if (this.model) { this.model.clear(); if (this.model instanceof CrudModel) { this.searchTimeout = this.$timeout(() => { this.refreshModel(); this.searchTimeout = null; }, value != null ? this.searchDelay : 0); } else this.refreshModel(); } this.buildList(); } get statusText() { let model = this.model; let data = this.modelData; if (!model) return 'No data'; if (model.isLoading || this.searchTimeout) return 'Loading...'; if (data == null) return 'No data'; if (model.moreRows) return 'Load More'; if (data.length === 0) return 'No results found'; return null; } get activeOption() { return this._activeOption; } /** * Shows the drop-down. If a parent is specified it is shown in a visible * relative position to it. * * @param {HTMLElement} parent The parent element to show drop down relative to * @param {String} search The initial search term or %null */ show(parent, search) { if (this.shown) return; super.show(parent); this._activeOption = -1; this.list = this.popup.querySelector('.list'); this.ul = this.popup.querySelector('ul'); this.docKeyDownHandler = e => this.onDocKeyDown(e); this.document.addEventListener('keydown', this.docKeyDownHandler); this.listScrollHandler = e => this.onScroll(e); this.list.addEventListener('scroll', this.listScrollHandler); this.list.scrollTop = 0; this.search = search; this.buildList(); focus(this.popup.querySelector('input')); } hide() { if (!this.shown) return; super.hide(); this.document.removeEventListener('keydown', this.docKeyDownHandler); this.docKeyDownHandler = null; this.list.removeEventListener('scroll', this.listScrollHandler); this.listScrollHandler = null; } onClose() { this.destroyList(); this.list = null; this.ul = null; super.onClose(); } /** * Activates an option and scrolls the drop-down to that option. * * @param {Number} option The option index */ moveToOption(option) { this.activateOption(option); let list = this.list; let li = this.activeLi; if (!li) return; let liRect = li.getBoundingClientRect(); let listRect = list.getBoundingClientRect(); if (liRect.bottom > listRect.bottom) list.scrollTop += liRect.bottom - listRect.bottom; else if (liRect.top < listRect.top) list.scrollTop -= listRect.top - liRect.top; } /** * Activates an option. * * @param {Number} option The option index */ activateOption(option) { this._activeOption = option; if (this.activeLi) this.activeLi.className = ''; let data = this.modelData; if (option >= 0 && data && option < data.length) { this.activeLi = this.ul.children[option]; this.activeLi.className = 'active'; } } /** * Selects an option. * * @param {Number} option The option index */ selectOption(option) { let data = this.modelData; let item = option != -1 && data ? data[option] : null; if (item) { let value = item[this.valueField]; if (this.multiple) { if (!Array.isArray(this.selection)) { this.selection = []; this.field = []; } this.selection.push(item); this.field.push(value); } else { this.selection = item; this.field = value; } this.emit('select', {item}); } if (!this.multiple) this.hide(); } onClearClick() { this.search = null; } onScroll() { let list = this.list; let shouldLoad = list.scrollTop + list.clientHeight >= (list.scrollHeight - 40) && !this.model.isLoading; if (shouldLoad) this.model.loadMore(); } onLoadMoreClick(event) { if (event.defaultPrevented) return; this.model.loadMore(); } onContainerClick(event) { if (event.defaultPrevented) return; let index = getPosition(this.ul, event); if (index != -1) this.selectOption(index); } onDocKeyDown(event) { if (event.defaultPrevented) return; let data = this.modelData; let option = this.activeOption; let nOpts = data ? data.length - 1 : 0; switch (event.key) { case 'Tab': this.selectOption(option); return; case 'Enter': this.selectOption(option); break; case 'ArrowUp': this.moveToOption(option <= 0 ? nOpts : option - 1); break; case 'ArrowDown': this.moveToOption(option >= nOpts ? 0 : option + 1); break; case 'End': this.moveToOption(nOpts); break; case 'Home': this.moveToOption(0); break; default: return; } event.preventDefault(); this.$.$applyAsync(); } buildList() { if (!this.shown) return; this.destroyList(); let hasTemplate = this.$transclude && this.$transclude.isSlotFilled('tplItem'); let fragment = this.document.createDocumentFragment(); let data = this.modelData; if (data) { for (let i = 0; i < data.length; i++) { let option = data[i]; option.orgShowField = option[this.showField]; if (this.translateFields) { option = Object.assign({}, option); for (let field of this.translateFields) option[field] = this.$t(option[field]); } let li = this.document.createElement('li'); li.setAttribute('name', option.orgShowField); fragment.appendChild(li); if (this.multiple) { let check = this.document.createElement('input'); check.type = 'checkbox'; li.appendChild(check); if (this.field && this.field.indexOf(option[this.valueField]) != -1) check.checked = true; } if (hasTemplate) { this.$transclude((clone, scope) => { Object.assign(scope, option); li.appendChild(clone[0]); this.scopes[i] = scope; }, null, 'tplItem'); } else { let text = this.document.createTextNode(option[this.showField]); li.appendChild(text); } } } this.ul.appendChild(fragment); this.activateOption(this._activeOption); this.$.$applyAsync(() => this.relocate()); } destroyList() { if (this.ul) this.ul.innerHTML = ''; if (this.scopes) { for (let scope of this.scopes) scope.$destroy(); } this.scopes = []; } getFields() { let fields = []; fields.push(this.valueField); fields.push(this.showField); if (this.fields) { for (let field of this.fields) fields.push(field); } return fields; } // Model related code onDataChange() { if (this.model.orgData) this.emit('dataReady'); this.buildList(); } get modelData() { return this._model ? this._model.data : null; } get model() { return this._model; } set model(value) { this.linkEvents({_model: value}, {dataChange: this.onDataChange}); this.onDataChange(); this.search = this.search; } get url() { return this._url; } set url(value) { this._url = value; if (value) { let model = new CrudModel(this.$q, this.$http); model.url = value; this.initModel(model); } } get data() { return this._data; } set data(value) { this._data = value; if (value) { let model = new ArrayModel(this.$q, this.$filter); model.orgData = value; this.initModel(model); } } initModel(model) { model.autoLoad = false; model.$onInit(); this.model = model; } refreshModel() { let model = this.model; let order; if (this.order) order = this.order; else if (this.showField) order = `${this.showField} ASC`; let filter = { order, limit: this.limit || 30 }; if (model instanceof CrudModel) { let searchExpr = this._search == null ? null : this.searchFunction({$search: this._search}); Object.assign(filter, { fields: this.getFields(), include: this.include, where: mergeWhere(this.where, searchExpr) }); } else if (model instanceof ArrayModel) { if (this._search != null) filter.where = this.searchFunction({$search: this._search}); } return this.model.applyFilter(filter); } searchFunction(scope) { if (this.model instanceof CrudModel) return {[this.showField]: {like: `%${scope.$search}%`}}; if (this.model instanceof ArrayModel) return {[this.showField]: scope.$search}; } } /** * Gets the position of an event element relative to a parent. * * @param {HTMLElement} parent The parent element * @param {Event} event The event object * @return {Number} The element index */ function getPosition(parent, event) { let target = event.target; let children = parent.children; if (target === parent) return -1; while (target.parentNode !== parent) target = target.parentNode; for (let i = 0; i < children.length; i++) { if (children[i] === target) return i; } return -1; } ngModule.vnComponent('vnDropDown', { slotTemplate: require('./index.html'), controller: DropDown, transclude: { tplItem: '?tplItem' }, bindings: { field: '=?', selection: '=?', search: '