@syncfusion/ej2-lists
Version:
The listview control allows you to select an item or multiple items from a list-like interface and represents the data in interactive hierarchical structure across different layouts or views.
484 lines (465 loc) • 21.5 kB
text/typescript
import { Base, Event, getUniqueID, NotifyPropertyChanges, INotifyPropertyChanged, Property, detach, Browser } from '@syncfusion/ej2-base';
import { closest, Draggable, DragPosition, MouseEventArgs, remove, compareElementParent } from '@syncfusion/ej2-base';
import { addClass, isNullOrUndefined, getComponent, isBlazor, BlazorDragEventArgs, EventHandler } from '@syncfusion/ej2-base';
import { SortableModel } from './sortable-model';
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* Sortable Module provides support to enable sortable functionality in Dom Elements.
* ```html
* <div id="sortable">
* <div>Item 1</div>
* <div>Item 2</div>
* <div>Item 3</div>
* <div>Item 4</div>
* <div>Item 5</div>
* </div>
* ```
* ```typescript
* let ele: HTMLElement = document.getElementById('sortable');
* let sortObj: Sortable = new Sortable(ele, {});
* ```
*/
export class Sortable extends Base<HTMLElement> implements INotifyPropertyChanged {
private target: HTMLElement;
private curTarget: HTMLElement;
private placeHolderElement: HTMLElement;
/**
* It is used to enable or disable the built-in animations. The default value is `false`
*
* @default false
*/
public enableAnimation: boolean;
/**
* Specifies the sortable item class.
*
* @default null
*/
public itemClass: string;
/**
* Defines the scope value to group sets of sortable libraries.
* More than one Sortable with same scope allows to transfer elements between different sortable libraries which has same scope value.
*/
public scope: string;
/**
* Defines the callback function for customizing the cloned element.
*/
public helper: (Element: object) => HTMLElement;
/**
* Defines the callback function for customizing the placeHolder element.
*/
public placeHolder: (Element: object) => HTMLElement;
/**
* Specifies the callback function for drag event.
*
* @event 'object'
*/
public drag: (e: any) => void;
/**
* Specifies the callback function for beforeDragStart event.
*
* @event 'object'
*/
public beforeDragStart: (e: any) => void;
/**
* Specifies the callback function for dragStart event.
*
* @event 'object'
*/
public dragStart: (e: any) => void;
/**
* Specifies the callback function for beforeDrop event.
*
* @event 'object'
*/
public beforeDrop: (e: any) => void;
/**
* Specifies the callback function for drop event.
*
* @event 'object'
*/
public drop: (e: any) => void;
constructor(element: HTMLElement, options?: SortableModel) {
super(options, element);
this.bind();
}
protected bind(): void {
if (!this.element.id) {
this.element.id = getUniqueID('sortable');
}
if (!this.itemClass) {
this.itemClass = 'e-sort-item';
this.dataBind();
}
this.initializeDraggable();
}
private initializeDraggable(): void {
new Draggable(this.element, {
helper: this.getHelper,
dragStart: this.onDragStart,
drag: this.onDrag,
dragStop: this.onDragStop,
dragTarget: `.${this.itemClass}`,
enableTapHold: true,
tapHoldThreshold: 200,
queryPositionInfo: this.queryPositionInfo,
distance: 1
});
this.wireEvents();
}
private wireEvents(): void {
const wrapper: Element = this.element;
EventHandler.add(wrapper, 'keydown', this.keyDownHandler, this);
}
private unwireEvents(): void {
const wrapper: Element = this.element;
EventHandler.remove(wrapper, 'keydown', this.keyDownHandler);
}
private keyDownHandler(e: KeyboardEvent): void {
if (e.keyCode === 27) {
const dragStop: Draggable = getComponent(this.element, 'draggable');
if (dragStop) {
dragStop.intDestroy(null);
}
const dragWrapper: Element = document.getElementsByClassName('e-sortableclone')[0];
if (dragWrapper) {
dragWrapper.remove();
}
const dragPlaceholder: Element = document.getElementsByClassName('e-sortable-placeholder')[0];
if (dragPlaceholder) {
dragPlaceholder.remove();
}
}
}
private getPlaceHolder(target: HTMLElement, instance: Sortable): HTMLElement {
if (instance.placeHolder) {
const placeHolderElement: HTMLElement = instance.placeHolder(
{ element: instance.element, grabbedElement: this.target, target: target });
placeHolderElement.classList.add('e-sortable-placeholder');
return placeHolderElement;
}
return null;
}
private getHelper: Function = (e: { sender: MouseEventArgs, element: HTMLElement }) => {
const target: HTMLElement = this.getSortableElement(e.sender.target as HTMLElement);
if (!this.isValidTarget(target as Element, this)) {
return false;
}
let element: HTMLElement;
if (this.helper) {
element = this.helper({ sender: target, element: e.element });
} else {
element = target.cloneNode(true) as HTMLElement;
element.style.width = `${target.offsetWidth}px`; element.style.height = `${target.offsetHeight}px`;
}
addClass([element], ['e-sortableclone']);
document.body.appendChild(element);
return element;
}
private isValidTarget(target: Element, instance: Sortable): boolean {
return target && compareElementParent(target, instance.element) && target.classList.contains(instance.itemClass) &&
!target.classList.contains('e-disabled');
}
private onDrag: Function = (e: { target: HTMLElement, event: MouseEventArgs }) => {
if (!e.target) { return; }
this.trigger('drag', { event: e.event, element: this.element, target: e.target });
let newInst: Sortable = this.getSortableInstance(e.target); let target: HTMLElement = this.getSortableElement(e.target, newInst);
if ((this.isValidTarget(target, newInst) || (e.target && typeof e.target.className === 'string' && e.target.className.indexOf('e-list-group-item') > -1)) && (this.curTarget !== target ||
!isNullOrUndefined(newInst.placeHolder)) && (newInst.placeHolderElement ? newInst.placeHolderElement !== e.target : true)) {
if (e.target.classList.contains('e-list-group-item')) {
target = e.target;
}
this.curTarget = target;
if (this.target === target) { return; }
let oldIdx: number = this.getIndex(newInst.placeHolderElement, newInst);
const placeHolder: HTMLElement = this.getPlaceHolder(target, newInst);
let newIdx: number;
if (placeHolder) {
oldIdx = isNullOrUndefined(oldIdx) ? this.getIndex(this.target) : oldIdx;
newIdx = this.getIndex(target, newInst, e.event);
const isPlaceHolderPresent: boolean = this.isPlaceHolderPresent(newInst);
if (isPlaceHolderPresent && oldIdx === newIdx) { return; }
if (isPlaceHolderPresent) { this.removePlaceHolder(newInst); }
newInst.placeHolderElement = placeHolder;
if (e.target && typeof e.target.className === 'string' && e.target.className.indexOf('e-list-group-item') > -1) {
newInst.element.insertBefore(newInst.placeHolderElement, newInst.element.children[newIdx as number]);
} else if (newInst.element !== this.element && newIdx === newInst.element.childElementCount) {
newInst.element.appendChild(newInst.placeHolderElement);
} else {
newInst.element.insertBefore(newInst.placeHolderElement, newInst.element.children[newIdx as number]);
}
} else {
oldIdx = isNullOrUndefined(oldIdx) ? this.getIndex(this.target) :
this.getIndex(target, newInst) < oldIdx || !oldIdx ? oldIdx : oldIdx - 1;
newIdx = this.getIndex(target, newInst);
const idx: number = newInst.element !== this.element ? newIdx : oldIdx < newIdx ? newIdx + 1 : newIdx;
this.updateItemClass(newInst);
newInst.element.insertBefore(this.target, newInst.element.children[idx as number]);
this.curTarget = this.target;
this.trigger('drop', {
droppedElement: this.target, element: newInst.element, previousIndex: oldIdx, currentIndex: newIdx,
target: e.target, helper: document.getElementsByClassName('e-sortableclone')[0], event: e.event, scope: this.scope
});
}
} else if (this.curTarget !== this.target && this.scope && this.curTarget !== target && !isNullOrUndefined(newInst.placeHolder)) {
this.removePlaceHolder(this.getSortableInstance(this.curTarget));
this.curTarget = this.target;
}
newInst = this.getSortableInstance(this.curTarget);
if (isNullOrUndefined(target) && e.target !== newInst.placeHolderElement) {
if (this.isPlaceHolderPresent(newInst)) { this.removePlaceHolder(newInst); }
} else {
const placeHolders: Element[] = [].slice.call(document.getElementsByClassName('e-sortable-placeholder')); let inst: Sortable;
placeHolders.forEach((placeHolder: Element) => {
inst = this.getSortableInstance(placeHolder);
if (inst.element && inst !== newInst) { this.removePlaceHolder(inst); }
});
}
}
private removePlaceHolder(instance: Sortable): void {
remove(instance.placeHolderElement);
instance.placeHolderElement = null;
}
private updateItemClass(instance: Sortable): void {
if (this !== instance) {
this.target.classList.remove(this.itemClass);
this.target.classList.add(instance.itemClass);
}
}
private getSortableInstance(element: Element): Sortable {
element = closest(element, `.e-${this.getModuleName()}`);
if (element) {
const inst: Sortable = getComponent(element as HTMLElement, Sortable) as Sortable;
return inst.scope && this.scope && inst.scope === this.scope ? inst : this;
} else {
return this;
}
}
private getIndex(target: Element, instance: Sortable = this, e?: MouseEventArgs): number {
let idx: number; let placeHolderPresent: boolean;
[].slice.call(instance.element.children).forEach((element: HTMLElement, index: number): void => {
if (element.classList.contains('e-sortable-placeholder')) { placeHolderPresent = true; }
if (element === target) {
idx = index;
if (!isNullOrUndefined(e)) {
if (placeHolderPresent) { idx -= 1; }
const offset: ClientRect = target.getBoundingClientRect();
const clientY: number = offset.bottom - ((offset.bottom - offset.top) / 2);
const cltY: number = e.changedTouches ? e.changedTouches[0].clientY : e.clientY;
idx = cltY <= clientY ? idx : idx + 1;
}
return;
}
});
return idx;
}
private getSortableElement(element: HTMLElement, instance: Sortable = this): HTMLElement {
return closest(element, `.${instance.itemClass}`) as HTMLElement;
}
private onDragStart: Function = (e: { target: HTMLElement, event: MouseEventArgs, helper: Element } & BlazorDragEventArgs) => {
this.target = this.getSortableElement(e.target);
if (isNullOrUndefined(this.target) && closest(this.element, '.e-listbox-container') && Browser.isDevice) {
detach(e.dragElement);
(getComponent(this.element, 'draggable') as any).intDestroy(e.event);
return;
}
let cancelDrag: boolean = false;
this.target.classList.add('e-grabbed');
this.curTarget = this.target;
e.helper = document.getElementsByClassName('e-sortableclone')[0];
const args: object = { cancel: false, element: this.element, target: this.target };
this.trigger('beforeDragStart', args, (observedArgs: BeforeDragEventArgs) => {
if (observedArgs.cancel) {
cancelDrag = observedArgs.cancel;
this.onDragStop(e);
}
});
if (cancelDrag) {
return;
}
if (isBlazor) {
this.trigger('dragStart', {
event: e.event, element: this.element, target: this.target,
bindEvents: e.bindEvents, dragElement: e.dragElement
});
} else {
this.trigger('dragStart', { event: e.event, element: this.element, target: this.target });
}
}
private queryPositionInfo(value: DragPosition): DragPosition {
value.left = scrollX ? `${parseFloat(value.left) - scrollX}px` : value.left;
value.top = scrollY ? `${parseFloat(value.top) - scrollY}px` : value.top;
return value;
}
private isPlaceHolderPresent(instance: Sortable): boolean {
return instance.placeHolderElement && !!closest(instance.placeHolderElement, `#${instance.element.id}`);
}
private onDragStop: Function = (e: { target: HTMLElement, event: MouseEvent & TouchEvent, helper: Element }) => {
let dropInst: Sortable = this.getSortableInstance(this.curTarget); let prevIdx: number; let curIdx: number; let handled: boolean;
prevIdx = this.getIndex(this.target);
const isPlaceHolderPresent: boolean = this.isPlaceHolderPresent(dropInst);
if (isPlaceHolderPresent) {
const curIdx: number = this.getIndex(dropInst.placeHolderElement, dropInst);
prevIdx = this === dropInst && (prevIdx - curIdx) >= 1 ? prevIdx - 1 : prevIdx;
const args: DropEventArgs = {
previousIndex: prevIdx, currentIndex: curIdx, target: e.target, droppedElement: this.target,
helper: e.helper, cancel: false, handled: false
};
this.trigger('beforeDrop', args, (observedArgs: DropEventArgs) => {
if (!observedArgs.cancel) {
handled = observedArgs.handled;
this.updateItemClass(dropInst);
if (observedArgs.handled) {
const ele: Node = this.target.cloneNode(true);
this.target.classList.remove('e-grabbed');
this.target = ele as HTMLElement;
}
dropInst.element.insertBefore(this.target, dropInst.placeHolderElement);
const curIdx: number = this.getIndex(this.target, dropInst);
if (observedArgs.currentIndex > observedArgs.previousIndex) {
prevIdx = this === dropInst && (prevIdx - curIdx) >= 1 ? prevIdx - 1 : prevIdx;
}
this.trigger('drop', {
event: e.event, element: dropInst.element, previousIndex: prevIdx, currentIndex: curIdx,
target: e.target, helper: e.helper, droppedElement: this.target, scopeName: this.scope, handled: handled
});
}
this.removePlaceHolder(dropInst);
});
}
dropInst = this.getSortableInstance(e.target);
curIdx = dropInst.element.childElementCount;
prevIdx = this.getIndex(this.target);
if (dropInst.element.querySelector('.e-list-nrt')) {
curIdx = curIdx - 1;
}
if (this.curTarget === this.target && e.target === this.curTarget) {
curIdx = prevIdx;
}
if (dropInst.element === e.target || (!isPlaceHolderPresent && this.curTarget === this.target)) {
const beforeDropArgs: DropEventArgs = {
previousIndex: prevIdx, currentIndex: curIdx,
target: e.target, droppedElement: this.target, helper: e.helper, cancel: false
};
this.trigger('beforeDrop', beforeDropArgs, (observedArgs: DropEventArgs) => {
if ((dropInst.element === e.target || (typeof e.target.className === 'string' && e.target.className.indexOf('e-list-nrt') > -1) || (typeof e.target.className === 'string' && e.target.className.indexOf('e-list-nr-template') > -1)
|| e.target.closest('.e-list-nr-template')) && !observedArgs.cancel) {
this.updateItemClass(dropInst);
dropInst.element.appendChild(this.target);
this.trigger('drop', {
event: e.event, element: dropInst.element, previousIndex: prevIdx, currentIndex: curIdx,
target: e.target, helper: e.helper, droppedElement: this.target, scopeName: this.scope
});
}
});
}
this.target.classList.remove('e-grabbed');
this.target = null;
this.curTarget = null;
remove(e.helper);
(getComponent(this.element, 'draggable') as Draggable).intDestroy(e.event);
}
/**
* It is used to sort array of elements from source element to destination element.
*
* @param destination - Defines the destination element to which the sortable elements needs to be appended.
*
* If it is null, then the Sortable library element will be considered as destination.
* @param targetIndexes - Specifies the sortable elements indexes which needs to be sorted.
* @param insertBefore - Specifies the index before which the sortable elements needs to be appended.
* If it is null, elements will be appended as last child.
* @function moveTo
* @returns {void}
*/
public moveTo(destination?: HTMLElement, targetIndexes?: number[], insertBefore?: number): void {
moveTo(this.element, destination, targetIndexes, insertBefore);
}
/**
* It is used to destroy the Sortable library.
*/
public destroy(): void {
this.unwireEvents();
if (this.itemClass === 'e-sort-item') { this.itemClass = null; this.dataBind(); }
(getComponent(this.element, Draggable) as Draggable).destroy();
super.destroy();
}
public getModuleName(): string {
return 'sortable';
}
public onPropertyChanged(newProp: SortableModel, oldProp: SortableModel): void {
for (const prop of Object.keys(newProp)) {
switch (prop) {
case 'itemClass':
[].slice.call(this.element.children).forEach((element: HTMLElement): void => {
if (element.classList.contains(oldProp.itemClass)) {
element.classList.remove(oldProp.itemClass);
}
if (newProp.itemClass) {
element.classList.add(newProp.itemClass);
}
});
break;
}
}
}
}
/**
* It is used to sort array of elements from source element to destination element.
*
* @param {HTMLElement} from - The source element from which to move elements.
* @param {HTMLElement} [to=from] - The destination element to which to move elements. Defaults to the source element.
* @param {number[]} [targetIndexes] - The indexes of elements to move. If not provided, all children of the source element will be moved.
* @param {number} [insertBefore] - The index before which to insert the moved elements in the destination element. If not provided, elements will be appended to the end of the destination element.
* @returns {void}
* @private
*/
export function moveTo(from: HTMLElement, to?: HTMLElement, targetIndexes?: number[], insertBefore?: number): void {
let targetElements: Element[] = [];
if (!to) { to = from; }
if (targetIndexes && targetIndexes.length) {
targetIndexes.forEach((index: number): void => {
targetElements.push(from.children[index as number]);
});
} else {
targetElements = [].slice.call(from.children);
}
if (isNullOrUndefined(insertBefore)) {
targetElements.forEach((target: Element): void => {
to.appendChild(target);
});
} else {
const insertElement: HTMLElement = to.children[insertBefore as number] as HTMLElement;
targetElements.forEach((target: Element): void => {
to.insertBefore(target, insertElement);
});
}
}
/**
* An interface that holds item drop event arguments
*/
export interface DropEventArgs {
previousIndex: number;
currentIndex: number;
droppedElement: Element;
target: Element;
helper: Element;
cancel?: boolean;
handled?: boolean;
}
/**
* An interface that holds item before drag event arguments
*/
export interface BeforeDragEventArgs {
cancel?: boolean;
target: Element;
}