src/Droppable/Droppable.js
import {closest} from 'shared/utils';
import Draggable from '../Draggable';
import {DroppableStartEvent, DroppableDroppedEvent, DroppableReturnedEvent, DroppableStopEvent} from './DroppableEvent';
const onDragStart = Symbol('onDragStart');
const onDragMove = Symbol('onDragMove');
const onDragStop = Symbol('onDragStop');
const dropInDropzone = Symbol('dropInDropZone');
const returnToOriginalDropzone = Symbol('returnToOriginalDropzone');
const closestDropzone = Symbol('closestDropzone');
const getDropzones = Symbol('getDropzones');
/**
* Returns an announcement message when the Draggable element is dropped into a dropzone element
* @param {DroppableDroppedEvent} droppableEvent
* @return {String}
*/
function onDroppableDroppedDefaultAnnouncement({dragEvent, dropzone}) {
const sourceText = dragEvent.source.textContent.trim() || dragEvent.source.id || 'draggable element';
const dropzoneText = dropzone.textContent.trim() || dropzone.id || 'droppable element';
return `Dropped ${sourceText} into ${dropzoneText}`;
}
/**
* Returns an announcement message when the Draggable element has returned to its original dropzone element
* @param {DroppableReturnedEvent} droppableEvent
* @return {String}
*/
function onDroppableReturnedDefaultAnnouncement({dragEvent, dropzone}) {
const sourceText = dragEvent.source.textContent.trim() || dragEvent.source.id || 'draggable element';
const dropzoneText = dropzone.textContent.trim() || dropzone.id || 'droppable element';
return `Returned ${sourceText} from ${dropzoneText}`;
}
/**
* @const {Object} defaultAnnouncements
* @const {Function} defaultAnnouncements['droppable:dropped']
* @const {Function} defaultAnnouncements['droppable:returned']
*/
const defaultAnnouncements = {
'droppable:dropped': onDroppableDroppedDefaultAnnouncement,
'droppable:returned': onDroppableReturnedDefaultAnnouncement,
};
const defaultClasses = {
'droppable:active': 'draggable-dropzone--active',
'droppable:occupied': 'draggable-dropzone--occupied',
};
const defaultOptions = {
dropzone: '.draggable-droppable',
};
/**
* Droppable is built on top of Draggable and allows dropping draggable elements
* into dropzone element
* @class Droppable
* @module Droppable
* @extends Draggable
*/
export default class Droppable extends Draggable {
/**
* Droppable constructor.
* @constructs Droppable
* @param {HTMLElement[]|NodeList|HTMLElement} containers - Droppable containers
* @param {Object} options - Options for Droppable
*/
constructor(containers = [], options = {}) {
super(containers, {
...defaultOptions,
...options,
classes: {
...defaultClasses,
...(options.classes || {}),
},
announcements: {
...defaultAnnouncements,
...(options.announcements || {}),
},
});
/**
* All dropzone elements on drag start
* @property dropzones
* @type {HTMLElement[]}
*/
this.dropzones = null;
/**
* Last dropzone element that the source was dropped into
* @property lastDropzone
* @type {HTMLElement}
*/
this.lastDropzone = null;
/**
* Initial dropzone element that the source was drag from
* @property initialDropzone
* @type {HTMLElement}
*/
this.initialDropzone = null;
this[onDragStart] = this[onDragStart].bind(this);
this[onDragMove] = this[onDragMove].bind(this);
this[onDragStop] = this[onDragStop].bind(this);
this.on('drag:start', this[onDragStart])
.on('drag:move', this[onDragMove])
.on('drag:stop', this[onDragStop]);
}
/**
* Destroys Droppable instance.
*/
destroy() {
super.destroy();
this.off('drag:start', this[onDragStart])
.off('drag:move', this[onDragMove])
.off('drag:stop', this[onDragStop]);
}
/**
* Drag start handler
* @private
* @param {DragStartEvent} event - Drag start event
*/
[onDragStart](event) {
if (event.canceled()) {
return;
}
this.dropzones = [...this[getDropzones]()];
const dropzone = closest(event.sensorEvent.target, this.options.dropzone);
if (!dropzone) {
event.cancel();
return;
}
const droppableStartEvent = new DroppableStartEvent({
dragEvent: event,
dropzone,
});
this.trigger(droppableStartEvent);
if (droppableStartEvent.canceled()) {
event.cancel();
return;
}
this.initialDropzone = dropzone;
for (const dropzoneElement of this.dropzones) {
if (dropzoneElement.classList.contains(this.getClassNameFor('droppable:occupied'))) {
continue;
}
dropzoneElement.classList.add(...this.getClassNamesFor('droppable:active'));
}
}
/**
* Drag move handler
* @private
* @param {DragMoveEvent} event - Drag move event
*/
[onDragMove](event) {
if (event.canceled()) {
return;
}
const dropzone = this[closestDropzone](event.sensorEvent.target);
const overEmptyDropzone = dropzone && !dropzone.classList.contains(this.getClassNameFor('droppable:occupied'));
if (overEmptyDropzone && this[dropInDropzone](event, dropzone)) {
this.lastDropzone = dropzone;
} else if ((!dropzone || dropzone === this.initialDropzone) && this.lastDropzone) {
this[returnToOriginalDropzone](event);
this.lastDropzone = null;
}
}
/**
* Drag stop handler
* @private
* @param {DragStopEvent} event - Drag stop event
*/
[onDragStop](event) {
const droppableStopEvent = new DroppableStopEvent({
dragEvent: event,
dropzone: this.lastDropzone || this.initialDropzone,
});
this.trigger(droppableStopEvent);
const occupiedClasses = this.getClassNamesFor('droppable:occupied');
for (const dropzone of this.dropzones) {
dropzone.classList.remove(...this.getClassNamesFor('droppable:active'));
}
if (this.lastDropzone && this.lastDropzone !== this.initialDropzone) {
this.initialDropzone.classList.remove(...occupiedClasses);
}
this.dropzones = null;
this.lastDropzone = null;
this.initialDropzone = null;
}
/**
* Drops a draggable element into a dropzone element
* @private
* @param {DragMoveEvent} event - Drag move event
* @param {HTMLElement} dropzone - Dropzone element to drop draggable into
*/
[dropInDropzone](event, dropzone) {
const droppableDroppedEvent = new DroppableDroppedEvent({
dragEvent: event,
dropzone,
});
this.trigger(droppableDroppedEvent);
if (droppableDroppedEvent.canceled()) {
return false;
}
const occupiedClasses = this.getClassNamesFor('droppable:occupied');
if (this.lastDropzone) {
this.lastDropzone.classList.remove(...occupiedClasses);
}
dropzone.appendChild(event.source);
dropzone.classList.add(...occupiedClasses);
return true;
}
/**
* Moves the previously dropped element back into its original dropzone
* @private
* @param {DragMoveEvent} event - Drag move event
*/
[returnToOriginalDropzone](event) {
const droppableReturnedEvent = new DroppableReturnedEvent({
dragEvent: event,
dropzone: this.lastDropzone,
});
this.trigger(droppableReturnedEvent);
if (droppableReturnedEvent.canceled()) {
return;
}
this.initialDropzone.appendChild(event.source);
this.lastDropzone.classList.remove(...this.getClassNamesFor('droppable:occupied'));
}
/**
* Returns closest dropzone element for even target
* @private
* @param {HTMLElement} target - Event target
* @return {HTMLElement|null}
*/
[closestDropzone](target) {
if (!this.dropzones) {
return null;
}
return closest(target, this.dropzones);
}
/**
* Returns all current dropzone elements for this draggable instance
* @private
* @return {NodeList|HTMLElement[]|Array}
*/
[getDropzones]() {
const dropzone = this.options.dropzone;
if (typeof dropzone === 'string') {
return document.querySelectorAll(dropzone);
} else if (dropzone instanceof NodeList || dropzone instanceof Array) {
return dropzone;
} else if (typeof dropzone === 'function') {
return dropzone();
} else {
return [];
}
}
}