UNPKG

misanek-angular-dual-listbox

Version:

Angular 4+ component for a dual listbox control.

972 lines (934 loc) 31.5 kB
import { Component, EventEmitter, Input, IterableDiffers, NgModule, Output } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; class BasicList { /** * @param {?} name */ constructor(name) { this._name = name; this.last = null; this.picker = ''; this.dragStart = false; this.dragOver = false; // Arrays will contain objects of { _id, _name }. this.pick = []; this.list = []; this.sift = []; } /** * @return {?} */ get name() { return this._name; } } var nextId = 0; class DualListComponent { /** * @param {?} differs */ constructor(differs) { this.differs = differs; this.id = `dual-list-${nextId++}`; this.key = '_id'; this.display = '_name'; this.height = '100px'; this.filter = false; this.format = DualListComponent.DEFAULT_FORMAT; this.sort = false; this.disabled = false; this.destinationChange = new EventEmitter(); this.sorter = (a, b) => { return (a._name < b._name) ? -1 : ((a._name > b._name) ? 1 : 0); }; this.available = new BasicList(DualListComponent.AVAILABLE_LIST_NAME); this.confirmed = new BasicList(DualListComponent.CONFIRMED_LIST_NAME); } /** * @param {?} changeRecord * @return {?} */ ngOnChanges(changeRecord) { if (changeRecord['filter']) { if (changeRecord['filter'].currentValue === false) { this.clearFilter(this.available); this.clearFilter(this.confirmed); } } if (changeRecord['sort']) { if (changeRecord['sort'].currentValue === true && this.compare === undefined) { this.compare = this.sorter; } else if (changeRecord['sort'].currentValue === false) { this.compare = undefined; } } if (changeRecord['format']) { this.format = changeRecord['format'].currentValue; if (typeof (this.format.direction) === 'undefined') { this.format.direction = DualListComponent.LTR; } if (typeof (this.format.add) === 'undefined') { this.format.add = DualListComponent.DEFAULT_FORMAT.add; } if (typeof (this.format.remove) === 'undefined') { this.format.remove = DualListComponent.DEFAULT_FORMAT.remove; } if (typeof (this.format.all) === 'undefined') { this.format.all = DualListComponent.DEFAULT_FORMAT.all; } if (typeof (this.format.none) === 'undefined') { this.format.none = DualListComponent.DEFAULT_FORMAT.none; } if (typeof (this.format.draggable) === 'undefined') { this.format.draggable = DualListComponent.DEFAULT_FORMAT.draggable; } } if (changeRecord['source']) { this.available = new BasicList(DualListComponent.AVAILABLE_LIST_NAME); this.updatedSource(); this.updatedDestination(); } if (changeRecord['destination']) { this.confirmed = new BasicList(DualListComponent.CONFIRMED_LIST_NAME); this.updatedDestination(); this.updatedSource(); } } /** * @return {?} */ ngDoCheck() { if (this.source && this.buildAvailable(this.source)) { this.onFilter(this.available); } if (this.destination && this.buildConfirmed(this.destination)) { this.onFilter(this.confirmed); } } /** * @param {?} source * @return {?} */ buildAvailable(source) { const /** @type {?} */ sourceChanges = this.sourceDiffer.diff(source); if (sourceChanges) { sourceChanges.forEachRemovedItem((r) => { const /** @type {?} */ idx = this.findItemIndex(this.available.list, r.item, this.key); if (idx !== -1) { this.available.list.splice(idx, 1); } }); sourceChanges.forEachAddedItem((r) => { // Do not add duplicates even if source has duplicates. if (this.findItemIndex(this.available.list, r.item, this.key) === -1) { this.available.list.push({ _id: this.makeId(r.item), _name: this.makeName(r.item) }); } }); if (this.compare !== undefined) { this.available.list.sort(this.compare); } this.available.sift = this.available.list; return true; } return false; } /** * @param {?} destination * @return {?} */ buildConfirmed(destination) { let /** @type {?} */ moved = false; const /** @type {?} */ destChanges = this.destinationDiffer.diff(destination); if (destChanges) { destChanges.forEachRemovedItem((r) => { const /** @type {?} */ idx = this.findItemIndex(this.confirmed.list, r.item, this.key); if (idx !== -1) { if (!this.isItemSelected(this.confirmed.pick, this.confirmed.list[idx])) { this.selectItem(this.confirmed.pick, this.confirmed.list[idx]); } this.moveItem(this.confirmed, this.available, this.confirmed.list[idx], false); moved = true; } }); destChanges.forEachAddedItem((r) => { const /** @type {?} */ idx = this.findItemIndex(this.available.list, r.item, this.key); if (idx !== -1) { if (!this.isItemSelected(this.available.pick, this.available.list[idx])) { this.selectItem(this.available.pick, this.available.list[idx]); } this.moveItem(this.available, this.confirmed, this.available.list[idx], false); moved = true; } }); if (this.compare !== undefined) { this.confirmed.list.sort(this.compare); } this.confirmed.sift = this.confirmed.list; if (moved) { this.trueUp(); } return true; } return false; } /** * @return {?} */ updatedSource() { this.available.list.length = 0; this.available.pick.length = 0; if (this.source !== undefined) { this.sourceDiffer = this.differs.find(this.source).create(null); } } /** * @return {?} */ updatedDestination() { if (this.destination !== undefined) { this.destinationDiffer = this.differs.find(this.destination).create(null); } } /** * @return {?} */ direction() { return this.format.direction === DualListComponent.LTR; } /** * @param {?=} list * @return {?} */ dragEnd(list = null) { if (list) { list.dragStart = false; } else { this.available.dragStart = false; this.confirmed.dragStart = false; } return false; } /** * @param {?} event * @param {?} item * @param {?} list * @return {?} */ drag(event, item, list) { if (!this.isItemSelected(list.pick, item)) { this.selectItem(list.pick, item); } list.dragStart = true; // Set a custom type to be this dual-list's id. event.dataTransfer.setData(this.id, item['_id']); } /** * @param {?} event * @param {?} list * @return {?} */ allowDrop(event, list) { if (event.dataTransfer.types.length && (event.dataTransfer.types[0] === this.id)) { event.preventDefault(); if (!list.dragStart) { list.dragOver = true; } } return false; } /** * @return {?} */ dragLeave() { this.available.dragOver = false; this.confirmed.dragOver = false; } /** * @param {?} event * @param {?} list * @return {?} */ drop(event, list) { if (event.dataTransfer.types.length && (event.dataTransfer.types[0] === this.id)) { event.preventDefault(); this.dragLeave(); this.dragEnd(); if (list === this.available) { this.moveItem(this.available, this.confirmed); } else { this.moveItem(this.confirmed, this.available); } } } /** * @return {?} */ trueUp() { let /** @type {?} */ changed = false; // Clear removed items. let /** @type {?} */ pos = this.destination.length; while ((pos -= 1) >= 0) { const /** @type {?} */ mv = this.confirmed.list.filter(conf => { if (typeof this.destination[pos] === 'object') { return conf._id === this.destination[pos][this.key]; } else { return conf._id === this.destination[pos]; } }); if (mv.length === 0) { // Not found so remove. this.destination.splice(pos, 1); changed = true; } } // Push added items. for (let /** @type {?} */ i = 0, /** @type {?} */ len = this.confirmed.list.length; i < len; i += 1) { let /** @type {?} */ mv = this.destination.filter((d) => { if (typeof d === 'object') { return (d[this.key] === this.confirmed.list[i]._id); } else { return (d === this.confirmed.list[i]._id); } }); if (mv.length === 0) { // Not found so add. mv = this.source.filter((o) => { if (typeof o === 'object') { return (o[this.key] === this.confirmed.list[i]._id); } else { return (o === this.confirmed.list[i]._id); } }); if (mv.length > 0) { this.destination.push(mv[0]); changed = true; } } } if (changed) { this.destinationChange.emit(this.destination); } } /** * @param {?} list * @param {?} item * @param {?=} key * @return {?} */ findItemIndex(list, item, key = '_id') { let /** @type {?} */ idx = -1; /** * @param {?} e * @return {?} */ function matchObject(e) { if (e._id === item[key]) { idx = list.indexOf(e); return true; } return false; } /** * @param {?} e * @return {?} */ function match(e) { if (e._id === item) { idx = list.indexOf(e); return true; } return false; } // Assumption is that the arrays do not have duplicates. if (typeof item === 'object') { list.filter(matchObject); } else { list.filter(match); } return idx; } /** * @param {?} source * @param {?} item * @return {?} */ makeUnavailable(source, item) { const /** @type {?} */ idx = source.list.indexOf(item); if (idx !== -1) { source.list.splice(idx, 1); } } /** * @param {?} source * @param {?} target * @param {?=} item * @param {?=} trueup * @return {?} */ moveItem(source, target, item = null, trueup = true) { let /** @type {?} */ i = 0; let /** @type {?} */ len = source.pick.length; if (item) { i = source.list.indexOf(item); len = i + 1; } for (; i < len; i += 1) { // Is the pick still in list? let /** @type {?} */ mv = []; if (item) { const /** @type {?} */ idx = this.findItemIndex(source.pick, item); if (idx !== -1) { mv[0] = source.pick[idx]; } } else { mv = source.list.filter(src => { return (src._id === source.pick[i]._id); }); } // Should only ever be 1 if (mv.length === 1) { // Add if not already in target. if (target.list.filter(trg => trg._id === mv[0]._id).length === 0) { target.list.push(mv[0]); } this.makeUnavailable(source, mv[0]); } } if (this.compare !== undefined) { target.list.sort(this.compare); } source.pick.length = 0; // Update destination if (trueup) { this.trueUp(); } // Delay ever-so-slightly to prevent race condition. setTimeout(() => { this.onFilter(source); this.onFilter(target); }, 10); } /** * @param {?} list * @param {?} item * @return {?} */ isItemSelected(list, item) { if (list.filter(e => Object.is(e, item)).length > 0) { return true; } return false; } /** * @param {?} event * @param {?} index * @param {?} source * @param {?} item * @return {?} */ shiftClick(event, index, source, item) { if (event.shiftKey && source.last && !Object.is(item, source.last)) { const /** @type {?} */ idx = source.sift.indexOf(source.last); if (index > idx) { for (let /** @type {?} */ i = (idx + 1); i < index; i += 1) { this.selectItem(source.pick, source.sift[i]); } } else if (idx !== -1) { for (let /** @type {?} */ i = (index + 1); i < idx; i += 1) { this.selectItem(source.pick, source.sift[i]); } } } source.last = item; } /** * @param {?} event * @param {?} list * @param {?} item * @return {?} */ selectItemClick(event, list, item) { if (this.format.ctrl_click && list.length !== 0 && !event.ctrlKey && !event.shiftKey) { list.splice(0, list.length); this.selectItem(list, item); } else { this.selectItem(list, item); } } /** * @param {?} list * @param {?} item * @return {?} */ selectItem(list, item) { const /** @type {?} */ pk = list.filter((e) => { return Object.is(e, item); }); if (pk.length > 0) { // Already in list, so deselect. for (let /** @type {?} */ i = 0, /** @type {?} */ len = pk.length; i < len; i += 1) { const /** @type {?} */ idx = list.indexOf(pk[i]); if (idx !== -1) { list.splice(idx, 1); } } } else { list.push(item); } } /** * @param {?} source * @return {?} */ selectAll(source) { source.pick.length = 0; source.pick = source.sift.slice(0); } /** * @param {?} source * @return {?} */ selectNone(source) { source.pick.length = 0; } /** * @param {?} source * @return {?} */ isAllSelected(source) { if (source.list.length === 0 || source.list.length === source.pick.length) { return true; } return false; } /** * @param {?} source * @return {?} */ isAnySelected(source) { if (source.pick.length > 0) { return true; } return false; } /** * @param {?} source * @return {?} */ unpick(source) { for (let /** @type {?} */ i = source.pick.length - 1; i >= 0; i -= 1) { if (source.sift.indexOf(source.pick[i]) === -1) { source.pick.splice(i, 1); } } } /** * @param {?} source * @return {?} */ clearFilter(source) { if (source) { source.picker = ''; this.onFilter(source); } } /** * @param {?} source * @return {?} */ onFilter(source) { if (source.picker.length > 0) { try { const /** @type {?} */ filtered = source.list.filter((item) => { if (Object.prototype.toString.call(item) === '[object Object]') { if (item._name !== undefined) { // @ts-ignore: remove when d.ts has locale as an argument. return item._name.toLocaleLowerCase(this.format.locale).indexOf(source.picker.toLocaleLowerCase(this.format.locale)) !== -1; } else { // @ts-ignore: remove when d.ts has locale as an argument. return JSON.stringify(item).toLocaleLowerCase(this.format.locale).indexOf(source.picker.toLocaleLowerCase(this.format.locale)) !== -1; } } else { // @ts-ignore: remove when d.ts has locale as an argument. return item.toLocaleLowerCase(this.format.locale).indexOf(source.picker.toLocaleLowerCase(this.format.locale)) !== -1; } }); source.sift = filtered; this.unpick(source); } catch (e) { if (e instanceof RangeError) { this.format.locale = undefined; } source.sift = source.list; } } else { source.sift = source.list; } } /** * @param {?} item * @return {?} */ makeId(item) { if (typeof item === 'object') { return item[this.key]; } else { return item; } } /** * @param {?} item * @param {?=} separator * @return {?} */ makeName(item, separator = '_') { const /** @type {?} */ display = this.display; /** * @param {?} itm * @return {?} */ function fallback(itm) { switch (Object.prototype.toString.call(itm)) { case '[object Number]': return itm; case '[object String]': return itm; default: if (itm !== undefined) { return itm[display]; } else { return 'undefined'; } } } let /** @type {?} */ str = ''; if (this.display !== undefined) { switch (Object.prototype.toString.call(this.display)) { case '[object Function]': str = this.display(item); break; case '[object Array]': for (let /** @type {?} */ i = 0, /** @type {?} */ len = this.display.length; i < len; i += 1) { if (str.length > 0) { str = str + separator; } if (this.display[i].indexOf('.') === -1) { // Simple, just add to string. str = str + item[this.display[i]]; } else { // Complex, some action needs to be performed const /** @type {?} */ parts = this.display[i].split('.'); const /** @type {?} */ s = item[parts[0]]; if (s) { // Use brute force if (parts[1].indexOf('substring') !== -1) { const /** @type {?} */ nums = (parts[1].substring(parts[1].indexOf('(') + 1, parts[1].indexOf(')'))).split(','); switch (nums.length) { case 1: str = str + s.substring(parseInt(nums[0], 10)); break; case 2: str = str + s.substring(parseInt(nums[0], 10), parseInt(nums[1], 10)); break; default: str = str + s; break; } } else { // method not approved, so just add s. str = str + s; } } } } break; default: str = fallback(item); break; } } else { str = fallback(item); } return str; } } DualListComponent.AVAILABLE_LIST_NAME = 'available'; DualListComponent.CONFIRMED_LIST_NAME = 'confirmed'; DualListComponent.LTR = 'left-to-right'; DualListComponent.RTL = 'right-to-left'; DualListComponent.DEFAULT_FORMAT = { add: 'Add', remove: 'Remove', all: 'All', none: 'None', direction: DualListComponent.LTR, draggable: true, locale: undefined, ctrl_click: false }; DualListComponent.decorators = [ { type: Component, args: [{ selector: 'dual-list', template: ` <div class="dual-list"> <div class="listbox" [ngStyle]="{ 'order' : direction() ? 1 : 2, 'margin-left' : direction() ? 0 : '10px' }"> <button type="button" name="addBtn" class="btn btn-primary btn-block" (click)="moveItem(available, confirmed)" [ngClass]="direction() ? 'point-right' : 'point-left'" [disabled]="available.pick.length === 0">{{format.add}}</button> <form *ngIf="filter" class="filter"> <input class="form-control" name="filterSource" [(ngModel)]="available.picker" (ngModelChange)="onFilter(available)"> </form> <div class="record-picker"> <ul [ngStyle]="{'max-height': height, 'min-height': height}" [ngClass]="{over:available.dragOver}" (drop)="drop($event, confirmed)" (dragover)="allowDrop($event, available)" (dragleave)="dragLeave()"> <li *ngFor="let item of available.sift; let idx=index;" (click)="disabled ? null : selectItemClick($event, available.pick, item); shiftClick($event, idx, available, item)" [ngClass]="{selected: isItemSelected(available.pick, item), disabled: disabled}" [draggable]="!disabled && format.draggable" (dragstart)="drag($event, item, available)" (dragend)="dragEnd(available)" ><label>{{item._name}}</label></li> </ul> </div> <div class="button-bar"> <button type="button" class="btn btn-primary pull-left" (click)="selectAll(available)" [disabled]="disabled || isAllSelected(available)">{{format.all}}</button> <button type="button" class="btn btn-default pull-right" (click)="selectNone(available)" [disabled]="!isAnySelected(available)">{{format.none}}</button> </div> </div> <div class="listbox" [ngStyle]="{ 'order' : direction() ? 2 : 1, 'margin-left' : direction() ? '10px' : 0 }"> <button type="button" name="removeBtn" class="btn btn-primary btn-block" (click)="moveItem(confirmed, available)" [ngClass]="direction() ? 'point-left' : 'point-right'" [disabled]="confirmed.pick.length === 0">{{format.remove}}</button> <form *ngIf="filter" class="filter"> <input class="form-control" name="filterDestination" [(ngModel)]="confirmed.picker" (ngModelChange)="onFilter(confirmed)"> </form> <div class="record-picker"> <ul [ngStyle]="{'max-height': height, 'min-height': height}" [ngClass]="{over:confirmed.dragOver}" (drop)="drop($event, available)" (dragover)="allowDrop($event, confirmed)" (dragleave)="dragLeave()"> <li #itmConf *ngFor="let item of confirmed.sift; let idx=index;" (click)="disabled ? null : selectItemClick($event, confirmed.pick, item); shiftClick($event, idx, confirmed, item)" [ngClass]="{selected: isItemSelected(confirmed.pick, item), disabled: disabled}" [draggable]="!disabled && format.draggable" (dragstart)="drag($event, item, confirmed)" (dragend)="dragEnd(confirmed)" ><label>{{item._name}}</label></li> </ul> </div> <div class="button-bar"> <button type="button" class="btn btn-primary pull-left" (click)="selectAll(confirmed)" [disabled]="disabled || isAllSelected(confirmed)">{{format.all}}</button> <button type="button" class="btn btn-default pull-right" (click)="selectNone(confirmed)" [disabled]="!isAnySelected(confirmed)">{{format.none}}</button> </div> </div> </div> `, styles: [` div.record-picker { overflow-x: hidden; overflow-y: auto; border: 1px solid #ddd; border-radius:8px; position: relative; cursor: pointer; } /* http://www.ourtuts.com/how-to-customize-browser-scrollbars-using-css3/ */ div.record-picker::-webkit-scrollbar { width: 12px; } div.record-picker::-webkit-scrollbar-button { width: 0px; height: 0px; } div.record-picker { scrollbar-base-color: #337ab7; scrollbar-3dlight-color: #337ab7; scrollbar-highlight-color: #337ab7; scrollbar-track-color: #eee; scrollbar-arrow-color: gray; scrollbar-shadow-color: gray; scrollbar-dark-shadow-color: gray; } div.record-picker::-webkit-scrollbar-track { background:#eee; -webkit-box-shadow: 0px 0px 3px #dfdfdf inset; box-shadow: 0px 0px 3px #dfdfdf inset; border-top-right-radius: 8px; border-bottom-right-radius: 8px; } div.record-picker::-webkit-scrollbar-thumb { background: #337ab7; border: thin solid gray; border-top-right-radius: 8px; border-bottom-right-radius: 8px; } div.record-picker::-webkit-scrollbar-thumb:hover { background: #286090; } .record-picker ul { margin: 0; padding: 0 0 1px 0; } .record-picker li { border-top: thin solid #ddd; border-bottom: 1px solid #ddd; display: block; padding: 2px 2px 2px 10px; margin-bottom: -1px; font-size: 0.85em; cursor: pointer; white-space: nowrap; min-height:16px; } .record-picker li:hover { background-color: #f5f5f5; } .record-picker li.selected { background-color: #d9edf7; } .record-picker li.selected:hover { background-color: #c4e3f3; } .record-picker li.disabled { opacity: 0.5; cursor: default; background-color: inherit; } .record-picker li:first-child { border-top-left-radius: 8px; border-top-right-radius: 8px; border-top: none; } .record-picker li:last-child { border-bottom-left-radius: 8px; border-bottom-right-radius: 8px; border-bottom: none; } .record-picker label { cursor: pointer; font-weight: inherit; font-size: 14px; padding: 4px; margin-bottom: -1px; -webkit-touch-callout: none; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none; } .record-picker ul.over { background-color:lightgray; } .dual-list { display: -webkit-box; display: -ms-flexbox; display: flex; -webkit-box-orient: horizontal; -webkit-box-direction: normal; -ms-flex-direction: row; flex-direction: row; -ms-flex-line-pack: start; align-content: flex-start; } .dual-list .listbox { width: 50%; margin: 0px; } .dual-list .button-bar { margin-top: 8px; } /* &nbsp;&nbsp;&nbsp;&#9654; */ .point-right::after { content: "\\25B6"; padding-left: 1em; } /* &#9664;&nbsp;&nbsp;&nbsp; */ .point-left::before { content: "\\25C0"; padding-right: 1em; } .dual-list .button-bar button { width: 47%; } button.btn-block { display: block; width: 100%; margin-bottom: 8px; } .filter { margin-bottom: -2.2em; } .filter::after { content:"o"; width:40px; color:transparent; font-size:2em; background-image:url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M0 64l192 192v192l128-32V256L512 64H0z"/></svg>'); background-repeat:no-repeat; background-position:center center; opacity:.2; top: -36px; left: calc(100% - 21px); position:relative; } `] },] }, ]; /** * @nocollapse */ DualListComponent.ctorParameters = () => [ { type: IterableDiffers, }, ]; DualListComponent.propDecorators = { 'id': [{ type: Input },], 'key': [{ type: Input },], 'display': [{ type: Input },], 'height': [{ type: Input },], 'filter': [{ type: Input },], 'format': [{ type: Input },], 'sort': [{ type: Input },], 'compare': [{ type: Input },], 'disabled': [{ type: Input },], 'source': [{ type: Input },], 'destination': [{ type: Input },], 'destinationChange': [{ type: Output },], }; class AngularDualListBoxModule { } AngularDualListBoxModule.decorators = [ { type: NgModule, args: [{ imports: [ CommonModule, FormsModule ], declarations: [DualListComponent], exports: [DualListComponent] },] }, ]; /** * @nocollapse */ AngularDualListBoxModule.ctorParameters = () => []; /** * Generated bundle index. Do not edit. */ export { BasicList, DualListComponent, AngularDualListBoxModule }; //# sourceMappingURL=misanek-angular-dual-listbox.js.map