UNPKG

elation

Version:

Elation Javascript Component Framework

814 lines (769 loc) 29.1 kB
elation.require(["elements.elements", "elements.ui.item"], function() { elation.requireCSS("ui.list"); /** * List UI element * * @class list * @augments elation.ui.base * @memberof elation.ui * @alias elation.ui.list * * @param {object} args * @param {string} args.tag * @param {string} args.classname * @param {string} args.title * @param {boolean} args.draggable * @param {boolean} args.selectable * @param {boolean} args.hidden * @param {string} args.orientation * @param {string} args.sortbydefault * @param {array} args.items * @param {boolean} args.autoscroll * @param {number} args.autoscrollmargin * @param {elation.collection.simple} args.itemcollection * * @param {object} args.attrs * @param {object} args.attrs.name * @param {object} args.attrs.children * @param {object} args.attrs.label * @param {object} args.attrs.disabled * @param {object} args.attrs.itemtemplate * @param {object} args.attrs.itemcomponent * @param {object} args.attrs.itemplaceholder * */ /** * ui_list_select event * @event elation.ui.list#ui_list_select * @type {object} */ elation.elements.define('ui.list', class extends elation.elements.base { init() { super.init(); this.defineAttributes({ title: { type: 'string' }, hidden: { type: 'boolean' }, draggable: { type: 'boolean' }, selectable: { type: 'boolean' }, sortbydefault: { type: 'string' }, multiselect: { type: 'boolean' }, spinner : { type: 'boolean' }, orientation: { type: 'string' }, autoscroll: { type: 'boolean' }, autoscrollmargin: { type: 'integer', default: 100 }, //items: { type: 'object' }, itemcount: { type: 'number', get: this.getItemCount }, nameattr: { type: 'string', default: 'name' }, childattr: { type: 'string', default: 'items' }, labelattr: { type: 'string', default: 'label' }, titleattr: { type: 'string', default: 'title' }, disabledattr: { type: 'string', default: 'disabled' }, collection: { type: 'object', default: null }, itemtemplate: { type: 'string', default: '' }, itemcomponent: { type: 'object', default: 'ui.item' }, itemplaceholder: { type: 'object', default: null }, emptytemplate: { type: 'string' }, emptycontent: { type: 'string' }, }); this.items = []; this.listitems = []; this.selection = []; this.dirty = false; this.animatetime = 850; } create() { if (this.preview) { this.items = [{value: 1, label: 'One'}, {value: 2, label: 'Two'}, {value: 2, label: 'Three'}]; } if (this.collection) { this.setItemCollection(this.collection); } else if (this.items && this.items.length > 0) { this.setItems(this.items); } else if (this.items.length == 0) { this.extractItems(); } if (this.selectable) { this.addclass('state_selectable'); this.setAttribute('tabindex', 0); this.addEventListener('keydown', (ev) => this.handleKeydown(ev)); this.addEventListener('click', (ev) => this.handleClick(ev)); } this.setAttribute('role', (this.selectable ? 'listbox' : 'list')); if (this.orientation) { this.setOrientation(this.orientation); } if (this.sortbydefault) { this.setSortBy(this.sortbydefault); } if (this.hidden) { this.hide(); } let emptycontent = this.emptycontent; if (this.emptytemplate) { emptycontent = elation.templates.get(this.emptytemplate, this); } if (emptycontent) { this.emptyitem = this.createlistitem({ value: emptycontent, innerHTML: emptycontent, selectable: false, disabled: true }); } this.refresh(); } /** * Returns the UL element for this component, or create a new one if it doesn't exist yet * @function getListElement * @memberof elation.ui.list# * @returns {HTMLUListElement} */ getListElement() { /* if (this instanceof HTMLUListElement) { return this; } else if (!this.listul) { this.listul = elation.html.create({tag: 'ul', append: this}); } return this.listul; */ return this; } getItemCount() { if (this.itemcollection) { return this.itemcollection.length; } return this.items.length; } /** * Update the items associated with this list * @function setItems * @memberof elation.ui.list# */ setItems(items) { this.clear(); if (elation.utils.isArray(items)) { this.items = items; } else if (elation.utils.isString(items)) { this.items = items.split('|').map((x) => { return { value: x, nameattr: this.nameattr, childattr: this.childattr, labelattr: this.labelattr, disabledattr: this.disabledattr, itemtemplate: this.itemtemplate, itemcomponent: this.itemcomponent, itemplaceholder: this.itemplaceholder, }; }); } else { for (var k in items) { this.items.push(items[k]); } } this.refresh(); } /** * Links this list component with a collection to automatically handle updates when data changes * @function setItemCollection * @memberof elation.ui.list# * @param {elation.collection.simple} itemcollection */ setItemCollection(itemcollection) { if (this.itemcollection) { elation.events.remove(this.itemcollection, "collection_add,collection_remove,collection_move", this); } //this.clear(); if (itemcollection instanceof elation.elements.collection.simple) { this.itemcollection = itemcollection; } else if (elation.utils.isString(itemcollection)) { this.itemcollection = document.getElementById(itemcollection); } if (this.itemcollection) { elation.events.add(this.itemcollection, "collection_add,collection_remove,collection_move,collection_load,collection_load_begin,collection_clear", this); //this.setItems(this.itemcollection.items); if (this.hasOwnProperty('items')) { delete this.items; } // FIXME - some interaction between this.items, this.listitems, and this.sort is causing problems when you swap out collections for a list Object.defineProperty(this, 'items', { get: function() { return this.itemcollection.items; }, configurable: true }); Object.defineProperty(this, 'count', { configurable: true, get: function() { return this.itemcollection.length; }, configurable: true }); } this.refresh(); } /** * Extracts items out of the list's existing HTML structure * @function extractItems * @memberof elation.ui.list# */ extractItems() { var items = []; for (var i = 0; i < this.childNodes.length; i++) { var node = this.childNodes[i]; if (node instanceof HTMLLIElement) { var item = this.createlistitem({ value: node.innerHTML, innerHTML: node.innerHTML, selectable: this.selectable, draggable: (this.draggable ? 'true' : 'false'), nameattr: this.nameattr, childattr: this.childattr, labelattr: this.labelattr, titleattr: this.titleattr, disabledattr: this.disabledattr, itemtemplate: this.itemtemplate, itemcomponent: this.itemcomponent, itemplaceholder: this.itemplaceholder }); node.parentNode.removeChild(node); i--; items.push(item); } else if (node instanceof elation.elements.ui.item) { items.push(node); node.value = node.firstChild; node.selectable = this.selectable; node.draggable = (this.draggable ? 'true' : 'false'); elation.events.add(node, 'select', (ev) => this.handleSelect(ev)); node.parentNode.removeChild(node); i--; } } this.setItems(items); } /** * Add a new item to this list * @function addItem * @memberof elation.ui.list# * @param {Object} item */ addItem(item) { let wasScrollAtBottom = this.isScrollAtBottom(this.autoscrollmargin); this.items.push(item); this.refresh(); this.applyAutoscroll(wasScrollAtBottom); } /** * Add a new item to a specific position in this list * @function addItemAtPosition * @memberof elation.ui.list# * @param {Object} item * @param {integer} position */ addItemAtPosition(item, position) { this.items.splice(position, 0, item); //this.listitems.splice(position, 0, null); this.refresh(); } /** * Resets the list to empty * @function clear * @memberof elation.ui.list# */ clear() { var ul = this.getListElement(); var items = this.items; for (var i = 0; i < items.length; i++) { if (items[i]) { var item = this.getlistitem(i); if (item.parentNode) { item.parentNode.removeChild(item); delete this.listitems[i]; delete items[i]; } } } if (!this.itemcollection) { this.items = this.items.filter(n => n !== null); // if this isn't a collection-backed list, filter out empty items } this.listitems = []; //delete this.items; ul.innerHTML = ''; } /** * Get the elation.ui.listitem for a specified item, allocating as needed * @function getlistitem * @memberof elation.ui.list# * @param {Object} item * @returns {elation.ui.listitem} */ getlistitem(itemnum) { if (this.items[itemnum] instanceof elation.elements.ui.item) { return this.items[itemnum]; } var item = this.items[itemnum]; for (var i = 0; i < this.listitems.length; i++) { if (this.listitems[i] && this.listitems[i].value === item) { return this.listitems[i]; } } //if (!item) { // no existing listitem, allocate a new one let itemargs = { value: item, }; if (this.selectable) itemargs.selectable = true; if (this.draggable) itemargs.draggable = 'true'; // draggable attribute requires the string 'true' not an actual boolean if (this.nameattr) itemargs.nameattr = this.nameattr; if (this.childattr) itemargs.childattr = this.childattr; if (this.labelattr) itemargs.labelattr = this.labelattr; if (this.titleattr) itemargs.titleattr = this.titleattr; if (this.disabledattr) itemargs.disabledattr = this.disabledattr; if (this.itemtemplate) itemargs.itemtemplate = this.itemtemplate; if (this.itemcomponent) itemargs.itemcomponent = this.itemcomponent; if (this.itemplaceholder) itemargs.itemplaceholder = this.itemplaceholde; item = this.createlistitem(itemargs); elation.events.add(item, 'select', (ev) => this.handleSelect(ev)); this.listitems.push(item); //} return item; } /** * Creates a new instance of an elation.ui.item * Can be overridden by inheriting classes to override the ui.item type * @param {Object} args */ createlistitem(args) { let listitem = elation.elements.create(this.itemcomponent, args); listitem.setAttribute('role', (this.selectable ? 'option' : 'listitem')); //listitem.setAttribute('aria-label', 'test'); return listitem; } /** * Updates the list item objects and the HTML representation of this list with any new or removed items * @function render * @memberof elation.ui.list# */ render() { super.render(); var ul = this.getListElement(); // FIXME - this could be made more efficient in two ways: // 1) instead of removing all elements and then re-adding them in order, we should be // able to figure out deletions, additions, and moves and apply them separately // 2) currently when we remove list items, we still keep a reference to the old object which gets // reused if the same item is re-added. this can be a performance optimization in some // cases (automatic object reuse reduces gc if the same objects are added and removed repeatedly // over the lifetime of the list), but can be a memory leak in cases where lots of // non-repeating data is added and removed. var items = this.items; if (!items) return; /* for (var i = 0; i < items.length; i++) { if (items[i].parentNode == ul) { ul.removeChild(items[i]); } } */ if (items.length > 0) { if (this.emptyitem && this.emptyitem.parentNode == ul) { ul.removeChild(this.emptyitem); } for (var i = 0; i < items.length; i++) { var listitem = this.getlistitem(i); if (listitem.parentNode != ul) { ul.appendChild(listitem); } listitem.refresh(); } } else if (this.emptyitem) { ul.appendChild(this.emptyitem); } } /** * Sorts the items in the list by the specified key * @function sort * @memberof elation.ui.list# * @param {string} sortby * @param {boolean} reverse */ sort(sortby, reverse) { if (!reverse) reverse = false; // force to bool var ul = this.getListElement(); // First, get the existing position of each item's element // Then get a sorted item list, and resort the elements in the DOM // Next, apply a transform to place the items back in their old positions // Finally, set animation parameters and transform each item to its (0,0,0) position // Resort list items // FIXME - should also update this.items to reflect new order if (typeof sortby == 'function') { this.sortfunc = sortby; this.listitems.sort(sortby.bind(this)); } else { this.listitems.sort(function(a, b) { var val1 = elation.utils.arrayget(a.value, sortby), val2 = elation.utils.arrayget(b.value, sortby); if ((val1 < val2) ^ reverse) return -1; else if ((val1 > val2) ^ reverse) return 1; else return 0; }); } // First calculate existing position of all items var items = []; for (var i = 0; i < this.listitems.length; i++) { items[i] = {}; items[i].value = this.listitems[i].value; items[i].container = this.listitems[i]; items[i].oldpos = [this.listitems[i].offsetLeft, this.listitems[i].offsetTop]; items[i].oldlistpos = this.items.indexOf(this.listitems[i].value); } // Remove and re-add all items from list, so DOM order reflects item order // FIXME - this could be much more efficient, and is probably the slowest part of the whole process for (var i = 0; i < items.length; i++) { elation.html.removeclass(items[i], 'state_animating'); if (items[i].parentNode == ul) { ul.removeChild(items[i].container); } ul.appendChild(items[i].container); } // Calculate new item positions, and set transform var maxdist = 0; for (var i = 0; i < items.length; i++) { items[i].newpos = [items[i].container.offsetLeft, items[i].container.offsetTop]; items[i].diff = [items[i].oldpos[0] - items[i].newpos[0], items[i].oldpos[1] - items[i].newpos[1]], items[i].dist = Math.sqrt(items[i].diff[0]*items[i].diff[0] + items[i].diff[1] * items[i].diff[1]); if (items[i].dist > maxdist) maxdist = items[i].dist; } for (var i = 0; i < items.length; i++) { // FIXME - zooming is exaggerated and the animation feels slow on lists with fewer items. need to scale this value somehow var ratio = items[i].dist / maxdist; items[i].z = 100 * ratio; items[i].animatetime = this.animatetime * ratio; items[i].container.style.zIndex = parseInt(items[i].z); // Start transform at item's old position, z=0 elation.html.transform(items[i].container, 'translate3d(' + items[i].diff[0] + 'px, ' + items[i].diff[1] + 'px, 0px)', '50% 50%', 'none'); // Animate halfway to the new position while zooming out setTimeout(elation.bind(items[i], function() { elation.html.transform(this, 'translate3d(' + (this.diff[0]/2) + 'px,' + (this.diff[1]/2) + 'px, ' + this.z + 'px)', '50% 50%', 'all ' + (this.animatetime / 2) + 'ms ease-in'); }), 0); // Finish animating to the new position, and zoom back in setTimeout(elation.bind(items[i], function() { elation.html.transform(this, 'translate3d(0, 0, 0)', '50% 50%', 'all ' + (this.animatetime / 2) + 'ms ease-out'); }), items[i].animatetime / 2); this.items[i] = items[i].value; } if (i < this.items.length) { this.items.splice(i, this.items.length); } // Set classname based on sortby parameter this.setSortBy(sortby); } /** * Sets the current sorting mode for this class * @function setSortBy * @memberof elation.ui.list# * @param {string} sortby */ setSortBy(sortby) { if (this.sortby && elation.utils.isString(this.sortby)) { this.removeclass('ui_list_sortby_' + this.sortby); } this.sortby = sortby; if (elation.utils.isString(this.sortby)) { this.addclass('ui_list_sortby_' + this.sortby); } } /** * Returns a list of which items are currently visible in this list * @function getVisibleItems * @memberof elation.ui.list# * @returns {array} */ getVisibleItems() { var visible = []; for (var i = 0; i < this.listitems.length; i++) { var li = this.listitems[i]; if (li.offsetTop + li.offsetHeight >= this.scrollTop && li.offsetTop <= this.scrollTop + this.offsetHeight) { //console.log('visible:', i, li.args.item.label); visible.push(i); } } return visible; } /** * Sets the selection state of all items in the list * @function selectall * @memberof elation.ui.list# * @param {bool} state * @param {Array} exclude */ selectall(state, exclude) { if (state === undefined) state = true; if (exclude === undefined) exclude = []; if (state) { // select all for (var i = 0; i < this.listitems.length; i++) { var li = this.listitems[i]; if (exclude.indexOf(li) == -1 && this.selection.indexOf(li) == -1) { li.select(false); this.selection.push(li); } } } else { // deselect all while (this.selection.length > 0) { var li = this.selection.pop(); if (exclude.indexOf(li) == -1) { li.unselect(); } } } this.dispatchEvent({type: 'selectionchange', data: this.selection}); } /** * Set the selection array to include the specified item range * @function selectrange * @memberof elation.ui.list# * @param {nunber} start * @param {nunber} end */ selectrange(start, end) { start = Math.max(0, start); end = Math.min(this.listitems.length - 1, end); for (let i = 0; i < this.listitems.length; i++) { let item = this.listitems[i]; if (i >= start && i <= end) { if (!item.selected) { item.select(false); } } else if (item.selected) { item.unselect(); } } this.selection = this.listitems.slice(start, end+1); this.dispatchEvent({type: 'selectionchange', data: this.selection}); } /** * Sets the specified selection as being the last one clicked * @function setlastselection * @memberof elation.ui.list# * @param {elation.ui.item} selection */ setlastselection(selection) { if (this.lastselection) { this.lastselection.setlastselected(false); } this.lastselection = selection; this.lastselection.setlastselected(true); } /** * Scrolls to the bottom of the list * @function scrollToBottom * @memberof elation.ui.list# */ scrollToBottom() { this.scrollTop = this.scrollHeight; } /** * Is the list currently scrolled to the bottom? * @function isScrollAtBottom * @memberof elation.ui.list# */ isScrollAtBottom(margin=0) { return this.scrollTop + this.offsetHeight >= this.scrollHeight - margin; } applyAutoScroll(wasScrollAtBottom=true) { if (this.autoscroll && wasScrollAtBottom) { // Only autoscroll if the list was already near the bottom this.scrollToBottom(); setTimeout(() => this.scrollToBottom(), 10); } } /** * Event handler: elation.ui.item#ui_list_item_select * @function ui_list_item_select * @memberof elation.ui.list# * @param {event} ev */ handleSelect(ev) { var newselection = ev.element; // Ignore select events that bubble up from unrelated elements (eg, <textarea>) if (!(ev.element instanceof elation.elements.ui.item)) return; if (!ev.ctrlKey && !ev.shiftKey && this.selection.length > 0) { // If ctrl key wasn't down, unselect all selected items in the list this.selectall(false, [newselection]); } if (this.multiselect && ev.shiftKey && this.lastselection) { // If shift key was down and we had a previous item selected, perform a range-select var idx1 = this.listitems.indexOf(this.lastselection); var idx2 = this.listitems.indexOf(newselection); if (idx1 != -1 && idx2 != -1) { var start = Math.min(idx1, idx2); var end = Math.max(idx1, idx2); let curstart = (this.selection.length > 0 ? this.listitems.indexOf(this.selection[0]) : start), curend = (this.selection.length > 0 ? this.listitems.indexOf(this.selection[this.selection.length - 1]) : end); if (idx2 < curstart) end = curend; if (idx2 > curend) start = curstart; for (var i = start; i <= end; i++) { if (this.selection.indexOf(this.listitems[i]) == -1) { this.listitems[i].select(false); this.selection.push(this.listitems[i]); } } } } else { // Otherwise, perform a single selection var idx = this.selection.indexOf(newselection); if (idx == -1) { this.selection.push(newselection); } else { this.selection.splice(idx, 1); newselection.unselect(); } } //if (this.multiselect) { // Make note of the most recently-clicked list item, for future interaction this.setlastselection(newselection); //} if (elation.events.wasDefaultPrevented(elation.events.fire({type: 'select', element: this, target: ev.element, data: ev.data}))) { ev.preventDefault(); } this.dispatchEvent({type: 'selectionchange', data: this.selection}); } /** * Event handler: elation.collection.simple#collection_add * @function oncollection_add * @memberof elation.ui.list# * @param {event} ev */ oncollection_add(ev) { let wasScrollAtBottom = this.isScrollAtBottom(this.autoscrollmargin); this.refresh(); this.applyAutoScroll(wasScrollAtBottom); } /** * Event handler: elation.collection.simple#collection_remove * @function oncollection_remove * @memberof elation.ui.list# * @param {event} ev */ oncollection_remove(ev) { let wasScrollAtBottom = this.isScrollAtBottom(this.autoscrollmargin); this.refresh(); this.applyAutoScroll(wasScrollAtBottom); } /** * Event handler: elation.collection.simple#collection_move * @function oncollection_move * @memberof elation.ui.list# * @param {event} ev */ oncollection_move(ev) { this.refresh(); } /** * Event handler: elation.collection.simple#collection_load_begin * @function oncollection_load_begin * @memberof elation.ui.list# * @param {event} ev */ oncollection_load_begin(ev) { this.clear(); var ul = this.getListElement(); ul.innerHTML = ''; if (this.spinner) { this.appendChild(this.spinner); this.spinner.show(); } } /** * Event handler: elation.collection.simple#collection_load * @function oncollection_load * @memberof elation.ui.list# * @param {event} ev */ oncollection_load(ev) { let wasScrollAtBottom = this.isScrollAtBottom(this.autoscrollmargin); if (this.spinner) { this.removeChild(this.spinner); } this.refresh(); this.applyAutoScroll(wasScrollAtBottom); } /** * Event handler: elation.collection.simple#collection_clear * @function oncollection_clear * @memberof elation.ui.list# * @param {event} ev */ oncollection_clear(ev) { this.clear(); var ul = this.getListElement(); ul.innerHTML = ''; this.refresh(); this.applyAutoScroll(true); } /** * Event handler: keydown * @param {event} ev */ handleKeydown(ev) { let dirh = 0, dirv = 0; if (ev.key == 'ArrowUp') { dirv = -1; ev.stopPropagation(); ev.preventDefault(); } else if (ev.key == 'ArrowDown') { dirv = 1; ev.stopPropagation(); ev.preventDefault(); } else if (ev.key == 'ArrowLeft') { dirh = -1; ev.stopPropagation(); ev.preventDefault(); } else if (ev.key == 'ArrowRight') { dirh = 1; ev.stopPropagation(); ev.preventDefault(); } let selstart = this.listitems.indexOf(this.selection[0]), selend = this.listitems.indexOf(this.selection[this.selection.length - 1]); if (dirv != 0) { if (this.lastselection) { let leftpos = this.lastselection.offsetLeft; let idx = this.listitems.indexOf(this.lastselection); for (let i = idx + dirv; i >= 0 && i < this.listitems.length; i += dirv) { if (this.listitems[i].offsetLeft == leftpos) { let newselection = this.listitems[i]; newselection.select(); if (ev.shiftKey) { let newidx = i; if (newidx < selstart && dirv == -1) selstart = newidx; else if (newidx > selend && dirv == 1) selend = newidx; else if (newidx < selend && newidx >= selstart && dirv == -1 && !(idx == 0 && selstart == 0)) selend = newidx; else if (newidx > selstart && newidx <= selend && dirv == 1 && !(idx == this.listitems.length - 1 && selend == this.listitems.length - 1)) selstart = newidx; this.selectrange(selstart, selend); } if (newselection.offsetTop < this.scrollTop || newselection.offsetTop + newselection.offsetHeight > this.scrollTop + this.offsetHeight) { newselection.scrollIntoView({behavior: 'smooth', block: 'nearest'}); } break; } } } } if (dirh != 0) { if (this.lastselection) { let idx = this.listitems.indexOf(this.lastselection); let newidx = Math.max(0, Math.min(this.listitems.length - 1, idx + dirh)); let newselection = this.listitems[newidx]; newselection.select(); if (ev.shiftKey) { if (newidx < selstart && dirh == -1) selstart = newidx; else if (newidx > selend && dirh == 1) selend = newidx; else if (newidx < selend && newidx >= selstart && dirh == -1 && !(idx == 0 && selstart == 0)) selend = newidx; else if (newidx > selstart && newidx <= selend && dirh == 1 && !(idx == this.listitems.length - 1 && selend == this.listitems.length - 1)) selstart = newidx; this.selectrange(selstart, selend); } if (newselection.offsetTop < this.scrollTop || newselection.offsetTop + newselection.offsetHeight > this.scrollTop + this.offsetHeight) { newselection.scrollIntoView({bbbbehavior: 'smooth', block: 'nearest'}); } } } } handleClick(ev) { if (ev.target === this && this.selection.length > 0) { this.selectall(false); } } }); });