@atlaskit/pragmatic-drag-and-drop-react-beautiful-dnd-migration
Version:
An optional Pragmatic drag and drop package that enables rapid migration from react-beautiful-dnd to Pragmatic drag and drop
464 lines (451 loc) • 14.1 kB
JavaScript
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
// eslint-disable-next-line import/no-extraneous-dependencies
import { getDraggableDimensions } from '../hooks/use-captured-dimensions';
import { useCleanupFn } from '../hooks/use-cleanup-fn';
import { attributes, getAttribute } from '../utils/attributes';
import { findDragHandle } from '../utils/find-drag-handle';
import { getClosestPositionedElement } from '../utils/get-closest-positioned-element';
import { cancelPointerDrag } from './cancel-drag';
import { isSameLocation } from './draggable-location';
import { useDroppableRegistry } from './droppable-registry';
import { ErrorBoundary } from './error-boundary';
import { getActualDestination } from './get-destination';
import useHiddenTextElement from './hooks/use-hidden-text-element';
import { useKeyboardControls } from './hooks/use-keyboard-controls';
import { usePointerControls } from './hooks/use-pointer-controls';
import useStyleMarshal from './hooks/use-style-marshal';
import { DragDropContextProvider } from './internal-context';
import { LifecycleContextProvider, useLifecycle } from './lifecycle-context';
import { announce } from './live-region';
import { rbdInvariant } from './rbd-invariant';
import { defaultDragHandleUsageInstructions, getProvided } from './screen-reader';
import { useScheduler } from './use-scheduler';
/**
* The instance count is used for selectors when querying the document.
*
* Ideally, in the future, this can be removed completely.
*/
let instanceCount = 0;
export function resetServerContext() {
instanceCount = 0;
}
function getContextId() {
return `${instanceCount++}`;
}
function getOffset(args) {
const offsetElement = getClosestPositionedElement(args);
return {
top: offsetElement.offsetTop,
left: offsetElement.offsetLeft
};
}
export function DragDropContext({
children,
dragHandleUsageInstructions = defaultDragHandleUsageInstructions,
nonce,
onBeforeCapture,
onBeforeDragStart,
onDragStart,
onDragUpdate,
onDragEnd
}) {
const [contextId] = useState(getContextId);
useHiddenTextElement({
contextId,
text: dragHandleUsageInstructions
});
const lifecycle = useLifecycle();
const {
schedule,
flush
} = useScheduler();
const dragStateRef = useRef({
isDragging: false
});
const getDragState = useCallback(() => {
return dragStateRef.current;
}, []);
const droppableRegistry = useDroppableRegistry();
const getClosestEnabledDraggableLocation = useCallback(({
droppableId
}) => {
let droppable = droppableRegistry.getEntry({
droppableId
});
while (droppable !== null && droppable.isDropDisabled) {
const {
parentDroppableId
} = droppable;
droppable = parentDroppableId ? droppableRegistry.getEntry({
droppableId: parentDroppableId
}) : null;
}
if (droppable === null) {
return null;
}
return {
droppableId: droppable.droppableId,
index: 0
};
}, [droppableRegistry]);
useEffect(() => {
/**
* If there is a drag when the context unmounts, cancel it.
*/
return () => {
const {
isDragging
} = getDragState();
if (isDragging) {
cancelPointerDrag();
}
};
}, [getDragState]);
const updateDrag = useCallback(({
targetLocation,
isImmediate = false
}) => {
if (!dragStateRef.current.isDragging) {
/**
* If there is no ongoing drag, then don't do anything.
*
* This should never occur, but treating it as a noop is more
* reasonable than an invariant.
*/
return;
}
const {
prevDestination,
draggableId,
type,
sourceLocation
} = dragStateRef.current;
/**
* Computes where it would actually move to
*/
const nextDestination = getActualDestination({
start: sourceLocation,
target: targetLocation
});
if (isSameLocation(prevDestination, nextDestination)) {
return;
}
Object.assign(dragStateRef.current, {
prevDestination: nextDestination,
sourceLocation,
targetLocation
});
const update = {
mode: dragStateRef.current.mode,
draggableId,
type,
source: sourceLocation,
destination: nextDestination,
combine: null // not supported by migration layer
};
const droppable = targetLocation ? droppableRegistry.getEntry({
droppableId: targetLocation.droppableId
}) : null;
/**
* This event exists solely to ensure that the drop indicator updates
* before the drag preview.
*/
lifecycle.dispatch('onPrePendingDragUpdate', {
update,
targetLocation
});
lifecycle.dispatch('onPendingDragUpdate', {
update,
targetLocation,
droppable
});
function dispatchConsumerLifecycleEvent() {
const {
provided,
getMessage
} = getProvided('onDragUpdate', update);
onDragUpdate === null || onDragUpdate === void 0 ? void 0 : onDragUpdate(update, provided);
announce(getMessage());
}
if (isImmediate) {
dispatchConsumerLifecycleEvent();
} else {
schedule(dispatchConsumerLifecycleEvent);
}
}, [droppableRegistry, lifecycle, onDragUpdate, schedule]);
const startDrag = useCallback(({
draggableId,
type,
getSourceLocation,
sourceElement,
mode
}) => {
if (dragStateRef.current.isDragging) {
/**
* If there is already an ongoing drag, then don't do anything.
*
* This should never occur, but treating it as a noop is more
* reasonable than an invariant.
*/
return;
}
const before = {
draggableId,
mode
};
// This is called in `onDragStart` rather than `onGenerateDragPreview`
// to avoid a browser bug. Some DOM manipulations can cancel
// the drag if they happen early in the drag.
// <https://bugs.chromium.org/p/chromium/issues/detail?id=674882>
onBeforeCapture === null || onBeforeCapture === void 0 ? void 0 : onBeforeCapture(before);
const start = {
mode,
draggableId,
type,
source: getSourceLocation()
};
/**
* If the active element is a drag handle, then
* we want to restore focus to it after the drag.
*
* This matches the behavior of `react-beautiful-dnd`.
*/
const {
activeElement
} = document;
const dragHandleDraggableId = activeElement instanceof HTMLElement && activeElement.hasAttribute(attributes.dragHandle.draggableId) ? getAttribute(activeElement, attributes.dragHandle.draggableId) : null;
const {
droppableId
} = start.source;
const droppable = droppableRegistry.getEntry({
droppableId
});
rbdInvariant(droppable, `should have entry for droppable '${droppableId}'`);
dragStateRef.current = {
isDragging: true,
mode,
draggableDimensions: getDraggableDimensions(sourceElement),
restoreFocusTo: dragHandleDraggableId,
draggableId,
type,
prevDestination: start.source,
sourceLocation: start.source,
targetLocation: start.source,
draggableInitialOffsetInSourceDroppable: getOffset({
element: sourceElement,
mode: droppable.mode
})
};
onBeforeDragStart === null || onBeforeDragStart === void 0 ? void 0 : onBeforeDragStart(start);
/**
* This is used to signal to <Draggable> and <Droppable> elements
* to update their state.
*
* This must be synchronous so that they have updated their state
* by the time that `DragStart` is published.
*/
lifecycle.dispatch('onPendingDragStart', {
start,
droppable
});
// rbd's `onDragStart` is called in the next event loop (via `setTimeout`)
//
// We can safely assume that the React state updates have occurred by
// now, and that the updated `snapshot` has been provided.
// <https://twitter.com/alexandereardon/status/1585784101885263872>
schedule(() => {
const start = {
mode,
draggableId,
type,
source: getSourceLocation()
};
const {
provided,
getMessage
} = getProvided('onDragStart', start);
onDragStart === null || onDragStart === void 0 ? void 0 : onDragStart(start, provided);
announce(getMessage());
/**
* If the droppable is initially disabled, then we publish an
* immediate `DragUpdate` with a new non-disabled destination.
*
* This is typically `destination === null` but can be a parent
* droppable if there are nested droppables.
*
* `react-beautiful-dnd` does this for mouse drags,
* but not for keyboard drags. This is likely a bug, and the migration
* layer will publish an update for all types of drags.
*
* This is scheduled so that state changes that occurred in the
* rbd `onDragStart` will have taken effect. That is,
* a synchronous `setIsDropDisabled(true)` call in the consumer's
* `onDragStart` should result in an immediate update here.
*/
schedule(() => {
const {
droppableId
} = start.source;
const droppable = droppableRegistry.getEntry({
droppableId
});
if (droppable !== null && droppable !== void 0 && droppable.isDropDisabled) {
const targetLocation = getClosestEnabledDraggableLocation({
droppableId
});
updateDrag({
targetLocation,
isImmediate: true
});
}
});
});
}, [droppableRegistry, getClosestEnabledDraggableLocation, lifecycle, onBeforeCapture, onBeforeDragStart, onDragStart, schedule, updateDrag]);
const keyboardCleanupManager = useCleanupFn();
const stopDrag = useCallback(({
reason
}) => {
if (!dragStateRef.current.isDragging) {
/**
* If there is no ongoing drag, then don't do anything.
*
* This should never occur, but treating it as a noop is more
* reasonable than an invariant.
*/
return;
}
keyboardCleanupManager.runCleanupFn();
/**
* If this is a cancel, then an update to a null
* destination will be made. (Unless it is already null)
*
* This is different to `react-beautiful-dnd` and exists
* to standardize behavior between mouse and keyboard drags.
*
* This is required because of a behavior in native drag and
* drop, where a `dragend` will fire exit events on every
* drop target you are over. This results in an unavoidable
* null destination update for mouse drags.
*/
if (reason === 'CANCEL') {
updateDrag({
targetLocation: null
});
}
const {
mode,
restoreFocusTo,
sourceLocation,
targetLocation,
type,
draggableId
} = dragStateRef.current;
dragStateRef.current = {
isDragging: false
};
flush();
const destination = getActualDestination({
start: sourceLocation,
target: targetLocation
});
const result = {
// We are saying all null destination drops count as a CANCEL
reason: destination === null ? 'CANCEL' : 'DROP',
type,
source: sourceLocation,
destination,
mode,
draggableId,
combine: null // not supported by migration layer
};
/**
* Tells <Draggable> instances to cleanup.
*/
lifecycle.dispatch('onBeforeDragEnd', {
draggableId
});
const {
provided,
getMessage
} = getProvided('onDragEnd', result);
onDragEnd(result, provided);
announce(getMessage());
if (restoreFocusTo) {
/**
* The `requestAnimationFrame` matches `react-beautiful-dnd`.
*
* It is required to wait for React state updates to have taken effect.
* Otherwise we might try to focus an element that no longer exists.
*/
requestAnimationFrame(() => {
const dragHandle = findDragHandle({
contextId,
draggableId
});
if (!dragHandle) {
return;
}
dragHandle.focus();
});
}
}, [contextId, flush, keyboardCleanupManager, lifecycle, onDragEnd, updateDrag]);
const dragController = useMemo(() => {
return {
getDragState,
startDrag,
updateDrag,
stopDrag
};
}, [getDragState, startDrag, stopDrag, updateDrag]);
usePointerControls({
dragController,
contextId
});
const {
startKeyboardDrag
} = useKeyboardControls({
dragController,
droppableRegistry,
contextId,
setKeyboardCleanupFn: keyboardCleanupManager.setCleanupFn
});
/**
* If a droppable becomes disabled during a drag, then a new destination
* should be found and published in a `DragUpdate`.
*/
const onDroppableUpdate = useCallback(entry => {
var _dragState$targetLoca;
const dragState = dragStateRef.current;
if (!dragState.isDragging) {
return;
}
if (!entry.isDropDisabled) {
return;
}
if (entry.droppableId !== ((_dragState$targetLoca = dragState.targetLocation) === null || _dragState$targetLoca === void 0 ? void 0 : _dragState$targetLoca.droppableId)) {
return;
}
const targetLocation = getClosestEnabledDraggableLocation({
droppableId: entry.droppableId
});
updateDrag({
targetLocation
});
}, [getClosestEnabledDraggableLocation, updateDrag]);
droppableRegistry.setUpdateListener(onDroppableUpdate);
useStyleMarshal({
contextId,
nonce
});
return /*#__PURE__*/React.createElement(ErrorBoundary, {
contextId: contextId,
dragController: dragController
}, /*#__PURE__*/React.createElement(LifecycleContextProvider, {
lifecycle: lifecycle
}, /*#__PURE__*/React.createElement(DragDropContextProvider, {
contextId: contextId,
getDragState: getDragState,
startKeyboardDrag: startKeyboardDrag,
droppableRegistry: droppableRegistry
}, children)));
}