react-beautiful-dnd-next
Version:
Beautiful and accessible drag and drop for lists with React
264 lines (228 loc) • 7.12 kB
JavaScript
// @flow
import type { Position } from 'css-box-model';
import { useRef } from 'react';
import { useMemo, useCallback } from 'use-memo-one';
import invariant from 'tiny-invariant';
import type { EventBinding } from '../util/event-types';
import { bindEvents, unbindEvents } from '../util/bind-events';
import createScheduler from '../util/create-scheduler';
import * as keyCodes from '../../key-codes';
import supportedPageVisibilityEventName from '../util/supported-page-visibility-event-name';
import preventStandardKeyEvents from '../util/prevent-standard-key-events';
import type { Callbacks } from '../drag-handle-types';
import getBorderBoxCenterPosition from '../../get-border-box-center-position';
export type Args = {|
callbacks: Callbacks,
getDraggableRef: () => ?HTMLElement,
getWindow: () => HTMLElement,
canStartCapturing: (event: Event) => boolean,
onCaptureStart: (abort: () => void) => void,
onCaptureEnd: () => void,
|};
export type OnKeyDown = (event: KeyboardEvent) => void;
type KeyMap = {
[key: number]: true,
};
const scrollJumpKeys: KeyMap = {
[keyCodes.pageDown]: true,
[keyCodes.pageUp]: true,
[keyCodes.home]: true,
[keyCodes.end]: true,
};
function noop() {}
export default function useKeyboardSensor(args: Args): OnKeyDown {
const {
canStartCapturing,
getWindow,
callbacks,
onCaptureStart,
onCaptureEnd,
getDraggableRef,
} = args;
const isDraggingRef = useRef<boolean>(false);
const unbindWindowEventsRef = useRef<() => void>(noop);
const getIsDragging = useCallback(() => isDraggingRef.current, []);
const schedule = useMemo(() => {
invariant(
!getIsDragging(),
'Should not recreate scheduler while capturing',
);
return createScheduler(callbacks);
}, [callbacks, getIsDragging]);
const stop = useCallback(() => {
if (!getIsDragging()) {
return;
}
schedule.cancel();
unbindWindowEventsRef.current();
isDraggingRef.current = false;
onCaptureEnd();
}, [getIsDragging, onCaptureEnd, schedule]);
const cancel = useCallback(() => {
const wasDragging: boolean = isDraggingRef.current;
stop();
if (wasDragging) {
callbacks.onCancel();
}
}, [callbacks, stop]);
const windowBindings: EventBinding[] = useMemo(() => {
invariant(
!getIsDragging(),
'Should not recreate window bindings when dragging',
);
return [
// any mouse actions kills a drag
{
eventName: 'mousedown',
fn: cancel,
},
{
eventName: 'mouseup',
fn: cancel,
},
{
eventName: 'click',
fn: cancel,
},
{
eventName: 'touchstart',
fn: cancel,
},
// resizing the browser kills a drag
{
eventName: 'resize',
fn: cancel,
},
// kill if the user is using the mouse wheel
// We are not supporting wheel / trackpad scrolling with keyboard dragging
{
eventName: 'wheel',
fn: cancel,
// chrome says it is a violation for this to not be passive
// it is fine for it to be passive as we just cancel as soon as we get
// any event
options: { passive: true },
},
// Need to respond instantly to a jump scroll request
// Not using the scheduler
{
eventName: 'scroll',
// Scroll events on elements do not bubble, but they go through the capture phase
// https://twitter.com/alexandereardon/status/985994224867819520
// Using capture: false here as we want to avoid intercepting droppable scroll requests
options: { capture: false },
fn: (event: UIEvent) => {
// IE11 fix:
// Scrollable events still bubble up and are caught by this handler in ie11.
// We can ignore this event
if (event.currentTarget !== getWindow()) {
return;
}
callbacks.onWindowScroll();
},
},
// Cancel on page visibility change
{
eventName: supportedPageVisibilityEventName,
fn: cancel,
},
];
}, [callbacks, cancel, getIsDragging, getWindow]);
const bindWindowEvents = useCallback(() => {
const win: HTMLElement = getWindow();
const options = { capture: true };
// setting up our unbind before we bind
unbindWindowEventsRef.current = () =>
unbindEvents(win, windowBindings, options);
bindEvents(win, windowBindings, options);
}, [getWindow, windowBindings]);
const startDragging = useCallback(() => {
invariant(!isDraggingRef.current, 'Cannot start a drag while dragging');
const ref: ?HTMLElement = getDraggableRef();
invariant(ref, 'Cannot start a keyboard drag without a draggable ref');
isDraggingRef.current = true;
onCaptureStart(stop);
bindWindowEvents();
const center: Position = getBorderBoxCenterPosition(ref);
callbacks.onLift({
clientSelection: center,
movementMode: 'SNAP',
});
}, [bindWindowEvents, callbacks, getDraggableRef, onCaptureStart, stop]);
const onKeyDown: OnKeyDown = useCallback(
(event: KeyboardEvent) => {
// not dragging yet
if (!getIsDragging()) {
// We may already be lifting on a child draggable.
// We do not need to use an EventMarshal here as
// we always call preventDefault on the first input
if (event.defaultPrevented) {
return;
}
// Cannot lift at this time
if (!canStartCapturing(event)) {
return;
}
if (event.keyCode !== keyCodes.space) {
return;
}
// Calling preventDefault as we are consuming the event
event.preventDefault();
startDragging();
return;
}
// already dragging
// Cancelling
if (event.keyCode === keyCodes.escape) {
event.preventDefault();
cancel();
return;
}
// Dropping
if (event.keyCode === keyCodes.space) {
// need to stop parent Draggable's thinking this is a lift
event.preventDefault();
stop();
callbacks.onDrop();
return;
}
// Movement
if (event.keyCode === keyCodes.arrowDown) {
event.preventDefault();
schedule.moveDown();
return;
}
if (event.keyCode === keyCodes.arrowUp) {
event.preventDefault();
schedule.moveUp();
return;
}
if (event.keyCode === keyCodes.arrowRight) {
event.preventDefault();
schedule.moveRight();
return;
}
if (event.keyCode === keyCodes.arrowLeft) {
event.preventDefault();
schedule.moveLeft();
return;
}
// preventing scroll jumping at this time
if (scrollJumpKeys[event.keyCode]) {
event.preventDefault();
return;
}
preventStandardKeyEvents(event);
},
[
callbacks,
canStartCapturing,
cancel,
getIsDragging,
schedule,
startDragging,
stop,
],
);
return onKeyDown;
}