456 lines
17 KiB
JavaScript
456 lines
17 KiB
JavaScript
"use strict";
|
|
|
|
//
|
|
// Takes a input form element and a hidden form element (to store the selected id in) along with an array of objects, to build a dropdown select control that allows the user to type part of the selection to filter the list.
|
|
// Modified for meteor.
|
|
//
|
|
// @param options: See Combo.DEFAULTS below.
|
|
//
|
|
//
|
|
(function($) {
|
|
let Combo = function($input, $hidden, options) {
|
|
let _this = this;
|
|
|
|
this.focusCounter = 0;
|
|
this.$input = $input;
|
|
this.$hidden = $hidden;
|
|
this.options = $.extend({}, Combo.DEFAULTS, options);
|
|
this.$selected = null;
|
|
this.$listContainer = $('<div/>', {style: 'position: relative; height: 0;'});
|
|
this.$list = $('<ul/>', {role: 'menu', class: this.options.listClass});
|
|
|
|
//Ensure that if the hidden field exists and changes, that the hidden field's id matches the text in the input field. If not then the hidden id field was changed manually and externally and the text field should be updated.
|
|
if(this.$hidden) {
|
|
this.$hidden.on('change', hiddenInputChanged);
|
|
}
|
|
|
|
function hiddenInputChanged() {
|
|
let id = _this.$hidden.val();
|
|
let $li = _this.$list.children("[role!='node']");
|
|
|
|
for(let i = 0; i < $li.length; i++) {
|
|
let $next = $($li[i]);
|
|
|
|
if($next.data('model').id == id) {
|
|
if(_this.$input.val() != $next.text())
|
|
_this.$input.val($next.text());
|
|
}
|
|
}
|
|
}
|
|
|
|
//this.$list.appendTo($input.parent());
|
|
this.$list.appendTo(this.$listContainer);
|
|
//this.$listContainer.appendTo($input.parent());
|
|
this.$listContainer.prependTo(document.body); //Place the container at the top of the page with no height.
|
|
|
|
//Setup the list to highlight the item the user is hovering over, to select the item the user clicks, and to remove the hover styling when the list closes due to a selection being made.
|
|
this.$list
|
|
.on('mousemove', 'li', function() {
|
|
// _this.$list.find(_this.options.selectionClass).removeClass(_this.options.selectionClass);
|
|
let $this = $(this);
|
|
|
|
//Skip nodes.
|
|
while($this && $this.attr('role') == 'node') {
|
|
$this = $this.next();
|
|
}
|
|
|
|
//If we could find a non-node element then highlight it.
|
|
if($this) $this.addClass(_this.options.selectionClass).siblings().removeClass(_this.options.selectionClass);
|
|
})
|
|
.on('mousedown', 'li', function() {
|
|
let $this = $(this);
|
|
|
|
//Skip nodes.
|
|
while($this && $this.attr('role') == 'node') {
|
|
$this = $this.next();
|
|
}
|
|
|
|
//If we could find a non-node element then highlight it.
|
|
if($this) _this.select($this);
|
|
})
|
|
.on('mouseup', 'li', function() {
|
|
//Remove the selection highlighting.
|
|
_this.$list.children().removeClass(_this.options.selectionClass);
|
|
//Hide the list.
|
|
_this.hide();
|
|
});
|
|
//Setup the input field to handle opening the list when it receives focus, and close it when it loses focus.
|
|
this.$input
|
|
.on('focus', $.proxy(_this.focus, _this))
|
|
.on('blur', $.proxy(_this.blur, _this));
|
|
// this.$listContainer
|
|
// .on('focus', $.proxy(_this.focus, _this, "list container"))
|
|
// .on('blur', $.proxy(_this.blur, _this, "list container"));
|
|
// this.$list
|
|
// .on('focus', $.proxy(_this.focus, _this, "list"))
|
|
// .on('blur', $.proxy(_this.blur, _this, "list"));
|
|
//Handle key events on the input field. Up/down arrows should change the selection in the list. Enter should select an item and close the list. Tab and escape should hide the list before moving to the next focusable element on the page.
|
|
this.$input.on('input keydown', function(event) {
|
|
switch(event.keyCode) {
|
|
case 38: { //Up
|
|
let visibles = _this.$list.find('li.visible[role!="node"]');
|
|
let selected = visibles.index(visibles.filter('.' + _this.options.selectionClass)) || 0;
|
|
_this.highlight(selected - 1);
|
|
event.preventDefault();
|
|
break;
|
|
}
|
|
case 40: { //Down
|
|
let visibles = _this.$list.find('li.visible[role!="node"]');
|
|
let selected = visibles.index(visibles.filter('li.selected')) || 0;
|
|
_this.highlight(selected + 1);
|
|
event.preventDefault();
|
|
break;
|
|
}
|
|
case 13: //Enter
|
|
if(_this.$list.is(':visible')) {
|
|
_this.select(_this.$list.find('li.selected'));
|
|
event.preventDefault();
|
|
}
|
|
break;
|
|
case 9: //Tab
|
|
_this.select(_this.$list.find('li.selected'));
|
|
break;
|
|
case 27: //Esc
|
|
if(_this.$input.hasClass('open')) {
|
|
_this.hide();
|
|
//Try to stop any default behavior from occurring.
|
|
if(event.stopPropagation) event.stopPropagation();
|
|
else event.cancelBubble = true; //IE 6-8
|
|
event.preventDefault();
|
|
return false;
|
|
}
|
|
else {
|
|
return true;
|
|
}
|
|
default:
|
|
_this.filter();
|
|
_this.highlight(0);
|
|
break;
|
|
}
|
|
});
|
|
|
|
//
|
|
// Adds one or more elements to the control.
|
|
// data: The item or array of items to add. This will be the root tree elements if groupFunctions is provided.
|
|
function add(data) {
|
|
let groupFunctions = _this.options.groupFunctions;
|
|
let getClasses = _this.options.getClasses;
|
|
|
|
let addOne = function(data, parent) {
|
|
let text = _this.options.textAttr ? ($.isFunction(_this.options.textAttr) ? _this.options.textAttr(data) : data[_this.options.textAttr]) : data;
|
|
let li = $("<li" + (parent ? " role='leaf'" : "") + (getClasses ? " class='" + getClasses(data) + "'" : "") + ">" + text + "</li>");
|
|
|
|
li.appendTo(_this.$list);
|
|
li.data('model', data);
|
|
if(parent) li.data('parent-li', parent);
|
|
};
|
|
let addOneGroup = function(data, text, children) {
|
|
let li = $("<li role='node'>" + text + "</li>");
|
|
|
|
li.appendTo(_this.$list);
|
|
li.data('model', data);
|
|
|
|
for(let childIndex = 0; childIndex < children.length; childIndex++) {
|
|
addOne(children[childIndex], li);
|
|
}
|
|
};
|
|
let addOneBranch = function(data) {
|
|
let parents = $.isFunction(groupFunctions.groupParents) ? groupFunctions.groupParents(data) : data;
|
|
|
|
//Since there may be one or more parents identified for each data element passed to us...
|
|
if(Array.isArray(parents)) {
|
|
for(let parentIndex = 0; parentIndex < parents.length; parentIndex++) {
|
|
addOneGroup(parents[parentIndex], groupFunctions.parentText(parents[parentIndex]), groupFunctions.children(parents[parentIndex]));
|
|
}
|
|
}
|
|
else {
|
|
addOneGroup(parents, groupFunctions.parentText(parents), groupFunctions.children(parents));
|
|
}
|
|
};
|
|
|
|
if(groupFunctions instanceof Object && $.isFunction(groupFunctions.children) && $.isFunction(groupFunctions.parentText)) {
|
|
if(Array.isArray(data)) {
|
|
for(let dataIndex = 0; dataIndex < data.length; dataIndex++) {
|
|
addOneBranch(data[dataIndex]);
|
|
}
|
|
}
|
|
else {
|
|
addOneBranch(data);
|
|
}
|
|
}
|
|
else {
|
|
if(Array.isArray(data)) {
|
|
for(let dataIndex = 0; dataIndex < data.length; dataIndex++) {
|
|
addOne(data[dataIndex]);
|
|
}
|
|
}
|
|
else {
|
|
addOne(data);
|
|
}
|
|
}
|
|
|
|
//Filter the set of elements so that only those matching the text in the input field are marked as visible.
|
|
_this.filter();
|
|
}
|
|
|
|
Tracker.autorun(function() {
|
|
this.$list.empty();
|
|
if(options.cursor) {
|
|
//Add the initial set of data.
|
|
add(options.cursor.fetch());
|
|
}
|
|
else if(options.set) {
|
|
for(let i = 0; i < options.set.length; i++) {
|
|
add(options.set[i]);
|
|
}
|
|
}
|
|
}.bind(this));
|
|
|
|
//Check the hidden input field for an ID, and setup the selection based in it if there is one.
|
|
if(this.$hidden && _this.$hidden.val()) {
|
|
hiddenInputChanged();
|
|
}
|
|
|
|
//TODO: Should probably check to ensure comparator is a function? Not that it will help much if the function is not written correctly. Stupid Javascript!
|
|
if(this.options.selection && this.options.comparator) {
|
|
Tracker.autorun(function() {
|
|
let selectedData = this.options.selection.get();
|
|
|
|
if(selectedData) {
|
|
let listItems = this.$list.children();
|
|
let found = false;
|
|
|
|
for(let i = 0; !found && i < listItems.length; i++) {
|
|
if(this.options.comparator($(listItems[i]).data('model'), selectedData)) {
|
|
found = true;
|
|
this.select($(listItems[i]));
|
|
}
|
|
}
|
|
}
|
|
}.bind(this));
|
|
}
|
|
};
|
|
|
|
Combo.DEFAULTS = {
|
|
cursor: undefined, //A meteor Cursor used to populate the values displayed in the combo.
|
|
set: [], //An array of values displayed in the combo. This must be specified if cursor is not specified.
|
|
selection: undefined, //A meteor ReactiveVar whose value will be set to the current selection.
|
|
comparator: function(a, b) {return a === b;}, //A function that takes two collection objects and compares them for equality. If the combo shows users for example, this comparator would compare one user id to another. Required for the combo to set the selection if the view changes it externally relative to this combo.
|
|
textAttr: undefined, //The attribute of the data elements to use for the name. This can also be a function that takes the data object and returns the text.
|
|
idAttr: 'id', //The attribute of the data elements to use for the ID. This can also be a function that takes the data obejct and returns the ID.
|
|
// groupFunctions: The object containing three functions: 'groupParent', 'parentText', 'children'.
|
|
// groupParents(data) will take a data element and return the objects that best represents the parents of the children (for a multi layer tree, this would be the node just before the leaf nodes).
|
|
// parentText(parent) will be passed the group parent and the data object that generated it, and will return the text that represents the path to that parent.
|
|
// children(parent) will be passed the group parent (returned by groupParents()), and will return an array of children or leaf nodes for the tree.
|
|
groupFunctions: undefined,
|
|
filter: true, //Whether to filter the list as the user types.
|
|
effects: 'fade',
|
|
duration: '200',
|
|
listClass: 'de.combo-list',
|
|
selectionClass: 'selected', //The class to use for the selected element in the dropdown list.
|
|
getClasses: undefined //An optional function that will return a string to use in the list item's class attribute to style the list item for a given model data. The function will be passed the data object for the list item.
|
|
};
|
|
|
|
Combo.prototype.select = function($li) {
|
|
if($li.length == 0) {
|
|
if(this.$input.val() != '') {
|
|
this.$input.val("");
|
|
if(this.$hidden) this.$hidden.val(undefined).change();
|
|
this.filter();
|
|
//Note: Don't trigger the select event - for some reason it causes the dropdown to reopen and the control to retain focus when clicking out of the widget.
|
|
}
|
|
}
|
|
else {
|
|
if(!this.$list.has($li) || !$li.is('li.visible')) return;
|
|
|
|
//No need to change selection if the selection has not changed.
|
|
if(this.$input.val() != $li.text()) {
|
|
this.$input.val($li.text()); //Save the selected text into the text input.
|
|
if(this.$hidden) {
|
|
this.$hidden.val($li.data('model')[this.options.idAttr]);
|
|
this.$hidden.change();
|
|
} //Save the ID into the hidden form input if it exists.
|
|
this.hide();
|
|
this.filter();
|
|
//this.trigger('select', $li);
|
|
|
|
//Set the reactive var for the selection if one is provided and the selection has changed relative to the model.
|
|
if(this.options.selection && this.options.selection.get() != $li.data('model')) {
|
|
this.options.selection.set($li.data('model'));
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
Combo.prototype.escapeRegex = function(s) {
|
|
return s.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
|
|
};
|
|
|
|
// Removes all filtering. This is used to clear the filtering when first opening the combo when there is a value in the field. This is desirable because when we have an exact match, but are opening the combo, we most often want to select a new value.
|
|
Combo.prototype.clearFilter = function() {
|
|
console.log("CLearing Filter");
|
|
//Show all list elements.
|
|
this.$list.find('li').addClass('visible').show();
|
|
//Hide any node list elements.
|
|
this.$list.find('li[role="node"]').removeClass('visible').hide();
|
|
};
|
|
|
|
//Filters the list items by marking those that match the text in the text field as having the class 'visible'.
|
|
Combo.prototype.filter = function() {
|
|
try {
|
|
let search = this.$input.val();
|
|
let _this = this;
|
|
|
|
search = search ? search : "";
|
|
search = search.toLowerCase().trim();
|
|
|
|
//Show all list elements.
|
|
this.$list.find('li').addClass('visible').show();
|
|
//Hide any node list elements.
|
|
this.$list.find('li[role="node"]').removeClass('visible').hide();
|
|
|
|
if(this.options.filter) {
|
|
//Hide non-node elements (leaf nodes) that don't match.
|
|
let li = this.$list.children();
|
|
|
|
let searches = search && search.length > 0 ? search.split(/\s+/) : undefined;
|
|
let regexs = searches ? [] : undefined;
|
|
|
|
if(searches) {
|
|
for(let i = 0; i < searches.length; i++) {
|
|
regexs.push(new RegExp("\\b" + this.escapeRegex(searches[i])));
|
|
}
|
|
}
|
|
|
|
//Iterate over the list elements:
|
|
// hide all list items that are nodes;
|
|
// show all list items that are not nodes and whose text matches the input value;
|
|
// show all node list items associated with visible child list items (they occur after the parent, so the parent will be hidden first, then made visible).
|
|
for(let i = 0; i < li.length; i++) {
|
|
let $next = $(li[i]);
|
|
let node = $next.attr('role') == 'node';
|
|
//let hidden = node || $next.text().toLowerCase().indexOf(search) < 0;
|
|
let text = $next.text().toLowerCase();
|
|
let match = true;
|
|
|
|
if(!node && searches) {
|
|
for(let i = 0; match && i < regexs.length; i++) {
|
|
match = regexs[i].test(text)
|
|
}
|
|
}
|
|
|
|
//let match = text.match(/\bxxx/gi);
|
|
let hidden = node || !match;
|
|
|
|
if(hidden) $next.removeClass('visible').hide();
|
|
|
|
//If this isn't hidden and we have a tree with grouping, then turn on the group (parent) associated with this option.
|
|
if(!hidden && _this.options.groupFunctions) {
|
|
let parent = $next.data('parent-li');
|
|
|
|
if(!parent.hasClass('visible')) parent.addClass('visible').show();
|
|
}
|
|
}
|
|
|
|
//If we hid all elements then hide the whole list.
|
|
if(this.$list.find('li.visible').length == 0) this.hide();
|
|
}
|
|
} catch(e) {
|
|
console.log(e);
|
|
}
|
|
};
|
|
|
|
Combo.prototype.focus = function() {
|
|
this.show();
|
|
this.$input.select();
|
|
};
|
|
|
|
Combo.prototype.blur = function() {
|
|
this.hide();
|
|
this.select(this.$list.find('li.selected'));
|
|
};
|
|
|
|
Combo.prototype.show = function() {
|
|
// Make sure we don't repeatedly try to show the combo.
|
|
if(!this.isShowing) {
|
|
let position = this.$input.offset();
|
|
|
|
this.isShowing = true;
|
|
// Position the list relative to the field. Note that we place the combo at the top of the page (in the body tag) to avoid overflow not showing and to ensure the page scrolls if needed.
|
|
this.$list.css({position: 'absolute', top: position.top + this.$input.outerHeight(), left: position.left, width: this.$input.outerWidth()});
|
|
this.clearFilter();
|
|
|
|
if(!this.$list.is(':visible') && this.$list.find('li.visible').length > 0) {
|
|
let fns = {default: 'show', fade: 'fadeIn', slide: 'slideDown'};
|
|
let fn = fns[this.options.effects];
|
|
|
|
this.trigger('show');
|
|
this.$input.addClass('open');
|
|
this.$list[fn](this.options.duration, $.proxy(this.trigger, this, 'shown'));
|
|
}
|
|
}
|
|
};
|
|
|
|
Combo.prototype.hide = function() {
|
|
if(this.isShowing) {
|
|
let fns = {default: 'hide', fade: 'fadeOut', slide: 'slideUp'};
|
|
let fn = fns[this.options.effects];
|
|
|
|
this.isShowing = false;
|
|
this.trigger('hide');
|
|
this.$input.removeClass('open');
|
|
this.$list[fn](this.options.duration, $.proxy(this.trigger, this, 'hidden'));
|
|
}
|
|
};
|
|
|
|
// goDown: true/false - defaults to true - indicating whether the highlighting should go up or down if the requested item is a node. Nodes cannot be highlighted or selected.
|
|
Combo.prototype.highlight = function(index) {
|
|
let _this = this;
|
|
|
|
this.show();
|
|
|
|
setTimeout(function() {
|
|
let visibles = _this.$list.find('li.visible[role!="node"]');
|
|
let oldSelected = _this.$list.find('li.' + _this.options.selectionClass).removeClass(_this.options.selectionClass);
|
|
let oldSelectedIndex = visibles.index(oldSelected);
|
|
|
|
if(visibles.length > 0) {
|
|
let selectedIndex = (visibles.length + index) % visibles.length;
|
|
let selected = visibles.eq(selectedIndex);
|
|
let top = selected.position().top;
|
|
|
|
if(selected.attr('role') != 'node') selected.addClass(_this.options.selectionClass);
|
|
|
|
if(selectedIndex < oldSelectedIndex && top < 0)
|
|
_this.$list.scrollTop(_this.$list.scrollTop() + top);
|
|
if(selectedIndex > oldSelectedIndex && top + selected.outerHeight() > _this.$list.outerHeight())
|
|
_this.$list.scrollTop(_this.$list.scrollTop() + selected.outerHeight() + 2 * (top - _this.$list.outerHeight()));
|
|
}
|
|
});
|
|
};
|
|
|
|
Combo.prototype.trigger = function(event) {
|
|
let params = Array.prototype.slice.call(arguments, 1);
|
|
let args = [event + '.de.combo'];
|
|
|
|
args.push(params);
|
|
|
|
if(this.$select) this.$select.trigger.apply(this.$select, args);
|
|
this.$input.trigger.apply(this.$input, args);
|
|
};
|
|
|
|
$.fn.buildCombo = function(options) {
|
|
for(let index = 0; index < this.length; index++) {
|
|
let $next = $(this[index]);
|
|
let nextCombo = new Combo($next, $next.siblings('input[type=hidden]').first(), options);
|
|
|
|
$next.data("de.combo", nextCombo);
|
|
}
|
|
};
|
|
$.fn.getCombo = function() {
|
|
if(this.length > 0) {
|
|
return $(this[0]).data('de.combo');
|
|
}
|
|
};
|
|
})(jQuery);
|