Hello @bigopon,
I finally wrangled this and now have a working Aurelia 2.0 wrapper around SortableJS.
I must have missed batch in the documentation. But that feature is pretty cool, I have to say! And, yes, it was necessary.
This works just fine now (I no longer have to work with the underlying items and then re-wrap the items into nodes):
onDragEnd(e) {
const oldIndex = e.detail.evt.oldIndex;
const newIndex = e.detail.evt.newIndex;
batch(() => {
const node = this.nodes.splice(oldIndex, 1)[0];
this.nodes.splice(newIndex, 0, node);
});
...
}
So, for the benefit of the community, should someone want to utilize vanilla SortableJS in Aurelia, let me share a few observations and pain points:
- The immediate child of a sortable instance should be a hard tag (
div, ul, etc.), not the sortable items themselves;
- Placing
repeat.for on a template tag appears to be a problem, not so much with SortableJS, but in terms of destabilizing the DOM downstream;
- Aurelia’s
batch feature is absolutely essential for the the two splicing operations you will perform to manifest the DOM changes in the underling items on the model or viewModel;
- The
draggable selector should represent a hard tag above any custom component you might have representing your items (in other words, not a class or an id on the custom component tag, or even the custom component tag itself);
- The
handle selector shouldn’t be the same as the draggable selector (it appears that this causes confusion);
- If you want animation, don’t forget to set
animation on the sortable instance (the SortableJS documentation will lead you to believe that it defaults to 150, but it defaults to 0 instead);
- Don’t use the default
ghostClass or dragClass; define your own.
With respect to the first two bullets above, you should have something like this (I provide my wrapper below this code snippet):
<tsi-sortable
direction="vertical"
draggable=".tsi-list-item-node-frame"
handle=".tsi-list-item-node"
ghost-class="tsi-sortable-ghost-class"
drag-class="tsi-sortable-drag-class"
force-ballback.bind="true"
fallback-on-body.bind="true"
on-end.trigger="onDragEnd($event)"
>
<div> <!-- <-- this div is necessary -->
<div repeat.for="node of nodes; key.bind: node.id" class="tsi-list-item-node-frame">
<tsi-list-item-node
class="tsi-list-item-node-wrapper"
columns.bind="columns"
selected.bind="selected"
node.bind="node"
config.bind="config"
data-id.bind="node.item.id"
></tsi-list-item-node>
</div>
</div>
</tsi-sortable>
I have thrown together an Aurelia wrapper around SortableJS. The below is a first pass at this. There are two files: tsi-sortable.js and tsi-sortable.html.
tsi-sortable.html:
<au-slot></au-slot>
tsi-sortable.js:
import { bindable, customElement, INode, resolve } from 'aurelia';
import { TsiCustomEventDispatcher } from 'common/library/TsiCustomEventDispatcher';
import { Sortable } from 'sortablejs';
@customElement('tsi-sortable')
export class TsiSortable {
@bindable animation = 150;
@bindable chosenClass = 'sortable-chosen';
@bindable dataIdAttr = 'data-id';
@bindable delay = 0;
@bindable delayOnTouchOnly = false;
@bindable direction = 'horizontal';
@bindable disabled = false;
@bindable dragClass = 'sortable-drag';
@bindable draggable = null;
@bindable dragoverBubble = false;
@bindable easing = 'ease-in-out';
@bindable emptyInsertThreshold = 5;
@bindable fallbackClass = 'sortable-fallback';
@bindable fallbackOnBody = false;
@bindable fallbackTolerance = 0;
@bindable filter = null;
@bindable forceFallback = false;
@bindable ghostClass = 'sortable-ghost';
@bindable group = null;
@bindable handle = null;
@bindable invertedSwapThreshold = 1;
@bindable invertSwap = false;
@bindable preventOnFilter = true;
@bindable removeCloneOnHide = true;
@bindable revertOnSpill = true;
@bindable snapBackOnAnimation = true;
@bindable sort = true;
@bindable store = null;
@bindable swapThreshold = 1;
@bindable touchStartThreshold = 0;
@bindable target = undefined;
@bindable includeOrderedArray = false;
constructor() {
this.$el = resolve(INode);
this.eventDispatcher = resolve(TsiCustomEventDispatcher);
this.sortable = undefined;
this._excludedOptions = ['target', 'includeOrderedArray'];
this._toArrayEvents = [
'drag-ended',
'item-unchosen',
'item-added',
'item-updated',
'sort-changed',
'item-removed',
'item-changed',
];
}
attached() {
// Query for the target if a selector is provided; otherwise, assume the first child
const $target = this.target ? this.$el.querySelectorAll(this.target)?.[0] : this.$el.children?.[0];
if (!$target) {
throw new Error('No DOM element was found either as the first child or at the selector provided');
}
this.sortable = new Sortable($target, {
animation: this.animation,
chosenClass: this.chosenClass,
dataIdAttr: this.dataIdAttr,
delay: this.delay,
delayOnTouchOnly: this.delayOnTouchOnly,
direction: this.direction,
disabled: this.disabled,
dragClass: this.dragClass,
draggable: this.draggable,
dragoverBubble: this.dragoverBubble,
easing: this.easing,
emptyInsertThreshold: this.emptyInsertThreshold,
fallbackClass: this.fallbackClass,
fallbackOnBody: this.fallbackOnBody,
fallbackTolerance: this.fallbackTolerance,
filter: this.filter,
forceFallback: this.forceFallback,
ghostClass: this.ghostClass,
group: this.group,
handle: this.handle,
invertedSwapThreshold: this.invertedSwapThreshold,
invertSwap: this.invertSwap,
preventOnFilter: this.preventOnFilter,
removeCloneOnHide: this.removeCloneOnHide,
revertOnSpill: this.revertOnSpill,
snapBackOnAnimation: this.snapBackOnAnimation,
sort: this.sort,
store: this.store,
swapThreshold: this.swapThreshold,
touchStartThreshold: this.touchStartThreshold,
setData: (dataTransfer, dragEl) => this.setData(dataTransfer, dragEl),
onChoose: (e) => this.dispatchMappedEvent('item-chosen', e),
onUnchoose: (e) => this.dispatchMappedEvent('item-unchosen', e),
onStart: (e) => this.dispatchMappedEvent('drag-started', e),
onEnd: (e) => this.dispatchMappedEvent('drag-ended', e),
onAdd: (e) => this.dispatchMappedEvent('item-added', e),
onUpdate: (e) => this.dispatchMappedEvent('sort-updated', e),
onSort: (e) => this.dispatchMappedEvent('sort-changed', e),
onRemove: (e) => this.dispatchMappedEvent('item-removed', e),
onFilter: (e) => this.dispatchMappedEvent('item-filtered', e),
onMove: (e) => this.dispatchMappedEvent('item-moved', e),
onClone: (e) => this.dispatchMappedEvent('item-cloned', e),
onChange: (e) => this.dispatchMappedEvent('item-changed', e),
});
}
detaching() {
this.sortable.destroy();
this.sortable = undefined;
}
// eslint-disable-next-line no-unused-vars
propertyChanged(option, newValue, oldValue) {
if (!this.sortable || this._excludedOptions.includes(option)) return;
this.sortable.option(option, newValue);
}
setData(dataTransfer, dragEl) {
this.eventDispatcher.dispatch(this.$el, 'setData', { dataTransfer, dragEl });
}
dispatchMappedEvent(eventName, e) {
let options = {};
if (this.includeOrderedArray && this._toArrayEvents.includes(eventName)) {
options = {
domOrderedArray: this.sortable.toArray(),
};
}
e.stopPropagation();
this.eventDispatcher.dispatch(this.$el, eventName, { evt: e, ...options });
}
}
Animated GIF of sorting working on my own custom list component:
