@shopware-ag/meteor-component-library
Version:
The meteor component library is a Vue component library developed by Shopware. It is based on the [Meteor Design System](https://shopware.design/).
617 lines (520 loc) • 17.8 kB
text/typescript
import type { Directive } from "vue";
export interface DragConfig<DATA = unknown> {
delay: number;
dragGroup: number | string;
draggableCls: string;
draggingStateCls: string;
dragElementCls: string;
validDragCls: string;
invalidDragCls: string;
preventEvent: boolean;
validateDrop:
| null
| ((
dragConfigData: DragConfig<DATA>["data"],
dropConfigData: DropConfig<DATA>["data"],
) => boolean);
validateDrag:
| null
| ((
dragConfigData: DragConfig<DATA>["data"],
dropConfigData: DropConfig<DATA>["data"],
) => boolean);
validateDragStart:
| null
| ((
dragConfigData: DragConfig<DATA>["data"],
el: HTMLElement,
event: MouseEvent | TouchEvent,
) => boolean);
onDragStart:
| null
| ((dragConfig: DragConfig<DATA>, el: HTMLElement, dragElement: HTMLElement) => void);
onDragEnter:
| null
| ((
dragConfigData: DragConfig<DATA>["data"],
dropConfigData: DropConfig<DATA>["data"],
valid: boolean,
) => void);
onDragLeave:
| null
| ((
dragConfigData: DragConfig<DATA>["data"],
dropConfigData: DropConfig<DATA>["data"],
) => void);
onDrop:
| null
| ((
dragConfigData: DragConfig<DATA>["data"],
dropConfigData: DropConfig<DATA>["data"],
) => void);
data: null | DATA;
disabled: boolean;
}
export interface DropConfig<DATA = unknown> {
dragGroup: number | string;
droppableCls: string;
validDropCls: string;
invalidDropCls: string;
validateDrop:
| null
| ((
dragConfigData: DragConfig<DATA>["data"],
dropConfigData: DropConfig<DATA>["data"],
) => boolean);
onDrop:
| null
| ((
dragConfigData: DragConfig<DATA>["data"],
dropConfigData: DropConfig<DATA>["data"],
) => void);
data: null | DATA;
}
export interface DropZone {
el: HTMLElement;
dropConfig: DropConfig;
}
/**
* @description An object representing the current drag element and config.
*/
let currentDrag: { el: HTMLElement; dragConfig: DragConfig } | null = null;
/**
* @description An object representing the current drop zone element and config.
*/
let currentDrop: { el: HTMLElement; dropConfig: DropConfig } | null = null;
/**
* @description The proxy element which is used to display the moved element.
*/
let dragElement: HTMLElement | null = null;
/**
* @description The x offset of the mouse position inside the dragged element.
*/
let dragMouseOffsetX = 0;
/**
* @description The y offset of the mouse position inside the dragged element.
*/
let dragMouseOffsetY = 0;
/**
* @description The timeout managing the delayed drag start.
*/
let delayTimeout: number | null = null;
/**
* @description A registry of all drop zones.
*/
const dropZones: DropZone[] = [];
/**
* The default config for the draggable directive.
*/
const defaultDragConfig: DragConfig = {
delay: 100,
dragGroup: 1,
draggableCls: "is--draggable",
draggingStateCls: "is--dragging",
dragElementCls: "is--drag-element",
validDragCls: "is--valid-drag",
invalidDragCls: "is--invalid-drag",
preventEvent: true,
validateDrop: null,
validateDrag: null,
validateDragStart: null,
onDragStart: null,
onDragEnter: null,
onDragLeave: null,
onDrop: null,
data: null,
disabled: false,
};
/**
* The default config for the droppable directive.
*/
const defaultDropConfig: DropConfig = {
dragGroup: 1,
droppableCls: "is--droppable",
validDropCls: "is--valid-drop",
invalidDropCls: "is--invalid-drop",
validateDrop: null,
onDrop: null,
data: null,
};
/**
* Fired by event callback when the user starts dragging an element.
*/
function onDrag(el: HTMLElement, dragConfig: DragConfig, event: MouseEvent | TouchEvent): boolean {
if (event instanceof MouseEvent && event.buttons !== 1) {
return false;
}
if (dragConfig.preventEvent) {
event.preventDefault();
event.stopPropagation();
}
if (dragConfig.delay === null || dragConfig.delay <= 0) {
startDrag(el, dragConfig, event);
} else {
// TODO: check if {} instead of "this" is working
delayTimeout = window.setTimeout(startDrag.bind({}, el, dragConfig, event), dragConfig.delay);
}
document.addEventListener("mouseup", stopDrag);
document.addEventListener("touchend", stopDrag);
return true;
}
/**
* Initializes the drag state for the current drag action.
*/
function startDrag(el: HTMLElement, dragConfig: DragConfig, event: MouseEvent | TouchEvent) {
// check if starting drag is valid
if (
dragConfig.validateDragStart !== null &&
!dragConfig.validateDragStart(dragConfig.data, el, event)
) {
return;
}
delayTimeout = null;
if (currentDrag !== null) {
return;
}
currentDrag = { el, dragConfig };
const elBoundingBox = el.getBoundingClientRect();
const pageX = ((event instanceof MouseEvent && event.pageX) ||
(event instanceof TouchEvent && event.touches[0].pageX)) as number;
const pageY = ((event instanceof MouseEvent && event.pageY) ||
(event instanceof TouchEvent && event.touches[0].pageY)) as number;
dragMouseOffsetX = pageX - elBoundingBox.left;
dragMouseOffsetY = pageY - elBoundingBox.top;
dragElement = el.cloneNode(true) as HTMLElement;
dragElement.classList.add(dragConfig.dragElementCls);
dragElement.style.width = `${elBoundingBox.width}px`;
dragElement.style.translate = `${pageX - dragMouseOffsetX}px ${pageY - dragMouseOffsetY}px`;
dragElement.style.left = "0";
dragElement.style.top = "0";
document.body.appendChild(dragElement);
el.classList.add(dragConfig.draggingStateCls);
if (typeof currentDrag.dragConfig.onDragStart === "function") {
currentDrag.dragConfig.onDragStart(currentDrag.dragConfig, el, dragElement);
}
document.addEventListener("mousemove", moveDrag);
document.addEventListener("touchmove", moveDrag);
}
let rotationTimeout = 0;
/**
* Calculate the rotation based on movement direction.
*/
function calculateRotation(oldX: number, newX: number): string {
if (oldX && Math.abs(newX - oldX) > 2) {
const moveRight = newX - oldX > 0;
return `${moveRight ? 5 : -5}deg`;
}
return "";
}
/**
* Fired by event callback when the user moves the dragged element.
*/
function moveDrag(event: MouseEvent | TouchEvent) {
if (currentDrag === null) {
stopDrag();
return;
}
const pageX = ((event instanceof MouseEvent && event.pageX) ||
(event instanceof TouchEvent && event.touches[0].pageX)) as number;
const pageY = ((event instanceof MouseEvent && event.pageY) ||
(event instanceof TouchEvent && event.touches[0].pageY)) as number;
if (dragElement) {
// get the old value from dataset
const oldX = Number(dragElement.dataset.translateX);
const newX = pageX - dragMouseOffsetX;
const newY = pageY - dragMouseOffsetY;
// calculate rotation
dragElement.style.rotate = calculateRotation(oldX, newX);
// use timeout for resetting the rotation after movement stops
clearTimeout(rotationTimeout);
rotationTimeout = window.setTimeout(() => {
if (dragElement) dragElement.style.rotate = "0deg";
}, 100);
// set translate value
dragElement.style.translate = `${newX}px ${newY}px`;
// save new x value to dataset
dragElement.dataset.translateX = newX.toString();
}
if (event.type === "touchmove") {
const foundZone = dropZones.find((zone) => isEventOverElement(event, zone.el));
clearAllDropZoneHighlights();
if (foundZone) {
enterDropZone(foundZone.el, foundZone.dropConfig);
} else if (currentDrop) {
leaveDropZone(currentDrop.el, currentDrop.dropConfig);
}
}
}
/**
* Helper method for detecting if the current event position
* is in the boundaries of an existing drop zone element.
*/
function isEventOverElement(event: MouseEvent | TouchEvent, el: HTMLElement): boolean {
const pageX = ((event instanceof MouseEvent && event.pageX) ||
(event instanceof TouchEvent && event.touches[0].pageX)) as number;
const pageY = ((event instanceof MouseEvent && event.pageY) ||
(event instanceof TouchEvent && event.touches[0].pageY)) as number;
const box = el.getBoundingClientRect();
return (
pageX >= box.x && pageX <= box.x + box.width && pageY >= box.y && pageY <= box.y + box.height
);
}
/**
* Stops all drag interaction and resets all variables and listeners.
*/
function stopDrag() {
if (delayTimeout !== null) {
window.clearTimeout(delayTimeout);
delayTimeout = null;
return;
}
const validDrag = validateDrag();
const validDrop = validateDrop();
if (validDrag && currentDrag) {
if (typeof currentDrag.dragConfig.onDrop === "function") {
currentDrag.dragConfig.onDrop(
currentDrag.dragConfig.data,
validDrop ? currentDrop?.dropConfig?.data : null,
);
}
}
if (validDrop && currentDrop) {
if (typeof currentDrop.dropConfig.onDrop === "function") {
currentDrop.dropConfig.onDrop(currentDrag?.dragConfig.data, currentDrop.dropConfig.data);
}
}
document.removeEventListener("mousemove", moveDrag);
document.removeEventListener("touchmove", moveDrag);
document.removeEventListener("mouseup", stopDrag);
document.removeEventListener("touchend", stopDrag);
if (dragElement !== null) {
dragElement.remove();
dragElement = null;
}
if (currentDrag !== null) {
currentDrag.el.classList.remove(currentDrag.dragConfig.draggingStateCls);
currentDrag.el.classList.remove(currentDrag.dragConfig.validDragCls);
currentDrag.el.classList.remove(currentDrag.dragConfig.invalidDragCls);
currentDrag = null;
}
if (currentDrop !== null) {
currentDrop.el.classList.remove(currentDrop.dropConfig.validDropCls);
currentDrop.el.classList.remove(currentDrop.dropConfig.invalidDropCls);
currentDrop = null;
}
dragMouseOffsetX = 0;
dragMouseOffsetY = 0;
}
/**
* Fired by event callback when the user moves the dragged element over an existing drop zone.
*/
function enterDropZone(el: HTMLElement, dropConfig: DropConfig) {
if (currentDrag === null) {
return;
}
currentDrop = { el, dropConfig };
const valid = validateDrop();
if (valid) {
el.classList.add(dropConfig.validDropCls);
el.classList.remove(dropConfig.invalidDropCls);
if (dragElement) {
dragElement.classList.add(currentDrag.dragConfig.validDragCls);
dragElement.classList.remove(currentDrag.dragConfig.invalidDragCls);
}
} else {
el.classList.add(dropConfig.invalidDropCls);
el.classList.remove(dropConfig.validDropCls);
if (dragElement) {
dragElement.classList.add(currentDrag.dragConfig.invalidDragCls);
dragElement.classList.remove(currentDrag.dragConfig.validDragCls);
}
}
if (typeof currentDrag.dragConfig.onDragEnter === "function") {
currentDrag.dragConfig.onDragEnter(
currentDrag.dragConfig.data,
currentDrop.dropConfig.data,
valid,
);
}
}
/**
* Fired by event callback when the user moves the dragged element out of an existing drop zone.
*
* @param {HTMLElement} el
* @param {DropConfig} dropConfig
*/
function leaveDropZone(el: HTMLElement, dropConfig: DropConfig) {
if (currentDrag === null) {
return;
}
if (currentDrop === null) {
return;
}
if (typeof currentDrag.dragConfig.onDragLeave === "function") {
currentDrag.dragConfig.onDragLeave(currentDrag.dragConfig.data, currentDrop.dropConfig.data);
}
el.classList.remove(dropConfig.validDropCls);
el.classList.remove(dropConfig.invalidDropCls);
if (dragElement) {
dragElement.classList.remove(currentDrag.dragConfig.validDragCls);
dragElement.classList.remove(currentDrag.dragConfig.invalidDragCls);
}
currentDrop = null;
}
/**
* Validates a drop using the {currentDrag} and {currentDrop} configuration.
* Also calls the custom validator functions of the two configs.
*/
function validateDrop(): boolean {
let valid = true;
let customDragValidation = true;
let customDropValidation = true;
// Validate if the drag and drop are using the same drag group.
if (
currentDrag === null ||
currentDrop === null ||
currentDrop.dropConfig.dragGroup !== currentDrag.dragConfig.dragGroup
) {
valid = false;
}
// Check the custom drag validate function.
if (currentDrag !== null && typeof currentDrag.dragConfig.validateDrop === "function") {
customDragValidation = currentDrag.dragConfig.validateDrop(
currentDrag.dragConfig.data,
currentDrop?.dropConfig.data,
);
}
// Check the custom drop validate function.
if (currentDrop !== null && typeof currentDrop.dropConfig.validateDrop === "function") {
customDropValidation = currentDrop.dropConfig.validateDrop(
currentDrag?.dragConfig.data,
currentDrop.dropConfig.data,
);
}
return valid && customDragValidation && customDropValidation;
}
/**
* Validates a drag using the {currentDrag} configuration.
* Also calls the custom validator functions of the config.
*/
function validateDrag(): boolean {
let valid = true;
let customDragValidation = true;
// Validate if the drag and drop are using the same drag group.
if (currentDrag === null) {
valid = false;
}
// Check the custom drag validate function.
if (currentDrag !== null && typeof currentDrag.dragConfig.validateDrag === "function") {
customDragValidation = currentDrag.dragConfig.validateDrag(
currentDrag.dragConfig.data,
currentDrop?.dropConfig.data,
);
}
return valid && customDragValidation;
}
function mergeConfigs(defaultConfig: DragConfig | DropConfig, binding: { value: unknown }) {
const mergedConfig = { ...defaultConfig };
if (binding.value instanceof Object) {
Object.assign(mergedConfig, binding.value);
} else {
Object.assign(mergedConfig, { data: binding.value });
}
return mergedConfig;
}
interface DragHTMLElement extends HTMLElement {
dragConfig?: DragConfig;
boundDragListener?: (event: MouseEvent | TouchEvent) => boolean;
}
/**
* Directive for making elements draggable.
*
* Usage:
* <div v-draggable="{ data: {...}, onDrop() {...} }"></div>
*
* See the {DragConfig} for all possible config options.
*/
export const draggable: Directive = {
mounted(el: DragHTMLElement, binding) {
const dragConfig = mergeConfigs(defaultDragConfig, binding as { value: unknown }) as DragConfig;
el.dragConfig = dragConfig;
el.boundDragListener = onDrag.bind(this, el, el.dragConfig);
if (!dragConfig.disabled) {
el.classList.add(dragConfig.draggableCls);
el.addEventListener("mousedown", el.boundDragListener);
el.addEventListener("touchstart", el.boundDragListener);
}
},
updated(el: DragHTMLElement, binding) {
const dragConfig = mergeConfigs(defaultDragConfig, binding as { value: unknown }) as DragConfig;
if (el.dragConfig && el.dragConfig.disabled !== dragConfig.disabled) {
if (!dragConfig.disabled) {
el.classList.remove(el.dragConfig.draggableCls);
el.classList.add(dragConfig.draggableCls);
if (el.boundDragListener) {
el.addEventListener("mousedown", el.boundDragListener);
el.addEventListener("touchstart", el.boundDragListener);
}
} else {
el.classList.remove(el.dragConfig.draggableCls);
if (el.boundDragListener) {
el.removeEventListener("mousedown", el.boundDragListener);
el.removeEventListener("touchstart", el.boundDragListener);
}
}
}
// @ts-expect-error - typescript support is missing here
Object.assign(el.dragConfig, dragConfig);
},
unmounted(el: DragHTMLElement, binding) {
const dragConfig = mergeConfigs(defaultDragConfig, binding as { value: unknown }) as DragConfig;
el.classList.remove(dragConfig.draggableCls);
if (el.boundDragListener) {
el.removeEventListener("mousedown", el.boundDragListener);
el.removeEventListener("touchstart", el.boundDragListener);
}
},
};
/**
* Directive to define an element as a drop zone.
*
* Usage:
* <div v-droppable="{ data: {...}, onDrop() {...} }"></div>
*
* See the {dropConfig} for all possible config options.
*/
export const droppable: Directive = {
mounted(el, binding) {
const dropConfig = mergeConfigs(defaultDropConfig, binding as { value: unknown }) as DropConfig;
dropZones.push({ el, dropConfig });
el.classList.add(dropConfig.droppableCls);
el.addEventListener("mouseenter", enterDropZone.bind(this, el, dropConfig));
el.addEventListener("mouseleave", leaveDropZone.bind(this, el, dropConfig));
},
unmounted(el, binding) {
const dropConfig = mergeConfigs(defaultDropConfig, binding as { value: unknown }) as DropConfig;
dropZones.splice(
dropZones.findIndex((zone) => zone.el === el),
1,
);
el.classList.remove(dropConfig.droppableCls);
el.removeEventListener("mouseenter", enterDropZone.bind(this, el, dropConfig));
el.removeEventListener("mouseleave", leaveDropZone.bind(this, el, dropConfig));
},
updated: (el, binding) => {
const dropZone = dropZones.find((zone) => zone.el === el);
if (binding.value instanceof Object) {
// @ts-expect-error - typescript support is missing here
Object.assign(dropZone?.dropConfig, binding.value);
} else {
// @ts-expect-error - typescript support is missing here
Object.assign(dropZone?.dropConfig, { data: binding.value });
}
},
};
function clearAllDropZoneHighlights() {
dropZones.forEach((zone) => {
zone.el.classList.remove(zone.dropConfig.validDropCls);
zone.el.classList.remove(zone.dropConfig.invalidDropCls);
});
}