484 lines
12 KiB
JavaScript
484 lines
12 KiB
JavaScript
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: '<?',
|
|
showFilter: '<?',
|
|
parent: '<?',
|
|
multiple: '<?',
|
|
onSelect: '&?',
|
|
translateFields: '<?',
|
|
data: '<?',
|
|
url: '@?',
|
|
fields: '<?',
|
|
include: '<?',
|
|
where: '<?',
|
|
order: '@?',
|
|
limit: '<?',
|
|
searchFunction: '&?',
|
|
searchDelay: '<?'
|
|
}
|
|
});
|