@rbuljan/jsort
Version:
A JavaScript library for sorting and reordering grid or list items.
741 lines (740 loc) • 28.5 kB
JavaScript
/**
* Small yet powerful drag and drop sortable library with touch support, smooth animations, for a great UX.
* @class JSort
* @author Roko C. Buljan <https://github.com/rokobuljan>
* @license MIT
* @see {@link https://github.com/rokobuljan/jsort}
*/
class JSort {
/** @type {string} */
static version = "1.10.3";
/** @type {string} selectorParent + selectorItems*/
selectorItemsFull = "";
/** @type {HTMLElement | null} */
elGhost;
/** @type {HTMLElement | null} */
elGrab;
/** @type {HTMLElement | null} */
elTarget;
/** @type {HTMLElement | null} */
elDrop;
/** @type {HTMLElement | null} */
elDropParent;
/** @type {number} */
indexGrab = -1;
/** @type {number} */
indexDrop = -1;
/** @type {HTMLElement[]} */
affectedElements = [];
/** @type {HTMLElement | null} */
scrollParent = null;
/** @type {string | null} */
scrollDirection = null;
/** @type {null | { start: Function, stop: Function, tick: Function }} */
scrollAnim = null;
/** @type {number} */
edgePressure = 0;
/** @type {number | undefined} */
moveTimeout;
/** @type {boolean} */
isScrollPrevented = false;
/** @type {boolean} */
hasPointerMoved = false;
/** @type {boolean} */
hasTouchMoved = false;
/** @type {boolean} - If we should remove jsort class from parent on destroy */
destroyParentClass = false;
/** @type {null | { clientX: number, clientY: number }} */
pointerGrab = null;
/** @type {null | { clientX: number, clientY: number }} */
touchStart = null;
/**
* @constructor
* @param {HTMLElement} el
* @param {Object} [options]
* @param {string} [options.group=""] Link-group parents i.e: "group-a"
* @param {boolean} [options.swap=false] Swap items mode (Swap elements on drop)
* @param {number} [options.duration=420] Ms items animation duration
* @param {string} [options.easing="cubic-bezier(0.6, 0, 0.6, 1)"] Items animation easing
* @param {number} [options.scale=1.1] Ghost element scale
* @param {number} [options.opacity=0.8] Ghost element opacity
* @param {number} [options.grabTimeout=140] Ms before grab is considered instead of scroll on touch devices (has no effect for mouse Event)
* @param {boolean} [options.parentDrop=true] Can drop item onto parent
* @param {number} [options.moveThreshold=0] Px before it's considered a pointer drag (Allows to click inner links, buttons, inputs, etc)
* @param {number} [options.scrollThreshold=8] Px before it's considered a scroll
* @param {number} [options.edgeThreshold=50] Pixels from edge to start parent auto-scrolling
* @param {number} [options.scrollSpeed=10] Scroll pixels per frame while ghost is near parent edge
* @param {number} [options.zIndex=2147483647] Maximum 32-bit signed integer
* @param {string} [options.selectorParent=".jsort"] Selector for parent elements
* @param {string} [options.selectorItems="*"] Selector for parent's immediate sortable children
* @param {string} [options.selectorItemsIgnore=".jsort-ignore"] Selector for ignored immediate children
* @param {string} [options.selectorHandler=".jsort.handler"] Selector for handler elements
* @param {string} [options.selectorIgnoreTarget=""] Selector for ignoring specific item's descendant elements (closest to pointer target)
* @param {string} [options.selectorIgnoreFields=""] Selector for ignoring specific item's descendant action fields elements like buttons, inputs, textarea, etc.
* @param {string} [options.classGhost="is-jsort-ghost"] Class name for ghost element
* @param {string} [options.classActive="is-jsort-active"] Class name for item on mousedown, pointerdown
* @param {string} [options.classTouch="is-jsort-touch"] Class name for item on touchstart event only
* @param {string} [options.classGrab="is-jsort-grab"] Class name for grabbed item
* @param {string} [options.classTarget="is-jsort-target"] Class name for hovered element (either item or Sort parent)
* @param {string} [options.classAnimated="is-jsort-animated"] Class name for all animated elements (on drop)
* @param {string} [options.classAnimatedDrop="is-jsort-animated-drop"] Class name for the animated grabbed element (on drop)
* @param {string} [options.classInvalid="is-jsort-invalid"] Class name for invalid item
* @param {function} [options.onBeforeGrab=(data) => {}] Callback function called before grab (return false to cancel grab)
* @param {function} [options.onGrab=(data) => {}] Callback function called after grab
* @param {function} [options.onMove=(data) => {}] Callback function called on move
* @param {function} [options.onBeforeDrop=(data) => {}] Callback function called before drop (return false to cancel drop)
* @param {function} [options.onDrop=(data) => {}] Callback function called after drop
* @param {function} [options.onAnimationEnd=() => {}] Callback function called when animation ends
*/
constructor(el, options = {}) {
this.elGrabParent = el;
this.group = "";
this.swap = false;
this.duration = 420;
this.easing = "cubic-bezier(0.6, 0, 0.6, 1)";
this.scale = 1.1;
this.opacity = 0.8;
this.grabTimeout = 140;
this.parentDrop = true;
this.moveThreshold = 0;
this.scrollThreshold = 8;
this.edgeThreshold = 50;
this.scrollSpeed = 10;
this.zIndex = 2147483647;
this.selectorParent = ".jsort";
this.selectorItems = "*";
this.selectorItemsIgnore = ".jsort-ignore";
this.selectorHandler = ".jsort-handler";
this.selectorIgnoreTarget = "";
this.selectorIgnoreFields = `:is(input, select, textarea, button, label, [contenteditable=""], [contenteditable="true"], [tabindex]:not([tabindex^="-"]), a[href]:not(a[href=""]), area[href]):not(:disabled)`;
this.classGhost = "is-jsort-ghost";
this.classActive = "is-jsort-active";
this.classTouch = "is-jsort-touch";
this.classGrab = "is-jsort-grab";
this.classTarget = "is-jsort-target";
this.classAnimated = "is-jsort-animated";
this.classAnimatedDrop = "is-jsort-animated-drop";
this.classInvalid = "is-jsort-invalid";
this.onBeforeGrab = () => {
};
this.onGrab = () => {
};
this.onMove = () => {
};
this.onBeforeDrop = () => {
};
this.onDrop = () => {
};
this.onAnimationEnd = () => {
};
this.init(options);
}
/**
* Get Array of all child elements (ignores .is-jsort-ghost element, if present)
* @param {HTMLElement} elParent
* @returns {HTMLElement[]}
*/
getChildren(elParent) {
if (!elParent) return [];
const children = (
/** @type {HTMLElement[]} */
[...elParent.children]
);
const childrenNoGhost = children.filter((el) => !el.matches(`.${this.classGhost}`));
return childrenNoGhost;
}
/**
* Parse `data-jsort` attribute
* @param {HTMLElement} el
* @returns {Object}
*/
parseDataAttribute(el) {
return el?.dataset.jsort?.replace(/\s/g, "").replace(/;$/, "").split(/;/).reduce((acc, str) => {
const [prop, val] = str.split(":");
acc[prop] = !isNaN(Number(val)) ? Number(val) : /^(true|false)$/.test(val) ? JSON.parse(val) : val;
return acc;
}, {}) ?? {};
}
/**
* Check if touch or pointer move distance is significant
* @param {Object} startXY {clientX, clientY} start
* @param {Object} currentXY {clientX, clientY} current
* @param {number} distance Distance in px
* @returns {boolean}
*/
isSignificantMove(startXY, currentXY, distance) {
const { clientX, clientY } = currentXY;
const deltaX = clientX - startXY.clientX;
const deltaY = clientY - startXY.clientY;
const touchMoveDistance = Math.hypot(deltaX, deltaY);
const isSignificant = touchMoveDistance >= distance;
return isSignificant;
}
/**
* Append ghost element to grab's parent
* @returns {void}
*/
insertGhost() {
if (!this.elGrab) return;
const { x, y, width, height } = this.elGrab.getBoundingClientRect();
this.elGhost = /** @type {HTMLElement} */
this.elGrab.cloneNode(true);
Object.assign(this.elGhost.style, {
position: "fixed",
left: `${x}px`,
top: `${y}px`,
width: `${width}px`,
height: `${height}px`,
pointerEvents: "none",
zIndex: this.zIndex,
opacity: this.opacity
});
this.elGhost.classList.remove(this.classActive, this.classTarget);
this.elGhost.classList.add(this.classGhost);
this.elGhost.animate([
{ scale: this.scale }
], {
duration: 250,
easing: this.easing,
fill: "forwards"
});
this.elGrabParent.append(this.elGhost);
}
/**
* Remove ghost element
* @returns {void}
*/
removeGhost() {
if (!this.elGhost) return;
this.elGhost.remove();
this.elGhost = null;
}
/**
* Animate item to new position
* @param {Object} data
* @param {Element} data.el - the Element to animate
* @param {number} data.x - the new X position
* @param {number} data.y - the new Y position
* @returns {Animation|undefined}
*/
animateItem({ el, x, y }) {
const { left, top } = el.getBoundingClientRect();
if (x === left && y === top) return;
el.classList.add(this.classAnimated);
const keyframes = el === this.elGrab ? [
{ position: "relative", zIndex: 1, translate: `${x - left}px ${y - top}px`, opacity: 0.9, scale: this.scale },
{ position: "relative", zIndex: 1, translate: "0", opacity: 1, scale: 1 }
] : [
{ position: "relative", zIndex: 0, scale: 1, translate: `${x - left}px ${y - top}px` },
{ position: "relative", zIndex: 0, scale: 2 - this.scale },
{ position: "relative", zIndex: 0, scale: 1, translate: "0" }
];
const anim = el.animate(keyframes, {
duration: this.duration,
easing: this.easing,
fill: "forwards"
});
anim.addEventListener("finish", () => {
el.classList.remove(this.classAnimated);
anim.cancel();
});
return anim;
}
/**
* Find closest element (similar to Element.closest() but without selector string)
* If not found, returns null, meaning el was not a descendant of elTarget, or elTarget itself
* @param {Element|null} el
* @param {Element|null} elTarget
* @returns {Element|null}
*/
closestElement(el, elTarget) {
while (el && el !== elTarget) el = el.parentElement;
return el === elTarget ? el : null;
}
/**
* Check if drop is valid by the given coordinates
* @param {Object} data
* @param {number} [data.clientX]
* @param {number} [data.clientY]
* @param {HTMLElement} [data.el]
* @returns {boolean} true if can be dropped at coordinates
*/
checkValidity({ clientX = 0, clientY = 0, el }) {
const elFromPoint = el ?? document.elementFromPoint(clientX, clientY);
const elTarget = elFromPoint?.closest(`${this.selectorItemsFull}, ${this.selectorParent}`);
const isIgnored = elFromPoint?.closest(`${this.selectorItemsIgnore}`);
if (isIgnored) return false;
const elDropParent = (
/** @type {HTMLElement} */
elFromPoint?.closest(this.selectorParent)
);
const isParentDrop = elTarget === elDropParent;
const isOntoSelf = elTarget && this.closestElement(elTarget, this.elGrab) === this.elGrab;
const isSameParent = elDropParent === this.elGrabParent;
const groupDrop = elDropParent?.dataset.jsortGroup;
const isValidGroup = !isSameParent && Boolean(groupDrop && this.group === groupDrop);
if (!this.parentDrop && isParentDrop) return false;
const isValid = !isOntoSelf && elTarget && elDropParent && isValidGroup || isSameParent;
return isValid;
}
/**
* Create reusable animation engine
* @param {Function} cb callback function
* @param {number} fps desired FPS target (60 frames per second by default)
* @returns {Object} { start, stop, tick } methods
*/
engine(cb, fps = 60) {
const msPerFrame = 1e3 / fps;
let msPrev = 0;
let id = null;
const tick = () => {
id = requestAnimationFrame(tick);
const msNow = window.performance.now();
const msPassed = msNow - msPrev;
if (msPassed < msPerFrame) return;
const excessTime = msPassed % msPerFrame;
msPrev = msNow - excessTime;
cb();
};
const start = () => {
stop();
msPrev = performance.now();
id = requestAnimationFrame(tick);
};
const stop = () => {
cancelAnimationFrame(id);
id = null;
msPrev = 0;
};
return { start, stop, tick };
}
/**
* Find closest scrollable element
* @param {Element|null|undefined} el Start element
* @returns {Element} Scrollable element
*/
findScrollParent(el) {
while (el && el !== document.documentElement) {
const style = getComputedStyle(el);
if ((el.scrollHeight > el.clientHeight || el.scrollWidth > el.clientWidth) && /^(auto|scroll)$/.test(style.overflowY)) {
return el;
}
el = el.parentElement;
}
return document.documentElement;
}
/**
* Scroll parent element given a this.scrollDirection
* @returns {void}
*/
scrollStep() {
if (!this.scrollDirection || !this.scrollParent) return;
const scrollSpeed = this.scrollSpeed * (this.edgePressure / Math.max(this.edgeThreshold, 1));
if (this.scrollDirection === "up") {
this.scrollParent.scrollTop -= scrollSpeed;
} else if (this.scrollDirection === "down") {
this.scrollParent.scrollTop += scrollSpeed;
} else if (this.scrollDirection === "left") {
this.scrollParent.scrollLeft -= scrollSpeed;
} else if (this.scrollDirection === "right") {
this.scrollParent.scrollLeft += scrollSpeed;
}
}
/**
* Start edge scroll engine
* @param {string} direction
*/
startEdgeScroll(direction) {
if (this.scrollDirection !== direction) {
this.scrollDirection = direction;
if (!this.scrollAnim) {
this.scrollAnim = this.engine(this.scrollStep.bind(this));
this.scrollAnim?.start();
}
}
}
/**
* Stop edge scroll engine
*/
stopEdgeScroll() {
this.scrollDirection = null;
if (this.scrollAnim) {
this.scrollAnim.stop();
this.scrollAnim = null;
}
}
/**
* Handle parent scroll depending on the position od the dragged item
* in regards to the closest scrollable parent's edges
* @param {PointerEvent} ev
*/
handleScrollParent(ev) {
if (!this.scrollParent) {
this.scrollParent = /** @type {HTMLElement} */
this.findScrollParent(this.elGrab);
}
const rect = this.scrollParent.getBoundingClientRect();
const doc = document.documentElement;
const isDOC = this.scrollParent === doc;
const topEdge = isDOC ? 0 : rect.top;
const bottomEdge = isDOC ? window.innerHeight : rect.bottom;
const leftEdge = isDOC ? 0 : rect.left;
const rightEdge = isDOC ? window.innerWidth : rect.left;
if (ev.clientY < topEdge + this.edgeThreshold) {
this.edgePressure = Math.min(this.edgeThreshold, this.edgeThreshold - (ev.clientY - topEdge));
this.startEdgeScroll("up");
} else if (ev.clientY > bottomEdge - this.edgeThreshold) {
this.edgePressure = Math.min(this.edgeThreshold, this.edgeThreshold - (bottomEdge - ev.clientY));
this.startEdgeScroll("down");
} else if (ev.clientX < leftEdge + this.edgeThreshold) {
this.edgePressure = Math.min(this.edgeThreshold, this.edgeThreshold - (ev.clientX - leftEdge));
this.startEdgeScroll("left");
} else if (ev.clientX > rightEdge - this.edgeThreshold) {
this.edgePressure = Math.min(this.edgeThreshold, this.edgeThreshold - (rightEdge - ev.clientX));
this.startEdgeScroll("right");
} else {
this.stopEdgeScroll();
}
}
/**
* Insert elItem into this JSort parent
* @param {HTMLElement} elGrab
* @param {HTMLElement} elTarget
* @returns {boolean} True if insertion was successful
*/
insert(elGrab, elTarget) {
const elGrabParent = (
/** @type {HTMLElement} */
elGrab.closest(this.selectorParent)
);
const grabChildren = this.getChildren(elGrabParent);
this.indexGrab = grabChildren.indexOf(elGrab);
const grabSiblings = grabChildren.filter((el) => el !== elGrab);
this.elDrop = /** @type {HTMLElement} */
elTarget?.closest(`${this.selectorItemsFull}, ${this.selectorParent}`);
const isDroppedOntoParent = this.elDrop?.matches(this.selectorParent);
this.elDropParent = /** @type {HTMLElement} */
this.elDrop?.closest(this.selectorParent);
const dropChildren = this.getChildren(this.elDropParent);
const isSameParent = elGrabParent === this.elDropParent;
this.indexDrop = isDroppedOntoParent ? Math.max(0, isSameParent ? grabSiblings.length : dropChildren.length) : dropChildren.indexOf(this.elDrop);
this.elGhost?.animate([{ scale: 1 }], { duration: 0, fill: "forwards" });
const ghostRect = this.elGhost?.getBoundingClientRect();
this.affectedElements = [];
if (this.swap) {
this.affectedElements = this.elDrop ? [this.elDrop] : [];
} else if (isSameParent) {
const indexMin = isDroppedOntoParent ? this.indexGrab : Math.min(this.indexDrop, this.indexGrab);
const indexMax = isDroppedOntoParent ? grabSiblings.length : Math.max(this.indexDrop, this.indexGrab);
this.affectedElements = /** @type {HTMLElement[]} */
grabSiblings.slice(indexMin, indexMax);
} else {
this.affectedElements = /** @type {HTMLElement[]} */
[...grabSiblings.slice(this.indexGrab), ...dropChildren.slice(this.indexDrop)];
}
const isValidTarget = this.checkValidity({ el: elTarget });
const isValidByUser = this.onBeforeDrop?.call(this, {
elGrab: this.elGrab,
elGrabParent,
elGhost: this.elGhost,
elDrop: this.elDrop,
elDropParent: this.elDropParent,
indexGrab: this.indexGrab,
indexDrop: this.indexDrop,
isValidTarget,
isSameParent,
event: this._currentEvent
}) ?? true;
const isNotActuallyMoved = isSameParent && this.indexGrab === this.indexDrop;
const isValid = Boolean(this.elDrop) && isValidTarget && isValidByUser && !isNotActuallyMoved;
if (isValid) {
const affectedElementsData = this.affectedElements.map((el) => {
const { x, y } = el.getBoundingClientRect();
return { el, x, y };
});
if (this.swap && !isDroppedOntoParent) {
const elNext = elGrab.nextSibling;
this.elDropParent?.insertBefore(elGrab, this.elDrop.nextSibling);
elGrabParent?.insertBefore(this.elDrop, elNext);
} else {
if (isDroppedOntoParent) {
this.elDropParent?.append(elGrab);
} else if (isSameParent) {
this.elDropParent?.insertBefore(elGrab, this.indexDrop < this.indexGrab ? this.elDrop : this.elDrop.nextSibling);
} else {
this.elDropParent?.insertBefore(elGrab, this.elDrop);
}
}
affectedElementsData.forEach((data) => {
if (data.el === elGrab) return;
this.animateItem(data);
});
}
if (ghostRect) {
elGrab.classList.add(`${this.classAnimatedDrop}`);
const anim = this.animateItem({ el: elGrab, x: ghostRect.left, y: ghostRect.top });
if (anim) {
anim.addEventListener("finish", () => {
elGrab.classList.remove(`${this.classAnimatedDrop}`);
this.onAnimationEnd?.call(this);
});
} else {
elGrab.classList.remove(`${this.classAnimatedDrop}`);
}
}
this.removeGhost();
return isValid;
}
/**
* Grab an item
* @param {PointerEvent} ev
*/
grab = (ev) => {
if (this.elGrab) return;
const evTarget = (
/** @type {Element} */
ev.target
);
const elClosestItem = (
/** @type {HTMLElement} */
evTarget.closest(`${this.selectorItemsFull}`)
);
const isElIgnored = Boolean(
evTarget !== elClosestItem && (this.selectorIgnoreTarget && evTarget.closest(this.selectorIgnoreTarget) || this.selectorIgnoreFields && evTarget.closest(this.selectorIgnoreFields))
);
if (
// Is in ignore list
isElIgnored || // Not an item
!elClosestItem || // Does not belongs to this sortable
elClosestItem.parentElement !== this.elGrabParent
) {
return;
}
const foundHandler = (
/** @type {Element} */
elClosestItem.querySelector(this.selectorHandler)
);
const isHandlerVisible = foundHandler?.["checkVisibility"]?.();
const hasHandler = Boolean(foundHandler);
const elHandler = evTarget?.closest(this.selectorHandler);
if (hasHandler && isHandlerVisible && !elHandler) return;
const { clientX, clientY } = ev;
this.pointerGrab = { clientX, clientY };
this.elGrab = elClosestItem;
const grabChildren = this.getChildren(this.elGrabParent);
this.indexGrab = grabChildren.indexOf(this.elGrab);
const isUserValidated = this.onBeforeGrab?.call(this, {
elGrab: this.elGrab,
elGrabParent: this.elGrabParent,
indexGrab: this.indexGrab,
event: ev
}) ?? true;
if (isUserValidated) {
ev.preventDefault();
this.elGrab.classList.add(this.classActive);
this.elGrab.style.cursor = "move";
this.elGrab.style.userSelect = "none";
if (ev.pointerType === "mouse") {
this.isScrollPrevented = true;
}
this.onGrab?.call(this, {
elGrab: this.elGrab,
elGrabParent: this.elGrabParent,
indexGrab: this.indexGrab,
event: ev
});
} else {
this.reset();
}
};
/**
* Move an item
* @param {PointerEvent} ev
*/
move = (ev) => {
if (!this.elGrab || !this.isScrollPrevented || this.hasPointerMoved && !this.elGrab?.hasPointerCapture(ev.pointerId)) return;
const isSignificantMove = this.isSignificantMove(this.pointerGrab, ev, this.moveThreshold);
if (!this.hasPointerMoved && isSignificantMove) {
this.hasPointerMoved = true;
this.elGrab.setPointerCapture(ev.pointerId);
this.elGrab.classList.add(this.classGrab);
this.insertGhost();
}
const { clientX, clientY } = ev;
const isValidTarget = this.checkValidity({ clientX, clientY });
if (this.elGhost && this.pointerGrab) {
this.elGhost.style.translate = `${clientX - this.pointerGrab.clientX}px ${clientY - this.pointerGrab.clientY}px`;
this.elGhost.classList.toggle(this.classInvalid, !isValidTarget);
}
this.elGrab.style.cursor = isValidTarget ? "grab" : "not-allowed";
const elFromPoint = document.elementFromPoint(clientX, clientY);
const elTarget = (
/** @type {HTMLElement} */
elFromPoint?.closest(`${this.selectorItemsFull}, ${this.selectorParent}`)
);
if (elTarget !== this.elTarget) {
this.elTarget?.classList.remove(this.classTarget);
if (isValidTarget) {
this.elTarget = elTarget;
this.elTarget?.classList.add(this.classTarget);
}
}
this.handleScrollParent(ev);
this.onMove?.call(this, {
elGrab: this.elGrab,
elGrabParent: this.elGrabParent,
elGhost: this.elGhost,
elTarget: this.elTarget,
isValidTarget,
event: ev
});
};
/**
* Called when an item is dropped on pointerUp
* @param {PointerEvent} ev
*/
drop = (ev) => {
if (!this.elGrab) return;
this.stopEdgeScroll();
this.isScrollPrevented = false;
this.elGrab.style.removeProperty("user-select");
this.elGrab.style.removeProperty("cursor");
this.elGrab.classList.remove(this.classActive, this.classGrab, this.classTouch);
this.elTarget?.classList.remove(this.classTarget);
const { clientX, clientY } = ev;
const elFromPoint = (
/** @type {HTMLElement} */
document.elementFromPoint(clientX, clientY)
);
this._currentEvent = ev;
const isInserted = this.insert(this.elGrab, elFromPoint);
if (isInserted) this.onDrop?.call(this, {
elGrab: this.elGrab,
elGrabParent: this.elGrabParent,
elDrop: this.elDrop,
elDropParent: this.elDropParent,
indexGrab: this.indexGrab,
indexDrop: this.indexDrop,
event: ev
});
this.reset();
this.removeGhost();
};
/**
* Handle touch start event - used for scroll-intent
* @param {TouchEvent} ev
*/
handleTouchStart = (ev) => {
if (!this.elGrab || this.touchStart) return;
const { clientX, clientY } = ev.touches[0];
this.touchStart = { clientX, clientY };
this.moveTimeout && clearTimeout(this.moveTimeout);
this.moveTimeout = setTimeout(() => {
if (!this.hasTouchMoved) {
this.elGrab?.classList.add(`${this.classTouch}`);
this.isScrollPrevented = true;
}
}, this.grabTimeout);
};
/**
* Handle touch move event - used for scroll-intent
* @param {TouchEvent} ev
*/
handleTouchMove = (ev) => {
if (!this.elGrab || !this.touchStart) return;
if (this.isScrollPrevented) {
if (ev.cancelable) ev.preventDefault();
return;
}
const isSignificantMove = this.isSignificantMove(this.touchStart, ev.touches[0], this.scrollThreshold);
if (!this.hasTouchMoved && isSignificantMove) {
this.hasTouchMoved = true;
clearTimeout(this.moveTimeout);
this.moveTimeout = void 0;
}
};
/**
* Just like Array.sort() but animated
* @param {(a: HTMLElement, b: HTMLElement) => number} fn
* @returns {HTMLElement[]} Sorted items
*/
sort(fn) {
const items = this.getChildren(this.elGrabParent);
const affectedElementsData = items.map((el) => {
const { x, y } = el.getBoundingClientRect();
return { el, x, y };
});
const itemsSorted = [...items].sort(fn);
this.elGrabParent.append(...itemsSorted);
affectedElementsData.forEach((data) => this.animateItem(data));
return itemsSorted;
}
/**
* Reset, cleanup internal-use properties
* @returns {void}
*/
reset() {
this.elGhost = null;
this.elGrab = null;
this.elTarget = null;
this.elDrop = null;
this.elDropParent = null;
this.indexGrab = -1;
this.indexDrop = -1;
this.affectedElements = [];
this.scrollParent = null;
this.scrollDirection = null;
this.scrollAnim = null;
this.edgePressure = 0;
this.moveTimeout = void 0;
this.isScrollPrevented = false;
this.pointerGrab = null;
this.touchStart = null;
this.hasPointerMoved = false;
this.hasTouchMoved = false;
}
/**
* Initialize: pass user options or data attr to constructor, and attach events
* @param {Object} options
*/
init(options) {
this.destroy();
const data = this.parseDataAttribute(this.elGrabParent);
Object.assign(this, options, data);
this.reset();
if (this.selectorParent[0]?.trim() === "." && !this.elGrabParent.matches(this.selectorParent)) {
this.elGrabParent.classList.add(`${this.selectorParent.replace(/^\./, "")}`);
this.destroyParentClass = true;
}
this.selectorItems = (this.selectorItems ?? "*").replace(/^(?! *>)/, "> $&");
this.selectorItemsFull = `${this.selectorParent}${this.selectorItems}${this.selectorItemsIgnore ? `:not(${this.selectorItemsIgnore})` : ""}`;
if (this.elGrabParent) {
this.elGrabParent.addEventListener("touchstart", this.handleTouchStart);
this.elGrabParent.addEventListener("touchmove", this.handleTouchMove);
this.elGrabParent.addEventListener("pointerdown", this.grab);
this.elGrabParent.addEventListener("pointermove", this.move);
this.elGrabParent.addEventListener("pointerup", this.drop);
this.elGrabParent.addEventListener("pointercancel", this.drop);
if (this.group !== "") this.elGrabParent.dataset.jsortGroup = this.group;
}
}
/**
* Destroy: remove event listeners
*/
destroy() {
this.removeGhost();
if (this.elGrabParent) {
this.elGrabParent.removeEventListener("touchstart", this.handleTouchStart);
this.elGrabParent.removeEventListener("touchmove", this.handleTouchMove);
this.elGrabParent.removeEventListener("pointerdown", this.grab);
this.elGrabParent.removeEventListener("pointermove", this.move);
this.elGrabParent.removeEventListener("pointerup", this.drop);
this.elGrabParent.removeEventListener("pointercancel", this.drop);
if (this.group !== "") delete this.elGrabParent.dataset.jsortGroup;
if (this.destroyParentClass) this.elGrabParent.classList.remove(`${this.selectorParent.replace(/^\./, "")}`);
}
}
}
export {
JSort as default
};