svelte-dnd-action
Version:
*An awesome drag and drop library for Svelte 3 and 4 (not using the browser's built-in dnd, thanks god): Rich animations, nested containers, touch support and more *
356 lines (336 loc) • 13.9 kB
JavaScript
import {decrementActiveDropZoneCount, incrementActiveDropZoneCount, ITEM_ID_KEY, SOURCES, TRIGGERS} from "./constants";
import {styleActiveDropZones, styleInactiveDropZones} from "./helpers/styler";
import {dispatchConsiderEvent, dispatchFinalizeEvent} from "./helpers/dispatcher";
import {initAria, alertToScreenReader, destroyAria} from "./helpers/aria";
import {toString} from "./helpers/util";
import {printDebug} from "./constants";
const DEFAULT_DROP_ZONE_TYPE = "--any--";
const DEFAULT_DROP_TARGET_STYLE = {
outline: "rgba(255, 255, 102, 0.7) solid 2px"
};
let isDragging = false;
let draggedItemType;
let focusedDz;
let focusedDzLabel = "";
let focusedItem;
let focusedItemId;
let focusedItemLabel = "";
const allDragTargets = new WeakSet();
const elToKeyDownListeners = new WeakMap();
const elToFocusListeners = new WeakMap();
const dzToHandles = new Map();
const dzToConfig = new Map();
const typeToDropZones = new Map();
/* TODO (potentially)
* what's the deal with the black border of voice-reader not following focus?
* maybe keep focus on the last dragged item upon drop?
*/
let INSTRUCTION_IDs;
/* drop-zones registration management */
function registerDropZone(dropZoneEl, type) {
printDebug(() => "registering drop-zone if absent");
if (typeToDropZones.size === 0) {
printDebug(() => "adding global keydown and click handlers");
INSTRUCTION_IDs = initAria();
window.addEventListener("keydown", globalKeyDownHandler);
window.addEventListener("click", globalClickHandler);
}
if (!typeToDropZones.has(type)) {
typeToDropZones.set(type, new Set());
}
if (!typeToDropZones.get(type).has(dropZoneEl)) {
typeToDropZones.get(type).add(dropZoneEl);
incrementActiveDropZoneCount();
}
}
function unregisterDropZone(dropZoneEl, type) {
printDebug(() => "unregistering drop-zone");
if (focusedDz === dropZoneEl) {
handleDrop();
}
typeToDropZones.get(type).delete(dropZoneEl);
decrementActiveDropZoneCount();
if (typeToDropZones.get(type).size === 0) {
typeToDropZones.delete(type);
}
if (typeToDropZones.size === 0) {
printDebug(() => "removing global keydown and click handlers");
window.removeEventListener("keydown", globalKeyDownHandler);
window.removeEventListener("click", globalClickHandler);
INSTRUCTION_IDs = undefined;
destroyAria();
}
}
function globalKeyDownHandler(e) {
if (!isDragging) return;
switch (e.key) {
case "Escape": {
handleDrop();
break;
}
}
}
function globalClickHandler() {
if (!isDragging) return;
if (!allDragTargets.has(document.activeElement)) {
printDebug(() => "clicked outside of any draggable");
handleDrop();
}
}
function handleZoneFocus(e) {
printDebug(() => "zone focus");
if (!isDragging) return;
const newlyFocusedDz = e.currentTarget;
if (newlyFocusedDz === focusedDz) return;
focusedDzLabel = newlyFocusedDz.getAttribute("aria-label") || "";
const {items: originItems} = dzToConfig.get(focusedDz);
const originItem = originItems.find(item => item[ITEM_ID_KEY] === focusedItemId);
const originIdx = originItems.indexOf(originItem);
const itemToMove = originItems.splice(originIdx, 1)[0];
const {items: targetItems, autoAriaDisabled} = dzToConfig.get(newlyFocusedDz);
if (
newlyFocusedDz.getBoundingClientRect().top < focusedDz.getBoundingClientRect().top ||
newlyFocusedDz.getBoundingClientRect().left < focusedDz.getBoundingClientRect().left
) {
targetItems.push(itemToMove);
if (!autoAriaDisabled) {
alertToScreenReader(`Moved item ${focusedItemLabel} to the end of the list ${focusedDzLabel}`);
}
} else {
targetItems.unshift(itemToMove);
if (!autoAriaDisabled) {
alertToScreenReader(`Moved item ${focusedItemLabel} to the beginning of the list ${focusedDzLabel}`);
}
}
const dzFrom = focusedDz;
dispatchFinalizeEvent(dzFrom, originItems, {trigger: TRIGGERS.DROPPED_INTO_ANOTHER, id: focusedItemId, source: SOURCES.KEYBOARD});
dispatchFinalizeEvent(newlyFocusedDz, targetItems, {trigger: TRIGGERS.DROPPED_INTO_ZONE, id: focusedItemId, source: SOURCES.KEYBOARD});
focusedDz = newlyFocusedDz;
}
function triggerAllDzsUpdate() {
dzToHandles.forEach(({update}, dz) => update(dzToConfig.get(dz)));
}
function handleDrop(dispatchConsider = true) {
printDebug(() => "drop");
if (!dzToConfig.get(focusedDz).autoAriaDisabled) {
alertToScreenReader(`Stopped dragging item ${focusedItemLabel}`);
}
if (allDragTargets.has(document.activeElement)) {
document.activeElement.blur();
}
if (dispatchConsider) {
dispatchConsiderEvent(focusedDz, dzToConfig.get(focusedDz).items, {
trigger: TRIGGERS.DRAG_STOPPED,
id: focusedItemId,
source: SOURCES.KEYBOARD
});
}
styleInactiveDropZones(
typeToDropZones.get(draggedItemType),
dz => dzToConfig.get(dz).dropTargetStyle,
dz => dzToConfig.get(dz).dropTargetClasses
);
focusedItem = null;
focusedItemId = null;
focusedItemLabel = "";
draggedItemType = null;
focusedDz = null;
focusedDzLabel = "";
isDragging = false;
triggerAllDzsUpdate();
}
//////
export function dndzone(node, options) {
const config = {
items: undefined,
type: undefined,
dragDisabled: false,
zoneTabIndex: 0,
zoneItemTabIndex: 0,
dropFromOthersDisabled: false,
dropTargetStyle: DEFAULT_DROP_TARGET_STYLE,
dropTargetClasses: [],
autoAriaDisabled: false
};
function swap(arr, i, j) {
if (arr.length <= 1) return;
arr.splice(j, 1, arr.splice(i, 1, arr[j])[0]);
}
function handleKeyDown(e) {
printDebug(() => ["handling key down", e.key]);
switch (e.key) {
case "Enter":
case " ": {
// we don't want to affect nested input elements or clickable elements
if ((e.target.disabled !== undefined || e.target.href || e.target.isContentEditable) && !allDragTargets.has(e.target)) {
return;
}
e.preventDefault(); // preventing scrolling on spacebar
e.stopPropagation();
if (isDragging) {
// TODO - should this trigger a drop? only here or in general (as in when hitting space or enter outside of any zone)?
handleDrop();
} else {
// drag start
handleDragStart(e);
}
break;
}
case "ArrowDown":
case "ArrowRight": {
if (!isDragging) return;
e.preventDefault(); // prevent scrolling
e.stopPropagation();
const {items} = dzToConfig.get(node);
const children = Array.from(node.children);
const idx = children.indexOf(e.currentTarget);
printDebug(() => ["arrow down", idx]);
if (idx < children.length - 1) {
if (!config.autoAriaDisabled) {
alertToScreenReader(`Moved item ${focusedItemLabel} to position ${idx + 2} in the list ${focusedDzLabel}`);
}
swap(items, idx, idx + 1);
dispatchFinalizeEvent(node, items, {trigger: TRIGGERS.DROPPED_INTO_ZONE, id: focusedItemId, source: SOURCES.KEYBOARD});
}
break;
}
case "ArrowUp":
case "ArrowLeft": {
if (!isDragging) return;
e.preventDefault(); // prevent scrolling
e.stopPropagation();
const {items} = dzToConfig.get(node);
const children = Array.from(node.children);
const idx = children.indexOf(e.currentTarget);
printDebug(() => ["arrow up", idx]);
if (idx > 0) {
if (!config.autoAriaDisabled) {
alertToScreenReader(`Moved item ${focusedItemLabel} to position ${idx} in the list ${focusedDzLabel}`);
}
swap(items, idx, idx - 1);
dispatchFinalizeEvent(node, items, {trigger: TRIGGERS.DROPPED_INTO_ZONE, id: focusedItemId, source: SOURCES.KEYBOARD});
}
break;
}
}
}
function handleDragStart(e) {
printDebug(() => "drag start");
setCurrentFocusedItem(e.currentTarget);
focusedDz = node;
draggedItemType = config.type;
isDragging = true;
const dropTargets = Array.from(typeToDropZones.get(config.type)).filter(dz => dz === focusedDz || !dzToConfig.get(dz).dropFromOthersDisabled);
styleActiveDropZones(
dropTargets,
dz => dzToConfig.get(dz).dropTargetStyle,
dz => dzToConfig.get(dz).dropTargetClasses
);
if (!config.autoAriaDisabled) {
let msg = `Started dragging item ${focusedItemLabel}. Use the arrow keys to move it within its list ${focusedDzLabel}`;
if (dropTargets.length > 1) {
msg += `, or tab to another list in order to move the item into it`;
}
alertToScreenReader(msg);
}
dispatchConsiderEvent(node, dzToConfig.get(node).items, {trigger: TRIGGERS.DRAG_STARTED, id: focusedItemId, source: SOURCES.KEYBOARD});
triggerAllDzsUpdate();
}
function handleClick(e) {
if (!isDragging) return;
if (e.currentTarget === focusedItem) return;
e.stopPropagation();
handleDrop(false);
handleDragStart(e);
}
function setCurrentFocusedItem(draggableEl) {
const {items} = dzToConfig.get(node);
const children = Array.from(node.children);
const focusedItemIdx = children.indexOf(draggableEl);
focusedItem = draggableEl;
focusedItem.tabIndex = config.zoneItemTabIndex;
focusedItemId = items[focusedItemIdx][ITEM_ID_KEY];
focusedItemLabel = children[focusedItemIdx].getAttribute("aria-label") || "";
}
function configure({
items = [],
type: newType = DEFAULT_DROP_ZONE_TYPE,
dragDisabled = false,
zoneTabIndex = 0,
zoneItemTabIndex = 0,
dropFromOthersDisabled = false,
dropTargetStyle = DEFAULT_DROP_TARGET_STYLE,
dropTargetClasses = [],
autoAriaDisabled = false
}) {
config.items = [...items];
config.dragDisabled = dragDisabled;
config.dropFromOthersDisabled = dropFromOthersDisabled;
config.zoneTabIndex = zoneTabIndex;
config.zoneItemTabIndex = zoneItemTabIndex;
config.dropTargetStyle = dropTargetStyle;
config.dropTargetClasses = dropTargetClasses;
config.autoAriaDisabled = autoAriaDisabled;
if (config.type && newType !== config.type) {
unregisterDropZone(node, config.type);
}
config.type = newType;
registerDropZone(node, newType);
if (!autoAriaDisabled) {
node.setAttribute("role", "list");
node.setAttribute("aria-describedby", dragDisabled ? INSTRUCTION_IDs.DND_ZONE_DRAG_DISABLED : INSTRUCTION_IDs.DND_ZONE_ACTIVE);
}
dzToConfig.set(node, config);
if (isDragging) {
node.tabIndex =
node === focusedDz ||
focusedItem.contains(node) ||
config.dropFromOthersDisabled ||
(focusedDz && config.type !== dzToConfig.get(focusedDz).type)
? -1
: 0;
} else {
node.tabIndex = config.zoneTabIndex;
}
node.addEventListener("focus", handleZoneFocus);
for (let i = 0; i < node.children.length; i++) {
const draggableEl = node.children[i];
allDragTargets.add(draggableEl);
draggableEl.tabIndex = isDragging ? -1 : config.zoneItemTabIndex;
if (!autoAriaDisabled) {
draggableEl.setAttribute("role", "listitem");
}
draggableEl.removeEventListener("keydown", elToKeyDownListeners.get(draggableEl));
draggableEl.removeEventListener("click", elToFocusListeners.get(draggableEl));
if (!dragDisabled) {
draggableEl.addEventListener("keydown", handleKeyDown);
elToKeyDownListeners.set(draggableEl, handleKeyDown);
draggableEl.addEventListener("click", handleClick);
elToFocusListeners.set(draggableEl, handleClick);
}
if (isDragging && config.items[i][ITEM_ID_KEY] === focusedItemId) {
printDebug(() => ["focusing on", {i, focusedItemId}]);
// if it is a nested dropzone, it was re-rendered and we need to refresh our pointer
focusedItem = draggableEl;
focusedItem.tabIndex = config.zoneItemTabIndex;
// without this the element loses focus if it moves backwards in the list
draggableEl.focus();
}
}
}
configure(options);
const handles = {
update: newOptions => {
printDebug(() => `keyboard dndzone will update newOptions: ${toString(newOptions)}`);
configure(newOptions);
},
destroy: () => {
printDebug(() => "keyboard dndzone will destroy");
unregisterDropZone(node, config.type);
dzToConfig.delete(node);
dzToHandles.delete(node);
}
};
dzToHandles.set(node, handles);
return handles;
}