UNPKG

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 *

357 lines (337 loc) 14 kB
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("aria-disabled", dragDisabled); 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; }