@hello-pangea/dnd
Version: 
Beautiful and accessible drag and drop for lists with React
245 lines (213 loc) • 6 kB
text/typescript
import { useRef } from 'react';
import { useMemo, useCallback } from 'use-memo-one';
import { invariant } from '../../../invariant';
import type {
  SensorAPI,
  PreDragActions,
  SnapDragActions,
  DraggableId,
} from '../../../types';
import type {
  KeyboardEventBinding,
  AnyEventBinding,
  EventOptions,
} from '../../event-bindings/event-types';
import * as keyCodes from '../../key-codes';
import bindEvents from '../../event-bindings/bind-events';
import preventStandardKeyEvents from './util/prevent-standard-key-events';
import supportedPageVisibilityEventName from './util/supported-page-visibility-event-name';
import useLayoutEffect from '../../use-isomorphic-layout-effect';
function noop() {}
interface KeyMap {
  [key: number]: true;
}
const scrollJumpKeys: KeyMap = {
  [keyCodes.pageDown]: true,
  [keyCodes.pageUp]: true,
  [keyCodes.home]: true,
  [keyCodes.end]: true,
};
function getDraggingBindings(
  actions: SnapDragActions,
  stop: () => void,
): AnyEventBinding[] {
  function cancel() {
    stop();
    actions.cancel();
  }
  function drop() {
    stop();
    actions.drop();
  }
  return [
    {
      eventName: 'keydown',
      fn: (event: KeyboardEvent) => {
        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();
          drop();
          return;
        }
        // Movement
        if (event.keyCode === keyCodes.arrowDown) {
          event.preventDefault();
          actions.moveDown();
          return;
        }
        if (event.keyCode === keyCodes.arrowUp) {
          event.preventDefault();
          actions.moveUp();
          return;
        }
        if (event.keyCode === keyCodes.arrowRight) {
          event.preventDefault();
          actions.moveRight();
          return;
        }
        if (event.keyCode === keyCodes.arrowLeft) {
          event.preventDefault();
          actions.moveLeft();
          return;
        }
        // preventing scroll jumping at this time
        if (scrollJumpKeys[event.keyCode]) {
          event.preventDefault();
          return;
        }
        preventStandardKeyEvents(event);
      },
    },
    // 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 },
    },
    // Cancel on page visibility change
    {
      eventName: supportedPageVisibilityEventName,
      fn: cancel,
    },
  ];
}
export default function useKeyboardSensor(api: SensorAPI) {
  const unbindEventsRef = useRef<() => void>(noop);
  const startCaptureBinding: KeyboardEventBinding = useMemo(
    () => ({
      eventName: 'keydown',
      fn: function onKeyDown(event: KeyboardEvent) {
        // Event already used
        if (event.defaultPrevented) {
          return;
        }
        // Need to start drag with a spacebar press
        if (event.keyCode !== keyCodes.space) {
          return;
        }
        const draggableId: DraggableId | null =
          api.findClosestDraggableId(event);
        if (!draggableId) {
          return;
        }
        const preDrag: PreDragActions | null = api.tryGetLock(
          draggableId,
          // abort function not defined yet
          // eslint-disable-next-line @typescript-eslint/no-use-before-define
          stop,
          { sourceEvent: event },
        );
        // Cannot start capturing at this time
        if (!preDrag) {
          return;
        }
        // we are consuming the event
        event.preventDefault();
        let isCapturing = true;
        // There is no pending period for a keyboard drag
        // We can lift immediately
        const actions: SnapDragActions = preDrag.snapLift();
        // unbind this listener
        unbindEventsRef.current();
        // setup our function to end everything
        function stop() {
          invariant(
            isCapturing,
            'Cannot stop capturing a keyboard drag when not capturing',
          );
          isCapturing = false;
          // unbind dragging bindings
          unbindEventsRef.current();
          // start listening for capture again
          // eslint-disable-next-line @typescript-eslint/no-use-before-define
          listenForCapture();
        }
        // bind dragging listeners
        unbindEventsRef.current = bindEvents(
          window,
          getDraggingBindings(actions, stop),
          { capture: true, passive: false },
        );
      },
    }),
    // not including startPendingDrag as it is not defined initially
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [api],
  );
  const listenForCapture = useCallback(
    function tryStartCapture() {
      const options: EventOptions = {
        passive: false,
        capture: true,
      };
      unbindEventsRef.current = bindEvents(
        window,
        [startCaptureBinding],
        options,
      );
    },
    [startCaptureBinding],
  );
  useLayoutEffect(
    function mount() {
      listenForCapture();
      // kill any pending window events when unmounting
      return function unmount() {
        unbindEventsRef.current();
      };
    },
    [listenForCapture],
  );
}