From 7dfa8dd186d6bb976368286d32ca65ef310a4689 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Wed, 1 May 2019 11:27:30 +1000 Subject: [PATCH 001/308] moving to contextId --- src/view/context/app-context.js | 2 +- src/view/drag-drop-context/app.jsx | 17 ++++++----------- .../drag-drop-context/drag-drop-context.jsx | 4 ++-- src/view/draggable/draggable.jsx | 4 ++-- src/view/droppable/droppable.jsx | 13 ++++--------- src/view/placeholder/placeholder.jsx | 6 +++--- src/view/use-announcer/use-announcer.js | 8 ++++---- src/view/use-drag-handle/drag-handle-types.js | 4 +++- src/view/use-drag-handle/use-drag-handle.js | 8 +++++--- src/view/use-style-marshal/use-style-marshal.js | 4 ++-- 10 files changed, 32 insertions(+), 38 deletions(-) diff --git a/src/view/context/app-context.js b/src/view/context/app-context.js index 45dcf6dc7a..7567cb8c94 100644 --- a/src/view/context/app-context.js +++ b/src/view/context/app-context.js @@ -5,7 +5,7 @@ import type { DimensionMarshal } from '../../state/dimension-marshal/dimension-m export type AppContextValue = {| marshal: DimensionMarshal, - style: string, + contextId: string, canLift: (id: DraggableId) => boolean, isMovementAllowed: () => boolean, |}; diff --git a/src/view/drag-drop-context/app.jsx b/src/view/drag-drop-context/app.jsx index a09480b28f..0f2da350ff 100644 --- a/src/view/drag-drop-context/app.jsx +++ b/src/view/drag-drop-context/app.jsx @@ -37,7 +37,7 @@ import { warning } from '../../dev-warning'; type Props = {| ...Responders, - uniqueId: number, + contextId: string, setOnError: (onError: Function) => void, // we do not technically need any children for this component children: Node | null, @@ -60,7 +60,7 @@ function getStore(lazyRef: LazyStoreRef): Store { } export default function App(props: Props) { - const { uniqueId, setOnError } = props; + const { contextId, setOnError } = props; const lazyStoreRef: LazyStoreRef = useRef(null); useStartupValidation(); @@ -72,8 +72,8 @@ export default function App(props: Props) { return createResponders(lastPropsRef.current); }, [lastPropsRef]); - const announce: Announce = useAnnouncer(uniqueId); - const styleMarshal: StyleMarshal = useStyleMarshal(uniqueId); + const announce: Announce = useAnnouncer(contextId); + const styleMarshal: StyleMarshal = useStyleMarshal(contextId); const lazyDispatch: Action => void = useCallback((action: Action): void => { getStore(lazyStoreRef).dispatch(action); @@ -162,16 +162,11 @@ export default function App(props: Props) { const appContext: AppContextValue = useMemo( () => ({ marshal: dimensionMarshal, - style: styleMarshal.styleContext, + contextId, canLift: getCanLift, isMovementAllowed: getIsMovementAllowed, }), - [ - dimensionMarshal, - getCanLift, - getIsMovementAllowed, - styleMarshal.styleContext, - ], + [contextId, dimensionMarshal, getCanLift, getIsMovementAllowed], ); // Clean store when unmounting diff --git a/src/view/drag-drop-context/drag-drop-context.jsx b/src/view/drag-drop-context/drag-drop-context.jsx index 156e573efb..cda318cd37 100644 --- a/src/view/drag-drop-context/drag-drop-context.jsx +++ b/src/view/drag-drop-context/drag-drop-context.jsx @@ -19,14 +19,14 @@ export function resetServerContext() { } export default function DragDropContext(props: Props) { - const uniqueId: number = useMemo(() => instanceCount++, []); + const contextId: string = useMemo(() => `${instanceCount++}`, []); // We need the error boundary to be on the outside of App // so that it can catch any errors caused by App return ( {setOnError => ( - + {props.children} )} diff --git a/src/view/draggable/draggable.jsx b/src/view/draggable/draggable.jsx index bbdf7e2157..110dadfa20 100644 --- a/src/view/draggable/draggable.jsx +++ b/src/view/draggable/draggable.jsx @@ -184,7 +184,7 @@ export default function Draggable(props: Props) { const result: Provided = { innerRef: setRef, draggableProps: { - 'data-react-beautiful-dnd-draggable': appContext.style, + 'data-react-beautiful-dnd-draggable': appContext.contextId, style, onTransitionEnd, }, @@ -192,7 +192,7 @@ export default function Draggable(props: Props) { }; return result; - }, [appContext.style, dragHandleProps, mapped, onMoveEnd, setRef]); + }, [appContext.contextId, dragHandleProps, mapped, onMoveEnd, setRef]); return children(provided, mapped.snapshot); } diff --git a/src/view/droppable/droppable.jsx b/src/view/droppable/droppable.jsx index c896f6968e..a072b5028b 100644 --- a/src/view/droppable/droppable.jsx +++ b/src/view/droppable/droppable.jsx @@ -19,7 +19,7 @@ import AnimateInOut, { export default function Droppable(props: Props) { const appContext: ?AppContextValue = useContext(AppContext); invariant(appContext, 'Could not find app context'); - const { style: styleContext, isMovementAllowed } = appContext; + const { contextId, isMovementAllowed } = appContext; const droppableRef = useRef(null); const placeholderRef = useRef(null); @@ -73,11 +73,6 @@ export default function Droppable(props: Props) { getPlaceholderRef, }); - // const instruction: ?AnimateProvided = useAnimateInOut({ - // on: props.placeholder, - // shouldAnimate: props.shouldAnimatePlaceholder, - // }); - const placeholder: Node = ( )} @@ -101,10 +96,10 @@ export default function Droppable(props: Props) { innerRef: setDroppableRef, placeholder, droppableProps: { - 'data-react-beautiful-dnd-droppable': styleContext, + 'data-react-beautiful-dnd-droppable': contextId, }, }), - [placeholder, setDroppableRef, styleContext], + [contextId, placeholder, setDroppableRef], ); const droppableContext: ?DroppableContextValue = useMemo( diff --git a/src/view/placeholder/placeholder.jsx b/src/view/placeholder/placeholder.jsx index 8f3d12c78e..e9aa206565 100644 --- a/src/view/placeholder/placeholder.jsx +++ b/src/view/placeholder/placeholder.jsx @@ -31,7 +31,7 @@ export type Props = {| onClose: () => void, innerRef?: () => ?HTMLElement, onTransitionEnd: () => void, - styleContext: string, + contextId: string, |}; type Size = {| @@ -124,7 +124,7 @@ function Placeholder(props: Props): Node { animateOpenTimerRef.current = null; }, []); - const { animate, onTransitionEnd, onClose, styleContext } = props; + const { animate, onTransitionEnd, onClose, contextId } = props; const [isAnimatingOpenOnMount, setIsAnimatingOpenOnMount] = useState( props.animate === 'open', ); @@ -186,7 +186,7 @@ function Placeholder(props: Props): Node { return React.createElement(props.placeholder.tagName, { style, - 'data-react-beautiful-dnd-placeholder': styleContext, + 'data-react-beautiful-dnd-placeholder': contextId, onTransitionEnd: onSizeChangeEnd, ref: props.innerRef, }); diff --git a/src/view/use-announcer/use-announcer.js b/src/view/use-announcer/use-announcer.js index 79dfbddecd..951e275099 100644 --- a/src/view/use-announcer/use-announcer.js +++ b/src/view/use-announcer/use-announcer.js @@ -21,11 +21,11 @@ const visuallyHidden: Object = { 'clip-path': 'inset(100%)', }; -export const getId = (uniqueId: number): string => - `react-beautiful-dnd-announcement-${uniqueId}`; +export const getId = (contextId: string): string => + `react-beautiful-dnd-announcement-${contextId}`; -export default function useAnnouncer(uniqueId: number): Announce { - const id: string = useMemo(() => getId(uniqueId), [uniqueId]); +export default function useAnnouncer(contextId: string): Announce { + const id: string = useMemo(() => getId(contextId), [contextId]); const ref = useRef(null); useEffect(() => { diff --git a/src/view/use-drag-handle/drag-handle-types.js b/src/view/use-drag-handle/drag-handle-types.js index 0d746aae3f..0a9a72ae5d 100644 --- a/src/view/use-drag-handle/drag-handle-types.js +++ b/src/view/use-drag-handle/drag-handle-types.js @@ -29,8 +29,10 @@ export type DragHandleProps = {| onKeyDown: (event: KeyboardEvent) => void, onTouchStart: (event: TouchEvent) => void, - // Control styling from style marshal + // What DragDropContext the drag handle is in 'data-react-beautiful-dnd-drag-handle': string, + // The DraggableId associated with the drag handle + 'data-react-beautiful-dnd-drag-handle-id': string, // Aria role (nicer screen reader text) 'aria-roledescription': string, diff --git a/src/view/use-drag-handle/use-drag-handle.js b/src/view/use-drag-handle/use-drag-handle.js index ac32f0f8cc..32c5478f66 100644 --- a/src/view/use-drag-handle/use-drag-handle.js +++ b/src/view/use-drag-handle/use-drag-handle.js @@ -54,7 +54,7 @@ export default function useDragHandle(args: Args): ?DragHandleProps { capturingRef.current.abort(); }, []); - const { canLift, style: styleContext }: AppContextValue = useRequiredContext( + const { canLift, contextId }: AppContextValue = useRequiredContext( AppContext, ); const { @@ -215,7 +215,8 @@ export default function useDragHandle(args: Args): ?DragHandleProps { onFocus, onBlur, tabIndex: 0, - 'data-react-beautiful-dnd-drag-handle': styleContext, + 'data-react-beautiful-dnd-drag-handle': contextId, + 'data-react-beautiful-dnd-drag-handle-id': draggableId, // English default. Consumers are welcome to add their own start instruction 'aria-roledescription': 'Draggable item. Press space bar to lift', // Opting out of html5 drag and drops @@ -223,13 +224,14 @@ export default function useDragHandle(args: Args): ?DragHandleProps { onDragStart: preventHtml5Dnd, }; }, [ + contextId, + draggableId, isEnabled, onBlur, onFocus, onKeyDown, onMouseDown, onTouchStart, - styleContext, ]); return props; diff --git a/src/view/use-style-marshal/use-style-marshal.js b/src/view/use-style-marshal/use-style-marshal.js index ddbc795720..f9103eea21 100644 --- a/src/view/use-style-marshal/use-style-marshal.js +++ b/src/view/use-style-marshal/use-style-marshal.js @@ -21,8 +21,8 @@ const createStyleEl = (): HTMLStyleElement => { return el; }; -export default function useStyleMarshal(uniqueId: number) { - const uniqueContext: string = useMemo(() => `${uniqueId}`, [uniqueId]); +export default function useStyleMarshal(contextId: string) { + const uniqueContext: string = useMemo(() => `${contextId}`, [contextId]); const styles: Styles = useMemo(() => getStyles(uniqueContext), [ uniqueContext, ]); From 68d2a8b2719627a987123670d8cfb8dc75a32f7f Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Wed, 1 May 2019 14:08:18 +1000 Subject: [PATCH 002/308] poc for window sensor --- src/view/drag-drop-context/app.jsx | 3 + .../get-closest-drag-handle.js | 34 ++++++++++++ src/view/use-sensor-marshal/index.js | 2 + .../use-sensor-marshal/use-sensor-marshal.js | 55 +++++++++++++++++++ 4 files changed, 94 insertions(+) create mode 100644 src/view/use-sensor-marshal/get-closest-drag-handle.js create mode 100644 src/view/use-sensor-marshal/index.js create mode 100644 src/view/use-sensor-marshal/use-sensor-marshal.js diff --git a/src/view/drag-drop-context/app.jsx b/src/view/drag-drop-context/app.jsx index 0f2da350ff..48f3f40615 100644 --- a/src/view/drag-drop-context/app.jsx +++ b/src/view/drag-drop-context/app.jsx @@ -34,6 +34,7 @@ import AppContext, { type AppContextValue } from '../context/app-context'; import useStartupValidation from './use-startup-validation'; import usePrevious from '../use-previous-ref'; import { warning } from '../../dev-warning'; +import useSensorMarshal from '../use-sensor-marshal/use-sensor-marshal'; type Props = {| ...Responders, @@ -169,6 +170,8 @@ export default function App(props: Props) { [contextId, dimensionMarshal, getCanLift, getIsMovementAllowed], ); + useSensorMarshal(contextId, getCanLift); + // Clean store when unmounting useEffect(() => { return tryResetStore; diff --git a/src/view/use-sensor-marshal/get-closest-drag-handle.js b/src/view/use-sensor-marshal/get-closest-drag-handle.js new file mode 100644 index 0000000000..a8467b2979 --- /dev/null +++ b/src/view/use-sensor-marshal/get-closest-drag-handle.js @@ -0,0 +1,34 @@ +// @flow +import invariant from 'tiny-invariant'; +import { dragHandle } from '../data-attributes'; +import type { DraggableId } from '../../types'; + +function isInContext(contextId, el: Element): boolean { + return el.getAttribute(dragHandle) === contextId; +} + +function isDragHandle(el: Element): boolean { + return el.hasAttribute(dragHandle); +} + +function getDraggableId(handle: Element): DraggableId { + const id: ?DraggableId = handle.getAttribute(`${dragHandle}-id`); + invariant(id, 'expected element to be a drag handle'); + return id; +} + +export default function getClosestDragHandle( + contextId: string, + el: ?Element, +): ?DraggableId { + if (el == null) { + return null; + } + + // not a drag handle or not in the right context + if (!isDragHandle(el) || !isInContext(contextId, el)) { + return getClosestDragHandle(contextId, el.parentElement); + } + + return getDraggableId(el); +} diff --git a/src/view/use-sensor-marshal/index.js b/src/view/use-sensor-marshal/index.js new file mode 100644 index 0000000000..7bb70be854 --- /dev/null +++ b/src/view/use-sensor-marshal/index.js @@ -0,0 +1,2 @@ +// @flow +export { default } from './use-sensor-marshal'; diff --git a/src/view/use-sensor-marshal/use-sensor-marshal.js b/src/view/use-sensor-marshal/use-sensor-marshal.js new file mode 100644 index 0000000000..0ba3c739c9 --- /dev/null +++ b/src/view/use-sensor-marshal/use-sensor-marshal.js @@ -0,0 +1,55 @@ +// @flow +import { useEffect } from 'react'; +import { useCallback } from 'use-memo-one'; +import type { DraggableId } from '../../types'; +import getClosestDragHandle from './get-closest-drag-handle'; + +// $ExpectError - cannot find type +const listenerOptions: AddEventListenerOptions = { + passive: false, + capture: true, +}; + +export default function useSensorMarshal( + contextId: string, + canLift: (id: DraggableId) => boolean, +) { + const onMouseDown = useCallback( + (event: MouseEvent) => { + if (event.defaultPrevented) { + console.log('already handled'); + return; + } + + const target: EventTarget = event.target; + if (!(target instanceof HTMLElement)) { + console.log('target is not a html element'); + return; + } + + const id: ?DraggableId = getClosestDragHandle(contextId, target); + + if (id == null) { + return; + } + + if (!canLift(id)) { + return; + } + + // TODO: interactive element check + // if(is) + console.log('start drag of', id); + event.preventDefault(); + }, + [canLift, contextId], + ); + + useEffect(() => { + window.addEventListener('mousedown', onMouseDown, listenerOptions); + + return () => { + window.removeEventListener('mousedown', onMouseDown, listenerOptions); + }; + }, [onMouseDown]); +} From 877ee08210d5cb9f81cd5b65d2b24b87e7c3ef8e Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Wed, 1 May 2019 15:33:45 +1000 Subject: [PATCH 003/308] api attempt --- src/view/drag-drop-context/app.jsx | 2 +- src/view/use-sensor-marshal/sensor-types.js | 28 +++++ .../sensors/use-mouse-sensor.js | 28 +++++ .../use-sensor-marshal/use-sensor-marshal.js | 113 +++++++++++++----- 4 files changed, 142 insertions(+), 29 deletions(-) create mode 100644 src/view/use-sensor-marshal/sensor-types.js create mode 100644 src/view/use-sensor-marshal/sensors/use-mouse-sensor.js diff --git a/src/view/drag-drop-context/app.jsx b/src/view/drag-drop-context/app.jsx index 48f3f40615..ddc1660e3c 100644 --- a/src/view/drag-drop-context/app.jsx +++ b/src/view/drag-drop-context/app.jsx @@ -170,7 +170,7 @@ export default function App(props: Props) { [contextId, dimensionMarshal, getCanLift, getIsMovementAllowed], ); - useSensorMarshal(contextId, getCanLift); + useSensorMarshal(contextId, store); // Clean store when unmounting useEffect(() => { diff --git a/src/view/use-sensor-marshal/sensor-types.js b/src/view/use-sensor-marshal/sensor-types.js new file mode 100644 index 0000000000..9c7b183fb3 --- /dev/null +++ b/src/view/use-sensor-marshal/sensor-types.js @@ -0,0 +1,28 @@ +// @flow +import type { Position } from 'css-box-model'; +import type { MovementMode, DraggableId } from '../../types'; + +export type SensorHookArgs = {| + // Capture lifecycle + canStartCapturing: (id: DraggableId) => boolean, + canStartCapturingFromEvent: (event: Event) => boolean, + onCaptureStart: (abort: () => void) => void, + onCaptureEnd: () => void, + + // Drag movement + onLift: ({| + id: DraggableId, + clientSelection: Position, + movementMode: MovementMode, + |}) => mixed, + onMove: (point: Position) => mixed, + onWindowScroll: () => mixed, + onMoveUp: () => mixed, + onMoveDown: () => mixed, + onMoveRight: () => mixed, + onMoveLeft: () => mixed, + onDrop: () => mixed, + onCancel: () => mixed, +|}; + +export type SensorHook = (args: SensorHookArgs) => void; diff --git a/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js b/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js new file mode 100644 index 0000000000..da2b7dcfdd --- /dev/null +++ b/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js @@ -0,0 +1,28 @@ +// @flow +import { useEffect } from 'react'; +import { useCallback } from 'use-memo-one'; +import type { SensorHookArgs } from '../sensor-types'; + +const listenerOptions = { + passive: false, + capture: true, +}; + +export default function useMouseSensor(args: SensorHookArgs) { + const onMouseDown = useCallback( + function onMouseDown(event: MouseEvent) { + if (!args.canStartCapturingFromEvent(event)) { + return; + } + console.log('can start dragging from event'); + }, + [args], + ); + + useEffect(() => { + window.addEventListener('mousedown', onMouseDown, listenerOptions); + return () => { + window.removeEventListener('mousedown', onMouseDown, listenerOptions); + }; + }, [onMouseDown]); +} diff --git a/src/view/use-sensor-marshal/use-sensor-marshal.js b/src/view/use-sensor-marshal/use-sensor-marshal.js index 0ba3c739c9..6da529c526 100644 --- a/src/view/use-sensor-marshal/use-sensor-marshal.js +++ b/src/view/use-sensor-marshal/use-sensor-marshal.js @@ -1,55 +1,112 @@ // @flow +import invariant from 'tiny-invariant'; import { useEffect } from 'react'; -import { useCallback } from 'use-memo-one'; -import type { DraggableId } from '../../types'; +import { useCallback, useMemo } from 'use-memo-one'; +import type { Position } from 'css-box-model'; +import type { DraggableId, MovementMode } from '../../types'; +import type { Store } from '../../state/store-types'; +import type { SensorHookArgs, SensorHook } from './sensor-types'; import getClosestDragHandle from './get-closest-drag-handle'; +import canStartDrag from '../../state/can-start-drag'; +import { + move as moveAction, + moveByWindowScroll as windowScrollAction, + moveUp as moveUpAction, + moveRight as moveRightAction, + moveDown as moveDownAction, + moveLeft as moveLeftAction, + drop as dropAction, + lift as liftAction, + type LiftArgs, +} from '../../state/action-creators'; +import getWindowScroll from '../window/get-window-scroll'; +import useMouseSensor from './sensors/use-mouse-sensor'; -// $ExpectError - cannot find type -const listenerOptions: AddEventListenerOptions = { - passive: false, - capture: true, -}; +let isCapturing: boolean = false; +function onCaptureStart() { + invariant(!isCapturing, 'Cannot start capturing when already capturing'); + isCapturing = true; +} + +function onCaptureEnd() { + invariant(isCapturing, 'Cannot end capturing when not capturing'); + isCapturing = false; +} export default function useSensorMarshal( contextId: string, - canLift: (id: DraggableId) => boolean, + store: Store, + // TODO: expose ability to create own sensor :O + useSensorHooks?: SensorHook[] = [useMouseSensor], ) { - const onMouseDown = useCallback( - (event: MouseEvent) => { + const canStartCapturing = useCallback( + function canStartCapturing(id: DraggableId) { + // Something else is capturing + if (isCapturing) { + return false; + } + + // Application is allowing a drag to start + return canStartDrag(store.getState(), id); + }, + [store], + ); + + const canStartCapturingFromEvent = useCallback( + function canStartCapturingFromEvent(event: Event): boolean { if (event.defaultPrevented) { console.log('already handled'); - return; + return false; } const target: EventTarget = event.target; if (!(target instanceof HTMLElement)) { console.log('target is not a html element'); - return; + return false; } const id: ?DraggableId = getClosestDragHandle(contextId, target); if (id == null) { - return; - } - - if (!canLift(id)) { - return; + return false; } - // TODO: interactive element check - // if(is) - console.log('start drag of', id); - event.preventDefault(); + return canStartCapturing(id); }, - [canLift, contextId], + [canStartCapturing, contextId], + ); + + const args: SensorHookArgs = useMemo( + () => ({ + // Capturing + canStartCapturing, + canStartCapturingFromEvent, + onCaptureStart, + onCaptureEnd, + + // Movement + onLift: (options: LiftArgs) => store.dispatch(liftAction(options)), + onMove: (clientSelection: Position) => + store.dispatch(moveAction({ client: clientSelection })), + onWindowScroll: () => + store.dispatch( + windowScrollAction({ + newScroll: getWindowScroll(), + }), + ), + onMoveUp: () => store.dispatch(moveUpAction()), + onMoveDown: () => store.dispatch(moveDownAction()), + onMoveRight: () => store.dispatch(moveRightAction()), + onMoveLeft: () => store.dispatch(moveLeftAction()), + onDrop: () => store.dispatch(dropAction({ reason: 'DROP' })), + onCancel: () => store.dispatch(dropAction({ reason: 'CANCEL' })), + }), + [canStartCapturing, canStartCapturingFromEvent, store], ); - useEffect(() => { - window.addEventListener('mousedown', onMouseDown, listenerOptions); + // TODO: validate length of sensor hooks has not changed from mount - return () => { - window.removeEventListener('mousedown', onMouseDown, listenerOptions); - }; - }, [onMouseDown]); + // Bad ass + // eslint-disable-next-line react-hooks/rules-of-hooks + useSensorHooks.forEach((useSensor: SensorHook) => useSensor(args)); } From f26e0836600fdbfe448313c3dee1efd768f13944 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Wed, 1 May 2019 16:29:17 +1000 Subject: [PATCH 004/308] wip --- src/view/use-sensor-marshal/sensor-types.js | 2 + .../sensors/use-mouse-sensor.js | 89 +++++++++++++++---- .../sensors/util/bind-events.js | 34 +++++++ .../sensors/util/event-types.js | 12 +++ 4 files changed, 121 insertions(+), 16 deletions(-) create mode 100644 src/view/use-sensor-marshal/sensors/util/bind-events.js create mode 100644 src/view/use-sensor-marshal/sensors/util/event-types.js diff --git a/src/view/use-sensor-marshal/sensor-types.js b/src/view/use-sensor-marshal/sensor-types.js index 9c7b183fb3..99ede98344 100644 --- a/src/view/use-sensor-marshal/sensor-types.js +++ b/src/view/use-sensor-marshal/sensor-types.js @@ -9,6 +9,8 @@ export type SensorHookArgs = {| onCaptureStart: (abort: () => void) => void, onCaptureEnd: () => void, + // Can only call after capturing has started + // Drag movement onLift: ({| id: DraggableId, diff --git a/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js b/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js index da2b7dcfdd..a1ba3a189f 100644 --- a/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js +++ b/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js @@ -1,28 +1,85 @@ // @flow -import { useEffect } from 'react'; -import { useCallback } from 'use-memo-one'; +import type { Position } from 'css-box-model'; +import { useEffect, useRef } from 'react'; +import { useCallback, useMemo } from 'use-memo-one'; import type { SensorHookArgs } from '../sensor-types'; +import type { EventBinding, EventOptions } from './util/event-types'; +import { bindEvents, unbindEvents } from './util/bind-events'; +import createScheduler from '../../use-drag-handle/util/create-scheduler'; -const listenerOptions = { - passive: false, - capture: true, -}; +// https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button +const primaryButton: number = 0; +function noop() {} export default function useMouseSensor(args: SensorHookArgs) { - const onMouseDown = useCallback( - function onMouseDown(event: MouseEvent) { - if (!args.canStartCapturingFromEvent(event)) { - return; - } - console.log('can start dragging from event'); + const { canStartCapturingFromEvent, onCaptureStart } = args; + const unbindWindowEventsRef = useRef<() => void>(noop); + + const stop = useCallback(() => {}, []); + + const startPendingDrag = useCallback( + function startPendingDrag(point: Position) { + console.log('start pending drag'); + onCaptureStart(stop); }, - [args], + [onCaptureStart, stop], + ); + + const startCaptureBinding: EventBinding = useMemo( + () => ({ + eventName: 'mousedown', + fn: function onMouseDown(event: MouseEvent) { + // only starting a drag if dragging with the primary mouse button + if (event.button !== primaryButton) { + return; + } + + // Do not start a drag if any modifier key is pressed + if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) { + return; + } + + if (!canStartCapturingFromEvent(event)) { + return; + } + + event.preventDefault(); + + const point: Position = { + x: event.clientX, + y: event.clientY, + }; + + // unbind this listener + unbindWindowEventsRef.current(); + + startPendingDrag(point); + }, + }), + [canStartCapturingFromEvent, startPendingDrag], + ); + + const listenForCapture = useCallback( + function tryStartCapture() { + const options: EventOptions = { + passive: false, + capture: true, + }; + + bindEvents(window, [startCaptureBinding], options); + // setup unbind + unbindWindowEventsRef.current = () => + unbindEvents(window, [startCaptureBinding], options); + }, + [startCaptureBinding], ); useEffect(() => { - window.addEventListener('mousedown', onMouseDown, listenerOptions); + listenForCapture(); + + // kill any pending window events when unmounting return () => { - window.removeEventListener('mousedown', onMouseDown, listenerOptions); + unbindWindowEventsRef.current(); }; - }, [onMouseDown]); + }, [listenForCapture]); } diff --git a/src/view/use-sensor-marshal/sensors/util/bind-events.js b/src/view/use-sensor-marshal/sensors/util/bind-events.js new file mode 100644 index 0000000000..6530914aae --- /dev/null +++ b/src/view/use-sensor-marshal/sensors/util/bind-events.js @@ -0,0 +1,34 @@ +// @flow +import type { EventBinding, EventOptions } from './event-types'; + +const getOptions = ( + shared?: EventOptions, + fromBinding: ?EventOptions, +): EventOptions => ({ + ...shared, + ...fromBinding, +}); + +export const bindEvents = ( + el: HTMLElement, + bindings: EventBinding[], + sharedOptions?: EventOptions, +) => { + bindings.forEach((binding: EventBinding) => { + const options: Object = getOptions(sharedOptions, binding.options); + + el.addEventListener(binding.eventName, binding.fn, options); + }); +}; + +export const unbindEvents = ( + el: HTMLElement, + bindings: EventBinding[], + sharedOptions?: EventOptions, +) => { + bindings.forEach((binding: EventBinding) => { + const options: Object = getOptions(sharedOptions, binding.options); + + el.removeEventListener(binding.eventName, binding.fn, options); + }); +}; diff --git a/src/view/use-sensor-marshal/sensors/util/event-types.js b/src/view/use-sensor-marshal/sensors/util/event-types.js new file mode 100644 index 0000000000..42e68d31c7 --- /dev/null +++ b/src/view/use-sensor-marshal/sensors/util/event-types.js @@ -0,0 +1,12 @@ +// @flow + +export type EventOptions = {| + passive?: boolean, + capture?: boolean, +|}; + +export type EventBinding = {| + eventName: string, + fn: Function, + options?: EventOptions, +|}; From b018d373c362dba97c99084467921a582c7f9287 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Wed, 1 May 2019 17:20:21 +1000 Subject: [PATCH 005/308] wip --- src/view/use-sensor-marshal/sensor-types.js | 33 ++++- .../sensors/use-mouse-sensor.js | 23 ++-- .../use-sensor-marshal/use-sensor-marshal.js | 128 +++++++++++++----- 3 files changed, 134 insertions(+), 50 deletions(-) diff --git a/src/view/use-sensor-marshal/sensor-types.js b/src/view/use-sensor-marshal/sensor-types.js index 99ede98344..1631b7ec45 100644 --- a/src/view/use-sensor-marshal/sensor-types.js +++ b/src/view/use-sensor-marshal/sensor-types.js @@ -2,7 +2,7 @@ import type { Position } from 'css-box-model'; import type { MovementMode, DraggableId } from '../../types'; -export type SensorHookArgs = {| +export type SensorHookArgsOld = {| // Capture lifecycle canStartCapturing: (id: DraggableId) => boolean, canStartCapturingFromEvent: (event: Event) => boolean, @@ -27,4 +27,33 @@ export type SensorHookArgs = {| onCancel: () => mixed, |}; -export type SensorHook = (args: SensorHookArgs) => void; +type IdlePhase = {| + type: 'IDLE', + callbacks: {| + tryStartCapturing: (event: Event) => boolean, + |}, +|}; + +type CapturingPhase = {| + type: 'CAPTURING', + callbacks: {| + getDragHandleRef: () => HTMLElement, + getDraggableRef: () => HTMLElement, + onLift: ({| + clientSelection: Position, + movementMode: MovementMode, + |}) => void, + onMove: (point: Position) => void, + onWindowScroll: () => void, + onMoveUp: () => void, + onMoveDown: () => void, + onMoveRight: () => void, + onMoveLeft: () => void, + onDrop: () => void, + onCancel: () => void, + |}, +|}; + +export type Phase = IdlePhase | CapturingPhase; + +export type SensorHook = (getPhase: () => Phase) => void; diff --git a/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js b/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js index a1ba3a189f..2cbbd05e56 100644 --- a/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js +++ b/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js @@ -1,8 +1,9 @@ // @flow -import type { Position } from 'css-box-model'; +import invariant from 'tiny-invariant'; import { useEffect, useRef } from 'react'; import { useCallback, useMemo } from 'use-memo-one'; -import type { SensorHookArgs } from '../sensor-types'; +import type { Position } from 'css-box-model'; +import type { Phase } from '../sensor-types'; import type { EventBinding, EventOptions } from './util/event-types'; import { bindEvents, unbindEvents } from './util/bind-events'; import createScheduler from '../../use-drag-handle/util/create-scheduler'; @@ -11,18 +12,14 @@ import createScheduler from '../../use-drag-handle/util/create-scheduler'; const primaryButton: number = 0; function noop() {} -export default function useMouseSensor(args: SensorHookArgs) { - const { canStartCapturingFromEvent, onCaptureStart } = args; +export default function useMouseSensor(getPhase: () => Phase) { const unbindWindowEventsRef = useRef<() => void>(noop); const stop = useCallback(() => {}, []); const startPendingDrag = useCallback( - function startPendingDrag(point: Position) { - console.log('start pending drag'); - onCaptureStart(stop); - }, - [onCaptureStart, stop], + function startPendingDrag(point: Position) {}, + [], ); const startCaptureBinding: EventBinding = useMemo( @@ -38,8 +35,12 @@ export default function useMouseSensor(args: SensorHookArgs) { if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) { return; } + const phase: Phase = getPhase(); + + const isCapturing: boolean = + phase.type === 'IDLE' && phase.callbacks.tryStartCapturing(event); - if (!canStartCapturingFromEvent(event)) { + if (!isCapturing) { return; } @@ -56,7 +57,7 @@ export default function useMouseSensor(args: SensorHookArgs) { startPendingDrag(point); }, }), - [canStartCapturingFromEvent, startPendingDrag], + [getPhase, startPendingDrag], ); const listenForCapture = useCallback( diff --git a/src/view/use-sensor-marshal/use-sensor-marshal.js b/src/view/use-sensor-marshal/use-sensor-marshal.js index 6da529c526..ddf25f5c82 100644 --- a/src/view/use-sensor-marshal/use-sensor-marshal.js +++ b/src/view/use-sensor-marshal/use-sensor-marshal.js @@ -5,7 +5,7 @@ import { useCallback, useMemo } from 'use-memo-one'; import type { Position } from 'css-box-model'; import type { DraggableId, MovementMode } from '../../types'; import type { Store } from '../../state/store-types'; -import type { SensorHookArgs, SensorHook } from './sensor-types'; +import type { Phase, SensorHook } from './sensor-types'; import getClosestDragHandle from './get-closest-drag-handle'; import canStartDrag from '../../state/can-start-drag'; import { @@ -22,15 +22,14 @@ import { import getWindowScroll from '../window/get-window-scroll'; import useMouseSensor from './sensors/use-mouse-sensor'; -let isCapturing: boolean = false; -function onCaptureStart() { - invariant(!isCapturing, 'Cannot start capturing when already capturing'); - isCapturing = true; +let capturingFor: ?DraggableId = null; +function startCapture(id: DraggableId) { + invariant(!capturingFor, 'Cannot start capturing when already capturing'); + capturingFor = id; } - -function onCaptureEnd() { - invariant(isCapturing, 'Cannot end capturing when not capturing'); - isCapturing = false; +function stopCapture() { + invariant(capturingFor, 'Cannot stop capturing when not already capturing'); + capturingFor = null; } export default function useSensorMarshal( @@ -42,7 +41,7 @@ export default function useSensorMarshal( const canStartCapturing = useCallback( function canStartCapturing(id: DraggableId) { // Something else is capturing - if (isCapturing) { + if (capturingFor != null) { return false; } @@ -76,37 +75,92 @@ export default function useSensorMarshal( [canStartCapturing, contextId], ); - const args: SensorHookArgs = useMemo( - () => ({ - // Capturing - canStartCapturing, - canStartCapturingFromEvent, - onCaptureStart, - onCaptureEnd, - - // Movement - onLift: (options: LiftArgs) => store.dispatch(liftAction(options)), - onMove: (clientSelection: Position) => - store.dispatch(moveAction({ client: clientSelection })), - onWindowScroll: () => - store.dispatch( - windowScrollAction({ - newScroll: getWindowScroll(), - }), - ), - onMoveUp: () => store.dispatch(moveUpAction()), - onMoveDown: () => store.dispatch(moveDownAction()), - onMoveRight: () => store.dispatch(moveRightAction()), - onMoveLeft: () => store.dispatch(moveLeftAction()), - onDrop: () => store.dispatch(dropAction({ reason: 'DROP' })), - onCancel: () => store.dispatch(dropAction({ reason: 'CANCEL' })), - }), - [canStartCapturing, canStartCapturingFromEvent, store], + const tryStartCapturing = useCallback( + function tryStartCapturing(event: Event): boolean { + if (capturingFor != null) { + return false; + } + + if (event.defaultPrevented) { + return false; + } + + const target: EventTarget = event.target; + if (!(target instanceof HTMLElement)) { + return false; + } + + const id: ?DraggableId = getClosestDragHandle(contextId, target); + + if (id == null) { + return false; + } + + if (!canStartDrag(store.getState(), id)) { + return false; + } + + startCapture(id); + return true; + }, + [contextId, store], + ); + + const getPhase = useCallback( + function getPhase(): Phase { + if (capturingFor == null) { + return { + type: 'IDLE', + callbacks: { + tryStartCapturing, + }, + }; + } + + return { + type: 'CAPTURING', + callbacks: { + getDragHandleRef: () => null, + getDraggableRef: () => null, + onLift: (options: LiftArgs) => store.dispatch(liftAction(options)), + onMove: (clientSelection: Position) => + store.dispatch(moveAction({ client: clientSelection })), + onWindowScroll: () => { + store.dispatch( + windowScrollAction({ + newScroll: getWindowScroll(), + }), + ); + }, + onMoveUp: () => { + store.dispatch(moveUpAction()); + }, + onMoveDown: () => { + store.dispatch(moveDownAction()); + }, + onMoveRight: () => { + store.dispatch(moveRightAction()); + }, + onMoveLeft: () => { + store.dispatch(moveLeftAction()); + }, + onDrop: () => { + stopCapture(); + store.dispatch(dropAction({ reason: 'DROP' })); + }, + onCancel: () => { + stopCapture(); + store.dispatch(dropAction({ reason: 'CANCEL' })); + }, + }, + }; + }, + [store, tryStartCapturing], ); // TODO: validate length of sensor hooks has not changed from mount // Bad ass // eslint-disable-next-line react-hooks/rules-of-hooks - useSensorHooks.forEach((useSensor: SensorHook) => useSensor(args)); + useSensorHooks.forEach((useSensor: SensorHook) => useSensor(getPhase)); } From 5776a5d357681175a282203945f73222c09f52a8 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Thu, 2 May 2019 13:52:08 +1000 Subject: [PATCH 006/308] yummmmm --- src/view/use-sensor-marshal/sensor-types.js | 47 ++--- .../sensors/use-mouse-sensor.js | 24 +-- .../use-sensor-marshal/use-sensor-marshal.js | 171 ++++++++---------- 3 files changed, 106 insertions(+), 136 deletions(-) diff --git a/src/view/use-sensor-marshal/sensor-types.js b/src/view/use-sensor-marshal/sensor-types.js index 1631b7ec45..c80a7db92c 100644 --- a/src/view/use-sensor-marshal/sensor-types.js +++ b/src/view/use-sensor-marshal/sensor-types.js @@ -27,33 +27,24 @@ export type SensorHookArgsOld = {| onCancel: () => mixed, |}; -type IdlePhase = {| - type: 'IDLE', - callbacks: {| - tryStartCapturing: (event: Event) => boolean, - |}, -|}; - -type CapturingPhase = {| - type: 'CAPTURING', - callbacks: {| - getDragHandleRef: () => HTMLElement, - getDraggableRef: () => HTMLElement, - onLift: ({| - clientSelection: Position, - movementMode: MovementMode, - |}) => void, - onMove: (point: Position) => void, - onWindowScroll: () => void, - onMoveUp: () => void, - onMoveDown: () => void, - onMoveRight: () => void, - onMoveLeft: () => void, - onDrop: () => void, - onCancel: () => void, - |}, +export type MovementCallbacks = {| + // getDragHandleRef: () => HTMLElement, + // getDraggableRef: () => HTMLElement, + onLift: ({ + clientSelection: Position, + movementMode: MovementMode, + }) => void, + onMove: (point: Position) => void, + onWindowScroll: () => void, + onMoveUp: () => void, + onMoveDown: () => void, + onMoveRight: () => void, + onMoveLeft: () => void, + onDrop: () => void, + onCancel: () => void, + onAbort: () => void, |}; -export type Phase = IdlePhase | CapturingPhase; - -export type SensorHook = (getPhase: () => Phase) => void; +export type SensorHook = ( + tryStartCapturing: (event: Event) => ?MovementCallbacks, +) => void; diff --git a/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js b/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js index 2cbbd05e56..0ad272bbea 100644 --- a/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js +++ b/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js @@ -3,7 +3,7 @@ import invariant from 'tiny-invariant'; import { useEffect, useRef } from 'react'; import { useCallback, useMemo } from 'use-memo-one'; import type { Position } from 'css-box-model'; -import type { Phase } from '../sensor-types'; +import type { MovementCallbacks } from '../sensor-types'; import type { EventBinding, EventOptions } from './util/event-types'; import { bindEvents, unbindEvents } from './util/bind-events'; import createScheduler from '../../use-drag-handle/util/create-scheduler'; @@ -12,15 +12,18 @@ import createScheduler from '../../use-drag-handle/util/create-scheduler'; const primaryButton: number = 0; function noop() {} -export default function useMouseSensor(getPhase: () => Phase) { +export default function useMouseSensor( + tryStartCapturing: (event: Event) => ?MovementCallbacks, +) { const unbindWindowEventsRef = useRef<() => void>(noop); + const movementRef = useRef(null); const stop = useCallback(() => {}, []); - const startPendingDrag = useCallback( - function startPendingDrag(point: Position) {}, - [], - ); + const startPendingDrag = useCallback(function startPendingDrag( + point: Position, + ) {}, + []); const startCaptureBinding: EventBinding = useMemo( () => ({ @@ -35,12 +38,10 @@ export default function useMouseSensor(getPhase: () => Phase) { if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) { return; } - const phase: Phase = getPhase(); - const isCapturing: boolean = - phase.type === 'IDLE' && phase.callbacks.tryStartCapturing(event); + const callbacks: ?MovementCallbacks = tryStartCapturing(event); - if (!isCapturing) { + if (!callbacks) { return; } @@ -54,10 +55,11 @@ export default function useMouseSensor(getPhase: () => Phase) { // unbind this listener unbindWindowEventsRef.current(); + movementRef.current = callbacks; startPendingDrag(point); }, }), - [getPhase, startPendingDrag], + [startPendingDrag, tryStartCapturing], ); const listenForCapture = useCallback( diff --git a/src/view/use-sensor-marshal/use-sensor-marshal.js b/src/view/use-sensor-marshal/use-sensor-marshal.js index ddf25f5c82..e2420a8d3b 100644 --- a/src/view/use-sensor-marshal/use-sensor-marshal.js +++ b/src/view/use-sensor-marshal/use-sensor-marshal.js @@ -1,12 +1,15 @@ // @flow import invariant from 'tiny-invariant'; import { useEffect } from 'react'; +import rafSchd from 'raf-schd'; import { useCallback, useMemo } from 'use-memo-one'; import type { Position } from 'css-box-model'; import type { DraggableId, MovementMode } from '../../types'; import type { Store } from '../../state/store-types'; -import type { Phase, SensorHook } from './sensor-types'; -import getClosestDragHandle from './get-closest-drag-handle'; +import type { MovementCallbacks, SensorHook } from './sensor-types'; +import getClosestDragHandle, { + getDraggableId, +} from './get-closest-drag-handle'; import canStartDrag from '../../state/can-start-drag'; import { move as moveAction, @@ -31,136 +34,110 @@ function stopCapture() { invariant(capturingFor, 'Cannot stop capturing when not already capturing'); capturingFor = null; } - export default function useSensorMarshal( contextId: string, store: Store, // TODO: expose ability to create own sensor :O useSensorHooks?: SensorHook[] = [useMouseSensor], ) { - const canStartCapturing = useCallback( - function canStartCapturing(id: DraggableId) { - // Something else is capturing - if (capturingFor != null) { - return false; - } - - // Application is allowing a drag to start - return canStartDrag(store.getState(), id); - }, - [store], - ); - - const canStartCapturingFromEvent = useCallback( - function canStartCapturingFromEvent(event: Event): boolean { - if (event.defaultPrevented) { - console.log('already handled'); - return false; - } - - const target: EventTarget = event.target; - if (!(target instanceof HTMLElement)) { - console.log('target is not a html element'); - return false; - } - - const id: ?DraggableId = getClosestDragHandle(contextId, target); - - if (id == null) { - return false; - } - - return canStartCapturing(id); - }, - [canStartCapturing, contextId], - ); - const tryStartCapturing = useCallback( - function tryStartCapturing(event: Event): boolean { + function tryStartCapturing(event: Event): ?MovementCallbacks { if (capturingFor != null) { - return false; + return null; } if (event.defaultPrevented) { - return false; + return null; } const target: EventTarget = event.target; if (!(target instanceof HTMLElement)) { - return false; + return null; } const id: ?DraggableId = getClosestDragHandle(contextId, target); if (id == null) { - return false; + return null; } if (!canStartDrag(store.getState(), id)) { - return false; + return null; } startCapture(id); - return true; - }, - [contextId, store], - ); - const getPhase = useCallback( - function getPhase(): Phase { - if (capturingFor == null) { - return { - type: 'IDLE', - callbacks: { - tryStartCapturing, - }, - }; - } + const onMove = rafSchd((clientSelection: Position) => { + store.dispatch(moveAction({ client: clientSelection })); + }); + const onWindowScroll = rafSchd(() => { + store.dispatch( + windowScrollAction({ + newScroll: getWindowScroll(), + }), + ); + }); + const onMoveUp = rafSchd(() => { + store.dispatch(moveUpAction()); + }); + const onMoveDown = rafSchd(() => { + store.dispatch(moveDownAction()); + }); + const onMoveRight = rafSchd(() => { + store.dispatch(moveRightAction()); + }); + const onMoveLeft = rafSchd(() => { + store.dispatch(moveLeftAction()); + }); + const finish = () => { + // stopping capture + stopCapture(); + + // cancel any pending request animation frames + onMove.cancel(); + onWindowScroll.cancel(); + onMoveUp.cancel(); + onMoveRight.cancel(); + onMoveDown.cancel(); + onMoveLeft.cancel(); + }; return { - type: 'CAPTURING', - callbacks: { - getDragHandleRef: () => null, - getDraggableRef: () => null, - onLift: (options: LiftArgs) => store.dispatch(liftAction(options)), - onMove: (clientSelection: Position) => - store.dispatch(moveAction({ client: clientSelection })), - onWindowScroll: () => { - store.dispatch( - windowScrollAction({ - newScroll: getWindowScroll(), - }), - ); - }, - onMoveUp: () => { - store.dispatch(moveUpAction()); - }, - onMoveDown: () => { - store.dispatch(moveDownAction()); - }, - onMoveRight: () => { - store.dispatch(moveRightAction()); - }, - onMoveLeft: () => { - store.dispatch(moveLeftAction()); - }, - onDrop: () => { - stopCapture(); - store.dispatch(dropAction({ reason: 'DROP' })); - }, - onCancel: () => { - stopCapture(); - store.dispatch(dropAction({ reason: 'CANCEL' })); - }, + onLift: (options: { + clientSelection: Position, + movementMode: MovementMode, + }) => { + store.dispatch( + liftAction({ + ...options, + id, + }), + ); }, + onMove, + onWindowScroll, + onMoveUp, + onMoveDown, + onMoveRight, + onMoveLeft, + onDrop: () => { + finish(); + store.dispatch(dropAction({ reason: 'DROP' })); + }, + onCancel: () => { + finish(); + store.dispatch(dropAction({ reason: 'CANCEL' })); + }, + onAbort: finish, }; }, - [store, tryStartCapturing], + [contextId, store], ); // TODO: validate length of sensor hooks has not changed from mount // Bad ass - // eslint-disable-next-line react-hooks/rules-of-hooks - useSensorHooks.forEach((useSensor: SensorHook) => useSensor(getPhase)); + for (let i = 0; i < useSensorHooks.length; i++) { + useSensorHooks[i](tryStartCapturing); + } } From 49b9e39f2e1aab15940a0ce7b9da4fed0b54303e Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Thu, 2 May 2019 19:25:38 +1000 Subject: [PATCH 007/308] workin --- .../sensor/use-mouse-sensor.js | 3 +- .../sensors/use-mouse-sensor.js | 271 +++++++++++++++++- .../is-sloppy-click-threshold-exceeded.js | 9 + .../util/prevent-standard-key-events.js | 19 ++ .../supported-page-visibility-event-name.js | 29 ++ .../use-sensor-marshal/use-sensor-marshal.js | 182 ++++++------ .../use-validate-sensor-hooks.js | 17 ++ 7 files changed, 430 insertions(+), 100 deletions(-) create mode 100644 src/view/use-sensor-marshal/sensors/util/is-sloppy-click-threshold-exceeded.js create mode 100644 src/view/use-sensor-marshal/sensors/util/prevent-standard-key-events.js create mode 100644 src/view/use-sensor-marshal/sensors/util/supported-page-visibility-event-name.js create mode 100644 src/view/use-sensor-marshal/use-validate-sensor-hooks.js diff --git a/src/view/use-drag-handle/sensor/use-mouse-sensor.js b/src/view/use-drag-handle/sensor/use-mouse-sensor.js index 419286730f..7904531c14 100644 --- a/src/view/use-drag-handle/sensor/use-mouse-sensor.js +++ b/src/view/use-drag-handle/sensor/use-mouse-sensor.js @@ -319,8 +319,9 @@ export default function useMouseSensor(args: Args): OnMouseDown { const onMouseDown = useCallback( (event: MouseEvent) => { + return; if (mouseDownMarshal.isHandled()) { - return; + } invariant( diff --git a/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js b/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js index 0ad272bbea..227b9e0d90 100644 --- a/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js +++ b/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js @@ -6,24 +6,210 @@ import type { Position } from 'css-box-model'; import type { MovementCallbacks } from '../sensor-types'; import type { EventBinding, EventOptions } from './util/event-types'; import { bindEvents, unbindEvents } from './util/bind-events'; -import createScheduler from '../../use-drag-handle/util/create-scheduler'; +import isSloppyClickThresholdExceeded from './util/is-sloppy-click-threshold-exceeded'; +import * as keyCodes from '../../key-codes'; +import preventStandardKeyEvents from './util/prevent-standard-key-events'; +import supportedPageVisibilityEventName from './util/supported-page-visibility-event-name'; // https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button const primaryButton: number = 0; function noop() {} +type Idle = {| + type: 'IDLE', +|}; + +type Pending = {| + type: 'PENDING', + point: Position, + callbacks: MovementCallbacks, +|}; + +type Dragging = {| + type: 'DRAGGING', + callbacks: MovementCallbacks, +|}; + +type Phase = Idle | Pending | Dragging; + +const idle: Idle = { type: 'IDLE' }; + +function getCaptureBindings( + stop: () => void, + cancel: () => void, + getPhase: () => Phase, + setPhase: (phase: Phase) => void, +): EventBinding[] { + return [ + { + eventName: 'mousemove', + fn: (event: MouseEvent) => { + const { button, clientX, clientY } = event; + if (button !== primaryButton) { + return; + } + + const point: Position = { + x: clientX, + y: clientY, + }; + + const phase: Phase = getPhase(); + + // Already dragging + if (phase.type === 'DRAGGING') { + // preventing default as we are using this event + event.preventDefault(); + phase.callbacks.onMove(point); + return; + } + + // There should be a pending drag at this point + invariant(phase.type === 'PENDING', 'Cannot be IDLE'); + const pending: Position = phase.point; + + // threshold not yet exceeded + if (!isSloppyClickThresholdExceeded(pending, point)) { + return; + } + + // preventing default as we are using this event + event.preventDefault(); + + setPhase({ + type: 'DRAGGING', + callbacks: phase.callbacks, + }); + + phase.callbacks.onLift({ + clientSelection: pending, + movementMode: 'FLUID', + }); + }, + }, + { + eventName: 'mouseup', + fn: (event: MouseEvent) => { + const phase: Phase = getPhase(); + + if (phase.type === 'DRAGGING') { + // preventing default as we are using this event + event.preventDefault(); + phase.callbacks.onDrop(); + } + stop(); + }, + }, + { + eventName: 'mousedown', + fn: (event: MouseEvent) => { + // this can happen during a drag when the user clicks a button + // other than the primary mouse button + if (getPhase().type === 'DRAGGING') { + event.preventDefault(); + } + + cancel(); + }, + }, + { + eventName: 'keydown', + fn: (event: KeyboardEvent) => { + const phase: Phase = getPhase(); + // Abort if any keystrokes while a drag is pending + if (phase.type === 'PENDING') { + stop(); + return; + } + + // cancelling a drag + if (event.keyCode === keyCodes.escape) { + event.preventDefault(); + cancel(); + return; + } + + preventStandardKeyEvents(event); + }, + }, + { + eventName: 'resize', + fn: cancel, + }, + { + eventName: 'scroll', + // ## Passive: true + // Eventual consistency is fine because we use position: fixed on the item + // ## Capture: false + // 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 + // TODO: can result in awkward drop position + options: { passive: true, 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 !== window) { + return; + } + + // stop a pending drag + const phase: Phase = getPhase(); + if (phase.type === 'DRAGGING') { + phase.callbacks.onWindowScroll(); + return; + } + stop(); + }, + }, + // Need to opt out of dragging if the user is a force press + // Only for safari which has decided to introduce its own custom way of doing things + // https://developer.apple.com/library/content/documentation/AppleApplications/Conceptual/SafariJSProgTopics/RespondingtoForceTouchEventsfromJavaScript.html + // { + // eventName: 'webkitmouseforcechanged', + // fn: (event: MouseForceChangedEvent) => { + // if ( + // event.webkitForce == null || + // (MouseEvent: any).WEBKIT_FORCE_AT_FORCE_MOUSE_DOWN == null + // ) { + // warning( + // 'handling a mouse force changed event when it is not supported', + // ); + // return; + // } + + // const forcePressThreshold: number = (MouseEvent: any) + // .WEBKIT_FORCE_AT_FORCE_MOUSE_DOWN; + // const isForcePressing: boolean = + // event.webkitForce >= forcePressThreshold; + + // // New behaviour + // if (!getShouldRespectForcePress()) { + // event.preventDefault(); + // return; + // } + + // if (isForcePressing) { + // // it is considered a indirect cancel so we do not + // // prevent default in any situation. + // cancel(); + // } + // }, + // }, + // Cancel on page visibility change + { + eventName: supportedPageVisibilityEventName, + fn: cancel, + }, + ]; +} + export default function useMouseSensor( tryStartCapturing: (event: Event) => ?MovementCallbacks, ) { + const phaseRef = useRef(idle); const unbindWindowEventsRef = useRef<() => void>(noop); - const movementRef = useRef(null); - - const stop = useCallback(() => {}, []); - - const startPendingDrag = useCallback(function startPendingDrag( - point: Position, - ) {}, - []); const startCaptureBinding: EventBinding = useMemo( () => ({ @@ -54,12 +240,11 @@ export default function useMouseSensor( // unbind this listener unbindWindowEventsRef.current(); - - movementRef.current = callbacks; - startPendingDrag(point); + startPendingDrag(callbacks, point); }, }), - [startPendingDrag, tryStartCapturing], + // eslint-disable-next-line react-hooks/exhaustive-deps + [tryStartCapturing], ); const listenForCapture = useCallback( @@ -77,6 +262,66 @@ export default function useMouseSensor( [startCaptureBinding], ); + const stop = useCallback(() => { + if (phaseRef.current.type === 'IDLE') { + return; + } + + phaseRef.current = idle; + unbindWindowEventsRef.current(); + + listenForCapture(); + // const shouldBlockClick: boolean = isDraggingRef.current; + + // mouseDownMarshal.reset(); + // if (shouldBlockClick) { + // postDragEventPreventer.preventNext(); + // } + }, [listenForCapture]); + + const cancel = useCallback(() => { + const phase: Phase = phaseRef.current; + stop(); + if (phase.type === 'DRAGGING') { + phase.callbacks.onCancel(); + } + }, [stop]); + + const bindCapturingEvents = useCallback( + function bindCapturingEvents() { + const options = { capture: true, passive: false }; + const bindings: EventBinding[] = getCaptureBindings( + stop, + cancel, + () => phaseRef.current, + (phase: Phase) => { + phaseRef.current = phase; + }, + ); + + bindEvents(window, bindings, options); + unbindWindowEventsRef.current = () => + unbindEvents(window, bindings, options); + }, + [cancel, stop], + ); + + const startPendingDrag = useCallback( + function startPendingDrag(callbacks: MovementCallbacks, point: Position) { + invariant( + phaseRef.current.type === 'IDLE', + 'Expected to move from IDLE to PENDING drag', + ); + phaseRef.current = { + type: 'PENDING', + point, + callbacks, + }; + bindCapturingEvents(); + }, + [bindCapturingEvents], + ); + useEffect(() => { listenForCapture(); diff --git a/src/view/use-sensor-marshal/sensors/util/is-sloppy-click-threshold-exceeded.js b/src/view/use-sensor-marshal/sensors/util/is-sloppy-click-threshold-exceeded.js new file mode 100644 index 0000000000..e73f3f2789 --- /dev/null +++ b/src/view/use-sensor-marshal/sensors/util/is-sloppy-click-threshold-exceeded.js @@ -0,0 +1,9 @@ +// @flow +import { type Position } from 'css-box-model'; +// The amount of pixels that need to move before we consider the movement +// a drag rather than a click. +export const sloppyClickThreshold: number = 5; + +export default (original: Position, current: Position): boolean => + Math.abs(current.x - original.x) >= sloppyClickThreshold || + Math.abs(current.y - original.y) >= sloppyClickThreshold; diff --git a/src/view/use-sensor-marshal/sensors/util/prevent-standard-key-events.js b/src/view/use-sensor-marshal/sensors/util/prevent-standard-key-events.js new file mode 100644 index 0000000000..f7865b2e6e --- /dev/null +++ b/src/view/use-sensor-marshal/sensors/util/prevent-standard-key-events.js @@ -0,0 +1,19 @@ +// @flow +import * as keyCodes from '../../../key-codes'; + +type KeyMap = { + [key: number]: true, +}; + +const preventedKeys: KeyMap = { + // submission + [keyCodes.enter]: true, + // tabbing + [keyCodes.tab]: true, +}; + +export default (event: KeyboardEvent) => { + if (preventedKeys[event.keyCode]) { + event.preventDefault(); + } +}; diff --git a/src/view/use-sensor-marshal/sensors/util/supported-page-visibility-event-name.js b/src/view/use-sensor-marshal/sensors/util/supported-page-visibility-event-name.js new file mode 100644 index 0000000000..4f1379968e --- /dev/null +++ b/src/view/use-sensor-marshal/sensors/util/supported-page-visibility-event-name.js @@ -0,0 +1,29 @@ +// @flow +import { find } from '../../../../native-with-fallback'; + +const supportedEventName: string = ((): string => { + const base: string = 'visibilitychange'; + + // Server side rendering + if (typeof document === 'undefined') { + return base; + } + + // See https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API + const candidates: string[] = [ + base, + `ms${base}`, + `webkit${base}`, + `moz${base}`, + `o${base}`, + ]; + + const supported: ?string = find( + candidates, + (eventName: string): boolean => `on${eventName}` in document, + ); + + return supported || base; +})(); + +export default supportedEventName; diff --git a/src/view/use-sensor-marshal/use-sensor-marshal.js b/src/view/use-sensor-marshal/use-sensor-marshal.js index e2420a8d3b..c2e4fecfb2 100644 --- a/src/view/use-sensor-marshal/use-sensor-marshal.js +++ b/src/view/use-sensor-marshal/use-sensor-marshal.js @@ -24,6 +24,7 @@ import { } from '../../state/action-creators'; import getWindowScroll from '../window/get-window-scroll'; import useMouseSensor from './sensors/use-mouse-sensor'; +import useValidateSensorHooks from './use-validate-sensor-hooks'; let capturingFor: ?DraggableId = null; function startCapture(id: DraggableId) { @@ -34,110 +35,119 @@ function stopCapture() { invariant(capturingFor, 'Cannot stop capturing when not already capturing'); capturingFor = null; } -export default function useSensorMarshal( + +function tryStartCapturing( contextId: string, store: Store, - // TODO: expose ability to create own sensor :O - useSensorHooks?: SensorHook[] = [useMouseSensor], -) { - const tryStartCapturing = useCallback( - function tryStartCapturing(event: Event): ?MovementCallbacks { - if (capturingFor != null) { - return null; - } + event: Event, +): ?MovementCallbacks { + if (capturingFor != null) { + return null; + } - if (event.defaultPrevented) { - return null; - } + if (event.defaultPrevented) { + return null; + } - const target: EventTarget = event.target; - if (!(target instanceof HTMLElement)) { - return null; - } + const target: EventTarget = event.target; + if (!(target instanceof HTMLElement)) { + return null; + } - const id: ?DraggableId = getClosestDragHandle(contextId, target); + const id: ?DraggableId = getClosestDragHandle(contextId, target); - if (id == null) { - return null; - } + if (id == null) { + return null; + } - if (!canStartDrag(store.getState(), id)) { - return null; - } + if (!canStartDrag(store.getState(), id)) { + return null; + } - startCapture(id); + startCapture(id); - const onMove = rafSchd((clientSelection: Position) => { - store.dispatch(moveAction({ client: clientSelection })); - }); - const onWindowScroll = rafSchd(() => { - store.dispatch( - windowScrollAction({ - newScroll: getWindowScroll(), - }), - ); - }); - const onMoveUp = rafSchd(() => { - store.dispatch(moveUpAction()); - }); - const onMoveDown = rafSchd(() => { - store.dispatch(moveDownAction()); - }); - const onMoveRight = rafSchd(() => { - store.dispatch(moveRightAction()); - }); - const onMoveLeft = rafSchd(() => { - store.dispatch(moveLeftAction()); - }); - const finish = () => { - // stopping capture - stopCapture(); + const onMove = rafSchd((clientSelection: Position) => { + store.dispatch(moveAction({ client: clientSelection })); + }); + const onWindowScroll = rafSchd(() => { + store.dispatch( + windowScrollAction({ + newScroll: getWindowScroll(), + }), + ); + }); + const onMoveUp = rafSchd(() => { + store.dispatch(moveUpAction()); + }); + const onMoveDown = rafSchd(() => { + store.dispatch(moveDownAction()); + }); + const onMoveRight = rafSchd(() => { + store.dispatch(moveRightAction()); + }); + const onMoveLeft = rafSchd(() => { + store.dispatch(moveLeftAction()); + }); + const finish = () => { + // stopping capture + stopCapture(); - // cancel any pending request animation frames - onMove.cancel(); - onWindowScroll.cancel(); - onMoveUp.cancel(); - onMoveRight.cancel(); - onMoveDown.cancel(); - onMoveLeft.cancel(); - }; + // cancel any pending request animation frames + onMove.cancel(); + onWindowScroll.cancel(); + onMoveUp.cancel(); + onMoveRight.cancel(); + onMoveDown.cancel(); + onMoveLeft.cancel(); + }; - return { - onLift: (options: { - clientSelection: Position, - movementMode: MovementMode, - }) => { - store.dispatch( - liftAction({ - ...options, - id, - }), - ); - }, - onMove, - onWindowScroll, - onMoveUp, - onMoveDown, - onMoveRight, - onMoveLeft, - onDrop: () => { - finish(); - store.dispatch(dropAction({ reason: 'DROP' })); - }, - onCancel: () => { - finish(); - store.dispatch(dropAction({ reason: 'CANCEL' })); - }, - onAbort: finish, - }; + return { + onLift: (options: { + clientSelection: Position, + movementMode: MovementMode, + }) => { + store.dispatch( + liftAction({ + ...options, + id, + }), + ); }, + onMove, + onWindowScroll, + onMoveUp, + onMoveDown, + onMoveRight, + onMoveLeft, + onDrop: () => { + finish(); + store.dispatch(dropAction({ reason: 'DROP' })); + }, + onCancel: () => { + finish(); + store.dispatch(dropAction({ reason: 'CANCEL' })); + }, + onAbort: finish, + }; +} + +export default function useSensorMarshal( + contextId: string, + store: Store, + // TODO: expose ability to create own sensor :O + useSensorHooks?: SensorHook[] = [useMouseSensor], +) { + const tryStartCapture = useCallback( + (event: Event): ?MovementCallbacks => + tryStartCapturing(contextId, store, event), [contextId, store], ); // TODO: validate length of sensor hooks has not changed from mount // Bad ass + useValidateSensorHooks(useSensorHooks); for (let i = 0; i < useSensorHooks.length; i++) { - useSensorHooks[i](tryStartCapturing); + useSensorHooks[i](tryStartCapture); } } diff --git a/src/view/use-sensor-marshal/use-validate-sensor-hooks.js b/src/view/use-sensor-marshal/use-validate-sensor-hooks.js new file mode 100644 index 0000000000..18c76a42de --- /dev/null +++ b/src/view/use-sensor-marshal/use-validate-sensor-hooks.js @@ -0,0 +1,17 @@ +// @flow +import invariant from 'tiny-invariant'; +import { useEffect } from 'react'; +import usePreviousRef from '../use-previous-ref'; + +export default function useValidateSensorHooks(sensorHooks: SensorHook[]) { + const previousRef = usePreviousRef(sensorHooks); + + useEffect(() => { + if (process.env.NODE_ENV !== 'production') { + invariant( + previousRef.current.length === sensorHooks.length, + 'Cannot change the amount of sensor hooks after mounting', + ); + } + }); +} From 61983c675941f57f5e946a7da356c3cc86458654 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Thu, 2 May 2019 19:36:22 +1000 Subject: [PATCH 008/308] cleaner bind event api --- .../sensors/use-mouse-sensor.js | 15 +++++++-------- .../sensors/util/bind-events.js | 15 +++++++++------ .../use-validate-sensor-hooks.js | 1 + 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js b/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js index 227b9e0d90..190324649e 100644 --- a/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js +++ b/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js @@ -5,7 +5,7 @@ import { useCallback, useMemo } from 'use-memo-one'; import type { Position } from 'css-box-model'; import type { MovementCallbacks } from '../sensor-types'; import type { EventBinding, EventOptions } from './util/event-types'; -import { bindEvents, unbindEvents } from './util/bind-events'; +import bindEvents from './util/bind-events'; import isSloppyClickThresholdExceeded from './util/is-sloppy-click-threshold-exceeded'; import * as keyCodes from '../../key-codes'; import preventStandardKeyEvents from './util/prevent-standard-key-events'; @@ -254,10 +254,11 @@ export default function useMouseSensor( capture: true, }; - bindEvents(window, [startCaptureBinding], options); - // setup unbind - unbindWindowEventsRef.current = () => - unbindEvents(window, [startCaptureBinding], options); + unbindWindowEventsRef.current = bindEvents( + window, + [startCaptureBinding], + options, + ); }, [startCaptureBinding], ); @@ -299,9 +300,7 @@ export default function useMouseSensor( }, ); - bindEvents(window, bindings, options); - unbindWindowEventsRef.current = () => - unbindEvents(window, bindings, options); + unbindWindowEventsRef.current = bindEvents(window, bindings, options); }, [cancel, stop], ); diff --git a/src/view/use-sensor-marshal/sensors/util/bind-events.js b/src/view/use-sensor-marshal/sensors/util/bind-events.js index 6530914aae..6e7f75517e 100644 --- a/src/view/use-sensor-marshal/sensors/util/bind-events.js +++ b/src/view/use-sensor-marshal/sensors/util/bind-events.js @@ -9,7 +9,7 @@ const getOptions = ( ...fromBinding, }); -export const bindEvents = ( +const unbindEvents = ( el: HTMLElement, bindings: EventBinding[], sharedOptions?: EventOptions, @@ -17,18 +17,21 @@ export const bindEvents = ( bindings.forEach((binding: EventBinding) => { const options: Object = getOptions(sharedOptions, binding.options); - el.addEventListener(binding.eventName, binding.fn, options); + el.removeEventListener(binding.eventName, binding.fn, options); }); }; -export const unbindEvents = ( +export default function bindEvents( el: HTMLElement, bindings: EventBinding[], sharedOptions?: EventOptions, -) => { +): Function { bindings.forEach((binding: EventBinding) => { const options: Object = getOptions(sharedOptions, binding.options); - el.removeEventListener(binding.eventName, binding.fn, options); + el.addEventListener(binding.eventName, binding.fn, options); }); -}; + + // Return a function to unbind events + return () => unbindEvents(el, bindings, sharedOptions); +} diff --git a/src/view/use-sensor-marshal/use-validate-sensor-hooks.js b/src/view/use-sensor-marshal/use-validate-sensor-hooks.js index 18c76a42de..6d0b1aeab0 100644 --- a/src/view/use-sensor-marshal/use-validate-sensor-hooks.js +++ b/src/view/use-sensor-marshal/use-validate-sensor-hooks.js @@ -1,6 +1,7 @@ // @flow import invariant from 'tiny-invariant'; import { useEffect } from 'react'; +import type { SensorHook } from './sensor-types'; import usePreviousRef from '../use-previous-ref'; export default function useValidateSensorHooks(sensorHooks: SensorHook[]) { From f0ab0be3b98086d0baf84595693101ade7a16522 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Fri, 3 May 2019 09:29:37 +1000 Subject: [PATCH 009/308] click blocking --- .../sensor/use-mouse-sensor.js | 7 ++-- src/view/use-sensor-marshal/sensor-types.js | 32 +++-------------- .../sensors/use-mouse-sensor.js | 22 ++++++------ .../use-sensor-marshal/use-sensor-marshal.js | 34 +++++++++++++------ 4 files changed, 46 insertions(+), 49 deletions(-) diff --git a/src/view/use-drag-handle/sensor/use-mouse-sensor.js b/src/view/use-drag-handle/sensor/use-mouse-sensor.js index 7904531c14..140ace4cfb 100644 --- a/src/view/use-drag-handle/sensor/use-mouse-sensor.js +++ b/src/view/use-drag-handle/sensor/use-mouse-sensor.js @@ -319,9 +319,12 @@ export default function useMouseSensor(args: Args): OnMouseDown { const onMouseDown = useCallback( (event: MouseEvent) => { - return; + // skipping for now to allow virtual + if (true) { + return; + } if (mouseDownMarshal.isHandled()) { - + return; } invariant( diff --git a/src/view/use-sensor-marshal/sensor-types.js b/src/view/use-sensor-marshal/sensor-types.js index c80a7db92c..7d76c631cd 100644 --- a/src/view/use-sensor-marshal/sensor-types.js +++ b/src/view/use-sensor-marshal/sensor-types.js @@ -1,32 +1,10 @@ // @flow import type { Position } from 'css-box-model'; -import type { MovementMode, DraggableId } from '../../types'; +import type { MovementMode } from '../../types'; -export type SensorHookArgsOld = {| - // Capture lifecycle - canStartCapturing: (id: DraggableId) => boolean, - canStartCapturingFromEvent: (event: Event) => boolean, - onCaptureStart: (abort: () => void) => void, - onCaptureEnd: () => void, - - // Can only call after capturing has started - - // Drag movement - onLift: ({| - id: DraggableId, - clientSelection: Position, - movementMode: MovementMode, - |}) => mixed, - onMove: (point: Position) => mixed, - onWindowScroll: () => mixed, - onMoveUp: () => mixed, - onMoveDown: () => mixed, - onMoveRight: () => mixed, - onMoveLeft: () => mixed, - onDrop: () => mixed, - onCancel: () => mixed, +export type CaptureEndOptions = {| + shouldBlockNextClick: boolean, |}; - export type MovementCallbacks = {| // getDragHandleRef: () => HTMLElement, // getDraggableRef: () => HTMLElement, @@ -40,8 +18,8 @@ export type MovementCallbacks = {| onMoveDown: () => void, onMoveRight: () => void, onMoveLeft: () => void, - onDrop: () => void, - onCancel: () => void, + onDrop: (args: CaptureEndOptions) => void, + onCancel: (args: CaptureEndOptions) => void, onAbort: () => void, |}; diff --git a/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js b/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js index 190324649e..847997d6a3 100644 --- a/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js +++ b/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js @@ -95,8 +95,9 @@ function getCaptureBindings( if (phase.type === 'DRAGGING') { // preventing default as we are using this event event.preventDefault(); - phase.callbacks.onDrop(); + phase.callbacks.onDrop({ shouldBlockNextClick: true }); } + stop(); }, }, @@ -154,12 +155,12 @@ function getCaptureBindings( return; } - // stop a pending drag const phase: Phase = getPhase(); if (phase.type === 'DRAGGING') { phase.callbacks.onWindowScroll(); return; } + // stop a pending drag stop(); }, }, @@ -240,9 +241,12 @@ export default function useMouseSensor( // unbind this listener unbindWindowEventsRef.current(); + // using this function before it is defined as their is a circular usage pattern + // eslint-disable-next-line no-use-before-define startPendingDrag(callbacks, point); }, }), + // not including startPendingDrag as it is not defined initially // eslint-disable-next-line react-hooks/exhaustive-deps [tryStartCapturing], ); @@ -264,7 +268,8 @@ export default function useMouseSensor( ); const stop = useCallback(() => { - if (phaseRef.current.type === 'IDLE') { + const current: Phase = phaseRef.current; + if (current.type === 'IDLE') { return; } @@ -272,19 +277,16 @@ export default function useMouseSensor( unbindWindowEventsRef.current(); listenForCapture(); - // const shouldBlockClick: boolean = isDraggingRef.current; - - // mouseDownMarshal.reset(); - // if (shouldBlockClick) { - // postDragEventPreventer.preventNext(); - // } }, [listenForCapture]); const cancel = useCallback(() => { const phase: Phase = phaseRef.current; stop(); if (phase.type === 'DRAGGING') { - phase.callbacks.onCancel(); + phase.callbacks.onCancel({ shouldBlockNextClick: true }); + } + if (phase.type === 'PENDING') { + phase.callbacks.onAbort(); } }, [stop]); diff --git a/src/view/use-sensor-marshal/use-sensor-marshal.js b/src/view/use-sensor-marshal/use-sensor-marshal.js index c2e4fecfb2..edd1f2602b 100644 --- a/src/view/use-sensor-marshal/use-sensor-marshal.js +++ b/src/view/use-sensor-marshal/use-sensor-marshal.js @@ -1,12 +1,15 @@ // @flow import invariant from 'tiny-invariant'; -import { useEffect } from 'react'; import rafSchd from 'raf-schd'; import { useCallback, useMemo } from 'use-memo-one'; import type { Position } from 'css-box-model'; import type { DraggableId, MovementMode } from '../../types'; import type { Store } from '../../state/store-types'; -import type { MovementCallbacks, SensorHook } from './sensor-types'; +import type { + MovementCallbacks, + SensorHook, + CaptureEndOptions, +} from './sensor-types'; import getClosestDragHandle, { getDraggableId, } from './get-closest-drag-handle'; @@ -36,6 +39,10 @@ function stopCapture() { capturingFor = null; } +function preventDefault(event: Event) { + event.preventDefault(); +} + function tryStartCapturing( contextId: string, store: Store, @@ -88,10 +95,19 @@ function tryStartCapturing( const onMoveLeft = rafSchd(() => { store.dispatch(moveLeftAction()); }); - const finish = () => { + const finish = ({ shouldBlockNextClick }: CaptureEndOptions) => { // stopping capture stopCapture(); + // block next click if requested + if (shouldBlockNextClick) { + window.addEventListener('click', preventDefault, { + once: true, + passive: false, + capture: true, + }); + } + // cancel any pending request animation frames onMove.cancel(); onWindowScroll.cancel(); @@ -119,15 +135,15 @@ function tryStartCapturing( onMoveDown, onMoveRight, onMoveLeft, - onDrop: () => { - finish(); + onDrop: (args: CaptureEndOptions) => { + finish(args); store.dispatch(dropAction({ reason: 'DROP' })); }, - onCancel: () => { - finish(); + onCancel: (args: CaptureEndOptions) => { + finish(args); store.dispatch(dropAction({ reason: 'CANCEL' })); }, - onAbort: finish, + onAbort: () => finish({ shouldBlockNextClick: false }), }; } @@ -143,8 +159,6 @@ export default function useSensorMarshal( [contextId, store], ); - // TODO: validate length of sensor hooks has not changed from mount - // Bad ass useValidateSensorHooks(useSensorHooks); for (let i = 0; i < useSensorHooks.length; i++) { From 788ac00db257b20a3625bd90cd0637f6584d8f3e Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Fri, 3 May 2019 15:35:05 +1000 Subject: [PATCH 010/308] using the DOM --- src/types.js | 1 + src/view/context/app-context.js | 4 +- src/view/data-attributes.js | 22 +++++- src/view/drag-drop-context/app.jsx | 5 +- .../drag-drop-context/drag-drop-context.jsx | 4 +- src/view/draggable/draggable-types.js | 4 +- src/view/draggable/draggable.jsx | 18 ++++- src/view/placeholder/placeholder.jsx | 3 +- src/view/use-announcer/use-announcer.js | 6 +- src/view/use-drag-handle/drag-handle-types.js | 4 +- src/view/use-drag-handle/use-drag-handle.js | 4 +- .../util/get-drag-handle-ref.js | 4 +- src/view/use-sensor-marshal/closest.js | 50 +++++++++++++ .../get-closest-drag-handle.js | 34 --------- src/view/use-sensor-marshal/get-closest.js | 27 +++++++ .../get-options-from-draggable.js | 35 ++++++++++ .../is-handle-in-interactive-element.js | 59 ++++++++++++++++ src/view/use-sensor-marshal/sensor-types.js | 2 + .../sensors/use-mouse-sensor.js | 70 +++++++++++-------- .../use-sensor-marshal/use-sensor-marshal.js | 65 ++++++++++++----- src/view/use-style-marshal/get-styles.js | 4 +- .../use-style-marshal/style-marshal-types.js | 1 - .../use-style-marshal/use-style-marshal.js | 18 ++--- 23 files changed, 325 insertions(+), 119 deletions(-) create mode 100644 src/view/use-sensor-marshal/closest.js delete mode 100644 src/view/use-sensor-marshal/get-closest-drag-handle.js create mode 100644 src/view/use-sensor-marshal/get-closest.js create mode 100644 src/view/use-sensor-marshal/get-options-from-draggable.js create mode 100644 src/view/use-sensor-marshal/is-handle-in-interactive-element.js diff --git a/src/types.js b/src/types.js index e6f09d82bb..f6b8844eac 100644 --- a/src/types.js +++ b/src/types.js @@ -5,6 +5,7 @@ export type Id = string; export type DraggableId = Id; export type DroppableId = Id; export type TypeId = Id; +export type ContextId = Id; export type DroppableDescriptor = {| id: DroppableId, diff --git a/src/view/context/app-context.js b/src/view/context/app-context.js index 7567cb8c94..cba447d97d 100644 --- a/src/view/context/app-context.js +++ b/src/view/context/app-context.js @@ -1,11 +1,11 @@ // @flow import React from 'react'; -import type { DraggableId } from '../../types'; +import type { DraggableId, ContextId } from '../../types'; import type { DimensionMarshal } from '../../state/dimension-marshal/dimension-marshal-types'; export type AppContextValue = {| marshal: DimensionMarshal, - contextId: string, + contextId: ContextId, canLift: (id: DraggableId) => boolean, isMovementAllowed: () => boolean, |}; diff --git a/src/view/data-attributes.js b/src/view/data-attributes.js index a6c9b7d551..fc26cfe817 100644 --- a/src/view/data-attributes.js +++ b/src/view/data-attributes.js @@ -1,6 +1,22 @@ // @flow -export const prefix: string = 'data-react-beautiful-dnd'; -export const dragHandle: string = `${prefix}-drag-handle`; -export const draggable: string = `${prefix}-draggable`; +export const prefix: string = 'data-rbd'; +export const dragHandle = (() => { + const base = `${prefix}-drag-handle`; + + return { + base, + contextId: `${base}-context-id`, + }; +})(); + +export const draggable = (() => { + const base: string = `${prefix}-draggable`; + return { + base, + contextId: `${base}-context-id`, + id: `${base}-id`, + options: `${base}-options`, + }; +})(); export const droppable: string = `${prefix}-droppable`; export const placeholder: string = `${prefix}-placeholder`; diff --git a/src/view/drag-drop-context/app.jsx b/src/view/drag-drop-context/app.jsx index ddc1660e3c..812e1acf16 100644 --- a/src/view/drag-drop-context/app.jsx +++ b/src/view/drag-drop-context/app.jsx @@ -170,7 +170,10 @@ export default function App(props: Props) { [contextId, dimensionMarshal, getCanLift, getIsMovementAllowed], ); - useSensorMarshal(contextId, store); + useSensorMarshal({ + contextId, + store, + }); // Clean store when unmounting useEffect(() => { diff --git a/src/view/drag-drop-context/drag-drop-context.jsx b/src/view/drag-drop-context/drag-drop-context.jsx index cda318cd37..43b040aac5 100644 --- a/src/view/drag-drop-context/drag-drop-context.jsx +++ b/src/view/drag-drop-context/drag-drop-context.jsx @@ -1,7 +1,7 @@ // @flow import React, { type Node } from 'react'; import { useMemo } from 'use-memo-one'; -import type { Responders } from '../../types'; +import type { Responders, ContextId } from '../../types'; import ErrorBoundary from '../error-boundary'; import App from './app'; @@ -19,7 +19,7 @@ export function resetServerContext() { } export default function DragDropContext(props: Props) { - const contextId: string = useMemo(() => `${instanceCount++}`, []); + const contextId: ContextId = useMemo(() => `${instanceCount++}`, []); // We need the error boundary to be on the outside of App // so that it can catch any errors caused by App diff --git a/src/view/draggable/draggable-types.js b/src/view/draggable/draggable-types.js index 9824b40f78..0d920002b8 100644 --- a/src/view/draggable/draggable-types.js +++ b/src/view/draggable/draggable-types.js @@ -62,7 +62,9 @@ export type DraggableProps = {| // inline style style: ?DraggableStyle, // used for shared global styles - 'data-react-beautiful-dnd-draggable': string, + 'data-rbd-draggable-context-id': string, + 'data-rbd-draggable-id': string, + 'data-rbd-draggable-options': string, // used to know when a transition ends onTransitionEnd: ?(event: TransitionEvent) => void, |}; diff --git a/src/view/draggable/draggable.jsx b/src/view/draggable/draggable.jsx index 110dadfa20..274d9f0a17 100644 --- a/src/view/draggable/draggable.jsx +++ b/src/view/draggable/draggable.jsx @@ -184,7 +184,12 @@ export default function Draggable(props: Props) { const result: Provided = { innerRef: setRef, draggableProps: { - 'data-react-beautiful-dnd-draggable': appContext.contextId, + 'data-rbd-draggable-context-id': appContext.contextId, + 'data-rbd-draggable-id': draggableId, + 'data-rbd-draggable-options': JSON.stringify({ + canDragInteractiveElements, + shouldRespectForcePress, + }), style, onTransitionEnd, }, @@ -192,7 +197,16 @@ export default function Draggable(props: Props) { }; return result; - }, [appContext.contextId, dragHandleProps, mapped, onMoveEnd, setRef]); + }, [ + appContext.contextId, + canDragInteractiveElements, + dragHandleProps, + draggableId, + mapped, + onMoveEnd, + setRef, + shouldRespectForcePress, + ]); return children(provided, mapped.snapshot); } diff --git a/src/view/placeholder/placeholder.jsx b/src/view/placeholder/placeholder.jsx index e9aa206565..1ef6e7d511 100644 --- a/src/view/placeholder/placeholder.jsx +++ b/src/view/placeholder/placeholder.jsx @@ -5,6 +5,7 @@ import type { Spacing } from 'css-box-model'; import type { Placeholder as PlaceholderType, InOutAnimationMode, + ContextId, } from '../../types'; import { transitions } from '../../animation'; import { noSpacing } from '../../state/spacing'; @@ -31,7 +32,7 @@ export type Props = {| onClose: () => void, innerRef?: () => ?HTMLElement, onTransitionEnd: () => void, - contextId: string, + contextId: ContextId, |}; type Size = {| diff --git a/src/view/use-announcer/use-announcer.js b/src/view/use-announcer/use-announcer.js index 951e275099..9ecf14a1af 100644 --- a/src/view/use-announcer/use-announcer.js +++ b/src/view/use-announcer/use-announcer.js @@ -2,7 +2,7 @@ import { useRef, useEffect } from 'react'; import invariant from 'tiny-invariant'; import { useMemo, useCallback } from 'use-memo-one'; -import type { Announce } from '../../types'; +import type { Announce, ContextId } from '../../types'; import { warning } from '../../dev-warning'; import getBodyElement from '../get-body-element'; @@ -21,10 +21,10 @@ const visuallyHidden: Object = { 'clip-path': 'inset(100%)', }; -export const getId = (contextId: string): string => +export const getId = (contextId: ContextId): string => `react-beautiful-dnd-announcement-${contextId}`; -export default function useAnnouncer(contextId: string): Announce { +export default function useAnnouncer(contextId: ContextId): Announce { const id: string = useMemo(() => getId(contextId), [contextId]); const ref = useRef(null); diff --git a/src/view/use-drag-handle/drag-handle-types.js b/src/view/use-drag-handle/drag-handle-types.js index 0a9a72ae5d..3ac0bf271b 100644 --- a/src/view/use-drag-handle/drag-handle-types.js +++ b/src/view/use-drag-handle/drag-handle-types.js @@ -30,9 +30,7 @@ export type DragHandleProps = {| onTouchStart: (event: TouchEvent) => void, // What DragDropContext the drag handle is in - 'data-react-beautiful-dnd-drag-handle': string, - // The DraggableId associated with the drag handle - 'data-react-beautiful-dnd-drag-handle-id': string, + 'data-rbd-drag-handle-context-id': string, // Aria role (nicer screen reader text) 'aria-roledescription': string, diff --git a/src/view/use-drag-handle/use-drag-handle.js b/src/view/use-drag-handle/use-drag-handle.js index 32c5478f66..1ebb637918 100644 --- a/src/view/use-drag-handle/use-drag-handle.js +++ b/src/view/use-drag-handle/use-drag-handle.js @@ -215,8 +215,7 @@ export default function useDragHandle(args: Args): ?DragHandleProps { onFocus, onBlur, tabIndex: 0, - 'data-react-beautiful-dnd-drag-handle': contextId, - 'data-react-beautiful-dnd-drag-handle-id': draggableId, + 'data-rbd-drag-handle-context-id': contextId, // English default. Consumers are welcome to add their own start instruction 'aria-roledescription': 'Draggable item. Press space bar to lift', // Opting out of html5 drag and drops @@ -225,7 +224,6 @@ export default function useDragHandle(args: Args): ?DragHandleProps { }; }, [ contextId, - draggableId, isEnabled, onBlur, onFocus, diff --git a/src/view/use-drag-handle/util/get-drag-handle-ref.js b/src/view/use-drag-handle/util/get-drag-handle-ref.js index 55e70b6ca9..0c89e4f0c7 100644 --- a/src/view/use-drag-handle/util/get-drag-handle-ref.js +++ b/src/view/use-drag-handle/util/get-drag-handle-ref.js @@ -4,7 +4,7 @@ import { dragHandle } from '../../data-attributes'; import isSvgElement from '../../is-type-of-element/is-svg-element'; import isHtmlElement from '../../is-type-of-element/is-html-element'; -const selector: string = `[${dragHandle}]`; +const selector: string = `[${dragHandle.contextId}]`; const throwIfSVG = (el: mixed) => { invariant( @@ -18,7 +18,7 @@ const throwIfSVG = (el: mixed) => { // If called when the component is disabled then the data // attribute will not be present const getDragHandleRef = (draggableRef: HTMLElement): HTMLElement => { - if (draggableRef.hasAttribute(dragHandle)) { + if (draggableRef.hasAttribute(dragHandle.contextId)) { throwIfSVG(draggableRef); return draggableRef; } diff --git a/src/view/use-sensor-marshal/closest.js b/src/view/use-sensor-marshal/closest.js new file mode 100644 index 0000000000..550a8ddb8f --- /dev/null +++ b/src/view/use-sensor-marshal/closest.js @@ -0,0 +1,50 @@ +// @flow +import { find } from '../../native-with-fallback'; + +const supportedMatchesName: string = ((): string => { + const base: string = 'matches'; + + // Server side rendering + if (typeof document === 'undefined') { + return base; + } + + // See https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API + const candidates: string[] = [ + base, + 'msMatchesSelector', + 'webkitMatchesSelector', + ]; + + const value: ?string = find( + candidates, + (name: string): boolean => name in Element.prototype, + ); + + return value || base; +})(); + +function closestPonyfill(el: ?Element, selector: string) { + if (el == null) { + return null; + } + + // Element.prototype.matches is supported in ie11 with a different name + // https://caniuse.com/#feat=matchesselector + // $FlowFixMe - dynamic property + if (el[supportedMatchesName](selector)) { + return el; + } + + // recursively look up the tree + return closestPonyfill(el.parentElement, selector); +} + +export default function closest(el: Element, selector: string): ?Element { + // Using native closest for maximum speed where we can + // if (el.closest) { + // return el.closest(selector); + // } + // ie11: damn you! + return closestPonyfill(el, selector); +} diff --git a/src/view/use-sensor-marshal/get-closest-drag-handle.js b/src/view/use-sensor-marshal/get-closest-drag-handle.js deleted file mode 100644 index a8467b2979..0000000000 --- a/src/view/use-sensor-marshal/get-closest-drag-handle.js +++ /dev/null @@ -1,34 +0,0 @@ -// @flow -import invariant from 'tiny-invariant'; -import { dragHandle } from '../data-attributes'; -import type { DraggableId } from '../../types'; - -function isInContext(contextId, el: Element): boolean { - return el.getAttribute(dragHandle) === contextId; -} - -function isDragHandle(el: Element): boolean { - return el.hasAttribute(dragHandle); -} - -function getDraggableId(handle: Element): DraggableId { - const id: ?DraggableId = handle.getAttribute(`${dragHandle}-id`); - invariant(id, 'expected element to be a drag handle'); - return id; -} - -export default function getClosestDragHandle( - contextId: string, - el: ?Element, -): ?DraggableId { - if (el == null) { - return null; - } - - // not a drag handle or not in the right context - if (!isDragHandle(el) || !isInContext(contextId, el)) { - return getClosestDragHandle(contextId, el.parentElement); - } - - return getDraggableId(el); -} diff --git a/src/view/use-sensor-marshal/get-closest.js b/src/view/use-sensor-marshal/get-closest.js new file mode 100644 index 0000000000..507c63a7fd --- /dev/null +++ b/src/view/use-sensor-marshal/get-closest.js @@ -0,0 +1,27 @@ +// @flow +import invariant from 'tiny-invariant'; +import type { ContextId } from '../../types'; +import * as attributes from '../data-attributes'; +import closest from './closest'; + +export function getClosestDragHandle(contextId: string, el: Element): ?Element { + const attribute: string = attributes.dragHandle.contextId; + const selector: string = `[${attribute}="${contextId}"]`; + const handle: ?Element = closest(el, selector); + + return handle || null; +} + +export function getClosestDraggable( + contextId: ContextId, + handle: Element, +): Element { + // TODO: id might not make a good selector if it has strange characters in it - such as whitespace + // const selector: string = `[${contextIdAttr}="${contextId}"] [${idAttr}="${id}"]`; + const selector: string = `[${attributes.draggable.contextId}="${contextId}"]`; + + const draggable: ?Element = closest(handle, selector); + invariant(draggable, 'expected drag handle to have draggable'); + + return draggable; +} diff --git a/src/view/use-sensor-marshal/get-options-from-draggable.js b/src/view/use-sensor-marshal/get-options-from-draggable.js new file mode 100644 index 0000000000..6bc51e8b98 --- /dev/null +++ b/src/view/use-sensor-marshal/get-options-from-draggable.js @@ -0,0 +1,35 @@ +// @flow +import invariant from 'tiny-invariant'; +import type { DraggableId } from '../../types'; +import { draggable as attr } from '../data-attributes'; + +export type DraggableData = {| + id: DraggableId, + canDragInteractiveElements: boolean, + shouldRespectForcePress: boolean, +|}; + +export default function getDraggableData(draggable: Element): DraggableData { + const id: ?DraggableId = draggable.getAttribute(`${attr.id}`); + invariant(id != null, 'Expected element to be a draggable'); + + const options: ?string = draggable.getAttribute(`${attr.options}`); + invariant(options, 'Expected draggable to have options'); + + const parsed: Object = JSON.parse(options); + + if (process.env.NODE_ENV !== 'production') { + invariant( + Object.keys(parsed).length === 2, + 'Unexpected parsed draggable options', + ); + Object.prototype.hasOwnProperty.call(parsed, 'canDragInteractiveElements'); + Object.prototype.hasOwnProperty.call(parsed, 'shouldRespectForcePress'); + } + + return { + id, + canDragInteractiveElements: parsed.canDragInteractiveElements, + shouldRespectForcePress: parsed.shouldRespectForcePress, + }; +} diff --git a/src/view/use-sensor-marshal/is-handle-in-interactive-element.js b/src/view/use-sensor-marshal/is-handle-in-interactive-element.js new file mode 100644 index 0000000000..ed8346424a --- /dev/null +++ b/src/view/use-sensor-marshal/is-handle-in-interactive-element.js @@ -0,0 +1,59 @@ +// @flow + +export type TagNameMap = { + [tagName: string]: true, +}; + +export const interactiveTagNames: TagNameMap = { + input: true, + button: true, + textarea: true, + select: true, + option: true, + optgroup: true, + video: true, + audio: true, +}; + +function isAnInteractiveElement(parent: Element, current: ?Element) { + if (current == null) { + return false; + } + + // Most interactive elements cannot have children. However, some can such as 'button'. + // See 'Permitted content' on https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button + // Rather than having two different functions we can consolidate our checks into this single + // function to keep things simple. + // There is no harm checking if the parent has an interactive tag name even if it cannot have + // any children. We need to perform this loop anyway to check for the contenteditable attribute + const hasAnInteractiveTag: boolean = Boolean( + interactiveTagNames[current.tagName.toLowerCase()], + ); + + if (hasAnInteractiveTag) { + return true; + } + + // contenteditable="true" or contenteditable="" are valid ways + // of creating a contenteditable container + // https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/contenteditable + const attribute: ?string = current.getAttribute('contenteditable'); + if (attribute === 'true' || attribute === '') { + return true; + } + + // nothing more can be done and no results found + if (current === parent) { + return false; + } + + // recursion to check parent + return isAnInteractiveElement(parent, current.parentElement); +} + +export default function isHandleInInteractiveElement( + draggable: Element, + handle: Element, +): boolean { + return isAnInteractiveElement(draggable, handle); +} diff --git a/src/view/use-sensor-marshal/sensor-types.js b/src/view/use-sensor-marshal/sensor-types.js index 7d76c631cd..0a39a65f26 100644 --- a/src/view/use-sensor-marshal/sensor-types.js +++ b/src/view/use-sensor-marshal/sensor-types.js @@ -5,7 +5,9 @@ import type { MovementMode } from '../../types'; export type CaptureEndOptions = {| shouldBlockNextClick: boolean, |}; + export type MovementCallbacks = {| + shouldRespectForcePress: () => boolean, // getDragHandleRef: () => HTMLElement, // getDraggableRef: () => HTMLElement, onLift: ({ diff --git a/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js b/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js index 847997d6a3..7b2ca21ecf 100644 --- a/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js +++ b/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js @@ -10,11 +10,16 @@ import isSloppyClickThresholdExceeded from './util/is-sloppy-click-threshold-exc import * as keyCodes from '../../key-codes'; import preventStandardKeyEvents from './util/prevent-standard-key-events'; import supportedPageVisibilityEventName from './util/supported-page-visibility-event-name'; +import { warning } from '../../../dev-warning'; // https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button const primaryButton: number = 0; function noop() {} +type MouseForceChangedEvent = MouseEvent & { + webkitForce?: number, +}; + type Idle = {| type: 'IDLE', |}; @@ -167,37 +172,40 @@ function getCaptureBindings( // Need to opt out of dragging if the user is a force press // Only for safari which has decided to introduce its own custom way of doing things // https://developer.apple.com/library/content/documentation/AppleApplications/Conceptual/SafariJSProgTopics/RespondingtoForceTouchEventsfromJavaScript.html - // { - // eventName: 'webkitmouseforcechanged', - // fn: (event: MouseForceChangedEvent) => { - // if ( - // event.webkitForce == null || - // (MouseEvent: any).WEBKIT_FORCE_AT_FORCE_MOUSE_DOWN == null - // ) { - // warning( - // 'handling a mouse force changed event when it is not supported', - // ); - // return; - // } - - // const forcePressThreshold: number = (MouseEvent: any) - // .WEBKIT_FORCE_AT_FORCE_MOUSE_DOWN; - // const isForcePressing: boolean = - // event.webkitForce >= forcePressThreshold; - - // // New behaviour - // if (!getShouldRespectForcePress()) { - // event.preventDefault(); - // return; - // } - - // if (isForcePressing) { - // // it is considered a indirect cancel so we do not - // // prevent default in any situation. - // cancel(); - // } - // }, - // }, + { + eventName: 'webkitmouseforcechanged', + fn: (event: MouseForceChangedEvent) => { + if ( + event.webkitForce == null || + (MouseEvent: any).WEBKIT_FORCE_AT_FORCE_MOUSE_DOWN == null + ) { + warning( + 'handling a mouse force changed event when it is not supported', + ); + return; + } + + const forcePressThreshold: number = (MouseEvent: any) + .WEBKIT_FORCE_AT_FORCE_MOUSE_DOWN; + const isForcePressing: boolean = + event.webkitForce >= forcePressThreshold; + + const phase: Phase = getPhase(); + invariant(phase.type !== 'IDLE', 'Unexpected phase'); + + // might not be respecting force press + if (!phase.callbacks.shouldRespectForcePress()) { + event.preventDefault(); + return; + } + + if (isForcePressing) { + // it is considered a indirect cancel so we do not + // prevent default in any situation. + cancel(); + } + }, + }, // Cancel on page visibility change { eventName: supportedPageVisibilityEventName, diff --git a/src/view/use-sensor-marshal/use-sensor-marshal.js b/src/view/use-sensor-marshal/use-sensor-marshal.js index edd1f2602b..a269140d18 100644 --- a/src/view/use-sensor-marshal/use-sensor-marshal.js +++ b/src/view/use-sensor-marshal/use-sensor-marshal.js @@ -1,18 +1,16 @@ // @flow import invariant from 'tiny-invariant'; import rafSchd from 'raf-schd'; -import { useCallback, useMemo } from 'use-memo-one'; +import { useCallback } from 'use-memo-one'; import type { Position } from 'css-box-model'; -import type { DraggableId, MovementMode } from '../../types'; +import type { ContextId, DraggableId, MovementMode } from '../../types'; import type { Store } from '../../state/store-types'; import type { MovementCallbacks, SensorHook, CaptureEndOptions, } from './sensor-types'; -import getClosestDragHandle, { - getDraggableId, -} from './get-closest-drag-handle'; +import { getClosestDragHandle, getClosestDraggable } from './get-closest'; import canStartDrag from '../../state/can-start-drag'; import { move as moveAction, @@ -23,11 +21,12 @@ import { moveLeft as moveLeftAction, drop as dropAction, lift as liftAction, - type LiftArgs, } from '../../state/action-creators'; import getWindowScroll from '../window/get-window-scroll'; import useMouseSensor from './sensors/use-mouse-sensor'; import useValidateSensorHooks from './use-validate-sensor-hooks'; +import isHandleInInteractiveElement from './is-handle-in-interactive-element'; +import getOptionsFromDraggable from './get-options-from-draggable'; let capturingFor: ?DraggableId = null; function startCapture(id: DraggableId) { @@ -43,11 +42,17 @@ function preventDefault(event: Event) { event.preventDefault(); } -function tryStartCapturing( - contextId: string, +type TryStartCapturingArgs = {| + contextId: ContextId, store: Store, event: Event, -): ?MovementCallbacks { +|}; + +function tryStartCapturing({ + contextId, + store, + event, +}: TryStartCapturingArgs): ?MovementCallbacks { if (capturingFor != null) { return null; } @@ -61,9 +66,24 @@ function tryStartCapturing( return null; } - const id: ?DraggableId = getClosestDragHandle(contextId, target); + const handle: ?Element = getClosestDragHandle(contextId, target); - if (id == null) { + if (handle == null) { + return null; + } + + const draggable: Element = getClosestDraggable(contextId, handle); + const { + id, + shouldRespectForcePress, + canDragInteractiveElements, + } = getOptionsFromDraggable(draggable); + + // do not allow dragging from interactive elements + if ( + !canDragInteractiveElements && + isHandleInInteractiveElement(draggable, handle) + ) { return null; } @@ -102,6 +122,7 @@ function tryStartCapturing( // block next click if requested if (shouldBlockNextClick) { window.addEventListener('click', preventDefault, { + // only blocking a single click once: true, passive: false, capture: true, @@ -118,6 +139,7 @@ function tryStartCapturing( }; return { + shouldRespectForcePress: (): boolean => shouldRespectForcePress, onLift: (options: { clientSelection: Position, movementMode: MovementMode, @@ -147,15 +169,24 @@ function tryStartCapturing( }; } -export default function useSensorMarshal( - contextId: string, +type SensorMarshalArgs = {| + contextId: ContextId, store: Store, - // TODO: expose ability to create own sensor :O - useSensorHooks?: SensorHook[] = [useMouseSensor], -) { + useSensorHooks?: SensorHook[], +|}; + +export default function useSensorMarshal({ + contextId, + store, + useSensorHooks = [useMouseSensor], +}: SensorMarshalArgs) { const tryStartCapture = useCallback( (event: Event): ?MovementCallbacks => - tryStartCapturing(contextId, store, event), + tryStartCapturing({ + contextId, + store, + event, + }), [contextId, store], ); diff --git a/src/view/use-style-marshal/get-styles.js b/src/view/use-style-marshal/get-styles.js index c7157125d2..8f7a10f6a2 100644 --- a/src/view/use-style-marshal/get-styles.js +++ b/src/view/use-style-marshal/get-styles.js @@ -78,7 +78,7 @@ export default (uniqueContext: string): Styles => { cursor: grab; `; return { - selector: getSelector(attributes.dragHandle), + selector: getSelector(attributes.dragHandle.contextId), styles: { always: ` -webkit-touch-callout: none; @@ -106,7 +106,7 @@ export default (uniqueContext: string): Styles => { transition: ${transitions.outOfTheWay}; `; return { - selector: getSelector(attributes.draggable), + selector: getSelector(attributes.draggable.contextId), styles: { dragging: transition, dropAnimating: transition, diff --git a/src/view/use-style-marshal/style-marshal-types.js b/src/view/use-style-marshal/style-marshal-types.js index 16e95c0372..60f44b3d3b 100644 --- a/src/view/use-style-marshal/style-marshal-types.js +++ b/src/view/use-style-marshal/style-marshal-types.js @@ -5,5 +5,4 @@ export type StyleMarshal = {| dragging: () => void, dropping: (reason: DropReason) => void, resting: () => void, - styleContext: string, |}; diff --git a/src/view/use-style-marshal/use-style-marshal.js b/src/view/use-style-marshal/use-style-marshal.js index f9103eea21..c2c9ea2cfd 100644 --- a/src/view/use-style-marshal/use-style-marshal.js +++ b/src/view/use-style-marshal/use-style-marshal.js @@ -4,7 +4,7 @@ import memoizeOne from 'memoize-one'; import { useMemo, useCallback } from 'use-memo-one'; import invariant from 'tiny-invariant'; import type { StyleMarshal } from './style-marshal-types'; -import type { DropReason } from '../../types'; +import type { ContextId, DropReason } from '../../types'; import getStyles, { type Styles } from './get-styles'; import { prefix } from '../data-attributes'; import useLayoutEffect from '../use-isomorphic-layout-effect'; @@ -21,11 +21,8 @@ const createStyleEl = (): HTMLStyleElement => { return el; }; -export default function useStyleMarshal(contextId: string) { - const uniqueContext: string = useMemo(() => `${contextId}`, [contextId]); - const styles: Styles = useMemo(() => getStyles(uniqueContext), [ - uniqueContext, - ]); +export default function useStyleMarshal(contextId: ContextId) { + const styles: Styles = useMemo(() => getStyles(contextId), [contextId]); const alwaysRef = useRef(null); const dynamicRef = useRef(null); @@ -60,8 +57,8 @@ export default function useStyleMarshal(contextId: string) { dynamicRef.current = dynamic; // for easy identification - always.setAttribute(`${prefix}-always`, uniqueContext); - dynamic.setAttribute(`${prefix}-dynamic`, uniqueContext); + always.setAttribute(`${prefix}-always`, contextId); + dynamic.setAttribute(`${prefix}-dynamic`, contextId); // add style tags to head getHead().appendChild(always); @@ -87,7 +84,7 @@ export default function useStyleMarshal(contextId: string) { setDynamicStyle, styles.always, styles.resting, - uniqueContext, + contextId, ]); const dragging = useCallback(() => setDynamicStyle(styles.dragging), [ @@ -117,9 +114,8 @@ export default function useStyleMarshal(contextId: string) { dragging, dropping, resting, - styleContext: uniqueContext, }), - [dragging, dropping, resting, uniqueContext], + [dragging, dropping, resting], ); return marshal; From 441d70fc2f9e53ccad109d565dae5414125ae41a Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Fri, 3 May 2019 15:43:48 +1000 Subject: [PATCH 011/308] makes us stronger --- src/view/data-attributes.js | 9 +++++++-- src/view/droppable/droppable-types.js | 3 ++- src/view/droppable/droppable.jsx | 2 +- src/view/placeholder/placeholder.jsx | 2 +- src/view/use-announcer/use-announcer.js | 2 +- src/view/use-style-marshal/get-styles.js | 7 ++++--- 6 files changed, 16 insertions(+), 9 deletions(-) diff --git a/src/view/data-attributes.js b/src/view/data-attributes.js index fc26cfe817..59c4d97760 100644 --- a/src/view/data-attributes.js +++ b/src/view/data-attributes.js @@ -18,5 +18,10 @@ export const draggable = (() => { options: `${base}-options`, }; })(); -export const droppable: string = `${prefix}-droppable`; -export const placeholder: string = `${prefix}-placeholder`; + +export const droppable = { + contextId: `${prefix}-droppable-context-id`, +}; +export const placeholder = { + contextId: `${prefix}-placeholder-context-id`, +}; diff --git a/src/view/droppable/droppable-types.js b/src/view/droppable/droppable-types.js index b9929bc94b..efbe98d398 100644 --- a/src/view/droppable/droppable-types.js +++ b/src/view/droppable/droppable-types.js @@ -7,12 +7,13 @@ import type { Direction, Placeholder, State, + ContextId, } from '../../types'; import { updateViewportMaxScroll } from '../../state/action-creators'; export type DroppableProps = {| // used for shared global styles - 'data-react-beautiful-dnd-droppable': string, + 'data-rbd-droppable-context-id': ContextId, |}; export type Provided = {| diff --git a/src/view/droppable/droppable.jsx b/src/view/droppable/droppable.jsx index a072b5028b..d6989dcccf 100644 --- a/src/view/droppable/droppable.jsx +++ b/src/view/droppable/droppable.jsx @@ -96,7 +96,7 @@ export default function Droppable(props: Props) { innerRef: setDroppableRef, placeholder, droppableProps: { - 'data-react-beautiful-dnd-droppable': contextId, + 'data-rbd-droppable-context-id': contextId, }, }), [contextId, placeholder, setDroppableRef], diff --git a/src/view/placeholder/placeholder.jsx b/src/view/placeholder/placeholder.jsx index 1ef6e7d511..7fb2abc6ec 100644 --- a/src/view/placeholder/placeholder.jsx +++ b/src/view/placeholder/placeholder.jsx @@ -187,7 +187,7 @@ function Placeholder(props: Props): Node { return React.createElement(props.placeholder.tagName, { style, - 'data-react-beautiful-dnd-placeholder': contextId, + 'data-rbd-placeholder-context-id': contextId, onTransitionEnd: onSizeChangeEnd, ref: props.innerRef, }); diff --git a/src/view/use-announcer/use-announcer.js b/src/view/use-announcer/use-announcer.js index 9ecf14a1af..45c7f892a9 100644 --- a/src/view/use-announcer/use-announcer.js +++ b/src/view/use-announcer/use-announcer.js @@ -22,7 +22,7 @@ const visuallyHidden: Object = { }; export const getId = (contextId: ContextId): string => - `react-beautiful-dnd-announcement-${contextId}`; + `rbd-announcement-${contextId}`; export default function useAnnouncer(contextId: ContextId): Announce { const id: string = useMemo(() => getId(contextId), [contextId]); diff --git a/src/view/use-style-marshal/get-styles.js b/src/view/use-style-marshal/get-styles.js index 8f7a10f6a2..c81562ac34 100644 --- a/src/view/use-style-marshal/get-styles.js +++ b/src/view/use-style-marshal/get-styles.js @@ -1,4 +1,5 @@ // @flow +import type { ContextId } from '../../types'; import { transitions } from '../../animation'; import * as attributes from '../data-attributes'; @@ -40,8 +41,8 @@ const getStyles = (rules: Rule[], property: string): string => const noPointerEvents: string = 'pointer-events: none;'; -export default (uniqueContext: string): Styles => { - const getSelector = makeGetSelector(uniqueContext); +export default (contextId: ContextId): Styles => { + const getSelector = makeGetSelector(contextId); // ## Drag handle styles @@ -124,7 +125,7 @@ export default (uniqueContext: string): Styles => { // When we drop a Draggable it already has the correct scroll applied. const droppable: Rule = { - selector: getSelector(attributes.droppable), + selector: getSelector(attributes.droppable.contextId), styles: { always: `overflow-anchor: none;`, // need pointer events on the droppable to allow manual scrolling From 81344d982c5d2bfec9f6933d1a89bc10d7d1d3d9 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Fri, 3 May 2019 16:16:56 +1000 Subject: [PATCH 012/308] adding a little demo sensor --- src/debug/use-demo-sensor.js | 56 +++++++++++++++++++ src/view/use-sensor-marshal/sensor-types.js | 2 +- .../use-sensor-marshal/use-sensor-marshal.js | 53 +++++++++++------- 3 files changed, 89 insertions(+), 22 deletions(-) create mode 100644 src/debug/use-demo-sensor.js diff --git a/src/debug/use-demo-sensor.js b/src/debug/use-demo-sensor.js new file mode 100644 index 0000000000..28b1d6f03a --- /dev/null +++ b/src/debug/use-demo-sensor.js @@ -0,0 +1,56 @@ +// @flow +import { useEffect } from 'react'; +import { useCallback } from 'use-memo-one'; +import { getBox, type BoxModel } from 'css-box-model'; +import type { MovementCallbacks } from '../view/use-sensor-marshal/sensor-types'; + +function delay(fn: Function, time?: number = 300) { + return new Promise(resolve => { + setTimeout(() => { + fn(); + resolve(); + }, time); + }); +} + +export default function useDemoSensor( + tryStartCapturing: (source: Event | Element) => ?MovementCallbacks, +) { + const start = useCallback(() => { + // grabbing the first drag handle we can + const handle: ?HTMLElement = document.querySelector( + '[data-rbd-drag-handle-context-id]', + ); + if (!handle) { + console.log('could not find drag handle'); + return; + } + + const callbacks: ?MovementCallbacks = tryStartCapturing(handle); + + if (!callbacks) { + console.log('unable to start drag'); + return; + } + const box: BoxModel = getBox(handle); + + // TODO: this is a bit lame as a programatic api + callbacks.onLift({ + clientSelection: box.borderBox.center, + movementMode: 'SNAP', + }); + + Promise.resolve() + .then(() => delay(callbacks.onMoveDown)) + .then(() => delay(callbacks.onMoveDown)) + .then(() => delay(callbacks.onMoveDown)) + .then(() => delay(callbacks.onMoveDown)) + .then(() => delay(callbacks.onMoveDown)) + .then(() => delay(callbacks.onMoveUp)) + .then(() => delay(callbacks.onMoveUp)); + }, [tryStartCapturing]); + + useEffect(() => { + start(); + }, [start]); +} diff --git a/src/view/use-sensor-marshal/sensor-types.js b/src/view/use-sensor-marshal/sensor-types.js index 0a39a65f26..706353fca4 100644 --- a/src/view/use-sensor-marshal/sensor-types.js +++ b/src/view/use-sensor-marshal/sensor-types.js @@ -26,5 +26,5 @@ export type MovementCallbacks = {| |}; export type SensorHook = ( - tryStartCapturing: (event: Event) => ?MovementCallbacks, + tryStartCapturing: (source: Event | Element) => ?MovementCallbacks, ) => void; diff --git a/src/view/use-sensor-marshal/use-sensor-marshal.js b/src/view/use-sensor-marshal/use-sensor-marshal.js index a269140d18..869cc4a225 100644 --- a/src/view/use-sensor-marshal/use-sensor-marshal.js +++ b/src/view/use-sensor-marshal/use-sensor-marshal.js @@ -27,6 +27,7 @@ import useMouseSensor from './sensors/use-mouse-sensor'; import useValidateSensorHooks from './use-validate-sensor-hooks'; import isHandleInInteractiveElement from './is-handle-in-interactive-element'; import getOptionsFromDraggable from './get-options-from-draggable'; +import useDemoSensor from '../../debug/use-demo-sensor'; let capturingFor: ?DraggableId = null; function startCapture(id: DraggableId) { @@ -45,23 +46,37 @@ function preventDefault(event: Event) { type TryStartCapturingArgs = {| contextId: ContextId, store: Store, - event: Event, + source: Event | Element, |}; +function getTarget(source: Event | Element): ?Element { + if (source instanceof Element) { + return source; + } + // source is an event + + // Event is already used: do not start a drag + if (source.defaultPrevented) { + return null; + } + + // Only interested if the target is an Element + const target: EventTarget = source.target; + return target instanceof Element ? target : null; +} + function tryStartCapturing({ contextId, store, - event, + source, }: TryStartCapturingArgs): ?MovementCallbacks { if (capturingFor != null) { return null; } - if (event.defaultPrevented) { - return null; - } + const target: ?Element = getTarget(source); - const target: EventTarget = event.target; + // Must be a HTMLElement if (!(target instanceof HTMLElement)) { return null; } @@ -103,18 +118,18 @@ function tryStartCapturing({ }), ); }); - const onMoveUp = rafSchd(() => { + const onMoveUp = () => { store.dispatch(moveUpAction()); - }); - const onMoveDown = rafSchd(() => { + }; + const onMoveDown = () => { store.dispatch(moveDownAction()); - }); - const onMoveRight = rafSchd(() => { + }; + const onMoveRight = () => { store.dispatch(moveRightAction()); - }); - const onMoveLeft = rafSchd(() => { + }; + const onMoveLeft = () => { store.dispatch(moveLeftAction()); - }); + }; const finish = ({ shouldBlockNextClick }: CaptureEndOptions) => { // stopping capture stopCapture(); @@ -132,10 +147,6 @@ function tryStartCapturing({ // cancel any pending request animation frames onMove.cancel(); onWindowScroll.cancel(); - onMoveUp.cancel(); - onMoveRight.cancel(); - onMoveDown.cancel(); - onMoveLeft.cancel(); }; return { @@ -178,14 +189,14 @@ type SensorMarshalArgs = {| export default function useSensorMarshal({ contextId, store, - useSensorHooks = [useMouseSensor], + useSensorHooks = [useMouseSensor /* useDemoSensor */], }: SensorMarshalArgs) { const tryStartCapture = useCallback( - (event: Event): ?MovementCallbacks => + (source: Event | Element): ?MovementCallbacks => tryStartCapturing({ contextId, store, - event, + source, }), [contextId, store], ); From 9d4fb6b26794b3230a2c813309810a404c001f5c Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Fri, 3 May 2019 17:16:51 +1000 Subject: [PATCH 013/308] fixing lift bug --- src/debug/use-demo-sensor.js | 8 ++- src/view/use-sensor-marshal/get-closest.js | 12 +++-- src/view/use-sensor-marshal/sensor-types.js | 20 +++++--- .../sensors/use-mouse-sensor.js | 2 +- .../use-sensor-marshal/use-sensor-marshal.js | 50 ++++++++++++------- 5 files changed, 57 insertions(+), 35 deletions(-) diff --git a/src/debug/use-demo-sensor.js b/src/debug/use-demo-sensor.js index 28b1d6f03a..7edef9a84b 100644 --- a/src/debug/use-demo-sensor.js +++ b/src/debug/use-demo-sensor.js @@ -1,7 +1,6 @@ // @flow import { useEffect } from 'react'; import { useCallback } from 'use-memo-one'; -import { getBox, type BoxModel } from 'css-box-model'; import type { MovementCallbacks } from '../view/use-sensor-marshal/sensor-types'; function delay(fn: Function, time?: number = 300) { @@ -32,12 +31,10 @@ export default function useDemoSensor( console.log('unable to start drag'); return; } - const box: BoxModel = getBox(handle); // TODO: this is a bit lame as a programatic api callbacks.onLift({ - clientSelection: box.borderBox.center, - movementMode: 'SNAP', + mode: 'SNAP', }); Promise.resolve() @@ -47,7 +44,8 @@ export default function useDemoSensor( .then(() => delay(callbacks.onMoveDown)) .then(() => delay(callbacks.onMoveDown)) .then(() => delay(callbacks.onMoveUp)) - .then(() => delay(callbacks.onMoveUp)); + .then(() => delay(callbacks.onMoveUp)) + .then(() => delay(callbacks.onDrop)); }, [tryStartCapturing]); useEffect(() => { diff --git a/src/view/use-sensor-marshal/get-closest.js b/src/view/use-sensor-marshal/get-closest.js index 507c63a7fd..33fea01918 100644 --- a/src/view/use-sensor-marshal/get-closest.js +++ b/src/view/use-sensor-marshal/get-closest.js @@ -3,19 +3,23 @@ import invariant from 'tiny-invariant'; import type { ContextId } from '../../types'; import * as attributes from '../data-attributes'; import closest from './closest'; +import isHtmlElement from '../is-type-of-element/is-html-element'; -export function getClosestDragHandle(contextId: string, el: Element): ?Element { +export function getClosestDragHandle( + contextId: string, + el: Element, +): ?HTMLElement { const attribute: string = attributes.dragHandle.contextId; const selector: string = `[${attribute}="${contextId}"]`; const handle: ?Element = closest(el, selector); - return handle || null; + return handle && isHtmlElement(handle) ? handle : null; } export function getClosestDraggable( contextId: ContextId, handle: Element, -): Element { +): HTMLElement { // TODO: id might not make a good selector if it has strange characters in it - such as whitespace // const selector: string = `[${contextIdAttr}="${contextId}"] [${idAttr}="${id}"]`; const selector: string = `[${attributes.draggable.contextId}="${contextId}"]`; @@ -23,5 +27,5 @@ export function getClosestDraggable( const draggable: ?Element = closest(handle, selector); invariant(draggable, 'expected drag handle to have draggable'); - return draggable; + return draggable && isHtmlElement(draggable) ? draggable : null; } diff --git a/src/view/use-sensor-marshal/sensor-types.js b/src/view/use-sensor-marshal/sensor-types.js index 706353fca4..0b7b835e5a 100644 --- a/src/view/use-sensor-marshal/sensor-types.js +++ b/src/view/use-sensor-marshal/sensor-types.js @@ -6,22 +6,30 @@ export type CaptureEndOptions = {| shouldBlockNextClick: boolean, |}; +type SnapLift = {| + mode: 'SNAP', +|}; + +type FluidLift = {| + mode: 'FLUID', + clientSelection: Position, +|}; + +export type OnLiftArgs = SnapLift | FluidLift; + export type MovementCallbacks = {| shouldRespectForcePress: () => boolean, // getDragHandleRef: () => HTMLElement, // getDraggableRef: () => HTMLElement, - onLift: ({ - clientSelection: Position, - movementMode: MovementMode, - }) => void, + onLift: (args: OnLiftArgs) => void, onMove: (point: Position) => void, onWindowScroll: () => void, onMoveUp: () => void, onMoveDown: () => void, onMoveRight: () => void, onMoveLeft: () => void, - onDrop: (args: CaptureEndOptions) => void, - onCancel: (args: CaptureEndOptions) => void, + onDrop: (args?: CaptureEndOptions) => void, + onCancel: (args?: CaptureEndOptions) => void, onAbort: () => void, |}; diff --git a/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js b/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js index 7b2ca21ecf..83cec2288b 100644 --- a/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js +++ b/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js @@ -88,7 +88,7 @@ function getCaptureBindings( phase.callbacks.onLift({ clientSelection: pending, - movementMode: 'FLUID', + mode: 'FLUID', }); }, }, diff --git a/src/view/use-sensor-marshal/use-sensor-marshal.js b/src/view/use-sensor-marshal/use-sensor-marshal.js index 869cc4a225..b492138210 100644 --- a/src/view/use-sensor-marshal/use-sensor-marshal.js +++ b/src/view/use-sensor-marshal/use-sensor-marshal.js @@ -3,12 +3,13 @@ import invariant from 'tiny-invariant'; import rafSchd from 'raf-schd'; import { useCallback } from 'use-memo-one'; import type { Position } from 'css-box-model'; -import type { ContextId, DraggableId, MovementMode } from '../../types'; +import type { ContextId, DraggableId } from '../../types'; import type { Store } from '../../state/store-types'; import type { MovementCallbacks, SensorHook, CaptureEndOptions, + OnLiftArgs, } from './sensor-types'; import { getClosestDragHandle, getClosestDraggable } from './get-closest'; import canStartDrag from '../../state/can-start-drag'; @@ -28,6 +29,9 @@ import useValidateSensorHooks from './use-validate-sensor-hooks'; import isHandleInInteractiveElement from './is-handle-in-interactive-element'; import getOptionsFromDraggable from './get-options-from-draggable'; import useDemoSensor from '../../debug/use-demo-sensor'; +import getBorderBoxCenterPosition from '../get-border-box-center-position'; +import { warning } from '../../dev-warning'; +import isHtmlElement from '../is-type-of-element/is-html-element'; let capturingFor: ?DraggableId = null; function startCapture(id: DraggableId) { @@ -77,17 +81,18 @@ function tryStartCapturing({ const target: ?Element = getTarget(source); // Must be a HTMLElement - if (!(target instanceof HTMLElement)) { + if (!isHtmlElement(target)) { + warning('Expected target to be a HTMLElement'); return null; } - const handle: ?Element = getClosestDragHandle(contextId, target); + const handle: ?HTMLElement = getClosestDragHandle(contextId, target); if (handle == null) { return null; } - const draggable: Element = getClosestDraggable(contextId, handle); + const draggable: HTMLElement = getClosestDraggable(contextId, handle); const { id, shouldRespectForcePress, @@ -130,12 +135,14 @@ function tryStartCapturing({ const onMoveLeft = () => { store.dispatch(moveLeftAction()); }; - const finish = ({ shouldBlockNextClick }: CaptureEndOptions) => { + const finish = ( + options?: CaptureEndOptions = { shouldBlockNextClick: false }, + ) => { // stopping capture stopCapture(); // block next click if requested - if (shouldBlockNextClick) { + if (options.shouldBlockNextClick) { window.addEventListener('click', preventDefault, { // only blocking a single click once: true, @@ -151,16 +158,21 @@ function tryStartCapturing({ return { shouldRespectForcePress: (): boolean => shouldRespectForcePress, - onLift: (options: { - clientSelection: Position, - movementMode: MovementMode, - }) => { - store.dispatch( - liftAction({ - ...options, - id, - }), - ); + onLift: (args: OnLiftArgs) => { + const actionArgs = + args.mode === 'FLUID' + ? { + clientSelection: args.clientSelection, + movementMode: 'FLUID', + id, + } + : { + movementMode: 'SNAP', + clientSelection: getBorderBoxCenterPosition(draggable), + id, + }; + + store.dispatch(liftAction(actionArgs)); }, onMove, onWindowScroll, @@ -168,15 +180,15 @@ function tryStartCapturing({ onMoveDown, onMoveRight, onMoveLeft, - onDrop: (args: CaptureEndOptions) => { + onDrop: (args?: CaptureEndOptions) => { finish(args); store.dispatch(dropAction({ reason: 'DROP' })); }, - onCancel: (args: CaptureEndOptions) => { + onCancel: (args?: CaptureEndOptions) => { finish(args); store.dispatch(dropAction({ reason: 'CANCEL' })); }, - onAbort: () => finish({ shouldBlockNextClick: false }), + onAbort: () => finish(), }; } From 7e7e4365a72e3ed22ffd1439e989b2de7b593e1a Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Mon, 6 May 2019 09:15:53 +1000 Subject: [PATCH 014/308] stand alone scroll listener --- src/debug/use-demo-sensor.js | 4 ++ src/state/create-store.js | 2 + src/state/middleware/scroll-listener.js | 31 ++++++++++ .../util => event-bindings}/bind-events.js | 0 .../util => event-bindings}/event-types.js | 0 src/view/scroll-listener.js | 60 +++++++++++++++++++ .../sensors/use-mouse-sensor.js | 32 +++------- .../use-sensor-marshal/use-sensor-marshal.js | 2 +- 8 files changed, 107 insertions(+), 24 deletions(-) create mode 100644 src/state/middleware/scroll-listener.js rename src/view/{use-sensor-marshal/sensors/util => event-bindings}/bind-events.js (100%) rename src/view/{use-sensor-marshal/sensors/util => event-bindings}/event-types.js (100%) create mode 100644 src/view/scroll-listener.js diff --git a/src/debug/use-demo-sensor.js b/src/debug/use-demo-sensor.js index 7edef9a84b..df51251d16 100644 --- a/src/debug/use-demo-sensor.js +++ b/src/debug/use-demo-sensor.js @@ -38,6 +38,10 @@ export default function useDemoSensor( }); Promise.resolve() + .then(() => delay(callbacks.onMoveDown)) + .then(() => delay(callbacks.onMoveDown)) + .then(() => delay(callbacks.onMoveDown)) + .then(() => delay(callbacks.onMoveDown)) .then(() => delay(callbacks.onMoveDown)) .then(() => delay(callbacks.onMoveDown)) .then(() => delay(callbacks.onMoveDown)) diff --git a/src/state/create-store.js b/src/state/create-store.js index 231c831db8..a8f6fd6dad 100644 --- a/src/state/create-store.js +++ b/src/state/create-store.js @@ -5,6 +5,7 @@ import reducer from './reducer'; import lift from './middleware/lift'; import style from './middleware/style'; import drop from './middleware/drop/drop-middleware'; +import scrollListener from './middleware/scroll-listener'; import responders from './middleware/responders/responders-middleware'; import dropAnimationFinish from './middleware/drop-animation-finish'; import dimensionMarshalStopper from './middleware/dimension-marshal-stopper'; @@ -79,6 +80,7 @@ export default ({ dropAnimationFinish, pendingDrop, autoScroll(autoScroller), + scrollListener, // Fire responders for consumers (after update to store) responders(getResponders, announce), ), diff --git a/src/state/middleware/scroll-listener.js b/src/state/middleware/scroll-listener.js new file mode 100644 index 0000000000..b044150561 --- /dev/null +++ b/src/state/middleware/scroll-listener.js @@ -0,0 +1,31 @@ +// @flow +import type { Position } from 'css-box-model'; +import { moveByWindowScroll } from '../action-creators'; +import type { MiddlewareStore, Action, Dispatch } from '../store-types'; +import getScrollListener from '../../view/scroll-listener'; + +// TODO: this is taken from auto-scroll. Let's make it a util +const shouldEnd = (action: Action): boolean => + action.type === 'DROP_COMPLETE' || + action.type === 'DROP_ANIMATE' || + action.type === 'CLEAN'; + +export default (store: MiddlewareStore) => { + const listener = getScrollListener({ + onWindowScroll: (newScroll: Position) => { + store.dispatch(moveByWindowScroll({ newScroll })); + }, + }); + + return (next: Dispatch) => (action: Action): any => { + if (action.type === 'INITIAL_PUBLISH') { + listener.start(); + } + + if (shouldEnd(action)) { + listener.stop(); + } + + next(action); + }; +}; diff --git a/src/view/use-sensor-marshal/sensors/util/bind-events.js b/src/view/event-bindings/bind-events.js similarity index 100% rename from src/view/use-sensor-marshal/sensors/util/bind-events.js rename to src/view/event-bindings/bind-events.js diff --git a/src/view/use-sensor-marshal/sensors/util/event-types.js b/src/view/event-bindings/event-types.js similarity index 100% rename from src/view/use-sensor-marshal/sensors/util/event-types.js rename to src/view/event-bindings/event-types.js diff --git a/src/view/scroll-listener.js b/src/view/scroll-listener.js new file mode 100644 index 0000000000..eb8e6aba7e --- /dev/null +++ b/src/view/scroll-listener.js @@ -0,0 +1,60 @@ +// @flow +import type { Position } from 'css-box-model'; +import rafSchd from 'raf-schd'; +import bindEvents from './event-bindings/bind-events'; +import type { EventBinding } from './event-bindings/event-types'; +import getWindowScroll from './window/get-window-scroll'; + +type OnWindowScroll = (newScroll: Position) => void; + +type Args = {| + onWindowScroll: OnWindowScroll, +|}; + +type Result = {| + start: () => void, + stop: () => void, +|}; + +function noop() {} + +function getWindowScrollBinding(onWindowScroll: OnWindowScroll): EventBinding { + return { + eventName: 'scroll', + // TODO: should this be different for SNAP dragging? + + // ## Passive: true + // Eventual consistency is fine because we use position: fixed on the item + // ## Capture: false + // 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: { passive: true, 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 !== window) { + return; + } + + onWindowScroll(getWindowScroll()); + }, + }; +} + +export default function getScrollListener({ onWindowScroll }: Args): Result { + const scheduled: OnWindowScroll = rafSchd(onWindowScroll); + const binding: EventBinding = getWindowScrollBinding(scheduled); + let unbind: () => void = noop; + + function start() { + unbind = bindEvents(window, [binding]); + } + function stop() { + unbind(); + unbind = noop; + } + + return { start, stop }; +} diff --git a/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js b/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js index 83cec2288b..ca9e8eaac5 100644 --- a/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js +++ b/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js @@ -4,8 +4,11 @@ import { useEffect, useRef } from 'react'; import { useCallback, useMemo } from 'use-memo-one'; import type { Position } from 'css-box-model'; import type { MovementCallbacks } from '../sensor-types'; -import type { EventBinding, EventOptions } from './util/event-types'; -import bindEvents from './util/bind-events'; +import type { + EventBinding, + EventOptions, +} from '../../event-bindings/event-types'; +import bindEvents from '../../event-bindings/bind-events'; import isSloppyClickThresholdExceeded from './util/is-sloppy-click-threshold-exceeded'; import * as keyCodes from '../../key-codes'; import preventStandardKeyEvents from './util/prevent-standard-key-events'; @@ -144,29 +147,12 @@ function getCaptureBindings( }, { eventName: 'scroll', - // ## Passive: true - // Eventual consistency is fine because we use position: fixed on the item - // ## Capture: false - // 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 - // TODO: can result in awkward drop position + // kill a pending drag if there is a window scroll options: { passive: true, 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 !== window) { - return; - } - - const phase: Phase = getPhase(); - if (phase.type === 'DRAGGING') { - phase.callbacks.onWindowScroll(); - return; + fn: () => { + if (getPhase().type === 'PENDING') { + stop(); } - // stop a pending drag - stop(); }, }, // Need to opt out of dragging if the user is a force press diff --git a/src/view/use-sensor-marshal/use-sensor-marshal.js b/src/view/use-sensor-marshal/use-sensor-marshal.js index b492138210..d3e8d80e9d 100644 --- a/src/view/use-sensor-marshal/use-sensor-marshal.js +++ b/src/view/use-sensor-marshal/use-sensor-marshal.js @@ -201,7 +201,7 @@ type SensorMarshalArgs = {| export default function useSensorMarshal({ contextId, store, - useSensorHooks = [useMouseSensor /* useDemoSensor */], + useSensorHooks = [useMouseSensor, useDemoSensor], }: SensorMarshalArgs) { const tryStartCapture = useCallback( (source: Event | Element): ?MovementCallbacks => From 3afa6d6087d9a4bdff2857484308550db4b794b3 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Mon, 6 May 2019 09:51:03 +1000 Subject: [PATCH 015/308] simplier api --- src/debug/use-demo-sensor.js | 74 ++++++++++--------- src/view/scroll-listener.js | 1 + src/view/use-sensor-marshal/sensor-types.js | 20 +++-- .../sensors/use-mouse-sensor.js | 10 +-- .../use-sensor-marshal/use-sensor-marshal.js | 41 ++++------ 5 files changed, 68 insertions(+), 78 deletions(-) diff --git a/src/debug/use-demo-sensor.js b/src/debug/use-demo-sensor.js index df51251d16..8f4d56a7cf 100644 --- a/src/debug/use-demo-sensor.js +++ b/src/debug/use-demo-sensor.js @@ -15,42 +15,44 @@ function delay(fn: Function, time?: number = 300) { export default function useDemoSensor( tryStartCapturing: (source: Event | Element) => ?MovementCallbacks, ) { - const start = useCallback(() => { - // grabbing the first drag handle we can - const handle: ?HTMLElement = document.querySelector( - '[data-rbd-drag-handle-context-id]', - ); - if (!handle) { - console.log('could not find drag handle'); - return; - } - - const callbacks: ?MovementCallbacks = tryStartCapturing(handle); - - if (!callbacks) { - console.log('unable to start drag'); - return; - } - - // TODO: this is a bit lame as a programatic api - callbacks.onLift({ - mode: 'SNAP', - }); - - Promise.resolve() - .then(() => delay(callbacks.onMoveDown)) - .then(() => delay(callbacks.onMoveDown)) - .then(() => delay(callbacks.onMoveDown)) - .then(() => delay(callbacks.onMoveDown)) - .then(() => delay(callbacks.onMoveDown)) - .then(() => delay(callbacks.onMoveDown)) - .then(() => delay(callbacks.onMoveDown)) - .then(() => delay(callbacks.onMoveDown)) - .then(() => delay(callbacks.onMoveDown)) - .then(() => delay(callbacks.onMoveUp)) - .then(() => delay(callbacks.onMoveUp)) - .then(() => delay(callbacks.onDrop)); - }, [tryStartCapturing]); + const start = useCallback( + async function start() { + // grabbing the first drag handle we can + const handle: ?HTMLElement = document.querySelector( + '[data-rbd-drag-handle-context-id]', + ); + if (!handle) { + console.log('could not find drag handle'); + return; + } + + // handle.scrollIntoView(); + + const callbacks: ?MovementCallbacks = tryStartCapturing(handle); + + if (!callbacks) { + console.log('unable to start drag'); + return; + } + + const { lift, moveDown, moveUp, drop } = callbacks; + + lift({ + mode: 'SNAP', + }); + + await delay(moveDown); + await delay(moveDown); + await delay(moveDown); + await delay(moveDown); + await delay(moveDown); + await delay(moveDown); + await delay(moveUp); + await delay(moveUp); + await delay(drop); + }, + [tryStartCapturing], + ); useEffect(() => { start(); diff --git a/src/view/scroll-listener.js b/src/view/scroll-listener.js index eb8e6aba7e..82c8d64554 100644 --- a/src/view/scroll-listener.js +++ b/src/view/scroll-listener.js @@ -52,6 +52,7 @@ export default function getScrollListener({ onWindowScroll }: Args): Result { unbind = bindEvents(window, [binding]); } function stop() { + scheduled.cancel(); unbind(); unbind = noop; } diff --git a/src/view/use-sensor-marshal/sensor-types.js b/src/view/use-sensor-marshal/sensor-types.js index 0b7b835e5a..ce78b9d360 100644 --- a/src/view/use-sensor-marshal/sensor-types.js +++ b/src/view/use-sensor-marshal/sensor-types.js @@ -1,6 +1,5 @@ // @flow import type { Position } from 'css-box-model'; -import type { MovementMode } from '../../types'; export type CaptureEndOptions = {| shouldBlockNextClick: boolean, @@ -21,16 +20,15 @@ export type MovementCallbacks = {| shouldRespectForcePress: () => boolean, // getDragHandleRef: () => HTMLElement, // getDraggableRef: () => HTMLElement, - onLift: (args: OnLiftArgs) => void, - onMove: (point: Position) => void, - onWindowScroll: () => void, - onMoveUp: () => void, - onMoveDown: () => void, - onMoveRight: () => void, - onMoveLeft: () => void, - onDrop: (args?: CaptureEndOptions) => void, - onCancel: (args?: CaptureEndOptions) => void, - onAbort: () => void, + lift: (args: OnLiftArgs) => void, + move: (point: Position) => void, + moveUp: () => void, + moveDown: () => void, + moveRight: () => void, + moveLeft: () => void, + drop: (args?: CaptureEndOptions) => void, + cancel: (args?: CaptureEndOptions) => void, + abort: () => void, |}; export type SensorHook = ( diff --git a/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js b/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js index ca9e8eaac5..3709d97891 100644 --- a/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js +++ b/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js @@ -68,7 +68,7 @@ function getCaptureBindings( if (phase.type === 'DRAGGING') { // preventing default as we are using this event event.preventDefault(); - phase.callbacks.onMove(point); + phase.callbacks.move(point); return; } @@ -89,7 +89,7 @@ function getCaptureBindings( callbacks: phase.callbacks, }); - phase.callbacks.onLift({ + phase.callbacks.lift({ clientSelection: pending, mode: 'FLUID', }); @@ -103,7 +103,7 @@ function getCaptureBindings( if (phase.type === 'DRAGGING') { // preventing default as we are using this event event.preventDefault(); - phase.callbacks.onDrop({ shouldBlockNextClick: true }); + phase.callbacks.drop({ shouldBlockNextClick: true }); } stop(); @@ -277,10 +277,10 @@ export default function useMouseSensor( const phase: Phase = phaseRef.current; stop(); if (phase.type === 'DRAGGING') { - phase.callbacks.onCancel({ shouldBlockNextClick: true }); + phase.callbacks.cancel({ shouldBlockNextClick: true }); } if (phase.type === 'PENDING') { - phase.callbacks.onAbort(); + phase.callbacks.abort(); } }, [stop]); diff --git a/src/view/use-sensor-marshal/use-sensor-marshal.js b/src/view/use-sensor-marshal/use-sensor-marshal.js index d3e8d80e9d..72807e49f8 100644 --- a/src/view/use-sensor-marshal/use-sensor-marshal.js +++ b/src/view/use-sensor-marshal/use-sensor-marshal.js @@ -15,7 +15,6 @@ import { getClosestDragHandle, getClosestDraggable } from './get-closest'; import canStartDrag from '../../state/can-start-drag'; import { move as moveAction, - moveByWindowScroll as windowScrollAction, moveUp as moveUpAction, moveRight as moveRightAction, moveDown as moveDownAction, @@ -23,7 +22,6 @@ import { drop as dropAction, lift as liftAction, } from '../../state/action-creators'; -import getWindowScroll from '../window/get-window-scroll'; import useMouseSensor from './sensors/use-mouse-sensor'; import useValidateSensorHooks from './use-validate-sensor-hooks'; import isHandleInInteractiveElement from './is-handle-in-interactive-element'; @@ -113,26 +111,19 @@ function tryStartCapturing({ startCapture(id); - const onMove = rafSchd((clientSelection: Position) => { + const move = rafSchd((clientSelection: Position) => { store.dispatch(moveAction({ client: clientSelection })); }); - const onWindowScroll = rafSchd(() => { - store.dispatch( - windowScrollAction({ - newScroll: getWindowScroll(), - }), - ); - }); - const onMoveUp = () => { + const moveUp = () => { store.dispatch(moveUpAction()); }; - const onMoveDown = () => { + const moveDown = () => { store.dispatch(moveDownAction()); }; - const onMoveRight = () => { + const moveRight = () => { store.dispatch(moveRightAction()); }; - const onMoveLeft = () => { + const moveLeft = () => { store.dispatch(moveLeftAction()); }; const finish = ( @@ -152,13 +143,12 @@ function tryStartCapturing({ } // cancel any pending request animation frames - onMove.cancel(); - onWindowScroll.cancel(); + move.cancel(); }; return { shouldRespectForcePress: (): boolean => shouldRespectForcePress, - onLift: (args: OnLiftArgs) => { + lift: (args: OnLiftArgs) => { const actionArgs = args.mode === 'FLUID' ? { @@ -174,21 +164,20 @@ function tryStartCapturing({ store.dispatch(liftAction(actionArgs)); }, - onMove, - onWindowScroll, - onMoveUp, - onMoveDown, - onMoveRight, - onMoveLeft, - onDrop: (args?: CaptureEndOptions) => { + move, + moveUp, + moveDown, + moveRight, + moveLeft, + drop: (args?: CaptureEndOptions) => { finish(args); store.dispatch(dropAction({ reason: 'DROP' })); }, - onCancel: (args?: CaptureEndOptions) => { + cancel: (args?: CaptureEndOptions) => { finish(args); store.dispatch(dropAction({ reason: 'CANCEL' })); }, - onAbort: () => finish(), + abort: () => finish(), }; } From 86205ba7f95f9ecb9b9552faaab68e2c0556dfae Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Mon, 6 May 2019 10:20:29 +1000 Subject: [PATCH 016/308] fixing pending drag --- src/view/use-sensor-marshal/get-closest.js | 3 ++- .../sensors/use-mouse-sensor.js | 22 +++++++++++-------- .../use-sensor-marshal/use-sensor-marshal.js | 2 +- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/view/use-sensor-marshal/get-closest.js b/src/view/use-sensor-marshal/get-closest.js index 33fea01918..d33e46748e 100644 --- a/src/view/use-sensor-marshal/get-closest.js +++ b/src/view/use-sensor-marshal/get-closest.js @@ -26,6 +26,7 @@ export function getClosestDraggable( const draggable: ?Element = closest(handle, selector); invariant(draggable, 'expected drag handle to have draggable'); + invariant(isHtmlElement(draggable), 'expected draggable to be a HTMLElement'); - return draggable && isHtmlElement(draggable) ? draggable : null; + return draggable; } diff --git a/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js b/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js index 3709d97891..6fb0729850 100644 --- a/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js +++ b/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js @@ -43,8 +43,8 @@ type Phase = Idle | Pending | Dragging; const idle: Idle = { type: 'IDLE' }; function getCaptureBindings( - stop: () => void, cancel: () => void, + completed: () => void, getPhase: () => Phase, setPhase: (phase: Phase) => void, ): EventBinding[] { @@ -100,13 +100,15 @@ function getCaptureBindings( fn: (event: MouseEvent) => { const phase: Phase = getPhase(); - if (phase.type === 'DRAGGING') { - // preventing default as we are using this event - event.preventDefault(); - phase.callbacks.drop({ shouldBlockNextClick: true }); + if (phase.type !== 'DRAGGING') { + cancel(); + return; } - stop(); + // preventing default as we are using this event + event.preventDefault(); + phase.callbacks.drop({ shouldBlockNextClick: true }); + completed(); }, }, { @@ -127,7 +129,7 @@ function getCaptureBindings( const phase: Phase = getPhase(); // Abort if any keystrokes while a drag is pending if (phase.type === 'PENDING') { - stop(); + cancel(); return; } @@ -151,7 +153,7 @@ function getCaptureBindings( options: { passive: true, capture: false }, fn: () => { if (getPhase().type === 'PENDING') { - stop(); + cancel(); } }, }, @@ -223,6 +225,7 @@ export default function useMouseSensor( const callbacks: ?MovementCallbacks = tryStartCapturing(event); if (!callbacks) { + console.log('cannot start a capture'); return; } @@ -262,6 +265,7 @@ export default function useMouseSensor( ); const stop = useCallback(() => { + console.log('trying to stop'); const current: Phase = phaseRef.current; if (current.type === 'IDLE') { return; @@ -288,8 +292,8 @@ export default function useMouseSensor( function bindCapturingEvents() { const options = { capture: true, passive: false }; const bindings: EventBinding[] = getCaptureBindings( - stop, cancel, + stop, () => phaseRef.current, (phase: Phase) => { phaseRef.current = phase; diff --git a/src/view/use-sensor-marshal/use-sensor-marshal.js b/src/view/use-sensor-marshal/use-sensor-marshal.js index 72807e49f8..37394a06eb 100644 --- a/src/view/use-sensor-marshal/use-sensor-marshal.js +++ b/src/view/use-sensor-marshal/use-sensor-marshal.js @@ -190,7 +190,7 @@ type SensorMarshalArgs = {| export default function useSensorMarshal({ contextId, store, - useSensorHooks = [useMouseSensor, useDemoSensor], + useSensorHooks = [useMouseSensor /*useDemoSensor */], }: SensorMarshalArgs) { const tryStartCapture = useCallback( (source: Event | Element): ?MovementCallbacks => From 113db49f9fdd809cff2e37ba2d79317d7059b91a Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Mon, 6 May 2019 10:23:01 +1000 Subject: [PATCH 017/308] cleaner naming --- .../sensors/use-mouse-sensor.js | 33 +++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js b/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js index 6fb0729850..c4f57193af 100644 --- a/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js +++ b/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js @@ -42,12 +42,19 @@ type Phase = Idle | Pending | Dragging; const idle: Idle = { type: 'IDLE' }; -function getCaptureBindings( +type GetCaptureArgs = {| cancel: () => void, completed: () => void, getPhase: () => Phase, setPhase: (phase: Phase) => void, -): EventBinding[] { +|}; + +function getCaptureBindings({ + cancel, + completed, + getPhase, + setPhase, +}: GetCaptureArgs): EventBinding[] { return [ { eventName: 'mousemove', @@ -206,7 +213,7 @@ export default function useMouseSensor( tryStartCapturing: (event: Event) => ?MovementCallbacks, ) { const phaseRef = useRef(idle); - const unbindWindowEventsRef = useRef<() => void>(noop); + const unbindEventsRef = useRef<() => void>(noop); const startCaptureBinding: EventBinding = useMemo( () => ({ @@ -237,7 +244,7 @@ export default function useMouseSensor( }; // unbind this listener - unbindWindowEventsRef.current(); + unbindEventsRef.current(); // using this function before it is defined as their is a circular usage pattern // eslint-disable-next-line no-use-before-define startPendingDrag(callbacks, point); @@ -255,7 +262,7 @@ export default function useMouseSensor( capture: true, }; - unbindWindowEventsRef.current = bindEvents( + unbindEventsRef.current = bindEvents( window, [startCaptureBinding], options, @@ -272,7 +279,7 @@ export default function useMouseSensor( } phaseRef.current = idle; - unbindWindowEventsRef.current(); + unbindEventsRef.current(); listenForCapture(); }, [listenForCapture]); @@ -291,16 +298,16 @@ export default function useMouseSensor( const bindCapturingEvents = useCallback( function bindCapturingEvents() { const options = { capture: true, passive: false }; - const bindings: EventBinding[] = getCaptureBindings( + const bindings: EventBinding[] = getCaptureBindings({ cancel, - stop, - () => phaseRef.current, - (phase: Phase) => { + completed: stop, + getPhase: () => phaseRef.current, + setPhase: (phase: Phase) => { phaseRef.current = phase; }, - ); + }); - unbindWindowEventsRef.current = bindEvents(window, bindings, options); + unbindEventsRef.current = bindEvents(window, bindings, options); }, [cancel, stop], ); @@ -326,7 +333,7 @@ export default function useMouseSensor( // kill any pending window events when unmounting return () => { - unbindWindowEventsRef.current(); + unbindEventsRef.current(); }; }, [listenForCapture]); } From 7e564d86651d92158b57201c4718634c0f850eb8 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Mon, 6 May 2019 14:57:16 +1000 Subject: [PATCH 018/308] wip --- src/debug/use-demo-sensor.js | 7 ++- src/view/use-sensor-marshal/sensor-types.js | 5 +- .../sensors/use-mouse-sensor.js | 6 +- .../use-sensor-marshal/use-sensor-marshal.js | 58 +++++++++++++++---- 4 files changed, 61 insertions(+), 15 deletions(-) diff --git a/src/debug/use-demo-sensor.js b/src/debug/use-demo-sensor.js index 8f4d56a7cf..b5ab4b3c87 100644 --- a/src/debug/use-demo-sensor.js +++ b/src/debug/use-demo-sensor.js @@ -12,8 +12,10 @@ function delay(fn: Function, time?: number = 300) { }); } +function noop() {} + export default function useDemoSensor( - tryStartCapturing: (source: Event | Element) => ?MovementCallbacks, + tryStartCapturing: (source: Event | Element, noop) => ?MovementCallbacks, ) { const start = useCallback( async function start() { @@ -47,6 +49,9 @@ export default function useDemoSensor( await delay(moveDown); await delay(moveDown); await delay(moveDown); + await delay(moveDown); + await delay(moveDown); + await delay(moveDown); await delay(moveUp); await delay(moveUp); await delay(drop); diff --git a/src/view/use-sensor-marshal/sensor-types.js b/src/view/use-sensor-marshal/sensor-types.js index ce78b9d360..cb711eb75e 100644 --- a/src/view/use-sensor-marshal/sensor-types.js +++ b/src/view/use-sensor-marshal/sensor-types.js @@ -32,5 +32,8 @@ export type MovementCallbacks = {| |}; export type SensorHook = ( - tryStartCapturing: (source: Event | Element) => ?MovementCallbacks, + tryStartCapturing: ( + source: Event | Element, + abort: () => void, + ) => ?MovementCallbacks, ) => void; diff --git a/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js b/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js index c4f57193af..fbc7a0b684 100644 --- a/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js +++ b/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js @@ -210,7 +210,7 @@ function getCaptureBindings({ } export default function useMouseSensor( - tryStartCapturing: (event: Event) => ?MovementCallbacks, + tryStartCapturing: (event: Event, abort: () => void) => ?MovementCallbacks, ) { const phaseRef = useRef(idle); const unbindEventsRef = useRef<() => void>(noop); @@ -229,7 +229,9 @@ export default function useMouseSensor( return; } - const callbacks: ?MovementCallbacks = tryStartCapturing(event); + // stop is defined later + // eslint-disable-next-line no-use-before-define + const callbacks: ?MovementCallbacks = tryStartCapturing(event, stop); if (!callbacks) { console.log('cannot start a capture'); diff --git a/src/view/use-sensor-marshal/use-sensor-marshal.js b/src/view/use-sensor-marshal/use-sensor-marshal.js index 37394a06eb..5779df9de7 100644 --- a/src/view/use-sensor-marshal/use-sensor-marshal.js +++ b/src/view/use-sensor-marshal/use-sensor-marshal.js @@ -1,9 +1,10 @@ // @flow import invariant from 'tiny-invariant'; import rafSchd from 'raf-schd'; +import { useEffect } from 'react'; import { useCallback } from 'use-memo-one'; import type { Position } from 'css-box-model'; -import type { ContextId, DraggableId } from '../../types'; +import type { ContextId, DraggableId, State } from '../../types'; import type { Store } from '../../state/store-types'; import type { MovementCallbacks, @@ -31,14 +32,20 @@ import getBorderBoxCenterPosition from '../get-border-box-center-position'; import { warning } from '../../dev-warning'; import isHtmlElement from '../is-type-of-element/is-html-element'; -let capturingFor: ?DraggableId = null; -function startCapture(id: DraggableId) { - invariant(!capturingFor, 'Cannot start capturing when already capturing'); - capturingFor = id; +type Capturing = {| + id: DraggableId, + abort: () => void, +|}; + +let capturing: ?Capturing = null; + +function startCapture(id: DraggableId, abort: () => void) { + invariant(!capturing, 'Cannot start capturing when already capturing'); + capturing = { id, abort }; } function stopCapture() { - invariant(capturingFor, 'Cannot stop capturing when not already capturing'); - capturingFor = null; + invariant(capturing, 'Cannot stop capturing when not already capturing'); + capturing = null; } function preventDefault(event: Event) { @@ -49,6 +56,7 @@ type TryStartCapturingArgs = {| contextId: ContextId, store: Store, source: Event | Element, + abort: () => void, |}; function getTarget(source: Event | Element): ?Element { @@ -71,8 +79,9 @@ function tryStartCapturing({ contextId, store, source, + abort, }: TryStartCapturingArgs): ?MovementCallbacks { - if (capturingFor != null) { + if (capturing != null) { return null; } @@ -109,9 +118,10 @@ function tryStartCapturing({ return null; } - startCapture(id); + startCapture(id, abort); const move = rafSchd((clientSelection: Position) => { + // TODO: isCOmpleted?/ store.dispatch(moveAction({ client: clientSelection })); }); const moveUp = () => { @@ -181,6 +191,13 @@ function tryStartCapturing({ }; } +function tryAbortCapture() { + if (capturing) { + capturing.abort(); + capturing = null; + } +} + type SensorMarshalArgs = {| contextId: ContextId, store: Store, @@ -190,14 +207,33 @@ type SensorMarshalArgs = {| export default function useSensorMarshal({ contextId, store, - useSensorHooks = [useMouseSensor /*useDemoSensor */], + useSensorHooks = [useMouseSensor /* useDemoSensor */], }: SensorMarshalArgs) { + // We need to abort any capturing if there is no longer a drag + useEffect( + function listen() { + let previous: State = store.getState(); + const unsubscribe = store.subscribe(() => { + const current: State = store.getState(); + + if (previous.isDragging && !current.isDragging) { + tryAbortCapture(); + } + + previous = current; + }); + return unsubscribe; + }, + [store], + ); + const tryStartCapture = useCallback( - (source: Event | Element): ?MovementCallbacks => + (source: Event | Element, abort: () => void): ?MovementCallbacks => tryStartCapturing({ contextId, store, source, + abort, }), [contextId, store], ); From e245ec4c0ddfb6eec7da23f259b4ee20a36e8c21 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Mon, 6 May 2019 16:16:19 +1000 Subject: [PATCH 019/308] more resilant api --- .../use-sensor-marshal/use-sensor-marshal.js | 94 ++++++++++++------- 1 file changed, 59 insertions(+), 35 deletions(-) diff --git a/src/view/use-sensor-marshal/use-sensor-marshal.js b/src/view/use-sensor-marshal/use-sensor-marshal.js index 5779df9de7..1fee8a07f8 100644 --- a/src/view/use-sensor-marshal/use-sensor-marshal.js +++ b/src/view/use-sensor-marshal/use-sensor-marshal.js @@ -5,7 +5,7 @@ import { useEffect } from 'react'; import { useCallback } from 'use-memo-one'; import type { Position } from 'css-box-model'; import type { ContextId, DraggableId, State } from '../../types'; -import type { Store } from '../../state/store-types'; +import type { Store, Action } from '../../state/store-types'; import type { MovementCallbacks, SensorHook, @@ -119,28 +119,65 @@ function tryStartCapturing({ } startCapture(id, abort); + let isCapturing: boolean = true; + + function ifCapturing(maybe: Function) { + if (isCapturing) { + maybe(); + return; + } + warning( + 'Trying to perform operation when no longer responsible for capturing', + ); + } + + const tryDispatch = (getAction: () => Action): void => { + if (!isCapturing) { + warning( + 'Trying to perform operation when no longer responsible for capturing', + ); + return; + } + store.dispatch(getAction()); + }; + const moveUp = () => tryDispatch(moveUpAction); + const moveDown = () => tryDispatch(moveDownAction); + const moveRight = () => tryDispatch(moveRightAction); + const moveLeft = () => tryDispatch(moveLeftAction); const move = rafSchd((clientSelection: Position) => { - // TODO: isCOmpleted?/ - store.dispatch(moveAction({ client: clientSelection })); + ifCapturing(() => store.dispatch(moveAction({ client: clientSelection }))); }); - const moveUp = () => { - store.dispatch(moveUpAction()); - }; - const moveDown = () => { - store.dispatch(moveDownAction()); - }; - const moveRight = () => { - store.dispatch(moveRightAction()); - }; - const moveLeft = () => { - store.dispatch(moveLeftAction()); - }; + + function lift(args: OnLiftArgs) { + const actionArgs = + args.mode === 'FLUID' + ? { + clientSelection: args.clientSelection, + movementMode: 'FLUID', + id, + } + : { + movementMode: 'SNAP', + clientSelection: getBorderBoxCenterPosition(draggable), + id, + }; + + tryDispatch(() => liftAction(actionArgs)); + } + const finish = ( options?: CaptureEndOptions = { shouldBlockNextClick: false }, + action?: Action, ) => { + if (!isCapturing) { + warning('Cannot finish a drag when not capturing'); + return; + } + // stopping capture stopCapture(); + isCapturing = false; // block next click if requested if (options.shouldBlockNextClick) { @@ -154,38 +191,25 @@ function tryStartCapturing({ // cancel any pending request animation frames move.cancel(); + + if (action) { + store.dispatch(action); + } }; return { shouldRespectForcePress: (): boolean => shouldRespectForcePress, - lift: (args: OnLiftArgs) => { - const actionArgs = - args.mode === 'FLUID' - ? { - clientSelection: args.clientSelection, - movementMode: 'FLUID', - id, - } - : { - movementMode: 'SNAP', - clientSelection: getBorderBoxCenterPosition(draggable), - id, - }; - - store.dispatch(liftAction(actionArgs)); - }, + lift, move, moveUp, moveDown, moveRight, moveLeft, drop: (args?: CaptureEndOptions) => { - finish(args); - store.dispatch(dropAction({ reason: 'DROP' })); + finish(args, dropAction({ reason: 'DROP' })); }, cancel: (args?: CaptureEndOptions) => { - finish(args); - store.dispatch(dropAction({ reason: 'CANCEL' })); + finish(args, dropAction({ reason: 'CANCEL' })); }, abort: () => finish(), }; From 0916145833ed6acf8870667a29ddb316c6307ce9 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Mon, 6 May 2019 17:17:13 +1000 Subject: [PATCH 020/308] removing old handle --- .size-snapshot.json | 24 ++++++------- src/view/use-drag-handle/use-drag-handle.js | 37 ++------------------- 2 files changed, 14 insertions(+), 47 deletions(-) diff --git a/.size-snapshot.json b/.size-snapshot.json index 9572783360..c4fe2c332d 100644 --- a/.size-snapshot.json +++ b/.size-snapshot.json @@ -1,25 +1,25 @@ { "dist/react-beautiful-dnd.js": { - "bundled": 393181, - "minified": 147695, - "gzipped": 41532 + "bundled": 455761, + "minified": 168875, + "gzipped": 49642 }, "dist/react-beautiful-dnd.min.js": { - "bundled": 324803, - "minified": 116622, - "gzipped": 33468 + "bundled": 386756, + "minified": 137264, + "gzipped": 41254 }, "dist/react-beautiful-dnd.esm.js": { - "bundled": 239412, - "minified": 124303, - "gzipped": 31620, + "bundled": 250198, + "minified": 130761, + "gzipped": 33443, "treeshaked": { "rollup": { - "code": 30396, - "import_statements": 793 + "code": 28810, + "import_statements": 1004 }, "webpack": { - "code": 34327 + "code": 32753 } } } diff --git a/src/view/use-drag-handle/use-drag-handle.js b/src/view/use-drag-handle/use-drag-handle.js index 1ebb637918..bbd09647fa 100644 --- a/src/view/use-drag-handle/use-drag-handle.js +++ b/src/view/use-drag-handle/use-drag-handle.js @@ -6,9 +6,6 @@ import type { Args, DragHandleProps } from './drag-handle-types'; import getWindowFromEl from '../window/get-window-from-el'; import useRequiredContext from '../use-required-context'; import AppContext, { type AppContextValue } from '../context/app-context'; -import useMouseSensor, { - type Args as MouseSensorArgs, -} from './sensor/use-mouse-sensor'; import shouldAllowDraggingFromTarget from './util/should-allow-dragging-from-target'; import useKeyboardSensor, { type Args as KeyboardSensorArgs, @@ -101,28 +98,6 @@ export default function useDragHandle(args: Args): ?DragHandleProps { const { onBlur, onFocus } = useFocusRetainer(args); - const mouseArgs: MouseSensorArgs = useMemo( - () => ({ - callbacks, - getDraggableRef, - getWindow, - canStartCapturing, - onCaptureStart, - onCaptureEnd, - getShouldRespectForcePress, - }), - [ - callbacks, - getDraggableRef, - getWindow, - canStartCapturing, - onCaptureStart, - onCaptureEnd, - getShouldRespectForcePress, - ], - ); - const onMouseDown = useMouseSensor(mouseArgs); - const keyboardArgs: KeyboardSensorArgs = useMemo( () => ({ callbacks, @@ -209,7 +184,7 @@ export default function useDragHandle(args: Args): ?DragHandleProps { return null; } return { - onMouseDown, + onMouseDown: () => {}, onKeyDown, onTouchStart, onFocus, @@ -222,15 +197,7 @@ export default function useDragHandle(args: Args): ?DragHandleProps { draggable: false, onDragStart: preventHtml5Dnd, }; - }, [ - contextId, - isEnabled, - onBlur, - onFocus, - onKeyDown, - onMouseDown, - onTouchStart, - ]); + }, [contextId, isEnabled, onBlur, onFocus, onKeyDown, onTouchStart]); return props; } From 2a8cdd412179005643a1e6784be4c666db613b55 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Tue, 7 May 2019 07:25:38 +1000 Subject: [PATCH 021/308] api time --- src/debug/use-demo-sensor.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/debug/use-demo-sensor.js b/src/debug/use-demo-sensor.js index b5ab4b3c87..8c7704305f 100644 --- a/src/debug/use-demo-sensor.js +++ b/src/debug/use-demo-sensor.js @@ -15,7 +15,10 @@ function delay(fn: Function, time?: number = 300) { function noop() {} export default function useDemoSensor( - tryStartCapturing: (source: Event | Element, noop) => ?MovementCallbacks, + tryStartCapturing: ( + source: Event | Element, + abort: () => void, + ) => ?MovementCallbacks, ) { const start = useCallback( async function start() { @@ -30,7 +33,7 @@ export default function useDemoSensor( // handle.scrollIntoView(); - const callbacks: ?MovementCallbacks = tryStartCapturing(handle); + const callbacks: ?MovementCallbacks = tryStartCapturing(handle, noop); if (!callbacks) { console.log('unable to start drag'); From 4fcc67bc94d4d0b3706100bff2b8c940c9f53010 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Tue, 7 May 2019 11:32:25 +1000 Subject: [PATCH 022/308] programatic example --- src/view/drag-drop-context/app.jsx | 5 +- .../drag-drop-context/drag-drop-context.jsx | 3 + src/view/use-drag-handle/use-drag-handle.js | 4 +- .../sensors/use-keyboard-sensor.js | 233 ++++++++++++++++++ .../use-sensor-marshal/use-sensor-marshal.js | 19 +- stories/40-programmatic.stories.js | 9 + stories/src/programmatic/with-controls.jsx | 178 +++++++++++++ 7 files changed, 442 insertions(+), 9 deletions(-) create mode 100644 src/view/use-sensor-marshal/sensors/use-keyboard-sensor.js create mode 100644 stories/40-programmatic.stories.js create mode 100644 stories/src/programmatic/with-controls.jsx diff --git a/src/view/drag-drop-context/app.jsx b/src/view/drag-drop-context/app.jsx index 812e1acf16..4713c1c85d 100644 --- a/src/view/drag-drop-context/app.jsx +++ b/src/view/drag-drop-context/app.jsx @@ -12,6 +12,7 @@ import createAutoScroller from '../../state/auto-scroller'; import useStyleMarshal from '../use-style-marshal/use-style-marshal'; import type { AutoScroller } from '../../state/auto-scroller/auto-scroller-types'; import type { StyleMarshal } from '../use-style-marshal/style-marshal-types'; +import type { SensorHook } from '../use-sensor-marshal/sensor-types'; import type { DimensionMarshal, Callbacks as DimensionMarshalCallbacks, @@ -42,6 +43,7 @@ type Props = {| setOnError: (onError: Function) => void, // we do not technically need any children for this component children: Node | null, + __unstableSensors?: SensorHook[], |}; const createResponders = (props: Props): Responders => ({ @@ -61,7 +63,7 @@ function getStore(lazyRef: LazyStoreRef): Store { } export default function App(props: Props) { - const { contextId, setOnError } = props; + const { contextId, setOnError, __unstableSensors } = props; const lazyStoreRef: LazyStoreRef = useRef(null); useStartupValidation(); @@ -173,6 +175,7 @@ export default function App(props: Props) { useSensorMarshal({ contextId, store, + customSensors: __unstableSensors, }); // Clean store when unmounting diff --git a/src/view/drag-drop-context/drag-drop-context.jsx b/src/view/drag-drop-context/drag-drop-context.jsx index 43b040aac5..19815b3de8 100644 --- a/src/view/drag-drop-context/drag-drop-context.jsx +++ b/src/view/drag-drop-context/drag-drop-context.jsx @@ -2,6 +2,7 @@ import React, { type Node } from 'react'; import { useMemo } from 'use-memo-one'; import type { Responders, ContextId } from '../../types'; +import type { SensorHook } from '../use-sensor-marshal/sensor-types'; import ErrorBoundary from '../error-boundary'; import App from './app'; @@ -9,6 +10,8 @@ type Props = {| ...Responders, // we do not technically need any children for this component children: Node | null, + + __unstableSensors?: SensorHook[], |}; let instanceCount: number = 0; diff --git a/src/view/use-drag-handle/use-drag-handle.js b/src/view/use-drag-handle/use-drag-handle.js index bbd09647fa..8c9bf96786 100644 --- a/src/view/use-drag-handle/use-drag-handle.js +++ b/src/view/use-drag-handle/use-drag-handle.js @@ -185,7 +185,7 @@ export default function useDragHandle(args: Args): ?DragHandleProps { } return { onMouseDown: () => {}, - onKeyDown, + onKeyDown: () => {}, onTouchStart, onFocus, onBlur, @@ -197,7 +197,7 @@ export default function useDragHandle(args: Args): ?DragHandleProps { draggable: false, onDragStart: preventHtml5Dnd, }; - }, [contextId, isEnabled, onBlur, onFocus, onKeyDown, onTouchStart]); + }, [contextId, isEnabled, onBlur, onFocus, onTouchStart]); return props; } diff --git a/src/view/use-sensor-marshal/sensors/use-keyboard-sensor.js b/src/view/use-sensor-marshal/sensors/use-keyboard-sensor.js new file mode 100644 index 0000000000..57a137fd25 --- /dev/null +++ b/src/view/use-sensor-marshal/sensors/use-keyboard-sensor.js @@ -0,0 +1,233 @@ +// @flow +import invariant from 'tiny-invariant'; +import { useRef, useEffect } from 'react'; +import { useMemo, useCallback } from 'use-memo-one'; +import type { MovementCallbacks } from '../sensor-types'; +import type { + EventBinding, + 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'; + +function noop() {} + +type KeyMap = { + [key: number]: true, +}; + +const scrollJumpKeys: KeyMap = { + [keyCodes.pageDown]: true, + [keyCodes.pageUp]: true, + [keyCodes.home]: true, + [keyCodes.end]: true, +}; + +function getDraggingBindings( + callbacks: MovementCallbacks, + stop: () => void, +): EventBinding[] { + function cancel() { + stop(); + callbacks.cancel(); + } + + function drop() { + stop(); + callbacks.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(); + callbacks.moveDown(); + return; + } + + if (event.keyCode === keyCodes.arrowUp) { + event.preventDefault(); + callbacks.moveUp(); + return; + } + + if (event.keyCode === keyCodes.arrowRight) { + event.preventDefault(); + callbacks.moveRight(); + return; + } + + if (event.keyCode === keyCodes.arrowLeft) { + event.preventDefault(); + callbacks.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( + tryStartCapturing: ( + source: Event | Element, + abort: () => void, + ) => ?MovementCallbacks, +) { + const unbindEventsRef = useRef<() => void>(noop); + + const startCaptureBinding: EventBinding = useMemo( + () => ({ + eventName: 'keydown', + fn: function onMouseDown(event: KeyboardEvent) { + // 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; + } + + // Need to start drag with a spacebar press + if (event.keyCode !== keyCodes.space) { + return; + } + + // abort function not defined yet + // eslint-disable-next-line no-use-before-define + const callbacks: ?MovementCallbacks = tryStartCapturing(event, stop); + + // Cannot start capturing at this time + if (!callbacks) { + return; + } + + // we are consuming the event + event.preventDefault(); + let isCapturing: boolean = true; + + // There is no pending period for a keyboard drag + // We can lift immediately + callbacks.lift({ + mode: 'SNAP', + }); + + // 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 no-use-before-define + listenForCapture(); + } + + // bind dragging listeners + unbindEventsRef.current = bindEvents( + window, + getDraggingBindings(callbacks, stop), + { capture: true, passive: false }, + ); + }, + }), + // not including startPendingDrag as it is not defined initially + // eslint-disable-next-line react-hooks/exhaustive-deps + [tryStartCapturing], + ); + + const listenForCapture = useCallback( + function tryStartCapture() { + const options: EventOptions = { + passive: false, + capture: true, + }; + + unbindEventsRef.current = bindEvents( + window, + [startCaptureBinding], + options, + ); + }, + [startCaptureBinding], + ); + + useEffect(() => { + listenForCapture(); + + // kill any pending window events when unmounting + return () => { + unbindEventsRef.current(); + }; + }); +} diff --git a/src/view/use-sensor-marshal/use-sensor-marshal.js b/src/view/use-sensor-marshal/use-sensor-marshal.js index 1fee8a07f8..0b63ccae47 100644 --- a/src/view/use-sensor-marshal/use-sensor-marshal.js +++ b/src/view/use-sensor-marshal/use-sensor-marshal.js @@ -27,10 +27,10 @@ import useMouseSensor from './sensors/use-mouse-sensor'; import useValidateSensorHooks from './use-validate-sensor-hooks'; import isHandleInInteractiveElement from './is-handle-in-interactive-element'; import getOptionsFromDraggable from './get-options-from-draggable'; -import useDemoSensor from '../../debug/use-demo-sensor'; import getBorderBoxCenterPosition from '../get-border-box-center-position'; import { warning } from '../../dev-warning'; import isHtmlElement from '../is-type-of-element/is-html-element'; +import useKeyboardSensor from './sensors/use-keyboard-sensor'; type Capturing = {| id: DraggableId, @@ -225,14 +225,21 @@ function tryAbortCapture() { type SensorMarshalArgs = {| contextId: ContextId, store: Store, - useSensorHooks?: SensorHook[], + customSensors: ?(SensorHook[]), |}; +const defaultSensors: SensorHook[] = [useMouseSensor, useKeyboardSensor]; + export default function useSensorMarshal({ contextId, store, - useSensorHooks = [useMouseSensor /* useDemoSensor */], + customSensors, }: SensorMarshalArgs) { + const useSensors: SensorHook[] = [ + ...defaultSensors, + ...(customSensors || []), + ]; + // We need to abort any capturing if there is no longer a drag useEffect( function listen() { @@ -263,8 +270,8 @@ export default function useSensorMarshal({ ); // Bad ass - useValidateSensorHooks(useSensorHooks); - for (let i = 0; i < useSensorHooks.length; i++) { - useSensorHooks[i](tryStartCapture); + useValidateSensorHooks(useSensors); + for (let i = 0; i < useSensors.length; i++) { + useSensors[i](tryStartCapture); } } diff --git a/stories/40-programmatic.stories.js b/stories/40-programmatic.stories.js new file mode 100644 index 0000000000..49b39a4509 --- /dev/null +++ b/stories/40-programmatic.stories.js @@ -0,0 +1,9 @@ +// @flow +import React from 'react'; +import { storiesOf } from '@storybook/react'; +import WithControls from './src/programmatic/with-controls'; +import { quotes } from './src/data'; + +storiesOf('Programmatic dragging', module).add('with controls', () => ( + +)); diff --git a/stories/src/programmatic/with-controls.jsx b/stories/src/programmatic/with-controls.jsx new file mode 100644 index 0000000000..111749f2f5 --- /dev/null +++ b/stories/src/programmatic/with-controls.jsx @@ -0,0 +1,178 @@ +// @flow +import React, { + useRef, + createRef, + useEffect, + useState, + useCallback, +} from 'react'; +import styled from '@emotion/styled'; +import type { Quote } from '../types'; +import type { DropResult } from '../../../src/types'; +import type { MovementCallbacks } from '../../../src/view/use-sensor-marshal/sensor-types'; +import { DragDropContext } from '../../../src'; +import QuoteList from '../primatives/quote-list'; +import reorder from '../reorder'; +import { grid } from '../constants'; + +type ControlProps = {| + quotes: Quote[], + canLift: boolean, + isDragging: boolean, + lift: (quoteId: string) => ?MovementCallbacks, +|}; + +function noop() {} + +function Controls(props: ControlProps) { + const { quotes, canLift, isDragging, lift } = props; + const callbacksRef = useRef(null); + + const selectRef = createRef(); + + function maybe(fn: (callbacks: MovementCallbacks) => void) { + if (callbacksRef.current) { + fn(callbacksRef.current); + } + } + + return ( +
+ + + + + +
+ ); +} + +type Props = {| + initial: Quote[], +|}; + +export default function QuoteApp(props: Props) { + const [quotes, setQuotes] = useState(props.initial); + const [isDragging, setIsDragging] = useState(false); + const [isControlDragging, setIsControlDragging] = useState(false); + const tryStartCapturingRef = useRef< + (el: Element, abort: () => void) => ?MovementCallbacks, + >(() => null); + + const onDragEnd = useCallback( + function onDragEnd(result: DropResult) { + setIsDragging(false); + setIsControlDragging(false); + // dropped outside the list + if (!result.destination) { + return; + } + + if (result.destination.index === result.source.index) { + return; + } + + const newQuotes = reorder( + quotes, + result.source.index, + result.destination.index, + ); + + setQuotes(newQuotes); + }, + [quotes], + ); + + function lift(quoteId: string): ?MovementCallbacks { + if (isDragging) { + return null; + } + const selector: string = `[data-rbd-draggable-id="${quoteId}"][data-rbd-drag-handle-context-id]`; + const handle: ?HTMLElement = document.querySelector(selector); + if (!handle) { + console.log('could not find drag handle'); + return null; + } + + const callbacks: ?MovementCallbacks = tryStartCapturingRef.current( + handle, + noop, + ); + + if (!callbacks) { + console.log('unable to start capturing'); + return null; + } + console.log('capture started'); + callbacks.lift({ mode: 'SNAP' }); + setIsControlDragging(true); + + return callbacks; + } + + return ( + setIsDragging(true)} + onDragEnd={onDragEnd} + __unstableSensors={[ + tryCapture => { + tryStartCapturingRef.current = tryCapture; + }, + ]} + > + + + + ); +} From a6e0d7ff9ef2a6e4493d847ca021d6f15995c863 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Tue, 7 May 2019 14:55:57 +1000 Subject: [PATCH 023/308] programatic example --- .storybook/.babelrc | 4 +- babel.config.js | 1 - package.json | 1 + stories/src/programmatic/with-controls.jsx | 150 +++++++++++++++------ yarn.lock | 19 ++- 5 files changed, 131 insertions(+), 44 deletions(-) diff --git a/.storybook/.babelrc b/.storybook/.babelrc index 309c47e728..c019ede44a 100644 --- a/.storybook/.babelrc +++ b/.storybook/.babelrc @@ -2,9 +2,11 @@ "presets": [ "@babel/react", "@babel/flow", - ["@babel/env", { "modules": false, "loose": true }] + ["@babel/env", { "modules": false, "loose": true }], + "@emotion/babel-preset-css-prop" ], "plugins": [ + "emotion", ["@babel/proposal-class-properties", { "loose": true }], ["@babel/proposal-object-rest-spread", { "loose": true }] ], diff --git a/babel.config.js b/babel.config.js index 8e9193cdcd..050fc64e40 100644 --- a/babel.config.js +++ b/babel.config.js @@ -2,7 +2,6 @@ module.exports = { presets: ['@babel/react', '@babel/flow', ['@babel/env', { loose: true }]], plugins: [ ['@babel/proposal-class-properties', { loose: true }], - 'emotion', // used for stripping out the `invariant` messages in production builds 'dev-expression', ], diff --git a/package.json b/package.json index 35387f5633..86b73ee569 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "@babel/preset-env": "^7.4.3", "@babel/preset-flow": "^7.0.0", "@babel/preset-react": "^7.0.0", + "@emotion/babel-preset-css-prop": "^10.0.9", "@emotion/core": "^10.0.10", "@emotion/styled": "^10.0.10", "@storybook/react": "^5.0.6", diff --git a/stories/src/programmatic/with-controls.jsx b/stories/src/programmatic/with-controls.jsx index 111749f2f5..aca897ec0a 100644 --- a/stories/src/programmatic/with-controls.jsx +++ b/stories/src/programmatic/with-controls.jsx @@ -1,11 +1,6 @@ // @flow -import React, { - useRef, - createRef, - useEffect, - useState, - useCallback, -} from 'react'; +/* eslint-disable no-console */ +import React, { useRef, createRef, useState, useCallback } from 'react'; import styled from '@emotion/styled'; import type { Quote } from '../types'; import type { DropResult } from '../../../src/types'; @@ -13,7 +8,7 @@ import type { MovementCallbacks } from '../../../src/view/use-sensor-marshal/sen import { DragDropContext } from '../../../src'; import QuoteList from '../primatives/quote-list'; import reorder from '../reorder'; -import { grid } from '../constants'; +import { grid, borderRadius } from '../constants'; type ControlProps = {| quotes: Quote[], @@ -24,6 +19,56 @@ type ControlProps = {| function noop() {} +const ControlBox = styled.div` + display: flex; + flex-direction: column; +`; + +const ArrowBox = styled.div` + margin-top: ${grid * 4}px; + display: flex; + flex-direction: column; + align-items: center; +`; + +const Button = styled.button` + --off-white: hsla(60, 100%, 98%, 1); + --dark-off-white: #efefe3; + --darker-off-white: #d6d6cb; + --border-width: 4px; + + background: var(--off-white); + border-radius: ${borderRadius}px; + cursor: ${props => (props.disabled ? 'not-allowed' : 'pointer')}; + font-size: 16px; + position: relative; + box-sizing: border-box; + border: var(--border-width) solid var(--dark-off-white); + box-shadow: 0 0 0 1px var(--darker-off-white); + margin: 2px; + + ::before { + position: absolute; + content: ' '; + top: 0; + right: 0; + bottom: 0; + left: 0; + border: 1px solid var(--dark-off-white); + } + + :active { + border-width: 3px; + } +`; + +const ArrowButton = styled(Button)` + width: 40px; + height: 40px; +`; + +const ActionButton = styled(Button)``; + function Controls(props: ControlProps) { const { quotes, canLift, isDragging, lift } = props; const callbacksRef = useRef(null); @@ -37,7 +82,7 @@ function Controls(props: ControlProps) { } return ( -
+ - - - - -
+ Drop 🤾‍♂️ + + + + maybe((callbacks: MovementCallbacks) => callbacks.moveUp()) + } + disabled={!isDragging} + label="up" + > + ↑ + +
+ + ← + + + maybe((callbacks: MovementCallbacks) => callbacks.moveDown()) + } + disabled={!isDragging} + label="down" + > + ↓ + + + → + +
+
+ ); } +const Layout = styled.div` + display: flex; + justify-content: center; + + > * { + margin: ${grid}px; + } +`; + type Props = {| initial: Quote[], |}; @@ -166,13 +232,15 @@ export default function QuoteApp(props: Props) { }, ]} > - - + + + + ); } diff --git a/yarn.lock b/yarn.lock index 48b1267b36..7cdd0aac47 100644 --- a/yarn.lock +++ b/yarn.lock @@ -917,7 +917,7 @@ "@babel/helper-plugin-utils" "^7.0.0" "@babel/plugin-syntax-jsx" "^7.2.0" -"@babel/plugin-transform-react-jsx@^7.0.0": +"@babel/plugin-transform-react-jsx@^7.0.0", "@babel/plugin-transform-react-jsx@^7.1.6": version "7.3.0" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.3.0.tgz#f2cab99026631c767e2745a5368b331cfe8f5290" integrity sha512-a/+aRb7R06WcKvQLOu4/TpjKOdvVEKRLWFpKcNuHhiREPgGRB4TQJxq07+EZLS8LFVYpfq1a5lDUnuMdcCpBKg== @@ -1345,6 +1345,23 @@ debug "^3.1.0" lodash.once "^4.1.1" +"@emotion/babel-plugin-jsx-pragmatic@^0.1.2": + version "0.1.2" + resolved "https://registry.yarnpkg.com/@emotion/babel-plugin-jsx-pragmatic/-/babel-plugin-jsx-pragmatic-0.1.2.tgz#bb98bbef8effe83418307563c34e784deae57a1a" + integrity sha512-BapTL0I1flAB+qrfOmltOdLORBtz8dvtKjcHZmYYWdiGsn+2bZxaZDra+S0jDLd1tnhvPvhHoGv3140WR8PAow== + dependencies: + "@babel/plugin-syntax-jsx" "^7.2.0" + +"@emotion/babel-preset-css-prop@^10.0.9": + version "10.0.9" + resolved "https://registry.yarnpkg.com/@emotion/babel-preset-css-prop/-/babel-preset-css-prop-10.0.9.tgz#70386bd88fe4d8896e1b9729364daf3a6051f726" + integrity sha512-fETOWFEe734RlJZTuq6+NeHTzl+Kge4yRm3yrQC+Y2I+KxZjYiU5XUPdbylr0EATbkSzFXgVGKppciZfA5j1mw== + dependencies: + "@babel/plugin-transform-react-jsx" "^7.1.6" + "@emotion/babel-plugin-jsx-pragmatic" "^0.1.2" + babel-plugin-emotion "^10.0.9" + object-assign "^4.1.1" + "@emotion/cache@^10.0.7": version "10.0.7" resolved "https://registry.yarnpkg.com/@emotion/cache/-/cache-10.0.7.tgz#6221de2e939f62022c04b4df2c165ce577809f23" From 058a15df2a343040f536d4c6f4b5bc206efe619e Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Tue, 7 May 2019 15:05:12 +1000 Subject: [PATCH 024/308] example --- stories/src/programmatic/with-controls.jsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/stories/src/programmatic/with-controls.jsx b/stories/src/programmatic/with-controls.jsx index aca897ec0a..ec58dfe24e 100644 --- a/stories/src/programmatic/with-controls.jsx +++ b/stories/src/programmatic/with-controls.jsx @@ -67,7 +67,9 @@ const ArrowButton = styled(Button)` height: 40px; `; -const ActionButton = styled(Button)``; +const ActionButton = styled(Button)` + height: 40px; +`; function Controls(props: ControlProps) { const { quotes, canLift, isDragging, lift } = props; @@ -153,6 +155,7 @@ function Controls(props: ControlProps) { const Layout = styled.div` display: flex; justify-content: center; + margin-top: ${grid * 4}px; > * { margin: ${grid}px; From 04c7ef2b8f0d45e6447dc8bd1e8c2b743fa16ccd Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Wed, 8 May 2019 09:45:50 +1000 Subject: [PATCH 025/308] example --- package.json | 1 + .../sensors/use-keyboard-sensor.js | 6 +- .../sensors/use-mouse-sensor.js | 10 ++ .../sensors/use-touch-sensor.js | 1 + .../use-sensor-marshal/use-sensor-marshal.js | 6 + stories/src/programmatic/with-controls.jsx | 2 + test/unit/integration/drag-handle/app.jsx | 56 +++++++ .../drag-handle/start-dragging.spec.js | 151 ++++++++++++++++++ yarn.lock | 28 ++++ 9 files changed, 257 insertions(+), 4 deletions(-) create mode 100644 src/view/use-sensor-marshal/sensors/use-touch-sensor.js create mode 100644 test/unit/integration/drag-handle/app.jsx create mode 100644 test/unit/integration/drag-handle/start-dragging.spec.js diff --git a/package.json b/package.json index 86b73ee569..2cf9d6126f 100644 --- a/package.json +++ b/package.json @@ -113,6 +113,7 @@ "react": "^16.8.6", "react-dom": "^16.8.6", "react-test-renderer": "^16.8.6", + "react-testing-library": "^7.0.0", "rimraf": "^2.6.3", "rollup": "^1.9.0", "rollup-plugin-babel": "^4.3.2", diff --git a/src/view/use-sensor-marshal/sensors/use-keyboard-sensor.js b/src/view/use-sensor-marshal/sensors/use-keyboard-sensor.js index 57a137fd25..f2a76aeb47 100644 --- a/src/view/use-sensor-marshal/sensors/use-keyboard-sensor.js +++ b/src/view/use-sensor-marshal/sensors/use-keyboard-sensor.js @@ -143,10 +143,8 @@ export default function useKeyboardSensor( const startCaptureBinding: EventBinding = useMemo( () => ({ eventName: 'keydown', - fn: function onMouseDown(event: KeyboardEvent) { - // 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 + fn: function onKeyDown(event: KeyboardEvent) { + // Event already used if (event.defaultPrevented) { return; } diff --git a/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js b/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js index fbc7a0b684..83fb0698ff 100644 --- a/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js +++ b/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js @@ -219,13 +219,21 @@ export default function useMouseSensor( () => ({ eventName: 'mousedown', fn: function onMouseDown(event: MouseEvent) { + console.log('on mouse down'); + // Event already used + if (event.defaultPrevented) { + console.log('default prevented'); + return; + } // only starting a drag if dragging with the primary mouse button if (event.button !== primaryButton) { + console.log('bye'); return; } // Do not start a drag if any modifier key is pressed if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) { + console.log('special key'); return; } @@ -238,6 +246,7 @@ export default function useMouseSensor( return; } + console.log('calling prevent default'); event.preventDefault(); const point: Position = { @@ -249,6 +258,7 @@ export default function useMouseSensor( unbindEventsRef.current(); // using this function before it is defined as their is a circular usage pattern // eslint-disable-next-line no-use-before-define + console.log('starting pending'); startPendingDrag(callbacks, point); }, }), diff --git a/src/view/use-sensor-marshal/sensors/use-touch-sensor.js b/src/view/use-sensor-marshal/sensors/use-touch-sensor.js new file mode 100644 index 0000000000..46e7f7c045 --- /dev/null +++ b/src/view/use-sensor-marshal/sensors/use-touch-sensor.js @@ -0,0 +1 @@ +// @flow diff --git a/src/view/use-sensor-marshal/use-sensor-marshal.js b/src/view/use-sensor-marshal/use-sensor-marshal.js index 0b63ccae47..57911c258d 100644 --- a/src/view/use-sensor-marshal/use-sensor-marshal.js +++ b/src/view/use-sensor-marshal/use-sensor-marshal.js @@ -31,6 +31,7 @@ import getBorderBoxCenterPosition from '../get-border-box-center-position'; import { warning } from '../../dev-warning'; import isHtmlElement from '../is-type-of-element/is-html-element'; import useKeyboardSensor from './sensors/use-keyboard-sensor'; +import useLayoutEffect from '../use-isomorphic-layout-effect'; type Capturing = {| id: DraggableId, @@ -258,6 +259,11 @@ export default function useSensorMarshal({ [store], ); + // abort any captures on unmount + useLayoutEffect(() => { + return tryAbortCapture; + }, []); + const tryStartCapture = useCallback( (source: Event | Element, abort: () => void): ?MovementCallbacks => tryStartCapturing({ diff --git a/stories/src/programmatic/with-controls.jsx b/stories/src/programmatic/with-controls.jsx index ec58dfe24e..c0930b254a 100644 --- a/stories/src/programmatic/with-controls.jsx +++ b/stories/src/programmatic/with-controls.jsx @@ -67,6 +67,8 @@ const ArrowButton = styled(Button)` height: 40px; `; +// locking the height so that the border width change +// does not change the size of the button const ActionButton = styled(Button)` height: 40px; `; diff --git a/test/unit/integration/drag-handle/app.jsx b/test/unit/integration/drag-handle/app.jsx new file mode 100644 index 0000000000..28a7023235 --- /dev/null +++ b/test/unit/integration/drag-handle/app.jsx @@ -0,0 +1,56 @@ +// @flow +import React, { useState } from 'react'; +import { + DragDropContext, + Droppable, + Draggable, + type DroppableProvided, + type DraggableProvided, +} from '../../../../src'; + +type Item = {| + id: string, +|}; + +export default function App() { + const [items] = useState(() => + Array.from( + { length: 3 }, + (v, k): Item => ({ + id: `${k}`, + }), + ), + ); + + return ( + + + {(droppableProvided: DroppableProvided) => ( +
+ {items.map((item: Item, index: number) => ( + + {( + provided: DraggableProvided, + snapshot: DraggableStateSnapshot, + ) => ( +
+ item: {item.id} +
+ )} +
+ ))} + {droppableProvided.placeholder} +
+ )} +
+
+ ); +} diff --git a/test/unit/integration/drag-handle/start-dragging.spec.js b/test/unit/integration/drag-handle/start-dragging.spec.js new file mode 100644 index 0000000000..7f6c5e56b3 --- /dev/null +++ b/test/unit/integration/drag-handle/start-dragging.spec.js @@ -0,0 +1,151 @@ +// @flow +import React from 'react'; +import type { Position } from 'css-box-model'; +import { render, fireEvent } from 'react-testing-library'; +import { sloppyClickThreshold } from '../../../../src/view/use-sensor-marshal/sensors/util/is-sloppy-click-threshold-exceeded'; +import App from './app'; + +const primaryButton: number = 0; + +function isDragging(el: HTMLElement): boolean { + return el.getAttribute('data-is-dragging') === 'true'; +} + +function getStartingMouseDown(): MouseEvent { + return new MouseEvent('mousedown', { + clientX: 0, + clientY: 0, + cancelable: true, + bubbles: true, + button: primaryButton, + }); +} + +it('should start a drag after sufficient movement', () => { + const valid: Position[] = [ + { x: 0, y: sloppyClickThreshold }, + { x: 0, y: -sloppyClickThreshold }, + { x: sloppyClickThreshold, y: 0 }, + { x: -sloppyClickThreshold, y: 0 }, + ]; + + valid.forEach((point: Position) => { + const { getByText, unmount } = render(); + + const handle: HTMLElement = getByText('item: 0'); + + const mouseDown: MouseEvent = new MouseEvent('mousedown', { + clientX: 0, + clientY: 0, + button: primaryButton, + bubbles: true, + cancelable: true, + }); + + fireEvent(handle, mouseDown); + // important that this is called to prevent focus + expect(mouseDown.defaultPrevented).toBe(true); + + // not dragging yet + expect(isDragging(handle)).toBe(false); + + // mouse move to start drag + const mouseMove: MouseEvent = new MouseEvent('mousemove', { + target: handle, + clientX: point.x, + clientY: point.y, + bubbles: true, + cancelable: true, + }); + fireEvent(window, mouseMove); + // we are using the event - so prevent default is called + expect(mouseMove.defaultPrevented).toBe(true); + + // now dragging + expect(isDragging(handle)).toBe(true); + + unmount(); + }); +}); + +it('should allow standard click events', () => { + const { getByText, unmount } = render(); + const handle: HTMLElement = getByText('item: 0'); + + const click: MouseEvent = new MouseEvent('click', { + target: handle, + bubbles: true, + cancelable: true, + }); + fireEvent(handle, click); + + expect(click.defaultPrevented).toBe(false); + + unmount(); +}); + +it('should not call preventDefault on mouse movements while we are not sure if a drag is starting', () => { + const { getByText, unmount } = render(); + const handle: HTMLElement = getByText('item: 0'); + + // start pending + fireEvent.mouseDown(handle, { + clientX: 0, + clientY: 0, + button: primaryButton, + }); + + // not dragging yet + const mouseMove: MouseEvent = new MouseEvent('mousemove', { + clientX: 0, + clientY: sloppyClickThreshold - 1, + cancelable: true, + bubbles: true, + }); + fireEvent(handle, mouseMove); + + expect(isDragging(handle)).toBe(false); + expect(mouseMove.defaultPrevented).toBe(false); + + unmount(); +}); + +it('should call preventDefault on the initial mousedown event to prevent the element gaining focus', () => { + const { getByText, unmount } = render(); + const handle: HTMLElement = getByText('item: 0'); + + const mouseDown: MouseEvent = new MouseEvent('mousedown', { + clientX: 0, + clientY: 0, + cancelable: true, + bubbles: true, + button: primaryButton, + }); + fireEvent(handle, mouseDown); + + expect(mouseDown.defaultPrevented).toBe(true); + + unmount(); +}); + +it('should allow multiple false starts', () => { + const { getByText, unmount } = render(); + const handle: HTMLElement = getByText('item: 0'); + + Array.from({ length: 5 }).forEach(() => { + fireEvent.mouseDown(handle, getStartingMouseDown()); + fireEvent.mouseUp(handle); + + expect(isDragging(handle)).toBe(false); + }); + + fireEvent.mouseDown(handle, getStartingMouseDown()); + fireEvent.mouseMove(handle, { + clientX: 0, + clientY: sloppyClickThreshold, + }); + + expect(isDragging(handle)).toBe(true); + + unmount(); +}); diff --git a/yarn.lock b/yarn.lock index 7cdd0aac47..57af2a6d3a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1690,6 +1690,11 @@ react-lifecycles-compat "^3.0.4" warning "^3.0.0" +"@sheerun/mutationobserver-shim@^0.3.2": + version "0.3.2" + resolved "https://registry.yarnpkg.com/@sheerun/mutationobserver-shim/-/mutationobserver-shim-0.3.2.tgz#8013f2af54a2b7d735f71560ff360d3a8176a87b" + integrity sha512-vTCdPp/T/Q3oSqwHmZ5Kpa9oI7iLtGl3RQaA/NyLHikvcrPxACkkKVr/XzkSPJWXHRhKGzVvb0urJsbMlRxi1Q== + "@storybook/addons@5.0.6": version "5.0.6" resolved "https://registry.yarnpkg.com/@storybook/addons/-/addons-5.0.6.tgz#3ecb7fd8421e6557b1abc71ebede905e8decfebc" @@ -4776,6 +4781,16 @@ dom-serializer@0, dom-serializer@~0.1.0: domelementtype "^1.3.0" entities "^1.1.1" +dom-testing-library@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/dom-testing-library/-/dom-testing-library-4.0.1.tgz#f21ef42aea0bd635969b4227a487e4704dbea735" + integrity sha512-Yr0yWlpI2QdTDEgPEk0TEekwP4VyZlJpl9E7nKP2FCKni44cb1jzjsy9KX6hBDsNA7EVlPpq9DHzO2eoEaqDZg== + dependencies: + "@babel/runtime" "^7.4.3" + "@sheerun/mutationobserver-shim" "^0.3.2" + pretty-format "^24.7.0" + wait-for-expect "^1.1.1" + dom-walk@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/dom-walk/-/dom-walk-0.1.1.tgz#672226dc74c8f799ad35307df936aba11acd6018" @@ -10005,6 +10020,14 @@ react-test-renderer@^16.8.6: react-is "^16.8.6" scheduler "^0.13.6" +react-testing-library@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/react-testing-library/-/react-testing-library-7.0.0.tgz#d3b535e44de94d7b0a83c56cd2e3cfed752dcec1" + integrity sha512-8SHqwG+uhN9VhAgNVkVa3f7VjTw/L5CIaoAxKmy+EZuDQ6O+VsfcpRAyUw3MDL1h8S/gGrEiazmHBVL/uXsftA== + dependencies: + "@babel/runtime" "^7.4.3" + dom-testing-library "^4.0.0" + react-textarea-autosize@^7.0.4: version "7.1.0" resolved "https://registry.yarnpkg.com/react-textarea-autosize/-/react-textarea-autosize-7.1.0.tgz#3132cb77e65d94417558d37c0bfe415a5afd3445" @@ -12175,6 +12198,11 @@ w3c-hr-time@^1.0.1: dependencies: browser-process-hrtime "^0.1.2" +wait-for-expect@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/wait-for-expect/-/wait-for-expect-1.1.1.tgz#9cd10e07d52810af9e0aaf509872e38f3c3d81ae" + integrity sha512-vd9JOqqEcBbCDhARWhW85ecjaEcfBLuXgVBqatfS3iw6oU4kzAcs+sCNjF+TC9YHPImCW7ypsuQc+htscIAQCw== + wait-port@^0.2.2: version "0.2.2" resolved "https://registry.yarnpkg.com/wait-port/-/wait-port-0.2.2.tgz#d51a491e484a17bf75a947e711a2f012b4e6f2e3" From dcc7d71f3b32c257142da9cd5cec9e2057b158ea Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Thu, 9 May 2019 08:08:48 +1000 Subject: [PATCH 026/308] wip --- src/index.js | 3 + .../sensors/use-mouse-sensor.js | 3 +- test/test-setup.js | 4 + test/unit/integration/drag-handle/app.jsx | 24 +++- .../drag-handle/start-dragging.spec.js | 112 ++++++++++++++++-- 5 files changed, 132 insertions(+), 14 deletions(-) diff --git a/src/index.js b/src/index.js index 30d1f6a912..5f87d27ce8 100644 --- a/src/index.js +++ b/src/index.js @@ -30,6 +30,9 @@ export type { OnDragEndResponder, } from './types'; +// TODO: should this be in types.js? +export type { SensorHook } from './view/use-sensor-marshal/sensor-types'; + // Droppable types export type { Provided as DroppableProvided, diff --git a/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js b/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js index 83fb0698ff..023b2492b7 100644 --- a/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js +++ b/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js @@ -226,8 +226,9 @@ export default function useMouseSensor( return; } // only starting a drag if dragging with the primary mouse button + console.log('button', event.button); if (event.button !== primaryButton) { - console.log('bye'); + console.log('not using the primary button'); return; } diff --git a/test/test-setup.js b/test/test-setup.js index 896e3118ad..c8c2209210 100644 --- a/test/test-setup.js +++ b/test/test-setup.js @@ -1,6 +1,10 @@ // @flow +import { cleanup } from 'react-testing-library'; // ensuring that each test has at least one assertion beforeEach(() => { expect.hasAssertions(); }); + +// unmount any components mounted with react-testing-library +afterEach(cleanup); diff --git a/test/unit/integration/drag-handle/app.jsx b/test/unit/integration/drag-handle/app.jsx index 28a7023235..1f27a55641 100644 --- a/test/unit/integration/drag-handle/app.jsx +++ b/test/unit/integration/drag-handle/app.jsx @@ -6,13 +6,27 @@ import { Draggable, type DroppableProvided, type DraggableProvided, + type DraggableStateSnapshot, + type SensorHook, } from '../../../../src'; type Item = {| id: string, |}; -export default function App() { +type Props = {| + onDragStart?: Function, + onDragEnd?: Function, + sensors?: [SensorHook], +|}; + +function noop() {} + +export default function App(props?: Props) { + const onDragStart = (props && props.onDragStart) || noop; + console.log('on drag start', onDragStart); + const onDragEnd = (props && props.onDragStart) || noop; + const [items] = useState(() => Array.from( { length: 3 }, @@ -22,8 +36,14 @@ export default function App() { ), ); + const sensors: SensorHook[] = (props && props.sensors) || []; + return ( - + {(droppableProvided: DroppableProvided) => (
{ + invariant( + message.includes('Message not passed to screen reader'), + `Unexpected console.warn("${message}")`, + ); +}); + function isDragging(el: HTMLElement): boolean { return el.getAttribute('data-is-dragging') === 'true'; } @@ -69,7 +87,7 @@ it('should start a drag after sufficient movement', () => { }); it('should allow standard click events', () => { - const { getByText, unmount } = render(); + const { getByText } = render(); const handle: HTMLElement = getByText('item: 0'); const click: MouseEvent = new MouseEvent('click', { @@ -80,12 +98,10 @@ it('should allow standard click events', () => { fireEvent(handle, click); expect(click.defaultPrevented).toBe(false); - - unmount(); }); it('should not call preventDefault on mouse movements while we are not sure if a drag is starting', () => { - const { getByText, unmount } = render(); + const { getByText } = render(); const handle: HTMLElement = getByText('item: 0'); // start pending @@ -106,12 +122,10 @@ it('should not call preventDefault on mouse movements while we are not sure if a expect(isDragging(handle)).toBe(false); expect(mouseMove.defaultPrevented).toBe(false); - - unmount(); }); it('should call preventDefault on the initial mousedown event to prevent the element gaining focus', () => { - const { getByText, unmount } = render(); + const { getByText } = render(); const handle: HTMLElement = getByText('item: 0'); const mouseDown: MouseEvent = new MouseEvent('mousedown', { @@ -124,12 +138,10 @@ it('should call preventDefault on the initial mousedown event to prevent the ele fireEvent(handle, mouseDown); expect(mouseDown.defaultPrevented).toBe(true); - - unmount(); }); it('should allow multiple false starts', () => { - const { getByText, unmount } = render(); + const { getByText } = render(); const handle: HTMLElement = getByText('item: 0'); Array.from({ length: 5 }).forEach(() => { @@ -146,6 +158,84 @@ it('should allow multiple false starts', () => { }); expect(isDragging(handle)).toBe(true); +}); + +it('should not start a drag if there was too little mouse movement while mouse was pressed', () => { + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + fireEvent.mouseDown(handle, getStartingMouseDown()); + fireEvent.mouseMove(handle, { + clientX: 0, + clientY: sloppyClickThreshold - 1, + }); + + expect(isDragging(handle)).toBe(false); +}); + +it('should not start a drag if not using the primary mouse button', () => { + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + fireEvent( + handle, + new MouseEvent('mousedown', { + clientX: 0, + clientY: 0, + cancelable: true, + bubbles: true, + button: primaryButton + 1, + }), + ); + fireEvent.mouseMove(handle, { + clientX: 0, + clientY: sloppyClickThreshold, + }); + + expect(isDragging(handle)).toBe(false); +}); + +it('should not start a drag if a modifier key was used while pressing the mouse down', () => { + // if any drag is started with these keys pressed then we do not start a drag + const keys: string[] = ['ctrlKey', 'altKey', 'shiftKey', 'metaKey']; + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + keys.forEach((key: string) => { + const mouseDown: MouseEvent = new MouseEvent('mousedown', { + clientX: 0, + clientY: 0, + cancelable: true, + bubbles: true, + button: primaryButton, + [key]: true, + }); + fireEvent(handle, mouseDown); + fireEvent.mouseMove(handle, { + clientX: 0, + clientY: sloppyClickThreshold, + }); - unmount(); + expect(isDragging(handle)).toBe(false); + }); +}); + +it('should not start a drag if another sensor is capturing', () => { + let tryCapture; + function greedy(tryStartCapture) { + tryCapture = tryStartCapture; + } + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + invariant(tryCapture, 'Expected function to be set'); + tryCapture(handle); + + // touch will now be capturing + // fireEvent.keyDown(handle, { keyCode: keyCodes.space }); + + // lift + simpleLift(handle); + + expect(isDragging(handle)).toBe(false); }); From b9f6378ba4dfbe369ca35f9443bd409ceff6b644 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Thu, 9 May 2019 16:36:36 +1000 Subject: [PATCH 027/308] renaming types --- src/debug/use-demo-sensor.js | 11 +- src/index.js | 4 +- src/types.js | 38 +++++ src/view/drag-drop-context/app.jsx | 11 +- .../drag-drop-context/drag-drop-context.jsx | 5 +- src/view/use-sensor-marshal/lock.js | 32 +++++ src/view/use-sensor-marshal/sensor-types.js | 39 ----- .../sensors/use-mouse-sensor.js | 21 +-- .../use-sensor-marshal/use-sensor-marshal.js | 134 ++++++++---------- .../use-validate-sensor-hooks.js | 6 +- stories/src/programmatic/with-controls.jsx | 24 ++-- test/unit/integration/drag-handle/app.jsx | 8 +- 12 files changed, 167 insertions(+), 166 deletions(-) create mode 100644 src/view/use-sensor-marshal/lock.js delete mode 100644 src/view/use-sensor-marshal/sensor-types.js diff --git a/src/debug/use-demo-sensor.js b/src/debug/use-demo-sensor.js index 8c7704305f..64127c6e56 100644 --- a/src/debug/use-demo-sensor.js +++ b/src/debug/use-demo-sensor.js @@ -1,7 +1,7 @@ // @flow import { useEffect } from 'react'; import { useCallback } from 'use-memo-one'; -import type { MovementCallbacks } from '../view/use-sensor-marshal/sensor-types'; +import type { ActionLock } from '../types'; function delay(fn: Function, time?: number = 300) { return new Promise(resolve => { @@ -15,10 +15,7 @@ function delay(fn: Function, time?: number = 300) { function noop() {} export default function useDemoSensor( - tryStartCapturing: ( - source: Event | Element, - abort: () => void, - ) => ?MovementCallbacks, + tryGetActionLock: (source: Event | Element, abort: () => void) => ?ActionLock, ) { const start = useCallback( async function start() { @@ -33,7 +30,7 @@ export default function useDemoSensor( // handle.scrollIntoView(); - const callbacks: ?MovementCallbacks = tryStartCapturing(handle, noop); + const callbacks: ?ActionLock = tryGetActionLock(handle, noop); if (!callbacks) { console.log('unable to start drag'); @@ -59,7 +56,7 @@ export default function useDemoSensor( await delay(moveUp); await delay(drop); }, - [tryStartCapturing], + [tryGetActionLock], ); useEffect(() => { diff --git a/src/index.js b/src/index.js index 5f87d27ce8..cfce1a531f 100644 --- a/src/index.js +++ b/src/index.js @@ -28,11 +28,9 @@ export type { OnDragStartResponder, OnDragUpdateResponder, OnDragEndResponder, + Sensor, } from './types'; -// TODO: should this be in types.js? -export type { SensorHook } from './view/use-sensor-marshal/sensor-types'; - // Droppable types export type { Provided as DroppableProvided, diff --git a/src/types.js b/src/types.js index f6b8844eac..ea376ad0d4 100644 --- a/src/types.js +++ b/src/types.js @@ -395,3 +395,41 @@ export type Responders = {| // always required onDragEnd: OnDragEndResponder, |}; + +// ## Sensors +export type ReleaseLockOptions = {| + shouldBlockNextClick: boolean, +|}; + +type SensorSnapLift = {| + mode: 'SNAP', +|}; + +type SensorFluidLift = {| + mode: 'FLUID', + clientSelection: Position, +|}; + +export type SensorLift = SensorSnapLift | SensorFluidLift; + +export type ActionLock = {| + shouldRespectForcePress: () => boolean, + // getDragHandleRef: () => HTMLElement, + // getDraggableRef: () => HTMLElement, + lift: (args: SensorLift) => void, + move: (point: Position) => void, + moveUp: () => void, + moveDown: () => void, + moveRight: () => void, + moveLeft: () => void, + drop: (args?: ReleaseLockOptions) => void, + cancel: (args?: ReleaseLockOptions) => void, + abort: () => void, +|}; + +export type Sensor = ( + tryGetActionLock: ( + source: Event | Element, + forceStop?: () => void, + ) => ?ActionLock, +) => void; diff --git a/src/view/drag-drop-context/app.jsx b/src/view/drag-drop-context/app.jsx index 4713c1c85d..d8e19c9fe9 100644 --- a/src/view/drag-drop-context/app.jsx +++ b/src/view/drag-drop-context/app.jsx @@ -12,12 +12,17 @@ import createAutoScroller from '../../state/auto-scroller'; import useStyleMarshal from '../use-style-marshal/use-style-marshal'; import type { AutoScroller } from '../../state/auto-scroller/auto-scroller-types'; import type { StyleMarshal } from '../use-style-marshal/style-marshal-types'; -import type { SensorHook } from '../use-sensor-marshal/sensor-types'; import type { DimensionMarshal, Callbacks as DimensionMarshalCallbacks, } from '../../state/dimension-marshal/dimension-marshal-types'; -import type { DraggableId, State, Responders, Announce } from '../../types'; +import type { + DraggableId, + State, + Responders, + Announce, + Sensor, +} from '../../types'; import type { Store, Action } from '../../state/store-types'; import StoreContext from '../context/store-context'; import { @@ -43,7 +48,7 @@ type Props = {| setOnError: (onError: Function) => void, // we do not technically need any children for this component children: Node | null, - __unstableSensors?: SensorHook[], + __unstableSensors?: Sensor[], |}; const createResponders = (props: Props): Responders => ({ diff --git a/src/view/drag-drop-context/drag-drop-context.jsx b/src/view/drag-drop-context/drag-drop-context.jsx index 19815b3de8..d81449d2c3 100644 --- a/src/view/drag-drop-context/drag-drop-context.jsx +++ b/src/view/drag-drop-context/drag-drop-context.jsx @@ -1,8 +1,7 @@ // @flow import React, { type Node } from 'react'; import { useMemo } from 'use-memo-one'; -import type { Responders, ContextId } from '../../types'; -import type { SensorHook } from '../use-sensor-marshal/sensor-types'; +import type { Responders, ContextId, Sensor } from '../../types'; import ErrorBoundary from '../error-boundary'; import App from './app'; @@ -11,7 +10,7 @@ type Props = {| // we do not technically need any children for this component children: Node | null, - __unstableSensors?: SensorHook[], + __unstableSensors?: Sensor[], |}; let instanceCount: number = 0; diff --git a/src/view/use-sensor-marshal/lock.js b/src/view/use-sensor-marshal/lock.js new file mode 100644 index 0000000000..1ad9f213cd --- /dev/null +++ b/src/view/use-sensor-marshal/lock.js @@ -0,0 +1,32 @@ +// @flow +import invariant from 'tiny-invariant'; +import type { DraggableId } from '../../types'; +import { warning } from '../../dev-warning'; + +type Lock = {| + id: DraggableId, + abort: () => void, +|}; + +let lock: ?Lock = null; + +export function getLock(): ?Lock { + return lock; +} + +export function obtainLock(id: DraggableId, abort: () => void) { + invariant(!lock, 'Cannot claim lock as it is already claimed'); + lock = { id, abort }; +} +export function releaseLock() { + invariant(lock, 'Cannot release lock when there is no lock'); + lock = null; +} +export function tryAbortLock() { + if (lock) { + lock.abort(); + if (lock != null) { + warning('aborting lock did not clear it'); + } + } +} diff --git a/src/view/use-sensor-marshal/sensor-types.js b/src/view/use-sensor-marshal/sensor-types.js deleted file mode 100644 index cb711eb75e..0000000000 --- a/src/view/use-sensor-marshal/sensor-types.js +++ /dev/null @@ -1,39 +0,0 @@ -// @flow -import type { Position } from 'css-box-model'; - -export type CaptureEndOptions = {| - shouldBlockNextClick: boolean, -|}; - -type SnapLift = {| - mode: 'SNAP', -|}; - -type FluidLift = {| - mode: 'FLUID', - clientSelection: Position, -|}; - -export type OnLiftArgs = SnapLift | FluidLift; - -export type MovementCallbacks = {| - shouldRespectForcePress: () => boolean, - // getDragHandleRef: () => HTMLElement, - // getDraggableRef: () => HTMLElement, - lift: (args: OnLiftArgs) => void, - move: (point: Position) => void, - moveUp: () => void, - moveDown: () => void, - moveRight: () => void, - moveLeft: () => void, - drop: (args?: CaptureEndOptions) => void, - cancel: (args?: CaptureEndOptions) => void, - abort: () => void, -|}; - -export type SensorHook = ( - tryStartCapturing: ( - source: Event | Element, - abort: () => void, - ) => ?MovementCallbacks, -) => void; diff --git a/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js b/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js index 023b2492b7..56cd4f5330 100644 --- a/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js +++ b/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js @@ -3,7 +3,7 @@ import invariant from 'tiny-invariant'; import { useEffect, useRef } from 'react'; import { useCallback, useMemo } from 'use-memo-one'; import type { Position } from 'css-box-model'; -import type { MovementCallbacks } from '../sensor-types'; +import type { ActionLock } from '../../../types'; import type { EventBinding, EventOptions, @@ -30,12 +30,12 @@ type Idle = {| type Pending = {| type: 'PENDING', point: Position, - callbacks: MovementCallbacks, + callbacks: ActionLock, |}; type Dragging = {| type: 'DRAGGING', - callbacks: MovementCallbacks, + callbacks: ActionLock, |}; type Phase = Idle | Pending | Dragging; @@ -210,7 +210,7 @@ function getCaptureBindings({ } export default function useMouseSensor( - tryStartCapturing: (event: Event, abort: () => void) => ?MovementCallbacks, + tryStartCapturing: (event: Event, abort: () => void) => ?ActionLock, ) { const phaseRef = useRef(idle); const unbindEventsRef = useRef<() => void>(noop); @@ -219,35 +219,29 @@ export default function useMouseSensor( () => ({ eventName: 'mousedown', fn: function onMouseDown(event: MouseEvent) { - console.log('on mouse down'); // Event already used if (event.defaultPrevented) { - console.log('default prevented'); return; } // only starting a drag if dragging with the primary mouse button - console.log('button', event.button); if (event.button !== primaryButton) { - console.log('not using the primary button'); return; } // Do not start a drag if any modifier key is pressed if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) { - console.log('special key'); return; } // stop is defined later // eslint-disable-next-line no-use-before-define - const callbacks: ?MovementCallbacks = tryStartCapturing(event, stop); + const callbacks: ?ActionLock = tryStartCapturing(event, stop); if (!callbacks) { - console.log('cannot start a capture'); return; } - console.log('calling prevent default'); + // consuming the event event.preventDefault(); const point: Position = { @@ -259,7 +253,6 @@ export default function useMouseSensor( unbindEventsRef.current(); // using this function before it is defined as their is a circular usage pattern // eslint-disable-next-line no-use-before-define - console.log('starting pending'); startPendingDrag(callbacks, point); }, }), @@ -326,7 +319,7 @@ export default function useMouseSensor( ); const startPendingDrag = useCallback( - function startPendingDrag(callbacks: MovementCallbacks, point: Position) { + function startPendingDrag(callbacks: ActionLock, point: Position) { invariant( phaseRef.current.type === 'IDLE', 'Expected to move from IDLE to PENDING drag', diff --git a/src/view/use-sensor-marshal/use-sensor-marshal.js b/src/view/use-sensor-marshal/use-sensor-marshal.js index 57911c258d..da0f40b55a 100644 --- a/src/view/use-sensor-marshal/use-sensor-marshal.js +++ b/src/view/use-sensor-marshal/use-sensor-marshal.js @@ -1,17 +1,18 @@ // @flow -import invariant from 'tiny-invariant'; import rafSchd from 'raf-schd'; import { useEffect } from 'react'; import { useCallback } from 'use-memo-one'; import type { Position } from 'css-box-model'; -import type { ContextId, DraggableId, State } from '../../types'; -import type { Store, Action } from '../../state/store-types'; import type { - MovementCallbacks, - SensorHook, - CaptureEndOptions, - OnLiftArgs, -} from './sensor-types'; + ContextId, + State, + Sensor, + SensorLift, + ActionLock, + ReleaseLockOptions, +} from '../../types'; +import { getLock, obtainLock, releaseLock, tryAbortLock } from './lock'; +import type { Store, Action } from '../../state/store-types'; import { getClosestDragHandle, getClosestDraggable } from './get-closest'; import canStartDrag from '../../state/can-start-drag'; import { @@ -33,31 +34,17 @@ import isHtmlElement from '../is-type-of-element/is-html-element'; import useKeyboardSensor from './sensors/use-keyboard-sensor'; import useLayoutEffect from '../use-isomorphic-layout-effect'; -type Capturing = {| - id: DraggableId, - abort: () => void, -|}; - -let capturing: ?Capturing = null; - -function startCapture(id: DraggableId, abort: () => void) { - invariant(!capturing, 'Cannot start capturing when already capturing'); - capturing = { id, abort }; -} -function stopCapture() { - invariant(capturing, 'Cannot stop capturing when not already capturing'); - capturing = null; -} - function preventDefault(event: Event) { event.preventDefault(); } -type TryStartCapturingArgs = {| +function noop() {} + +type TryGetLockArgs = {| contextId: ContextId, store: Store, source: Event | Element, - abort: () => void, + forceSensorStop: () => void, |}; function getTarget(source: Event | Element): ?Element { @@ -76,13 +63,14 @@ function getTarget(source: Event | Element): ?Element { return target instanceof Element ? target : null; } -function tryStartCapturing({ +function tryGetLock({ contextId, store, source, - abort, -}: TryStartCapturingArgs): ?MovementCallbacks { - if (capturing != null) { + forceSensorStop, +}: TryGetLockArgs): ?ActionLock { + // there is already a lock - cannot start + if (getLock() != null) { return null; } @@ -119,38 +107,38 @@ function tryStartCapturing({ return null; } - startCapture(id, abort); - let isCapturing: boolean = true; + // TODO: what if a sensor does not call .abort + let hasLock: boolean = true; - function ifCapturing(maybe: Function) { - if (isCapturing) { + function ifHasLock(maybe: Function) { + if (hasLock) { maybe(); return; } warning( - 'Trying to perform operation when no longer responsible for capturing', + 'Trying to perform operation when no longer owner of sensor action lock', ); } - const tryDispatch = (getAction: () => Action): void => { - if (!isCapturing) { + function tryDispatch(getAction: () => Action): void { + if (!hasLock) { warning( 'Trying to perform operation when no longer responsible for capturing', ); return; } store.dispatch(getAction()); - }; + } const moveUp = () => tryDispatch(moveUpAction); const moveDown = () => tryDispatch(moveDownAction); const moveRight = () => tryDispatch(moveRightAction); const moveLeft = () => tryDispatch(moveLeftAction); const move = rafSchd((clientSelection: Position) => { - ifCapturing(() => store.dispatch(moveAction({ client: clientSelection }))); + ifHasLock(() => store.dispatch(moveAction({ client: clientSelection }))); }); - function lift(args: OnLiftArgs) { + function lift(args: SensorLift) { const actionArgs = args.mode === 'FLUID' ? { @@ -167,18 +155,21 @@ function tryStartCapturing({ tryDispatch(() => liftAction(actionArgs)); } - const finish = ( - options?: CaptureEndOptions = { shouldBlockNextClick: false }, + function finish( + options?: ReleaseLockOptions = { shouldBlockNextClick: false }, action?: Action, - ) => { - if (!isCapturing) { - warning('Cannot finish a drag when not capturing'); + ) { + if (!hasLock) { + warning('Cannot finish a drag when there is no lock'); return; } - // stopping capture - stopCapture(); - isCapturing = false; + // cancel any pending request animation frames + move.cancel(); + + // release the lock and record that we no longer have it + hasLock = false; + releaseLock(); // block next click if requested if (options.shouldBlockNextClick) { @@ -190,13 +181,15 @@ function tryStartCapturing({ }); } - // cancel any pending request animation frames - move.cancel(); - if (action) { store.dispatch(action); } - }; + } + + obtainLock(id, function abort() { + forceSensorStop(); + finish(); + }); return { shouldRespectForcePress: (): boolean => shouldRespectForcePress, @@ -206,54 +199,45 @@ function tryStartCapturing({ moveDown, moveRight, moveLeft, - drop: (args?: CaptureEndOptions) => { + drop: (args?: ReleaseLockOptions) => { finish(args, dropAction({ reason: 'DROP' })); }, - cancel: (args?: CaptureEndOptions) => { + cancel: (args?: ReleaseLockOptions) => { finish(args, dropAction({ reason: 'CANCEL' })); }, abort: () => finish(), }; } -function tryAbortCapture() { - if (capturing) { - capturing.abort(); - capturing = null; - } -} - type SensorMarshalArgs = {| contextId: ContextId, store: Store, - customSensors: ?(SensorHook[]), + customSensors: ?(Sensor[]), |}; -const defaultSensors: SensorHook[] = [useMouseSensor, useKeyboardSensor]; +const defaultSensors: Sensor[] = [useMouseSensor, useKeyboardSensor]; export default function useSensorMarshal({ contextId, store, customSensors, }: SensorMarshalArgs) { - const useSensors: SensorHook[] = [ - ...defaultSensors, - ...(customSensors || []), - ]; + const useSensors: Sensor[] = [...defaultSensors, ...(customSensors || [])]; // We need to abort any capturing if there is no longer a drag useEffect( - function listen() { + function listenToStore() { let previous: State = store.getState(); const unsubscribe = store.subscribe(() => { const current: State = store.getState(); if (previous.isDragging && !current.isDragging) { - tryAbortCapture(); + tryAbortLock(); } previous = current; }); + return unsubscribe; }, [store], @@ -261,16 +245,16 @@ export default function useSensorMarshal({ // abort any captures on unmount useLayoutEffect(() => { - return tryAbortCapture; + return tryAbortLock; }, []); - const tryStartCapture = useCallback( - (source: Event | Element, abort: () => void): ?MovementCallbacks => - tryStartCapturing({ + const wrapper = useCallback( + (source: Event | Element, forceStop?: () => void = noop): ?ActionLock => + tryGetLock({ contextId, store, source, - abort, + forceSensorStop: forceStop, }), [contextId, store], ); @@ -278,6 +262,6 @@ export default function useSensorMarshal({ // Bad ass useValidateSensorHooks(useSensors); for (let i = 0; i < useSensors.length; i++) { - useSensors[i](tryStartCapture); + useSensors[i](wrapper); } } diff --git a/src/view/use-sensor-marshal/use-validate-sensor-hooks.js b/src/view/use-sensor-marshal/use-validate-sensor-hooks.js index 6d0b1aeab0..89c6ba2716 100644 --- a/src/view/use-sensor-marshal/use-validate-sensor-hooks.js +++ b/src/view/use-sensor-marshal/use-validate-sensor-hooks.js @@ -1,11 +1,11 @@ // @flow import invariant from 'tiny-invariant'; import { useEffect } from 'react'; -import type { SensorHook } from './sensor-types'; +import type { Sensor } from '../../types'; import usePreviousRef from '../use-previous-ref'; -export default function useValidateSensorHooks(sensorHooks: SensorHook[]) { - const previousRef = usePreviousRef(sensorHooks); +export default function useValidateSensorHooks(sensorHooks: Sensor[]) { + const previousRef = usePreviousRef(sensorHooks); useEffect(() => { if (process.env.NODE_ENV !== 'production') { diff --git a/stories/src/programmatic/with-controls.jsx b/stories/src/programmatic/with-controls.jsx index c0930b254a..04cf74d386 100644 --- a/stories/src/programmatic/with-controls.jsx +++ b/stories/src/programmatic/with-controls.jsx @@ -3,8 +3,7 @@ import React, { useRef, createRef, useState, useCallback } from 'react'; import styled from '@emotion/styled'; import type { Quote } from '../types'; -import type { DropResult } from '../../../src/types'; -import type { MovementCallbacks } from '../../../src/view/use-sensor-marshal/sensor-types'; +import type { DropResult, ActionLock } from '../../../src/types'; import { DragDropContext } from '../../../src'; import QuoteList from '../primatives/quote-list'; import reorder from '../reorder'; @@ -14,7 +13,7 @@ type ControlProps = {| quotes: Quote[], canLift: boolean, isDragging: boolean, - lift: (quoteId: string) => ?MovementCallbacks, + lift: (quoteId: string) => ?ActionLock, |}; function noop() {} @@ -75,11 +74,11 @@ const ActionButton = styled(Button)` function Controls(props: ControlProps) { const { quotes, canLift, isDragging, lift } = props; - const callbacksRef = useRef(null); + const callbacksRef = useRef(null); const selectRef = createRef(); - function maybe(fn: (callbacks: MovementCallbacks) => void) { + function maybe(fn: (callbacks: ActionLock) => void) { if (callbacksRef.current) { fn(callbacksRef.current); } @@ -172,8 +171,8 @@ export default function QuoteApp(props: Props) { const [quotes, setQuotes] = useState(props.initial); const [isDragging, setIsDragging] = useState(false); const [isControlDragging, setIsControlDragging] = useState(false); - const tryStartCapturingRef = useRef< - (el: Element, abort: () => void) => ?MovementCallbacks, + const tryGetActionLock = useRef< + (el: Element, stop: () => void) => ?ActionLock, >(() => null); const onDragEnd = useCallback( @@ -200,7 +199,7 @@ export default function QuoteApp(props: Props) { [quotes], ); - function lift(quoteId: string): ?MovementCallbacks { + function lift(quoteId: string): ?ActionLock { if (isDragging) { return null; } @@ -211,10 +210,7 @@ export default function QuoteApp(props: Props) { return null; } - const callbacks: ?MovementCallbacks = tryStartCapturingRef.current( - handle, - noop, - ); + const callbacks: ?ActionLock = tryGetActionLock.current(handle, noop); if (!callbacks) { console.log('unable to start capturing'); @@ -232,8 +228,8 @@ export default function QuoteApp(props: Props) { onDragStart={() => setIsDragging(true)} onDragEnd={onDragEnd} __unstableSensors={[ - tryCapture => { - tryStartCapturingRef.current = tryCapture; + tryGetLock => { + tryGetActionLock.current = tryGetLock; }, ]} > diff --git a/test/unit/integration/drag-handle/app.jsx b/test/unit/integration/drag-handle/app.jsx index 1f27a55641..c3585a5896 100644 --- a/test/unit/integration/drag-handle/app.jsx +++ b/test/unit/integration/drag-handle/app.jsx @@ -7,7 +7,7 @@ import { type DroppableProvided, type DraggableProvided, type DraggableStateSnapshot, - type SensorHook, + type Sensor, } from '../../../../src'; type Item = {| @@ -17,15 +17,15 @@ type Item = {| type Props = {| onDragStart?: Function, onDragEnd?: Function, - sensors?: [SensorHook], + sensors?: [Sensor], |}; function noop() {} export default function App(props?: Props) { const onDragStart = (props && props.onDragStart) || noop; - console.log('on drag start', onDragStart); const onDragEnd = (props && props.onDragStart) || noop; + const sensors: Sensor[] = [...((props && props.sensors) || [])]; const [items] = useState(() => Array.from( @@ -36,8 +36,6 @@ export default function App(props?: Props) { ), ); - const sensors: SensorHook[] = (props && props.sensors) || []; - return ( Date: Thu, 9 May 2019 17:12:04 +1000 Subject: [PATCH 028/308] disabled draggable support --- src/native-with-fallback.js | 2 ++ src/view/draggable/draggable.jsx | 11 ++++--- .../get-options-from-draggable.js | 32 +++++++++++++++++-- 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/src/native-with-fallback.js b/src/native-with-fallback.js index 0b1daced11..857a7b9aa7 100644 --- a/src/native-with-fallback.js +++ b/src/native-with-fallback.js @@ -14,6 +14,7 @@ export function values(map: Map): T[] { // Could also extend to pass index and list type PredicateFn = (value: T) => boolean; +// TODO: swap order export function findIndex( list: Array, predicate: PredicateFn, @@ -32,6 +33,7 @@ export function findIndex( return -1; } +// TODO: swap order export function find(list: Array, predicate: PredicateFn): ?T { if (list.find) { return list.find(predicate); diff --git a/src/view/draggable/draggable.jsx b/src/view/draggable/draggable.jsx index 274d9f0a17..732c5cdfc0 100644 --- a/src/view/draggable/draggable.jsx +++ b/src/view/draggable/draggable.jsx @@ -61,6 +61,7 @@ export default function Draggable(props: Props) { lift: liftAction, dropAnimationFinished: dropAnimationFinishedAction, } = props; + const isEnabled: boolean = !isDragDisabled; // The dimension publisher: talks to the marshal const forPublisher: DimensionPublisherArgs = useMemo( @@ -80,7 +81,7 @@ export default function Draggable(props: Props) { timings.start('LIFT'); const el: ?HTMLElement = ref.current; invariant(el); - invariant(!isDragDisabled, 'Cannot lift a Draggable when it is disabled'); + invariant(isEnabled, 'Cannot lift a Draggable when it is disabled'); const { clientSelection, movementMode } = options; liftAction({ @@ -90,7 +91,7 @@ export default function Draggable(props: Props) { }); timings.finish('LIFT'); }, - [draggableId, isDragDisabled, liftAction], + [draggableId, isEnabled, liftAction], ); const getShouldRespectForcePress = useCallback( @@ -135,7 +136,7 @@ export default function Draggable(props: Props) { draggableId, isDragging, isDropAnimating, - isEnabled: !isDragDisabled, + isEnabled, callbacks, getDraggableRef: getRef, canDragInteractiveElements, @@ -147,9 +148,9 @@ export default function Draggable(props: Props) { draggableId, getRef, getShouldRespectForcePress, - isDragDisabled, isDragging, isDropAnimating, + isEnabled, ], ); @@ -189,6 +190,7 @@ export default function Draggable(props: Props) { 'data-rbd-draggable-options': JSON.stringify({ canDragInteractiveElements, shouldRespectForcePress, + isEnabled, }), style, onTransitionEnd, @@ -202,6 +204,7 @@ export default function Draggable(props: Props) { canDragInteractiveElements, dragHandleProps, draggableId, + isEnabled, mapped, onMoveEnd, setRef, diff --git a/src/view/use-sensor-marshal/get-options-from-draggable.js b/src/view/use-sensor-marshal/get-options-from-draggable.js index 6bc51e8b98..9a814d0845 100644 --- a/src/view/use-sensor-marshal/get-options-from-draggable.js +++ b/src/view/use-sensor-marshal/get-options-from-draggable.js @@ -2,11 +2,13 @@ import invariant from 'tiny-invariant'; import type { DraggableId } from '../../types'; import { draggable as attr } from '../data-attributes'; +import { find } from '../../native-with-fallback'; export type DraggableData = {| id: DraggableId, canDragInteractiveElements: boolean, shouldRespectForcePress: boolean, + isEnabled: boolean, |}; export default function getDraggableData(draggable: Element): DraggableData { @@ -18,18 +20,42 @@ export default function getDraggableData(draggable: Element): DraggableData { const parsed: Object = JSON.parse(options); + // validation in dev if (process.env.NODE_ENV !== 'production') { + const properties: string[] = [ + 'canDragInteractiveElements', + 'shouldRespectForcePress', + 'isEnabled', + ]; + const keys: string[] = Object.keys(parsed); + + const arrange = (list: string[]): string => list.sort().join(','); + + invariant( + arrange(properties) !== arrange(keys), + ` + Unexpected data keys. + Expected: ${arrange(properties)} + Actual: ${arrange(keys)} + `, + ); + invariant( - Object.keys(parsed).length === 2, + keys.length === properties.length, 'Unexpected parsed draggable options', ); - Object.prototype.hasOwnProperty.call(parsed, 'canDragInteractiveElements'); - Object.prototype.hasOwnProperty.call(parsed, 'shouldRespectForcePress'); + properties.forEach((property: string) => { + invariant( + find(keys, (key: string) => property === key), + `Could not find key ${property} in draggable attributes`, + ); + }); } return { id, canDragInteractiveElements: parsed.canDragInteractiveElements, shouldRespectForcePress: parsed.shouldRespectForcePress, + isEnabled: parsed.isEnabled, }; } From 24f507f1459649a5482a5f378e7887edf41b6d5b Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Thu, 9 May 2019 17:27:35 +1000 Subject: [PATCH 029/308] is enabled guarding --- src/view/use-sensor-marshal/get-options-from-draggable.js | 2 +- src/view/use-sensor-marshal/use-sensor-marshal.js | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/view/use-sensor-marshal/get-options-from-draggable.js b/src/view/use-sensor-marshal/get-options-from-draggable.js index 9a814d0845..ea92b48d4e 100644 --- a/src/view/use-sensor-marshal/get-options-from-draggable.js +++ b/src/view/use-sensor-marshal/get-options-from-draggable.js @@ -32,7 +32,7 @@ export default function getDraggableData(draggable: Element): DraggableData { const arrange = (list: string[]): string => list.sort().join(','); invariant( - arrange(properties) !== arrange(keys), + arrange(properties) === arrange(keys), ` Unexpected data keys. Expected: ${arrange(properties)} diff --git a/src/view/use-sensor-marshal/use-sensor-marshal.js b/src/view/use-sensor-marshal/use-sensor-marshal.js index da0f40b55a..e3c0a95292 100644 --- a/src/view/use-sensor-marshal/use-sensor-marshal.js +++ b/src/view/use-sensor-marshal/use-sensor-marshal.js @@ -93,8 +93,14 @@ function tryGetLock({ id, shouldRespectForcePress, canDragInteractiveElements, + isEnabled, } = getOptionsFromDraggable(draggable); + // draggable is not enabled - don't start the drag + if (!isEnabled) { + return null; + } + // do not allow dragging from interactive elements if ( !canDragInteractiveElements && From 3a9b388696db5a86e0b32d7dd76aa49966308e10 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Mon, 13 May 2019 10:14:17 +1000 Subject: [PATCH 030/308] wip --- src/types.js | 13 +- .../use-sensor-marshal/use-sensor-marshal.js | 24 ++-- test/unit/integration/drag-handle/app.jsx | 40 ++++-- .../{ => mouse-sensor}/start-dragging.spec.js | 25 +++- .../drag-handle/move-throttling.spec.js | 74 ++++++++++ .../drag-handle/obtaining-lock.spec.js | 131 ++++++++++++++++++ test/unit/integration/drag-handle/util.js | 30 ++++ 7 files changed, 300 insertions(+), 37 deletions(-) rename test/unit/integration/drag-handle/{ => mouse-sensor}/start-dragging.spec.js (91%) create mode 100644 test/unit/integration/drag-handle/move-throttling.spec.js create mode 100644 test/unit/integration/drag-handle/obtaining-lock.spec.js create mode 100644 test/unit/integration/drag-handle/util.js diff --git a/src/types.js b/src/types.js index ea376ad0d4..127a6b8fbe 100644 --- a/src/types.js +++ b/src/types.js @@ -413,6 +413,7 @@ type SensorFluidLift = {| export type SensorLift = SensorSnapLift | SensorFluidLift; export type ActionLock = {| + isActive: () => boolean, shouldRespectForcePress: () => boolean, // getDragHandleRef: () => HTMLElement, // getDraggableRef: () => HTMLElement, @@ -427,9 +428,9 @@ export type ActionLock = {| abort: () => void, |}; -export type Sensor = ( - tryGetActionLock: ( - source: Event | Element, - forceStop?: () => void, - ) => ?ActionLock, -) => void; +export type TryGetActionLock = ( + source: Event | Element, + forceStop?: () => void, +) => ?ActionLock; + +export type Sensor = (tryGetActionLock: TryGetActionLock) => void; diff --git a/src/view/use-sensor-marshal/use-sensor-marshal.js b/src/view/use-sensor-marshal/use-sensor-marshal.js index e3c0a95292..bbaeb16f14 100644 --- a/src/view/use-sensor-marshal/use-sensor-marshal.js +++ b/src/view/use-sensor-marshal/use-sensor-marshal.js @@ -115,6 +115,7 @@ function tryGetLock({ // TODO: what if a sensor does not call .abort let hasLock: boolean = true; + let isLifted: boolean = false; function ifHasLock(maybe: Function) { if (hasLock) { @@ -128,6 +129,7 @@ function tryGetLock({ function tryDispatch(getAction: () => Action): void { if (!hasLock) { + console.log('BOOM'); warning( 'Trying to perform operation when no longer responsible for capturing', ); @@ -163,7 +165,7 @@ function tryGetLock({ function finish( options?: ReleaseLockOptions = { shouldBlockNextClick: false }, - action?: Action, + reason?: 'CANCEL' | 'DROP' = 'CANCEL', ) { if (!hasLock) { warning('Cannot finish a drag when there is no lock'); @@ -173,10 +175,6 @@ function tryGetLock({ // cancel any pending request animation frames move.cancel(); - // release the lock and record that we no longer have it - hasLock = false; - releaseLock(); - // block next click if requested if (options.shouldBlockNextClick) { window.addEventListener('click', preventDefault, { @@ -187,9 +185,14 @@ function tryGetLock({ }); } - if (action) { - store.dispatch(action); + if (isLifted) { + store.dispatch(dropAction({ reason })); } + + // release the lock and record that we no longer have it + isLifted = false; + hasLock = false; + releaseLock(); } obtainLock(id, function abort() { @@ -198,6 +201,7 @@ function tryGetLock({ }); return { + isActive: (): boolean => hasLock, shouldRespectForcePress: (): boolean => shouldRespectForcePress, lift, move, @@ -206,11 +210,13 @@ function tryGetLock({ moveRight, moveLeft, drop: (args?: ReleaseLockOptions) => { - finish(args, dropAction({ reason: 'DROP' })); + finish(args, 'DROP'); }, cancel: (args?: ReleaseLockOptions) => { - finish(args, dropAction({ reason: 'CANCEL' })); + finish(args); }, + // TODO: can this leave a drag forever unfished? + // Should this be a cancel? abort: () => finish(), }; } diff --git a/test/unit/integration/drag-handle/app.jsx b/test/unit/integration/drag-handle/app.jsx index c3585a5896..e7b4bf59ed 100644 --- a/test/unit/integration/drag-handle/app.jsx +++ b/test/unit/integration/drag-handle/app.jsx @@ -10,31 +10,35 @@ import { type Sensor, } from '../../../../src'; -type Item = {| +export type Item = {| id: string, + isEnabled: boolean, |}; type Props = {| onDragStart?: Function, onDragEnd?: Function, - sensors?: [Sensor], + sensors?: Sensor[], + items?: Item[], |}; function noop() {} -export default function App(props?: Props) { - const onDragStart = (props && props.onDragStart) || noop; - const onDragEnd = (props && props.onDragStart) || noop; - const sensors: Sensor[] = [...((props && props.sensors) || [])]; - - const [items] = useState(() => - Array.from( - { length: 3 }, - (v, k): Item => ({ - id: `${k}`, - }), - ), +function getItems() { + return Array.from( + { length: 3 }, + (v, k): Item => ({ + id: `${k}`, + isEnabled: true, + }), ); +} + +export default function App(props: Props) { + const onDragStart = props.onDragStart || noop; + const onDragEnd = props.onDragStart || noop; + const sensors: Sensor[] = props.sensors || []; + const [items] = useState(() => props.items || getItems()); return ( {items.map((item: Item, index: number) => ( - + {( provided: DraggableProvided, snapshot: DraggableStateSnapshot, @@ -58,6 +67,7 @@ export default function App(props?: Props) { {...provided.draggableProps} {...provided.dragHandleProps} data-is-dragging={snapshot.isDragging} + data-is-drop-animating={snapshot.isDropAnimating} ref={provided.innerRef} > item: {item.id} diff --git a/test/unit/integration/drag-handle/start-dragging.spec.js b/test/unit/integration/drag-handle/mouse-sensor/start-dragging.spec.js similarity index 91% rename from test/unit/integration/drag-handle/start-dragging.spec.js rename to test/unit/integration/drag-handle/mouse-sensor/start-dragging.spec.js index 1d0096e33f..421397735e 100644 --- a/test/unit/integration/drag-handle/start-dragging.spec.js +++ b/test/unit/integration/drag-handle/mouse-sensor/start-dragging.spec.js @@ -3,9 +3,9 @@ import invariant from 'tiny-invariant'; import React from 'react'; import type { Position } from 'css-box-model'; import { render, fireEvent } from 'react-testing-library'; -import { sloppyClickThreshold } from '../../../../src/view/use-sensor-marshal/sensors/util/is-sloppy-click-threshold-exceeded'; -import * as keyCodes from '../../../../src/view/key-codes'; -import App from './app'; +import { sloppyClickThreshold } from '../../../../../src/view/use-sensor-marshal/sensors/util/is-sloppy-click-threshold-exceeded'; +import { isDragging } from '../util'; +import App, { type Item } from '../app'; const primaryButton: number = 0; @@ -25,10 +25,6 @@ jest.spyOn(console, 'warn').mockImplementation((message: string) => { ); }); -function isDragging(el: HTMLElement): boolean { - return el.getAttribute('data-is-dragging') === 'true'; -} - function getStartingMouseDown(): MouseEvent { return new MouseEvent('mousedown', { clientX: 0, @@ -239,3 +235,18 @@ it('should not start a drag if another sensor is capturing', () => { expect(isDragging(handle)).toBe(false); }); + +it('should not start a drag if disabled', () => { + const items: Item[] = [{ id: '0', isEnabled: false }]; + + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + // lift + simpleLift(handle); + + // not lifting as is disabled + expect(isDragging(handle)).toBe(false); +}); + +describe('cancel during pending drag', () => {}); diff --git a/test/unit/integration/drag-handle/move-throttling.spec.js b/test/unit/integration/drag-handle/move-throttling.spec.js new file mode 100644 index 0000000000..81f245c326 --- /dev/null +++ b/test/unit/integration/drag-handle/move-throttling.spec.js @@ -0,0 +1,74 @@ +// @flow +import invariant from 'tiny-invariant'; +import React from 'react'; +import type { Position } from 'css-box-model'; +import { render } from 'react-testing-library'; +import type { + TryGetActionLock, + ActionLock, + Sensor, +} from '../../../../src/types'; +import App from './app'; +import { isDragging, isDropAnimating, getOffset } from './util'; +import { add } from '../../../../src/state/position'; + +function noop() {} + +it('should throttle move events by request animation frame', () => { + let tryGet: TryGetActionLock; + const a: Sensor = (tryGetLock: TryGetActionLock) => { + tryGet = tryGetLock; + }; + const { getByText } = render(); + invariant(tryGet, 'expected getter to be set'); + const handle: HTMLElement = getByText('item: 0'); + + const lock: ?ActionLock = tryGet(handle, noop); + invariant(lock); + + const initial: Position = { x: 2, y: 3 }; + lock.lift({ mode: 'FLUID', clientSelection: initial }); + // has not moved yet + expect(getOffset(handle)).toEqual({ x: 0, y: 0 }); + + const offset: Position = { x: 1, y: 5 }; + lock.move(add(initial, offset)); + lock.move(add(initial, offset)); + lock.move(add(initial, offset)); + + // still not moved + expect(getOffset(handle)).toEqual({ x: 0, y: 0 }); + + // moved after frame + requestAnimationFrame.step(); + expect(getOffset(handle)).toEqual(offset); +}); + +it('should cancel any pending moves after a lock is released', () => { + let tryGet: TryGetActionLock; + const a: Sensor = (tryGetLock: TryGetActionLock) => { + tryGet = tryGetLock; + }; + const { getByText } = render(); + invariant(tryGet, 'expected getter to be set'); + const handle: HTMLElement = getByText('item: 0'); + + const lock: ?ActionLock = tryGet(handle, noop); + invariant(lock); + + const initial: Position = { x: 2, y: 3 }; + lock.lift({ mode: 'FLUID', clientSelection: initial }); + // has not moved yet + expect(getOffset(handle)).toEqual({ x: 0, y: 0 }); + + const offset: Position = { x: 1, y: 5 }; + lock.move(add(initial, offset)); + // not moved yet + expect(getOffset(handle)).toEqual({ x: 0, y: 0 }); + + lock.cancel(); + + // will not do anything + requestAnimationFrame.step(); + expect(getOffset(handle)).toEqual({ x: 0, y: 0 }); +}); diff --git a/test/unit/integration/drag-handle/obtaining-lock.spec.js b/test/unit/integration/drag-handle/obtaining-lock.spec.js new file mode 100644 index 0000000000..dec477a55f --- /dev/null +++ b/test/unit/integration/drag-handle/obtaining-lock.spec.js @@ -0,0 +1,131 @@ +// @flow +import invariant from 'tiny-invariant'; +import React from 'react'; +import { render } from 'react-testing-library'; +import type { + TryGetActionLock, + ActionLock, + Sensor, +} from '../../../../src/types'; +import App from './app'; +import { isDragging, isDropAnimating } from './util'; + +function noop() {} + +it('should allow an exclusive lock for drag actions', () => { + let first: TryGetActionLock; + let second: TryGetActionLock; + + const a: Sensor = (tryGetLock: TryGetActionLock) => { + first = tryGetLock; + }; + const b: Sensor = (tryGetLock: TryGetActionLock) => { + second = tryGetLock; + }; + + const { getByText } = render(); + invariant(first, 'expected first to be set'); + invariant(second, 'expected second to be set'); + const item0: HTMLElement = getByText('item: 0'); + const item1: HTMLElement = getByText('item: 1'); + + // first can get a lock + expect(first(item0)).toBeTruthy(); + + // second cannot get a lock + expect(second(item0)).toBe(null); + + // first cannot get another lock on the same element + expect(first(item0)).toBe(null); + + // nothing cannot get lock on a different element + expect(first(item1)).toBe(null); + expect(second(item1)).toBe(null); +}); + +it('should allow a lock to be released', () => { + let tryGet: TryGetActionLock; + const a: Sensor = (tryGetLock: TryGetActionLock) => { + tryGet = tryGetLock; + }; + + const { getByText } = render(); + invariant(tryGet, 'expected getter to be set'); + const handle: HTMLElement = getByText('item: 0'); + + Array.from({ length: 4 }).forEach(() => { + // get the lock + const lock: ?ActionLock = tryGet(handle, noop); + expect(lock).toBeTruthy(); + invariant(lock, 'Expected lock to be set'); + + // cannot get another lock + expect(tryGet(handle)).toBe(null); + + // release the lock + lock.abort(); + }); +}); + +it('should not allow a sensor to obtain a on a dropping item, but can claim one on something else while dragging', () => { + let tryGet: TryGetActionLock; + const a: Sensor = (tryGetLock: TryGetActionLock) => { + tryGet = tryGetLock; + }; + const { getByText } = render(); + invariant(tryGet, 'expected getter to be set'); + const handle: HTMLElement = getByText('item: 0'); + + const lock: ?ActionLock = tryGet(handle, noop); + invariant(lock, 'Expected to get lock'); + + // drag not started yet + expect(isDragging(handle)).toBe(false); + // start a drag + lock.lift({ mode: 'FLUID', clientSelection: { x: 0, y: 0 } }); + expect(isDragging(handle)).toBe(true); + + // release the movement + lock.move({ x: 100, y: 100 }); + requestAnimationFrame.flush(); + + lock.drop(); + expect(isDropAnimating(handle)).toBe(true); + + // lock is no longer active + expect(lock.isActive()).toBe(false); + + // cannot get a new lock while still dropping + expect(tryGet(handle, noop)).toBe(null); + + // can get a lock on a handle that is not dropping - while the other is dropping + expect(tryGet(getByText('item: 1'), noop)).toBeTruthy(); +}); + +it('should release a lock on an "abort", "cancel" or "drop"', () => { + let tryGet: TryGetActionLock; + const a: Sensor = (tryGetLock: TryGetActionLock) => { + tryGet = tryGetLock; + }; + const { getByText } = render(); + invariant(tryGet, 'expected getter to be set'); + const handle0: HTMLElement = getByText('item: 0'); + const handle1: HTMLElement = getByText('item: 1'); + + ['drop', 'cancel', 'abort'].forEach((key: string) => { + const lock: ?ActionLock = tryGet(handle0, noop); + invariant(lock, 'Expected to get lock'); + expect(lock.isActive()).toBe(true); + // should release the lock + lock[key](); + expect(lock.isActive()).toBe(false); + + // can get another lock + const second: ?ActionLock = tryGet(handle1, noop); + expect(second).toBeTruthy(); + invariant(second); + // need to release this one :) + second.abort(); + expect(second.isActive()).toBe(false); + }); +}); diff --git a/test/unit/integration/drag-handle/util.js b/test/unit/integration/drag-handle/util.js new file mode 100644 index 0000000000..c82736fd8e --- /dev/null +++ b/test/unit/integration/drag-handle/util.js @@ -0,0 +1,30 @@ +// @flow +import invariant from 'tiny-invariant'; +import type { Position } from 'css-box-model'; + +export function isDragging(el: HTMLElement): boolean { + return el.getAttribute('data-is-dragging') === 'true'; +} + +export function isDropAnimating(el: HTMLElement): boolean { + return el.getAttribute('data-is-drop-animating') === 'true'; +} + +export function getOffset(el: HTMLElement): Position { + const style: CSSStyleDeclaration = el.style; + + const transform: string = style.transform; + if (!transform) { + return { x: 0, y: 0 }; + } + + const regex: RegExp = /translate\((\d+)px, (\d+)px\)/; + + const result = transform.match(regex); + invariant(result, 'Unable to formate translate'); + + return { + x: Number(result[1]), + y: Number(result[2]), + }; +} From 767f9236103aa9a2be77e44c54b1278989bc7b1c Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Mon, 13 May 2019 11:30:23 +1000 Subject: [PATCH 031/308] lock for life --- src/types.js | 19 +- src/view/use-sensor-marshal/lock.js | 34 ++-- .../sensors/use-keyboard-sensor.js | 26 +-- .../sensors/use-mouse-sensor.js | 41 ++--- .../use-sensor-marshal/use-sensor-marshal.js | 173 +++++++++--------- .../drag-handle/obtaining-lock.spec.js | 85 ++++++--- 6 files changed, 213 insertions(+), 165 deletions(-) diff --git a/src/types.js b/src/types.js index 127a6b8fbe..0971e29a6d 100644 --- a/src/types.js +++ b/src/types.js @@ -397,7 +397,7 @@ export type Responders = {| |}; // ## Sensors -export type ReleaseLockOptions = {| +export type StopDragOptions = {| shouldBlockNextClick: boolean, |}; @@ -412,25 +412,28 @@ type SensorFluidLift = {| export type SensorLift = SensorSnapLift | SensorFluidLift; -export type ActionLock = {| +export type DragActions = {| isActive: () => boolean, shouldRespectForcePress: () => boolean, - // getDragHandleRef: () => HTMLElement, - // getDraggableRef: () => HTMLElement, - lift: (args: SensorLift) => void, move: (point: Position) => void, moveUp: () => void, moveDown: () => void, moveRight: () => void, moveLeft: () => void, - drop: (args?: ReleaseLockOptions) => void, - cancel: (args?: ReleaseLockOptions) => void, + drop: (args?: StopDragOptions) => void, + cancel: (args?: StopDragOptions) => void, +|}; + +export type PreDragActions = {| + isActive: () => boolean, + shouldRespectForcePress: () => boolean, + lift: (args: SensorLift) => DragActions, abort: () => void, |}; export type TryGetActionLock = ( source: Event | Element, forceStop?: () => void, -) => ?ActionLock; +) => ?PreDragActions; export type Sensor = (tryGetActionLock: TryGetActionLock) => void; diff --git a/src/view/use-sensor-marshal/lock.js b/src/view/use-sensor-marshal/lock.js index 1ad9f213cd..89bfc048d4 100644 --- a/src/view/use-sensor-marshal/lock.js +++ b/src/view/use-sensor-marshal/lock.js @@ -1,32 +1,36 @@ // @flow import invariant from 'tiny-invariant'; -import type { DraggableId } from '../../types'; -import { warning } from '../../dev-warning'; -type Lock = {| - id: DraggableId, - abort: () => void, +export type Lock = {| + abandon: () => void, |}; let lock: ?Lock = null; -export function getLock(): ?Lock { - return lock; +export function isClaimed(): boolean { + return Boolean(lock); } -export function obtainLock(id: DraggableId, abort: () => void) { +export function isActive(value: Lock): boolean { + return value === lock; +} + +export function claim(abandon: () => void): Lock { invariant(!lock, 'Cannot claim lock as it is already claimed'); - lock = { id, abort }; + const newLock: Lock = { abandon }; + // update singleton + lock = newLock; + // return lock + return newLock; } -export function releaseLock() { +export function release() { invariant(lock, 'Cannot release lock when there is no lock'); lock = null; } -export function tryAbortLock() { + +export function tryAbandon() { if (lock) { - lock.abort(); - if (lock != null) { - warning('aborting lock did not clear it'); - } + lock.abandon(); + release(); } } diff --git a/src/view/use-sensor-marshal/sensors/use-keyboard-sensor.js b/src/view/use-sensor-marshal/sensors/use-keyboard-sensor.js index f2a76aeb47..3aff1f8a13 100644 --- a/src/view/use-sensor-marshal/sensors/use-keyboard-sensor.js +++ b/src/view/use-sensor-marshal/sensors/use-keyboard-sensor.js @@ -2,7 +2,7 @@ import invariant from 'tiny-invariant'; import { useRef, useEffect } from 'react'; import { useMemo, useCallback } from 'use-memo-one'; -import type { MovementCallbacks } from '../sensor-types'; +import type { PreDragActions, DragActions } from '../../../types'; import type { EventBinding, EventOptions, @@ -26,17 +26,17 @@ const scrollJumpKeys: KeyMap = { }; function getDraggingBindings( - callbacks: MovementCallbacks, + actions: DragActions, stop: () => void, ): EventBinding[] { function cancel() { stop(); - callbacks.cancel(); + actions.cancel(); } function drop() { stop(); - callbacks.drop(); + actions.drop(); } return [ @@ -61,25 +61,25 @@ function getDraggingBindings( if (event.keyCode === keyCodes.arrowDown) { event.preventDefault(); - callbacks.moveDown(); + actions.moveDown(); return; } if (event.keyCode === keyCodes.arrowUp) { event.preventDefault(); - callbacks.moveUp(); + actions.moveUp(); return; } if (event.keyCode === keyCodes.arrowRight) { event.preventDefault(); - callbacks.moveRight(); + actions.moveRight(); return; } if (event.keyCode === keyCodes.arrowLeft) { event.preventDefault(); - callbacks.moveLeft(); + actions.moveLeft(); return; } @@ -136,7 +136,7 @@ export default function useKeyboardSensor( tryStartCapturing: ( source: Event | Element, abort: () => void, - ) => ?MovementCallbacks, + ) => ?PreDragActions, ) { const unbindEventsRef = useRef<() => void>(noop); @@ -156,10 +156,10 @@ export default function useKeyboardSensor( // abort function not defined yet // eslint-disable-next-line no-use-before-define - const callbacks: ?MovementCallbacks = tryStartCapturing(event, stop); + const preDrag: ?PreDragActions = tryStartCapturing(event, stop); // Cannot start capturing at this time - if (!callbacks) { + if (!preDrag) { return; } @@ -169,7 +169,7 @@ export default function useKeyboardSensor( // There is no pending period for a keyboard drag // We can lift immediately - callbacks.lift({ + const actions: DragActions = preDrag.lift({ mode: 'SNAP', }); @@ -194,7 +194,7 @@ export default function useKeyboardSensor( // bind dragging listeners unbindEventsRef.current = bindEvents( window, - getDraggingBindings(callbacks, stop), + getDraggingBindings(actions, stop), { capture: true, passive: false }, ); }, diff --git a/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js b/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js index 56cd4f5330..1c055202b8 100644 --- a/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js +++ b/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js @@ -3,7 +3,7 @@ import invariant from 'tiny-invariant'; import { useEffect, useRef } from 'react'; import { useCallback, useMemo } from 'use-memo-one'; import type { Position } from 'css-box-model'; -import type { ActionLock } from '../../../types'; +import type { PreDragActions, DragActions } from '../../../types'; import type { EventBinding, EventOptions, @@ -30,12 +30,12 @@ type Idle = {| type Pending = {| type: 'PENDING', point: Position, - callbacks: ActionLock, + actions: PreDragActions, |}; type Dragging = {| type: 'DRAGGING', - callbacks: ActionLock, + actions: DragActions, |}; type Phase = Idle | Pending | Dragging; @@ -75,7 +75,7 @@ function getCaptureBindings({ if (phase.type === 'DRAGGING') { // preventing default as we are using this event event.preventDefault(); - phase.callbacks.move(point); + phase.actions.move(point); return; } @@ -91,15 +91,15 @@ function getCaptureBindings({ // preventing default as we are using this event event.preventDefault(); - setPhase({ - type: 'DRAGGING', - callbacks: phase.callbacks, - }); - - phase.callbacks.lift({ + const actions: DragActions = phase.actions.lift({ clientSelection: pending, mode: 'FLUID', }); + + setPhase({ + type: 'DRAGGING', + actions, + }); }, }, { @@ -114,7 +114,7 @@ function getCaptureBindings({ // preventing default as we are using this event event.preventDefault(); - phase.callbacks.drop({ shouldBlockNextClick: true }); + phase.actions.drop({ shouldBlockNextClick: true }); completed(); }, }, @@ -189,7 +189,7 @@ function getCaptureBindings({ invariant(phase.type !== 'IDLE', 'Unexpected phase'); // might not be respecting force press - if (!phase.callbacks.shouldRespectForcePress()) { + if (!phase.actions.shouldRespectForcePress()) { event.preventDefault(); return; } @@ -210,7 +210,7 @@ function getCaptureBindings({ } export default function useMouseSensor( - tryStartCapturing: (event: Event, abort: () => void) => ?ActionLock, + tryStartCapturing: (event: Event, abort: () => void) => ?PreDragActions, ) { const phaseRef = useRef(idle); const unbindEventsRef = useRef<() => void>(noop); @@ -235,9 +235,9 @@ export default function useMouseSensor( // stop is defined later // eslint-disable-next-line no-use-before-define - const callbacks: ?ActionLock = tryStartCapturing(event, stop); + const actions: ?PreDragActions = tryStartCapturing(event, stop); - if (!callbacks) { + if (!actions) { return; } @@ -253,7 +253,7 @@ export default function useMouseSensor( unbindEventsRef.current(); // using this function before it is defined as their is a circular usage pattern // eslint-disable-next-line no-use-before-define - startPendingDrag(callbacks, point); + startPendingDrag(actions, point); }, }), // not including startPendingDrag as it is not defined initially @@ -278,7 +278,6 @@ export default function useMouseSensor( ); const stop = useCallback(() => { - console.log('trying to stop'); const current: Phase = phaseRef.current; if (current.type === 'IDLE') { return; @@ -294,10 +293,10 @@ export default function useMouseSensor( const phase: Phase = phaseRef.current; stop(); if (phase.type === 'DRAGGING') { - phase.callbacks.cancel({ shouldBlockNextClick: true }); + phase.actions.cancel({ shouldBlockNextClick: true }); } if (phase.type === 'PENDING') { - phase.callbacks.abort(); + phase.actions.abort(); } }, [stop]); @@ -319,7 +318,7 @@ export default function useMouseSensor( ); const startPendingDrag = useCallback( - function startPendingDrag(callbacks: ActionLock, point: Position) { + function startPendingDrag(actions: PreDragActions, point: Position) { invariant( phaseRef.current.type === 'IDLE', 'Expected to move from IDLE to PENDING drag', @@ -327,7 +326,7 @@ export default function useMouseSensor( phaseRef.current = { type: 'PENDING', point, - callbacks, + actions, }; bindCapturingEvents(); }, diff --git a/src/view/use-sensor-marshal/use-sensor-marshal.js b/src/view/use-sensor-marshal/use-sensor-marshal.js index bbaeb16f14..443e54d11c 100644 --- a/src/view/use-sensor-marshal/use-sensor-marshal.js +++ b/src/view/use-sensor-marshal/use-sensor-marshal.js @@ -8,10 +8,18 @@ import type { State, Sensor, SensorLift, - ActionLock, - ReleaseLockOptions, + StopDragOptions, + PreDragActions, + DragActions, } from '../../types'; -import { getLock, obtainLock, releaseLock, tryAbortLock } from './lock'; +import { + isClaimed, + claim, + isActive, + type Lock, + release, + tryAbandon, +} from './lock'; import type { Store, Action } from '../../state/store-types'; import { getClosestDragHandle, getClosestDraggable } from './get-closest'; import canStartDrag from '../../state/can-start-drag'; @@ -68,9 +76,9 @@ function tryGetLock({ store, source, forceSensorStop, -}: TryGetLockArgs): ?ActionLock { - // there is already a lock - cannot start - if (getLock() != null) { +}: TryGetLockArgs): ?PreDragActions { + // lock is already claimed - cannot start + if (isClaimed()) { return null; } @@ -84,6 +92,7 @@ function tryGetLock({ const handle: ?HTMLElement = getClosestDragHandle(contextId, target); + // event did not contain a drag handle parent - cannot start if (handle == null) { return null; } @@ -96,7 +105,7 @@ function tryGetLock({ isEnabled, } = getOptionsFromDraggable(draggable); - // draggable is not enabled - don't start the drag + // draggable is not enabled - cannot start if (!isEnabled) { return null; } @@ -109,44 +118,37 @@ function tryGetLock({ return null; } + // Application might now allow dragging right now if (!canStartDrag(store.getState(), id)) { return null; } - // TODO: what if a sensor does not call .abort - let hasLock: boolean = true; - let isLifted: boolean = false; + // claiming lock + const lock: Lock = claim(forceSensorStop); - function ifHasLock(maybe: Function) { - if (hasLock) { - maybe(); - return; + function abortPreDrag() { + if (isActive(lock)) { + release(); } - warning( - 'Trying to perform operation when no longer owner of sensor action lock', - ); } function tryDispatch(getAction: () => Action): void { - if (!hasLock) { - console.log('BOOM'); - warning( - 'Trying to perform operation when no longer responsible for capturing', - ); + if (!isActive(lock)) { + warning('Cannot perform action when no longer the owner of the lock'); return; } store.dispatch(getAction()); } - const moveUp = () => tryDispatch(moveUpAction); - const moveDown = () => tryDispatch(moveDownAction); - const moveRight = () => tryDispatch(moveRightAction); - const moveLeft = () => tryDispatch(moveLeftAction); - const move = rafSchd((clientSelection: Position) => { - ifHasLock(() => store.dispatch(moveAction({ client: clientSelection }))); - }); + function isLockActive() { + return isActive(lock); + } + + function getShouldRespectForcePress(): boolean { + return shouldRespectForcePress; + } - function lift(args: SensorLift) { + function lift(args: SensorLift): DragActions { const actionArgs = args.mode === 'FLUID' ? { @@ -160,65 +162,67 @@ function tryGetLock({ id, }; + // Do lift operation tryDispatch(() => liftAction(actionArgs)); - } - - function finish( - options?: ReleaseLockOptions = { shouldBlockNextClick: false }, - reason?: 'CANCEL' | 'DROP' = 'CANCEL', - ) { - if (!hasLock) { - warning('Cannot finish a drag when there is no lock'); - return; - } - - // cancel any pending request animation frames - move.cancel(); - - // block next click if requested - if (options.shouldBlockNextClick) { - window.addEventListener('click', preventDefault, { - // only blocking a single click - once: true, - passive: false, - capture: true, - }); - } - if (isLifted) { + // Setup DragActions + const moveUp = () => tryDispatch(moveUpAction); + const moveDown = () => tryDispatch(moveDownAction); + const moveRight = () => tryDispatch(moveRightAction); + const moveLeft = () => tryDispatch(moveLeftAction); + + const move = rafSchd((clientSelection: Position) => { + tryDispatch(() => moveAction({ client: clientSelection })); + }); + + function finish( + reason: 'CANCEL' | 'DROP', + options?: StopDragOptions = { shouldBlockNextClick: false }, + ) { + if (!isLockActive()) { + warning('Cannot finish a drag when there is no lock'); + return; + } + + // Cancel any pending request animation frames + move.cancel(); + + // block next click if requested + if (options.shouldBlockNextClick) { + window.addEventListener('click', preventDefault, { + // only blocking a single click + once: true, + passive: false, + capture: true, + }); + } + + // releasing lock first so that a tryAbort will not run due to useEffect + release(); store.dispatch(dropAction({ reason })); } - // release the lock and record that we no longer have it - isLifted = false; - hasLock = false; - releaseLock(); + return { + isActive: isLockActive, + shouldRespectForcePress: getShouldRespectForcePress, + move, + moveUp, + moveDown, + moveRight, + moveLeft, + drop: (options?: StopDragOptions) => finish('DROP', options), + cancel: (options?: StopDragOptions) => finish('CANCEL', options), + }; } - obtainLock(id, function abort() { - forceSensorStop(); - finish(); - }); - - return { - isActive: (): boolean => hasLock, - shouldRespectForcePress: (): boolean => shouldRespectForcePress, + const preDrag: PreDragActions = { + isActive: isLockActive, + shouldRespectForcePress: getShouldRespectForcePress, lift, - move, - moveUp, - moveDown, - moveRight, - moveLeft, - drop: (args?: ReleaseLockOptions) => { - finish(args, 'DROP'); - }, - cancel: (args?: ReleaseLockOptions) => { - finish(args); - }, - // TODO: can this leave a drag forever unfished? - // Should this be a cancel? - abort: () => finish(), + abort: abortPreDrag, }; + + return preDrag; } type SensorMarshalArgs = {| @@ -244,24 +248,25 @@ export default function useSensorMarshal({ const current: State = store.getState(); if (previous.isDragging && !current.isDragging) { - tryAbortLock(); + tryAbandon(); } previous = current; }); + // unsubscribe from store when unmounting return unsubscribe; }, [store], ); - // abort any captures on unmount + // abort any lock on unmount useLayoutEffect(() => { - return tryAbortLock; + return tryAbandon; }, []); const wrapper = useCallback( - (source: Event | Element, forceStop?: () => void = noop): ?ActionLock => + (source: Event | Element, forceStop?: () => void = noop): ?PreDragActions => tryGetLock({ contextId, store, diff --git a/test/unit/integration/drag-handle/obtaining-lock.spec.js b/test/unit/integration/drag-handle/obtaining-lock.spec.js index dec477a55f..2a7680184e 100644 --- a/test/unit/integration/drag-handle/obtaining-lock.spec.js +++ b/test/unit/integration/drag-handle/obtaining-lock.spec.js @@ -4,7 +4,8 @@ import React from 'react'; import { render } from 'react-testing-library'; import type { TryGetActionLock, - ActionLock, + PreDragActions, + DragActions, Sensor, } from '../../../../src/types'; import App from './app'; @@ -55,7 +56,7 @@ it('should allow a lock to be released', () => { Array.from({ length: 4 }).forEach(() => { // get the lock - const lock: ?ActionLock = tryGet(handle, noop); + const lock: ?PreDragActions = tryGet(handle, noop); expect(lock).toBeTruthy(); invariant(lock, 'Expected lock to be set'); @@ -76,24 +77,28 @@ it('should not allow a sensor to obtain a on a dropping item, but can claim one invariant(tryGet, 'expected getter to be set'); const handle: HTMLElement = getByText('item: 0'); - const lock: ?ActionLock = tryGet(handle, noop); - invariant(lock, 'Expected to get lock'); + const preDrag: ?PreDragActions = tryGet(handle, noop); + invariant(preDrag, 'Expected to get lock'); // drag not started yet expect(isDragging(handle)).toBe(false); // start a drag - lock.lift({ mode: 'FLUID', clientSelection: { x: 0, y: 0 } }); + const actions: DragActions = preDrag.lift({ + mode: 'FLUID', + clientSelection: { x: 0, y: 0 }, + }); expect(isDragging(handle)).toBe(true); // release the movement - lock.move({ x: 100, y: 100 }); + actions.move({ x: 100, y: 100 }); requestAnimationFrame.flush(); - lock.drop(); + actions.drop(); expect(isDropAnimating(handle)).toBe(true); // lock is no longer active - expect(lock.isActive()).toBe(false); + expect(actions.isActive()).toBe(false); + expect(preDrag.isActive()).toBe(false); // cannot get a new lock while still dropping expect(tryGet(handle, noop)).toBe(null); @@ -102,7 +107,7 @@ it('should not allow a sensor to obtain a on a dropping item, but can claim one expect(tryGet(getByText('item: 1'), noop)).toBeTruthy(); }); -it('should release a lock on an "abort", "cancel" or "drop"', () => { +it('should release a lock when aborting a pre drag', () => { let tryGet: TryGetActionLock; const a: Sensor = (tryGetLock: TryGetActionLock) => { tryGet = tryGetLock; @@ -112,20 +117,52 @@ it('should release a lock on an "abort", "cancel" or "drop"', () => { const handle0: HTMLElement = getByText('item: 0'); const handle1: HTMLElement = getByText('item: 1'); - ['drop', 'cancel', 'abort'].forEach((key: string) => { - const lock: ?ActionLock = tryGet(handle0, noop); - invariant(lock, 'Expected to get lock'); - expect(lock.isActive()).toBe(true); - // should release the lock - lock[key](); - expect(lock.isActive()).toBe(false); - - // can get another lock - const second: ?ActionLock = tryGet(handle1, noop); - expect(second).toBeTruthy(); - invariant(second); - // need to release this one :) - second.abort(); - expect(second.isActive()).toBe(false); + const preDrag: ?PreDragActions = tryGet(handle0, noop); + invariant(preDrag, 'Expected to get lock'); + expect(preDrag.isActive()).toBe(true); + // should release the lock + preDrag.abort(); + expect(preDrag.isActive()).toBe(false); + + // can get another lock + const second: ?PreDragActions = tryGet(handle1, noop); + expect(second).toBeTruthy(); + invariant(second); + // need to release this one :) + second.abort(); + expect(second.isActive()).toBe(false); +}); + +it('should release a lock when cancelling or dropping a drag', () => { + let tryGet: TryGetActionLock; + const a: Sensor = (tryGetLock: TryGetActionLock) => { + tryGet = tryGetLock; + }; + const { getByText } = render(); + invariant(tryGet, 'expected getter to be set'); + const handle0: HTMLElement = getByText('item: 0'); + const handle1: HTMLElement = getByText('item: 1'); + + ['cancel', 'drop'].forEach((property: string) => { + const preDrag: ?PreDragActions = tryGet(handle0, noop); + invariant(preDrag, 'Expected to get lock'); + expect(preDrag.isActive()).toBe(true); + + const drag: DragActions = preDrag.lift({ mode: 'SNAP' }); + expect(drag.isActive()).toBe(true); + + // cannot get another lock + const second: ?PreDragActions = tryGet(handle1, noop); + expect(second).toBe(null); + + // calling canel or drop + drag[property](); + + // can now get another lock + const third: ?PreDragActions = tryGet(handle1, noop); + expect(third).toBeTruthy(); + // need to try to release it + invariant(third); + third.abort(); }); }); From 3a4f2922a28eab555d0e820c5355c4984c59709a Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Mon, 13 May 2019 11:43:45 +1000 Subject: [PATCH 032/308] updating demos --- src/debug/use-demo-sensor.js | 16 +++++---- stories/src/programmatic/with-controls.jsx | 41 +++++++++++----------- 2 files changed, 29 insertions(+), 28 deletions(-) diff --git a/src/debug/use-demo-sensor.js b/src/debug/use-demo-sensor.js index 64127c6e56..bc93f1897b 100644 --- a/src/debug/use-demo-sensor.js +++ b/src/debug/use-demo-sensor.js @@ -1,7 +1,7 @@ // @flow import { useEffect } from 'react'; import { useCallback } from 'use-memo-one'; -import type { ActionLock } from '../types'; +import type { PreDragActions, DragActions } from '../types'; function delay(fn: Function, time?: number = 300) { return new Promise(resolve => { @@ -15,7 +15,10 @@ function delay(fn: Function, time?: number = 300) { function noop() {} export default function useDemoSensor( - tryGetActionLock: (source: Event | Element, abort: () => void) => ?ActionLock, + tryGetActionLock: ( + source: Event | Element, + abort: () => void, + ) => ?PreDragActions, ) { const start = useCallback( async function start() { @@ -30,18 +33,17 @@ export default function useDemoSensor( // handle.scrollIntoView(); - const callbacks: ?ActionLock = tryGetActionLock(handle, noop); + const preDrag: ?PreDragActions = tryGetActionLock(handle, noop); - if (!callbacks) { + if (!preDrag) { console.log('unable to start drag'); return; } - const { lift, moveDown, moveUp, drop } = callbacks; - - lift({ + const actions: DragActions = preDrag.lift({ mode: 'SNAP', }); + const { moveDown, moveUp, drop } = actions; await delay(moveDown); await delay(moveDown); diff --git a/stories/src/programmatic/with-controls.jsx b/stories/src/programmatic/with-controls.jsx index 04cf74d386..831b6b61e0 100644 --- a/stories/src/programmatic/with-controls.jsx +++ b/stories/src/programmatic/with-controls.jsx @@ -3,7 +3,11 @@ import React, { useRef, createRef, useState, useCallback } from 'react'; import styled from '@emotion/styled'; import type { Quote } from '../types'; -import type { DropResult, ActionLock } from '../../../src/types'; +import type { + DropResult, + PreDragActions, + DragActions, +} from '../../../src/types'; import { DragDropContext } from '../../../src'; import QuoteList from '../primatives/quote-list'; import reorder from '../reorder'; @@ -13,7 +17,7 @@ type ControlProps = {| quotes: Quote[], canLift: boolean, isDragging: boolean, - lift: (quoteId: string) => ?ActionLock, + lift: (quoteId: string) => ?DragActions, |}; function noop() {} @@ -74,13 +78,13 @@ const ActionButton = styled(Button)` function Controls(props: ControlProps) { const { quotes, canLift, isDragging, lift } = props; - const callbacksRef = useRef(null); + const actionsRef = useRef(null); const selectRef = createRef(); - function maybe(fn: (callbacks: ActionLock) => void) { - if (callbacksRef.current) { - fn(callbacksRef.current); + function maybe(fn: (callbacks: DragActions) => void) { + if (actionsRef.current) { + fn(actionsRef.current); } } @@ -102,7 +106,7 @@ function Controls(props: ControlProps) { return; } - callbacksRef.current = lift(select.value); + actionsRef.current = lift(select.value); }} > Lift 🏋️‍♀️ @@ -110,8 +114,8 @@ function Controls(props: ControlProps) { - maybe((callbacks: MovementCallbacks) => { - callbacksRef.current = null; + maybe((callbacks: DragActions) => { + actionsRef.current = null; callbacks.drop(); }) } @@ -122,9 +126,7 @@ function Controls(props: ControlProps) { - maybe((callbacks: MovementCallbacks) => callbacks.moveUp()) - } + onClick={() => maybe((callbacks: DragActions) => callbacks.moveUp())} disabled={!isDragging} label="up" > @@ -137,7 +139,7 @@ function Controls(props: ControlProps) { - maybe((callbacks: MovementCallbacks) => callbacks.moveDown()) + maybe((callbacks: DragActions) => callbacks.moveDown()) } disabled={!isDragging} label="down" @@ -172,7 +174,7 @@ export default function QuoteApp(props: Props) { const [isDragging, setIsDragging] = useState(false); const [isControlDragging, setIsControlDragging] = useState(false); const tryGetActionLock = useRef< - (el: Element, stop: () => void) => ?ActionLock, + (el: Element, stop: () => void) => ?PreDragActions, >(() => null); const onDragEnd = useCallback( @@ -199,7 +201,7 @@ export default function QuoteApp(props: Props) { [quotes], ); - function lift(quoteId: string): ?ActionLock { + function lift(quoteId: string): ?DragActions { if (isDragging) { return null; } @@ -210,17 +212,14 @@ export default function QuoteApp(props: Props) { return null; } - const callbacks: ?ActionLock = tryGetActionLock.current(handle, noop); + const preDrag: ?PreDragActions = tryGetActionLock.current(handle, noop); - if (!callbacks) { + if (!preDrag) { console.log('unable to start capturing'); return null; } - console.log('capture started'); - callbacks.lift({ mode: 'SNAP' }); setIsControlDragging(true); - - return callbacks; + return preDrag.lift({ mode: 'SNAP' }); } return ( From 8fabf48b6304a5033df835dd6ffc3e6c1c0c203b Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Mon, 13 May 2019 14:46:34 +1000 Subject: [PATCH 033/308] wip --- src/view/use-sensor-marshal/state-machine.js | 15 ++++ .../use-sensor-marshal/use-sensor-marshal.js | 75 ++++++++++++------- .../drag-handle/outdated-locks.spec.js | 38 ++++++++++ 3 files changed, 101 insertions(+), 27 deletions(-) create mode 100644 src/view/use-sensor-marshal/state-machine.js create mode 100644 test/unit/integration/drag-handle/outdated-locks.spec.js diff --git a/src/view/use-sensor-marshal/state-machine.js b/src/view/use-sensor-marshal/state-machine.js new file mode 100644 index 0000000000..6b162f087e --- /dev/null +++ b/src/view/use-sensor-marshal/state-machine.js @@ -0,0 +1,15 @@ +// @flow + +type Definition = {| + initial: T, + states: { + [value: 'PENDING', transition: New] + } +|}; + +type Machine = {| + transition: (newState: T) => void, + getValue: () => T, +|}; + +export default function getMachine(definition: Definition): Machine {} diff --git a/src/view/use-sensor-marshal/use-sensor-marshal.js b/src/view/use-sensor-marshal/use-sensor-marshal.js index 443e54d11c..0b2ed0e9f1 100644 --- a/src/view/use-sensor-marshal/use-sensor-marshal.js +++ b/src/view/use-sensor-marshal/use-sensor-marshal.js @@ -15,12 +15,12 @@ import type { import { isClaimed, claim, - isActive, + isActive as isLockActive, type Lock, release, tryAbandon, } from './lock'; -import type { Store, Action } from '../../state/store-types'; +import type { Store, Action, Dispatch } from '../../state/store-types'; import { getClosestDragHandle, getClosestDraggable } from './get-closest'; import canStartDrag from '../../state/can-start-drag'; import { @@ -48,6 +48,7 @@ function preventDefault(event: Event) { function noop() {} +type LockPhase = 'PRE_DRAG' | 'DRAGGING' | 'FINISHED'; type TryGetLockArgs = {| contextId: ContextId, store: Store, @@ -71,6 +72,20 @@ function getTarget(source: Event | Element): ?Element { return target instanceof Element ? target : null; } +const tryDispatch = (dispatch: Dispatch, isActive: () => boolean) => ( + getAction: () => Action, +): void => { + if (!isActive()) { + warning( + `Cannot perform action. This can occur when actions are outdated or lock is expired. + + Lost: ${JSON.stringify(getAction())}`, + ); + return; + } + dispatch(getAction()); +}; + function tryGetLock({ contextId, store, @@ -125,23 +140,10 @@ function tryGetLock({ // claiming lock const lock: Lock = claim(forceSensorStop); + let phase: LockPhase = 'PRE_DRAG'; - function abortPreDrag() { - if (isActive(lock)) { - release(); - } - } - - function tryDispatch(getAction: () => Action): void { - if (!isActive(lock)) { - warning('Cannot perform action when no longer the owner of the lock'); - return; - } - store.dispatch(getAction()); - } - - function isLockActive() { - return isActive(lock); + function isPreDragActive() { + return phase === 'PRE_DRAG' && isLockActive(lock); } function getShouldRespectForcePress(): boolean { @@ -163,23 +165,31 @@ function tryGetLock({ }; // Do lift operation - tryDispatch(() => liftAction(actionArgs)); + tryDispatch(store.dispatch, isPreDragActive)(() => liftAction(actionArgs)); + + // We are now in the DRAGGING phase + phase = 'DRAGGING'; + + function isDraggingActive() { + return phase === 'DRAGGING' && isLockActive(lock); + } + const execute = tryDispatch(store.dispatch, isDraggingActive); // Setup DragActions - const moveUp = () => tryDispatch(moveUpAction); - const moveDown = () => tryDispatch(moveDownAction); - const moveRight = () => tryDispatch(moveRightAction); - const moveLeft = () => tryDispatch(moveLeftAction); + const moveUp = () => execute(moveUpAction); + const moveDown = () => execute(moveDownAction); + const moveRight = () => execute(moveRightAction); + const moveLeft = () => execute(moveLeftAction); const move = rafSchd((clientSelection: Position) => { - tryDispatch(() => moveAction({ client: clientSelection })); + execute(() => moveAction({ client: clientSelection })); }); function finish( reason: 'CANCEL' | 'DROP', options?: StopDragOptions = { shouldBlockNextClick: false }, ) { - if (!isLockActive()) { + if (!isDraggingActive()) { warning('Cannot finish a drag when there is no lock'); return; } @@ -197,13 +207,16 @@ function tryGetLock({ }); } + // We are no longer dragging + phase = 'FINISHED'; + // releasing lock first so that a tryAbort will not run due to useEffect release(); store.dispatch(dropAction({ reason })); } return { - isActive: isLockActive, + isActive: isDraggingActive, shouldRespectForcePress: getShouldRespectForcePress, move, moveUp, @@ -215,8 +228,16 @@ function tryGetLock({ }; } + function abortPreDrag() { + if (!isPreDragActive()) { + warning('Cannot abort pre drag when no longer active'); + return; + } + release(); + } + const preDrag: PreDragActions = { - isActive: isLockActive, + isActive: isPreDragActive, shouldRespectForcePress: getShouldRespectForcePress, lift, abort: abortPreDrag, diff --git a/test/unit/integration/drag-handle/outdated-locks.spec.js b/test/unit/integration/drag-handle/outdated-locks.spec.js new file mode 100644 index 0000000000..3c9d6fa182 --- /dev/null +++ b/test/unit/integration/drag-handle/outdated-locks.spec.js @@ -0,0 +1,38 @@ +// @flow +import invariant from 'tiny-invariant'; +import React from 'react'; +import { render } from 'react-testing-library'; +import type { + TryGetActionLock, + PreDragActions, + DragActions, + Sensor, +} from '../../../../src/types'; +import App from './app'; +import { isDragging, isDropAnimating } from './util'; + +function noop() {} + +it('it should not allow pre drag actions when in a dragging phase', () => { + let first: TryGetActionLock; + + const a: Sensor = (tryGetLock: TryGetActionLock) => { + first = tryGetLock; + }; + + const { getByText } = render(); + invariant(first, 'expected first to be set'); + const item: HTMLElement = getByText('item: 0'); + + const preDrag: ?PreDragActions = first(item); + invariant(preDrag); + + const drag: DragActions = preDrag.lift({ mode: 'SNAP' }); + + // now outdated + jest.spyOn(console, 'warn').mockImplementation(noop); + preDrag.abort(); + expect(console.warn).toHaveBeenCalledWith( + 'Cannot abort pre drag when no longer active', + ); +}); From 6ea8b99cc75c168f27a405a64fc95f837c5694fc Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Mon, 13 May 2019 17:08:04 +1000 Subject: [PATCH 034/308] new internal state management --- .size-snapshot.json | 24 ++-- src/view/use-announcer/use-announcer.js | 14 ++- .../use-sensor-marshal/use-sensor-marshal.js | 115 ++++++++++++------ .../drag-handle/move-throttling.spec.js | 33 +++-- .../drag-handle/outdated-locks.spec.js | 63 ++++++++-- 5 files changed, 176 insertions(+), 73 deletions(-) diff --git a/.size-snapshot.json b/.size-snapshot.json index c4fe2c332d..eaab082c00 100644 --- a/.size-snapshot.json +++ b/.size-snapshot.json @@ -1,25 +1,25 @@ { "dist/react-beautiful-dnd.js": { - "bundled": 455761, - "minified": 168875, - "gzipped": 49642 + "bundled": 409727, + "minified": 154843, + "gzipped": 43710 }, "dist/react-beautiful-dnd.min.js": { - "bundled": 386756, - "minified": 137264, - "gzipped": 41254 + "bundled": 339834, + "minified": 122765, + "gzipped": 35220 }, "dist/react-beautiful-dnd.esm.js": { - "bundled": 250198, - "minified": 130761, - "gzipped": 33443, + "bundled": 255890, + "minified": 133643, + "gzipped": 34226, "treeshaked": { "rollup": { - "code": 28810, - "import_statements": 1004 + "code": 28738, + "import_statements": 854 }, "webpack": { - "code": 32753 + "code": 32580 } } } diff --git a/src/view/use-announcer/use-announcer.js b/src/view/use-announcer/use-announcer.js index 45c7f892a9..79d972f8a4 100644 --- a/src/view/use-announcer/use-announcer.js +++ b/src/view/use-announcer/use-announcer.js @@ -52,12 +52,14 @@ export default function useAnnouncer(contextId: ContextId): Announce { getBodyElement().appendChild(el); return () => { - const toBeRemoved: ?HTMLElement = ref.current; - invariant(toBeRemoved, 'Cannot unmount announcement node'); - - // Remove from body - getBodyElement().removeChild(toBeRemoved); - ref.current = null; + setTimeout(function remove() { + const toBeRemoved: ?HTMLElement = ref.current; + invariant(toBeRemoved, 'Cannot unmount announcement node'); + + // Remove from body + getBodyElement().removeChild(toBeRemoved); + ref.current = null; + }); }; }, [id]); diff --git a/src/view/use-sensor-marshal/use-sensor-marshal.js b/src/view/use-sensor-marshal/use-sensor-marshal.js index 0b2ed0e9f1..7da19ad471 100644 --- a/src/view/use-sensor-marshal/use-sensor-marshal.js +++ b/src/view/use-sensor-marshal/use-sensor-marshal.js @@ -56,6 +56,54 @@ type TryGetLockArgs = {| forceSensorStop: () => void, |}; +type IsActiveArgs = {| + expected: LockPhase, + phase: LockPhase, + lock: Lock, + shouldWarn: boolean, +|}; + +function isActive({ + expected, + phase, + lock, + shouldWarn, +}: IsActiveArgs): boolean { + // lock is no longer active + if (!isLockActive(lock)) { + if (shouldWarn) { + warning(` + Cannot perform action. + The sensor no longer has an action lock. + + Tips: + + - Throw away your action handlers when forceStop() is called + - Check actions.isActive() if you really need to + `); + } + return false; + } + // wrong phase + if (expected !== phase) { + if (shouldWarn) { + warning(` + Cannot perform action. + The actions you used belong to an outdated phase + + Current phase: ${expected} + You called an action from outdated phase: ${phase} + + Tips: + + - Do not use preDragActions actions after calling preDragActions.lift() + `); + } + return false; + } + return true; +} + function getTarget(source: Event | Element): ?Element { if (source instanceof Element) { return source; @@ -72,20 +120,6 @@ function getTarget(source: Event | Element): ?Element { return target instanceof Element ? target : null; } -const tryDispatch = (dispatch: Dispatch, isActive: () => boolean) => ( - getAction: () => Action, -): void => { - if (!isActive()) { - warning( - `Cannot perform action. This can occur when actions are outdated or lock is expired. - - Lost: ${JSON.stringify(getAction())}`, - ); - return; - } - dispatch(getAction()); -}; - function tryGetLock({ contextId, store, @@ -142,14 +176,16 @@ function tryGetLock({ const lock: Lock = claim(forceSensorStop); let phase: LockPhase = 'PRE_DRAG'; - function isPreDragActive() { - return phase === 'PRE_DRAG' && isLockActive(lock); - } - function getShouldRespectForcePress(): boolean { return shouldRespectForcePress; } + function tryDispatch(expected: LockPhase, getAction: () => Action): void { + if (isActive({ expected, phase, lock, shouldWarn: true })) { + store.dispatch(getAction()); + } + } + function lift(args: SensorLift): DragActions { const actionArgs = args.mode === 'FLUID' @@ -165,15 +201,12 @@ function tryGetLock({ }; // Do lift operation - tryDispatch(store.dispatch, isPreDragActive)(() => liftAction(actionArgs)); + tryDispatch('PRE_DRAG', () => liftAction(actionArgs)); // We are now in the DRAGGING phase phase = 'DRAGGING'; - function isDraggingActive() { - return phase === 'DRAGGING' && isLockActive(lock); - } - const execute = tryDispatch(store.dispatch, isDraggingActive); + const execute = tryDispatch.bind(this, 'DRAGGING'); // Setup DragActions const moveUp = () => execute(moveUpAction); @@ -189,11 +222,6 @@ function tryGetLock({ reason: 'CANCEL' | 'DROP', options?: StopDragOptions = { shouldBlockNextClick: false }, ) { - if (!isDraggingActive()) { - warning('Cannot finish a drag when there is no lock'); - return; - } - // Cancel any pending request animation frames move.cancel(); @@ -216,7 +244,14 @@ function tryGetLock({ } return { - isActive: isDraggingActive, + isActive: () => + isActive({ + expected: 'DRAGGING', + phase, + lock, + // Do not want to want warnings for boolean checks + shouldWarn: false, + }), shouldRespectForcePress: getShouldRespectForcePress, move, moveUp, @@ -229,15 +264,27 @@ function tryGetLock({ } function abortPreDrag() { - if (!isPreDragActive()) { - warning('Cannot abort pre drag when no longer active'); - return; + const shouldRelease: boolean = isActive({ + expected: 'PRE_DRAG', + phase, + lock, + shouldWarn: true, + }); + + if (shouldRelease) { + release(); } - release(); } const preDrag: PreDragActions = { - isActive: isPreDragActive, + isActive: () => + isActive({ + expected: 'PRE_DRAG', + phase, + lock, + // Do not want to want warnings for boolean checks + shouldWarn: false, + }), shouldRespectForcePress: getShouldRespectForcePress, lift, abort: abortPreDrag, diff --git a/test/unit/integration/drag-handle/move-throttling.spec.js b/test/unit/integration/drag-handle/move-throttling.spec.js index 81f245c326..acd0d9ff93 100644 --- a/test/unit/integration/drag-handle/move-throttling.spec.js +++ b/test/unit/integration/drag-handle/move-throttling.spec.js @@ -5,11 +5,12 @@ import type { Position } from 'css-box-model'; import { render } from 'react-testing-library'; import type { TryGetActionLock, - ActionLock, + PreDragActions, + DragActions, Sensor, } from '../../../../src/types'; import App from './app'; -import { isDragging, isDropAnimating, getOffset } from './util'; +import { getOffset } from './util'; import { add } from '../../../../src/state/position'; function noop() {} @@ -23,18 +24,21 @@ it('should throttle move events by request animation frame', () => { invariant(tryGet, 'expected getter to be set'); const handle: HTMLElement = getByText('item: 0'); - const lock: ?ActionLock = tryGet(handle, noop); - invariant(lock); + const preDrag: ?PreDragActions = tryGet(handle, noop); + invariant(preDrag); const initial: Position = { x: 2, y: 3 }; - lock.lift({ mode: 'FLUID', clientSelection: initial }); + const actions: DragActions = preDrag.lift({ + mode: 'FLUID', + clientSelection: initial, + }); // has not moved yet expect(getOffset(handle)).toEqual({ x: 0, y: 0 }); const offset: Position = { x: 1, y: 5 }; - lock.move(add(initial, offset)); - lock.move(add(initial, offset)); - lock.move(add(initial, offset)); + actions.move(add(initial, offset)); + actions.move(add(initial, offset)); + actions.move(add(initial, offset)); // still not moved expect(getOffset(handle)).toEqual({ x: 0, y: 0 }); @@ -53,20 +57,23 @@ it('should cancel any pending moves after a lock is released', () => { invariant(tryGet, 'expected getter to be set'); const handle: HTMLElement = getByText('item: 0'); - const lock: ?ActionLock = tryGet(handle, noop); - invariant(lock); + const preDrag: ?PreDragActions = tryGet(handle, noop); + invariant(preDrag); const initial: Position = { x: 2, y: 3 }; - lock.lift({ mode: 'FLUID', clientSelection: initial }); + const actions: DragActions = preDrag.lift({ + mode: 'FLUID', + clientSelection: initial, + }); // has not moved yet expect(getOffset(handle)).toEqual({ x: 0, y: 0 }); const offset: Position = { x: 1, y: 5 }; - lock.move(add(initial, offset)); + actions.move(add(initial, offset)); // not moved yet expect(getOffset(handle)).toEqual({ x: 0, y: 0 }); - lock.cancel(); + actions.cancel(); // will not do anything requestAnimationFrame.step(); diff --git a/test/unit/integration/drag-handle/outdated-locks.spec.js b/test/unit/integration/drag-handle/outdated-locks.spec.js index 3c9d6fa182..d3304c5ebb 100644 --- a/test/unit/integration/drag-handle/outdated-locks.spec.js +++ b/test/unit/integration/drag-handle/outdated-locks.spec.js @@ -9,30 +9,77 @@ import type { Sensor, } from '../../../../src/types'; import App from './app'; -import { isDragging, isDropAnimating } from './util'; function noop() {} -it('it should not allow pre drag actions when in a dragging phase', () => { - let first: TryGetActionLock; +jest.spyOn(console, 'warn').mockImplementation(noop); + +afterEach(() => { + console.warn.mockClear(); +}); +it('should not allow pre drag actions when in a dragging phase', () => { + let first: TryGetActionLock; const a: Sensor = (tryGetLock: TryGetActionLock) => { first = tryGetLock; }; - const { getByText } = render(); invariant(first, 'expected first to be set'); const item: HTMLElement = getByText('item: 0'); const preDrag: ?PreDragActions = first(item); invariant(preDrag); + // it is currently active + expect(preDrag.isActive()).toBe(true); const drag: DragActions = preDrag.lift({ mode: 'SNAP' }); - // now outdated - jest.spyOn(console, 'warn').mockImplementation(noop); + // pre drag now outdated + expect(preDrag.isActive()).toBe(false); + preDrag.abort(); + expect(console.warn.mock.calls[0][0]).toEqual( + expect.stringContaining('Cannot perform action'), + ); + + // drag is active - not aborted by preDrag + expect(drag.isActive()).toBe(true); + + // ending drag + console.warn.mockClear(); + drag.drop(); + expect(console.warn).not.toHaveBeenCalled(); + + // preDrag is still out of date preDrag.abort(); - expect(console.warn).toHaveBeenCalledWith( - 'Cannot abort pre drag when no longer active', + expect(console.warn.mock.calls[0][0]).toEqual( + expect.stringContaining('Cannot perform action'), + ); +}); + +it('should not allow drag actions after a drop', () => { + let first: TryGetActionLock; + const a: Sensor = (tryGetLock: TryGetActionLock) => { + first = tryGetLock; + }; + const { getByText } = render(); + invariant(first, 'expected first to be set'); + const item: HTMLElement = getByText('item: 0'); + + const preDrag: ?PreDragActions = first(item); + invariant(preDrag); + expect(preDrag.isActive()).toBe(true); + + const drag: DragActions = preDrag.lift({ mode: 'SNAP' }); + expect(drag.isActive()).toBe(true); + + drag.cancel(); + + // no longer active + expect(drag.isActive()).toBe(false); + expect(console.warn).not.toHaveBeenCalled(); + + drag.moveUp(); + expect(console.warn.mock.calls[0][0]).toEqual( + expect.stringContaining('Cannot perform action'), ); }); From c4ce9b3d8f1d19f16ad07cc2016a1241ee33bb1a Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Mon, 13 May 2019 17:28:21 +1000 Subject: [PATCH 035/308] more tests --- .../drag-handle/outdated-locks.spec.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/test/unit/integration/drag-handle/outdated-locks.spec.js b/test/unit/integration/drag-handle/outdated-locks.spec.js index d3304c5ebb..50a4b3a15d 100644 --- a/test/unit/integration/drag-handle/outdated-locks.spec.js +++ b/test/unit/integration/drag-handle/outdated-locks.spec.js @@ -83,3 +83,22 @@ it('should not allow drag actions after a drop', () => { expect.stringContaining('Cannot perform action'), ); }); + +it('should not allow drag actions after lock lost', () => { + let first: TryGetActionLock; + const a: Sensor = (tryGetLock: TryGetActionLock) => { + first = tryGetLock; + }; + const { getByText, unmount } = render(); + invariant(first, 'expected first to be set'); + const item: HTMLElement = getByText('item: 0'); + + const preDrag: ?PreDragActions = first(item); + invariant(preDrag); + expect(preDrag.isActive()).toBe(true); + + // will cause all lock to be lost + unmount(); + + expect(preDrag.isActive()).toBe(false); +}); From 293157d6d1268a8dc3431ef9c664ba1edf077df0 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Mon, 13 May 2019 17:36:55 +1000 Subject: [PATCH 036/308] adding runsheet example --- stories/40-programmatic.stories.js | 7 +-- .../src/programmatic/runsheet.jsx | 52 +++++++++++++++++-- 2 files changed, 52 insertions(+), 7 deletions(-) rename src/debug/use-demo-sensor.js => stories/src/programmatic/runsheet.jsx (54%) diff --git a/stories/40-programmatic.stories.js b/stories/40-programmatic.stories.js index 49b39a4509..24a8dcd5a6 100644 --- a/stories/40-programmatic.stories.js +++ b/stories/40-programmatic.stories.js @@ -2,8 +2,9 @@ import React from 'react'; import { storiesOf } from '@storybook/react'; import WithControls from './src/programmatic/with-controls'; +import Runsheet from './src/programmatic/runsheet'; import { quotes } from './src/data'; -storiesOf('Programmatic dragging', module).add('with controls', () => ( - -)); +storiesOf('Programmatic dragging', module) + .add('with controls', () => ) + .add('with runsheet', () => ); diff --git a/src/debug/use-demo-sensor.js b/stories/src/programmatic/runsheet.jsx similarity index 54% rename from src/debug/use-demo-sensor.js rename to stories/src/programmatic/runsheet.jsx index bc93f1897b..acb8f4119e 100644 --- a/src/debug/use-demo-sensor.js +++ b/stories/src/programmatic/runsheet.jsx @@ -1,7 +1,15 @@ // @flow -import { useEffect } from 'react'; -import { useCallback } from 'use-memo-one'; -import type { PreDragActions, DragActions } from '../types'; +/* eslint-disable no-console */ +import React, { useState, useCallback, useEffect } from 'react'; +import type { Quote } from '../types'; +import type { + DropResult, + PreDragActions, + DragActions, +} from '../../../src/types'; +import { DragDropContext } from '../../../src'; +import QuoteList from '../primatives/quote-list'; +import reorder from '../reorder'; function delay(fn: Function, time?: number = 300) { return new Promise(resolve => { @@ -14,7 +22,7 @@ function delay(fn: Function, time?: number = 300) { function noop() {} -export default function useDemoSensor( +function useDemoSensor( tryGetActionLock: ( source: Event | Element, abort: () => void, @@ -65,3 +73,39 @@ export default function useDemoSensor( start(); }, [start]); } + +type Props = {| + initial: Quote[], +|}; + +export default function QuoteApp(props: Props) { + const [quotes, setQuotes] = useState(props.initial); + + const onDragEnd = useCallback( + function onDragEnd(result: DropResult) { + // dropped outside the list + if (!result.destination) { + return; + } + + if (result.destination.index === result.source.index) { + return; + } + + const newQuotes = reorder( + quotes, + result.source.index, + result.destination.index, + ); + + setQuotes(newQuotes); + }, + [quotes], + ); + + return ( + + + + ); +} From 2f2e122fe8b62da3e4a6f9f3070051e0b0e3bf63 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Tue, 14 May 2019 09:35:18 +1000 Subject: [PATCH 037/308] isolating locks per context --- src/view/use-sensor-marshal/lock.js | 58 +++++++++++-------- src/view/use-sensor-marshal/state-machine.js | 15 ----- src/view/use-sensor-marshal/use-lock.js | 1 + .../use-sensor-marshal/use-sensor-marshal.js | 53 ++++++++--------- .../lock-context-isolation.spec.js | 36 ++++++++++++ 5 files changed, 99 insertions(+), 64 deletions(-) delete mode 100644 src/view/use-sensor-marshal/state-machine.js create mode 100644 src/view/use-sensor-marshal/use-lock.js create mode 100644 test/unit/integration/drag-handle/lock-context-isolation.spec.js diff --git a/src/view/use-sensor-marshal/lock.js b/src/view/use-sensor-marshal/lock.js index 89bfc048d4..a88f93db46 100644 --- a/src/view/use-sensor-marshal/lock.js +++ b/src/view/use-sensor-marshal/lock.js @@ -5,32 +5,44 @@ export type Lock = {| abandon: () => void, |}; -let lock: ?Lock = null; +export type LockAPI = {| + isClaimed: () => boolean, + isActive: (lock: Lock) => boolean, + claim: (abandon: () => void) => Lock, + release: () => void, + tryAbandon: () => void, +|}; -export function isClaimed(): boolean { - return Boolean(lock); -} +export default function create(): LockAPI { + let lock: ?Lock = null; -export function isActive(value: Lock): boolean { - return value === lock; -} + function isClaimed(): boolean { + return Boolean(lock); + } -export function claim(abandon: () => void): Lock { - invariant(!lock, 'Cannot claim lock as it is already claimed'); - const newLock: Lock = { abandon }; - // update singleton - lock = newLock; - // return lock - return newLock; -} -export function release() { - invariant(lock, 'Cannot release lock when there is no lock'); - lock = null; -} + function isActive(value: Lock): boolean { + return value === lock; + } -export function tryAbandon() { - if (lock) { - lock.abandon(); - release(); + function claim(abandon: () => void): Lock { + invariant(!lock, 'Cannot claim lock as it is already claimed'); + const newLock: Lock = { abandon }; + // update singleton + lock = newLock; + // return lock + return newLock; + } + function release() { + invariant(lock, 'Cannot release lock when there is no lock'); + lock = null; } + + function tryAbandon() { + if (lock) { + lock.abandon(); + release(); + } + } + + return { isClaimed, isActive, claim, release, tryAbandon }; } diff --git a/src/view/use-sensor-marshal/state-machine.js b/src/view/use-sensor-marshal/state-machine.js deleted file mode 100644 index 6b162f087e..0000000000 --- a/src/view/use-sensor-marshal/state-machine.js +++ /dev/null @@ -1,15 +0,0 @@ -// @flow - -type Definition = {| - initial: T, - states: { - [value: 'PENDING', transition: New] - } -|}; - -type Machine = {| - transition: (newState: T) => void, - getValue: () => T, -|}; - -export default function getMachine(definition: Definition): Machine {} diff --git a/src/view/use-sensor-marshal/use-lock.js b/src/view/use-sensor-marshal/use-lock.js new file mode 100644 index 0000000000..46e7f7c045 --- /dev/null +++ b/src/view/use-sensor-marshal/use-lock.js @@ -0,0 +1 @@ +// @flow diff --git a/src/view/use-sensor-marshal/use-sensor-marshal.js b/src/view/use-sensor-marshal/use-sensor-marshal.js index 7da19ad471..cd8b545b96 100644 --- a/src/view/use-sensor-marshal/use-sensor-marshal.js +++ b/src/view/use-sensor-marshal/use-sensor-marshal.js @@ -1,6 +1,6 @@ // @flow import rafSchd from 'raf-schd'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { useCallback } from 'use-memo-one'; import type { Position } from 'css-box-model'; import type { @@ -12,15 +12,8 @@ import type { PreDragActions, DragActions, } from '../../types'; -import { - isClaimed, - claim, - isActive as isLockActive, - type Lock, - release, - tryAbandon, -} from './lock'; -import type { Store, Action, Dispatch } from '../../state/store-types'; +import create, { type Lock, type LockAPI } from './lock'; +import type { Store, Action } from '../../state/store-types'; import { getClosestDragHandle, getClosestDraggable } from './get-closest'; import canStartDrag from '../../state/can-start-drag'; import { @@ -50,6 +43,7 @@ function noop() {} type LockPhase = 'PRE_DRAG' | 'DRAGGING' | 'FINISHED'; type TryGetLockArgs = {| + lockAPI: LockAPI, contextId: ContextId, store: Store, source: Event | Element, @@ -59,18 +53,18 @@ type TryGetLockArgs = {| type IsActiveArgs = {| expected: LockPhase, phase: LockPhase, - lock: Lock, + isLockActive: () => boolean, shouldWarn: boolean, |}; function isActive({ expected, phase, - lock, + isLockActive, shouldWarn, }: IsActiveArgs): boolean { // lock is no longer active - if (!isLockActive(lock)) { + if (!isLockActive()) { if (shouldWarn) { warning(` Cannot perform action. @@ -121,13 +115,14 @@ function getTarget(source: Event | Element): ?Element { } function tryGetLock({ + lockAPI, contextId, store, source, forceSensorStop, }: TryGetLockArgs): ?PreDragActions { // lock is already claimed - cannot start - if (isClaimed()) { + if (lockAPI.isClaimed()) { return null; } @@ -173,15 +168,19 @@ function tryGetLock({ } // claiming lock - const lock: Lock = claim(forceSensorStop); + const lock: Lock = lockAPI.claim(forceSensorStop); let phase: LockPhase = 'PRE_DRAG'; function getShouldRespectForcePress(): boolean { return shouldRespectForcePress; } + function isLockActive(): boolean { + return lockAPI.isActive(lock); + } + function tryDispatch(expected: LockPhase, getAction: () => Action): void { - if (isActive({ expected, phase, lock, shouldWarn: true })) { + if (isActive({ expected, phase, isLockActive, shouldWarn: true })) { store.dispatch(getAction()); } } @@ -239,7 +238,7 @@ function tryGetLock({ phase = 'FINISHED'; // releasing lock first so that a tryAbort will not run due to useEffect - release(); + lockAPI.release(); store.dispatch(dropAction({ reason })); } @@ -248,7 +247,7 @@ function tryGetLock({ isActive({ expected: 'DRAGGING', phase, - lock, + isLockActive, // Do not want to want warnings for boolean checks shouldWarn: false, }), @@ -267,12 +266,12 @@ function tryGetLock({ const shouldRelease: boolean = isActive({ expected: 'PRE_DRAG', phase, - lock, + isLockActive, shouldWarn: true, }); if (shouldRelease) { - release(); + lockAPI.release(); } } @@ -281,7 +280,7 @@ function tryGetLock({ isActive({ expected: 'PRE_DRAG', phase, - lock, + isLockActive, // Do not want to want warnings for boolean checks shouldWarn: false, }), @@ -307,6 +306,7 @@ export default function useSensorMarshal({ customSensors, }: SensorMarshalArgs) { const useSensors: Sensor[] = [...defaultSensors, ...(customSensors || [])]; + const lockAPI: LockAPI = useState(() => create())[0]; // We need to abort any capturing if there is no longer a drag useEffect( @@ -316,7 +316,7 @@ export default function useSensorMarshal({ const current: State = store.getState(); if (previous.isDragging && !current.isDragging) { - tryAbandon(); + lockAPI.tryAbandon(); } previous = current; @@ -325,23 +325,24 @@ export default function useSensorMarshal({ // unsubscribe from store when unmounting return unsubscribe; }, - [store], + [lockAPI, store], ); // abort any lock on unmount useLayoutEffect(() => { - return tryAbandon; - }, []); + return lockAPI.tryAbandon; + }, [lockAPI.tryAbandon]); const wrapper = useCallback( (source: Event | Element, forceStop?: () => void = noop): ?PreDragActions => tryGetLock({ + lockAPI, contextId, store, source, forceSensorStop: forceStop, }), - [contextId, store], + [contextId, lockAPI, store], ); // Bad ass diff --git a/test/unit/integration/drag-handle/lock-context-isolation.spec.js b/test/unit/integration/drag-handle/lock-context-isolation.spec.js new file mode 100644 index 0000000000..c0efbc7692 --- /dev/null +++ b/test/unit/integration/drag-handle/lock-context-isolation.spec.js @@ -0,0 +1,36 @@ +// @flow +import invariant from 'tiny-invariant'; +import React from 'react'; +import { render } from 'react-testing-library'; +import type { TryGetActionLock, Sensor } from '../../../../src/types'; +import App from './app'; + +it('should allow different locks in different DragDropContexts', () => { + let first: TryGetActionLock; + let second: TryGetActionLock; + + const a: Sensor = (tryGetLock: TryGetActionLock) => { + first = tryGetLock; + }; + const b: Sensor = (tryGetLock: TryGetActionLock) => { + second = tryGetLock; + }; + + const { getAllByText } = render( + + + + , + ); + + const items: HTMLElement[] = getAllByText('item: 0'); + expect(items).toHaveLength(2); + const [inFirst, inSecond] = items; + expect(inFirst).not.toBe(inSecond); + + // each sensor can get a different lock + invariant(first, 'expected first to be set'); + invariant(second, 'expected second to be set'); + expect(first(inFirst)).toBeTruthy(); + expect(second(inSecond)).toBeTruthy(); +}); From f835692a6b3655a715ae4ab13e90f43f1cf4be0e Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Tue, 14 May 2019 10:34:23 +1000 Subject: [PATCH 038/308] allowing non base 0 index for mouse dragging --- .../get-drag-impact/get-reorder-impact.js | 18 ++++++++++++++---- src/state/get-drag-impact/index.js | 2 +- src/state/get-home-on-lift.js | 8 +++++++- src/view/use-sensor-marshal/use-lock.js | 1 - stories/src/primatives/quote-list.jsx | 2 +- stories/src/vertical/quote-app.jsx | 4 ++-- 6 files changed, 25 insertions(+), 10 deletions(-) delete mode 100644 src/view/use-sensor-marshal/use-lock.js diff --git a/src/state/get-drag-impact/get-reorder-impact.js b/src/state/get-drag-impact/get-reorder-impact.js index d508fdfb4e..124f43a40f 100644 --- a/src/state/get-drag-impact/get-reorder-impact.js +++ b/src/state/get-drag-impact/get-reorder-impact.js @@ -17,12 +17,13 @@ import getDisplacementMap from '../get-displacement-map'; import isUserMovingForward from '../user-direction/is-user-moving-forward'; import getDisplacedBy from '../get-displaced-by'; import getDidStartDisplaced from '../starting-displaced/did-start-displaced'; +import removeDraggableFromList from '../remove-draggable-from-list'; type Args = {| pageBorderBoxCenterWithDroppableScrollChange: Position, draggable: DraggableDimension, destination: DroppableDimension, - insideDestinationWithoutDraggable: DraggableDimension[], + insideDestination: DraggableDimension[], previousImpact: DragImpact, viewport: Viewport, userDirection: UserDirection, @@ -33,7 +34,7 @@ export default ({ pageBorderBoxCenterWithDroppableScrollChange: currentCenter, draggable, destination, - insideDestinationWithoutDraggable, + insideDestination, previousImpact, viewport, userDirection, @@ -51,6 +52,11 @@ export default ({ const targetCenter: number = currentCenter[axis.line]; const displacement: number = displacedBy.value; + const insideDestinationWithoutDraggable = removeDraggableFromList( + draggable, + insideDestination, + ); + const displaced: Displacement[] = insideDestinationWithoutDraggable .filter( (child: DraggableDimension): boolean => { @@ -99,8 +105,12 @@ export default ({ }), ); - const newIndex: number = - insideDestinationWithoutDraggable.length - displaced.length; + // This is needed as we support lists with indexes that do not start from 0 + const rawIndexOfLastItem: number = insideDestination.length + ? insideDestination[insideDestination.length - 1].descriptor.index + : 0; + + const newIndex: number = rawIndexOfLastItem - displaced.length; const movement: DragMovement = { displacedBy, diff --git a/src/state/get-drag-impact/index.js b/src/state/get-drag-impact/index.js index 2da6679db9..df145a9482 100644 --- a/src/state/get-drag-impact/index.js +++ b/src/state/get-drag-impact/index.js @@ -88,7 +88,7 @@ export default ({ pageBorderBoxCenterWithDroppableScrollChange, destination, draggable, - insideDestinationWithoutDraggable, + insideDestination, previousImpact, viewport, userDirection, diff --git a/src/state/get-home-on-lift.js b/src/state/get-home-on-lift.js index e7626578b3..8844993ed9 100644 --- a/src/state/get-home-on-lift.js +++ b/src/state/get-home-on-lift.js @@ -1,4 +1,5 @@ // @flow +import invariant from 'tiny-invariant'; import getHomeLocation from './get-home-location'; import type { DraggableDimension, @@ -41,8 +42,13 @@ export default ({ draggable, home, draggables, viewport }: Args): Result => { draggables, ); + // in a list that does not start at 0 the descriptor.index might be different from the index in the list + // eg a list could be: [2,3,4]. A descriptor.index of '2' would actually be in index '0' of the list + const rawIndex: number = insideHome.indexOf(draggable); + invariant(rawIndex !== -1, 'Expected draggable to be inside home list'); + const originallyDisplaced: DraggableDimension[] = insideHome.slice( - draggable.descriptor.index + 1, + rawIndex + 1, ); const wasDisplaced: DraggableIdMap = originallyDisplaced.reduce( (previous: DraggableIdMap, item: DraggableDimension): DraggableIdMap => { diff --git a/src/view/use-sensor-marshal/use-lock.js b/src/view/use-sensor-marshal/use-lock.js deleted file mode 100644 index 46e7f7c045..0000000000 --- a/src/view/use-sensor-marshal/use-lock.js +++ /dev/null @@ -1 +0,0 @@ -// @flow diff --git a/stories/src/primatives/quote-list.jsx b/stories/src/primatives/quote-list.jsx index 97d91bcdfe..6dc7e8cf8d 100644 --- a/stories/src/primatives/quote-list.jsx +++ b/stories/src/primatives/quote-list.jsx @@ -86,7 +86,7 @@ const InnerQuoteList = React.memo(function InnerQuoteList( props: QuoteListProps, ) { return props.quotes.map((quote: Quote, index: number) => ( - + {( dragProvided: DraggableProvided, dragSnapshot: DraggableStateSnapshot, diff --git a/stories/src/vertical/quote-app.jsx b/stories/src/vertical/quote-app.jsx index 913610853e..96234855ff 100644 --- a/stories/src/vertical/quote-app.jsx +++ b/stories/src/vertical/quote-app.jsx @@ -65,8 +65,8 @@ export default class QuoteApp extends Component { const quotes = reorder( this.state.quotes, - result.source.index, - result.destination.index, + result.source.index - 1, + result.destination.index - 1, ); this.setState({ From 4f668893a5a5ee0f8e49a11ad88c361cb6decac2 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Tue, 14 May 2019 15:31:51 +1000 Subject: [PATCH 039/308] supporting non 0 based lists --- .../get-drag-impact/get-reorder-impact.js | 17 ++++++++-- .../move-cross-axis/move-to-new-droppable.js | 33 ++++++++++++++----- .../move-to-next-index/from-reorder.js | 28 ++++++++-------- .../move-to-next-index/index.js | 16 +++++++-- 4 files changed, 66 insertions(+), 28 deletions(-) diff --git a/src/state/get-drag-impact/get-reorder-impact.js b/src/state/get-drag-impact/get-reorder-impact.js index 124f43a40f..2852e5fac9 100644 --- a/src/state/get-drag-impact/get-reorder-impact.js +++ b/src/state/get-drag-impact/get-reorder-impact.js @@ -18,6 +18,7 @@ import isUserMovingForward from '../user-direction/is-user-moving-forward'; import getDisplacedBy from '../get-displaced-by'; import getDidStartDisplaced from '../starting-displaced/did-start-displaced'; import removeDraggableFromList from '../remove-draggable-from-list'; +import isHomeOf from '../droppable/is-home-of'; type Args = {| pageBorderBoxCenterWithDroppableScrollChange: Position, @@ -106,9 +107,19 @@ export default ({ ); // This is needed as we support lists with indexes that do not start from 0 - const rawIndexOfLastItem: number = insideDestination.length - ? insideDestination[insideDestination.length - 1].descriptor.index - : 0; + const rawIndexOfLastItem: number = (() => { + if (!insideDestination.length) { + return 0; + } + + const indexOfLastItem: number = + insideDestination[insideDestination.length - 1].descriptor.index; + + // When in a foreign list there will be an additional one item in the list + return isHomeOf(draggable, destination) + ? indexOfLastItem + : indexOfLastItem + 1; + })(); const newIndex: number = rawIndexOfLastItem - displaced.length; diff --git a/src/state/move-in-direction/move-cross-axis/move-to-new-droppable.js b/src/state/move-in-direction/move-cross-axis/move-to-new-droppable.js index 04fe12564c..1b77386ffe 100644 --- a/src/state/move-in-direction/move-cross-axis/move-to-new-droppable.js +++ b/src/state/move-in-direction/move-cross-axis/move-to-new-droppable.js @@ -90,28 +90,43 @@ export default ({ moveRelativeTo.page.borderBox.center[destination.axis.line], ); - // Moving to a populated list - const targetIndex: number = insideDestination.indexOf(moveRelativeTo); - invariant(targetIndex !== -1, 'Cannot find target in list'); - const proposedIndex: number = (() => { - // TODO: is this logic correct? + const relativeTo: number = moveRelativeTo.descriptor.index; + if (moveRelativeTo.descriptor.id === draggable.descriptor.id) { - return targetIndex; + return relativeTo; } if (isGoingBeforeTarget) { - return targetIndex; + return relativeTo; } - return targetIndex + 1; + return relativeTo + 1; })(); + const sliceFrom: number = (() => { + const firstIndex: number = insideDestination[0].descriptor.index; + return proposedIndex - firstIndex; + })(); + + console.log( + { + moveRelativeTo: moveRelativeTo.descriptor.id, + sliceFrom, + proposedIndex, + // isGoingBeforeTarget, + }, + { + droppableId: destination.descriptor.id, + index: proposedIndex, + }, + ); + const displaced: Displacement[] = removeDraggableFromList( draggable, insideDestination, ) - .slice(proposedIndex) + .slice(sliceFrom) .map( (dimension: DraggableDimension): Displacement => getDisplacement({ diff --git a/src/state/move-in-direction/move-to-next-place/move-to-next-index/from-reorder.js b/src/state/move-in-direction/move-to-next-place/move-to-next-index/from-reorder.js index cc343ee51b..46fc20a8fe 100644 --- a/src/state/move-in-direction/move-to-next-place/move-to-next-index/from-reorder.js +++ b/src/state/move-in-direction/move-to-next-place/move-to-next-index/from-reorder.js @@ -6,36 +6,38 @@ type Args = {| isMovingForward: boolean, isInHomeList: boolean, location: DraggableLocation, - draggable: DraggableDimension, insideDestination: DraggableDimension[], |}; export default ({ isMovingForward, isInHomeList, - draggable, - insideDestination: initialInside, + insideDestination, location, }: Args): ?Instruction => { - const insideDestination: DraggableDimension[] = initialInside.slice(); - const currentIndex: number = location.index; - const isInForeignList: boolean = !isInHomeList; - - // in foreign list we need to insert the item into the right spot - if (isInForeignList) { - insideDestination.splice(location.index, 0, draggable); + if (!insideDestination.length) { + return null; } + + const currentIndex: number = location.index; const proposedIndex: number = isMovingForward ? currentIndex + 1 : currentIndex - 1; - if (proposedIndex < 0) { + // Accounting for lists that might not start with an index of 0 + const firstIndex: number = insideDestination[0].descriptor.index; + const lastIndex: number = + insideDestination[insideDestination.length - 1].descriptor.index; + + // When in foreign list we allow movement after the last item + const upperBound: number = isInHomeList ? lastIndex : lastIndex + 1; + + if (proposedIndex < firstIndex) { return null; } - if (proposedIndex > insideDestination.length - 1) { + if (proposedIndex > upperBound) { return null; } - return { proposedIndex, modifyDisplacement: true, diff --git a/src/state/move-in-direction/move-to-next-place/move-to-next-index/index.js b/src/state/move-in-direction/move-to-next-place/move-to-next-index/index.js index 91db020a78..10ff4c7b84 100644 --- a/src/state/move-in-direction/move-to-next-place/move-to-next-index/index.js +++ b/src/state/move-in-direction/move-to-next-place/move-to-next-index/index.js @@ -44,7 +44,6 @@ export default ({ return fromReorder({ isMovingForward, isInHomeList, - draggable, location: previousImpact.destination, insideDestination, }); @@ -84,17 +83,28 @@ export default ({ } if (isMovingForward) { + console.log('remove closest'); return removeClosest(lastDisplaced); } // moving backwards - will increase the amount of displaced items - const withoutDraggable: DraggableDimension[] = removeDraggableFromList( draggable, insideDestination, ); - const atProposedIndex: DraggableDimension = withoutDraggable[proposedIndex]; + console.log('trying to add at logical index'); + const startIndex: number = insideDestination[0].descriptor.index; + const atProposedIndex: ?DraggableDimension = + withoutDraggable[proposedIndex - startIndex]; + invariant( + atProposedIndex, + `Could not find item at proposed index ${proposedIndex}`, + ); + console.log('add closest', { + proposedIndex, + item: atProposedIndex.descriptor, + }); return addClosest(atProposedIndex, lastDisplaced); })(); From 08030e92a9b8154c96d1c5ac2e3ae1c262ced325 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Wed, 15 May 2019 08:11:39 +1000 Subject: [PATCH 040/308] wip --- src/view/context/droppable-context.js | 1 + src/view/draggable/draggable.jsx | 30 ++++++++++++++++++++++++++- src/view/draggable/use-validation.js | 6 +++++- src/view/droppable/droppable.jsx | 1 + 4 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/view/context/droppable-context.js b/src/view/context/droppable-context.js index a8e27227af..dcfd04ed20 100644 --- a/src/view/context/droppable-context.js +++ b/src/view/context/droppable-context.js @@ -3,6 +3,7 @@ import React from 'react'; import type { DroppableId, TypeId } from '../../types'; export type DroppableContextValue = {| + usingCloneWhenDragging: boolean, droppableId: DroppableId, type: TypeId, |}; diff --git a/src/view/draggable/draggable.jsx b/src/view/draggable/draggable.jsx index 732c5cdfc0..8b92a7bc78 100644 --- a/src/view/draggable/draggable.jsx +++ b/src/view/draggable/draggable.jsx @@ -1,5 +1,5 @@ // @flow -import { useRef } from 'react'; +import React, { useRef } from 'react'; import { type Position } from 'css-box-model'; import invariant from 'tiny-invariant'; import { useMemo, useCallback } from 'use-memo-one'; @@ -20,6 +20,9 @@ import getWindowScroll from '../window/get-window-scroll'; // import throwIfRefIsInvalid from '../throw-if-invalid-inner-ref'; // import checkOwnProps from './check-own-props'; import AppContext, { type AppContextValue } from '../context/app-context'; +import DroppableContext, { + type DroppableContextValue, +} from '../context/droppable-context'; import useRequiredContext from '../use-required-context'; import useValidation from './use-validation'; @@ -33,6 +36,10 @@ export default function Draggable(props: Props) { // context const appContext: AppContextValue = useRequiredContext(AppContext); + const droppableContext: DroppableContextValue = useRequiredContext( + DroppableContext, + ); + const { usingCloneWhenDragging } = droppableContext; // Validating props and innerRef useValidation(props, getRef); @@ -211,5 +218,26 @@ export default function Draggable(props: Props) { shouldRespectForcePress, ]); + if (isDragging && usingCloneWhenDragging) { + return ( +
+ + 🤘 + +
+ ); + } + return children(provided, mapped.snapshot); } diff --git a/src/view/draggable/use-validation.js b/src/view/draggable/use-validation.js index 6680ddd4fb..284fa4f83c 100644 --- a/src/view/draggable/use-validation.js +++ b/src/view/draggable/use-validation.js @@ -36,7 +36,11 @@ export default function useValidation( if (process.env.NODE_ENV !== 'production') { checkOwnProps(props); checkForOutdatedProps(props); - checkIsValidInnerRef(getRef()); + + // TODO: run check when virtual? + if (props.mapped.type !== 'DRAGGING') { + checkIsValidInnerRef(getRef()); + } } }); } diff --git a/src/view/droppable/droppable.jsx b/src/view/droppable/droppable.jsx index d6989dcccf..d62fa1a873 100644 --- a/src/view/droppable/droppable.jsx +++ b/src/view/droppable/droppable.jsx @@ -106,6 +106,7 @@ export default function Droppable(props: Props) { () => ({ droppableId, type, + usingCloneWhenDragging: true, }), [droppableId, type], ); From 91524a2af7fe269af709c238cdbb6bbe51761c28 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Wed, 15 May 2019 11:15:03 +1000 Subject: [PATCH 041/308] dragging a clone --- src/view/draggable/connected-draggable.js | 1 + src/view/draggable/draggable-types.js | 1 + src/view/draggable/draggable.jsx | 43 ++++++++++--------- src/view/droppable/connected-droppable.js | 19 +++++++- src/view/droppable/droppable-types.js | 19 +++++++- src/view/droppable/droppable.jsx | 43 +++++++++++++++++-- src/view/use-drag-handle/use-validation.js | 1 - .../use-draggable-dimension-publisher.js | 9 +++- stories/src/primatives/quote-list.jsx | 9 +++- stories/src/vertical/quote-app.jsx | 4 +- 10 files changed, 119 insertions(+), 30 deletions(-) diff --git a/src/view/draggable/connected-draggable.js b/src/view/draggable/connected-draggable.js index c2f351693a..2cac9b7a9c 100644 --- a/src/view/draggable/connected-draggable.js +++ b/src/view/draggable/connected-draggable.js @@ -309,6 +309,7 @@ const mapDispatchToProps: DispatchProps = { }; const defaultProps = ({ + isClone: false, isDragDisabled: false, // Cannot drag interactive elements by default disableInteractiveElementBlocking: false, diff --git a/src/view/draggable/draggable-types.js b/src/view/draggable/draggable-types.js index 0d920002b8..b1de6f6ef0 100644 --- a/src/view/draggable/draggable-types.js +++ b/src/view/draggable/draggable-types.js @@ -142,6 +142,7 @@ export type ChildrenFn = (Provided, StateSnapshot) => Node | null; export type DefaultProps = {| isDragDisabled: boolean, + isClone: boolean, disableInteractiveElementBlocking: boolean, shouldRespectForcePress: boolean, |}; diff --git a/src/view/draggable/draggable.jsx b/src/view/draggable/draggable.jsx index 8b92a7bc78..ae733e7e97 100644 --- a/src/view/draggable/draggable.jsx +++ b/src/view/draggable/draggable.jsx @@ -53,6 +53,7 @@ export default function Draggable(props: Props) { shouldRespectForcePress, disableInteractiveElementBlocking: canDragInteractiveElements, index, + isClone, // mapProps mapped, @@ -76,8 +77,9 @@ export default function Draggable(props: Props) { draggableId, index, getDraggableRef: getRef, + isClone, }), - [draggableId, getRef, index], + [draggableId, getRef, index, isClone], ); useDraggableDimensionPublisher(forPublisher); @@ -218,25 +220,26 @@ export default function Draggable(props: Props) { shouldRespectForcePress, ]); - if (isDragging && usingCloneWhenDragging) { - return ( -
- - 🤘 - -
- ); + if (isDragging && usingCloneWhenDragging && !isClone) { + return null; + // return ( + //
+ // + // 🤘 + // + //
+ // ); } return children(provided, mapped.snapshot); diff --git a/src/view/droppable/connected-droppable.js b/src/view/droppable/connected-droppable.js index 26f80f557c..6fd0b6761e 100644 --- a/src/view/droppable/connected-droppable.js +++ b/src/view/droppable/connected-droppable.js @@ -1,4 +1,5 @@ // @flow +import invariant from 'tiny-invariant'; // eslint-disable-next-line no-unused-vars import { Component } from 'react'; import { connect } from 'react-redux'; @@ -20,6 +21,7 @@ import type { Selector, DispatchProps, StateSnapshot, + DraggingFromThisWith, } from './droppable-types'; import Droppable from './droppable'; import isStrictEqual from '../is-strict-equal'; @@ -96,7 +98,16 @@ export const makeMapStateToProps = (): Selector => { const draggingOverWith: ?DraggableId = isDraggingOver ? draggableId : null; - const draggingFromThisWith: ?DraggableId = isHome ? draggableId : null; + const draggingFromThisWith: ?DraggingFromThisWith = isHome + ? { + id: draggableId, + source: { index: dragging.descriptor.index, droppableId: id }, + } + : null; + console.log( + 'draggingFromThisWith', + draggingFromThisWith ? draggingFromThisWith.source : null, + ); return { isDraggingOver, @@ -200,12 +211,18 @@ const mapDispatchToProps: DispatchProps = { updateViewportMaxScroll: updateViewportMaxScrollAction, }; +function getBody(): HTMLElement { + invariant(document.body, 'document.body is not ready'); + return document.body; +} + const defaultProps = ({ type: 'DEFAULT', direction: 'vertical', isDropDisabled: false, isCombineEnabled: false, ignoreContainerClipping: false, + getContainerForClone: getBody, }: DefaultProps); // Abstract class allows to specify props and defaults to component. diff --git a/src/view/droppable/droppable-types.js b/src/view/droppable/droppable-types.js index efbe98d398..c231920a85 100644 --- a/src/view/droppable/droppable-types.js +++ b/src/view/droppable/droppable-types.js @@ -8,7 +8,12 @@ import type { Placeholder, State, ContextId, + DraggableLocation, } from '../../types'; +import type { + StateSnapshot as DraggableStateSnapshot, + Provided as DraggableProvided, +} from '../draggable/draggable-types'; import { updateViewportMaxScroll } from '../../state/action-creators'; export type DroppableProps = {| @@ -22,6 +27,11 @@ export type Provided = {| droppableProps: DroppableProps, |}; +export type DraggingFromThisWith = {| + id: DraggableId, + source: DraggableLocation, +|}; + export type StateSnapshot = {| // Is the Droppable being dragged over? isDraggingOver: boolean, @@ -29,7 +39,7 @@ export type StateSnapshot = {| draggingOverWith: ?DraggableId, // What is the id of the draggable that is dragging from this list? // Useful for styling the home list when not being dragged over - draggingFromThisWith: ?DraggableId, + draggingFromThisWith: ?DraggingFromThisWith, |}; export type MapProps = {| @@ -48,6 +58,7 @@ export type DefaultProps = {| isCombineEnabled: boolean, direction: Direction, ignoreContainerClipping: boolean, + getContainerForClone: () => HTMLElement, |}; export type DispatchProps = {| @@ -58,6 +69,12 @@ export type OwnProps = {| ...DefaultProps, children: (Provided, StateSnapshot) => Node, droppableId: DroppableId, + // TODO: hoist these types up? + whenDraggingClone?: ( + provided: DraggableProvided, + snapshot: DraggableStateSnapshot, + source: DraggableLocation, + ) => Node, |}; export type Props = {| diff --git a/src/view/droppable/droppable.jsx b/src/view/droppable/droppable.jsx index d62fa1a873..7cfb5a81ac 100644 --- a/src/view/droppable/droppable.jsx +++ b/src/view/droppable/droppable.jsx @@ -1,8 +1,9 @@ // @flow import invariant from 'tiny-invariant'; +import ReactDOM from 'react-dom'; import { useMemo, useCallback } from 'use-memo-one'; import React, { useRef, useContext, type Node } from 'react'; -import type { Props, Provided } from './droppable-types'; +import type { Props, Provided, DraggingFromThisWith } from './droppable-types'; import useDroppableDimensionPublisher from '../use-droppable-dimension-publisher'; import Placeholder from '../placeholder'; import AppContext, { type AppContextValue } from '../context/app-context'; @@ -12,6 +13,11 @@ import DroppableContext, { // import useAnimateInOut from '../use-animate-in-out/use-animate-in-out'; import getMaxWindowScroll from '../window/get-max-window-scroll'; import useValidation from './use-validation'; +import Draggable from '../draggable'; +import type { + StateSnapshot as DraggableStateSnapshot, + Provided as DraggableProvided, +} from '../draggable/draggable-types'; import AnimateInOut, { type AnimateProvided, } from '../animate-in-out/animate-in-out'; @@ -38,6 +44,10 @@ export default function Droppable(props: Props) { snapshot, // dispatch props updateViewportMaxScroll, + + // clone (ownProps) + whenDraggingClone, + getPortalForClone, } = props; const getDroppableRef = useCallback( @@ -102,13 +112,15 @@ export default function Droppable(props: Props) { [contextId, placeholder, setDroppableRef], ); + const usingCloneWhenDragging: boolean = Boolean(whenDraggingClone); + const droppableContext: ?DroppableContextValue = useMemo( () => ({ droppableId, type, - usingCloneWhenDragging: true, + usingCloneWhenDragging, }), - [droppableId, type], + [droppableId, type, usingCloneWhenDragging], ); useValidation({ @@ -117,9 +129,34 @@ export default function Droppable(props: Props) { getPlaceholderRef: () => placeholderRef.current, }); + function getClone(): ?Node { + if (!whenDraggingClone) { + return null; + } + const draggingFromThisWith: ?DraggingFromThisWith = + snapshot.draggingFromThisWith; + if (!draggingFromThisWith) { + return null; + } + const { id, source } = draggingFromThisWith; + console.log('source', source); + + const item: Node = ( + + {( + draggableProvided: DraggableProvided, + draggableSnapshot: DraggableStateSnapshot, + ) => whenDraggingClone(draggableProvided, draggableSnapshot, source)} + + ); + + return ReactDOM.createPortal(item, props.getContainerForClone()); + } + return ( {children(provided, snapshot)} + {getClone()} ); } diff --git a/src/view/use-drag-handle/use-validation.js b/src/view/use-drag-handle/use-validation.js index f6dfc6973c..6f40de5a4c 100644 --- a/src/view/use-drag-handle/use-validation.js +++ b/src/view/use-drag-handle/use-validation.js @@ -17,7 +17,6 @@ export default function useValidation({ isEnabled, getDraggableRef }: Args) { if (!isEnabled) { return; } - const draggableRef: ?HTMLElement = getDraggableRef(); invariant(draggableRef, 'Drag handle was unable to find draggable ref'); getDragHandleRef(draggableRef); diff --git a/src/view/use-draggable-dimension-publisher/use-draggable-dimension-publisher.js b/src/view/use-draggable-dimension-publisher/use-draggable-dimension-publisher.js index e61c16dfb7..f30fd8e258 100644 --- a/src/view/use-draggable-dimension-publisher/use-draggable-dimension-publisher.js +++ b/src/view/use-draggable-dimension-publisher/use-draggable-dimension-publisher.js @@ -20,11 +20,18 @@ import useLayoutEffect from '../use-isomorphic-layout-effect'; export type Args = {| draggableId: DraggableId, index: number, + isClone: boolean, getDraggableRef: () => ?HTMLElement, |}; export default function useDraggableDimensionPublisher(args: Args) { - const { draggableId, index, getDraggableRef } = args; + const { isClone, draggableId, index, getDraggableRef } = args; + + // TODO: breaks the rules of hooks + if (isClone) { + return; + } + // App context const appContext: AppContextValue = useRequiredContext(AppContext); const marshal: DimensionMarshal = appContext.marshal; diff --git a/stories/src/primatives/quote-list.jsx b/stories/src/primatives/quote-list.jsx index 6dc7e8cf8d..6da2c06549 100644 --- a/stories/src/primatives/quote-list.jsx +++ b/stories/src/primatives/quote-list.jsx @@ -86,7 +86,7 @@ const InnerQuoteList = React.memo(function InnerQuoteList( props: QuoteListProps, ) { return props.quotes.map((quote: Quote, index: number) => ( - + {( dragProvided: DraggableProvided, dragSnapshot: DraggableStateSnapshot, @@ -145,6 +145,13 @@ export default function QuoteList(props: Props) { ignoreContainerClipping={ignoreContainerClipping} isDropDisabled={isDropDisabled} isCombineEnabled={isCombineEnabled} + whenDraggingClone={(provided, snapshot, source) => ( + + )} > {( dropProvided: DroppableProvided, diff --git a/stories/src/vertical/quote-app.jsx b/stories/src/vertical/quote-app.jsx index 96234855ff..913610853e 100644 --- a/stories/src/vertical/quote-app.jsx +++ b/stories/src/vertical/quote-app.jsx @@ -65,8 +65,8 @@ export default class QuoteApp extends Component { const quotes = reorder( this.state.quotes, - result.source.index - 1, - result.destination.index - 1, + result.source.index, + result.destination.index, ); this.setState({ From a7a88c76c9efdbfb2c6fc994a7112788e3ac2e70 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Wed, 15 May 2019 14:06:34 +1000 Subject: [PATCH 042/308] wip --- src/view/draggable/draggable.jsx | 25 ++++++++----- src/view/droppable/connected-droppable.js | 37 +++++++++++++------ src/view/droppable/droppable-types.js | 20 ++++++---- src/view/droppable/droppable.jsx | 21 ++++------- .../use-draggable-dimension-publisher.js | 8 +--- 5 files changed, 61 insertions(+), 50 deletions(-) diff --git a/src/view/draggable/draggable.jsx b/src/view/draggable/draggable.jsx index ae733e7e97..179f4f0397 100644 --- a/src/view/draggable/draggable.jsx +++ b/src/view/draggable/draggable.jsx @@ -71,17 +71,21 @@ export default function Draggable(props: Props) { } = props; const isEnabled: boolean = !isDragDisabled; + // TODO: is this the right approach? // The dimension publisher: talks to the marshal - const forPublisher: DimensionPublisherArgs = useMemo( - () => ({ - draggableId, - index, - getDraggableRef: getRef, - isClone, - }), - [draggableId, getRef, index, isClone], - ); - useDraggableDimensionPublisher(forPublisher); + // We are violating the rules of hooks here: conditional hooks. + // In this specific use case it is okay as an item will always either be a clone or not for it's whole lifecycle + if (!isClone) { + const forPublisher: DimensionPublisherArgs = useMemo( + () => ({ + draggableId, + index, + getDraggableRef: getRef, + }), + [draggableId, getRef, index], + ); + useDraggableDimensionPublisher(forPublisher); + } // The Drag handle @@ -196,6 +200,7 @@ export default function Draggable(props: Props) { draggableProps: { 'data-rbd-draggable-context-id': appContext.contextId, 'data-rbd-draggable-id': draggableId, + // TODO: create helper 'data-rbd-draggable-options': JSON.stringify({ canDragInteractiveElements, shouldRespectForcePress, diff --git a/src/view/droppable/connected-droppable.js b/src/view/droppable/connected-droppable.js index 6fd0b6761e..7eed0adc37 100644 --- a/src/view/droppable/connected-droppable.js +++ b/src/view/droppable/connected-droppable.js @@ -21,7 +21,8 @@ import type { Selector, DispatchProps, StateSnapshot, - DraggingFromThisWith, + UseClone, + RenderClone, } from './droppable-types'; import Droppable from './droppable'; import isStrictEqual from '../is-strict-equal'; @@ -29,6 +30,7 @@ import whatIsDraggedOver from '../../state/droppable/what-is-dragged-over'; import { updateViewportMaxScroll as updateViewportMaxScrollAction } from '../../state/action-creators'; import StoreContext from '../context/store-context'; import whatIsDraggedOverFromResult from '../../state/droppable/what-is-dragged-over-from-result'; +import getHomeLocation from '../../state/get-home-location'; const isMatchingType = (type: TypeId, critical: Critical): boolean => type === critical.droppable.type; @@ -49,6 +51,7 @@ export const makeMapStateToProps = (): Selector => { draggingOverWith: null, draggingFromThisWith: null, }, + useClone: null, }; const idleWithoutAnimation = { @@ -62,14 +65,24 @@ export const makeMapStateToProps = (): Selector => { isDraggingOver: boolean, dragging: DraggableDimension, snapshot: StateSnapshot, + whenDraggingClone: ?RenderClone, ): MapProps => { const isHome: boolean = dragging.descriptor.droppableId === id; if (isHome) { + const useClone: ?UseClone = whenDraggingClone + ? { + render: whenDraggingClone, + draggableId: dragging.descriptor.id, + source: getHomeLocation(dragging.descriptor), + } + : null; + return { placeholder: dragging.placeholder, shouldAnimatePlaceholder: false, snapshot, + useClone, }; } @@ -83,6 +96,7 @@ export const makeMapStateToProps = (): Selector => { // Animating placeholder in foreign list shouldAnimatePlaceholder: true, snapshot, + useClone: null, }; }, ); @@ -98,16 +112,7 @@ export const makeMapStateToProps = (): Selector => { const draggingOverWith: ?DraggableId = isDraggingOver ? draggableId : null; - const draggingFromThisWith: ?DraggingFromThisWith = isHome - ? { - id: draggableId, - source: { index: dragging.descriptor.index, droppableId: id }, - } - : null; - console.log( - 'draggingFromThisWith', - draggingFromThisWith ? draggingFromThisWith.source : null, - ); + const draggingFromThisWith: ?DraggableId = isHome ? draggableId : null; return { isDraggingOver, @@ -122,6 +127,7 @@ export const makeMapStateToProps = (): Selector => { const id: DroppableId = ownProps.droppableId; const type: TypeId = ownProps.type; + const whenDraggingClone: ?RenderClone = ownProps.whenDraggingClone; if (state.isDragging) { const critical: Critical = state.critical; @@ -137,7 +143,13 @@ export const makeMapStateToProps = (): Selector => { // Snapshot based on current impact const snapshot: StateSnapshot = getSnapshot(id, isDraggingOver, dragging); - return getMapProps(id, isDraggingOver, dragging, snapshot); + return getMapProps( + id, + isDraggingOver, + dragging, + snapshot, + whenDraggingClone, + ); } if (state.phase === 'DROP_ANIMATING') { @@ -165,6 +177,7 @@ export const makeMapStateToProps = (): Selector => { whatIsDraggedOver(completed.impact) === id, dragging, snapshot, + whenDraggingClone, ); } diff --git a/src/view/droppable/droppable-types.js b/src/view/droppable/droppable-types.js index c231920a85..2dc79c71c4 100644 --- a/src/view/droppable/droppable-types.js +++ b/src/view/droppable/droppable-types.js @@ -27,9 +27,16 @@ export type Provided = {| droppableProps: DroppableProps, |}; -export type DraggingFromThisWith = {| - id: DraggableId, +export type RenderClone = ( + provided: DraggableProvided, + snapshot: DraggableStateSnapshot, source: DraggableLocation, +) => Node; + +export type UseClone = {| + draggableId: DraggableId, + source: DraggableLocation, + render: RenderClone, |}; export type StateSnapshot = {| @@ -39,7 +46,7 @@ export type StateSnapshot = {| draggingOverWith: ?DraggableId, // What is the id of the draggable that is dragging from this list? // Useful for styling the home list when not being dragged over - draggingFromThisWith: ?DraggingFromThisWith, + draggingFromThisWith: ?DraggableId, |}; export type MapProps = {| @@ -50,6 +57,7 @@ export type MapProps = {| shouldAnimatePlaceholder: boolean, // snapshot based on redux state to be provided to consumers snapshot: StateSnapshot, + useClone: ?UseClone, |}; export type DefaultProps = {| @@ -70,11 +78,7 @@ export type OwnProps = {| children: (Provided, StateSnapshot) => Node, droppableId: DroppableId, // TODO: hoist these types up? - whenDraggingClone?: ( - provided: DraggableProvided, - snapshot: DraggableStateSnapshot, - source: DraggableLocation, - ) => Node, + whenDraggingClone?: RenderClone, |}; export type Props = {| diff --git a/src/view/droppable/droppable.jsx b/src/view/droppable/droppable.jsx index 7cfb5a81ac..54bd75e69f 100644 --- a/src/view/droppable/droppable.jsx +++ b/src/view/droppable/droppable.jsx @@ -3,7 +3,7 @@ import invariant from 'tiny-invariant'; import ReactDOM from 'react-dom'; import { useMemo, useCallback } from 'use-memo-one'; import React, { useRef, useContext, type Node } from 'react'; -import type { Props, Provided, DraggingFromThisWith } from './droppable-types'; +import type { Props, Provided, UseClone } from './droppable-types'; import useDroppableDimensionPublisher from '../use-droppable-dimension-publisher'; import Placeholder from '../placeholder'; import AppContext, { type AppContextValue } from '../context/app-context'; @@ -42,12 +42,13 @@ export default function Droppable(props: Props) { isCombineEnabled, // map props snapshot, + useClone, // dispatch props updateViewportMaxScroll, // clone (ownProps) whenDraggingClone, - getPortalForClone, + getContainerForClone, } = props; const getDroppableRef = useCallback( @@ -130,27 +131,21 @@ export default function Droppable(props: Props) { }); function getClone(): ?Node { - if (!whenDraggingClone) { + if (!useClone) { return null; } - const draggingFromThisWith: ?DraggingFromThisWith = - snapshot.draggingFromThisWith; - if (!draggingFromThisWith) { - return null; - } - const { id, source } = draggingFromThisWith; - console.log('source', source); + const { draggableId, source, render } = useClone; const item: Node = ( - + {( draggableProvided: DraggableProvided, draggableSnapshot: DraggableStateSnapshot, - ) => whenDraggingClone(draggableProvided, draggableSnapshot, source)} + ) => render(draggableProvided, draggableSnapshot, source)} ); - return ReactDOM.createPortal(item, props.getContainerForClone()); + return ReactDOM.createPortal(item, getContainerForClone()); } return ( diff --git a/src/view/use-draggable-dimension-publisher/use-draggable-dimension-publisher.js b/src/view/use-draggable-dimension-publisher/use-draggable-dimension-publisher.js index f30fd8e258..0eb76fb49c 100644 --- a/src/view/use-draggable-dimension-publisher/use-draggable-dimension-publisher.js +++ b/src/view/use-draggable-dimension-publisher/use-draggable-dimension-publisher.js @@ -20,17 +20,11 @@ import useLayoutEffect from '../use-isomorphic-layout-effect'; export type Args = {| draggableId: DraggableId, index: number, - isClone: boolean, getDraggableRef: () => ?HTMLElement, |}; export default function useDraggableDimensionPublisher(args: Args) { - const { isClone, draggableId, index, getDraggableRef } = args; - - // TODO: breaks the rules of hooks - if (isClone) { - return; - } + const { draggableId, index, getDraggableRef } = args; // App context const appContext: AppContextValue = useRequiredContext(AppContext); From 4334c17f764a48fd919d45cc1501919abb941d6a Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Wed, 15 May 2019 15:13:12 +1000 Subject: [PATCH 043/308] what have I done? --- package.json | 1 + stories/45-virtual.stories.js | 9 +++ stories/src/primatives/quote-item.jsx | 16 +++- stories/src/virtual/react-window.jsx | 102 ++++++++++++++++++++++++++ yarn.lock | 10 ++- 5 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 stories/45-virtual.stories.js create mode 100644 stories/src/virtual/react-window.jsx diff --git a/package.json b/package.json index 61ed67dc43..9b01bbc59f 100644 --- a/package.json +++ b/package.json @@ -114,6 +114,7 @@ "react-dom": "^16.8.6", "react-test-renderer": "^16.8.6", "react-testing-library": "^7.0.0", + "react-window": "^1.8.1", "rimraf": "^2.6.3", "rollup": "^1.11.3", "rollup-plugin-babel": "^4.3.2", diff --git a/stories/45-virtual.stories.js b/stories/45-virtual.stories.js new file mode 100644 index 0000000000..9c8a934ccd --- /dev/null +++ b/stories/45-virtual.stories.js @@ -0,0 +1,9 @@ +// @flow +import React from 'react'; +import { storiesOf } from '@storybook/react'; +import ReactWindow from './src/virtual/react-window'; +import { getQuotes } from './src/data'; + +storiesOf('Virtual lists', module).add('with react-window', () => ( + +)); diff --git a/stories/src/primatives/quote-item.jsx b/stories/src/primatives/quote-item.jsx index 79ed551ae3..0d3502ba9a 100644 --- a/stories/src/primatives/quote-item.jsx +++ b/stories/src/primatives/quote-item.jsx @@ -11,6 +11,7 @@ type Props = { isDragging: boolean, provided: DraggableProvided, isGroupedOver?: boolean, + style?: Object, }; const getBackgroundColor = ( @@ -40,6 +41,7 @@ const Container = styled.a` getBackgroundColor(props.isDragging, props.isGroupedOver, props.colors)}; box-shadow: ${({ isDragging }) => isDragging ? `2px 2px 1px ${colors.N70}` : 'none'}; + box-sizing: border-box; padding: ${grid}px; min-height: 40px; margin-bottom: ${grid}px; @@ -122,6 +124,17 @@ const QuoteId = styled.small` text-align: right; `; +function getStyle(provided: DraggableProvided, style: ?Object) { + if (!style) { + return provided.draggableProps.style; + } + + return { + ...provided.draggableProps.style, + ...style, + }; +} + // Previously this extended React.Component // That was a good thing, because using React.PureComponent can hide // issues with the selectors. However, moving it over does can considerable @@ -130,7 +143,7 @@ const QuoteId = styled.small` // things we should be doing in the selector as we do not know if consumers // will be using PureComponent function QuoteItem(props: Props) { - const { quote, isDragging, isGroupedOver, provided } = props; + const { quote, isDragging, isGroupedOver, provided, style } = props; return ( diff --git a/stories/src/virtual/react-window.jsx b/stories/src/virtual/react-window.jsx new file mode 100644 index 0000000000..4a44add71e --- /dev/null +++ b/stories/src/virtual/react-window.jsx @@ -0,0 +1,102 @@ +// @flow +import React, { useState, useEffect } from 'react'; +import { FixedSizeList as List, areEqual } from 'react-window'; +import type { Quote } from '../types'; +import { + Droppable, + Draggable, + DragDropContext, + type DroppableProvided, + type DroppableStateSnapshot, + type DraggableProvided, + type DraggableStateSnapshot, + type DraggableLocation, +} from '../../../src'; +import QuoteItem from '../primatives/quote-item'; + +type Props = {| + initial: Quote[], +|}; + +type ItemProps = {| + provided: DraggableProvided, + quote: Quote, + style?: Object, +|}; + +function Item(props: ItemProps) { + const { quote, provided, style } = props; + + useEffect(() => { + console.log('quote mounted', quote.id); + return () => console.log('quote unmounted', quote.id); + }, [quote.id]); + return ( +
+ {quote.id} +
+ ); +} + +const Row = React.memo(({ data: quotes, index, style }) => { + const quote: Quote = quotes[index]; + + return ( + + {(provided: DraggableProvided, snapshot: DraggableStateSnapshot) => ( + + )} + + ); +}, areEqual); + +function App(props: Props) { + const quotes: Quote[] = useState(() => props.initial)[0]; + + return ( + {}}> + ( + + )} + > + {(droppableProvided: DroppableProvided) => ( + + {Row} + + )} + + + ); +} + +export default App; diff --git a/yarn.lock b/yarn.lock index 3361a9dfea..5900ca208e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8576,7 +8576,7 @@ mem@^4.0.0: mimic-fn "^1.0.0" p-is-promise "^2.0.0" -memoize-one@^5.0.4: +"memoize-one@>=3.1.1 <6", memoize-one@^5.0.4: version "5.0.4" resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.0.4.tgz#005928aced5c43d890a4dfab18ca908b0ec92cbc" integrity sha512-P0z5IeAH6qHHGkJIXWw0xC2HNEgkx/9uWWBQw64FJj3/ol14VYdfVGWWr0fXfjhhv3TKVIqUq65os6O4GUNksA== @@ -10433,6 +10433,14 @@ react-textarea-autosize@^7.0.4: "@babel/runtime" "^7.1.2" prop-types "^15.6.0" +react-window@^1.8.1: + version "1.8.1" + resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.1.tgz#12f1db71d91552ed2263790e57cc77d950803db1" + integrity sha512-iNzekymggL9zAnil3QbmRG74RDMfIbO+plE/soP3M/zskicA1DwoLthC6/QA6xu9dr+A5UoawCTsEYcva2mfeA== + dependencies: + "@babel/runtime" "^7.0.0" + memoize-one ">=3.1.1 <6" + react@^16.8.1: version "16.8.4" resolved "https://registry.yarnpkg.com/react/-/react-16.8.4.tgz#fdf7bd9ae53f03a9c4cd1a371432c206be1c4768" From 0c25cefcfb3816f19be2f465a4cc22450a3bafd5 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Thu, 16 May 2019 08:48:17 +1000 Subject: [PATCH 044/308] wip --- src/state/create-store.js | 2 +- .../dimension-marshal/dimension-marshal.js | 8 +- .../index.js | 111 ++++++++++++++++++ src/state/reducer.js | 3 +- stories/45-virtual.stories.js | 2 +- stories/src/virtual/react-window.jsx | 22 +++- 6 files changed, 139 insertions(+), 9 deletions(-) create mode 100644 src/state/publish-while-dragging-in-virtual/index.js diff --git a/src/state/create-store.js b/src/state/create-store.js index a8f6fd6dad..cdcdf8d867 100644 --- a/src/state/create-store.js +++ b/src/state/create-store.js @@ -54,7 +54,7 @@ export default ({ // // user timing api // require('../debug/middleware/user-timing').default, // debugging timer - // require('../debug/middleware/action-timing').default, + require('../debug/middleware/action-timing').default, // average action timer // require('../debug/middleware/action-timing-average').default(200), diff --git a/src/state/dimension-marshal/dimension-marshal.js b/src/state/dimension-marshal/dimension-marshal.js index d46bdfcfc6..0ec56a1940 100644 --- a/src/state/dimension-marshal/dimension-marshal.js +++ b/src/state/dimension-marshal/dimension-marshal.js @@ -158,10 +158,10 @@ export default (callbacks: Callbacks) => { return; } - invariant( - collection.critical.draggable.id !== descriptor.id, - 'Cannot remove the dragging item during a drag', - ); + // invariant( + // collection.critical.draggable.id !== descriptor.id, + // 'Cannot remove the dragging item during a drag', + // ); throwIfAddOrRemoveOfWrongType(collection, descriptor); diff --git a/src/state/publish-while-dragging-in-virtual/index.js b/src/state/publish-while-dragging-in-virtual/index.js new file mode 100644 index 0000000000..57ade20743 --- /dev/null +++ b/src/state/publish-while-dragging-in-virtual/index.js @@ -0,0 +1,111 @@ +// @flow +import invariant from 'tiny-invariant'; +import type { + DimensionMap, + DraggingState, + CollectingState, + DropPendingState, + Published, + Critical, + DraggableId, + DraggableDimension, + DraggableDimensionMap, + DroppableDimensionMap, + DragImpact, +} from '../../types'; +import * as timings from '../../debug/timings'; +import getDragImpact from '../get-drag-impact'; +import adjustAdditionsForScrollChanges from '../publish-while-dragging/update-draggables/adjust-additions-for-scroll-changes'; +import { toDraggableMap, toDraggableList } from '../dimension-structures'; + +type Args = {| + state: CollectingState | DropPendingState, + published: Published, +|}; + +const timingsKey: string = 'Processing dynamic changes'; + +export default ({ + state, + published, +}: Args): DraggingState | DropPendingState => { + timings.start(timingsKey); + + // Rules: + + // - do not remove old dimensions (WE SHOULD! to speed up list lookups) + // - do not add a new dimension if we already have it + // - shift any added dimension to account for change scroll + + const updated: DraggableDimension[] = adjustAdditionsForScrollChanges({ + additions: published.additions, + updatedDroppables: state.dimensions.droppables, + viewport: state.viewport, + }); + + const draggables: DraggableDimensionMap = { + ...state.dimensions.draggables, + ...toDraggableMap(updated), + }; + + // remove all the old ones (except for the critical) + // we do this so that list operations remain fast + // TODO: need to test the impact of this like crazy + published.removals.forEach((id: DraggableId) => { + if (id !== state.critical.draggable.id) { + delete draggables[id]; + } + }); + + const dimensions: DimensionMap = { + droppables: state.dimensions.droppables, + draggables, + }; + + const impact: DragImpact = getDragImpact({ + pageBorderBoxCenter: state.current.page.borderBoxCenter, + draggable: dimensions.draggables[state.critical.draggable.id], + draggables: dimensions.draggables, + droppables: dimensions.droppables, + // starting from a fresh slate + previousImpact: state.impact, + viewport: state.viewport, + userDirection: state.userDirection, + onLift: state.onLift, + }); + + timings.finish(timingsKey); + + const draggingState: DraggingState = { + // appeasing flow + phase: 'DRAGGING', + ...state, + // eslint-disable-next-line + phase: 'DRAGGING', + impact, + dimensions, + // not animating this movement + forceShouldAnimate: false, + }; + + if (state.phase === 'COLLECTING') { + return draggingState; + } + + // There was a DROP_PENDING + // Staying in the DROP_PENDING phase + // setting isWaiting for false + + const dropPending: DropPendingState = { + // appeasing flow + phase: 'DROP_PENDING', + ...draggingState, + // eslint-disable-next-line + phase: 'DROP_PENDING', + // No longer waiting + reason: state.reason, + isWaiting: false, + }; + + return dropPending; +}; diff --git a/src/state/reducer.js b/src/state/reducer.js index 1c21f7416c..9584eab9c5 100644 --- a/src/state/reducer.js +++ b/src/state/reducer.js @@ -31,6 +31,7 @@ import update from './post-reducer/when-moving/update'; import refreshSnap from './post-reducer/when-moving/refresh-snap'; import getHomeOnLift from './get-home-on-lift'; import patchDimensionMap from './patch-dimension-map'; +import publishWhileDraggingInVirtual from './publish-while-dragging-in-virtual'; const isSnapping = (state: StateWhenUpdatesAllowed): boolean => state.movementMode === 'SNAP'; @@ -161,7 +162,7 @@ export default (state: State = idle, action: Action): State => { `Unexpected ${action.type} received in phase ${state.phase}`, ); - return publishWhileDragging({ + return publishWhileDraggingInVirtual({ state, published: action.payload, }); diff --git a/stories/45-virtual.stories.js b/stories/45-virtual.stories.js index 9c8a934ccd..6e760e00b8 100644 --- a/stories/45-virtual.stories.js +++ b/stories/45-virtual.stories.js @@ -5,5 +5,5 @@ import ReactWindow from './src/virtual/react-window'; import { getQuotes } from './src/data'; storiesOf('Virtual lists', module).add('with react-window', () => ( - + )); diff --git a/stories/src/virtual/react-window.jsx b/stories/src/virtual/react-window.jsx index 4a44add71e..fa2b553a56 100644 --- a/stories/src/virtual/react-window.jsx +++ b/stories/src/virtual/react-window.jsx @@ -11,8 +11,10 @@ import { type DraggableProvided, type DraggableStateSnapshot, type DraggableLocation, + type DropResult, } from '../../../src'; import QuoteItem from '../primatives/quote-item'; +import reorder from '../reorder'; type Props = {| initial: Quote[], @@ -64,10 +66,26 @@ const Row = React.memo(({ data: quotes, index, style }) => { }, areEqual); function App(props: Props) { - const quotes: Quote[] = useState(() => props.initial)[0]; + const [quotes, setQuotes] = useState(() => props.initial); + + function onDragEnd(result: DropResult) { + if (!result.destination) { + return; + } + if (result.source.index === result.destination.index) { + return; + } + + const newQuotes: Quote[] = reorder( + quotes, + result.source.index, + result.destination.index, + ); + setQuotes(newQuotes); + } return ( - {}}> + Date: Thu, 16 May 2019 10:46:48 +1000 Subject: [PATCH 045/308] focus marshal wip --- src/state/create-store.js | 2 +- src/state/focus-marshal/index.js | 4 ++++ src/view/context/focus-context.js | 15 +++++++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 src/state/focus-marshal/index.js create mode 100644 src/view/context/focus-context.js diff --git a/src/state/create-store.js b/src/state/create-store.js index cdcdf8d867..a8f6fd6dad 100644 --- a/src/state/create-store.js +++ b/src/state/create-store.js @@ -54,7 +54,7 @@ export default ({ // // user timing api // require('../debug/middleware/user-timing').default, // debugging timer - require('../debug/middleware/action-timing').default, + // require('../debug/middleware/action-timing').default, // average action timer // require('../debug/middleware/action-timing-average').default(200), diff --git a/src/state/focus-marshal/index.js b/src/state/focus-marshal/index.js new file mode 100644 index 0000000000..577cc81322 --- /dev/null +++ b/src/state/focus-marshal/index.js @@ -0,0 +1,4 @@ +// @flow + +// needs to focus on: +// drag handle after a diff --git a/src/view/context/focus-context.js b/src/view/context/focus-context.js new file mode 100644 index 0000000000..0d4392a439 --- /dev/null +++ b/src/view/context/focus-context.js @@ -0,0 +1,15 @@ +// @flow +import React from 'react'; +import type { DraggableId } from '../../types'; + +type FocusAPI = {| + unregister: () => void, + onFocus: () => void, + onBlur: () => void, +|}; + +export type FocusContextValue = {| + register: (id: DraggableId, focus: () => void) => FocusAPI, +|}; + +export default React.createContext(null); From a7e1c16bd54e0e5a52fc985c1b7123520042c5df Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Thu, 16 May 2019 12:14:36 +1000 Subject: [PATCH 046/308] new focus marshal --- src/state/create-store.js | 5 + src/state/focus-marshal/index.js | 118 +++++++++++++++++- src/state/middleware/focus.js | 47 +++++++ src/view/context/app-context.js | 2 + src/view/context/focus-context.js | 15 --- src/view/data-attributes.js | 1 + src/view/drag-drop-context/app.jsx | 27 +++- src/view/use-drag-handle/drag-handle-types.js | 19 +-- src/view/use-drag-handle/use-drag-handle.js | 34 +++-- .../sensors/use-keyboard-sensor.js | 2 + 10 files changed, 234 insertions(+), 36 deletions(-) create mode 100644 src/state/middleware/focus.js delete mode 100644 src/view/context/focus-context.js diff --git a/src/state/create-store.js b/src/state/create-store.js index a8f6fd6dad..e4c8e80994 100644 --- a/src/state/create-store.js +++ b/src/state/create-store.js @@ -9,9 +9,11 @@ import scrollListener from './middleware/scroll-listener'; import responders from './middleware/responders/responders-middleware'; import dropAnimationFinish from './middleware/drop-animation-finish'; import dimensionMarshalStopper from './middleware/dimension-marshal-stopper'; +import focus from './middleware/focus'; import autoScroll from './middleware/auto-scroll'; import pendingDrop from './middleware/pending-drop'; import type { DimensionMarshal } from './dimension-marshal/dimension-marshal-types'; +import type { FocusMarshal } from './focus-marshal'; import type { StyleMarshal } from '../view/use-style-marshal/style-marshal-types'; import type { AutoScroller } from './auto-scroller/auto-scroller-types'; import type { Responders, Announce } from '../types'; @@ -29,6 +31,7 @@ const composeEnhancers = type Args = {| dimensionMarshal: DimensionMarshal, + focusMarshal: FocusMarshal, styleMarshal: StyleMarshal, getResponders: () => Responders, announce: Announce, @@ -37,6 +40,7 @@ type Args = {| export default ({ dimensionMarshal, + focusMarshal, styleMarshal, getResponders, announce, @@ -81,6 +85,7 @@ export default ({ pendingDrop, autoScroll(autoScroller), scrollListener, + focus(focusMarshal), // Fire responders for consumers (after update to store) responders(getResponders, announce), ), diff --git a/src/state/focus-marshal/index.js b/src/state/focus-marshal/index.js index 577cc81322..253af7f910 100644 --- a/src/state/focus-marshal/index.js +++ b/src/state/focus-marshal/index.js @@ -1,4 +1,118 @@ // @flow +import type { DraggableId, ContextId } from '../../types'; +// TODO: move out of state? +import { dragHandle as dragHandleAttr } from '../../view/data-attributes'; +import { warning } from '../../dev-warning'; -// needs to focus on: -// drag handle after a +type Unregister = () => void; + +export type Register = (id: DraggableId, focus: () => void) => Unregister; + +export type FocusMarshal = {| + register: Register, + tryRecordFocus: (tryRecordFor: DraggableId) => void, + tryRestoreFocusRecorded: () => void, + tryGiveFocus: (tryGiveFocusTo: DraggableId) => void, +|}; + +type Entry = {| + id: DraggableId, + focus: () => void, +|}; + +type EntryMap = { + [id: DraggableId]: Entry, +}; + +function getDragHandle( + contextId: ContextId, + draggableId: DraggableId, +): ?HTMLElement { + // find the drag handle + const selector: string = `[${dragHandleAttr.contextId}="${contextId}"]`; + const possible: Element[] = Array.from(document.querySelectorAll(selector)); + + if (!possible.length) { + warning(`Unable to find any drag handles in the context "${contextId}"`); + return null; + } + + const handle: ?Element = possible.find( + (el: Element): boolean => { + return el.getAttribute(dragHandleAttr.draggableId) === draggableId; + }, + ); + + if (!handle) { + warning( + `Unable to find drag handle with id "${draggableId}" as no handle with a matching id was found`, + ); + return null; + } + + if (!(handle instanceof HTMLElement)) { + warning(`Drag handle is not a HTMLElement`); + return null; + } + + return handle; +} + +export default function create(contextId: ContextId): FocusMarshal { + const entries: EntryMap = {}; + let hadFocus: ?DraggableId = null; + + function register(id: DraggableId, focus: () => void): Unregister { + const entry: Entry = { id, focus }; + entries[id] = entry; + + return function unregister() { + const current: Entry = entries[id]; + // entry might have been overrided by another registration + if (current !== entry) { + delete entries[id]; + } + }; + } + + function tryGiveFocus(tryGiveFocusTo: DraggableId) { + const handle: ?HTMLElement = getDragHandle(contextId, tryGiveFocusTo); + + if (handle && handle !== document.activeElement) { + console.log('giving focus to ', tryGiveFocusTo); + handle.focus(); + } + } + + function tryRestoreFocusRecorded() { + if (!hadFocus) { + return; + } + + console.log('tryRestoreFocusRecorded'); + tryGiveFocus(hadFocus); + } + + function tryRecordFocus(id: DraggableId) { + const focused: ?Element = document.activeElement; + + // no item focused so it cannot be our item + if (!focused) { + return; + } + + // focused element is not a drag handle or does not have the right id + if (focused.getAttribute(dragHandleAttr.draggableId) !== id) { + return; + } + + hadFocus = id; + } + + return { + register, + tryRecordFocus, + tryRestoreFocusRecorded, + tryGiveFocus, + }; +} diff --git a/src/state/middleware/focus.js b/src/state/middleware/focus.js new file mode 100644 index 0000000000..6fcbf59128 --- /dev/null +++ b/src/state/middleware/focus.js @@ -0,0 +1,47 @@ +// @flow +import type { Action, Dispatch } from '../store-types'; +import type { FocusMarshal } from '../focus-marshal'; + +export default (marshal: FocusMarshal) => { + let isWatching: boolean = false; + let focusTimeoutId: ?TimeoutID = null; + + function focusOnTimeout() { + if (focusTimeoutId) { + return; + } + focusTimeoutId = setTimeout(() => marshal.tryRestoreFocusRecorded()); + } + + function abortFocusTimeout() { + if (!focusTimeoutId) { + return; + } + clearTimeout(focusTimeoutId); + focusTimeoutId = null; + } + + return () => (next: Dispatch) => (action: Action): any => { + if (action.type === 'INITIAL_PUBLISH') { + isWatching = true; + abortFocusTimeout(); + + marshal.tryRecordFocus(action.payload.critical.draggable.id); + next(action); + marshal.tryRestoreFocusRecorded(); + return; + } + + next(action); + + if (!isWatching) { + return; + } + + // on end - focus after timeout + if (action.type === 'DROP_COMPLETE' || action.type === 'CLEAN') { + isWatching = false; + focusOnTimeout(); + } + }; +}; diff --git a/src/view/context/app-context.js b/src/view/context/app-context.js index cba447d97d..9fc8738818 100644 --- a/src/view/context/app-context.js +++ b/src/view/context/app-context.js @@ -2,9 +2,11 @@ import React from 'react'; import type { DraggableId, ContextId } from '../../types'; import type { DimensionMarshal } from '../../state/dimension-marshal/dimension-marshal-types'; +import type { FocusMarshal } from '../../state/focus-marshal'; export type AppContextValue = {| marshal: DimensionMarshal, + focus: FocusMarshal, contextId: ContextId, canLift: (id: DraggableId) => boolean, isMovementAllowed: () => boolean, diff --git a/src/view/context/focus-context.js b/src/view/context/focus-context.js deleted file mode 100644 index 0d4392a439..0000000000 --- a/src/view/context/focus-context.js +++ /dev/null @@ -1,15 +0,0 @@ -// @flow -import React from 'react'; -import type { DraggableId } from '../../types'; - -type FocusAPI = {| - unregister: () => void, - onFocus: () => void, - onBlur: () => void, -|}; - -export type FocusContextValue = {| - register: (id: DraggableId, focus: () => void) => FocusAPI, -|}; - -export default React.createContext(null); diff --git a/src/view/data-attributes.js b/src/view/data-attributes.js index 59c4d97760..f492b1b825 100644 --- a/src/view/data-attributes.js +++ b/src/view/data-attributes.js @@ -5,6 +5,7 @@ export const dragHandle = (() => { return { base, + draggableId: `${base}-draggable-id`, contextId: `${base}-context-id`, }; })(); diff --git a/src/view/drag-drop-context/app.jsx b/src/view/drag-drop-context/app.jsx index d8e19c9fe9..4d11121929 100644 --- a/src/view/drag-drop-context/app.jsx +++ b/src/view/drag-drop-context/app.jsx @@ -10,6 +10,9 @@ import canStartDrag from '../../state/can-start-drag'; import scrollWindow from '../window/scroll-window'; import createAutoScroller from '../../state/auto-scroller'; import useStyleMarshal from '../use-style-marshal/use-style-marshal'; +import createFocusMarshal, { + type FocusMarshal, +} from '../../state/focus-marshal'; import type { AutoScroller } from '../../state/auto-scroller/auto-scroller-types'; import type { StyleMarshal } from '../use-style-marshal/style-marshal-types'; import type { @@ -123,16 +126,29 @@ export default function App(props: Props) { [dimensionMarshal.scrollDroppable, lazyDispatch], ); + const focusMarshal: FocusMarshal = useMemo( + () => createFocusMarshal(contextId), + [contextId], + ); + const store: Store = useMemo( () => createStore({ dimensionMarshal, + focusMarshal, styleMarshal, announce, autoScroller, getResponders, }), - [announce, autoScroller, dimensionMarshal, getResponders, styleMarshal], + [ + announce, + autoScroller, + dimensionMarshal, + focusMarshal, + getResponders, + styleMarshal, + ], ); // Checking for unexpected store changes @@ -170,11 +186,18 @@ export default function App(props: Props) { const appContext: AppContextValue = useMemo( () => ({ marshal: dimensionMarshal, + focus: focusMarshal, contextId, canLift: getCanLift, isMovementAllowed: getIsMovementAllowed, }), - [contextId, dimensionMarshal, getCanLift, getIsMovementAllowed], + [ + contextId, + dimensionMarshal, + focusMarshal, + getCanLift, + getIsMovementAllowed, + ], ); useSensorMarshal({ diff --git a/src/view/use-drag-handle/drag-handle-types.js b/src/view/use-drag-handle/drag-handle-types.js index 3ac0bf271b..6c62482d40 100644 --- a/src/view/use-drag-handle/drag-handle-types.js +++ b/src/view/use-drag-handle/drag-handle-types.js @@ -1,6 +1,6 @@ // @flow import { type Position } from 'css-box-model'; -import type { MovementMode, DraggableId } from '../../types'; +import type { MovementMode, DraggableId, ContextId } from '../../types'; export type Callbacks = {| onLift: ({ @@ -21,16 +21,19 @@ export type DragHandleProps = {| // If a consumer is using a portal then the item will lose focus // when moving to the portal. This breaks keyboard dragging. // To get around this we manually apply focus if needed when mounting - onFocus: () => void, - onBlur: () => void, + // onFocus: () => void, + // onBlur: () => void, - // Used to initiate dragging - onMouseDown: (event: MouseEvent) => void, - onKeyDown: (event: KeyboardEvent) => void, - onTouchStart: (event: TouchEvent) => void, + // // Used to initiate dragging + // onMouseDown: (event: MouseEvent) => void, + // onKeyDown: (event: KeyboardEvent) => void, + // onTouchStart: (event: TouchEvent) => void, + + // what draggable the handle belongs to + 'data-rbd-drag-handle-draggable-id': DraggableId, // What DragDropContext the drag handle is in - 'data-rbd-drag-handle-context-id': string, + 'data-rbd-drag-handle-context-id': ContextId, // Aria role (nicer screen reader text) 'aria-roledescription': string, diff --git a/src/view/use-drag-handle/use-drag-handle.js b/src/view/use-drag-handle/use-drag-handle.js index 8c9bf96786..7d6170a5df 100644 --- a/src/view/use-drag-handle/use-drag-handle.js +++ b/src/view/use-drag-handle/use-drag-handle.js @@ -16,8 +16,9 @@ import useTouchSensor, { import usePreviousRef from '../use-previous-ref'; import { warning } from '../../dev-warning'; import useValidation from './use-validation'; -import useFocusRetainer from './use-focus-retainer'; +// import useFocusRetainer from './use-focus-retainer'; import useLayoutEffect from '../use-isomorphic-layout-effect'; +import getDragHandleRef from './util/get-drag-handle-ref'; function preventHtml5Dnd(event: DragEvent) { event.preventDefault(); @@ -51,7 +52,7 @@ export default function useDragHandle(args: Args): ?DragHandleProps { capturingRef.current.abort(); }, []); - const { canLift, contextId }: AppContextValue = useRequiredContext( + const { canLift, contextId, focus }: AppContextValue = useRequiredContext( AppContext, ); const { @@ -96,7 +97,7 @@ export default function useDragHandle(args: Args): ?DragHandleProps { [canDragInteractiveElements, canLift, draggableId, isEnabled], ); - const { onBlur, onFocus } = useFocusRetainer(args); + // const { onBlur, onFocus } = useFocusRetainer(args); const keyboardArgs: KeyboardSensorArgs = useMemo( () => ({ @@ -169,6 +170,20 @@ export default function useDragHandle(args: Args): ?DragHandleProps { } } + const focusOnHandle = useCallback(() => { + const draggableRef: ?HTMLElement = getDraggableRef(); + if (!draggableRef) { + warning(`Cannot find draggable ref from drag handle`); + return; + } + getDragHandleRef(draggableRef).focus(); + }, [getDraggableRef]); + + useLayoutEffect(() => { + const unregister = focus.register(draggableId, focusOnHandle); + return unregister; + }, [draggableId, focus, focusOnHandle]); + // Handle aborting // No longer dragging but still capturing: need to abort // Using a layout effect to ensure that there is a flip from isDragging => !isDragging @@ -184,12 +199,13 @@ export default function useDragHandle(args: Args): ?DragHandleProps { return null; } return { - onMouseDown: () => {}, - onKeyDown: () => {}, - onTouchStart, - onFocus, - onBlur, + // onMouseDown: () => {}, + // onKeyDown: () => {}, + // onTouchStart, + // onFocus, + // onBlur, tabIndex: 0, + 'data-rbd-drag-handle-draggable-id': draggableId, 'data-rbd-drag-handle-context-id': contextId, // English default. Consumers are welcome to add their own start instruction 'aria-roledescription': 'Draggable item. Press space bar to lift', @@ -197,7 +213,7 @@ export default function useDragHandle(args: Args): ?DragHandleProps { draggable: false, onDragStart: preventHtml5Dnd, }; - }, [contextId, isEnabled, onBlur, onFocus, onTouchStart]); + }, [contextId, draggableId, isEnabled]); return props; } diff --git a/src/view/use-sensor-marshal/sensors/use-keyboard-sensor.js b/src/view/use-sensor-marshal/sensors/use-keyboard-sensor.js index 3aff1f8a13..55259aad8a 100644 --- a/src/view/use-sensor-marshal/sensors/use-keyboard-sensor.js +++ b/src/view/use-sensor-marshal/sensors/use-keyboard-sensor.js @@ -45,6 +45,8 @@ function getDraggingBindings( fn: (event: KeyboardEvent) => { if (event.keyCode === keyCodes.escape) { event.preventDefault(); + // Needed to stop focus loss :( + event.stopPropagation(); cancel(); return; } From a8856e1c081e427c7597b328bd7ab2a064306d46 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Thu, 16 May 2019 13:23:20 +1000 Subject: [PATCH 047/308] focus retention --- src/state/focus-marshal/index.js | 14 +++++++----- src/state/middleware/focus.js | 19 +-------------- stories/src/primatives/quote-item.jsx | 33 ++++++++++++++++++++++++++- stories/src/primatives/quote-list.jsx | 1 + 4 files changed, 42 insertions(+), 25 deletions(-) diff --git a/src/state/focus-marshal/index.js b/src/state/focus-marshal/index.js index 253af7f910..79fac50f4b 100644 --- a/src/state/focus-marshal/index.js +++ b/src/state/focus-marshal/index.js @@ -85,15 +85,17 @@ export default function create(contextId: ContextId): FocusMarshal { } function tryRestoreFocusRecorded() { - if (!hadFocus) { - return; - } - - console.log('tryRestoreFocusRecorded'); - tryGiveFocus(hadFocus); + requestAnimationFrame(() => { + if (hadFocus) { + tryGiveFocus(hadFocus); + } + }); } function tryRecordFocus(id: DraggableId) { + // clear any existing record + hadFocus = null; + const focused: ?Element = document.activeElement; // no item focused so it cannot be our item diff --git a/src/state/middleware/focus.js b/src/state/middleware/focus.js index 6fcbf59128..4d3491784c 100644 --- a/src/state/middleware/focus.js +++ b/src/state/middleware/focus.js @@ -4,27 +4,10 @@ import type { FocusMarshal } from '../focus-marshal'; export default (marshal: FocusMarshal) => { let isWatching: boolean = false; - let focusTimeoutId: ?TimeoutID = null; - - function focusOnTimeout() { - if (focusTimeoutId) { - return; - } - focusTimeoutId = setTimeout(() => marshal.tryRestoreFocusRecorded()); - } - - function abortFocusTimeout() { - if (!focusTimeoutId) { - return; - } - clearTimeout(focusTimeoutId); - focusTimeoutId = null; - } return () => (next: Dispatch) => (action: Action): any => { if (action.type === 'INITIAL_PUBLISH') { isWatching = true; - abortFocusTimeout(); marshal.tryRecordFocus(action.payload.critical.draggable.id); next(action); @@ -41,7 +24,7 @@ export default (marshal: FocusMarshal) => { // on end - focus after timeout if (action.type === 'DROP_COMPLETE' || action.type === 'CLEAN') { isWatching = false; - focusOnTimeout(); + marshal.tryRestoreFocusRecorded(); } }; }; diff --git a/stories/src/primatives/quote-item.jsx b/stories/src/primatives/quote-item.jsx index 0d3502ba9a..52642553cd 100644 --- a/stories/src/primatives/quote-item.jsx +++ b/stories/src/primatives/quote-item.jsx @@ -5,11 +5,13 @@ import { colors } from '@atlaskit/theme'; import { borderRadius, grid } from '../constants'; import type { Quote, AuthorColors } from '../types'; import type { DraggableProvided } from '../../../src'; +import useLayoutEffect from '../../../src/view/use-isomorphic-layout-effect'; type Props = { quote: Quote, isDragging: boolean, provided: DraggableProvided, + isClone?: boolean, isGroupedOver?: boolean, style?: Object, }; @@ -33,6 +35,27 @@ const getBackgroundColor = ( const getBorderColor = (isDragging: boolean, authorColors: AuthorColors) => isDragging ? authorColors.hard : 'transparent'; +function getCloneStyle(isClone: boolean): string { + if (!isClone) { + return ''; + } + + // will be over the top of the avatar + return ` + &::after { + position: absolute; + content: 'clone'; + background: lightgreen; + height: 40px; + width: 40px; + border-radius: 50%; + display: flex; + justify-content: center; + align-items: center; + } + `; +} + const Container = styled.a` border-radius: ${borderRadius}px; border: 2px solid transparent; @@ -50,6 +73,8 @@ const Container = styled.a` /* anchor overrides */ color: ${colors.N900}; + ${props => getCloneStyle(Boolean(props.isClone))} + &:hover, &:active { color: ${colors.N900}; @@ -143,13 +168,19 @@ function getStyle(provided: DraggableProvided, style: ?Object) { // things we should be doing in the selector as we do not know if consumers // will be using PureComponent function QuoteItem(props: Props) { - const { quote, isDragging, isGroupedOver, provided, style } = props; + const { quote, isDragging, isGroupedOver, provided, style, isClone } = props; + + useLayoutEffect(() => { + console.log('mounting', quote.id); + return () => console.log('unmounting', quote.id); + }, [quote.id]); return ( )} > From 9cb7e8f3ac78088226eca0a79d1da6a4204107a8 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Thu, 16 May 2019 13:35:45 +1000 Subject: [PATCH 048/308] use focus marshal --- src/state/focus-marshal/index.js | 120 --------------- src/view/context/app-context.js | 2 +- src/view/drag-drop-context/app.jsx | 10 +- .../use-focus-marshal/focus-marshal-types.js | 13 ++ src/view/use-focus-marshal/index.js | 2 + .../use-focus-marshal/use-focus-marshal.js | 141 ++++++++++++++++++ 6 files changed, 160 insertions(+), 128 deletions(-) delete mode 100644 src/state/focus-marshal/index.js create mode 100644 src/view/use-focus-marshal/focus-marshal-types.js create mode 100644 src/view/use-focus-marshal/index.js create mode 100644 src/view/use-focus-marshal/use-focus-marshal.js diff --git a/src/state/focus-marshal/index.js b/src/state/focus-marshal/index.js deleted file mode 100644 index 79fac50f4b..0000000000 --- a/src/state/focus-marshal/index.js +++ /dev/null @@ -1,120 +0,0 @@ -// @flow -import type { DraggableId, ContextId } from '../../types'; -// TODO: move out of state? -import { dragHandle as dragHandleAttr } from '../../view/data-attributes'; -import { warning } from '../../dev-warning'; - -type Unregister = () => void; - -export type Register = (id: DraggableId, focus: () => void) => Unregister; - -export type FocusMarshal = {| - register: Register, - tryRecordFocus: (tryRecordFor: DraggableId) => void, - tryRestoreFocusRecorded: () => void, - tryGiveFocus: (tryGiveFocusTo: DraggableId) => void, -|}; - -type Entry = {| - id: DraggableId, - focus: () => void, -|}; - -type EntryMap = { - [id: DraggableId]: Entry, -}; - -function getDragHandle( - contextId: ContextId, - draggableId: DraggableId, -): ?HTMLElement { - // find the drag handle - const selector: string = `[${dragHandleAttr.contextId}="${contextId}"]`; - const possible: Element[] = Array.from(document.querySelectorAll(selector)); - - if (!possible.length) { - warning(`Unable to find any drag handles in the context "${contextId}"`); - return null; - } - - const handle: ?Element = possible.find( - (el: Element): boolean => { - return el.getAttribute(dragHandleAttr.draggableId) === draggableId; - }, - ); - - if (!handle) { - warning( - `Unable to find drag handle with id "${draggableId}" as no handle with a matching id was found`, - ); - return null; - } - - if (!(handle instanceof HTMLElement)) { - warning(`Drag handle is not a HTMLElement`); - return null; - } - - return handle; -} - -export default function create(contextId: ContextId): FocusMarshal { - const entries: EntryMap = {}; - let hadFocus: ?DraggableId = null; - - function register(id: DraggableId, focus: () => void): Unregister { - const entry: Entry = { id, focus }; - entries[id] = entry; - - return function unregister() { - const current: Entry = entries[id]; - // entry might have been overrided by another registration - if (current !== entry) { - delete entries[id]; - } - }; - } - - function tryGiveFocus(tryGiveFocusTo: DraggableId) { - const handle: ?HTMLElement = getDragHandle(contextId, tryGiveFocusTo); - - if (handle && handle !== document.activeElement) { - console.log('giving focus to ', tryGiveFocusTo); - handle.focus(); - } - } - - function tryRestoreFocusRecorded() { - requestAnimationFrame(() => { - if (hadFocus) { - tryGiveFocus(hadFocus); - } - }); - } - - function tryRecordFocus(id: DraggableId) { - // clear any existing record - hadFocus = null; - - const focused: ?Element = document.activeElement; - - // no item focused so it cannot be our item - if (!focused) { - return; - } - - // focused element is not a drag handle or does not have the right id - if (focused.getAttribute(dragHandleAttr.draggableId) !== id) { - return; - } - - hadFocus = id; - } - - return { - register, - tryRecordFocus, - tryRestoreFocusRecorded, - tryGiveFocus, - }; -} diff --git a/src/view/context/app-context.js b/src/view/context/app-context.js index 9fc8738818..fa6673dc2a 100644 --- a/src/view/context/app-context.js +++ b/src/view/context/app-context.js @@ -2,7 +2,7 @@ import React from 'react'; import type { DraggableId, ContextId } from '../../types'; import type { DimensionMarshal } from '../../state/dimension-marshal/dimension-marshal-types'; -import type { FocusMarshal } from '../../state/focus-marshal'; +import type { FocusMarshal } from '../use-focus-marshal/focus-marshal-types'; export type AppContextValue = {| marshal: DimensionMarshal, diff --git a/src/view/drag-drop-context/app.jsx b/src/view/drag-drop-context/app.jsx index 4d11121929..b0221fd062 100644 --- a/src/view/drag-drop-context/app.jsx +++ b/src/view/drag-drop-context/app.jsx @@ -10,9 +10,8 @@ import canStartDrag from '../../state/can-start-drag'; import scrollWindow from '../window/scroll-window'; import createAutoScroller from '../../state/auto-scroller'; import useStyleMarshal from '../use-style-marshal/use-style-marshal'; -import createFocusMarshal, { - type FocusMarshal, -} from '../../state/focus-marshal'; +import useFocusMarshal from '../use-focus-marshal'; +import type { FocusMarshal } from '../use-focus-marshal/focus-marshal-types'; import type { AutoScroller } from '../../state/auto-scroller/auto-scroller-types'; import type { StyleMarshal } from '../use-style-marshal/style-marshal-types'; import type { @@ -126,10 +125,7 @@ export default function App(props: Props) { [dimensionMarshal.scrollDroppable, lazyDispatch], ); - const focusMarshal: FocusMarshal = useMemo( - () => createFocusMarshal(contextId), - [contextId], - ); + const focusMarshal: FocusMarshal = useFocusMarshal(contextId); const store: Store = useMemo( () => diff --git a/src/view/use-focus-marshal/focus-marshal-types.js b/src/view/use-focus-marshal/focus-marshal-types.js new file mode 100644 index 0000000000..b63434b7ff --- /dev/null +++ b/src/view/use-focus-marshal/focus-marshal-types.js @@ -0,0 +1,13 @@ +// @flow +import type { DraggableId } from '../../types'; + +type Unregister = () => void; + +export type Register = (id: DraggableId, focus: () => void) => Unregister; + +export type FocusMarshal = {| + register: Register, + tryRecordFocus: (tryRecordFor: DraggableId) => void, + tryRestoreFocusRecorded: () => void, + tryGiveFocus: (tryGiveFocusTo: DraggableId) => void, +|}; diff --git a/src/view/use-focus-marshal/index.js b/src/view/use-focus-marshal/index.js new file mode 100644 index 0000000000..cb96ff715e --- /dev/null +++ b/src/view/use-focus-marshal/index.js @@ -0,0 +1,2 @@ +// @flow +export { default } from './use-focus-marshal'; diff --git a/src/view/use-focus-marshal/use-focus-marshal.js b/src/view/use-focus-marshal/use-focus-marshal.js new file mode 100644 index 0000000000..c5ad71c469 --- /dev/null +++ b/src/view/use-focus-marshal/use-focus-marshal.js @@ -0,0 +1,141 @@ +// @flow +import { useRef } from 'react'; +import { useMemo, useCallback } from 'use-memo-one'; +import type { DraggableId, ContextId } from '../../types'; +import type { FocusMarshal } from './focus-marshal-types'; +// TODO: move out of state? +import { dragHandle as dragHandleAttr } from '../data-attributes'; +import { warning } from '../../dev-warning'; +import useLayoutEffect from '../use-isomorphic-layout-effect'; + +type Entry = {| + id: DraggableId, + focus: () => void, +|}; + +type EntryMap = { + [id: DraggableId]: Entry, +}; + +function getDragHandle( + contextId: ContextId, + draggableId: DraggableId, +): ?HTMLElement { + // find the drag handle + const selector: string = `[${dragHandleAttr.contextId}="${contextId}"]`; + const possible: Element[] = Array.from(document.querySelectorAll(selector)); + + if (!possible.length) { + warning(`Unable to find any drag handles in the context "${contextId}"`); + return null; + } + + const handle: ?Element = possible.find( + (el: Element): boolean => { + return el.getAttribute(dragHandleAttr.draggableId) === draggableId; + }, + ); + + if (!handle) { + warning( + `Unable to find drag handle with id "${draggableId}" as no handle with a matching id was found`, + ); + return null; + } + + if (!(handle instanceof HTMLElement)) { + warning(`Drag handle is not a HTMLElement`); + return null; + } + + return handle; +} + +export default function useFocusMarshal(contextId: ContextId): FocusMarshal { + const entriesRef = useRef({}); + const recordRef = useRef(null); + const restoreFocusFrameRef = useRef(null); + + const register = useCallback(function register( + id: DraggableId, + focus: () => void, + ): Unregister { + const entry: Entry = { id, focus }; + entriesRef.current[id] = entry; + + return function unregister() { + const entries: EntryMap = entriesRef.current; + const current: Entry = entries[id]; + // entry might have been overrided by another registration + if (current !== entry) { + delete entries[id]; + } + }; + }, + []); + + const tryGiveFocus = useCallback( + function tryGiveFocus(tryGiveFocusTo: DraggableId) { + const handle: ?HTMLElement = getDragHandle(contextId, tryGiveFocusTo); + + if (handle && handle !== document.activeElement) { + console.log('giving focus to ', tryGiveFocusTo); + handle.focus(); + } + }, + [contextId], + ); + + const tryRestoreFocusRecorded = useCallback( + function tryRestoreFocusRecorded() { + restoreFocusFrameRef.current = requestAnimationFrame(() => { + restoreFocusFrameRef.current = null; + const record: ?DraggableId = recordRef.current; + if (record) { + tryGiveFocus(record); + } + }); + }, + [tryGiveFocus], + ); + + function tryRecordFocus(id: DraggableId) { + // clear any existing record + recordRef.current = null; + + const focused: ?Element = document.activeElement; + + // no item focused so it cannot be our item + if (!focused) { + return; + } + + // focused element is not a drag handle or does not have the right id + if (focused.getAttribute(dragHandleAttr.draggableId) !== id) { + return; + } + + recordRef.current = id; + } + + useLayoutEffect(() => { + return function clearFrameOnUnmount() { + const frameId: ?AnimationFrameID = restoreFocusFrameRef.current; + if (frameId) { + cancelAnimationFrame(frameId); + } + }; + }, []); + + const marshal: FocusMarshal = useMemo( + () => ({ + register, + tryRecordFocus, + tryRestoreFocusRecorded, + tryGiveFocus, + }), + [register, tryGiveFocus, tryRestoreFocusRecorded], + ); + + return marshal; +} From 752dd9d37794e74226d1ea6faba829a6d3748e2c Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Thu, 16 May 2019 13:48:16 +1000 Subject: [PATCH 049/308] focus retention --- src/state/middleware/focus.js | 18 +++++++++++++++--- .../use-focus-marshal/focus-marshal-types.js | 4 ++-- .../use-focus-marshal/use-focus-marshal.js | 17 +++++++++++++---- 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/state/middleware/focus.js b/src/state/middleware/focus.js index 4d3491784c..ecb760c631 100644 --- a/src/state/middleware/focus.js +++ b/src/state/middleware/focus.js @@ -1,6 +1,7 @@ // @flow +import type { DropResult } from '../../types'; import type { Action, Dispatch } from '../store-types'; -import type { FocusMarshal } from '../focus-marshal'; +import type { FocusMarshal } from '../../view/use-focus-marshal/focus-marshal-types'; export default (marshal: FocusMarshal) => { let isWatching: boolean = false; @@ -21,10 +22,21 @@ export default (marshal: FocusMarshal) => { return; } - // on end - focus after timeout - if (action.type === 'DROP_COMPLETE' || action.type === 'CLEAN') { + if (action.type === 'CLEAN') { isWatching = false; marshal.tryRestoreFocusRecorded(); + return; + } + + if (action.type === 'DROP_COMPLETE') { + isWatching = false; + const result: DropResult = action.payload.completed.result; + + // give focus to the combine target when combining + if (result.combine) { + marshal.tryShiftRecord(result.draggableId, result.combine.draggableId); + } + marshal.tryRestoreFocusRecorded(); } }; }; diff --git a/src/view/use-focus-marshal/focus-marshal-types.js b/src/view/use-focus-marshal/focus-marshal-types.js index b63434b7ff..de176878f5 100644 --- a/src/view/use-focus-marshal/focus-marshal-types.js +++ b/src/view/use-focus-marshal/focus-marshal-types.js @@ -1,7 +1,7 @@ // @flow import type { DraggableId } from '../../types'; -type Unregister = () => void; +export type Unregister = () => void; export type Register = (id: DraggableId, focus: () => void) => Unregister; @@ -9,5 +9,5 @@ export type FocusMarshal = {| register: Register, tryRecordFocus: (tryRecordFor: DraggableId) => void, tryRestoreFocusRecorded: () => void, - tryGiveFocus: (tryGiveFocusTo: DraggableId) => void, + tryShiftRecord: (previous: DraggableId, redirectTo: DraggableId) => void, |}; diff --git a/src/view/use-focus-marshal/use-focus-marshal.js b/src/view/use-focus-marshal/use-focus-marshal.js index c5ad71c469..bd1e9292db 100644 --- a/src/view/use-focus-marshal/use-focus-marshal.js +++ b/src/view/use-focus-marshal/use-focus-marshal.js @@ -2,8 +2,7 @@ import { useRef } from 'react'; import { useMemo, useCallback } from 'use-memo-one'; import type { DraggableId, ContextId } from '../../types'; -import type { FocusMarshal } from './focus-marshal-types'; -// TODO: move out of state? +import type { FocusMarshal, Unregister } from './focus-marshal-types'; import { dragHandle as dragHandleAttr } from '../data-attributes'; import { warning } from '../../dev-warning'; import useLayoutEffect from '../use-isomorphic-layout-effect'; @@ -86,6 +85,16 @@ export default function useFocusMarshal(contextId: ContextId): FocusMarshal { [contextId], ); + const tryShiftRecord = useCallback(function tryShiftRecord( + previous: DraggableId, + redirectTo: DraggableId, + ) { + if (recordRef.current === previous) { + recordRef.current = redirectTo; + } + }, + []); + const tryRestoreFocusRecorded = useCallback( function tryRestoreFocusRecorded() { restoreFocusFrameRef.current = requestAnimationFrame(() => { @@ -132,9 +141,9 @@ export default function useFocusMarshal(contextId: ContextId): FocusMarshal { register, tryRecordFocus, tryRestoreFocusRecorded, - tryGiveFocus, + tryShiftRecord, }), - [register, tryGiveFocus, tryRestoreFocusRecorded], + [register, tryRestoreFocusRecorded, tryShiftRecord], ); return marshal; From 1aa15a40f8130f4642f087f3bd825b018f8b5d41 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Thu, 16 May 2019 14:10:11 +1000 Subject: [PATCH 050/308] simplier clone styling --- stories/src/primatives/quote-item.jsx | 38 ++++++++++++--------------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/stories/src/primatives/quote-item.jsx b/stories/src/primatives/quote-item.jsx index 52642553cd..43d1da2fb9 100644 --- a/stories/src/primatives/quote-item.jsx +++ b/stories/src/primatives/quote-item.jsx @@ -35,26 +35,23 @@ const getBackgroundColor = ( const getBorderColor = (isDragging: boolean, authorColors: AuthorColors) => isDragging ? authorColors.hard : 'transparent'; -function getCloneStyle(isClone: boolean): string { - if (!isClone) { - return ''; - } +const CloneBadge = styled.div` + background: ${colors.G100}; + bottom: ${grid / 2}px; + border: 2px solid ${colors.G200}; + border-radius: 50%; + box-sizing: border-box; + content: 'clone'; + font-size: 10px; + position: absolute; - // will be over the top of the avatar - return ` - &::after { - position: absolute; - content: 'clone'; - background: lightgreen; - height: 40px; - width: 40px; - border-radius: 50%; - display: flex; - justify-content: center; - align-items: center; - } - `; -} + height: 40px; + width: 40px; + + display: flex; + justify-content: center; + align-items: center; +`; const Container = styled.a` border-radius: ${borderRadius}px; @@ -73,8 +70,6 @@ const Container = styled.a` /* anchor overrides */ color: ${colors.N900}; - ${props => getCloneStyle(Boolean(props.isClone))} - &:hover, &:active { color: ${colors.N900}; @@ -188,6 +183,7 @@ function QuoteItem(props: Props) { style={getStyle(provided, style)} > + {isClone ? Clone : null}
{quote.content}
From 0b433c2ec05dd05ee5af096860f8fd630e11027b Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Thu, 16 May 2019 14:46:10 +1000 Subject: [PATCH 051/308] cleaner clone badge --- stories/src/primatives/quote-item.jsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/stories/src/primatives/quote-item.jsx b/stories/src/primatives/quote-item.jsx index 43d1da2fb9..f342bf360c 100644 --- a/stories/src/primatives/quote-item.jsx +++ b/stories/src/primatives/quote-item.jsx @@ -35,6 +35,8 @@ const getBackgroundColor = ( const getBorderColor = (isDragging: boolean, authorColors: AuthorColors) => isDragging ? authorColors.hard : 'transparent'; +const imageSize: number = 40; + const CloneBadge = styled.div` background: ${colors.G100}; bottom: ${grid / 2}px; @@ -44,9 +46,11 @@ const CloneBadge = styled.div` content: 'clone'; font-size: 10px; position: absolute; + right: -${imageSize / 3}px; + top: -${imageSize / 3}px; - height: 40px; - width: 40px; + height: ${imageSize}px; + width: ${imageSize}px; display: flex; justify-content: center; @@ -63,7 +67,7 @@ const Container = styled.a` isDragging ? `2px 2px 1px ${colors.N70}` : 'none'}; box-sizing: border-box; padding: ${grid}px; - min-height: 40px; + min-height: ${imageSize}px; margin-bottom: ${grid}px; user-select: none; @@ -87,8 +91,8 @@ const Container = styled.a` `; const Avatar = styled.img` - width: 40px; - height: 40px; + width: ${imageSize}px; + height: ${imageSize}px; border-radius: 50%; margin-right: ${grid}px; flex-shrink: 0; From 3246788313b5ef5724e0d4830e86aca9ec1469ba Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Thu, 16 May 2019 17:00:47 +1000 Subject: [PATCH 052/308] touch listener --- src/empty.js | 6 + .../sensors/use-mouse-sensor.js | 2 +- .../sensors/use-touch-sensor.js | 374 ++++++++++++++++++ 3 files changed, 381 insertions(+), 1 deletion(-) create mode 100644 src/empty.js diff --git a/src/empty.js b/src/empty.js new file mode 100644 index 0000000000..7c6e177f0a --- /dev/null +++ b/src/empty.js @@ -0,0 +1,6 @@ +// @flow +export function noop(): void {} + +export function identity(value: T): T { + return value; +} diff --git a/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js b/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js index 1c055202b8..d59791f5fb 100644 --- a/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js +++ b/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js @@ -262,7 +262,7 @@ export default function useMouseSensor( ); const listenForCapture = useCallback( - function tryStartCapture() { + function listenForCapture() { const options: EventOptions = { passive: false, capture: true, diff --git a/src/view/use-sensor-marshal/sensors/use-touch-sensor.js b/src/view/use-sensor-marshal/sensors/use-touch-sensor.js index 46e7f7c045..c9e897a8ab 100644 --- a/src/view/use-sensor-marshal/sensors/use-touch-sensor.js +++ b/src/view/use-sensor-marshal/sensors/use-touch-sensor.js @@ -1 +1,375 @@ // @flow +import invariant from 'tiny-invariant'; +import { useEffect, useRef } from 'react'; +import { useCallback, useMemo } from 'use-memo-one'; +import type { Position } from 'css-box-model'; +import type { PreDragActions, DragActions } from '../../../types'; +import type { + EventBinding, + EventOptions, +} from '../../event-bindings/event-types'; +import bindEvents from '../../event-bindings/bind-events'; +import isSloppyClickThresholdExceeded from './util/is-sloppy-click-threshold-exceeded'; +import * as keyCodes from '../../key-codes'; +import preventStandardKeyEvents from './util/prevent-standard-key-events'; +import supportedPageVisibilityEventName from './util/supported-page-visibility-event-name'; +import { warning } from '../../../dev-warning'; +import { noop } from '../../../empty'; + +type TouchWithForce = Touch & { + force: number, +}; + +type Idle = {| + type: 'IDLE', +|}; + +type Pending = {| + type: 'PENDING', + point: Position, + actions: PreDragActions, + longPressTimerId: TimeoutID, +|}; + +type Dragging = {| + type: 'DRAGGING', + actions: DragActions, + hasMoved: boolean, +|}; + +type Phase = Idle | Pending | Dragging; + +const idle: Idle = { type: 'IDLE' }; +export const timeForLongPress: number = 150; +export const forcePressThreshold: number = 0.15; + +type GetCaptureArgs = {| + cancel: () => void, + completed: () => void, + getPhase: () => Phase, +|}; + +function getCaptureBindings({ + cancel, + completed, + getPhase, +}: GetCaptureArgs): EventBinding[] { + return [ + { + eventName: 'touchmove', + // Opting out of passive touchmove (default) so as to prevent scrolling while moving + // Not worried about performance as effect of move is throttled in requestAnimationFrame + // Using `capture: false` due to a recent horrible firefox bug: https://twitter.com/alexandereardon/status/1125904207184187393 + options: { passive: false, capture: false }, + fn: (event: TouchEvent) => { + const phase: Phase = getPhase(); + // Drag has not yet started and we are waiting for a long press. + if (phase.type !== 'DRAGGING') { + cancel(); + return; + } + + // At this point we are dragging + phase.hasMoved = true; + + const { clientX, clientY } = event.touches[0]; + + const point: Position = { + x: clientX, + y: clientY, + }; + + // We need to prevent the default event in order to block native scrolling + // Also because we are using it as part of a drag we prevent the default action + // as a sign that we are using the event + event.preventDefault(); + phase.actions.move(point); + }, + }, + { + eventName: 'touchend', + fn: (event: TouchEvent) => { + const phase: Phase = getPhase(); + // drag had not started yet - do not prevent the default action + if (phase.type !== 'DRAGGING') { + cancel(); + return; + } + + // ending the drag + event.preventDefault(); + phase.actions.drop({ shouldBlockNextClick: true }); + completed(); + }, + }, + { + eventName: 'touchcancel', + fn: (event: TouchEvent) => { + // drag had not started yet - do not prevent the default action + if (getPhase().type !== 'DRAGGING') { + cancel(); + return; + } + + // already dragging - this event is directly ending a drag + event.preventDefault(); + cancel(); + }, + }, + // another touch start should not happen without a + // touchend or touchcancel. However, just being super safe + { + eventName: 'touchstart', + fn: cancel, + }, + // If the orientation of the device changes - kill the drag + // https://davidwalsh.name/orientation-change + { + eventName: 'orientationchange', + fn: cancel, + }, + // some devices fire resize if the orientation changes + { + eventName: 'resize', + fn: cancel, + }, + // Long press can bring up a context menu + // need to opt out of this behavior + { + eventName: 'contextmenu', + fn: (event: Event) => { + // always opting out of context menu events + event.preventDefault(); + }, + }, + // On some devices it is possible to have a touch interface with a keyboard. + // On any keyboard event we cancel a touch drag + { + eventName: 'keydown', + fn: (event: KeyboardEvent) => { + if (getPhase().type !== 'DRAGGING') { + cancel(); + return; + } + + // direct cancel: we are preventing the default action + // indirect cancel: we are not preventing the default action + + // escape is a direct cancel + if (event.keyCode === keyCodes.escape) { + event.preventDefault(); + } + cancel(); + }, + }, + // Need to opt out of dragging if the user is a force press + // Only for webkit which has decided to introduce its own custom way of doing things + // https://developer.apple.com/library/content/documentation/AppleApplications/Conceptual/SafariJSProgTopics/RespondingtoForceTouchEventsfromJavaScript.html + { + eventName: 'touchforcechange', + fn: (event: TouchEvent) => { + const phase: Phase = getPhase(); + + // needed to use phase.actions + invariant(phase.type !== 'IDLE'); + + // Opting out of respecting force press interactions + if (!phase.actions.shouldRespectForcePress()) { + event.preventDefault(); + return; + } + + // A force push action will no longer fire after a touchmove + // This is being super safe. While this situation should not occur we + // are still expressing that we want to opt out of force pressing + if (phase.type === 'DRAGGING' && phase.hasMoved) { + event.preventDefault(); + return; + } + + // A drag could be pending or has already started but no movement has occurred + + const touch: TouchWithForce = (event.touches[0]: any); + + if (touch.force >= forcePressThreshold) { + // this is an indirect cancel so we do not preventDefault + // we also want to allow the force press to occur + cancel(); + } + }, + }, + ]; +} + +export default function useMouseSensor( + tryStartCapturing: (event: Event, abort: () => void) => ?PreDragActions, +) { + const phaseRef = useRef(idle); + const unbindEventsRef = useRef<() => void>(noop); + + const getPhase = useCallback(function getPhase(): Phase { + return phaseRef.current; + }, []); + + const setPhase = useCallback(function setPhase(phase: Phase) { + phaseRef.current = phase; + }, []); + + const startCaptureBinding: EventBinding = useMemo( + () => ({ + eventName: 'touchstart', + fn: function onTouchStart(event: TouchEvent) { + // Event already used by something else + if (event.defaultPrevented) { + return; + } + + // We need to NOT call event.preventDefault() so as to maintain as much standard + // browser interactions as possible. + // This includes navigation on anchors which we want to preserve + + const actions: ?PreDragActions = tryStartCapturing(event, stop); + + // could not start a drag + if (!actions) { + return; + } + + // unbind this event handler + unbindEventsRef.current(); + + const touch: Touch = event.touches[0]; + const { clientX, clientY } = touch; + const point: Position = { + x: clientX, + y: clientY, + }; + + // eslint-disable-next-line no-use-before-define + startPendingDrag(actions, point); + }, + }), + // not including stop or startPendingDrag as it is not defined initially + // eslint-disable-next-line react-hooks/exhaustive-deps + [tryStartCapturing], + ); + + const listenForCapture = useCallback( + function listenForCapture() { + const options: EventOptions = { + passive: false, + capture: true, + }; + + unbindEventsRef.current = bindEvents( + window, + [startCaptureBinding], + options, + ); + }, + [startCaptureBinding], + ); + + const stop = useCallback(() => { + const current: Phase = phaseRef.current; + if (current.type === 'IDLE') { + return; + } + + // aborting any pending drag + if (current.type === 'PENDING') { + clearTimeout(current.longPressTimerId); + } + + setPhase(idle); + unbindEventsRef.current(); + + listenForCapture(); + }, [listenForCapture, setPhase]); + + const cancel = useCallback(() => { + const phase: Phase = phaseRef.current; + stop(); + if (phase.type === 'DRAGGING') { + phase.actions.cancel({ shouldBlockNextClick: true }); + } + if (phase.type === 'PENDING') { + phase.actions.abort(); + } + }, [stop]); + + const bindCapturingEvents = useCallback( + function bindCapturingEvents() { + const options = { capture: true, passive: false }; + const bindings: EventBinding[] = getCaptureBindings({ + cancel, + completed: stop, + getPhase, + }); + + unbindEventsRef.current = bindEvents(window, bindings, options); + }, + [cancel, getPhase, stop], + ); + + const startDragging = useCallback( + function startDragging() { + const phase: Phase = getPhase(); + invariant( + phase.type === 'PENDING', + `Cannot start dragging from phase ${phase.type}`, + ); + + const actions: DragActions = phase.actions.lift({ + clientSelection: phase.point, + mode: 'FLUID', + }); + + setPhase({ + type: 'DRAGGING', + actions, + hasMoved: false, + }); + }, + [getPhase, setPhase], + ); + + const startPendingDrag = useCallback( + function startPendingDrag(actions: PreDragActions, point: Position) { + invariant( + getPhase().type === 'IDLE', + 'Expected to move from IDLE to PENDING drag', + ); + + const longPressTimerId: TimeoutID = setTimeout( + startDragging, + timeForLongPress, + ); + + setPhase({ + type: 'PENDING', + point, + actions, + longPressTimerId, + }); + + bindCapturingEvents(); + }, + [bindCapturingEvents, getPhase, setPhase, startDragging], + ); + + useEffect(() => { + listenForCapture(); + + return function unmount() { + // remove any existing listeners + unbindEventsRef.current(); + + const phase: Phase = getPhase(); + if (phase.type === 'PENDING') { + clearTimeout(phase.longPressTimerId); + setPhase(idle); + } + }; + }, [getPhase, listenForCapture, setPhase]); +} From f1ec333da07cb1fabe638df3c25cd8f35f57119d Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Fri, 17 May 2019 08:22:32 +1000 Subject: [PATCH 053/308] touch sensor wip --- src/view/use-drag-handle/use-drag-handle.js | 4 ++-- .../sensors/use-touch-sensor.js | 15 +++++++++++---- src/view/use-sensor-marshal/use-sensor-marshal.js | 9 +++++++-- stories/src/primatives/quote-item.jsx | 2 +- 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/view/use-drag-handle/use-drag-handle.js b/src/view/use-drag-handle/use-drag-handle.js index 7d6170a5df..7c318f0338 100644 --- a/src/view/use-drag-handle/use-drag-handle.js +++ b/src/view/use-drag-handle/use-drag-handle.js @@ -201,7 +201,7 @@ export default function useDragHandle(args: Args): ?DragHandleProps { return { // onMouseDown: () => {}, // onKeyDown: () => {}, - // onTouchStart, + onTouchStart, // onFocus, // onBlur, tabIndex: 0, @@ -213,7 +213,7 @@ export default function useDragHandle(args: Args): ?DragHandleProps { draggable: false, onDragStart: preventHtml5Dnd, }; - }, [contextId, draggableId, isEnabled]); + }, [contextId, draggableId, isEnabled, onTouchStart]); return props; } diff --git a/src/view/use-sensor-marshal/sensors/use-touch-sensor.js b/src/view/use-sensor-marshal/sensors/use-touch-sensor.js index c9e897a8ab..4ce0ddd72a 100644 --- a/src/view/use-sensor-marshal/sensors/use-touch-sensor.js +++ b/src/view/use-sensor-marshal/sensors/use-touch-sensor.js @@ -9,11 +9,8 @@ import type { EventOptions, } from '../../event-bindings/event-types'; import bindEvents from '../../event-bindings/bind-events'; -import isSloppyClickThresholdExceeded from './util/is-sloppy-click-threshold-exceeded'; import * as keyCodes from '../../key-codes'; -import preventStandardKeyEvents from './util/prevent-standard-key-events'; import supportedPageVisibilityEventName from './util/supported-page-visibility-event-name'; -import { warning } from '../../../dev-warning'; import { noop } from '../../../empty'; type TouchWithForce = Touch & { @@ -60,8 +57,9 @@ function getCaptureBindings({ // Opting out of passive touchmove (default) so as to prevent scrolling while moving // Not worried about performance as effect of move is throttled in requestAnimationFrame // Using `capture: false` due to a recent horrible firefox bug: https://twitter.com/alexandereardon/status/1125904207184187393 - options: { passive: false, capture: false }, + options: { capture: false }, fn: (event: TouchEvent) => { + console.log('touch move'); const phase: Phase = getPhase(); // Drag has not yet started and we are waiting for a long press. if (phase.type !== 'DRAGGING') { @@ -83,6 +81,7 @@ function getCaptureBindings({ // Also because we are using it as part of a drag we prevent the default action // as a sign that we are using the event event.preventDefault(); + console.log('moving'); phase.actions.move(point); }, }, @@ -138,6 +137,7 @@ function getCaptureBindings({ { eventName: 'contextmenu', fn: (event: Event) => { + console.log('context menu opt out'); // always opting out of context menu events event.preventDefault(); }, @@ -198,6 +198,11 @@ function getCaptureBindings({ } }, }, + // Cancel on page visibility change + { + eventName: supportedPageVisibilityEventName, + fn: cancel, + }, ]; } @@ -281,6 +286,7 @@ export default function useMouseSensor( clearTimeout(current.longPressTimerId); } + console.log('STOPPING'); setPhase(idle); unbindEventsRef.current(); @@ -325,6 +331,7 @@ export default function useMouseSensor( mode: 'FLUID', }); + console.log('in dragging phase'); setPhase({ type: 'DRAGGING', actions, diff --git a/src/view/use-sensor-marshal/use-sensor-marshal.js b/src/view/use-sensor-marshal/use-sensor-marshal.js index cd8b545b96..4aaf6448bd 100644 --- a/src/view/use-sensor-marshal/use-sensor-marshal.js +++ b/src/view/use-sensor-marshal/use-sensor-marshal.js @@ -26,13 +26,14 @@ import { lift as liftAction, } from '../../state/action-creators'; import useMouseSensor from './sensors/use-mouse-sensor'; +import useKeyboardSensor from './sensors/use-keyboard-sensor'; +import useTouchSensor from './sensors/use-touch-sensor'; import useValidateSensorHooks from './use-validate-sensor-hooks'; import isHandleInInteractiveElement from './is-handle-in-interactive-element'; import getOptionsFromDraggable from './get-options-from-draggable'; import getBorderBoxCenterPosition from '../get-border-box-center-position'; import { warning } from '../../dev-warning'; import isHtmlElement from '../is-type-of-element/is-html-element'; -import useKeyboardSensor from './sensors/use-keyboard-sensor'; import useLayoutEffect from '../use-isomorphic-layout-effect'; function preventDefault(event: Event) { @@ -298,7 +299,11 @@ type SensorMarshalArgs = {| customSensors: ?(Sensor[]), |}; -const defaultSensors: Sensor[] = [useMouseSensor, useKeyboardSensor]; +const defaultSensors: Sensor[] = [ + useMouseSensor, + useKeyboardSensor, + // useTouchSensor, +]; export default function useSensorMarshal({ contextId, diff --git a/stories/src/primatives/quote-item.jsx b/stories/src/primatives/quote-item.jsx index f342bf360c..2704ea0f29 100644 --- a/stories/src/primatives/quote-item.jsx +++ b/stories/src/primatives/quote-item.jsx @@ -43,11 +43,11 @@ const CloneBadge = styled.div` border: 2px solid ${colors.G200}; border-radius: 50%; box-sizing: border-box; - content: 'clone'; font-size: 10px; position: absolute; right: -${imageSize / 3}px; top: -${imageSize / 3}px; + transform: rotate(40deg); height: ${imageSize}px; width: ${imageSize}px; From 363ca8cf3053e0eafdbad58038819e3f1cac4d7a Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Fri, 17 May 2019 10:11:48 +1000 Subject: [PATCH 054/308] more stable event handlers --- src/view/event-bindings/bind-events.js | 4 +- src/view/use-drag-handle/use-drag-handle.js | 4 +- .../sensors/use-touch-sensor.js | 147 +++++++++++------- .../use-sensor-marshal/use-sensor-marshal.js | 2 +- 4 files changed, 93 insertions(+), 64 deletions(-) diff --git a/src/view/event-bindings/bind-events.js b/src/view/event-bindings/bind-events.js index 6e7f75517e..a948035935 100644 --- a/src/view/event-bindings/bind-events.js +++ b/src/view/event-bindings/bind-events.js @@ -33,5 +33,7 @@ export default function bindEvents( }); // Return a function to unbind events - return () => unbindEvents(el, bindings, sharedOptions); + return function unbind() { + unbindEvents(el, bindings, sharedOptions); + }; } diff --git a/src/view/use-drag-handle/use-drag-handle.js b/src/view/use-drag-handle/use-drag-handle.js index 7c318f0338..7d6170a5df 100644 --- a/src/view/use-drag-handle/use-drag-handle.js +++ b/src/view/use-drag-handle/use-drag-handle.js @@ -201,7 +201,7 @@ export default function useDragHandle(args: Args): ?DragHandleProps { return { // onMouseDown: () => {}, // onKeyDown: () => {}, - onTouchStart, + // onTouchStart, // onFocus, // onBlur, tabIndex: 0, @@ -213,7 +213,7 @@ export default function useDragHandle(args: Args): ?DragHandleProps { draggable: false, onDragStart: preventHtml5Dnd, }; - }, [contextId, draggableId, isEnabled, onTouchStart]); + }, [contextId, draggableId, isEnabled]); return props; } diff --git a/src/view/use-sensor-marshal/sensors/use-touch-sensor.js b/src/view/use-sensor-marshal/sensors/use-touch-sensor.js index 4ce0ddd72a..d0bf46bb3c 100644 --- a/src/view/use-sensor-marshal/sensors/use-touch-sensor.js +++ b/src/view/use-sensor-marshal/sensors/use-touch-sensor.js @@ -40,17 +40,71 @@ const idle: Idle = { type: 'IDLE' }; export const timeForLongPress: number = 150; export const forcePressThreshold: number = 0.15; -type GetCaptureArgs = {| +type GetBindingArgs = {| cancel: () => void, completed: () => void, getPhase: () => Phase, |}; -function getCaptureBindings({ +function getWindowBindings({ + cancel, + getPhase, +}: GetBindingArgs): EventBinding[] { + return [ + // If the orientation of the device changes - kill the drag + // https://davidwalsh.name/orientation-change + { + eventName: 'orientationchange', + fn: cancel, + }, + // some devices fire resize if the orientation changes + { + eventName: 'resize', + fn: cancel, + }, + // Long press can bring up a context menu + // need to opt out of this behavior + { + eventName: 'contextmenu', + fn: (event: Event) => { + console.log('context menu opt out'); + // always opting out of context menu events + event.preventDefault(); + }, + }, + // On some devices it is possible to have a touch interface with a keyboard. + // On any keyboard event we cancel a touch drag + { + eventName: 'keydown', + fn: (event: KeyboardEvent) => { + if (getPhase().type !== 'DRAGGING') { + cancel(); + return; + } + + // direct cancel: we are preventing the default action + // indirect cancel: we are not preventing the default action + + // escape is a direct cancel + if (event.keyCode === keyCodes.escape) { + event.preventDefault(); + } + cancel(); + }, + }, + // Cancel on page visibility change + { + eventName: supportedPageVisibilityEventName, + fn: cancel, + }, + ]; +} + +function getTargetBindings({ cancel, completed, getPhase, -}: GetCaptureArgs): EventBinding[] { +}: GetBindingArgs): EventBinding[] { return [ { eventName: 'touchmove', @@ -88,6 +142,7 @@ function getCaptureBindings({ { eventName: 'touchend', fn: (event: TouchEvent) => { + console.log('touch end'); const phase: Phase = getPhase(); // drag had not started yet - do not prevent the default action if (phase.type !== 'DRAGGING') { @@ -104,6 +159,7 @@ function getCaptureBindings({ { eventName: 'touchcancel', fn: (event: TouchEvent) => { + console.log('touch cancel'); // drag had not started yet - do not prevent the default action if (getPhase().type !== 'DRAGGING') { cancel(); @@ -115,59 +171,13 @@ function getCaptureBindings({ cancel(); }, }, - // another touch start should not happen without a - // touchend or touchcancel. However, just being super safe - { - eventName: 'touchstart', - fn: cancel, - }, - // If the orientation of the device changes - kill the drag - // https://davidwalsh.name/orientation-change - { - eventName: 'orientationchange', - fn: cancel, - }, - // some devices fire resize if the orientation changes - { - eventName: 'resize', - fn: cancel, - }, - // Long press can bring up a context menu - // need to opt out of this behavior - { - eventName: 'contextmenu', - fn: (event: Event) => { - console.log('context menu opt out'); - // always opting out of context menu events - event.preventDefault(); - }, - }, - // On some devices it is possible to have a touch interface with a keyboard. - // On any keyboard event we cancel a touch drag - { - eventName: 'keydown', - fn: (event: KeyboardEvent) => { - if (getPhase().type !== 'DRAGGING') { - cancel(); - return; - } - - // direct cancel: we are preventing the default action - // indirect cancel: we are not preventing the default action - - // escape is a direct cancel - if (event.keyCode === keyCodes.escape) { - event.preventDefault(); - } - cancel(); - }, - }, // Need to opt out of dragging if the user is a force press // Only for webkit which has decided to introduce its own custom way of doing things // https://developer.apple.com/library/content/documentation/AppleApplications/Conceptual/SafariJSProgTopics/RespondingtoForceTouchEventsfromJavaScript.html { eventName: 'touchforcechange', fn: (event: TouchEvent) => { + console.log('touch force change'); const phase: Phase = getPhase(); // needed to use phase.actions @@ -203,6 +213,7 @@ function getCaptureBindings({ eventName: supportedPageVisibilityEventName, fn: cancel, }, + // Not adding a cancel on touchstart as this handler will pick up the initial touchstart event ]; } @@ -240,18 +251,23 @@ export default function useMouseSensor( return; } - // unbind this event handler - unbindEventsRef.current(); - const touch: Touch = event.touches[0]; const { clientX, clientY } = touch; const point: Position = { x: clientX, y: clientY, }; + const target: EventTarget = event.target; + invariant( + target instanceof HTMLElement, + 'Expected touch target to be an element', + ); + + // unbind this event handler + unbindEventsRef.current(); // eslint-disable-next-line no-use-before-define - startPendingDrag(actions, point); + startPendingDrag(actions, point, target); }, }), // not including stop or startPendingDrag as it is not defined initially @@ -305,15 +321,22 @@ export default function useMouseSensor( }, [stop]); const bindCapturingEvents = useCallback( - function bindCapturingEvents() { + function bindCapturingEvents(target: HTMLElement) { const options = { capture: true, passive: false }; - const bindings: EventBinding[] = getCaptureBindings({ + const args: GetBindingArgs = { cancel, completed: stop, getPhase, - }); + }; + + const unbindTarget = bindEvents(target, getTargetBindings(args), options); + const unbindWindow = bindEvents(window, getWindowBindings(args), options); - unbindEventsRef.current = bindEvents(window, bindings, options); + unbindEventsRef.current = function unbind() { + console.log('unbinding both'); + unbindTarget(); + unbindWindow(); + }; }, [cancel, getPhase, stop], ); @@ -342,7 +365,11 @@ export default function useMouseSensor( ); const startPendingDrag = useCallback( - function startPendingDrag(actions: PreDragActions, point: Position) { + function startPendingDrag( + actions: PreDragActions, + point: Position, + target: HTMLElement, + ) { invariant( getPhase().type === 'IDLE', 'Expected to move from IDLE to PENDING drag', @@ -360,7 +387,7 @@ export default function useMouseSensor( longPressTimerId, }); - bindCapturingEvents(); + bindCapturingEvents(target); }, [bindCapturingEvents, getPhase, setPhase, startDragging], ); diff --git a/src/view/use-sensor-marshal/use-sensor-marshal.js b/src/view/use-sensor-marshal/use-sensor-marshal.js index 4aaf6448bd..4194dc0d9d 100644 --- a/src/view/use-sensor-marshal/use-sensor-marshal.js +++ b/src/view/use-sensor-marshal/use-sensor-marshal.js @@ -302,7 +302,7 @@ type SensorMarshalArgs = {| const defaultSensors: Sensor[] = [ useMouseSensor, useKeyboardSensor, - // useTouchSensor, + useTouchSensor, ]; export default function useSensorMarshal({ From b6ba1fc4f423f00374286202c9bb6f80a8faa5cc Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Fri, 17 May 2019 10:43:59 +1000 Subject: [PATCH 055/308] cleanup touch sensor --- .../sensors/use-touch-sensor.js | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/view/use-sensor-marshal/sensors/use-touch-sensor.js b/src/view/use-sensor-marshal/sensors/use-touch-sensor.js index d0bf46bb3c..3cd54e2e39 100644 --- a/src/view/use-sensor-marshal/sensors/use-touch-sensor.js +++ b/src/view/use-sensor-marshal/sensors/use-touch-sensor.js @@ -67,7 +67,6 @@ function getWindowBindings({ { eventName: 'contextmenu', fn: (event: Event) => { - console.log('context menu opt out'); // always opting out of context menu events event.preventDefault(); }, @@ -100,6 +99,8 @@ function getWindowBindings({ ]; } +// All of the touch events get applied to the initial target of the touch interaction +// This plays well with the target being unmounted during a drag function getTargetBindings({ cancel, completed, @@ -113,7 +114,6 @@ function getTargetBindings({ // Using `capture: false` due to a recent horrible firefox bug: https://twitter.com/alexandereardon/status/1125904207184187393 options: { capture: false }, fn: (event: TouchEvent) => { - console.log('touch move'); const phase: Phase = getPhase(); // Drag has not yet started and we are waiting for a long press. if (phase.type !== 'DRAGGING') { @@ -135,14 +135,12 @@ function getTargetBindings({ // Also because we are using it as part of a drag we prevent the default action // as a sign that we are using the event event.preventDefault(); - console.log('moving'); phase.actions.move(point); }, }, { eventName: 'touchend', fn: (event: TouchEvent) => { - console.log('touch end'); const phase: Phase = getPhase(); // drag had not started yet - do not prevent the default action if (phase.type !== 'DRAGGING') { @@ -159,7 +157,6 @@ function getTargetBindings({ { eventName: 'touchcancel', fn: (event: TouchEvent) => { - console.log('touch cancel'); // drag had not started yet - do not prevent the default action if (getPhase().type !== 'DRAGGING') { cancel(); @@ -177,7 +174,6 @@ function getTargetBindings({ { eventName: 'touchforcechange', fn: (event: TouchEvent) => { - console.log('touch force change'); const phase: Phase = getPhase(); // needed to use phase.actions @@ -244,6 +240,7 @@ export default function useMouseSensor( // browser interactions as possible. // This includes navigation on anchors which we want to preserve + // eslint-disable-next-line no-use-before-define const actions: ?PreDragActions = tryStartCapturing(event, stop); // could not start a drag @@ -302,7 +299,6 @@ export default function useMouseSensor( clearTimeout(current.longPressTimerId); } - console.log('STOPPING'); setPhase(idle); unbindEventsRef.current(); @@ -322,18 +318,23 @@ export default function useMouseSensor( const bindCapturingEvents = useCallback( function bindCapturingEvents(target: HTMLElement) { - const options = { capture: true, passive: false }; + const options: EventOptions = { capture: true, passive: false }; const args: GetBindingArgs = { cancel, completed: stop, getPhase, }; + // When removing a drag handle, such as moving into a portal or clone, + // touch events stop being published to the window. + // Even though the handle is removed, if you attach events to it they will + // continue to fire for the interaction. Strange, but hey - that's the web + // https://gist.github.com/parris/dda613e3ae78f14eb2dc9fa0f4bfce3d + // https://stackoverflow.com/questions/33298828/touch-move-event-dont-fire-after-touch-start-target-is-removed const unbindTarget = bindEvents(target, getTargetBindings(args), options); const unbindWindow = bindEvents(window, getWindowBindings(args), options); unbindEventsRef.current = function unbind() { - console.log('unbinding both'); unbindTarget(); unbindWindow(); }; @@ -354,7 +355,6 @@ export default function useMouseSensor( mode: 'FLUID', }); - console.log('in dragging phase'); setPhase({ type: 'DRAGGING', actions, From 1189ce860ac69f975926b85083b9f0b2403f6e4b Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Fri, 17 May 2019 11:11:27 +1000 Subject: [PATCH 056/308] sensor tests --- .../sensors/use-touch-sensor.js | 1 + .../use-sensor-marshal/use-sensor-marshal.js | 3 + .../mouse-sensor/click-blocking.spec.js | 6 ++ .../drag-handle/sensor/click-blocking.spec.js | 81 +++++++++++++++++++ .../lock-context-isolation.spec.js | 4 +- .../{ => sensor}/move-throttling.spec.js | 8 +- .../{ => sensor}/obtaining-lock.spec.js | 6 +- .../{ => sensor}/outdated-locks.spec.js | 4 +- 8 files changed, 102 insertions(+), 11 deletions(-) create mode 100644 test/unit/integration/drag-handle/mouse-sensor/click-blocking.spec.js create mode 100644 test/unit/integration/drag-handle/sensor/click-blocking.spec.js rename test/unit/integration/drag-handle/{ => sensor}/lock-context-isolation.spec.js (90%) rename test/unit/integration/drag-handle/{ => sensor}/move-throttling.spec.js (93%) rename test/unit/integration/drag-handle/{ => sensor}/obtaining-lock.spec.js (97%) rename test/unit/integration/drag-handle/{ => sensor}/outdated-locks.spec.js (97%) diff --git a/src/view/use-sensor-marshal/sensors/use-touch-sensor.js b/src/view/use-sensor-marshal/sensors/use-touch-sensor.js index 3cd54e2e39..69bac8e161 100644 --- a/src/view/use-sensor-marshal/sensors/use-touch-sensor.js +++ b/src/view/use-sensor-marshal/sensors/use-touch-sensor.js @@ -399,6 +399,7 @@ export default function useMouseSensor( // remove any existing listeners unbindEventsRef.current(); + // need to kill any pending drag start timer const phase: Phase = getPhase(); if (phase.type === 'PENDING') { clearTimeout(phase.longPressTimerId); diff --git a/src/view/use-sensor-marshal/use-sensor-marshal.js b/src/view/use-sensor-marshal/use-sensor-marshal.js index 4194dc0d9d..0a073c7649 100644 --- a/src/view/use-sensor-marshal/use-sensor-marshal.js +++ b/src/view/use-sensor-marshal/use-sensor-marshal.js @@ -37,7 +37,9 @@ import isHtmlElement from '../is-type-of-element/is-html-element'; import useLayoutEffect from '../use-isomorphic-layout-effect'; function preventDefault(event: Event) { + console.log('preventing click'); event.preventDefault(); + console.log('prevented?', event); } function noop() {} @@ -227,6 +229,7 @@ function tryGetLock({ // block next click if requested if (options.shouldBlockNextClick) { + console.log('adding handler'); window.addEventListener('click', preventDefault, { // only blocking a single click once: true, diff --git a/test/unit/integration/drag-handle/mouse-sensor/click-blocking.spec.js b/test/unit/integration/drag-handle/mouse-sensor/click-blocking.spec.js new file mode 100644 index 0000000000..983524b005 --- /dev/null +++ b/test/unit/integration/drag-handle/mouse-sensor/click-blocking.spec.js @@ -0,0 +1,6 @@ +// @flow +it('should not prevent a subsequent click if aborting during a pending drag', () => {}); + +it('should prevent a subsequent click if cancelling a drag', () => {}); + +it('should prevent a subsequent click if dropping a drag', () => {}); diff --git a/test/unit/integration/drag-handle/sensor/click-blocking.spec.js b/test/unit/integration/drag-handle/sensor/click-blocking.spec.js new file mode 100644 index 0000000000..b81c2a4fcb --- /dev/null +++ b/test/unit/integration/drag-handle/sensor/click-blocking.spec.js @@ -0,0 +1,81 @@ +// @flow +import invariant from 'tiny-invariant'; +import React from 'react'; +import { render, fireEvent } from 'react-testing-library'; +import type { + TryGetActionLock, + Sensor, + PreDragActions, + DragActions, +} from '../../../../../src/types'; +import App from '../app'; + +function getClick(): MouseEvent { + return new MouseEvent('click', { + clientX: 0, + clientY: 0, + cancelable: true, + bubbles: true, + }); +} + +it('should block a single click if requested', () => { + let tryGetLock: TryGetActionLock; + + const a: Sensor = (tryStart: TryGetActionLock) => { + tryGetLock = tryStart; + }; + + const { getByText } = render( + + + , + ); + const handle: HTMLElement = getByText('item: 0'); + invariant(tryGetLock); + + // trigger a drop + const preDrag: ?PreDragActions = tryGetLock(handle); + invariant(preDrag); + const drag: DragActions = preDrag.lift({ mode: 'SNAP' }); + drag.drop({ shouldBlockNextClick: true }); + + // fire click + const first: MouseEvent = getClick(); + const second: MouseEvent = getClick(); + fireEvent(handle, first); + fireEvent(handle, second); + + // only first click is prevented + expect(first.defaultPrevented).toBe(true); + expect(second.defaultPrevented).toBe(false); +}); + +it('should not block any clicks if not requested', () => { + let tryGetLock: TryGetActionLock; + + const a: Sensor = (tryStart: TryGetActionLock) => { + tryGetLock = tryStart; + }; + + const { getByText } = render( + + + , + ); + const handle: HTMLElement = getByText('item: 0'); + invariant(tryGetLock); + + // trigger a drop + const preDrag: ?PreDragActions = tryGetLock(handle); + invariant(preDrag); + const drag: DragActions = preDrag.lift({ mode: 'SNAP' }); + drag.drop({ shouldBlockNextClick: false }); + + // fire click + const first: MouseEvent = getClick(); + fireEvent(handle, first); + + // click not prevented + expect(first.defaultPrevented).toBe(false); +}); diff --git a/test/unit/integration/drag-handle/lock-context-isolation.spec.js b/test/unit/integration/drag-handle/sensor/lock-context-isolation.spec.js similarity index 90% rename from test/unit/integration/drag-handle/lock-context-isolation.spec.js rename to test/unit/integration/drag-handle/sensor/lock-context-isolation.spec.js index c0efbc7692..6bd3370ef9 100644 --- a/test/unit/integration/drag-handle/lock-context-isolation.spec.js +++ b/test/unit/integration/drag-handle/sensor/lock-context-isolation.spec.js @@ -2,8 +2,8 @@ import invariant from 'tiny-invariant'; import React from 'react'; import { render } from 'react-testing-library'; -import type { TryGetActionLock, Sensor } from '../../../../src/types'; -import App from './app'; +import type { TryGetActionLock, Sensor } from '../../../../../src/types'; +import App from '../app'; it('should allow different locks in different DragDropContexts', () => { let first: TryGetActionLock; diff --git a/test/unit/integration/drag-handle/move-throttling.spec.js b/test/unit/integration/drag-handle/sensor/move-throttling.spec.js similarity index 93% rename from test/unit/integration/drag-handle/move-throttling.spec.js rename to test/unit/integration/drag-handle/sensor/move-throttling.spec.js index acd0d9ff93..ba98698953 100644 --- a/test/unit/integration/drag-handle/move-throttling.spec.js +++ b/test/unit/integration/drag-handle/sensor/move-throttling.spec.js @@ -8,10 +8,10 @@ import type { PreDragActions, DragActions, Sensor, -} from '../../../../src/types'; -import App from './app'; -import { getOffset } from './util'; -import { add } from '../../../../src/state/position'; +} from '../../../../../src/types'; +import App from '../app'; +import { getOffset } from '../util'; +import { add } from '../../../../../src/state/position'; function noop() {} diff --git a/test/unit/integration/drag-handle/obtaining-lock.spec.js b/test/unit/integration/drag-handle/sensor/obtaining-lock.spec.js similarity index 97% rename from test/unit/integration/drag-handle/obtaining-lock.spec.js rename to test/unit/integration/drag-handle/sensor/obtaining-lock.spec.js index 2a7680184e..e91bdf9fa4 100644 --- a/test/unit/integration/drag-handle/obtaining-lock.spec.js +++ b/test/unit/integration/drag-handle/sensor/obtaining-lock.spec.js @@ -7,9 +7,9 @@ import type { PreDragActions, DragActions, Sensor, -} from '../../../../src/types'; -import App from './app'; -import { isDragging, isDropAnimating } from './util'; +} from '../../../../../src/types'; +import App from '../app'; +import { isDragging, isDropAnimating } from '../util'; function noop() {} diff --git a/test/unit/integration/drag-handle/outdated-locks.spec.js b/test/unit/integration/drag-handle/sensor/outdated-locks.spec.js similarity index 97% rename from test/unit/integration/drag-handle/outdated-locks.spec.js rename to test/unit/integration/drag-handle/sensor/outdated-locks.spec.js index 50a4b3a15d..dd6f3d0bfb 100644 --- a/test/unit/integration/drag-handle/outdated-locks.spec.js +++ b/test/unit/integration/drag-handle/sensor/outdated-locks.spec.js @@ -7,8 +7,8 @@ import type { PreDragActions, DragActions, Sensor, -} from '../../../../src/types'; -import App from './app'; +} from '../../../../../src/types'; +import App from '../app'; function noop() {} From acbf929394850337bb18ee6c648df7b9eabe3156 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Fri, 17 May 2019 11:11:43 +1000 Subject: [PATCH 057/308] renaming folder --- .../drag-handle/{sensor => sensor-marshal}/click-blocking.spec.js | 0 .../{sensor => sensor-marshal}/lock-context-isolation.spec.js | 0 .../{sensor => sensor-marshal}/move-throttling.spec.js | 0 .../drag-handle/{sensor => sensor-marshal}/obtaining-lock.spec.js | 0 .../drag-handle/{sensor => sensor-marshal}/outdated-locks.spec.js | 0 5 files changed, 0 insertions(+), 0 deletions(-) rename test/unit/integration/drag-handle/{sensor => sensor-marshal}/click-blocking.spec.js (100%) rename test/unit/integration/drag-handle/{sensor => sensor-marshal}/lock-context-isolation.spec.js (100%) rename test/unit/integration/drag-handle/{sensor => sensor-marshal}/move-throttling.spec.js (100%) rename test/unit/integration/drag-handle/{sensor => sensor-marshal}/obtaining-lock.spec.js (100%) rename test/unit/integration/drag-handle/{sensor => sensor-marshal}/outdated-locks.spec.js (100%) diff --git a/test/unit/integration/drag-handle/sensor/click-blocking.spec.js b/test/unit/integration/drag-handle/sensor-marshal/click-blocking.spec.js similarity index 100% rename from test/unit/integration/drag-handle/sensor/click-blocking.spec.js rename to test/unit/integration/drag-handle/sensor-marshal/click-blocking.spec.js diff --git a/test/unit/integration/drag-handle/sensor/lock-context-isolation.spec.js b/test/unit/integration/drag-handle/sensor-marshal/lock-context-isolation.spec.js similarity index 100% rename from test/unit/integration/drag-handle/sensor/lock-context-isolation.spec.js rename to test/unit/integration/drag-handle/sensor-marshal/lock-context-isolation.spec.js diff --git a/test/unit/integration/drag-handle/sensor/move-throttling.spec.js b/test/unit/integration/drag-handle/sensor-marshal/move-throttling.spec.js similarity index 100% rename from test/unit/integration/drag-handle/sensor/move-throttling.spec.js rename to test/unit/integration/drag-handle/sensor-marshal/move-throttling.spec.js diff --git a/test/unit/integration/drag-handle/sensor/obtaining-lock.spec.js b/test/unit/integration/drag-handle/sensor-marshal/obtaining-lock.spec.js similarity index 100% rename from test/unit/integration/drag-handle/sensor/obtaining-lock.spec.js rename to test/unit/integration/drag-handle/sensor-marshal/obtaining-lock.spec.js diff --git a/test/unit/integration/drag-handle/sensor/outdated-locks.spec.js b/test/unit/integration/drag-handle/sensor-marshal/outdated-locks.spec.js similarity index 100% rename from test/unit/integration/drag-handle/sensor/outdated-locks.spec.js rename to test/unit/integration/drag-handle/sensor-marshal/outdated-locks.spec.js From 7d8e1b5ebb13ea9e24c161fe39b484fe82b0ea04 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Fri, 17 May 2019 15:01:36 +1000 Subject: [PATCH 058/308] using inline images for snappy times --- package.json | 1 + .../sensors/use-mouse-sensor.js | 2 +- stories/src/data.js | 14 +++- stories/{assets => static/media}/bmo.png | Bin stories/{assets => static/media}/finn.png | Bin stories/{assets => static/media}/jake.png | Bin stories/{assets => static/media}/princess.png | Bin .../drag-handle/mouse-sensor/cleanup.spec.js | 41 +++++++++ .../mouse-sensor/start-dragging.spec.js | 78 +++++++++++++++--- .../drag-handle/mouse-sensor/util.js | 13 +++ 10 files changed, 133 insertions(+), 16 deletions(-) rename stories/{assets => static/media}/bmo.png (100%) rename stories/{assets => static/media}/finn.png (100%) rename stories/{assets => static/media}/jake.png (100%) rename stories/{assets => static/media}/princess.png (100%) create mode 100644 test/unit/integration/drag-handle/mouse-sensor/cleanup.spec.js create mode 100644 test/unit/integration/drag-handle/mouse-sensor/util.js diff --git a/package.json b/package.json index 9b01bbc59f..482ffe01f3 100644 --- a/package.json +++ b/package.json @@ -133,6 +133,7 @@ "stylelint-config-styled-components": "^0.1.1", "stylelint-processor-styled-components": "^1.6.0", "testcafe-reporter-xunit": "^2.1.0", + "url-loader": "^1.1.2", "wait-port": "^0.2.2", "webpack": "^4.30.0" }, diff --git a/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js b/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js index d59791f5fb..0b8fcd8afc 100644 --- a/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js +++ b/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js @@ -337,7 +337,7 @@ export default function useMouseSensor( listenForCapture(); // kill any pending window events when unmounting - return () => { + return function unmount() { unbindEventsRef.current(); }; }, [listenForCapture]); diff --git a/stories/src/data.js b/stories/src/data.js index f167807b55..1ea0a19be9 100644 --- a/stories/src/data.js +++ b/stories/src/data.js @@ -1,10 +1,16 @@ // @flow +/* eslint-disable import/no-unresolved */ +/* eslint-disable import/no-webpack-loader-syntax */ import { colors } from '@atlaskit/theme'; import type { Author, Quote, QuoteMap } from './types'; -import jakeImg from '../assets/jake.png'; -import finnImg from '../assets/finn.png'; -import bmoImg from '../assets/bmo.png'; -import princessImg from '../assets/princess.png'; +// $ExpectError - flow cannot resolve the import +import finnImg from '!!url-loader!../static/media/finn.png'; +// $ExpectError - flow cannot resolve the import +import bmoImg from '!!url-loader!../static/media/bmo.png'; +// $ExpectError - flow cannot resolve the import +import princessImg from '!!url-loader!../static/media/princess.png'; +// $ExpectError - flow cannot resolve the import +import jakeImg from '!!url-loader!../static/media/jake.png'; const jake: Author = { id: '1', diff --git a/stories/assets/bmo.png b/stories/static/media/bmo.png similarity index 100% rename from stories/assets/bmo.png rename to stories/static/media/bmo.png diff --git a/stories/assets/finn.png b/stories/static/media/finn.png similarity index 100% rename from stories/assets/finn.png rename to stories/static/media/finn.png diff --git a/stories/assets/jake.png b/stories/static/media/jake.png similarity index 100% rename from stories/assets/jake.png rename to stories/static/media/jake.png diff --git a/stories/assets/princess.png b/stories/static/media/princess.png similarity index 100% rename from stories/assets/princess.png rename to stories/static/media/princess.png diff --git a/test/unit/integration/drag-handle/mouse-sensor/cleanup.spec.js b/test/unit/integration/drag-handle/mouse-sensor/cleanup.spec.js new file mode 100644 index 0000000000..460614e07e --- /dev/null +++ b/test/unit/integration/drag-handle/mouse-sensor/cleanup.spec.js @@ -0,0 +1,41 @@ +// @flow +import invariant from 'tiny-invariant'; +import React from 'react'; +import type { Position } from 'css-box-model'; +import { render, fireEvent } from 'react-testing-library'; +import { sloppyClickThreshold } from '../../../../../src/view/use-sensor-marshal/sensors/util/is-sloppy-click-threshold-exceeded'; +import * as keyCodes from '../../../../../src/view/key-codes'; +import { isDragging } from '../util'; +import App, { type Item } from '../app'; +import { simpleLift } from './util'; + +it('should remove all window listeners when unmounting', () => { + jest.spyOn(window, 'addEventListener'); + jest.spyOn(window, 'removeEventListener'); + + const { unmount } = render(); + + unmount(); + + expect(window.addEventListener.mock.calls.length).toEqual( + window.removeEventListener.mock.calls.length, + ); +}); + +it('should remove all window listeners when unmounting mid drag', () => { + jest.spyOn(window, 'addEventListener'); + jest.spyOn(window, 'removeEventListener'); + + const { unmount, getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + // mid drag + simpleLift(handle); + expect(isDragging(handle)).toEqual(true); + + unmount(); + + expect(window.addEventListener.mock.calls.length).toEqual( + window.removeEventListener.mock.calls.length, + ); +}); diff --git a/test/unit/integration/drag-handle/mouse-sensor/start-dragging.spec.js b/test/unit/integration/drag-handle/mouse-sensor/start-dragging.spec.js index 421397735e..c67dfb1c8b 100644 --- a/test/unit/integration/drag-handle/mouse-sensor/start-dragging.spec.js +++ b/test/unit/integration/drag-handle/mouse-sensor/start-dragging.spec.js @@ -4,18 +4,10 @@ import React from 'react'; import type { Position } from 'css-box-model'; import { render, fireEvent } from 'react-testing-library'; import { sloppyClickThreshold } from '../../../../../src/view/use-sensor-marshal/sensors/util/is-sloppy-click-threshold-exceeded'; +import * as keyCodes from '../../../../../src/view/key-codes'; import { isDragging } from '../util'; import App, { type Item } from '../app'; - -const primaryButton: number = 0; - -function simpleLift(handle: HTMLElement) { - fireEvent.mouseDown(handle); - fireEvent.mouseMove(handle, { - clientX: 0, - clientY: sloppyClickThreshold, - }); -} +import { simpleLift, primaryButton } from './util'; // blocking announcement messages jest.spyOn(console, 'warn').mockImplementation((message: string) => { @@ -249,4 +241,68 @@ it('should not start a drag if disabled', () => { expect(isDragging(handle)).toBe(false); }); -describe('cancel during pending drag', () => {}); +it('should not allow starting after the handle is unmounted', () => { + const { getByText, unmount } = render(); + const handle: HTMLElement = getByText('item: 0'); + + unmount(); + + simpleLift(handle); + + expect(isDragging(handle)).toBe(false); +}); + +describe('cancel pending drag', () => { + Object.keys(keyCodes).forEach((keyCode: string) => { + it(`should cancel a pending drag with keydown: ${keyCode}`, () => { + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + fireEvent.mouseDown(handle, getStartingMouseDown()); + + // abort + const event: Event = new KeyboardEvent('keydown', { + keyCode, + bubbles: true, + cancelable: true, + }); + fireEvent(handle, event); + + // would normally start + fireEvent.mouseMove(handle, { + clientX: 0, + clientY: sloppyClickThreshold, + }); + + // drag not started + expect(isDragging(handle)).toBe(false); + // default behaviour not prevented on keypress + expect(event.defaultPrevented).toBe(false); + }); + }); + + it('should cancel when resize is fired', () => { + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + fireEvent.mouseDown(handle, getStartingMouseDown()); + + // abort + const event: Event = new Event('resize', { + bubbles: true, + cancelable: true, + }); + fireEvent(handle, event); + + // would normally start + fireEvent.mouseMove(handle, { + clientX: 0, + clientY: sloppyClickThreshold, + }); + + // drag not started + expect(isDragging(handle)).toBe(false); + // default behaviour not prevented on keypress + expect(event.defaultPrevented).toBe(false); + }); +}); diff --git a/test/unit/integration/drag-handle/mouse-sensor/util.js b/test/unit/integration/drag-handle/mouse-sensor/util.js new file mode 100644 index 0000000000..f2a31e5d75 --- /dev/null +++ b/test/unit/integration/drag-handle/mouse-sensor/util.js @@ -0,0 +1,13 @@ +// @flow +import { fireEvent } from 'react-testing-library'; +import { sloppyClickThreshold } from '../../../../../src/view/use-sensor-marshal/sensors/util/is-sloppy-click-threshold-exceeded'; + +export const primaryButton: number = 0; + +export function simpleLift(handle: HTMLElement) { + fireEvent.mouseDown(handle); + fireEvent.mouseMove(handle, { + clientX: 0, + clientY: sloppyClickThreshold, + }); +} From 730433cc1a180b70535b1fa4ec318749cd49d5d7 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Fri, 17 May 2019 15:39:09 +1000 Subject: [PATCH 059/308] adding compressed assets --- stories/src/data.js | 8 ++++---- stories/static/media/bmo-min.png | Bin 0 -> 8366 bytes stories/static/media/finn-min.png | Bin 0 -> 7564 bytes stories/static/media/jake-min.png | Bin 0 -> 3932 bytes stories/static/media/princess-min.png | Bin 0 -> 11240 bytes 5 files changed, 4 insertions(+), 4 deletions(-) create mode 100644 stories/static/media/bmo-min.png create mode 100644 stories/static/media/finn-min.png create mode 100644 stories/static/media/jake-min.png create mode 100644 stories/static/media/princess-min.png diff --git a/stories/src/data.js b/stories/src/data.js index 1ea0a19be9..3378776a65 100644 --- a/stories/src/data.js +++ b/stories/src/data.js @@ -4,13 +4,13 @@ import { colors } from '@atlaskit/theme'; import type { Author, Quote, QuoteMap } from './types'; // $ExpectError - flow cannot resolve the import -import finnImg from '!!url-loader!../static/media/finn.png'; +import finnImg from '!!url-loader!../static/media/finn-min.png'; // $ExpectError - flow cannot resolve the import -import bmoImg from '!!url-loader!../static/media/bmo.png'; +import bmoImg from '!!url-loader!../static/media/bmo-min.png'; // $ExpectError - flow cannot resolve the import -import princessImg from '!!url-loader!../static/media/princess.png'; +import princessImg from '!!url-loader!../static/media/princess-min.png'; // $ExpectError - flow cannot resolve the import -import jakeImg from '!!url-loader!../static/media/jake.png'; +import jakeImg from '!!url-loader!../static/media/jake-min.png'; const jake: Author = { id: '1', diff --git a/stories/static/media/bmo-min.png b/stories/static/media/bmo-min.png new file mode 100644 index 0000000000000000000000000000000000000000..27e97df4c070f13fc51c69d34d1801b44a09ad4c GIT binary patch literal 8366 zcmWk!Ra6x00{nJaVCfFYRcT2n=>3P zEOd>nbWLiTYjva*E%i)XM{7!&8=S{#JVt6QbWL4H>Rd*vir&_b-z~e0)lc57xsBAA z=^0lyRaZ6FxsO)c=$N>T)t0_%^f~C&lz!$p^2T|j#(TJW@^10Dw$a-U?>$BuyoVdi z3{1!Fmc08b9L8&We)THrYJ2?dZEUEsZ%%P;%~O$(3*PVZ8>%+YH*xLibbX)UGg#~M z@q^<;z0+7Tg}i{vMAg^p#q!2F9d#{}7=MqhVz;sK+1nM*!3Mq7EZ3$y-|ja#Mfq0{ z%b>o7fZ>LM`l`x?DotGz|KDF;R}}Z2&-j0M z2BoBs4xjU?FrUraa!oZ20(N?P2EVxU78|K(m(^4_PLwAWdX=u`?U41R zR8>yNzpdTY(ZQiIz2aTt^O^+v+_+yj7IqVjS{|OEm9GlsTfGKbGV`+?z1)rSqm$E9 zED}(*DQPCO*%8N=hEr(K_{ znj9EV-{?|NC9SFu5E15BQ1o)3au`pS#X@O#Z&a#O!|L@5Rwa~2Q=mt1fL?m$r`7J9 zdumGAh6#1WqUxHD+Vt=r+ToCg3I{xwG&k~HYO@-s&@IVSu8Fm%C|mv}d%epU_`Y=N zeBMRHOf{m=+c_TADgPO67Ev$4sO-PWV$r z37dYc((DT3ogFfc6s7zpg++tqx{2C3-{M*}@JfD~Q>PN~M*eh5EjGa;P1E_Ybr|CB z-p3)eSJ^x#t0L3_G@L&*DZjkqI8qCYVd4F`Ceho{K4$u7-#AvBa|!^)ugdbWdI3uz zIg{>7Ce(wBEcO7#cD6uB40Ls%6y%Xv96Fn^fsT>n^&RS>LayxUJaB9>+;LUxN+%_K zeV)CIQtp;rUQP2cE*%=}yAHAKHD~bj`#g^2Kf2=7Nk006M%Kzj#}WUy{(bVrHR7g> zu+cZe=LU~s&;L1xo47CYI?-&CTlKu3swuYoVQ29(P`Y84ZjydHWkOYwk7$%5hseSP zcU`aCJXUI1*;XS{NPXnO~_CjUJHRhOjp!U!i`o zOggIe3B74%ymH6|m4=fb(dRvA!+y7`_p*M<>m@`slf zRmyWWH!UqU{)YcVgsoOle7OqiCDSFF?(#8QS)#+9y8Rimwx0~yKPZW}JSGMcO?sXz zQ~X&yQ7?S&e(!$!HvH|I0dLOov+<~e=K6{8afS8Gmj?%pjns)JC(9o$&D`){_Qi#T zg(4h#i=wiC8TBYhrEYGz=ksp<7DP^~Y*y~oZ26_DjDM)CFWiJpoMk`$%KJ&hwx#8t z$al*hG6xnlv27m6o}KO)PB}PiP@vXZqba$qxH8CN_-BJ(4V+^O_;nOQc`9^TI8_PM zT3T9yTRI3bZIboEC7*ry;@IpwFKB zG$+xlykz9v*}JbUJx`nX?;*96G$Wr~dWY{mc+W5K43%P}WL$iEfZ(L&_CF)}#bfF6 z`%Wk@L^YBas9VlGmd6 z;1Rqeon1)i(%@5pp)06o;Lk}L{X*<6fvPun@e zJaFrU+DyKXlvaFGUN`N~N=qD9E?-n_#LdLWDD}|edv|x}YRu2D0u5D12W2hN%V1m^ zaER<}xATzwab~b0(JubSJIFPG@*&yreQPW-nI0>x(@QJVLv|O`%EJMvX?@Md#$&FM zyEi*qN9lM>n7~iITAOAGM3Ao}YhJAJ_7l&$p5X6OrU7$*G6`T_hO zdtJer(iaLM+>nb^H}+5DL%Z}tj1f)oeEeL+C%2(q+4JDhWK;1ScE&OqTh#XbQ<^f8V^wg(Gc!_bp4c zstvHBiMu8xhoa>Q#Y%}VGe}OM>?thW36!Fb#s^vH{o}TOMDd}08%Q!Jh6xAWScCwi zvrbKJ$GJCME;k!u{ zNSKF&TwW6Gd{B3f^$85TyytVd-e>b5vdb$gIY=I+ghOW4DI&#vz&rct6Mg>Q1d~NT zf~4__+E3Mxk(83w`1|r;SF!6dUmkzF|izd4Z! zo(%2kGIgR>vx6Z8%y}!11r#MQ=Qk3afB%wg$2Uk+f1AIDdN|N;{i|eC^tuy^&_8y| z<8Nv)-_Kr3D_3Ih>^ByA^yB!O+OG%p5G1LJW)TwJC1|9%-Y>dx6~sLtU@XG80=t~2 zC@ent$9U_z5P>zA^JuY|52H#9A>;=r(vF?Fdx4C?RJy#y%w211NxGm~TynSvGo(x6 zPZ&|!t*5u{ohDBd4}R4hIYM@hj{qv$jyDnc;NUrcfd87JzsLZ@Z6=kfJhkW@3vUxgu$#JQP37@*GU>yigP7FY>Xip=$yMq+e>>ro&Jb>B3#hwUm zo{6U#FhyuX`3-J&Zlj}GR{m5G9|_8jVof6#wqr>{B%9Al9eK0x1d=rtAn0}CI`C|? zNdD$7EB{WF-jo{6F4)I5#*&d6p;n^&fgnhN?jlz-ZJJ;{j2YCd5rum^UFeQsNIT2lx`gf)?Z-jJmU(GPZFq)0;RcBQb8TKst*-^pibvX->y?=b+l8mYOct2YZcb2Wq@?;-<&{+-wC z#B?hJ+p{7u%6Lo#+k@&fsJX~!@}b)rQ-R;_KRHL2WF|pT^G&}O`=dH1VKCD7(Ig>N1dQ@@LsqY-BHw{(47fXOg%X&#!=<9a(N1cSh?epJU%yYD(|4T8m*0_KV z8-!2{g3?+V#l*quBNKjWnn@GpqK()n<909BIcI0N;pWMqO^N<*Wac1=25B|4@JzG^Lljffudg4~c@x*|@EZry6_wh!S71 z$ht5?(VnI-e{6YxI?GhSeDhb$A7j~xCqUJZ(#&f=WphOAL$zqhQ2}B2%)csZ-x94F zpD@6dg{DS{;z|*>fH=zM^$hw(t!y;-d#uv^A7#pqq zw5GF2;KQjTlY!&iqswru6irQ<5F!Exm>1-YwLp*(c!|0imp$SFEU@Klmf`HeDvZ9d z=p$9ZvsSD_2dNPVtce0zWo8+8=724d>UBkDt$M(DMjnR7Ak|US2DJ zT5L6}*!PE5^XL2w#NY!O0-?j>ABtJw zptp_+|EU_bJR%LRj3BXv(@ZAsW{MGl2#)OD_7b~(k-zlyz>MzL3c)Ypw+LIo{!;z^ zdT)F%y!`s==*OrfHFt#l15NpLPe*EeZE?~lwH3TDBIG&n(S39<(f=7B4wFR+z8!Du zTPjP3MW2;-$;WFb3`D{!(b&}?j3}}vK#&;S%8cHRLT}!GK$A@pE*sd~<r%H$HO zTk&Lkh9U!}AXDBn!f0Sbv{gBkN7AevPnUcCmS2dG;(na~aelDY$l#0$fMkEJg+SA~ z0VW?Y!W7@@6>RAzjlhrW7N2o(FpjG{Hj>5bpS#0Cvv$CmhamD1{!sPo^UGsWUmsd4 ztvVP_{<=IgdynRQQZM*SkKO#@AH?Uj%&o16*$n$|ARIDJc3FtB*?2<9Y{3#uKxY)E zMhHB?-)XEgMUX2lmzxjc+jG9Tg@ZMGf(ck}Han=-DcfY#A`je5c-fqtfW1+g7Ub+- zLp~ra@6}#Vmxi}|nOzkBF;e2HYS zN((*sT{`KTF`D%(A_>e8H=SnB<7>NCu{BoKf!xvyW1r9O{|80+7g|2O_ zh5OOziJJMqGOwdv~0R>Nu5;=XeHwzYkhFp|NZ&SEcI)s6E%+8W(Pd{ zW~&V@E+C+57B>xs^7K04_$BP5Ky=4R6&iQj=S2r0BoFg|J>}Xeg+VC+h6i`YCw0xR z&QwuKi2ypPs54dc>mtr{&gs1X_n!Ev5)V+a(WTZp0SLL(mVl?WX4G`ahf^wF=!U%> z-mJxPbQRM@HP&hk$qCGI(fQnbdVMos;Oo3V5Z4!(NnZJB_;%SIXst?_3YMz7$nqdi z@g{3wAdksfrQ!j`vQznv$gDUey;N=!OGY-<5jzq=mHLGu2Kw#6U$flc37ll7a!(lb zTVWamM}un-Q5z;Evt?Y^G&4aOwVybeMzCt*P=$Y`ET2D9=q<6$;ma^k=EX*4xLFN> zrB!&~zD}krQ5Dm~%Fx^WEs5P~?`~QGsoBXzl)X5vTq=Ls#b|^Rc%3Vr=KK9pN>a-X ztql+N(;O`yO?>agMinRde^=3G-mliI2mlg7nidAs_fOG8^#3Fn#i|^6(b4&w+xE_t zCXV!(I3TIYN8GklYXeaJH{L;G#E_S>rqjV8Q3U=cBlgcHd5eFjdU!gPs*cj z;9ChrV;T(#ZY~sBBS+UzoEL3Fe@342e;6u(wwwafv6Srt& z9n3(S|d!4|K^l3APIFgZDC-;x*fNz#%pXW#c#WAtXgQ-%ai zeCYFDr7D6LIq_t+0@W4uadkCKcG;WztR>%G$%VbCj+w@KeslAjoqbr#$9BC}{>>s| zNNBgIkZTcM=J3`Hvus=YuFk}VwDN8?Xs(x36i1She&+D0kP*D3AVNS_l%V5U94YKv9r0&MY5*-h+GN1`s>*Vg#Y>s z{239&V#zZYo1?8(oM4XonRWitad&CUF!S^P!+YemYc9GjM8^2!kPp`UsLY~MNx`Squ>D}v5?|@>;wC(BD$_r?mPb!MZv&_;lKG~@ZJS84l{@(-3$n=3){?2DUwLu-#Y z-KN3TgJ!Cs?4zS>9OP1-__7R?3{D?4>ju=EovFP=oDi}QE~*07uFa&+=EjTF5(ctq zHb9a)JVkKy!F$cL1`sLc;|G(FiT4{GYoxO>(KUL%lDk13*%Xn$qudNl6y&pP}@Wo(W8xCrFq$Cy*iZb!8M;+=x5n7DZ7 z?abKm@kmgpyHv5kk8wJT?`WsU2Omp~KOl|zV;t`(gX1W&`FXS08Wk|W^WnFxl?I%o zcBhM1%k6?BO!e2TpKpH^atysxH&BWf?rs&5X{vqF{?wMxe7AKry2O{{d(<-+&XC6ZT>Kzkx?_>2cxnFLiZc3RIQxu{@1bUa+wBKu_xVg9_o!^VKGwy`rAnT}@(d0oN?d@^xRqcj`L+dvGgkO7x zoJH5j103MPlF1B1712R4II#g~0}n z$Nn#xeKMe3v@(uPI{d3c)XidX3~5A8)3dx09C)x{1AH)-gs^(7Mw&qiYXh`x$$}{i zS&1w{Prr%JK5lRJps;mmwBzp_%(U2dk=2Qj3T1-m7{jictxFP5)7h-y^`v3iER%FY%Qyf3NU-e_t;UAWpj?MKO?sX0ymjDGloK zXvWoJo*WXN;eOd^2C4;7BZ};dSOyF`@Ry#&*{TGq6pyd+|DEY#k0NtK* zlYxHl+zhI+G3=PQHEHPX{(j=IjQ}3f1t0G@fG_|!JE=q?rXSR63=cv}XtDynQTezB zSZ~N6DB2rymQLE;FOPS0HIp_N{O<$W5|COdisDKz>LP_y|BOkUMUf!jDS~QEBru>A z^yBWa=e}-gOW$5D1C+kxB9+UigKL{XBRxPW;^BHJP6(<&B<(pJQ3RhdT_PDWN55Bs zeTQ5!NXUG2TeaQtk}d#yyqgmT3kQ31EVl_bLH&K?M0q?c+2vX6R_6LF?5)Ivd6Vo>KBM9dxoDo|mxxtN8_42$^r=x9u!`ia1eJ7D1i zu@V;BCCmw5o;4jx`jC3`A4Wu(?OxF0@e?OYecr6zqXqeN!GZi$Dnv*q%~i=1$MT*K6R9r3SPOyj5AG*rMdGo_S5&~@>}C0007rP)t-s|I)wx z{{Q~|`uzO-{r&x*;(-B~%l`lW0GP`E|Ns8|`To+s{`~U((Z2ux{r~#r{`AoP`s)7u z_5S+p{rB4b`|$tO!v6T=pW%T1?zsK+)&BCz|J%p^-pc;*#s2r*{_L(#r6v8t0sq*< z{^pPV<%#|9!2ar;{pzFs>!|(cnf~pw0hY-A`QZJ>2>$Q9`_R1p{qX+bd;*}={oG{! z=al^X_+ldr1D(&%*(Z2n*0-TwahI?}U+Ef18U;f)g<>cf2)4-OKkfWiW!NI{~CJu*&gZ;@F zvYL&ZjDxSPuBnlQ($UfB>gtb;iuLvO?(p&d`_@br1=rWv{oZw`sHh5doy^P2(Xyg| zfPVh*tN!hir09kI^~3(^fdc{p{_mpxK{gwiefp^^9;FE}f{i6c@>x=&9 zb;rfV0DGz7zpnn%!MwbYyI3@{mm5q)V}`YTJ_Ps*}19e#k2nOvk`BR z0EV~w`SN;tc5RtetMiIl8wLH+BJIn#XeSQHs+*C`aNpL_b#!o5tt;Nl#sB~60FA@{ z|KJx`V~4(H{fGke%(x6=fmk>${MN%MRz7HHWPPn)gLzP2gie{(c5#cA!@r(NZAY)F zoiB8YRf3X%X<%1nQC2A%Wkx+XZX5so$y`lFKqMR2(zkM0O;&nHhL@g3cZQUqr}*yc zi*RU{j8&oFdS-ZC`S8z7k{PF=d6s4p>Ep&38W-o;m{509-n13Eu587CgQ$9Q_442N zpP|%>jM#7hxS=}R$ab%b0PK~R&=b!4000`lNkl%Q*$G0>{FO|x!S_nmUN?CiJ9ajhXWjD}mN zSDT&O&Q7Onn>A6BMQJvk?=P22$2L9RH(bM;x2omNPI0felWW?h-;gX(YT)o@ydany`!U@T-mW9Q>fwi!)dP+jfX<5#R}urD-cIs4xJhT?4qH!2xe>R~^TxqCLeU z05AlQmo)kb=X1h8sBuDsu-@s-iZe5RC!emFW94bK9|nn04NOg@uO z2=wGcKE;zl-^4Gw zG6C>|JhO>nip2nX>exCCBH+pbV`5o0kOBC+ z3;?7|(mXpcjz|Gam6B0Zl1t^YnhOH6U|0fd!jHfuvJ9wMMQnJuMzC|*zDr{QCPV}= zU;w!z20-UOb)avEvXCKj)8XCSaCagPvDN}SPshb4I&zYHK=1LtXj)GvlO;3(yT$i? zx4*c!xESz!20Mb<1OnA^ee|Ge+gL+vP6akf1VBS@LI7mI>C*Q6nyaY^zEn!4Loo_K zZ#p{z0GEU`P7BFs8edBM3Mrb7$J5aykKl{gP{ZkezJ2=izDwa`J0hPaIRpe2V8b?8 zNMQ;t!!eq` z1zl|LpcY{MLmd@~L_$9lDkb047B0vDoHzEW#=jXR&|-;kf?dFj!&o00{m90Bi^_lrSNx=K3_F=m0Jd zjHp1Dn&{&Kp$xu0!5V}C4Z3eKTsjnoPDJM>J-JUMtJy%3HGq!4xxKwf1%_Az-V*@y zx8FSsR>%NiSP#B<`;zS_n9%KBEPDN6qtTf5M~lftx7!^|dMa*7zg;coXb1p+_W?Cg z!*!ENB2%$VS^M|Mu+G!PDnYAKv@^=O>4UKT#bX z-hcA*<9qiW9^C%%!-s!={dxs(>UcSX&F8zkBxZ`O~}KKeQH&049^fVt|eRkpNfFS*bw_`cl!(qMoqnd3ipU+NzNNI6U4{{gODyG8`m3DkoZ6$osxKO4cH9^Zcit4``y~8 ztXC@6w(x_G?gQe(w=aWkHyX$J|HrDey)=^L;YzBYIhGZj*p_s6Rd++dFuNdK4Q%?g zn71MU%*BwqAt9JN`IHDqViY71FtZ^Bgl1SI(y%WLu7Zy9((Def^U}@C%l~oGbW;)1D+mRd5l{&TXd{Wp)9y0>=nQLXqgc#u;FT|K z(QgSPP{tV`xs=e5t0gzypXCwN7WbV^0X{whfNw!CP&`9n-eZ8?DW5MVD&g2}eRnra zR%AB}=A6ru9~#0W1LR8{AnYd(V>H1Ad>l?WD@2AY6JReQGV~d}JeA-$0|F0rU^MpQ z$$C1Q8aB`MqazJ3+3XxQyX{oE9#7;k27ZD8me9b%59!@v2G(80K+tkyEP)W(wr;@9 z0B`uvR-&+*PF0ViimED_%yE*5m&CPXSyPngaWz|yC5jdeV+;g{r5Cmk`!3W2m;#!Q z7aasV+(040Bu6P*e44J((~-k@pw?Pv@;ia?4PF z+PA>O2mlrgl!#dpWXc2S`7)8=g>nR&i!IJ`0W#4vMMY_5lL;Ofyx?MB%+dh>bYX9B z{Y!6Q^#oETxda2n5{P#`6M(=&#X`2Dh$i>AWty^joKEHtMjmi8;G&g#MY|w|fO;an z20zO~HUqH#cBHDBohTz z6#cQAQve_bSsg|)&jP#`124XRh5In7gGC#`iS0t{`~n}#DzeVay(ClVWRp%|JIMeU zM_?JOty#CQx_A-{p;`m+Zv7WhsxFC?0COG$V#idgksLeDJqE});1!htDU7fmEd~P{ zHDoIlatw;KRb3QG(3?@soCks^>dK(LPdT{9z&kP=0U^Ah4H`voLP`<1*xHx|2m;yx**d;mnLoqxNc|%(vTpb4e`NJ(_Rl_{m*zK^}hm&`1cXob1mZeARd&EA)iAScS)%OUg z6AZk4@q&z~a4asd+`PI18Pkb@5Fcx5`Vo0jOINcyvj9kA=hjUpBwL-!vV98<13&!b zEumnUBn8~Pg9_jV_U#zp(Lo#HU$g*R0)QLk5dkTQsw%roNo)34c5yTC0~w9J13zLT z2~2^%_=(vdWB}Ai!}%>PP0YaW)kkO`DtGFa16g99G(}x)7f?v>`NkNa!|`|DgBwsk z1R0*7{hDQiJpU}*)FfA;x%hHZRm|4IHh6Kfv-3rDc_#=lF=nrrfxrC~kDkGehz!=( zFaakhxo=4TBEX42dNNhj9^Rj$V!3ex5amH+Yb?RwD0mBZK<3lSR%TuaZhgwL7yt{5 z4O&rgLRu}67>qa*01$u~aDP%wRhYdv+3*##sju05^f?VhKkgbV0um|3^m4da2y|rx z04BxKiBNGl4B+`MnG3$8^Fe^Hh6))Qrw|*+9wOI)3TXxdk)56T1b9;c3iz=ocx_E) z@nCG7PUlt*H&_htDCb3h4qAYc)P(Y2Di2YvlO0Ze7F4injFsBl@^5r5YB?cv>%m-& zB@Tf{@vb)Yl)5&kUtVULT2%MA2vJn;2dl1RSw(I!ux#alCFTJT0~Kp3BB0`KbZYS_ z$<0xzU#cY9=d!y z1}cAUsyZpp;V%&?azztOlD5U^_3C-r5ZnyVN%eOu594YuQ0&n}gaKr#QzZa?SUUZ% z+5e1C<0h-9T8oo4O^Kd2hr{lL%o4{`>1x>qax*}^Z_#N=peYYq^|*k-$1Q0pfI0^7 z;i?e6DV6S0-9e|>9kxH;jSdo(bazIkfH>H5x7^&yDjg7#sSs`mLMWVI8uSH<=_ofH zKpOx6f>H@?qF=fiUB?d+<>KfL@u)ExdLuBEVa7dl;H=>o8?r&)E!3>mSwQ*h^cav@ zgV6_Ao2RFn@b@MlIY5D}v(j}Q`NE~Eu4l3mu?pP`&_)Q4hRApd0ngbkNE@uU`-_Os1slqwgdts6M7~B^WqEKKVazh$sNFTY!Wo^ z^{sckAj;>vhBL+!7ywMZ0Ac`Ws9;HeWFplQ00|Y!ZTayMK|^B+qbWOe!aUb!QP{5NDs=adQH^2VLJ*S*$@C^E*B8!YpK}2 z{d1rrtx4jnaz6_8H|SP}?RF~L9(D&8P%c3V@CGd^Bn<6|GDmNKP9_Chf}WLar-WPt zMl`$dgDimOLJA%iDcTVpj?65Sm=QS)(5V<%0bl`b92(ACDPZiLx6B7Sa>#%tT@N?! zV8scW0oaZUSqv~6!X1gN5gvuR+FT<6&MOT%0J3G90UlK-0LzJakU$1G6hKqjR^|pU zvuP9Mbl!ExNiJj!=KR!#dS&FydZ8k#Kc)wogB|%e{EWBa92EgTFl1W+9}3W>)Mtl* zQ5!tv{%`};eR}{6xn_a^10|RQ7)1uEv=#G70i?=SO^EBAI2WWNdYTF#5!f%uXqccz z{V4%R@^QiGjzMvIob{H3hm7bCp5QP>sjYAe&H@4wNvnIc-;EgcLCcip+s3O?fCL)1%RL-p zh4(H1At=ZT6=yOOU{;VwJrM;W)DV#kr#pt)=W~#=clRUYLx*L1GGvJ$gbLlqwBwJN zRH>=B{a+FA*}}hkIrLOk;GO9}vG{`J$pfBf<9zaF^e!!`{5S)uv_ zhODdEjPo8A-#{e|02UVh-@4Vlw5^5l#3rYSvM_)v;o1VDUjy_KkbDe*08#<9MXWmO z5L*ZNdM99XTDWY|x(dU(L1$6uO6$ZT47m6Py1_2{r|xr3-cG8zsU9T_wDtV*dw$Q$ zIp=YsH}~?`+~iHuJR0#3(kFl5F2n1IB(w6s`L|~Lol0ljH~!@X02mz16)OgB^0HMg z2K&RbGwL9N^;q)B!(#CA%N(aurWN(#kEc(W!Jb^PxoPq|FB_FKB>{?o2u83RORg|| z_jQ1w{`cU4dT{QKGg0xyz*FWA{ev?Oz@%Fa{~&?t zI88tp1q?~6lkvC1bD|#JAQbh_KmG&&2Nv%P*=VMzGZHBb1E1K6)5BZw z{^XrlcrrSg9F4C!M>g~ZAsDsz*QXZ)Oxuz@0F~)m7$8EJO=Xv3vEYIj9Wyr%5 zCv5JidrMwL*zE=Gg&hCCN-s_4D(QP8OyZZMSIYXT)L z_r^t|hLBc*g`3XapaJ)+gNsOF^m!(|m|cCln~28}i4~}|Rz~~&S#cT&;<4oV*3{VK zv@VjvbLyzdV*pA$5`TCP03?DPO%RZlKiP=tw=m$YDRGxZZf-5Ln4MZ(SYA)MV|RTS zR?1V^sr1?$o}KRF2r&Q!anq0~0On?8PgM{?s(m^_x2{~nfJ*}bp^p^S(y3H-cyVkI zkFivG{&nG1jwP)u_{>wfmH|$BS0xe12Vfl7m;t0{<--i_y$Cl2g&FYHEuaU|#23|P zXLC9Dg{PL>MjDP|ySknz1|Y*#G z2}HbX8dw4X89;)nxFr0V0qM$ISDL-L7~ph0$`+0en)Pb6$~ypv1bhJa2S|7g8H0`f z76V=3`*sF&sIxz`ss^?In7|~lSjJI62mlCJ3H#sKfk;#zJFXiROpK>yEXz#< zno9sN0t`VdAI@)(p1;Rq0B;446f2ftdO47(SKHVf0tvVPFbP`zG}9Z^^+#j?s>`GL zrfm8Fn0fZ>BOYJ#VF385AP{4x(1Q&jJq`m<)ErffV0r?8j7Axo0C`W32gC({P%G_k zOHs(@(T6jDtLvk7Kz|5;LLSKI zK;7r6MFTJmv+8M}ab?m#2}p3Rf~G2VsdF}c4h0d58{1jGhW-@;u)NHPlduMoJ?dr1 zKlOvU#rPV$4Sgdc>PY?>W?LXPv{mt?X4v8SELMy<_hj zw++VCAi4zrLPA$?P!xm!g#uKR4o^G#XOnB0rKQ5QHwsHjnatY!*~xzAu#~sUHTZPx z-~vzV0x*rAwyN5VI^#${bLK`}o^TBVfmjs*X_|`3sFiKo&by<82fnfGa=8q$!5%Zq3+cK zACzV5pr$EEK^o)5!8-v6#|sCDLS*b*$i3o!1OFLi=R&3;K$FNG-e71AQWcTo4q;2s z0FWCU5Dn1#npaY|@8FA8{z+v4P(WF8KE`+&L_s2UVTaSB`!+=sLbNR??!teByn+N; i1}VavLagl-VCa8I(T$0#t_zp|0000C0005MP)t-s`@ksu z{rmv{1Ox&C0002}z$*j;1^@s5{J<&z00R5LDf+!B|G_E!z$yE|EB(PJ`oAjtzbX2@ zDF6Qc_`WFm!Ycm${RRXF`oSmqy(a(0DgVqZ2?PZGzbpU4DF4ec|H&=?&NTnfHvi5q z|H&%<&oTJFDF4JO{J<;zz$gF5Ed9hZ6b%Udz$O38JNUdO`M)YA8Vvu;Hu=3P_`ovo zx-k_K5)BFo`}_MK84>luJO9TvHYE?Xj4cod1uY&69TN-qza-J6JAY*@ZdfYhv^$7w zENW00`o=w_gfwMMLlQ(K>YpJQJ zLM;vQ^6?!V9t#Q&+Ndx4y)E0^+dL@_F((*BGZ%YZAJ(coy^<=Cbu{q3JvB8pifA90 zbtJ!-IV>zLo12@txVSw%Kb?Lx?YcQhJ0Q=VE$X!~kZmCv4+Mf@Ac28_<>cguh=?d5 z6J}8+)uk@4iZR2SIm5%l+OR|Gy+=t(OI=-EudlDGgC`~?C+zF$byyscl9PISeACm@ zb8~Z6RaK&+q|D6B>bXAu&_&6iHl~L@|JXZ?KHtj#01a44L_t(|+U=T$Q`^WIz-?(= zBqLdNWQ1jdEo`u`jU_CD3%x_=Ap{OW0;G}N8zH@r)JAR8Ms57x59_}?Y5+BW8bA%822ca|9{}L=AgYo9s@W=eVQn%E(rlrmY;$8=6EhEH<_Ayvpi3$-e}^(&}KlUy5BE9U2;% zzjZS1sT*u%Y4$Kl(5zi%2W=r=eB$f5p`ml5nV1wbXbu(*^h*G$O_VM!9lyQPl@x?z zdUIrQG#+%QhX~XvGc@aN_nka<^T|vaW_r6e7RH`sq$V5JP*MaX0ce_2#MrsxX$s|x z=)%&3-+h=KSkCRqOx{jXIdjR4u~DhfCOeJ@AhWH5(u*faL=eb)>D0CB&!1nvcIuKD z5t~=yc3TDg8)Q}qP5ik*Ap)VUR}Yt;e|f(A_){1BWB8VbSLGuBu#Hxhw@+M%B2qwW zr@k01`@{JAA6>}%r26_}@cZ8K4TN@jCFgpv7K#8G zT`pdHxq$E@uOGn*sgw~$Dpg-^d34H*cD{R*vH*9+IeEhCuOTFH6%k@Wy%A{Zr=rDCIs}c^!}LO zF(|X!DHpmf*Y*-r2ckP;F_xTZwCsT;F8Ny-xh#OpHNEJf;1pMXv(%RcsJEo9B4WyC zEAS|nA{__OnbC)G= zvgiPByo9LBJ}oz)81nXS2n{8%09GrlV)^ZzzDz@&2yC$tU3ZVmqr*?GA!?_S;mQD5 z89sg-k#LMtWz@K@%Rdm+laLd6xnI{EK?%QCmqKwLW?2Hk2o0M)-lkOV;`&r|})p5wjGmK#=`-M4iORFob3 z>?1Nq7eibb8*uxEbrY;)0{ebIosAd{YzYdWrqKbAU1bx<8i7E)^=M@P9QrZ935}M@ zg1mu<=wcJE@iv={x2e374}=~b|Bi0D-z56&V5S|NJU>VzJrZrfDT>bI$2*6s64N`)ePJ<%)^NxYV30=LJ@4 zWColGi=Zf;q%5gZXl}kaGc)2)W<>OCu0PurH z`AWdWVBgc2*vgq0c|Qka)rh;+A5O)G_fyZX*D-6p$Q!4llq8XS!2XIq84st4#{ks-h#y1>RM?;WvBE?_uu z5l13j2Dv3k%JPO9?{+DUyy|(73H#zOq+pYi&t1o)^39VtE!F4yQ0f(=3leOwfwM9t z0jz^ik?JCV#D4uih$+i;>Opfueow}S;uo)67+F|&GCX;%+waIJw@s!7MOrFIj3AHK zI9Pho10U}y4Pa$${$4680W-DsX1}^NTD}PHB~5Lle-;3oA%1QoP3$+H$<3*Z-}W=f z%d)C?mOu-}oRT<&_lMQ{PKTUjYeIu_ViUxq6A;A1l-^xg_Eu-(Zih9e{!ryzE;yk3r#bcn0an9zf9y)W4( zKsP?`Sw4_=JQ|ZgkB>!~jvR}mH|uGzLdZn*-0 ztTv5ypdUwrpaEU}+IF*M&#k1D_%Mb%*dG2yAv3x^-;|?V)~bbvyr-70J$!u?mJwi0 z1PYjosH!Yi>W+E;74NVw-}EH%yMM0kRzD>Of;lTC!b{!16V$A- zT(LWLHS)eG;lK_^sG<$|O&A{-gOpVSK%jxU`St~&{W4|#p(QYa0cfk`L+=NyoZ?%8 z5|`ACi$J~pf~y@~8rE}W-k<=rzcZB23L?M;Md{m2ii85r z!O;ch8b;=#^TPpq68R6(Rzaw z1oT3KF1&1L+D#H9X*N@+r#C=rN4X`_FB??Hy07zIKZOVgoUNw(d5Scgj8f(T4S>sn zyA(tfm!lg;Rz#kiN_aGlJt6>DB4gD|fkf~4+DgR17Tj#{(#Y&hgq4~#E^H~V{mMvB zq!560OJitYWotU(vGXR=(E)JE=alx~w~eh9Tj4B48d>>kDvFH+Gl1~HTF_?ZLVu*_ zDwGqy6K|V+!B$>oe*L;$Z911c?=QU8(g7m=`4fGRgR=gIsQ$}|mf;crtUWXE016qd zs`mw|XEIvo+WP=LMU0=`-W>3fB-z)0;%%o-@s0IIidR!(vvUF7!BhoclSajrIGV=*B@W?#+6VUmRRM7Ane=v`7{HAIxPzB6PA;3oN&|4>xxVSJvpRsL(P-XX z!Vdrm0C-1X7WI?{u-}SKd2H1IK(l;4$d!`7=3jQj#>ujrEx;gu@PmJq1~5d@4+7Nz zsKG1VJN{w^aziqDKMD<^fpTA^#H2)tZ%na0zokX$h7p%q0>MT_obQ9UO&7-EhKZf6fUP=! zwu=K(o&o^cv@Q;L&j)!cr}8$Z!QgNhoNzmVqkj5fqcbH%>^c#kEzdzt_7P9?Yv= zWEp}+N20At05$xKggVxG;M^vfZq$NH-sy0WuAvbzWctS?g{FUc0K)bezE%V3{pfzdCd!@PErmCz1QlB;$ z{5FEl`+1j%E*wmMIaQ)3y)-_&m4>O@a$ zXvW(M9boBMj4eFc6P-NS5`@Rw>%>58DcyfpT(`h=v=QatkGq^ zCt+O;taN&OK){c8dGF7j?{=-r?egCMfLyPNQsi{+CU*P(&sF))y{H!usN~& z_TKirw|6=vzhP)C0007rP)t-s`Pu0C z&f@gM-1f@e_S@<4k-_)Z=JJri``PLAkHPrb>G#p&_K(5$*ys1vh;{{_0;6|j>GiG-1^kz?K+tG*y!<)#P;s* z_Ql@u+~x0*!t>qe^w{P1kjC}D+4$4s_}b<9*5~LvnfK-F@{zvw=u^yTaJ(cRE$xYhdR z@AAjn^uyZxkHz}X{oC&Pio@~Y==kpP?SQxQlE(Sp?(xjs{MP65hP?UU>GF-h z@x`wK_nXT7)9dg* zo9d3c`ii~!g1_a2wAqQVr!sH%m&Warx%(Jfp zp~ujGtMIdo>rtPuOn=RJqWkPr_tEIQX_VYUkeOnEvm)B}>Spu6h1|i-zn-u1$akhH z!TP`Adn9_BB(T%C#>uF=!FWn3xhV6xSrw)NEX{ zrY0ewtgAXD5pSQ6mDN?9ShHr?s9^w*kgzc!U7SC{v;v?!e{0c>9l0Imh|gf~npuWT zvied|C_*ISRm1?)vcC`j880x|Db-zMq^D#0h}rKWKh)Gn0Q6s`SEr;H+6-OG5g1Gl zw1jF2AT_tKJU6$zXv;c?AR6>~Lw=TJnWUT$0Lc49ygJM;m{=kK0x-bHJf4A^>|}$_ zhtG)58{{~Xxw@;mnn(n14}f$cyOiYBIsP_Z*Kz>7AYo%xLYD~O+av;vtlP4+DR(Gp z)md4NW0R7S{K>^c2)@wj90hAK`E3lxSvUaLb^<_Qwq~!+UcEXyTjOUKp6AyKCY|4q z-c^IQrT{))B(yr)ir4vSeg`0q^y=#BlmTc!`Hmes)~(yJrPLvC3}eHi*45P|wXL=W zy~zbc{3iPRbvBy^0P1gddekPXE?XKpmND_=BO?J<$9hGuYa;}J=^@H&f3_xMjs|t! z1Tyof-vdBVs?8m_JMu++MT0CkKwJUs7kID{cO92{HM*4C!`6cYGWJ+utK#PNaB zqN4o9Mwg89Vksh#kl(8Fug(quz#I%|iir$Pbu9-V>H{%FJ96`>1h#C+bnzTV=jJ$0 z#95wlsraDvI7ZAjLHLLrU8 z;G^1su>=4MRqZqQf)EX-U`^(w0Od_9$tY^dY$^@#o}^Tc=b$#4ayx|pTUcv zTc4YopI@5qaEOmj0oa^X^+yL!T$}2jqND%VRQTrL$4CEs`%{7Sn3e+Q1Xq6MN^uln zB^(|iLK&m5TCo5jjn~?m+o2#!L)3pGlSK!tK|0y>bTa{#SS#$*jR;x~@3t4jj(6llx14y7Q$7Bu#y+Kg`rraGn%3Y@Ht}bc; z7-EAVjudfzeAQ?)UNiO*%dT3*@jNpyFqAsn+uh?fYPBl4Qz?$Zi-juX?0+xq`S9A! z0~S}~KtXcUY{gJ8jHSFNE`X+73zOpyddZuwfDR-Z)K%qyfJ;~n^Scp)(d4WaCzb#( zza|t668p=VtY$&r9jgZW+ubcHZAnRqQ5B}6qGI-9p|Rx458gQ0b8O=1D&D2=QwzbQ zG6Q@dz66<>0Y|FMx(J~00-(In#re}~$ovTmYqR-v{*VNqp#!=aG%+T@nyt|Z(bnN( zhujs;*|2@y?#vTMIGm@taAE$zo;NOCJk&e&alNB)eVeE;Y8x9UACUl>^0AjqA@3qU z$lRLS=yIioy1FoZLINXGtDzrWpD2S+aty%A5`cnitLfuwJ?;v7gWld^SLPL#(2-VI zGCzO*t4nXZar^s1cki{2cRDN-fb3k>0)nC<>;W=A0D>{~(u6eDAm3`Sr0S}>5(q#w z)J$y(S~Z4cBG81qRt0p*>Mz)Ebh6p)bQU&TxKMZz<9uxV_sa*D_Uw6M&pRjVZpdJj z;K8XuhD^p;T;=&ABboUDUI2jA8+&)`bF)>D3BgELS60?W9N^WA4J&Q+le26IK(fXg z(wT{3Z#Xd6Un{RX)HD12mk&NXxi~Iex_$eNH}>q=^Pr-(u(ogFC@d8n*b-`1;9ZRv zyg~tI!RlQRQ!%gC3Ne`?5Puc~P*!GObXLiVhP*IDL97y80xuN0puiL@_;_l;UNQUC zJ0ITO^9C7vr16IM(;ItEet&4ee&N{cfySW$%+A>?GGcki!NGHk8c4c0K(T=WB;~*o zlKB%8%NQ#KD9H9(%|RGR>{N1M&>i*H>~Eug9q+$*{+$m$z=V5lUpo2V!8_u3@ZjX7 z+akn=v-5fK3*B#b40$}fOTCJ!f_RFk$&tAKLS7n;I}Gl zZ8;_@5r8=ugkW%X-}%d_>32TFOnW{!`QV)|zn`tJ_w@9Xlr-ooX3u~1&V!R=89sP$ z{?Nq>^V1)z1%_42)RO&Vi6cYKF^2I2pqLDUPe=a@Bj=|C6o9UP&KecK#^^$tAgln~ zJ}QF0oILr#9z6Pkug=egm8!Io6207xp-M~BcP?B!4@JBUH8_9qVz@WlX5s3R)Gm60 z5z}wAvPo(Jz?vlqc+Ctep02Dl20#xmt2G#nx^UofCY=uQCk}_nl>OJgeu_oD^x>DY zorey={uWjisw(Jks`MpBZDC<&#rF`oFhF9lTU@kSw z>R<{Y!C;ilRoPaKfR+UhJ2q#`6*RBy;C5MKf&o#$Nva>Q|> ztx$L|dm_9*rU2qMo7t+SqID}*ZY?T@uLCQL50mMycmDFh?RU=WjcFyya0Mn;%H>KG zAyDa+O1oX2hh-^A%bWP}((OxM_LNM0-0E7@2B9m=hGc5dGzPOk_okyUzqF}oWF*%@ z0qPi?FL4*<@9KgClCy*Bt1@He8*T{4QJ2@WGy3VtcNQ+p&)YFK8S=b5ssVaEC{#*n zk@W!SJb&_yOJ7xX-wZeoV1CvVB%9fQ4=6OmEjSuSnznA;x^7(&Oe4$Uz|#4OcV&^| zkjY+MkR8o!iXR^%4wJZrSoY4)f4-Wn#Z$+IoDQYlXf$G4x!frie`ODU|KaURU$yso zhYsxIIUGJAO>6*w6&goYVtSFl;Tcdctio%c^CvJ6mf1D3a?yiu2{I8t5%mmCUAO=- zFBx(upwn(Aiio9AiNBn^I1kU@Ek# zB>-#?bMDF|00EN(zzVH5`!1BshV|mq`0P|^5B0S7wYS4LNprhf#J{L!l^0asf4Jw; z`OX5*PM5%%mr8&KKq;Mk>$;WewiE^Ux&e4utIHB32^b8)T%v&D`Mn6hFtVMqeT9`V zAA~BPs<*$tmyCXVU(mNe8!*E1!b6>3UE1^D+8U2X&8cHLKsEwdP@#04G*+(LS}p-p zO8_ab&#tCL31qrr0PM~?Q-@~r^zuc3wDt*Kh?wbMBw_I8Lie#=GQUb64jU`ae|hPH zcW$;0wW%3JTmpz};g7}vR^l^%{c;JUr~ACo(zp)=C6w$q!wekly-=YnR4xK&o5wpW z8o$y7ErrGA0MAKDuJyrfBb0$`XB1 zSOIaYB!(bn&Qo{wy6uo}T>X{C=C|t=eCyr-Z(~_bom#(PaH<#Hf>W-ltSp&5x#z(t z3kl^|E9^NOv5-H9D3htRNu2+WE7vtO@&f~~g{+SR1Mmk3K+qC%q%(Io5QDG^1#xUz zhntOhEdfw20&EgmpZ@f`gBpC+#MbYcxK^oxaPX3aFF&~SpKpg)VoC+-h3k@%M19U* zDT!c9zJnVWU@e?2Ie|pZ)eJ&MpOtUiF_H;el$r0~Q*Ch3FieX2!G5=0D__(BeG38D z`_unC+G`SHmQkhFwRyIeaD z3|>1_tAvTvHhjP5&uv2<=J!KzhW8P>YfJR{Lj9K?e7aNp3IHaFY78cU&jJDPjhRsSDh^6o z?MuS>&2W-O%bEco&hKp-?`yGZDL`*!8WuutY(HUxlOW!bCZt}b8ho>ry z_V8@U#gm`zeAWDTXEn(+wAGQ!l3M0yA{)Ob7i$@@#{4G?j`N3BB!{GNC){T*(GvwX zHaECcN~hd-c=CY5ljQ0UT2qnkQS*W4|MPOM-{RPL?pm#J{zP8MJD+-%%^R21lAl?| zQN9&X!@8Bo!7x^7L(w6SAfBcXXe2+|-cq7eYL!kSLPG%9-3`4*1D>3!pMQ8+)w-SC zDYzUrqUt)rGhmEXtI(IK~PvFznlO}^oRg* zcXzv64*+9B|C+#14mRT7e)#Dd*@0Cu3|n22!!bJ1+wPv7uRZ^t`XxFL--nWvIZ9B+ z@QtGB&5v4R^P6HKAOLiB4FRxA0DXN%y;80>);15eI&!wp{qQ%8M^z5&gSxt$B-xF* z$x62o0R8{1SFLD6Ot9GN&8S7th**>rK(H~}9xo~otxYp6>u|h;G$~Djv27Lngy~R^kz95E+<|ftCR9H}Zusts3uh=V=iRIGx6xZnvI{ z7HA<%QJtu0ZEba|kh=Z&_3=WL%2<*Y);9DRQbhm1?XI>q6Tdw)Iyzoh34>__Ku_}u z5kM#h0AbkDh(sPe00O0%ICD!|g(ddM1PORU8XX}(K(_20Ve8*+cIQ>v;d|Tl?&jt+ ztbpF#K6#*xcO_9R!y#AI`pNzG$MY)TI?DCoS#3l2Q4xS25e4wabrJwpJ$D3|%Ul_; zM;4A31WwHf49OaVbUG5CnUy}A?=Nt3$1LPnf*#n&aB>PJNG$cgVS#_@f2v^#j1VC@>Zr3~Q z`ZPCAWD&sBDtt3KIz8j=w3DqW*DjL)7g$FH0Bd7HPC!kMJ3b19u-RC+A8^u|NG!@A zW)Ec(04f2NS#mVNA#oE|3jm!s2%FQuq8;m}_1+1zzZU#ZeV`f%R0_HbBL z(tT8>pjOC{kA(Lhw-hzfXp;aCTdan8v?(0S2W&D^E36-O=IA=~JAjhzHR~k+c!EhuHhLh_pW=I*b8Hm> zG9A2$A$CY(g$qwY0c`C2k^HLseBOlAFK3wQG{ykr^2+X>7HDJ|agAAS`}JG3dgpHd zkgvEr90$N+lKsEv2*o5|X%YcO93BOe;?ocb`0I$Gc!mO*BO@qR1WY*upjWvF5KilB zZc)lrX)T8*P%;yaPu4msUI8$;$s^ixnaqN^4zvnqA1mg}Vj?INm2k()nixDs0K9Gl@+_&VC4Kq1^J^^89jFlsZ{n zZ?=?A=R8CC)C^?u*(n5|E*=jv9#?5*K9+#!fS|Va5&($biG;|Bim~2ax3)4IMv#7> zs&#b2ZFh#l^01gVAk)j`DgbO^=_U;16Ll`n&kY2F*Ch4(Ww#v>P*l;&-(K6TnT_N z#~gjLNpk)aEJ+=*Oen%cP$Adk#Jy)0X+@rsG`4<>0u(yOZ+(6L{{3?ky*&+NL#itO z{??!V^w#IU+}gi?EN^W8__&;ifH2gyR}B0XfQthJW4<>_O3!3E9fNAO&zrr}Sd$Y& zv>f$_%~Z;;bMn0-XRqSp$a}r|3S@=y3J?DS1Rp*5{Ik2$7#jw4v>hyu|tQ-^|B=p1bQ$MBdMkU zhLGu1ffP>x7IuGq_Tbu#wfpw1%{X(pR@8Yag16ox6hHp!;OR40j=VPm!DyBCo+&fk zQhcC_JU^CTeK0mZ03-=;92eVpL;${^z`Y`>rxH+&O~1I8v2X3>wQJXAY}~SKmO~?=6x9lk9=LbPzW>8)2mU72A>e*<^}>*q^!;2u*L&0DH_MDUaw}g z-y9|WHN$bgmq6#t-LnTz9ee9fPagmH(ML}nU%9s}BO~L?i}%M! z7Q3N$1>Oj6?BF{nz)*A%ARhmSM*^ubF+qP)s#=zdN}i;c-@AJH^x8Op zksg8bd58Y-ryq~}>z7}ST-}Gi?%Q|z%4a8#z*aUi*Cf$5LL!4FHGV-yN(Vt=i{s%> zJXlBGT1ZWmAzUwaAfDhXF^`PCh+jnj#@@R^RDdqQ=6#pbRGnle7FPcK$uIXXwrxIr z`ZUpxgGbKIz#7%|pQ>k(>Z~Iz9u`0_Np0CA;bSJnSZRI*sR&u&bOgN&UgT;V*;+~} zEg}F3)U0H^_*G8WfyuMm@O&8(z?&p7DwjjwnvYyPxEa6Qwr$(ywfg{Z<-PH+(+Q__ z6*28~G#h}U5H;{sG)yLS2=UoWq6=tNWJl7O^?^przqQH5)`^lcDbQ5JFJ6y>Qd*30Gv)bIzk#>Z@W|JeD6xezKo2`1Yj)zs0af< z?@W7h8~M$_t5*+_@1Xf^+TE#iBX-Maivj#LLn0%tu>_D^P>kjwrjdHc$k4LlF5rYk zfRz3oCs9JourGsZ;aX^6I1lOLyl~oOto-I}2hW~8xS33!0STbK?{0p()z-$p1^}_Q z)ufXE5~~RSj%Lzm!COo*0MyJwVv@wrO8~G9GxqI+B_aU(`Y3>WHe7l5-OUHr5`Z%z z0CWJ`z}Pn~tE+n*fcW&fY+bfhhx$1hHyDSjkz`0?hDgmtt??XDSwH*%rSr2Zo9|_a z64mworm=o}OsT5GlJ9>1#gQxbu3Wit_VJH@pRZ6hRF0h+J^tv~!=Jwx zeR1{3k$=7T^_O3#$2zHV*g3T;l^=MG4hZNaD=47tHj!#{kpO`BCd-ciQ!H0({kTsK z7tCI=aP!vV&mTX2{P~lQKKkc;I8S+E^!T%?s@$ro?SY4*zy69}Klz**VscbnThsb_ z01^g^0N!fa$HxS7)XVb{2rH$tgB|YH17N=8V&(fkz9pW1pZ`C5xUk~>%k5PG$Frvo zpXY8b|K{IMo_zk%iFG!t_Xp{jE2k&?$0|A2_Cm8$K(apRag-`rPCDUwrcG)qftY z?3uas!%x?zCk`Ec_sZ25k2*?!KECDA_5F4U0MYa7BoNEV5(kV8M#|94#LzZP^Q(#s z3;@Q663XY(4%P0y_Ql@4&p*p}_wbnAIJO@jdR5Pxn@?xFcvQakn{S?vUUbq(QJ&U( zqMm&nfLe+aQ8Q4RLXk33H$)Xb5;QSOqet4r$dC+4OPV$ew-lB0mdUxH1JD0`<=w*- z6~@X1WDHTdJ$5Ez?Y$QdTQ|LYbp4_fBWe2pP-l}Z=BPRKQUEsQ46&rOBas3?1cV7S zn}Gp8Y9~^nrWGX{bWG5&M`@gx>sWu|ha&_)rB#WpVIKhYUAf;l5IjCN;Z})N%)SMV zZEKT)J!Sx55QzYmtwYTHhDL}0J}arCAall(dW=UQ0gU&?08}dX#9V8~v-{uOYexeI ztzfB8NPTy6#=bKzzS+L%;ScZ6bcz5yy=HY=k_5mCmfWgb7q`skV&-v(dZn^%#vRT0}%8yXQ@~2^ru0aAT(P#fqm$pYrx_6fPzh*_#3#sj?}y{1QQLi(LV}E9@;lV)Ienx8F2--0B2u`U?pd9ECp;TLJ=Lsruyo-F@9|0I0-rs{Qc0+s@iN=HVTO8FaJX$kx` zWb?@i>>yvN&`>P1tl!nEtw__`MICTod)d188vxMajWjA?3%8y9`qrc8FE?3kjGj;d zKyPoKG|AeeekR_9gimQzD@j*E25k0<70JoT-Y8m!M>5ONJ}*ijqNB<5*noou_jP?j ze_9-%bN9JN+mV_h0Qxwvqb1V9rULSyO3jzGs>V*u79319~;DG0=W?l{$73jo@rp~~BRR|IHoU(8ObW=sD5 z(OW;>otWGD&C~qWThlY7xT5du-IW@*1Ef~PbAs3-t+s}eSD-E#jOIcFjUAgp6d*k% zqEoYcl_Ub}S^~g!oN8VK=$yI!^Ckj-3gY4tT&(^3M}PW!y7&0rpC6WE3DDW&M18M+ zxRsSHDOSd90Be>2g6m;&EFA+rg9zaBBS}ilzz(7@sKCQAZW`{ctuRUeD%I%o)a_r~ z+Kn3O;u2ge>H7x&YDaHW5jyNc0CYN!4YmrecF8C}GBRetj;h>_I0-+8#ZL2qAN zgZT^pc=C9<^2GJI(d#G3sev?+zUA=bPK#}6x+UIYh>$Otj1?OUA)OcV8_-3Q;tSQq zdbk}@yBh_lXh%LuMH0c4smMpiF}we8W-o=zXe#`goZ_uc|z@IpYZSe`RGnb*~~hf=!-Rdt^B| zs|yMs1FKDlTD^%iHE8G4kztb7311aRXK1W~ZN*d%U4pP&uNu2`e0#KYbgH4d#BE#z zu)8ZSA3S~ayK(4>vqCN@%s`ozH|LWcFhZum2)=jD06;uLMA=BTWmEHs2MAi8T@c89LhPir5VexcKlD`|b?( zYiSnRSz)Zj3h1^UpRT>!qhBHbWnrEi=i%mUM{do?oiPBpQ?KeRb}ep0ma*_25Aso_ z96#o#9k%4Lal>Py2!Pt9mB9&U!@A)42_XP(_KP-1UXfON{gX|bo{f&RUoKfzBT>n5 zAa7oK1{)z9-3bMWNs}va3h ze!JU=Y7NYbb86HvwEZP9!>G{wW*0ySNvaxZIH8qv0LbWf7+%kF*&7i3X>=AUfhva? znTe23Y_vBNuUKIyF6*j^1CX9584?IVY|9`3HryltstRY}%-u&-u4i*&?WA=38h~vF zu?FE709ro!-u4VdaZ59T>yN=eX*pc*sBSg7$i1T7fdCBjlE-oYm?Eng?g{}o(Ptz! zKv_F6Cpd^RcC5#3mn&ZhfQ0GUuXisj0=U~NlA()E-(6#s~02EYCZ-x@cV*q+p z?X|(9I7-N0TJG)Yd2GWsORz}QCbwoqzH#r5ThKFMS=_!c@T=${9_F3fKDal$T`m&}D zD^F-)gddSyWCdC(rSRal06ikWT3F+;B>>8v-m;^Kk#uqX^x_zRfuw=`DN6uSQi_Yy z$)C~aoRuzKZca%&Iy}{&3>WS`cf3kiKQ~Px!dC$l%AGwpjtRiQZQo6bnG^A4=)F8j zR~8KEi4p*o11@pMpT0~2zQlBL`#OQ#+7_>OC#D>I8+nLA>;S7}?-$o+8oGO8VfymZ zQ1l0=1Twz6C2FCR(rF&tfNLal6Quj3CCI8yhaM)cUJj7>dw}$#;|PK)Puzc4kIct7 zIy%#ouUvvo0+2xf)*gKE{(jmAFNO2XZ<|w~1>{bJsaf==bkX!Y1ZroVX+~e+iC4dq+5>m6mk$f7iX*dp`8f&i*e7j(b@PNgM=@J0B zWZu;kNsa*w4>MHj(|-ft8}4tgLjkszKfOPhR@+W|VdZN8P_&~}#e=Ar$W?JQbawWd z>xzS@8*5M-TLM6u`qMX3g5lv9KuQYvOBT7gPkyd0zS&>vp1gl!{dzxgN3q7M3gxkyBwnyaqvl8?k^avG;shubojNrvj{grpbDO(6 S`6M_10000 Date: Fri, 17 May 2019 16:14:58 +1000 Subject: [PATCH 060/308] more tests --- src/state/create-store.js | 2 +- .../cancel-while-dragging.spec.js | 4 ++ .../mouse-sensor/cancel-while-pending.spec.js | 61 +++++++++++++++++ .../drag-handle/mouse-sensor/cleanup.spec.js | 20 +++--- .../mouse-sensor/force-abort.spec.js | 2 + .../mouse-sensor/start-dragging.spec.js | 68 +------------------ .../drag-handle/mouse-sensor/util.js | 10 +++ 7 files changed, 89 insertions(+), 78 deletions(-) create mode 100644 test/unit/integration/drag-handle/mouse-sensor/cancel-while-dragging.spec.js create mode 100644 test/unit/integration/drag-handle/mouse-sensor/cancel-while-pending.spec.js create mode 100644 test/unit/integration/drag-handle/mouse-sensor/force-abort.spec.js diff --git a/src/state/create-store.js b/src/state/create-store.js index e4c8e80994..6012f2de04 100644 --- a/src/state/create-store.js +++ b/src/state/create-store.js @@ -13,7 +13,7 @@ import focus from './middleware/focus'; import autoScroll from './middleware/auto-scroll'; import pendingDrop from './middleware/pending-drop'; import type { DimensionMarshal } from './dimension-marshal/dimension-marshal-types'; -import type { FocusMarshal } from './focus-marshal'; +import type { FocusMarshal } from '../view/use-focus-marshal/focus-marshal-types'; import type { StyleMarshal } from '../view/use-style-marshal/style-marshal-types'; import type { AutoScroller } from './auto-scroller/auto-scroller-types'; import type { Responders, Announce } from '../types'; diff --git a/test/unit/integration/drag-handle/mouse-sensor/cancel-while-dragging.spec.js b/test/unit/integration/drag-handle/mouse-sensor/cancel-while-dragging.spec.js new file mode 100644 index 0000000000..ccabffa842 --- /dev/null +++ b/test/unit/integration/drag-handle/mouse-sensor/cancel-while-dragging.spec.js @@ -0,0 +1,4 @@ +// @flow +it('should cancel when pressing escape', () => {}); + +it('should cancel when window is resized', () => {}); diff --git a/test/unit/integration/drag-handle/mouse-sensor/cancel-while-pending.spec.js b/test/unit/integration/drag-handle/mouse-sensor/cancel-while-pending.spec.js new file mode 100644 index 0000000000..7e9c25a3ff --- /dev/null +++ b/test/unit/integration/drag-handle/mouse-sensor/cancel-while-pending.spec.js @@ -0,0 +1,61 @@ +// @flow +import React from 'react'; +import { fireEvent, render } from 'react-testing-library'; +import * as keyCodes from '../../../../../src/view/key-codes'; +import { sloppyClickThreshold } from '../../../../../src/view/use-sensor-marshal/sensors/util/is-sloppy-click-threshold-exceeded'; +import App from '../app'; +import { isDragging } from '../util'; +import { getStartingMouseDown } from './util'; + +Object.keys(keyCodes).forEach((keyCode: string) => { + it(`should cancel a pending drag with keydown: ${keyCode}`, () => { + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + fireEvent.mouseDown(handle, getStartingMouseDown()); + + // abort + const event: Event = new KeyboardEvent('keydown', { + keyCode, + bubbles: true, + cancelable: true, + }); + fireEvent(handle, event); + + // would normally start + fireEvent.mouseMove(handle, { + clientX: 0, + clientY: sloppyClickThreshold, + }); + + // drag not started + expect(isDragging(handle)).toBe(false); + // default behaviour not prevented on keypress + expect(event.defaultPrevented).toBe(false); + }); +}); + +it('should cancel when resize is fired', () => { + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + fireEvent.mouseDown(handle, getStartingMouseDown()); + + // abort + const event: Event = new Event('resize', { + bubbles: true, + cancelable: true, + }); + fireEvent(handle, event); + + // would normally start + fireEvent.mouseMove(handle, { + clientX: 0, + clientY: sloppyClickThreshold, + }); + + // drag not started + expect(isDragging(handle)).toBe(false); + // default behaviour not prevented on keypress + expect(event.defaultPrevented).toBe(false); +}); diff --git a/test/unit/integration/drag-handle/mouse-sensor/cleanup.spec.js b/test/unit/integration/drag-handle/mouse-sensor/cleanup.spec.js index 460614e07e..a91e0c0e7b 100644 --- a/test/unit/integration/drag-handle/mouse-sensor/cleanup.spec.js +++ b/test/unit/integration/drag-handle/mouse-sensor/cleanup.spec.js @@ -1,14 +1,14 @@ // @flow -import invariant from 'tiny-invariant'; import React from 'react'; -import type { Position } from 'css-box-model'; -import { render, fireEvent } from 'react-testing-library'; -import { sloppyClickThreshold } from '../../../../../src/view/use-sensor-marshal/sensors/util/is-sloppy-click-threshold-exceeded'; -import * as keyCodes from '../../../../../src/view/key-codes'; +import { render } from 'react-testing-library'; import { isDragging } from '../util'; -import App, { type Item } from '../app'; +import App from '../app'; import { simpleLift } from './util'; +function getCallCount(myMock): number { + return myMock.mock.calls.length; +} + it('should remove all window listeners when unmounting', () => { jest.spyOn(window, 'addEventListener'); jest.spyOn(window, 'removeEventListener'); @@ -17,8 +17,8 @@ it('should remove all window listeners when unmounting', () => { unmount(); - expect(window.addEventListener.mock.calls.length).toEqual( - window.removeEventListener.mock.calls.length, + expect(getCallCount(window.addEventListener)).toEqual( + getCallCount(window.removeEventListener), ); }); @@ -35,7 +35,7 @@ it('should remove all window listeners when unmounting mid drag', () => { unmount(); - expect(window.addEventListener.mock.calls.length).toEqual( - window.removeEventListener.mock.calls.length, + expect(getCallCount(window.addEventListener)).toEqual( + getCallCount(window.removeEventListener), ); }); diff --git a/test/unit/integration/drag-handle/mouse-sensor/force-abort.spec.js b/test/unit/integration/drag-handle/mouse-sensor/force-abort.spec.js new file mode 100644 index 0000000000..c3478f61e2 --- /dev/null +++ b/test/unit/integration/drag-handle/mouse-sensor/force-abort.spec.js @@ -0,0 +1,2 @@ +// @flow +it('should abort a drag if instructed', () => {}); diff --git a/test/unit/integration/drag-handle/mouse-sensor/start-dragging.spec.js b/test/unit/integration/drag-handle/mouse-sensor/start-dragging.spec.js index c67dfb1c8b..8f9e726c27 100644 --- a/test/unit/integration/drag-handle/mouse-sensor/start-dragging.spec.js +++ b/test/unit/integration/drag-handle/mouse-sensor/start-dragging.spec.js @@ -4,10 +4,9 @@ import React from 'react'; import type { Position } from 'css-box-model'; import { render, fireEvent } from 'react-testing-library'; import { sloppyClickThreshold } from '../../../../../src/view/use-sensor-marshal/sensors/util/is-sloppy-click-threshold-exceeded'; -import * as keyCodes from '../../../../../src/view/key-codes'; import { isDragging } from '../util'; import App, { type Item } from '../app'; -import { simpleLift, primaryButton } from './util'; +import { simpleLift, primaryButton, getStartingMouseDown } from './util'; // blocking announcement messages jest.spyOn(console, 'warn').mockImplementation((message: string) => { @@ -17,16 +16,6 @@ jest.spyOn(console, 'warn').mockImplementation((message: string) => { ); }); -function getStartingMouseDown(): MouseEvent { - return new MouseEvent('mousedown', { - clientX: 0, - clientY: 0, - cancelable: true, - bubbles: true, - button: primaryButton, - }); -} - it('should start a drag after sufficient movement', () => { const valid: Position[] = [ { x: 0, y: sloppyClickThreshold }, @@ -251,58 +240,3 @@ it('should not allow starting after the handle is unmounted', () => { expect(isDragging(handle)).toBe(false); }); - -describe('cancel pending drag', () => { - Object.keys(keyCodes).forEach((keyCode: string) => { - it(`should cancel a pending drag with keydown: ${keyCode}`, () => { - const { getByText } = render(); - const handle: HTMLElement = getByText('item: 0'); - - fireEvent.mouseDown(handle, getStartingMouseDown()); - - // abort - const event: Event = new KeyboardEvent('keydown', { - keyCode, - bubbles: true, - cancelable: true, - }); - fireEvent(handle, event); - - // would normally start - fireEvent.mouseMove(handle, { - clientX: 0, - clientY: sloppyClickThreshold, - }); - - // drag not started - expect(isDragging(handle)).toBe(false); - // default behaviour not prevented on keypress - expect(event.defaultPrevented).toBe(false); - }); - }); - - it('should cancel when resize is fired', () => { - const { getByText } = render(); - const handle: HTMLElement = getByText('item: 0'); - - fireEvent.mouseDown(handle, getStartingMouseDown()); - - // abort - const event: Event = new Event('resize', { - bubbles: true, - cancelable: true, - }); - fireEvent(handle, event); - - // would normally start - fireEvent.mouseMove(handle, { - clientX: 0, - clientY: sloppyClickThreshold, - }); - - // drag not started - expect(isDragging(handle)).toBe(false); - // default behaviour not prevented on keypress - expect(event.defaultPrevented).toBe(false); - }); -}); diff --git a/test/unit/integration/drag-handle/mouse-sensor/util.js b/test/unit/integration/drag-handle/mouse-sensor/util.js index f2a31e5d75..7fad428838 100644 --- a/test/unit/integration/drag-handle/mouse-sensor/util.js +++ b/test/unit/integration/drag-handle/mouse-sensor/util.js @@ -11,3 +11,13 @@ export function simpleLift(handle: HTMLElement) { clientY: sloppyClickThreshold, }); } + +export function getStartingMouseDown(): MouseEvent { + return new MouseEvent('mousedown', { + clientX: 0, + clientY: 0, + cancelable: true, + bubbles: true, + button: primaryButton, + }); +} From dd9006d8d87d6a5f3f6f1aac85640e920438d91c Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Fri, 17 May 2019 17:04:04 +1000 Subject: [PATCH 061/308] tests --- .../cancel-while-dragging.spec.js | 72 ++++++++++++++++++- .../mouse-sensor/cancel-while-pending.spec.js | 60 +++++++++++++++- .../mouse-sensor/force-press.spec.js | 2 + ...event-standard-keys-while-dragging.spec.js | 2 + 4 files changed, 131 insertions(+), 5 deletions(-) create mode 100644 test/unit/integration/drag-handle/mouse-sensor/force-press.spec.js create mode 100644 test/unit/integration/drag-handle/mouse-sensor/prevent-standard-keys-while-dragging.spec.js diff --git a/test/unit/integration/drag-handle/mouse-sensor/cancel-while-dragging.spec.js b/test/unit/integration/drag-handle/mouse-sensor/cancel-while-dragging.spec.js index ccabffa842..83a8e44fbc 100644 --- a/test/unit/integration/drag-handle/mouse-sensor/cancel-while-dragging.spec.js +++ b/test/unit/integration/drag-handle/mouse-sensor/cancel-while-dragging.spec.js @@ -1,4 +1,72 @@ // @flow -it('should cancel when pressing escape', () => {}); +import React from 'react'; +import { fireEvent, render } from 'react-testing-library'; +import * as keyCodes from '../../../../../src/view/key-codes'; +import App from '../app'; +import { isDragging } from '../util'; +import { simpleLift } from './util'; +import supportedEventName from '../../../../../src/view/use-sensor-marshal/sensors/util/supported-page-visibility-event-name'; -it('should cancel when window is resized', () => {}); +it('should cancel when pressing escape', () => { + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + simpleLift(handle); + expect(isDragging(handle)).toBe(true); + + // cancel + const event: Event = new KeyboardEvent('keydown', { + keyCode: keyCodes.escape, + bubbles: true, + cancelable: true, + }); + + fireEvent(handle, event); + + // event consumed + expect(event.defaultPrevented).toBe(true); + // drag ended + expect(isDragging(handle)).toBe(false); +}); + +it('should cancel when window is resized', () => { + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + simpleLift(handle); + expect(isDragging(handle)).toBe(true); + + // cancel + const event: Event = new Event('resize', { + bubbles: true, + cancelable: true, + }); + + fireEvent(handle, event); + + // event not consumed as it is an indirect cancel + expect(event.defaultPrevented).toBe(false); + // drag ended + expect(isDragging(handle)).toBe(false); +}); + +it('should cancel when there is a visibility change', () => { + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + simpleLift(handle); + expect(isDragging(handle)).toBe(true); + + // cancel + const event: Event = new Event(supportedEventName, { + bubbles: true, + cancelable: true, + }); + + fireEvent(handle, event); + + // event not consumed as it is an indirect cancel + expect(event.defaultPrevented).toBe(false); + // drag ended + expect(isDragging(handle)).toBe(false); +}); diff --git a/test/unit/integration/drag-handle/mouse-sensor/cancel-while-pending.spec.js b/test/unit/integration/drag-handle/mouse-sensor/cancel-while-pending.spec.js index 7e9c25a3ff..614421a52e 100644 --- a/test/unit/integration/drag-handle/mouse-sensor/cancel-while-pending.spec.js +++ b/test/unit/integration/drag-handle/mouse-sensor/cancel-while-pending.spec.js @@ -6,10 +6,11 @@ import { sloppyClickThreshold } from '../../../../../src/view/use-sensor-marshal import App from '../app'; import { isDragging } from '../util'; import { getStartingMouseDown } from './util'; +import supportedEventName from '../../../../../src/view/use-sensor-marshal/sensors/util/supported-page-visibility-event-name'; -Object.keys(keyCodes).forEach((keyCode: string) => { - it(`should cancel a pending drag with keydown: ${keyCode}`, () => { - const { getByText } = render(); +it(`should cancel a pending drag with keydown`, () => { + Object.keys(keyCodes).forEach((keyCode: string) => { + const { getByText, unmount } = render(); const handle: HTMLElement = getByText('item: 0'); fireEvent.mouseDown(handle, getStartingMouseDown()); @@ -32,6 +33,8 @@ Object.keys(keyCodes).forEach((keyCode: string) => { expect(isDragging(handle)).toBe(false); // default behaviour not prevented on keypress expect(event.defaultPrevented).toBe(false); + + unmount(); }); }); @@ -59,3 +62,54 @@ it('should cancel when resize is fired', () => { // default behaviour not prevented on keypress expect(event.defaultPrevented).toBe(false); }); + +it('should abort when there is a visibility change', () => { + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + fireEvent.mouseDown(handle, getStartingMouseDown()); + + // abort + const event: Event = new Event(supportedEventName, { + bubbles: true, + cancelable: true, + }); + fireEvent(handle, event); + + // would normally start + fireEvent.mouseMove(handle, { + clientX: 0, + clientY: sloppyClickThreshold, + }); + + // event not consumed as it is an indirect cancel + expect(event.defaultPrevented).toBe(false); + // drag not started + expect(isDragging(handle)).toBe(false); +}); + +it('should abort when there is a window scroll', () => { + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + fireEvent.mouseDown(handle, getStartingMouseDown()); + + // abort + const event: Event = new Event('scroll', { + target: window, + bubbles: true, + cancelable: true, + }); + fireEvent(window, event); + + // would normally start + fireEvent.mouseMove(handle, { + clientX: 0, + clientY: sloppyClickThreshold, + }); + + // event not consumed as it is an indirect cancel + expect(event.defaultPrevented).toBe(false); + // drag not started + expect(isDragging(handle)).toBe(false); +}); diff --git a/test/unit/integration/drag-handle/mouse-sensor/force-press.spec.js b/test/unit/integration/drag-handle/mouse-sensor/force-press.spec.js new file mode 100644 index 0000000000..dd571b564c --- /dev/null +++ b/test/unit/integration/drag-handle/mouse-sensor/force-press.spec.js @@ -0,0 +1,2 @@ +// @flow +it('should do something'); diff --git a/test/unit/integration/drag-handle/mouse-sensor/prevent-standard-keys-while-dragging.spec.js b/test/unit/integration/drag-handle/mouse-sensor/prevent-standard-keys-while-dragging.spec.js new file mode 100644 index 0000000000..24c1b6233a --- /dev/null +++ b/test/unit/integration/drag-handle/mouse-sensor/prevent-standard-keys-while-dragging.spec.js @@ -0,0 +1,2 @@ +// @flow +it('should prevent enter or tab being pressed during a drag', () => {}); From 9813216ee16fff9f7153c735bfc0cae5fa4803dd Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Fri, 17 May 2019 17:10:52 +1000 Subject: [PATCH 062/308] wiring up old handle --- .../sensor/use-mouse-sensor.js | 4 --- src/view/use-drag-handle/use-drag-handle.js | 26 ++++++++++++++++++- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/view/use-drag-handle/sensor/use-mouse-sensor.js b/src/view/use-drag-handle/sensor/use-mouse-sensor.js index 140ace4cfb..419286730f 100644 --- a/src/view/use-drag-handle/sensor/use-mouse-sensor.js +++ b/src/view/use-drag-handle/sensor/use-mouse-sensor.js @@ -319,10 +319,6 @@ export default function useMouseSensor(args: Args): OnMouseDown { const onMouseDown = useCallback( (event: MouseEvent) => { - // skipping for now to allow virtual - if (true) { - return; - } if (mouseDownMarshal.isHandled()) { return; } diff --git a/src/view/use-drag-handle/use-drag-handle.js b/src/view/use-drag-handle/use-drag-handle.js index 7d6170a5df..de7829539b 100644 --- a/src/view/use-drag-handle/use-drag-handle.js +++ b/src/view/use-drag-handle/use-drag-handle.js @@ -19,6 +19,9 @@ import useValidation from './use-validation'; // import useFocusRetainer from './use-focus-retainer'; import useLayoutEffect from '../use-isomorphic-layout-effect'; import getDragHandleRef from './util/get-drag-handle-ref'; +import useMouseSensor, { + type Args as MouseSensorArgs, +} from './sensor/use-mouse-sensor'; function preventHtml5Dnd(event: DragEvent) { event.preventDefault(); @@ -98,6 +101,27 @@ export default function useDragHandle(args: Args): ?DragHandleProps { ); // const { onBlur, onFocus } = useFocusRetainer(args); + const mouseArgs: MouseSensorArgs = useMemo( + () => ({ + callbacks, + getDraggableRef, + getWindow, + canStartCapturing, + getShouldRespectForcePress, + onCaptureStart, + onCaptureEnd, + }), + [ + callbacks, + getDraggableRef, + getWindow, + canStartCapturing, + getShouldRespectForcePress, + onCaptureStart, + onCaptureEnd, + ], + ); + const onMouseDown = useMouseSensor(mouseArgs); const keyboardArgs: KeyboardSensorArgs = useMemo( () => ({ @@ -199,7 +223,7 @@ export default function useDragHandle(args: Args): ?DragHandleProps { return null; } return { - // onMouseDown: () => {}, + // onMouseDown, // onKeyDown: () => {}, // onTouchStart, // onFocus, From aba259f119f86b8d53f8d9c43f38a492ff3a0913 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Tue, 21 May 2019 09:22:33 +1000 Subject: [PATCH 063/308] fixing all browser tests. Adding focus management browser test --- cypress/integration/focus.spec.js | 84 +++++++++++++++++++ .../integration/move-between-lists.spec.js | 7 +- cypress/integration/reorder-lists.spec.js | 11 +-- cypress/integration/reorder.spec.js | 16 ++-- cypress/integration/util.js | 23 +++++ src/view/data-attributes.js | 12 ++- src/view/droppable/connected-droppable.js | 1 + src/view/droppable/droppable-types.js | 5 +- src/view/droppable/droppable.jsx | 3 +- .../sensors/use-keyboard-sensor.js | 10 ++- .../sensors/use-mouse-sensor.js | 3 +- .../sensors/use-touch-sensor.js | 5 +- .../use-sensor-marshal/use-sensor-marshal.js | 2 +- stories/3-board.stories.stories.js | 1 + stories/src/board/board.jsx | 15 +++- stories/src/board/column.jsx | 2 + stories/src/primatives/quote-item.jsx | 1 + stories/src/primatives/quote-list.jsx | 23 +++-- 18 files changed, 188 insertions(+), 36 deletions(-) create mode 100644 cypress/integration/focus.spec.js create mode 100644 cypress/integration/util.js diff --git a/cypress/integration/focus.spec.js b/cypress/integration/focus.spec.js new file mode 100644 index 0000000000..4aa03f3e50 --- /dev/null +++ b/cypress/integration/focus.spec.js @@ -0,0 +1,84 @@ +// @flow +import * as keyCodes from '../../src/view/key-codes'; +import { getHandleSelector, getDraggableSelector } from './util'; + +beforeEach(() => { + cy.visit('/iframe.html?id=board--dragging-a-clone'); +}); + +it('should not steal focus if not already focused when lifting', () => { + // focusing on another handle + cy.get(getHandleSelector('1')).focus(); + cy.focused().should('contain', 'id:1'); + + cy.get(getHandleSelector('2')) + .as('id:2') + .trigger('mousedown', { button: 0 }) + .trigger('mousemove', { + button: 0, + clientX: 200, + clientY: 300, + force: true, + }); + + // asserting id:2 is now dragging + cy.get(getHandleSelector('2')).should( + 'have.attr', + 'data-is-dragging', + 'true', + ); + + // focus not stolen + cy.focused().should('contain', 'id:1'); + + cy.get(getHandleSelector('2')) + .trigger('mouseup', { force: true }) + // clone will be unmounting during drop + .should('not.exist'); + + // getting post clone handle + cy.get(getHandleSelector('2')).should( + 'have.attr', + 'data-is-dragging', + 'false', + ); + + // focus not stolen + cy.focused().should('contain', 'id:1'); +}); + +it('should maintain focus if dragging a clone', () => { + // focusing on another handle + cy.get(getHandleSelector('2')).focus(); + cy.focused().should('contain', 'id:2'); + + cy.get(getHandleSelector('2')).trigger('keydown', { + keyCode: keyCodes.space, + }); + + // asserting id:2 is now dragging + cy.get(getHandleSelector('2')).should( + 'have.attr', + 'data-is-dragging', + 'true', + ); + + // focus maintained + cy.focused().should('contain', 'id:2'); + + cy.get(getHandleSelector('2')) + .trigger('keydown', { keyCode: keyCodes.arrowRight, force: true }) + .trigger('keydown', { keyCode: keyCodes.space, force: true }) + // clone will be unmounting during drop + .should('not.exist'); + + // getting post clone handle + cy.get(getHandleSelector('2')) + // no longer dragging + .should('have.attr', 'data-is-dragging', 'false') + // is in the second column (normally would loose focus moving between lists) + .closest(getDraggableSelector('BMO')); + + // focus maintained + cy.focused().should('contain', 'id:2'); +}); diff --git a/cypress/integration/move-between-lists.spec.js b/cypress/integration/move-between-lists.spec.js index 9571355359..d3e37b5029 100644 --- a/cypress/integration/move-between-lists.spec.js +++ b/cypress/integration/move-between-lists.spec.js @@ -1,6 +1,7 @@ // @flow import * as keyCodes from '../../src/view/key-codes'; import { timings } from '../../src/animation'; +import { getDroppableSelector, getHandleSelector } from './util'; beforeEach(() => { cy.visit('/iframe.html?id=board--simple'); @@ -8,19 +9,19 @@ beforeEach(() => { it('should move between lists', () => { // first list has item with id:2 - cy.get('[data-react-beautiful-dnd-droppable]') + cy.get(getDroppableSelector()) .eq(1) .as('first-list') .should('contain', 'id:2'); // second list does not have item with id:2 - cy.get('[data-react-beautiful-dnd-droppable]') + cy.get(getDroppableSelector()) .eq(2) .as('second-list') .should('not.contain', 'id:2'); cy.get('@first-list') - .find('[data-react-beautiful-dnd-drag-handle]') + .find(getHandleSelector()) .first() .should('contain', 'id:2') .focus() diff --git a/cypress/integration/reorder-lists.spec.js b/cypress/integration/reorder-lists.spec.js index bfe2017fd0..722b82f7e1 100644 --- a/cypress/integration/reorder-lists.spec.js +++ b/cypress/integration/reorder-lists.spec.js @@ -1,6 +1,7 @@ // @flow import * as keyCodes from '../../src/view/key-codes'; import { timings } from '../../src/animation'; +import { getHandleSelector } from './util'; beforeEach(() => { cy.visit('/iframe.html?id=board--simple'); @@ -11,15 +12,15 @@ it('should reorder lists', () => { cy.get('h4') .eq(0) .as('first') - .should('have.text', 'Jake'); + .should('contain', 'Jake'); cy.get('h4') .eq(1) - .should('have.text', 'BMO'); + .should('contain', 'BMO'); // reorder operation cy.get('@first') - .closest('[data-react-beautiful-dnd-drag-handle]') + .closest(getHandleSelector()) .focus() .trigger('keydown', { keyCode: keyCodes.space }) .trigger('keydown', { keyCode: keyCodes.arrowRight, force: true }) @@ -31,10 +32,10 @@ it('should reorder lists', () => { // note: not using get aliases as they where returning incorrect results cy.get('h4') .eq(0) - .should('have.text', 'BMO'); + .should('contain', 'BMO'); // index of the drag handle has changed cy.get('h4') .eq(1) - .should('have.text', 'Jake'); + .should('contain', 'Jake'); }); diff --git a/cypress/integration/reorder.spec.js b/cypress/integration/reorder.spec.js index 6e3f7dba4d..f18cc7fb9a 100644 --- a/cypress/integration/reorder.spec.js +++ b/cypress/integration/reorder.spec.js @@ -1,18 +1,19 @@ // @flow import * as keyCodes from '../../src/view/key-codes'; import { timings } from '../../src/animation'; +import { getHandleSelector } from './util'; beforeEach(() => { cy.visit('/iframe.html?id=single-vertical-list--basic'); }); -it('should reorder a list', () => { +it('should reorder within a list', () => { // order: 1, 2 - cy.get('[data-react-beautiful-dnd-drag-handle]') + cy.get(getHandleSelector()) .eq(0) .as('first') .should('contain', 'id:1'); - cy.get('[data-react-beautiful-dnd-drag-handle]') + cy.get(getHandleSelector()) .eq(1) .should('contain', 'id:2'); @@ -20,6 +21,8 @@ it('should reorder a list', () => { cy.get('@first') .focus() .trigger('keydown', { keyCode: keyCodes.space }) + // need to re-query for a clone + .get('@first') .trigger('keydown', { keyCode: keyCodes.arrowDown, force: true }) // finishing before the movement time is fine - but this looks nice .wait(timings.outOfTheWay * 1000) @@ -27,11 +30,14 @@ it('should reorder a list', () => { // order now 2, 1 // note: not using get aliases as they where returning incorrect results - cy.get('[data-react-beautiful-dnd-drag-handle]') + cy.get(getHandleSelector()) .eq(0) .should('contain', 'id:2'); - cy.get('[data-react-beautiful-dnd-drag-handle]') + cy.get(getHandleSelector()) .eq(1) .should('contain', 'id:1'); + + // element should maintain focus post drag + cy.focused().should('contain', 'id:1'); }); diff --git a/cypress/integration/util.js b/cypress/integration/util.js new file mode 100644 index 0000000000..23c7f80c45 --- /dev/null +++ b/cypress/integration/util.js @@ -0,0 +1,23 @@ +// @flow +import * as dataAttr from '../../src/view/data-attributes'; + +export function getDroppableSelector(droppableId?: string) { + if (droppableId) { + return `[${dataAttr.droppable.contextId}="${droppableId}"]`; + } + return `[${dataAttr.droppable.contextId}]`; +} + +export function getHandleSelector(draggableId?: string) { + if (draggableId) { + return `[${dataAttr.dragHandle.draggableId}="${draggableId}"]`; + } + return `[${dataAttr.dragHandle.draggableId}]`; +} + +export function getDraggableSelector(draggableId?: string) { + if (draggableId) { + return `[${dataAttr.draggable.id}="${draggableId}"]`; + } + return `[${dataAttr.draggable.id}]`; +} diff --git a/src/view/data-attributes.js b/src/view/data-attributes.js index f492b1b825..89fe14fa67 100644 --- a/src/view/data-attributes.js +++ b/src/view/data-attributes.js @@ -20,9 +20,15 @@ export const draggable = (() => { }; })(); -export const droppable = { - contextId: `${prefix}-droppable-context-id`, -}; +export const droppable = (() => { + const base: string = `${prefix}-droppable`; + return { + base, + contextId: `${base}-context-id`, + id: `${base}-id`, + }; +})(); + export const placeholder = { contextId: `${prefix}-placeholder-context-id`, }; diff --git a/src/view/droppable/connected-droppable.js b/src/view/droppable/connected-droppable.js index 7eed0adc37..212e57a999 100644 --- a/src/view/droppable/connected-droppable.js +++ b/src/view/droppable/connected-droppable.js @@ -235,6 +235,7 @@ const defaultProps = ({ isDropDisabled: false, isCombineEnabled: false, ignoreContainerClipping: false, + whenDraggingClone: null, getContainerForClone: getBody, }: DefaultProps); diff --git a/src/view/droppable/droppable-types.js b/src/view/droppable/droppable-types.js index 2dc79c71c4..657b3bd769 100644 --- a/src/view/droppable/droppable-types.js +++ b/src/view/droppable/droppable-types.js @@ -19,6 +19,8 @@ import { updateViewportMaxScroll } from '../../state/action-creators'; export type DroppableProps = {| // used for shared global styles 'data-rbd-droppable-context-id': ContextId, + // Used to lookup. Currently not used for drag and drop lifecycle + 'data-rbd-droppable-droppable-id': DroppableId, |}; export type Provided = {| @@ -65,6 +67,7 @@ export type DefaultProps = {| isDropDisabled: boolean, isCombineEnabled: boolean, direction: Direction, + whenDraggingClone: ?RenderClone, ignoreContainerClipping: boolean, getContainerForClone: () => HTMLElement, |}; @@ -78,7 +81,7 @@ export type OwnProps = {| children: (Provided, StateSnapshot) => Node, droppableId: DroppableId, // TODO: hoist these types up? - whenDraggingClone?: RenderClone, + whenDraggingClone: ?RenderClone, |}; export type Props = {| diff --git a/src/view/droppable/droppable.jsx b/src/view/droppable/droppable.jsx index 54bd75e69f..34befb8a5b 100644 --- a/src/view/droppable/droppable.jsx +++ b/src/view/droppable/droppable.jsx @@ -107,10 +107,11 @@ export default function Droppable(props: Props) { innerRef: setDroppableRef, placeholder, droppableProps: { + 'data-rbd-droppable-droppable-id': droppableId, 'data-rbd-droppable-context-id': contextId, }, }), - [contextId, placeholder, setDroppableRef], + [contextId, droppableId, placeholder, setDroppableRef], ); const usingCloneWhenDragging: boolean = Boolean(whenDraggingClone); diff --git a/src/view/use-sensor-marshal/sensors/use-keyboard-sensor.js b/src/view/use-sensor-marshal/sensors/use-keyboard-sensor.js index 55259aad8a..225effc158 100644 --- a/src/view/use-sensor-marshal/sensors/use-keyboard-sensor.js +++ b/src/view/use-sensor-marshal/sensors/use-keyboard-sensor.js @@ -1,6 +1,6 @@ // @flow import invariant from 'tiny-invariant'; -import { useRef, useEffect } from 'react'; +import { useRef } from 'react'; import { useMemo, useCallback } from 'use-memo-one'; import type { PreDragActions, DragActions } from '../../../types'; import type { @@ -11,6 +11,7 @@ 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() {} @@ -148,11 +149,13 @@ export default function useKeyboardSensor( fn: function onKeyDown(event: KeyboardEvent) { // Event already used if (event.defaultPrevented) { + console.log('unable default prevented'); return; } // Need to start drag with a spacebar press if (event.keyCode !== keyCodes.space) { + console.log('wrong code to start'); return; } @@ -162,9 +165,12 @@ export default function useKeyboardSensor( // Cannot start capturing at this time if (!preDrag) { + console.log('unable to start'); return; } + console.log('starting drag'); + // we are consuming the event event.preventDefault(); let isCapturing: boolean = true; @@ -222,7 +228,7 @@ export default function useKeyboardSensor( [startCaptureBinding], ); - useEffect(() => { + useLayoutEffect(() => { listenForCapture(); // kill any pending window events when unmounting diff --git a/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js b/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js index 0b8fcd8afc..79339c028d 100644 --- a/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js +++ b/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js @@ -14,6 +14,7 @@ import * as keyCodes from '../../key-codes'; import preventStandardKeyEvents from './util/prevent-standard-key-events'; import supportedPageVisibilityEventName from './util/supported-page-visibility-event-name'; import { warning } from '../../../dev-warning'; +import useLayoutEffect from '../../use-isomorphic-layout-effect'; // https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button const primaryButton: number = 0; @@ -333,7 +334,7 @@ export default function useMouseSensor( [bindCapturingEvents], ); - useEffect(() => { + useLayoutEffect(() => { listenForCapture(); // kill any pending window events when unmounting diff --git a/src/view/use-sensor-marshal/sensors/use-touch-sensor.js b/src/view/use-sensor-marshal/sensors/use-touch-sensor.js index 69bac8e161..65e0122873 100644 --- a/src/view/use-sensor-marshal/sensors/use-touch-sensor.js +++ b/src/view/use-sensor-marshal/sensors/use-touch-sensor.js @@ -1,6 +1,6 @@ // @flow import invariant from 'tiny-invariant'; -import { useEffect, useRef } from 'react'; +import { useRef } from 'react'; import { useCallback, useMemo } from 'use-memo-one'; import type { Position } from 'css-box-model'; import type { PreDragActions, DragActions } from '../../../types'; @@ -12,6 +12,7 @@ import bindEvents from '../../event-bindings/bind-events'; import * as keyCodes from '../../key-codes'; import supportedPageVisibilityEventName from './util/supported-page-visibility-event-name'; import { noop } from '../../../empty'; +import useLayoutEffect from '../../use-isomorphic-layout-effect'; type TouchWithForce = Touch & { force: number, @@ -392,7 +393,7 @@ export default function useMouseSensor( [bindCapturingEvents, getPhase, setPhase, startDragging], ); - useEffect(() => { + useLayoutEffect(() => { listenForCapture(); return function unmount() { diff --git a/src/view/use-sensor-marshal/use-sensor-marshal.js b/src/view/use-sensor-marshal/use-sensor-marshal.js index 0a073c7649..79a9d77747 100644 --- a/src/view/use-sensor-marshal/use-sensor-marshal.js +++ b/src/view/use-sensor-marshal/use-sensor-marshal.js @@ -317,7 +317,7 @@ export default function useSensorMarshal({ const lockAPI: LockAPI = useState(() => create())[0]; // We need to abort any capturing if there is no longer a drag - useEffect( + useLayoutEffect( function listenToStore() { let previous: State = store.getState(); const unsubscribe = store.subscribe(() => { diff --git a/stories/3-board.stories.stories.js b/stories/3-board.stories.stories.js index ced9d1c294..b23c13813b 100644 --- a/stories/3-board.stories.stories.js +++ b/stories/3-board.stories.stories.js @@ -11,6 +11,7 @@ const data = { storiesOf('board', module) .add('simple', () => ) + .add('dragging a clone', () => ) .add('medium data set', () => ) .add('large data set', () => ) .add('long lists in a short container', () => ( diff --git a/stories/src/board/board.jsx b/stories/src/board/board.jsx index 645f1f116b..e635c801cd 100644 --- a/stories/src/board/board.jsx +++ b/stories/src/board/board.jsx @@ -32,6 +32,7 @@ type Props = {| withScrollableColumns?: boolean, isCombineEnabled?: boolean, containerHeight?: string, + useClone?: boolean, |}; type State = {| @@ -117,7 +118,12 @@ export default class Board extends Component { render() { const columns: QuoteMap = this.state.columns; const ordered: string[] = this.state.ordered; - const { containerHeight } = this.props; + const { + containerHeight, + useClone, + isCombineEnabled, + withScrollableColumns, + } = this.props; const board = ( { type="COLUMN" direction="horizontal" ignoreContainerClipping={Boolean(containerHeight)} - isCombineEnabled={this.props.isCombineEnabled} + isCombineEnabled={isCombineEnabled} > {(provided: DroppableProvided) => ( @@ -135,8 +141,9 @@ export default class Board extends Component { index={index} title={key} quotes={columns[key]} - isScrollable={this.props.withScrollableColumns} - isCombineEnabled={this.props.isCombineEnabled} + isScrollable={withScrollableColumns} + isCombineEnabled={isCombineEnabled} + useClone={useClone} /> ))} {provided.placeholder} diff --git a/stories/src/board/column.jsx b/stories/src/board/column.jsx index 8bc0f4b2a7..528fb53590 100644 --- a/stories/src/board/column.jsx +++ b/stories/src/board/column.jsx @@ -36,6 +36,7 @@ type Props = {| index: number, isScrollable?: boolean, isCombineEnabled?: boolean, + useClone?: boolean, |}; export default class Column extends Component { @@ -64,6 +65,7 @@ export default class Column extends Component { quotes={quotes} internalScroll={this.props.isScrollable} isCombineEnabled={Boolean(this.props.isCombineEnabled)} + useClone={Boolean(this.props.useClone)} /> )} diff --git a/stories/src/primatives/quote-item.jsx b/stories/src/primatives/quote-item.jsx index 2704ea0f29..a659b658ac 100644 --- a/stories/src/primatives/quote-item.jsx +++ b/stories/src/primatives/quote-item.jsx @@ -185,6 +185,7 @@ function QuoteItem(props: Props) { {...provided.draggableProps} {...provided.dragHandleProps} style={getStyle(provided, style)} + data-is-dragging={isDragging} > {isClone ? Clone : null} diff --git a/stories/src/primatives/quote-list.jsx b/stories/src/primatives/quote-list.jsx index a0b2c90c89..5e1e104b4b 100644 --- a/stories/src/primatives/quote-list.jsx +++ b/stories/src/primatives/quote-list.jsx @@ -76,6 +76,8 @@ type Props = {| style?: Object, // may not be provided - and might be null ignoreContainerClipping?: boolean, + + useClone?: boolean, |}; type QuoteListProps = {| @@ -136,6 +138,7 @@ export default function QuoteList(props: Props) { style, quotes, title, + useClone, } = props; return ( @@ -145,14 +148,18 @@ export default function QuoteList(props: Props) { ignoreContainerClipping={ignoreContainerClipping} isDropDisabled={isDropDisabled} isCombineEnabled={isCombineEnabled} - whenDraggingClone={(provided, snapshot, source) => ( - - )} + whenDraggingClone={ + useClone + ? (provided, snapshot, source) => ( + + ) + : null + } > {( dropProvided: DroppableProvided, From 84f17351181be2e946fbcad2b4115988b9503b1d Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Wed, 22 May 2019 10:26:44 +1000 Subject: [PATCH 064/308] adding focus guide and cloning tets --- cypress/integration/focus.spec.js | 64 ++++++++++++++++++++++++++++++ docs/about/accessibility.md | 4 +- docs/api/draggable.md | 9 +---- docs/guides/dragging-svgs.md | 6 +-- docs/guides/focus.md | 23 +++++++++++ docs/support/media.md | 4 +- stories/3-board.stories.stories.js | 5 ++- 7 files changed, 99 insertions(+), 16 deletions(-) create mode 100644 docs/guides/focus.md diff --git a/cypress/integration/focus.spec.js b/cypress/integration/focus.spec.js index 4aa03f3e50..cf3bd25dc4 100644 --- a/cypress/integration/focus.spec.js +++ b/cypress/integration/focus.spec.js @@ -82,3 +82,67 @@ it('should maintain focus if dragging a clone', () => { // focus maintained cy.focused().should('contain', 'id:2'); }); + +it('should give focus to a combine target', () => { + cy.visit('/iframe.html?id=board--with-combining-and-cloning'); + cy.get(getHandleSelector('2')).focus(); + cy.focused().should('contain', 'id:2'); + + cy.get(getHandleSelector('2')).trigger('keydown', { + keyCode: keyCodes.space, + }); + + // asserting id:2 is now dragging + cy.get(getHandleSelector('2')).should( + 'have.attr', + 'data-is-dragging', + 'true', + ); + + // focus maintained + cy.focused().should('contain', 'id:2'); + + cy.get(getHandleSelector('2')) + .trigger('keydown', { keyCode: keyCodes.arrowRight, force: true }) + // combining with item:1 + .trigger('keydown', { keyCode: keyCodes.arrowUp, force: true }) + // dropping + .trigger('keydown', { keyCode: keyCodes.space, force: true }) + // clone will be unmounting during drop + .should('not.exist'); + + // focus giving to item:1 the combine target + cy.focused().should('contain', 'id:1'); +}); + +it('should not give focus to a combine target if source did not have focus at start of drag', () => { + cy.visit('/iframe.html?id=board--with-combining-and-cloning'); + // focusing on something unrelated to the drag + cy.get(getHandleSelector('3')).focus(); + + cy.get(getHandleSelector('2')).trigger('keydown', { + keyCode: keyCodes.space, + }); + + // asserting id:2 is now dragging + cy.get(getHandleSelector('2')).should( + 'have.attr', + 'data-is-dragging', + 'true', + ); + + // focus not stolen + cy.focused().should('contain', 'id:3'); + + cy.get(getHandleSelector('2')) + .trigger('keydown', { keyCode: keyCodes.arrowRight, force: true }) + // combining with item:1 + .trigger('keydown', { keyCode: keyCodes.arrowUp, force: true }) + // dropping + .trigger('keydown', { keyCode: keyCodes.space, force: true }) + // clone will be unmounting during drop + .should('not.exist'); + + // focus not given to the combine target + cy.focused().should('contain', 'id:3'); +}); diff --git a/docs/about/accessibility.md b/docs/about/accessibility.md index 52c08499cc..0f2199ce7a 100644 --- a/docs/about/accessibility.md +++ b/docs/about/accessibility.md @@ -9,9 +9,7 @@ Traditionally drag and drop interactions have been exclusively a mouse or touch - Keyboard [auto scrolling](/docs/guides/auto-scrolling.md) - Fantastic [screen reader support](/docs/guides/screen-reader.md) - _We ship with english messaging out of the box 📦_ - Correct use of `aria-*` attributes for [lift announcements](/docs/guides/screen-reader.md) -- Ensure a dragging item maintains focus if started dragging with focus - [more info](/docs/api/draggable.md) -- Ensure a dragging item maintains focus when dropping into a new list to allow drags to be chained together - [more info](/docs/api/draggable.md) -- Ensure a dragging item maintains focus moving into a [portal](/docs/patterns/using-a-portal.md) +- Smart management of focus - [more info](/docs/guides/focus.md) ![screen-reader-text](https://user-images.githubusercontent.com/2182637/36571009-d326d82a-1888-11e8-9a1d-e44f8b969c2f.gif) diff --git a/docs/api/draggable.md b/docs/api/draggable.md index b75de9b42d..92437e4eca 100644 --- a/docs/api/draggable.md +++ b/docs/api/draggable.md @@ -217,14 +217,9 @@ If the user force presses on the element before they have moved the element (eve Any force press action will cancel an existing or pending drag -#### Focus retention when moving between lists +#### Focus retention -When moving a `` from one list to another the default browser behaviour is for the _drag handle_ element to lose focus. This is because the old element is being destroyed and a new one is being created. The loss of focus is not good when dragging with a keyboard as the user is then unable to continue to interact with the element. To improve this user experience we automatically give a _drag handle_ focus when: - -- It was unmounted at the end of a drag -- It had focus -- It is enabled when mounted -- No other elements have gained browser focus before the drag handle has mounted +See [/docs/guides/focus.md] #### Extending `DraggableProps.style` diff --git a/docs/guides/dragging-svgs.md b/docs/guides/dragging-svgs.md index 955dbb17bd..8943dcae7f 100644 --- a/docs/guides/dragging-svgs.md +++ b/docs/guides/dragging-svgs.md @@ -8,11 +8,9 @@ We require that a `` and its drag handle be a `HTMLElement`. Almost ![HTMLElement](https://user-images.githubusercontent.com/2182637/42302315-9150d4e0-805d-11e8-8345-71bc32135203.png) -## Using focus +## Focus management -We use and manipulate focus on a drag handle during a drag if it is needed. This is especially true for keyboard dragging that relies on focus management. - -An element loses focus when it moves into a [`React Portal`](https://reactjs.org/docs/portals.html). We can detect when a `` is moving into a portal and we give the new element in the portal focus with `.focus()`. Additionally, we also will maintain focus when you move a `` from one list to another using `.focus()` if it had focus when dragging. An element will always have focus when keyboard dragging. +We use and manipulate focus on a drag handle during a drag if it is needed. This is especially true for keyboard dragging that relies on focus management. See our [focus guide](/docs/guides/focus.md). ## Enter [`SVGElement`](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement) 🖼 diff --git a/docs/guides/focus.md b/docs/guides/focus.md new file mode 100644 index 0000000000..dd911950df --- /dev/null +++ b/docs/guides/focus.md @@ -0,0 +1,23 @@ +# Focus + +> "You got to focus on what's real, man" - [Jake from Adventure time](https://www.youtube.com/watch?v=TFGz6Qvg1CE) + +`react-beautiful-dnd` includes logic to maintain browser focus for _drag handles_. This especially important for [keyboard dragging](/docs/sensors/keyboard.md) which requires the dragging item to be focused. + +## Terminology reminder 📖 + +A `` has a _drag handle_. A _drag handle_ is the part of the `` that controls the dragging of the whole ``. A _drag handle_ can be the same element as the `` + +## Drag handle not focused at drag start + +If the _drag handle_ is not focused when a drag starts then **focus is not given** to the dragging item. This is a mirror of the native HTML5 drag and drop behaviour which does not give focus to an item just because it is dragging. You are welcome to call `HTMLElement.focus()` when a drag starts to give it focus, but that is up to you. + +## Drag handle is focused at drag start + +If a _drag handle_ has browser focus when a drag starts then `rbd` will try to give focus to the _drag handle_ during a drag and just after a drag ends. + +Here is what is done: + +- Give focus to a _drag handle_ with a matching `DraggableId` after the drag starts. This might be a different element to the original _drag handle_ if you are using a [portal](TODO) or a [clone](TODO). +- Give focus to a _drag handle_ with a matching `DraggableId` after the drag ends. Sometimes the original _drag handle_ element is lost during a drag, such as when using a [portal](TODO) or a [clone](TODO), or when moving a `Draggable` from one list to another as `React` will recreate the element. +- If [combining](TODO) then focus is given to the combine target after a drag ends. This allows keyboard users to continue to engage with the application without needing to get the focus back to where they where the last interaction was diff --git a/docs/support/media.md b/docs/support/media.md index ad482f8a70..d4914225ad 100644 --- a/docs/support/media.md +++ b/docs/support/media.md @@ -19,8 +19,9 @@ This page contains a list of articles, blogs and newsletters that `react-beautif - [Dragging React performance forward](https://medium.com/@alexandereardon/dragging-react-performance-forward-688b30d40a33) - [Grabbing the flame 🔥](https://medium.com/@alexandereardon/grabbing-the-flame-290c794fe852) -## React ecosystem +## Other +- [What does react-beautiful-dnd cost to maintain?](https://dev.to/alexandereardon/what-does-react-beautiful-dnd-cost-to-maintain-52e8) - [Deep Sea Fishing with React Hooks](https://www.youtube.com/watch?v=MVi17tk3VsI) ## Podcasts @@ -57,6 +58,7 @@ This page contains a list of articles, blogs and newsletters that `react-beautif - sidebar.io - [28/8/16](https://sidebar.io/?after=2017-08-21&before=2017-08-21) - Best of JS [issue 25](https://weekly.bestofjs.org/issues/25/) - BxJS Weekly [episode 59](https://dev.to/yamalight/bxjs-weekly-episode-59-javascript-news-podcast-b28) +- FASination Daily [May 21st](http://opensource.faseidl.com/#/) ## Articles, tutorials and blogs diff --git a/stories/3-board.stories.stories.js b/stories/3-board.stories.stories.js index b23c13813b..1337ff3af4 100644 --- a/stories/3-board.stories.stories.js +++ b/stories/3-board.stories.stories.js @@ -20,6 +20,9 @@ storiesOf('board', module) .add('scrollable columns', () => ( )) - .add('with combine enabled', () => ( + .add('with combining', () => ( + )) + .add('with combining and cloning', () => ( + )); From 724f6465e223b46ecc8a59164752e5935d4c03b6 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Wed, 22 May 2019 17:28:59 +1000 Subject: [PATCH 065/308] centeralized serialization for draggable options --- src/view/draggable-options.js | 43 +++++++++++++ src/view/draggable/draggable.jsx | 27 +++++++- .../get-data-from-draggable.js | 25 ++++++++ .../get-options-from-draggable.js | 61 ------------------- .../use-sensor-marshal/use-sensor-marshal.js | 8 +-- 5 files changed, 96 insertions(+), 68 deletions(-) create mode 100644 src/view/draggable-options.js create mode 100644 src/view/use-sensor-marshal/get-data-from-draggable.js delete mode 100644 src/view/use-sensor-marshal/get-options-from-draggable.js diff --git a/src/view/draggable-options.js b/src/view/draggable-options.js new file mode 100644 index 0000000000..75d13b5284 --- /dev/null +++ b/src/view/draggable-options.js @@ -0,0 +1,43 @@ +// @flow +import invariant from 'tiny-invariant'; + +export type DraggableOptions = {| + canDragInteractiveElements: boolean, + shouldRespectForcePress: boolean, + isEnabled: boolean, +|}; + +export function serialize(options: DraggableOptions): string { + return JSON.stringify(options); +} + +export function deserialize(raw: string): DraggableOptions { + // this might throw too + const parsed: Object = JSON.parse(raw); + + const proposed: DraggableOptions = { + canDragInteractiveElements: parsed.canDragInteractiveElements, + shouldRespectForcePress: parsed.shouldRespectForcePress, + isEnabled: parsed.isEnabled, + }; + + if (process.env.NODE_ENV !== 'production') { + const parsedKeys: string[] = Object.keys(parsed); + const proposedKeys: string[] = Object.keys(proposed); + + const arrange = (keys: string[]): string => keys.sort().join(''); + + invariant( + arrange(parsedKeys) === arrange(proposedKeys), + `Expected ${arrange(parsedKeys)} to equal ${arrange(proposedKeys)}`, + ); + proposedKeys.forEach((key: string) => { + invariant( + proposed[key] != null, + `Expected parsed object to have key "${key}"`, + ); + }); + } + + return proposed; +} diff --git a/src/view/draggable/draggable.jsx b/src/view/draggable/draggable.jsx index 179f4f0397..dabd8dde4a 100644 --- a/src/view/draggable/draggable.jsx +++ b/src/view/draggable/draggable.jsx @@ -4,7 +4,6 @@ import { type Position } from 'css-box-model'; import invariant from 'tiny-invariant'; import { useMemo, useCallback } from 'use-memo-one'; import getStyle from './get-style'; -import useDragHandle from '../use-drag-handle/use-drag-handle'; import type { Args as DragHandleArgs, Callbacks as DragHandleCallbacks, @@ -25,6 +24,11 @@ import DroppableContext, { } from '../context/droppable-context'; import useRequiredContext from '../use-required-context'; import useValidation from './use-validation'; +import { serialize } from '../draggable-options'; + +function preventHtml5Dnd(event: DragEvent) { + event.preventDefault(); +} export default function Draggable(props: Props) { // reference to DOM node @@ -167,7 +171,24 @@ export default function Draggable(props: Props) { ], ); - const dragHandleProps: ?DragHandleProps = useDragHandle(dragHandleArgs); + // const dragHandleProps: ?DragHandleProps = useDragHandle(dragHandleArgs); + + const dragHandleProps: ?DragHandleProps = useMemo( + () => + isEnabled + ? { + tabIndex: 0, + 'data-rbd-drag-handle-draggable-id': draggableId, + 'data-rbd-drag-handle-context-id': appContext.contextId, + // English default. Consumers are welcome to add their own start instruction + 'aria-roledescription': 'Draggable item. Press space bar to lift', + // Opting out of html5 drag and drops + draggable: false, + onDragStart: preventHtml5Dnd, + } + : null, + [appContext.contextId, draggableId, isEnabled], + ); const onMoveEnd = useCallback( (event: TransitionEvent) => { @@ -201,7 +222,7 @@ export default function Draggable(props: Props) { 'data-rbd-draggable-context-id': appContext.contextId, 'data-rbd-draggable-id': draggableId, // TODO: create helper - 'data-rbd-draggable-options': JSON.stringify({ + 'data-rbd-draggable-options': serialize({ canDragInteractiveElements, shouldRespectForcePress, isEnabled, diff --git a/src/view/use-sensor-marshal/get-data-from-draggable.js b/src/view/use-sensor-marshal/get-data-from-draggable.js new file mode 100644 index 0000000000..fa9d5c4f85 --- /dev/null +++ b/src/view/use-sensor-marshal/get-data-from-draggable.js @@ -0,0 +1,25 @@ +// @flow +import invariant from 'tiny-invariant'; +import type { DraggableId } from '../../types'; +import { draggable as attr } from '../data-attributes'; +import { deserialize, type DraggableOptions } from '../draggable-options'; + +export type DraggableData = {| + id: DraggableId, + ...DraggableOptions, +|}; + +export default function getDataFromDraggable( + draggable: Element, +): DraggableData { + const id: ?DraggableId = draggable.getAttribute(`${attr.id}`); + invariant(id != null, 'Expected element to be a draggable'); + + const options: ?string = draggable.getAttribute(`${attr.options}`); + invariant(options, 'Expected draggable to have options'); + + return { + id, + ...deserialize(options), + }; +} diff --git a/src/view/use-sensor-marshal/get-options-from-draggable.js b/src/view/use-sensor-marshal/get-options-from-draggable.js deleted file mode 100644 index ea92b48d4e..0000000000 --- a/src/view/use-sensor-marshal/get-options-from-draggable.js +++ /dev/null @@ -1,61 +0,0 @@ -// @flow -import invariant from 'tiny-invariant'; -import type { DraggableId } from '../../types'; -import { draggable as attr } from '../data-attributes'; -import { find } from '../../native-with-fallback'; - -export type DraggableData = {| - id: DraggableId, - canDragInteractiveElements: boolean, - shouldRespectForcePress: boolean, - isEnabled: boolean, -|}; - -export default function getDraggableData(draggable: Element): DraggableData { - const id: ?DraggableId = draggable.getAttribute(`${attr.id}`); - invariant(id != null, 'Expected element to be a draggable'); - - const options: ?string = draggable.getAttribute(`${attr.options}`); - invariant(options, 'Expected draggable to have options'); - - const parsed: Object = JSON.parse(options); - - // validation in dev - if (process.env.NODE_ENV !== 'production') { - const properties: string[] = [ - 'canDragInteractiveElements', - 'shouldRespectForcePress', - 'isEnabled', - ]; - const keys: string[] = Object.keys(parsed); - - const arrange = (list: string[]): string => list.sort().join(','); - - invariant( - arrange(properties) === arrange(keys), - ` - Unexpected data keys. - Expected: ${arrange(properties)} - Actual: ${arrange(keys)} - `, - ); - - invariant( - keys.length === properties.length, - 'Unexpected parsed draggable options', - ); - properties.forEach((property: string) => { - invariant( - find(keys, (key: string) => property === key), - `Could not find key ${property} in draggable attributes`, - ); - }); - } - - return { - id, - canDragInteractiveElements: parsed.canDragInteractiveElements, - shouldRespectForcePress: parsed.shouldRespectForcePress, - isEnabled: parsed.isEnabled, - }; -} diff --git a/src/view/use-sensor-marshal/use-sensor-marshal.js b/src/view/use-sensor-marshal/use-sensor-marshal.js index 79a9d77747..29bb49909e 100644 --- a/src/view/use-sensor-marshal/use-sensor-marshal.js +++ b/src/view/use-sensor-marshal/use-sensor-marshal.js @@ -30,7 +30,7 @@ import useKeyboardSensor from './sensors/use-keyboard-sensor'; import useTouchSensor from './sensors/use-touch-sensor'; import useValidateSensorHooks from './use-validate-sensor-hooks'; import isHandleInInteractiveElement from './is-handle-in-interactive-element'; -import getOptionsFromDraggable from './get-options-from-draggable'; +import getDataFromDraggable from './get-data-from-draggable'; import getBorderBoxCenterPosition from '../get-border-box-center-position'; import { warning } from '../../dev-warning'; import isHtmlElement from '../is-type-of-element/is-html-element'; @@ -147,10 +147,10 @@ function tryGetLock({ const draggable: HTMLElement = getClosestDraggable(contextId, handle); const { id, - shouldRespectForcePress, - canDragInteractiveElements, isEnabled, - } = getOptionsFromDraggable(draggable); + canDragInteractiveElements, + shouldRespectForcePress, + } = getDataFromDraggable(draggable); // draggable is not enabled - cannot start if (!isEnabled) { From 40e70d202cbacb70fe71f4788c329eeb4f489a81 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Thu, 23 May 2019 08:19:10 +1000 Subject: [PATCH 066/308] removing old comments --- src/view/draggable-options.js | 1 + src/view/draggable/draggable.jsx | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/src/view/draggable-options.js b/src/view/draggable-options.js index 75d13b5284..8a83634d17 100644 --- a/src/view/draggable-options.js +++ b/src/view/draggable-options.js @@ -21,6 +21,7 @@ export function deserialize(raw: string): DraggableOptions { isEnabled: parsed.isEnabled, }; + // Some extra validation for non production environments if (process.env.NODE_ENV !== 'production') { const parsedKeys: string[] = Object.keys(parsed); const proposedKeys: string[] = Object.keys(proposed); diff --git a/src/view/draggable/draggable.jsx b/src/view/draggable/draggable.jsx index dabd8dde4a..a76c7aadd7 100644 --- a/src/view/draggable/draggable.jsx +++ b/src/view/draggable/draggable.jsx @@ -221,7 +221,6 @@ export default function Draggable(props: Props) { draggableProps: { 'data-rbd-draggable-context-id': appContext.contextId, 'data-rbd-draggable-id': draggableId, - // TODO: create helper 'data-rbd-draggable-options': serialize({ canDragInteractiveElements, shouldRespectForcePress, From f4c0827405c173b75c95869b25d02f14b9cdae03 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Thu, 23 May 2019 09:31:12 +1000 Subject: [PATCH 067/308] moving to createEvent util --- package.json | 2 +- .../mouse-sensor/cancel-while-pending.spec.js | 17 ++--- .../mouse-sensor/click-blocking.spec.js | 64 ++++++++++++++++++- .../mouse-sensor/start-dragging.spec.js | 63 +++++------------- .../drag-handle/mouse-sensor/util.js | 12 +--- yarn.lock | 18 +++--- 6 files changed, 94 insertions(+), 82 deletions(-) diff --git a/package.json b/package.json index 482ffe01f3..01e364d798 100644 --- a/package.json +++ b/package.json @@ -113,7 +113,7 @@ "react": "^16.8.6", "react-dom": "^16.8.6", "react-test-renderer": "^16.8.6", - "react-testing-library": "^7.0.0", + "react-testing-library": "^7.0.1", "react-window": "^1.8.1", "rimraf": "^2.6.3", "rollup": "^1.11.3", diff --git a/test/unit/integration/drag-handle/mouse-sensor/cancel-while-pending.spec.js b/test/unit/integration/drag-handle/mouse-sensor/cancel-while-pending.spec.js index 614421a52e..9ef2c38109 100644 --- a/test/unit/integration/drag-handle/mouse-sensor/cancel-while-pending.spec.js +++ b/test/unit/integration/drag-handle/mouse-sensor/cancel-while-pending.spec.js @@ -1,11 +1,10 @@ // @flow import React from 'react'; -import { fireEvent, render } from 'react-testing-library'; +import { createEvent, fireEvent, render } from 'react-testing-library'; import * as keyCodes from '../../../../../src/view/key-codes'; import { sloppyClickThreshold } from '../../../../../src/view/use-sensor-marshal/sensors/util/is-sloppy-click-threshold-exceeded'; import App from '../app'; import { isDragging } from '../util'; -import { getStartingMouseDown } from './util'; import supportedEventName from '../../../../../src/view/use-sensor-marshal/sensors/util/supported-page-visibility-event-name'; it(`should cancel a pending drag with keydown`, () => { @@ -13,14 +12,10 @@ it(`should cancel a pending drag with keydown`, () => { const { getByText, unmount } = render(); const handle: HTMLElement = getByText('item: 0'); - fireEvent.mouseDown(handle, getStartingMouseDown()); + fireEvent.mouseDown(handle); // abort - const event: Event = new KeyboardEvent('keydown', { - keyCode, - bubbles: true, - cancelable: true, - }); + const event: KeyboardEvent = createEvent.keyDown(handle, { keyCode }); fireEvent(handle, event); // would normally start @@ -42,7 +37,7 @@ it('should cancel when resize is fired', () => { const { getByText } = render(); const handle: HTMLElement = getByText('item: 0'); - fireEvent.mouseDown(handle, getStartingMouseDown()); + fireEvent.mouseDown(handle); // abort const event: Event = new Event('resize', { @@ -67,7 +62,7 @@ it('should abort when there is a visibility change', () => { const { getByText } = render(); const handle: HTMLElement = getByText('item: 0'); - fireEvent.mouseDown(handle, getStartingMouseDown()); + fireEvent.mouseDown(handle); // abort const event: Event = new Event(supportedEventName, { @@ -92,7 +87,7 @@ it('should abort when there is a window scroll', () => { const { getByText } = render(); const handle: HTMLElement = getByText('item: 0'); - fireEvent.mouseDown(handle, getStartingMouseDown()); + fireEvent.mouseDown(handle); // abort const event: Event = new Event('scroll', { diff --git a/test/unit/integration/drag-handle/mouse-sensor/click-blocking.spec.js b/test/unit/integration/drag-handle/mouse-sensor/click-blocking.spec.js index 983524b005..1084517538 100644 --- a/test/unit/integration/drag-handle/mouse-sensor/click-blocking.spec.js +++ b/test/unit/integration/drag-handle/mouse-sensor/click-blocking.spec.js @@ -1,6 +1,64 @@ // @flow -it('should not prevent a subsequent click if aborting during a pending drag', () => {}); +import React from 'react'; +import { createEvent, fireEvent, render } from 'react-testing-library'; +import * as keyCodes from '../../../../../src/view/key-codes'; +import { sloppyClickThreshold } from '../../../../../src/view/use-sensor-marshal/sensors/util/is-sloppy-click-threshold-exceeded'; +import App from '../app'; +import { isDragging } from '../util'; +import { simpleLift } from './util'; -it('should prevent a subsequent click if cancelling a drag', () => {}); +it('should not prevent a subsequent click if aborting during a pending drag', () => { + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); -it('should prevent a subsequent click if dropping a drag', () => {}); + fireEvent.mouseDown(handle); + + // abort + fireEvent.keyDown(handle, { keyCode: keyCodes.escape }); + + // would normally start + fireEvent.mouseMove(handle, { + clientX: 0, + clientY: sloppyClickThreshold, + }); + + // drag not started + expect(isDragging(handle)).toBe(false); + + const click: Event = createEvent.click(handle); + fireEvent(handle, click); + + expect(click.defaultPrevented).toBe(false); +}); + +it('should prevent a subsequent click if cancelling a drag', () => { + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + simpleLift(handle); + expect(isDragging(handle)).toBe(true); + + // cancel + fireEvent.keyDown(handle, { keyCode: keyCodes.escape }); + + // click event prevented + const click: Event = createEvent.click(handle); + fireEvent(handle, click); + expect(click.defaultPrevented).toBe(true); +}); + +it('should prevent a subsequent click if dropping a drag', () => { + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + simpleLift(handle); + expect(isDragging(handle)).toBe(true); + + // cancel + fireEvent.mouseUp(handle); + + // click event prevented + const click: Event = createEvent.click(handle); + fireEvent(handle, click); + expect(click.defaultPrevented).toBe(true); +}); diff --git a/test/unit/integration/drag-handle/mouse-sensor/start-dragging.spec.js b/test/unit/integration/drag-handle/mouse-sensor/start-dragging.spec.js index 8f9e726c27..66d347cc68 100644 --- a/test/unit/integration/drag-handle/mouse-sensor/start-dragging.spec.js +++ b/test/unit/integration/drag-handle/mouse-sensor/start-dragging.spec.js @@ -2,11 +2,11 @@ import invariant from 'tiny-invariant'; import React from 'react'; import type { Position } from 'css-box-model'; -import { render, fireEvent } from 'react-testing-library'; +import { render, fireEvent, createEvent } from 'react-testing-library'; import { sloppyClickThreshold } from '../../../../../src/view/use-sensor-marshal/sensors/util/is-sloppy-click-threshold-exceeded'; import { isDragging } from '../util'; import App, { type Item } from '../app'; -import { simpleLift, primaryButton, getStartingMouseDown } from './util'; +import { simpleLift, primaryButton } from './util'; // blocking announcement messages jest.spyOn(console, 'warn').mockImplementation((message: string) => { @@ -29,13 +29,7 @@ it('should start a drag after sufficient movement', () => { const handle: HTMLElement = getByText('item: 0'); - const mouseDown: MouseEvent = new MouseEvent('mousedown', { - clientX: 0, - clientY: 0, - button: primaryButton, - bubbles: true, - cancelable: true, - }); + const mouseDown: MouseEvent = createEvent.mouseDown(handle); fireEvent(handle, mouseDown); // important that this is called to prevent focus @@ -45,12 +39,10 @@ it('should start a drag after sufficient movement', () => { expect(isDragging(handle)).toBe(false); // mouse move to start drag - const mouseMove: MouseEvent = new MouseEvent('mousemove', { - target: handle, + + const mouseMove: MouseEvent = createEvent.mouseMove(handle, { clientX: point.x, clientY: point.y, - bubbles: true, - cancelable: true, }); fireEvent(window, mouseMove); // we are using the event - so prevent default is called @@ -67,11 +59,7 @@ it('should allow standard click events', () => { const { getByText } = render(); const handle: HTMLElement = getByText('item: 0'); - const click: MouseEvent = new MouseEvent('click', { - target: handle, - bubbles: true, - cancelable: true, - }); + const click: MouseEvent = createEvent.click(handle); fireEvent(handle, click); expect(click.defaultPrevented).toBe(false); @@ -89,11 +77,9 @@ it('should not call preventDefault on mouse movements while we are not sure if a }); // not dragging yet - const mouseMove: MouseEvent = new MouseEvent('mousemove', { + const mouseMove: MouseEvent = createEvent.mouseMove(handle, { clientX: 0, clientY: sloppyClickThreshold - 1, - cancelable: true, - bubbles: true, }); fireEvent(handle, mouseMove); @@ -105,13 +91,7 @@ it('should call preventDefault on the initial mousedown event to prevent the ele const { getByText } = render(); const handle: HTMLElement = getByText('item: 0'); - const mouseDown: MouseEvent = new MouseEvent('mousedown', { - clientX: 0, - clientY: 0, - cancelable: true, - bubbles: true, - button: primaryButton, - }); + const mouseDown: MouseEvent = createEvent.mouseDown(handle); fireEvent(handle, mouseDown); expect(mouseDown.defaultPrevented).toBe(true); @@ -122,13 +102,13 @@ it('should allow multiple false starts', () => { const handle: HTMLElement = getByText('item: 0'); Array.from({ length: 5 }).forEach(() => { - fireEvent.mouseDown(handle, getStartingMouseDown()); + fireEvent.mouseDown(handle); fireEvent.mouseUp(handle); expect(isDragging(handle)).toBe(false); }); - fireEvent.mouseDown(handle, getStartingMouseDown()); + fireEvent.mouseDown(handle); fireEvent.mouseMove(handle, { clientX: 0, clientY: sloppyClickThreshold, @@ -141,7 +121,7 @@ it('should not start a drag if there was too little mouse movement while mouse w const { getByText } = render(); const handle: HTMLElement = getByText('item: 0'); - fireEvent.mouseDown(handle, getStartingMouseDown()); + fireEvent.mouseDown(handle); fireEvent.mouseMove(handle, { clientX: 0, clientY: sloppyClickThreshold - 1, @@ -154,16 +134,10 @@ it('should not start a drag if not using the primary mouse button', () => { const { getByText } = render(); const handle: HTMLElement = getByText('item: 0'); - fireEvent( - handle, - new MouseEvent('mousedown', { - clientX: 0, - clientY: 0, - cancelable: true, - bubbles: true, - button: primaryButton + 1, - }), - ); + const mouseDown: Event = createEvent.mouseDown(handle, { + button: primaryButton + 1, + }); + fireEvent(handle, mouseDown); fireEvent.mouseMove(handle, { clientX: 0, clientY: sloppyClickThreshold, @@ -179,12 +153,7 @@ it('should not start a drag if a modifier key was used while pressing the mouse const handle: HTMLElement = getByText('item: 0'); keys.forEach((key: string) => { - const mouseDown: MouseEvent = new MouseEvent('mousedown', { - clientX: 0, - clientY: 0, - cancelable: true, - bubbles: true, - button: primaryButton, + const mouseDown: MouseEvent = createEvent.mouseDown(handle, { [key]: true, }); fireEvent(handle, mouseDown); diff --git a/test/unit/integration/drag-handle/mouse-sensor/util.js b/test/unit/integration/drag-handle/mouse-sensor/util.js index 7fad428838..72fa823a37 100644 --- a/test/unit/integration/drag-handle/mouse-sensor/util.js +++ b/test/unit/integration/drag-handle/mouse-sensor/util.js @@ -2,7 +2,7 @@ import { fireEvent } from 'react-testing-library'; import { sloppyClickThreshold } from '../../../../../src/view/use-sensor-marshal/sensors/util/is-sloppy-click-threshold-exceeded'; -export const primaryButton: number = 0; +export const primaryButton = 0; export function simpleLift(handle: HTMLElement) { fireEvent.mouseDown(handle); @@ -11,13 +11,3 @@ export function simpleLift(handle: HTMLElement) { clientY: sloppyClickThreshold, }); } - -export function getStartingMouseDown(): MouseEvent { - return new MouseEvent('mousedown', { - clientX: 0, - clientY: 0, - cancelable: true, - bubbles: true, - button: primaryButton, - }); -} diff --git a/yarn.lock b/yarn.lock index 5900ca208e..f3b926bff5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5115,10 +5115,10 @@ dom-serializer@0, dom-serializer@~0.1.0: domelementtype "^1.3.0" entities "^1.1.1" -dom-testing-library@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/dom-testing-library/-/dom-testing-library-4.0.1.tgz#f21ef42aea0bd635969b4227a487e4704dbea735" - integrity sha512-Yr0yWlpI2QdTDEgPEk0TEekwP4VyZlJpl9E7nKP2FCKni44cb1jzjsy9KX6hBDsNA7EVlPpq9DHzO2eoEaqDZg== +dom-testing-library@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/dom-testing-library/-/dom-testing-library-4.1.0.tgz#273264e62e9e63f4e404f7349ddd7b9356aacd23" + integrity sha512-654GHd0oPC31S+ll1bJH9NUOBRzcHcrf23/XzJh41o6g67uGUpF9tn23qmbcwjNauoRqKQfAdHCDwr/Ez/Ot7A== dependencies: "@babel/runtime" "^7.4.3" "@sheerun/mutationobserver-shim" "^0.3.2" @@ -10417,13 +10417,13 @@ react-test-renderer@^16.8.6: react-is "^16.8.6" scheduler "^0.13.6" -react-testing-library@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/react-testing-library/-/react-testing-library-7.0.0.tgz#d3b535e44de94d7b0a83c56cd2e3cfed752dcec1" - integrity sha512-8SHqwG+uhN9VhAgNVkVa3f7VjTw/L5CIaoAxKmy+EZuDQ6O+VsfcpRAyUw3MDL1h8S/gGrEiazmHBVL/uXsftA== +react-testing-library@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/react-testing-library/-/react-testing-library-7.0.1.tgz#0cf113bb53a78599f018378f6854e91a52dbf205" + integrity sha512-doQkM3/xPcIm22x9jgTkGxU8xqXg4iWvM1WwbbQ7CI5/EMk3DhloYBwMyk+Ywtta3dIAIh9sC7llXoKovf3L+w== dependencies: "@babel/runtime" "^7.4.3" - dom-testing-library "^4.0.0" + dom-testing-library "^4.1.0" react-textarea-autosize@^7.0.4: version "7.1.0" From 79641424d5845475030a806fa064fd77efde0852 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Thu, 23 May 2019 10:49:17 +1000 Subject: [PATCH 068/308] wip --- test/unit/integration/drag-handle/app.jsx | 72 ++++++++++--------- .../mouse-sensor/force-abort.spec.js | 60 +++++++++++++++- 2 files changed, 97 insertions(+), 35 deletions(-) diff --git a/test/unit/integration/drag-handle/app.jsx b/test/unit/integration/drag-handle/app.jsx index e7b4bf59ed..48946987c0 100644 --- a/test/unit/integration/drag-handle/app.jsx +++ b/test/unit/integration/drag-handle/app.jsx @@ -1,5 +1,5 @@ // @flow -import React, { useState } from 'react'; +import React, { useState, type Node } from 'react'; import { DragDropContext, Droppable, @@ -20,6 +20,7 @@ type Props = {| onDragEnd?: Function, sensors?: Sensor[], items?: Item[], + anotherChild?: Node, |}; function noop() {} @@ -46,39 +47,42 @@ export default function App(props: Props) { onDragEnd={onDragEnd} __unstableSensors={sensors} > - - {(droppableProvided: DroppableProvided) => ( -
- {items.map((item: Item, index: number) => ( - - {( - provided: DraggableProvided, - snapshot: DraggableStateSnapshot, - ) => ( -
- item: {item.id} -
- )} -
- ))} - {droppableProvided.placeholder} -
- )} -
+ <> + + {(droppableProvided: DroppableProvided) => ( +
+ {items.map((item: Item, index: number) => ( + + {( + provided: DraggableProvided, + snapshot: DraggableStateSnapshot, + ) => ( +
+ item: {item.id} +
+ )} +
+ ))} + {droppableProvided.placeholder} +
+ )} +
+ {props.anotherChild || null} + ); } diff --git a/test/unit/integration/drag-handle/mouse-sensor/force-abort.spec.js b/test/unit/integration/drag-handle/mouse-sensor/force-abort.spec.js index c3478f61e2..92f3619591 100644 --- a/test/unit/integration/drag-handle/mouse-sensor/force-abort.spec.js +++ b/test/unit/integration/drag-handle/mouse-sensor/force-abort.spec.js @@ -1,2 +1,60 @@ // @flow -it('should abort a drag if instructed', () => {}); +import invariant from 'tiny-invariant'; +import React, { useEffect, useState, useRef } from 'react'; +import { act } from 'react-dom/test-utils'; +import { render, fireEvent } from 'react-testing-library'; +import { isDragging, getOffset } from '../util'; +import App from '../app'; +import { simpleLift } from './util'; + +jest.useFakeTimers(); + +it('should abort a drag if instructed', () => { + function Throw() { + const shouldThrowRef = useRef(false); + const [, setShouldThrow] = useState(false); + + if (shouldThrowRef.current) { + shouldThrowRef.current = false; + // throw new Error('yolo'); + invariant(false, 'throwing'); + } + + useEffect(() => { + // trigger re-render so we can throw during render pass + setTimeout(() => { + shouldThrowRef.current = true; + setShouldThrow(true); + }); + }, []); + console.log('rendered. should throw:', shouldThrowRef.current); + return null; + } + const { getByText } = render(} />); + + const handle: HTMLElement = getByText('item: 0'); + + simpleLift(handle); + expect(isDragging(handle)).toBe(true); + + // fast forward fake timer - will throw + act(() => { + jest.runOnlyPendingTimers(); + }); + // no longer dragging - recovered from error + const newHandle: HTMLElement = getByText('item: 0'); + // handle is a new element + expect(newHandle).not.toBe(handle); + expect(isDragging(newHandle)).toBe(false); + + expect(() => { + // trying to move the mounted and unmounted handles + fireEvent.mouseMove(handle, { clientX: 100, clientY: 100 }); + fireEvent.mouseMove(newHandle, { clientX: 100, clientY: 100 }); + // flush frames which would cause movement + requestAnimationFrame.flush(); + }).not.toThrow(); + + expect(getOffset(handle)).toEqual({ x: 0, y: 0 }); + expect(getOffset(newHandle)).toEqual({ x: 0, y: 0 }); +}); From 61e394d6ab0db77f9c09447b404c7261dba78b67 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Thu, 23 May 2019 13:50:30 +1000 Subject: [PATCH 069/308] removing force abort test --- .../mouse-sensor/force-abort.spec.js | 60 ------------------- 1 file changed, 60 deletions(-) delete mode 100644 test/unit/integration/drag-handle/mouse-sensor/force-abort.spec.js diff --git a/test/unit/integration/drag-handle/mouse-sensor/force-abort.spec.js b/test/unit/integration/drag-handle/mouse-sensor/force-abort.spec.js deleted file mode 100644 index 92f3619591..0000000000 --- a/test/unit/integration/drag-handle/mouse-sensor/force-abort.spec.js +++ /dev/null @@ -1,60 +0,0 @@ -// @flow -import invariant from 'tiny-invariant'; -import React, { useEffect, useState, useRef } from 'react'; -import { act } from 'react-dom/test-utils'; -import { render, fireEvent } from 'react-testing-library'; -import { isDragging, getOffset } from '../util'; -import App from '../app'; -import { simpleLift } from './util'; - -jest.useFakeTimers(); - -it('should abort a drag if instructed', () => { - function Throw() { - const shouldThrowRef = useRef(false); - const [, setShouldThrow] = useState(false); - - if (shouldThrowRef.current) { - shouldThrowRef.current = false; - // throw new Error('yolo'); - invariant(false, 'throwing'); - } - - useEffect(() => { - // trigger re-render so we can throw during render pass - setTimeout(() => { - shouldThrowRef.current = true; - setShouldThrow(true); - }); - }, []); - console.log('rendered. should throw:', shouldThrowRef.current); - return null; - } - const { getByText } = render(} />); - - const handle: HTMLElement = getByText('item: 0'); - - simpleLift(handle); - expect(isDragging(handle)).toBe(true); - - // fast forward fake timer - will throw - act(() => { - jest.runOnlyPendingTimers(); - }); - // no longer dragging - recovered from error - const newHandle: HTMLElement = getByText('item: 0'); - // handle is a new element - expect(newHandle).not.toBe(handle); - expect(isDragging(newHandle)).toBe(false); - - expect(() => { - // trying to move the mounted and unmounted handles - fireEvent.mouseMove(handle, { clientX: 100, clientY: 100 }); - fireEvent.mouseMove(newHandle, { clientX: 100, clientY: 100 }); - // flush frames which would cause movement - requestAnimationFrame.flush(); - }).not.toThrow(); - - expect(getOffset(handle)).toEqual({ x: 0, y: 0 }); - expect(getOffset(newHandle)).toEqual({ x: 0, y: 0 }); -}); From 7192311e1ea03c2b66640857a4d0139937908761 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Thu, 23 May 2019 15:18:00 +1000 Subject: [PATCH 070/308] wip --- .eslintrc.js | 2 +- src/debug/middleware/log.js | 14 +++- src/state/create-store.js | 2 +- src/view/drag-drop-context/app.jsx | 1 + .../{server-side-rendering => }/app.jsx | 6 +- test/unit/integration/drag-handle/app.jsx | 69 +++++++++---------- .../mouse-sensor/force-abort.spec.js | 25 +++++++ ...event-standard-keys-while-dragging.spec.js | 21 +++++- ...ent-rendering-should-not-kill-drag.spec.js | 21 ++++++ 9 files changed, 116 insertions(+), 45 deletions(-) rename test/unit/integration/{server-side-rendering => }/app.jsx (83%) create mode 100644 test/unit/integration/drag-handle/mouse-sensor/force-abort.spec.js create mode 100644 test/unit/integration/drag-handle/parent-rendering-should-not-kill-drag.spec.js diff --git a/.eslintrc.js b/.eslintrc.js index 92632eb9d0..5cbd394472 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -162,6 +162,6 @@ module.exports = { // Enforce rules of hooks 'react-hooks/rules-of-hooks': 'error', // Second argument to hook functions - 'react-hooks/exhaustive-deps': 'warn', + 'react-hooks/exhaustive-deps': 'error', }, }; diff --git a/src/debug/middleware/log.js b/src/debug/middleware/log.js index b187df0fbf..96d5537f91 100644 --- a/src/debug/middleware/log.js +++ b/src/debug/middleware/log.js @@ -2,11 +2,19 @@ /* eslint-disable no-console */ import type { Action, Store } from '../../state/store-types'; -export default (store: Store) => (next: Action => mixed) => ( - action: Action, -): any => { +type Mode = 'verbose' | 'light'; + +export default (mode?: Mode = 'verbose') => (store: Store) => ( + next: Action => mixed, +) => (action: Action): any => { + if (mode === 'light') { + console.log('🏃‍ Action:', action); + return next(action); + } + console.group(`action: ${action.type}`); console.log('action payload', action.payload); + console.log('state before', store.getState()); const result: mixed = next(action); diff --git a/src/state/create-store.js b/src/state/create-store.js index 6012f2de04..f769feaea5 100644 --- a/src/state/create-store.js +++ b/src/state/create-store.js @@ -54,7 +54,7 @@ export default ({ // > uncomment to use // debugging logger - // require('../debug/middleware/log').default, + require('../debug/middleware/log').default('light'), // // user timing api // require('../debug/middleware/user-timing').default, // debugging timer diff --git a/src/view/drag-drop-context/app.jsx b/src/view/drag-drop-context/app.jsx index b0221fd062..de0a5230a0 100644 --- a/src/view/drag-drop-context/app.jsx +++ b/src/view/drag-drop-context/app.jsx @@ -43,6 +43,7 @@ import useStartupValidation from './use-startup-validation'; import usePrevious from '../use-previous-ref'; import { warning } from '../../dev-warning'; import useSensorMarshal from '../use-sensor-marshal/use-sensor-marshal'; +import useLayoutEffect from '../use-isomorphic-layout-effect'; type Props = {| ...Responders, diff --git a/test/unit/integration/server-side-rendering/app.jsx b/test/unit/integration/app.jsx similarity index 83% rename from test/unit/integration/server-side-rendering/app.jsx rename to test/unit/integration/app.jsx index 462483c6b6..701065af7f 100644 --- a/test/unit/integration/server-side-rendering/app.jsx +++ b/test/unit/integration/app.jsx @@ -1,8 +1,8 @@ // @flow import React from 'react'; -import { DragDropContext, Droppable, Draggable } from '../../../../src'; -import type { Provided as DroppableProvided } from '../../../../src/view/droppable/droppable-types'; -import type { Provided as DraggableProvided } from '../../../../src/view/draggable/draggable-types'; +import { DragDropContext, Droppable, Draggable } from '../../../src'; +import type { Provided as DroppableProvided } from '../../../src/view/droppable/droppable-types'; +import type { Provided as DraggableProvided } from '../../../src/view/draggable/draggable-types'; export default class App extends React.Component<*, *> { onDragStart = () => { diff --git a/test/unit/integration/drag-handle/app.jsx b/test/unit/integration/drag-handle/app.jsx index 48946987c0..58779340d4 100644 --- a/test/unit/integration/drag-handle/app.jsx +++ b/test/unit/integration/drag-handle/app.jsx @@ -47,42 +47,39 @@ export default function App(props: Props) { onDragEnd={onDragEnd} __unstableSensors={sensors} > - <> - - {(droppableProvided: DroppableProvided) => ( -
- {items.map((item: Item, index: number) => ( - - {( - provided: DraggableProvided, - snapshot: DraggableStateSnapshot, - ) => ( -
- item: {item.id} -
- )} -
- ))} - {droppableProvided.placeholder} -
- )} -
- {props.anotherChild || null} - + + {(droppableProvided: DroppableProvided) => ( +
+ {items.map((item: Item, index: number) => ( + + {( + provided: DraggableProvided, + snapshot: DraggableStateSnapshot, + ) => ( +
+ item: {item.id} +
+ )} +
+ ))} + {droppableProvided.placeholder} +
+ )} +
); } diff --git a/test/unit/integration/drag-handle/mouse-sensor/force-abort.spec.js b/test/unit/integration/drag-handle/mouse-sensor/force-abort.spec.js new file mode 100644 index 0000000000..17b2b71aa8 --- /dev/null +++ b/test/unit/integration/drag-handle/mouse-sensor/force-abort.spec.js @@ -0,0 +1,25 @@ +// @flow +import React from 'react'; +import { createEvent, fireEvent, render } from 'react-testing-library'; +import * as keyCodes from '../../../../../src/view/key-codes'; +import App, { type Item } from '../app'; +import { isDragging } from '../util'; +import { simpleLift } from './util'; + +it('should prevent enter or tab being pressed during a drag', () => { + const items: Item[] = [{ id: '0', isEnabled: true }]; + const container: HTMLElement = document.createElement('div'); + document.body.appendChild(container); + + const { getByText, rerender } = render(, { + container, + baseElement: container, + }); + const handle: HTMLElement = getByText('item: 0'); + + simpleLift(handle); + expect(isDragging(handle)).toBe(true); + + console.warn('re-render'); + rerender(); +}); diff --git a/test/unit/integration/drag-handle/mouse-sensor/prevent-standard-keys-while-dragging.spec.js b/test/unit/integration/drag-handle/mouse-sensor/prevent-standard-keys-while-dragging.spec.js index 24c1b6233a..ae72cced61 100644 --- a/test/unit/integration/drag-handle/mouse-sensor/prevent-standard-keys-while-dragging.spec.js +++ b/test/unit/integration/drag-handle/mouse-sensor/prevent-standard-keys-while-dragging.spec.js @@ -1,2 +1,21 @@ // @flow -it('should prevent enter or tab being pressed during a drag', () => {}); +import React from 'react'; +import { createEvent, fireEvent, render } from 'react-testing-library'; +import * as keyCodes from '../../../../../src/view/key-codes'; +import App from '../app'; +import { isDragging } from '../util'; +import { simpleLift } from './util'; + +it('should prevent enter or tab being pressed during a drag', () => { + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + simpleLift(handle); + expect(isDragging(handle)).toBe(true); + + [keyCodes.enter, keyCodes.tab].forEach((keyCode: number) => { + const event: Event = createEvent.keyDown(handle, { keyCode }); + fireEvent(handle, event); + expect(event.defaultPrevented).toBe(true); + }); +}); diff --git a/test/unit/integration/drag-handle/parent-rendering-should-not-kill-drag.spec.js b/test/unit/integration/drag-handle/parent-rendering-should-not-kill-drag.spec.js new file mode 100644 index 0000000000..138333726c --- /dev/null +++ b/test/unit/integration/drag-handle/parent-rendering-should-not-kill-drag.spec.js @@ -0,0 +1,21 @@ +// @flow +import React from 'react'; +import { render } from 'react-testing-library'; +import { isDragging } from './util'; +import { simpleLift } from './mouse-sensor/util'; +import App from './app'; + +it('should not abort a drag if a parent render occurs', () => { + const { getByText, rerender } = render(); + const handle: HTMLElement = getByText('item: 0'); + + simpleLift(handle); + expect(isDragging(handle)).toBe(true); + + rerender(); + + // handle element is unchanged + expect(getByText('item: 0')).toBe(handle); + // it is still dragging + expect(isDragging(handle)).toBe(true); +}); From fc1cae486461d82b3dba27dac270557fc88b308f Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Thu, 23 May 2019 16:44:09 +1000 Subject: [PATCH 071/308] more tests --- src/debug/middleware/log.js | 2 +- test/unit/integration/drag-handle/app.jsx | 1 + .../mouse-sensor/abort-on-error.spec.js | 109 ++++++++++++++++++ .../mouse-sensor/force-abort.spec.js | 25 ---- ...ent-rendering-should-not-kill-drag.spec.js | 6 +- 5 files changed, 114 insertions(+), 29 deletions(-) create mode 100644 test/unit/integration/drag-handle/mouse-sensor/abort-on-error.spec.js delete mode 100644 test/unit/integration/drag-handle/mouse-sensor/force-abort.spec.js rename test/unit/integration/drag-handle/{ => mouse-sensor}/parent-rendering-should-not-kill-drag.spec.js (81%) diff --git a/src/debug/middleware/log.js b/src/debug/middleware/log.js index 96d5537f91..1b91c321ba 100644 --- a/src/debug/middleware/log.js +++ b/src/debug/middleware/log.js @@ -8,7 +8,7 @@ export default (mode?: Mode = 'verbose') => (store: Store) => ( next: Action => mixed, ) => (action: Action): any => { if (mode === 'light') { - console.log('🏃‍ Action:', action); + console.log('🏃‍ Action:', action.type); return next(action); } diff --git a/test/unit/integration/drag-handle/app.jsx b/test/unit/integration/drag-handle/app.jsx index 58779340d4..ac596b1539 100644 --- a/test/unit/integration/drag-handle/app.jsx +++ b/test/unit/integration/drag-handle/app.jsx @@ -80,6 +80,7 @@ export default function App(props: Props) {
)}
+ {props.anotherChild || null}
); } diff --git a/test/unit/integration/drag-handle/mouse-sensor/abort-on-error.spec.js b/test/unit/integration/drag-handle/mouse-sensor/abort-on-error.spec.js new file mode 100644 index 0000000000..417dd5b75f --- /dev/null +++ b/test/unit/integration/drag-handle/mouse-sensor/abort-on-error.spec.js @@ -0,0 +1,109 @@ +// @flow +import invariant from 'tiny-invariant'; +import React, { useEffect, useState, useRef } from 'react'; +import { render, act, createEvent } from 'react-testing-library'; +import { isDragging, getOffset } from '../util'; +import { simpleLift } from './util'; +import App from '../app'; +import { noop } from '../../../../../src/empty'; + +jest.useFakeTimers(); +jest.spyOn(console, 'error').mockImplementation(noop); + +type Props = { + throw: () => void, +}; + +function Vomit(props: Props) { + const setShouldThrow = useState(false)[1]; + const shouldThrowRef = useRef(false); + + useEffect(() => { + setTimeout(() => { + shouldThrowRef.current = true; + setShouldThrow(true); + }); + }, [setShouldThrow]); + + if (shouldThrowRef.current) { + shouldThrowRef.current = false; + props.throw(); + } + + return null; +} + +it('should abort a drag if an invariant error occurs in the application', () => { + const { getByText } = render( + invariant(false, 'Do not pass go, do not collect $200')} + /> + } + />, + ); + const handle: HTMLElement = getByText('item: 0'); + + simpleLift(handle); + expect(isDragging(handle)).toBe(true); + + act(() => { + jest.runOnlyPendingTimers(); + }); + + const newHandle: HTMLElement = getByText('item: 0'); + // handle is now a new element + expect(handle).not.toBe(newHandle); + expect(isDragging(newHandle)).toBe(false); + + // moving the handles around + expect(() => { + createEvent.mouseMove(handle, { clientX: 100, clientY: 100 }); + createEvent.mouseMove(newHandle, { clientX: 100, clientY: 100 }); + }).not.toThrow(); + + // would normally release any movements + requestAnimationFrame.flush(); + + expect(getOffset(handle)).toEqual({ x: 0, y: 0 }); + expect(getOffset(newHandle)).toEqual({ x: 0, y: 0 }); +}); + +it('should abort a drag if an a non-invariant error occurs in the application', () => { + const { getByText, queryByText } = render( + { + throw new Error('Raw error throw'); + }} + /> + } + />, + ); + const handle: HTMLElement = getByText('item: 0'); + + simpleLift(handle); + expect(isDragging(handle)).toBe(true); + + expect(() => { + act(() => { + jest.runOnlyPendingTimers(); + }); + }).toThrow(); + + // handle is gone + expect(queryByText('item: 0')).toBe(null); + + // strange - but firing events on old handle + expect(() => { + act(() => { + createEvent.mouseMove(handle, { clientX: 100, clientY: 100 }); + // would normally release any movements + requestAnimationFrame.flush(); + + expect(getOffset(handle)).toEqual({ x: 0, y: 0 }); + }); + }).not.toThrow(); +}); diff --git a/test/unit/integration/drag-handle/mouse-sensor/force-abort.spec.js b/test/unit/integration/drag-handle/mouse-sensor/force-abort.spec.js deleted file mode 100644 index 17b2b71aa8..0000000000 --- a/test/unit/integration/drag-handle/mouse-sensor/force-abort.spec.js +++ /dev/null @@ -1,25 +0,0 @@ -// @flow -import React from 'react'; -import { createEvent, fireEvent, render } from 'react-testing-library'; -import * as keyCodes from '../../../../../src/view/key-codes'; -import App, { type Item } from '../app'; -import { isDragging } from '../util'; -import { simpleLift } from './util'; - -it('should prevent enter or tab being pressed during a drag', () => { - const items: Item[] = [{ id: '0', isEnabled: true }]; - const container: HTMLElement = document.createElement('div'); - document.body.appendChild(container); - - const { getByText, rerender } = render(, { - container, - baseElement: container, - }); - const handle: HTMLElement = getByText('item: 0'); - - simpleLift(handle); - expect(isDragging(handle)).toBe(true); - - console.warn('re-render'); - rerender(); -}); diff --git a/test/unit/integration/drag-handle/parent-rendering-should-not-kill-drag.spec.js b/test/unit/integration/drag-handle/mouse-sensor/parent-rendering-should-not-kill-drag.spec.js similarity index 81% rename from test/unit/integration/drag-handle/parent-rendering-should-not-kill-drag.spec.js rename to test/unit/integration/drag-handle/mouse-sensor/parent-rendering-should-not-kill-drag.spec.js index 138333726c..245c6110bb 100644 --- a/test/unit/integration/drag-handle/parent-rendering-should-not-kill-drag.spec.js +++ b/test/unit/integration/drag-handle/mouse-sensor/parent-rendering-should-not-kill-drag.spec.js @@ -1,9 +1,9 @@ // @flow import React from 'react'; import { render } from 'react-testing-library'; -import { isDragging } from './util'; -import { simpleLift } from './mouse-sensor/util'; -import App from './app'; +import { isDragging } from '../util'; +import { simpleLift } from './util'; +import App from '../app'; it('should not abort a drag if a parent render occurs', () => { const { getByText, rerender } = render(); From 152619ea1d7d19bb9c29a77a20b60684338242da Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Fri, 24 May 2019 08:24:21 +1000 Subject: [PATCH 072/308] generic sensor test framework --- src/state/create-store.js | 2 +- .../sensors/use-mouse-sensor.js | 18 ++- .../is-sloppy-click-threshold-exceeded.js | 9 -- .../shared-behaviours/abort-on-error.spec.js | 125 ++++++++++++++++++ .../drag-handle/shared-behaviours/controls.js | 99 ++++++++++++++ 5 files changed, 239 insertions(+), 14 deletions(-) delete mode 100644 src/view/use-sensor-marshal/sensors/util/is-sloppy-click-threshold-exceeded.js create mode 100644 test/unit/integration/drag-handle/shared-behaviours/abort-on-error.spec.js create mode 100644 test/unit/integration/drag-handle/shared-behaviours/controls.js diff --git a/src/state/create-store.js b/src/state/create-store.js index f769feaea5..1fd4bbf833 100644 --- a/src/state/create-store.js +++ b/src/state/create-store.js @@ -54,7 +54,7 @@ export default ({ // > uncomment to use // debugging logger - require('../debug/middleware/log').default('light'), + // require('../debug/middleware/log').default('light'), // // user timing api // require('../debug/middleware/user-timing').default, // debugging timer diff --git a/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js b/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js index 79339c028d..82336e9946 100644 --- a/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js +++ b/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js @@ -1,6 +1,6 @@ // @flow import invariant from 'tiny-invariant'; -import { useEffect, useRef } from 'react'; +import { useRef } from 'react'; import { useCallback, useMemo } from 'use-memo-one'; import type { Position } from 'css-box-model'; import type { PreDragActions, DragActions } from '../../../types'; @@ -9,16 +9,26 @@ import type { EventOptions, } from '../../event-bindings/event-types'; import bindEvents from '../../event-bindings/bind-events'; -import isSloppyClickThresholdExceeded from './util/is-sloppy-click-threshold-exceeded'; import * as keyCodes from '../../key-codes'; import preventStandardKeyEvents from './util/prevent-standard-key-events'; import supportedPageVisibilityEventName from './util/supported-page-visibility-event-name'; import { warning } from '../../../dev-warning'; import useLayoutEffect from '../../use-isomorphic-layout-effect'; +import { noop } from '../../../empty'; // https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button -const primaryButton: number = 0; -function noop() {} +export const primaryButton: number = 0; +export const sloppyClickThreshold: number = 5; + +function isSloppyClickThresholdExceeded( + original: Position, + current: Position, +): boolean { + return ( + Math.abs(current.x - original.x) >= sloppyClickThreshold || + Math.abs(current.y - original.y) >= sloppyClickThreshold + ); +} type MouseForceChangedEvent = MouseEvent & { webkitForce?: number, diff --git a/src/view/use-sensor-marshal/sensors/util/is-sloppy-click-threshold-exceeded.js b/src/view/use-sensor-marshal/sensors/util/is-sloppy-click-threshold-exceeded.js deleted file mode 100644 index e73f3f2789..0000000000 --- a/src/view/use-sensor-marshal/sensors/util/is-sloppy-click-threshold-exceeded.js +++ /dev/null @@ -1,9 +0,0 @@ -// @flow -import { type Position } from 'css-box-model'; -// The amount of pixels that need to move before we consider the movement -// a drag rather than a click. -export const sloppyClickThreshold: number = 5; - -export default (original: Position, current: Position): boolean => - Math.abs(current.x - original.x) >= sloppyClickThreshold || - Math.abs(current.y - original.y) >= sloppyClickThreshold; diff --git a/test/unit/integration/drag-handle/shared-behaviours/abort-on-error.spec.js b/test/unit/integration/drag-handle/shared-behaviours/abort-on-error.spec.js new file mode 100644 index 0000000000..ae160de3b0 --- /dev/null +++ b/test/unit/integration/drag-handle/shared-behaviours/abort-on-error.spec.js @@ -0,0 +1,125 @@ +// @flow +import invariant from 'tiny-invariant'; +import React, { useEffect, useState, useRef } from 'react'; +import { render, act, createEvent } from 'react-testing-library'; +import { isDragging, getOffset } from '../util'; +import App from '../app'; +import { noop } from '../../../../../src/empty'; +import { forEachSensor, simpleLift, type Control } from './controls'; + +jest.spyOn(console, 'error').mockImplementation(noop); + +type Props = { + throw: () => void, + setForceThrow: (fn: () => void) => void, +}; + +function Vomit(props: Props) { + const setShouldThrow = useState(0)[1]; + const shouldThrowRef = useRef(false); + + function chuck() { + shouldThrowRef.current = true; + setShouldThrow(current => current + 1); + } + + props.setForceThrow(chuck); + + if (shouldThrowRef.current) { + shouldThrowRef.current = false; + props.throw(); + } + + return null; +} + +type Thrower = {| + setForceThrow: (fn: () => void) => void, + execute: () => void, +|}; + +function getThrower(): Thrower { + let current: ?() => void = null; + function setForceThrow(fn) { + current = fn; + } + + function execute() { + act(() => { + invariant(current, 'Expected throw callback to be set'); + current(); + }); + } + + return { setForceThrow, execute }; +} + +forEachSensor((control: Control) => { + it('should abort a drag if an invariant error occurs in the application', () => { + const thrower: Thrower = getThrower(); + const { getByText } = render( + + invariant(false, 'Do not pass go, do not collect $200') + } + setForceThrow={thrower.setForceThrow} + /> + } + />, + ); + const handle: HTMLElement = getByText('item: 0'); + + simpleLift(control, handle); + expect(isDragging(handle)).toBe(true); + + thrower.execute(); + + const newHandle: HTMLElement = getByText('item: 0'); + // handle is now a new element + expect(handle).not.toBe(newHandle); + expect(isDragging(newHandle)).toBe(false); + + // moving the handles around + expect(() => { + control.move(handle); + control.move(newHandle); + expect(getOffset(handle)).toEqual({ x: 0, y: 0 }); + expect(getOffset(newHandle)).toEqual({ x: 0, y: 0 }); + }).not.toThrow(); + }); + + it('should abort a drag if an a non-invariant error occurs in the application', () => { + const thrower: Thrower = getThrower(); + const { getByText, queryByText } = render( + { + throw new Error('Raw error throw'); + }} + setForceThrow={thrower.setForceThrow} + /> + } + />, + ); + const handle: HTMLElement = getByText('item: 0'); + + simpleLift(control, handle); + expect(isDragging(handle)).toBe(true); + + expect(() => { + thrower.execute(); + }).toThrow(); + + // handle is gone + expect(queryByText('item: 0')).toBe(null); + + // strange - but firing events on old handle + expect(() => { + control.move(handle); + expect(getOffset(handle)).toEqual({ x: 0, y: 0 }); + }).not.toThrow(); + }); +}); diff --git a/test/unit/integration/drag-handle/shared-behaviours/controls.js b/test/unit/integration/drag-handle/shared-behaviours/controls.js new file mode 100644 index 0000000000..d12722abcb --- /dev/null +++ b/test/unit/integration/drag-handle/shared-behaviours/controls.js @@ -0,0 +1,99 @@ +// @flow +import { fireEvent, act } from 'react-testing-library'; +import { sloppyClickThreshold } from '../../../../../src/view/use-sensor-marshal/sensors/use-mouse-sensor'; +import { timeForLongPress } from '../../../../../src/view/use-sensor-marshal/sensors/use-touch-sensor'; +import * as keyCodes from '../../../../../src/view/key-codes'; + +export type Control = {| + name: string, + preLift: (handle: HTMLElement) => void, + lift: (handle: HTMLElement) => void, + move: (handle: HTMLElement) => void, + drop: (handle: HTMLElement) => void, +|}; + +export function simpleLift(control: Control, handle: HTMLElement) { + control.preLift(handle); + control.lift(handle); +} + +export const mouse: Control = { + name: 'mouse', + preLift: (handle: HTMLElement) => { + fireEvent.mouseDown(handle); + }, + lift: (handle: HTMLElement) => { + fireEvent.mouseMove(handle, { clientX: 0, clientY: sloppyClickThreshold }); + }, + move: (handle: HTMLElement) => { + fireEvent.mouseMove(handle, { + clientX: 0, + clientY: sloppyClickThreshold + 1, + }); + // movements are throttled by raf + act(() => { + requestAnimationFrame.step(); + }); + }, + drop: (handle: HTMLElement) => { + fireEvent.mouseUp(handle); + }, +}; + +export const keyboard: Control = { + name: 'keyboard', + preLift: () => {}, + lift: (handle: HTMLElement) => { + fireEvent.keyDown(handle, { keyCode: keyCodes.space }); + }, + move: (handle: HTMLElement) => { + fireEvent.keyDown(handle, { + keyCode: keyCodes.arrowDown, + }); + }, + drop: (handle: HTMLElement) => { + fireEvent.keyDown(handle, { keyCode: keyCodes.space }); + }, +}; + +export const touch: Control = { + name: 'touch', + preLift: (handle: HTMLElement) => { + fireEvent.touchStart(handle, { touches: [{ clientX: 0, clientY: 0 }] }); + }, + lift: () => { + act(() => { + jest.runTimersToTime(timeForLongPress); + }); + }, + move: (handle: HTMLElement) => { + fireEvent.touchMove(handle, { + touches: [{ clientX: 0, clientY: 1 }], + }); + act(() => { + // movements are throttled by raf + requestAnimationFrame.step(); + }); + }, + drop: (handle: HTMLElement) => { + fireEvent.touchEnd(handle); + }, +}; + +export const controls: Control[] = [mouse, keyboard, touch]; + +export const forEachSensor = (tests: (control: Control) => void) => { + controls.forEach((control: Control) => { + describe(`with: ${control.name}`, () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + tests(control); + }); + }); +}; From 2a721cc7d5d4b3df7de396fb72a20cef6a9cac0b Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Fri, 24 May 2019 11:25:14 +1000 Subject: [PATCH 073/308] adding more control tests --- .../sensors/use-touch-sensor.js | 3 + .../mouse-sensor/abort-on-error.spec.js | 109 ------------------ .../drag-handle/mouse-sensor/cleanup.spec.js | 41 ------- .../shared-behaviours/cleanup.spec.js | 43 +++++++ 4 files changed, 46 insertions(+), 150 deletions(-) delete mode 100644 test/unit/integration/drag-handle/mouse-sensor/abort-on-error.spec.js delete mode 100644 test/unit/integration/drag-handle/mouse-sensor/cleanup.spec.js create mode 100644 test/unit/integration/drag-handle/shared-behaviours/cleanup.spec.js diff --git a/src/view/use-sensor-marshal/sensors/use-touch-sensor.js b/src/view/use-sensor-marshal/sensors/use-touch-sensor.js index 65e0122873..c837fb67d6 100644 --- a/src/view/use-sensor-marshal/sensors/use-touch-sensor.js +++ b/src/view/use-sensor-marshal/sensors/use-touch-sensor.js @@ -249,6 +249,7 @@ export default function useMouseSensor( return; } + console.log('touches', event.touches); const touch: Touch = event.touches[0]; const { clientX, clientY } = touch; const point: Position = { @@ -300,9 +301,11 @@ export default function useMouseSensor( clearTimeout(current.longPressTimerId); } + console.log('STOPPing'); setPhase(idle); unbindEventsRef.current(); + console.log('listen for capture'); listenForCapture(); }, [listenForCapture, setPhase]); diff --git a/test/unit/integration/drag-handle/mouse-sensor/abort-on-error.spec.js b/test/unit/integration/drag-handle/mouse-sensor/abort-on-error.spec.js deleted file mode 100644 index 417dd5b75f..0000000000 --- a/test/unit/integration/drag-handle/mouse-sensor/abort-on-error.spec.js +++ /dev/null @@ -1,109 +0,0 @@ -// @flow -import invariant from 'tiny-invariant'; -import React, { useEffect, useState, useRef } from 'react'; -import { render, act, createEvent } from 'react-testing-library'; -import { isDragging, getOffset } from '../util'; -import { simpleLift } from './util'; -import App from '../app'; -import { noop } from '../../../../../src/empty'; - -jest.useFakeTimers(); -jest.spyOn(console, 'error').mockImplementation(noop); - -type Props = { - throw: () => void, -}; - -function Vomit(props: Props) { - const setShouldThrow = useState(false)[1]; - const shouldThrowRef = useRef(false); - - useEffect(() => { - setTimeout(() => { - shouldThrowRef.current = true; - setShouldThrow(true); - }); - }, [setShouldThrow]); - - if (shouldThrowRef.current) { - shouldThrowRef.current = false; - props.throw(); - } - - return null; -} - -it('should abort a drag if an invariant error occurs in the application', () => { - const { getByText } = render( - invariant(false, 'Do not pass go, do not collect $200')} - /> - } - />, - ); - const handle: HTMLElement = getByText('item: 0'); - - simpleLift(handle); - expect(isDragging(handle)).toBe(true); - - act(() => { - jest.runOnlyPendingTimers(); - }); - - const newHandle: HTMLElement = getByText('item: 0'); - // handle is now a new element - expect(handle).not.toBe(newHandle); - expect(isDragging(newHandle)).toBe(false); - - // moving the handles around - expect(() => { - createEvent.mouseMove(handle, { clientX: 100, clientY: 100 }); - createEvent.mouseMove(newHandle, { clientX: 100, clientY: 100 }); - }).not.toThrow(); - - // would normally release any movements - requestAnimationFrame.flush(); - - expect(getOffset(handle)).toEqual({ x: 0, y: 0 }); - expect(getOffset(newHandle)).toEqual({ x: 0, y: 0 }); -}); - -it('should abort a drag if an a non-invariant error occurs in the application', () => { - const { getByText, queryByText } = render( - { - throw new Error('Raw error throw'); - }} - /> - } - />, - ); - const handle: HTMLElement = getByText('item: 0'); - - simpleLift(handle); - expect(isDragging(handle)).toBe(true); - - expect(() => { - act(() => { - jest.runOnlyPendingTimers(); - }); - }).toThrow(); - - // handle is gone - expect(queryByText('item: 0')).toBe(null); - - // strange - but firing events on old handle - expect(() => { - act(() => { - createEvent.mouseMove(handle, { clientX: 100, clientY: 100 }); - // would normally release any movements - requestAnimationFrame.flush(); - - expect(getOffset(handle)).toEqual({ x: 0, y: 0 }); - }); - }).not.toThrow(); -}); diff --git a/test/unit/integration/drag-handle/mouse-sensor/cleanup.spec.js b/test/unit/integration/drag-handle/mouse-sensor/cleanup.spec.js deleted file mode 100644 index a91e0c0e7b..0000000000 --- a/test/unit/integration/drag-handle/mouse-sensor/cleanup.spec.js +++ /dev/null @@ -1,41 +0,0 @@ -// @flow -import React from 'react'; -import { render } from 'react-testing-library'; -import { isDragging } from '../util'; -import App from '../app'; -import { simpleLift } from './util'; - -function getCallCount(myMock): number { - return myMock.mock.calls.length; -} - -it('should remove all window listeners when unmounting', () => { - jest.spyOn(window, 'addEventListener'); - jest.spyOn(window, 'removeEventListener'); - - const { unmount } = render(); - - unmount(); - - expect(getCallCount(window.addEventListener)).toEqual( - getCallCount(window.removeEventListener), - ); -}); - -it('should remove all window listeners when unmounting mid drag', () => { - jest.spyOn(window, 'addEventListener'); - jest.spyOn(window, 'removeEventListener'); - - const { unmount, getByText } = render(); - const handle: HTMLElement = getByText('item: 0'); - - // mid drag - simpleLift(handle); - expect(isDragging(handle)).toEqual(true); - - unmount(); - - expect(getCallCount(window.addEventListener)).toEqual( - getCallCount(window.removeEventListener), - ); -}); diff --git a/test/unit/integration/drag-handle/shared-behaviours/cleanup.spec.js b/test/unit/integration/drag-handle/shared-behaviours/cleanup.spec.js new file mode 100644 index 0000000000..b97418aa84 --- /dev/null +++ b/test/unit/integration/drag-handle/shared-behaviours/cleanup.spec.js @@ -0,0 +1,43 @@ +// @flow +import React from 'react'; +import { render } from 'react-testing-library'; +import { isDragging } from '../util'; +import App from '../app'; +import { forEachSensor, simpleLift, type Control } from './controls'; + +function getCallCount(myMock): number { + return myMock.mock.calls.length; +} + +forEachSensor((control: Control) => { + it('should remove all window listeners when unmounting', () => { + jest.spyOn(window, 'addEventListener'); + jest.spyOn(window, 'removeEventListener'); + + const { unmount } = render(); + + unmount(); + + expect(getCallCount(window.addEventListener)).toEqual( + getCallCount(window.removeEventListener), + ); + }); + + it('should remove all window listeners when unmounting mid drag', () => { + jest.spyOn(window, 'addEventListener'); + jest.spyOn(window, 'removeEventListener'); + + const { unmount, getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + // mid drag + simpleLift(control, handle); + expect(isDragging(handle)).toEqual(true); + + unmount(); + + expect(getCallCount(window.addEventListener)).toEqual( + getCallCount(window.removeEventListener), + ); + }); +}); From d6552389593b06be3eba4f340d9af1fd2265fa6e Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Fri, 24 May 2019 11:26:37 +1000 Subject: [PATCH 074/308] moving into shared --- ...ent-rendering-should-not-kill-drag.spec.js | 21 ----------------- ...ent-rendering-should-not-kill-drag.spec.js | 23 +++++++++++++++++++ 2 files changed, 23 insertions(+), 21 deletions(-) delete mode 100644 test/unit/integration/drag-handle/mouse-sensor/parent-rendering-should-not-kill-drag.spec.js create mode 100644 test/unit/integration/drag-handle/shared-behaviours/parent-rendering-should-not-kill-drag.spec.js diff --git a/test/unit/integration/drag-handle/mouse-sensor/parent-rendering-should-not-kill-drag.spec.js b/test/unit/integration/drag-handle/mouse-sensor/parent-rendering-should-not-kill-drag.spec.js deleted file mode 100644 index 245c6110bb..0000000000 --- a/test/unit/integration/drag-handle/mouse-sensor/parent-rendering-should-not-kill-drag.spec.js +++ /dev/null @@ -1,21 +0,0 @@ -// @flow -import React from 'react'; -import { render } from 'react-testing-library'; -import { isDragging } from '../util'; -import { simpleLift } from './util'; -import App from '../app'; - -it('should not abort a drag if a parent render occurs', () => { - const { getByText, rerender } = render(); - const handle: HTMLElement = getByText('item: 0'); - - simpleLift(handle); - expect(isDragging(handle)).toBe(true); - - rerender(); - - // handle element is unchanged - expect(getByText('item: 0')).toBe(handle); - // it is still dragging - expect(isDragging(handle)).toBe(true); -}); diff --git a/test/unit/integration/drag-handle/shared-behaviours/parent-rendering-should-not-kill-drag.spec.js b/test/unit/integration/drag-handle/shared-behaviours/parent-rendering-should-not-kill-drag.spec.js new file mode 100644 index 0000000000..2a18f5953a --- /dev/null +++ b/test/unit/integration/drag-handle/shared-behaviours/parent-rendering-should-not-kill-drag.spec.js @@ -0,0 +1,23 @@ +// @flow +import React from 'react'; +import { render } from 'react-testing-library'; +import { isDragging } from '../util'; +import App from '../app'; +import { forEachSensor, simpleLift, type Control } from './controls'; + +forEachSensor((control: Control) => { + it('should not abort a drag if a parent render occurs', () => { + const { getByText, rerender } = render(); + const handle: HTMLElement = getByText('item: 0'); + + simpleLift(control, handle); + expect(isDragging(handle)).toBe(true); + + rerender(); + + // handle element is unchanged + expect(getByText('item: 0')).toBe(handle); + // it is still dragging + expect(isDragging(handle)).toBe(true); + }); +}); From 63146afaf3356aff19c5e42056c83842996103c2 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Fri, 24 May 2019 13:26:42 +1000 Subject: [PATCH 075/308] more control tests --- .../cancel-while-dragging.spec.js | 72 ------------------- .../shared-behaviours/abort-on-error.spec.js | 2 +- .../cancel-while-dragging.spec.js | 72 +++++++++++++++++++ .../drag-handle/shared-behaviours/controls.js | 16 ++++- .../validate-controls.spec.js | 28 ++++++++ 5 files changed, 116 insertions(+), 74 deletions(-) delete mode 100644 test/unit/integration/drag-handle/mouse-sensor/cancel-while-dragging.spec.js create mode 100644 test/unit/integration/drag-handle/shared-behaviours/cancel-while-dragging.spec.js create mode 100644 test/unit/integration/drag-handle/shared-behaviours/validate-controls.spec.js diff --git a/test/unit/integration/drag-handle/mouse-sensor/cancel-while-dragging.spec.js b/test/unit/integration/drag-handle/mouse-sensor/cancel-while-dragging.spec.js deleted file mode 100644 index 83a8e44fbc..0000000000 --- a/test/unit/integration/drag-handle/mouse-sensor/cancel-while-dragging.spec.js +++ /dev/null @@ -1,72 +0,0 @@ -// @flow -import React from 'react'; -import { fireEvent, render } from 'react-testing-library'; -import * as keyCodes from '../../../../../src/view/key-codes'; -import App from '../app'; -import { isDragging } from '../util'; -import { simpleLift } from './util'; -import supportedEventName from '../../../../../src/view/use-sensor-marshal/sensors/util/supported-page-visibility-event-name'; - -it('should cancel when pressing escape', () => { - const { getByText } = render(); - const handle: HTMLElement = getByText('item: 0'); - - simpleLift(handle); - expect(isDragging(handle)).toBe(true); - - // cancel - const event: Event = new KeyboardEvent('keydown', { - keyCode: keyCodes.escape, - bubbles: true, - cancelable: true, - }); - - fireEvent(handle, event); - - // event consumed - expect(event.defaultPrevented).toBe(true); - // drag ended - expect(isDragging(handle)).toBe(false); -}); - -it('should cancel when window is resized', () => { - const { getByText } = render(); - const handle: HTMLElement = getByText('item: 0'); - - simpleLift(handle); - expect(isDragging(handle)).toBe(true); - - // cancel - const event: Event = new Event('resize', { - bubbles: true, - cancelable: true, - }); - - fireEvent(handle, event); - - // event not consumed as it is an indirect cancel - expect(event.defaultPrevented).toBe(false); - // drag ended - expect(isDragging(handle)).toBe(false); -}); - -it('should cancel when there is a visibility change', () => { - const { getByText } = render(); - const handle: HTMLElement = getByText('item: 0'); - - simpleLift(handle); - expect(isDragging(handle)).toBe(true); - - // cancel - const event: Event = new Event(supportedEventName, { - bubbles: true, - cancelable: true, - }); - - fireEvent(handle, event); - - // event not consumed as it is an indirect cancel - expect(event.defaultPrevented).toBe(false); - // drag ended - expect(isDragging(handle)).toBe(false); -}); diff --git a/test/unit/integration/drag-handle/shared-behaviours/abort-on-error.spec.js b/test/unit/integration/drag-handle/shared-behaviours/abort-on-error.spec.js index ae160de3b0..c7f7ff21f4 100644 --- a/test/unit/integration/drag-handle/shared-behaviours/abort-on-error.spec.js +++ b/test/unit/integration/drag-handle/shared-behaviours/abort-on-error.spec.js @@ -1,7 +1,7 @@ // @flow import invariant from 'tiny-invariant'; import React, { useEffect, useState, useRef } from 'react'; -import { render, act, createEvent } from 'react-testing-library'; +import { render, act } from 'react-testing-library'; import { isDragging, getOffset } from '../util'; import App from '../app'; import { noop } from '../../../../../src/empty'; diff --git a/test/unit/integration/drag-handle/shared-behaviours/cancel-while-dragging.spec.js b/test/unit/integration/drag-handle/shared-behaviours/cancel-while-dragging.spec.js new file mode 100644 index 0000000000..52bfafd0bc --- /dev/null +++ b/test/unit/integration/drag-handle/shared-behaviours/cancel-while-dragging.spec.js @@ -0,0 +1,72 @@ +// @flow +import React from 'react'; +import { createEvent, fireEvent, render } from 'react-testing-library'; +import * as keyCodes from '../../../../../src/view/key-codes'; +import App from '../app'; +import { isDragging } from '../util'; +import supportedEventName from '../../../../../src/view/use-sensor-marshal/sensors/util/supported-page-visibility-event-name'; +import { forEachSensor, simpleLift, type Control } from './controls'; + +forEachSensor((control: Control) => { + it('should cancel when pressing escape', () => { + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + simpleLift(control, handle); + expect(isDragging(handle)).toBe(true); + + // cancel + const event: Event = createEvent.keyDown(handle, { + keyCode: keyCodes.escape, + }); + + fireEvent(handle, event); + + // event consumed + expect(event.defaultPrevented).toBe(true); + // drag ended + expect(isDragging(handle)).toBe(false); + }); + + it('should cancel when window is resized', () => { + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + simpleLift(control, handle); + expect(isDragging(handle)).toBe(true); + + // cancel + const event: Event = new Event('resize', { + bubbles: true, + cancelable: true, + }); + + fireEvent(handle, event); + + // event not consumed as it is an indirect cancel + expect(event.defaultPrevented).toBe(false); + // drag ended + expect(isDragging(handle)).toBe(false); + }); + + it('should cancel when there is a visibility change', () => { + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + simpleLift(control, handle); + expect(isDragging(handle)).toBe(true); + + // cancel + const event: Event = new Event(supportedEventName, { + bubbles: true, + cancelable: true, + }); + + fireEvent(handle, event); + + // event not consumed as it is an indirect cancel + expect(event.defaultPrevented).toBe(false); + // drag ended + expect(isDragging(handle)).toBe(false); + }); +}); diff --git a/test/unit/integration/drag-handle/shared-behaviours/controls.js b/test/unit/integration/drag-handle/shared-behaviours/controls.js index d12722abcb..6abc2dd67d 100644 --- a/test/unit/integration/drag-handle/shared-behaviours/controls.js +++ b/test/unit/integration/drag-handle/shared-behaviours/controls.js @@ -1,5 +1,5 @@ // @flow -import { fireEvent, act } from 'react-testing-library'; +import { createEvent, fireEvent, act } from 'react-testing-library'; import { sloppyClickThreshold } from '../../../../../src/view/use-sensor-marshal/sensors/use-mouse-sensor'; import { timeForLongPress } from '../../../../../src/view/use-sensor-marshal/sensors/use-touch-sensor'; import * as keyCodes from '../../../../../src/view/key-codes'; @@ -17,6 +17,16 @@ export function simpleLift(control: Control, handle: HTMLElement) { control.lift(handle); } +function getTransitionEnd(): Event { + const event: Event = new Event('transitionend', { + bubbles: true, + cancelable: true, + }); + // cheating and adding property to event as TransitionEvent constructor does not exist + event.propertyName = 'transform'; + return event; +} + export const mouse: Control = { name: 'mouse', preLift: (handle: HTMLElement) => { @@ -37,6 +47,7 @@ export const mouse: Control = { }, drop: (handle: HTMLElement) => { fireEvent.mouseUp(handle); + fireEvent(handle, getTransitionEnd()); }, }; @@ -53,6 +64,7 @@ export const keyboard: Control = { }, drop: (handle: HTMLElement) => { fireEvent.keyDown(handle, { keyCode: keyCodes.space }); + // no drop animation }, }; @@ -77,6 +89,8 @@ export const touch: Control = { }, drop: (handle: HTMLElement) => { fireEvent.touchEnd(handle); + // allow for drop animation + fireEvent(handle, getTransitionEnd()); }, }; diff --git a/test/unit/integration/drag-handle/shared-behaviours/validate-controls.spec.js b/test/unit/integration/drag-handle/shared-behaviours/validate-controls.spec.js new file mode 100644 index 0000000000..fe0f10f0d7 --- /dev/null +++ b/test/unit/integration/drag-handle/shared-behaviours/validate-controls.spec.js @@ -0,0 +1,28 @@ +// @flow +import React from 'react'; +import { render } from 'react-testing-library'; +import { isDragging } from '../util'; +import App from '../app'; +import { forEachSensor, type Control } from './controls'; + +forEachSensor((control: Control) => { + it('should control the drag through the sensor', () => { + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + control.preLift(handle); + expect(isDragging(handle)).toBe(false); + + control.lift(handle); + expect(isDragging(handle)).toBe(true); + + // move + control.move(handle); + expect(isDragging(handle)).toBe(true); + + // drop + console.log('drop start'); + control.drop(handle); + expect(isDragging(handle)).toBe(false); + }); +}); From 88ad9789b409b8bf46829b281ce53787de5ce2f0 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Fri, 24 May 2019 14:20:19 +1000 Subject: [PATCH 076/308] new multi sensor story --- .../use-sensor-marshal/use-sensor-marshal.js | 2 - stories/50-multiple-contexts.stories.js | 8 ++ .../src/programmatic/multiple-contexts.jsx | 127 ++++++++++++++++++ stories/src/programmatic/runsheet.jsx | 3 +- 4 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 stories/50-multiple-contexts.stories.js create mode 100644 stories/src/programmatic/multiple-contexts.jsx diff --git a/src/view/use-sensor-marshal/use-sensor-marshal.js b/src/view/use-sensor-marshal/use-sensor-marshal.js index 29bb49909e..32159cf425 100644 --- a/src/view/use-sensor-marshal/use-sensor-marshal.js +++ b/src/view/use-sensor-marshal/use-sensor-marshal.js @@ -37,9 +37,7 @@ import isHtmlElement from '../is-type-of-element/is-html-element'; import useLayoutEffect from '../use-isomorphic-layout-effect'; function preventDefault(event: Event) { - console.log('preventing click'); event.preventDefault(); - console.log('prevented?', event); } function noop() {} diff --git a/stories/50-multiple-contexts.stories.js b/stories/50-multiple-contexts.stories.js new file mode 100644 index 0000000000..60ddf7037b --- /dev/null +++ b/stories/50-multiple-contexts.stories.js @@ -0,0 +1,8 @@ +// @flow +import React from 'react'; +import { storiesOf } from '@storybook/react'; +import MultipleContexts from './src/programmatic/multiple-contexts'; + +storiesOf('Multiple contexts', module).add('two DragDropContexts', () => ( + +)); diff --git a/stories/src/programmatic/multiple-contexts.jsx b/stories/src/programmatic/multiple-contexts.jsx new file mode 100644 index 0000000000..c02d2adc52 --- /dev/null +++ b/stories/src/programmatic/multiple-contexts.jsx @@ -0,0 +1,127 @@ +// @flow +import React, { useState, useEffect } from 'react'; +import styled from '@emotion/styled'; +import { useCallback } from 'use-memo-one'; +import type { Quote } from '../types'; +import type { + DropResult, + PreDragActions, + DragActions, + Sensor, +} from '../../../src/types'; +import { quotes as initial } from '../data'; +import { DragDropContext } from '../../../src'; +import QuoteList from '../primatives/quote-list'; +import reorder from '../reorder'; + +function sleep(fn: Function, time?: number = 300) { + return new Promise(resolve => { + setTimeout(() => { + fn(); + resolve(); + }, time); + }); +} + +function getSensor(contextId: string, delay: number) { + return function useCustomSensor( + tryGetActionLock: ( + source: Event | Element, + abort: () => void, + ) => ?PreDragActions, + ) { + const start = useCallback( + async function start() { + // grabbing the first drag handle we can + const handle: ?HTMLElement = document.querySelector( + `[data-rbd-drag-handle-context-id="${contextId}"]`, + ); + if (!handle) { + console.log('could not find drag handle'); + return; + } + + const preDrag: ?PreDragActions = tryGetActionLock(handle, () => {}); + + if (!preDrag) { + console.warn('unable to start drag'); + return; + } + console.warn('starting drag'); + + const actions: DragActions = preDrag.lift({ + mode: 'SNAP', + }); + const { moveDown, moveUp, drop } = actions; + + for (let i = 0; i < 100; i++) { + await sleep(moveDown, delay); + await sleep(moveDown, delay); + await sleep(moveUp, delay); + await sleep(moveUp, delay); + } + + await sleep(drop, delay); + }, + [tryGetActionLock], + ); + + useEffect(() => { + start(); + }, [start]); + }; +} + +type Props = {| + initial: Quote[], + sensors?: Sensor[], +|}; + +function QuoteApp(props: Props) { + const [quotes, setQuotes] = useState(props.initial); + + const onDragEnd = useCallback( + function onDragEnd(result: DropResult) { + // dropped outside the list + if (!result.destination) { + return; + } + + if (result.destination.index === result.source.index) { + return; + } + + const newQuotes = reorder( + quotes, + result.source.index, + result.destination.index, + ); + + setQuotes(newQuotes); + }, + [quotes], + ); + + return ( + + + + ); +} + +const Root = styled.div` + display: flex; + justify-content: space-evenly; +`; + +export default function App() { + // This is a pretty basic setup that will not work with hot reloading + // would need to manually pull the context id from a data attribute to make it more resiliant + return ( + + + + + + ); +} diff --git a/stories/src/programmatic/runsheet.jsx b/stories/src/programmatic/runsheet.jsx index acb8f4119e..1b89ebcd7d 100644 --- a/stories/src/programmatic/runsheet.jsx +++ b/stories/src/programmatic/runsheet.jsx @@ -44,9 +44,10 @@ function useDemoSensor( const preDrag: ?PreDragActions = tryGetActionLock(handle, noop); if (!preDrag) { - console.log('unable to start drag'); + console.warn('unable to start drag'); return; } + console.warn('starting drag'); const actions: DragActions = preDrag.lift({ mode: 'SNAP', From b13ae877ba094d97947283c582c8acac662efc3c Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Fri, 24 May 2019 16:28:42 +1000 Subject: [PATCH 077/308] multiple contexts --- src/view/event-bindings/event-types.js | 1 + .../src/programmatic/multiple-contexts.jsx | 92 ++++++++++++++++--- 2 files changed, 78 insertions(+), 15 deletions(-) diff --git a/src/view/event-bindings/event-types.js b/src/view/event-bindings/event-types.js index 42e68d31c7..ae936fb03b 100644 --- a/src/view/event-bindings/event-types.js +++ b/src/view/event-bindings/event-types.js @@ -3,6 +3,7 @@ export type EventOptions = {| passive?: boolean, capture?: boolean, + once?: boolean, |}; export type EventBinding = {| diff --git a/stories/src/programmatic/multiple-contexts.jsx b/stories/src/programmatic/multiple-contexts.jsx index c02d2adc52..7cb8f0b477 100644 --- a/stories/src/programmatic/multiple-contexts.jsx +++ b/stories/src/programmatic/multiple-contexts.jsx @@ -1,5 +1,7 @@ +/* eslint-disable no-await-in-loop */ // @flow -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, createRef } from 'react'; +import invariant from 'tiny-invariant'; import styled from '@emotion/styled'; import { useCallback } from 'use-memo-one'; import type { Quote } from '../types'; @@ -10,9 +12,11 @@ import type { Sensor, } from '../../../src/types'; import { quotes as initial } from '../data'; +import * as dataAttr from '../../../src/view/data-attributes'; import { DragDropContext } from '../../../src'; import QuoteList from '../primatives/quote-list'; import reorder from '../reorder'; +import bindEvents from '../../../src/view/event-bindings/bind-events'; function sleep(fn: Function, time?: number = 300) { return new Promise(resolve => { @@ -23,7 +27,7 @@ function sleep(fn: Function, time?: number = 300) { }); } -function getSensor(contextId: string, delay: number) { +function getSensor(getContextId: () => string, delay: number) { return function useCustomSensor( tryGetActionLock: ( source: Event | Element, @@ -34,7 +38,7 @@ function getSensor(contextId: string, delay: number) { async function start() { // grabbing the first drag handle we can const handle: ?HTMLElement = document.querySelector( - `[data-rbd-drag-handle-context-id="${contextId}"]`, + `[data-rbd-drag-handle-context-id="${getContextId()}"]`, ); if (!handle) { console.log('could not find drag handle'); @@ -52,16 +56,35 @@ function getSensor(contextId: string, delay: number) { const actions: DragActions = preDrag.lift({ mode: 'SNAP', }); - const { moveDown, moveUp, drop } = actions; + const { moveDown, moveUp, drop, isActive, cancel } = actions; + + const unbind = bindEvents(window, [ + { + eventName: 'resize', + fn: cancel, + options: { once: true }, + }, + ]); + + for (let i = 0; i < 20 && isActive(); i++) { + await sleep(() => { + // might no longer be active after delay + if (!isActive()) { + return; + } + if (i % 2 === 0) { + moveDown(); + } else { + moveUp(); + } + }, delay); + } - for (let i = 0; i < 100; i++) { - await sleep(moveDown, delay); - await sleep(moveDown, delay); - await sleep(moveUp, delay); - await sleep(moveUp, delay); + if (isActive()) { + await sleep(drop, delay); } - await sleep(drop, delay); + unbind(); }, [tryGetActionLock], ); @@ -114,14 +137,53 @@ const Root = styled.div` justify-content: space-evenly; `; +const Column = styled.div``; + +const Title = styled.h3` + text-align: center; +`; + +const selector: string = `[${dataAttr.droppable.contextId}]`; + +function getContextIdFromEl(el: ?HTMLElement) { + invariant(el, 'No ref set'); + const droppable: ?HTMLElement = el.querySelector(selector); + invariant(droppable, 'Could not find droppable'); + const contextId: ?string = droppable.getAttribute( + dataAttr.droppable.contextId, + ); + invariant(contextId, 'Expected data attribute to be set'); + return contextId; +} + export default function App() { - // This is a pretty basic setup that will not work with hot reloading - // would need to manually pull the context id from a data attribute to make it more resiliant + const firstRef = createRef(); + const secondRef = createRef(); + + function getContextId(ref) { + return () => getContextIdFromEl(ref.current); + } + return ( - - - + + Programmatic #1 + + + + Programmatic #2 + + + + User controlled + + ); } From fd2fb8dad1bcc6591fdf919272f2b6756156c970 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Fri, 24 May 2019 17:25:50 +1000 Subject: [PATCH 078/308] wip --- src/view/event-bindings/event-types.js | 1 + stories/50-multiple-contexts.stories.js | 2 +- .../src/programmatic/multiple-contexts.jsx | 23 ++++++++++++++++--- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/view/event-bindings/event-types.js b/src/view/event-bindings/event-types.js index ae936fb03b..55bb533a94 100644 --- a/src/view/event-bindings/event-types.js +++ b/src/view/event-bindings/event-types.js @@ -3,6 +3,7 @@ export type EventOptions = {| passive?: boolean, capture?: boolean, + // sometimes an event might only event want to be bound once once?: boolean, |}; diff --git a/stories/50-multiple-contexts.stories.js b/stories/50-multiple-contexts.stories.js index 60ddf7037b..7ee00578a8 100644 --- a/stories/50-multiple-contexts.stories.js +++ b/stories/50-multiple-contexts.stories.js @@ -3,6 +3,6 @@ import React from 'react'; import { storiesOf } from '@storybook/react'; import MultipleContexts from './src/programmatic/multiple-contexts'; -storiesOf('Multiple contexts', module).add('two DragDropContexts', () => ( +storiesOf('Multiple contexts', module).add('with multiple contexts', () => ( )); diff --git a/stories/src/programmatic/multiple-contexts.jsx b/stories/src/programmatic/multiple-contexts.jsx index 7cb8f0b477..306c966e6d 100644 --- a/stories/src/programmatic/multiple-contexts.jsx +++ b/stories/src/programmatic/multiple-contexts.jsx @@ -17,6 +17,7 @@ import { DragDropContext } from '../../../src'; import QuoteList from '../primatives/quote-list'; import reorder from '../reorder'; import bindEvents from '../../../src/view/event-bindings/bind-events'; +import { grid } from '../constants'; function sleep(fn: Function, time?: number = 300) { return new Promise(resolve => { @@ -141,6 +142,7 @@ const Column = styled.div``; const Title = styled.h3` text-align: center; + padding: ${grid * 2}px; `; const selector: string = `[${dataAttr.droppable.contextId}]`; @@ -167,21 +169,36 @@ export default function App() { return ( - Programmatic #1 + + Programmatic #1{' '} + <span role="img" aria-label="controller"> + 🎮 + </span> + - Programmatic #2 + + Programmatic #2{' '} + <span role="img" aria-label="controller"> + 🎮 + </span> + - User controlled + + User controlled{' '} + <span role="img" aria-label="hand"> + 🤚 + </span> + From 7cfc9b17f0f9a01698c236171f510711a263fae7 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Fri, 24 May 2019 19:31:04 +1000 Subject: [PATCH 079/308] internal cleanup --- docs/sensors/programmatic.md | 1 + src/view/event-bindings/bind-events.js | 46 +++++++++++++------------- 2 files changed, 24 insertions(+), 23 deletions(-) create mode 100644 docs/sensors/programmatic.md diff --git a/docs/sensors/programmatic.md b/docs/sensors/programmatic.md new file mode 100644 index 0000000000..3c12db8b24 --- /dev/null +++ b/docs/sensors/programmatic.md @@ -0,0 +1 @@ +# Programmatic dragging 🎮 diff --git a/src/view/event-bindings/bind-events.js b/src/view/event-bindings/bind-events.js index a948035935..11094e5503 100644 --- a/src/view/event-bindings/bind-events.js +++ b/src/view/event-bindings/bind-events.js @@ -1,39 +1,39 @@ // @flow import type { EventBinding, EventOptions } from './event-types'; -const getOptions = ( +type UnbindFn = () => void; + +function getOptions( shared?: EventOptions, fromBinding: ?EventOptions, -): EventOptions => ({ - ...shared, - ...fromBinding, -}); - -const unbindEvents = ( - el: HTMLElement, - bindings: EventBinding[], - sharedOptions?: EventOptions, -) => { - bindings.forEach((binding: EventBinding) => { - const options: Object = getOptions(sharedOptions, binding.options); - - el.removeEventListener(binding.eventName, binding.fn, options); - }); -}; +): EventOptions { + return { + ...shared, + ...fromBinding, + }; +} export default function bindEvents( el: HTMLElement, bindings: EventBinding[], sharedOptions?: EventOptions, ): Function { - bindings.forEach((binding: EventBinding) => { - const options: Object = getOptions(sharedOptions, binding.options); + const unbindings: UnbindFn[] = bindings.map( + (binding: EventBinding): UnbindFn => { + const options: Object = getOptions(sharedOptions, binding.options); + + el.addEventListener(binding.eventName, binding.fn, options); - el.addEventListener(binding.eventName, binding.fn, options); - }); + return function unbind() { + el.removeEventListener(binding.eventName, binding.fn, options); + }; + }, + ); // Return a function to unbind events - return function unbind() { - unbindEvents(el, bindings, sharedOptions); + return function unbindAll() { + unbindings.forEach((unbind: UnbindFn) => { + unbind(); + }); }; } From ee2c914989ad818db60b32d69a517a80353b980c Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Mon, 27 May 2019 09:59:56 +1000 Subject: [PATCH 080/308] adding another newsletter --- docs/support/media.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/support/media.md b/docs/support/media.md index d4914225ad..237b3cd87f 100644 --- a/docs/support/media.md +++ b/docs/support/media.md @@ -59,6 +59,7 @@ This page contains a list of articles, blogs and newsletters that `react-beautif - Best of JS [issue 25](https://weekly.bestofjs.org/issues/25/) - BxJS Weekly [episode 59](https://dev.to/yamalight/bxjs-weekly-episode-59-javascript-news-podcast-b28) - FASination Daily [May 21st](http://opensource.faseidl.com/#/) +- The Week of React [issue 57](http://www.theweekofreact.com/issues/fold-up-images-in-react-using-webassembly-w-react-react-europe-livestream-more-179542) ## Articles, tutorials and blogs From ba725b41af946b741a956771ea548ac9a980f632 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Mon, 27 May 2019 11:11:50 +1000 Subject: [PATCH 081/308] disable default sensors --- README.md | 4 +++- src/view/drag-drop-context/app.jsx | 6 +++++- src/view/drag-drop-context/drag-drop-context.jsx | 1 + .../use-sensor-marshal/use-sensor-marshal.js | 8 +++++++- test/unit/integration/drag-handle/app.jsx | 5 ++++- .../drag-handle/shared-behaviours/controls.js | 1 + .../disable-default-sensors.spec.js | 16 ++++++++++++++++ .../shared-behaviours/validate-controls.spec.js | 1 - 8 files changed, 37 insertions(+), 5 deletions(-) create mode 100644 test/unit/integration/drag-handle/shared-behaviours/disable-default-sensors.spec.js diff --git a/README.md b/README.md index ce88b287d5..3e16cf6b0e 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,8 @@ We have created [a free course on `egghead.io` 🥚](https://egghead.io/courses/ - Compatible with semantic `` reordering - [table pattern](/docs/patterns/tables.md) - [Auto scrolling](/docs/guides/auto-scrolling.md) - automatically scroll containers and the window as required during a drag (even with keyboard 🔥) - Custom drag handles - you can drag a whole item by just a part of it -- Compatible with [`ReactDOM.createPortal`](https://reactjs.org/docs/portals.html) - [portal pattern](/docs/patterns/using-a-portal.md) +- Able to drag a [clone](TODO) or use a [portal](/docs/patterns/using-a-portal.md) +- Full [programmatic api 🎮](/docs/api/programmatic.md) - 🌲 Tree support through the [`@atlaskit/tree`](https://atlaskit.atlassian.com/packages/core/tree) package - A `` list can be a scroll container (without a scrollable parent) or be the child of a scroll container (that also does not have a scrollable parent) - Independent nested lists - a list can be a child of another list, but you cannot drag items from the parent list into a child list @@ -85,6 +86,7 @@ There are a lot of libraries out there that allow for drag and drop interactions - [Mouse dragging 🐭](/docs/sensors/mouse.md) - [Touch dragging 👉📱](/docs/sensors/touch.md) - [Keyboard dragging 🎹♿️](/docs/sensors/keyboard.md) +- [Programmatic dragging 🎮](/docs/sensors/programmatic.md) ### API 🏋️‍ diff --git a/src/view/drag-drop-context/app.jsx b/src/view/drag-drop-context/app.jsx index de0a5230a0..ccd5677790 100644 --- a/src/view/drag-drop-context/app.jsx +++ b/src/view/drag-drop-context/app.jsx @@ -43,7 +43,6 @@ import useStartupValidation from './use-startup-validation'; import usePrevious from '../use-previous-ref'; import { warning } from '../../dev-warning'; import useSensorMarshal from '../use-sensor-marshal/use-sensor-marshal'; -import useLayoutEffect from '../use-isomorphic-layout-effect'; type Props = {| ...Responders, @@ -51,7 +50,10 @@ type Props = {| setOnError: (onError: Function) => void, // we do not technically need any children for this component children: Node | null, + + // sensors __unstableSensors?: Sensor[], + enableDefaultSensors?: ?boolean, |}; const createResponders = (props: Props): Responders => ({ @@ -201,6 +203,8 @@ export default function App(props: Props) { contextId, store, customSensors: __unstableSensors, + // default to 'true' unless 'false' is explicitly passed + enableDefaultSensors: props.enableDefaultSensors !== false, }); // Clean store when unmounting diff --git a/src/view/drag-drop-context/drag-drop-context.jsx b/src/view/drag-drop-context/drag-drop-context.jsx index d81449d2c3..5399035b6e 100644 --- a/src/view/drag-drop-context/drag-drop-context.jsx +++ b/src/view/drag-drop-context/drag-drop-context.jsx @@ -11,6 +11,7 @@ type Props = {| children: Node | null, __unstableSensors?: Sensor[], + enableDefaultSensors?: ?boolean, |}; let instanceCount: number = 0; diff --git a/src/view/use-sensor-marshal/use-sensor-marshal.js b/src/view/use-sensor-marshal/use-sensor-marshal.js index 32159cf425..d37f10dcdb 100644 --- a/src/view/use-sensor-marshal/use-sensor-marshal.js +++ b/src/view/use-sensor-marshal/use-sensor-marshal.js @@ -298,6 +298,7 @@ type SensorMarshalArgs = {| contextId: ContextId, store: Store, customSensors: ?(Sensor[]), + enableDefaultSensors: boolean, |}; const defaultSensors: Sensor[] = [ @@ -310,8 +311,13 @@ export default function useSensorMarshal({ contextId, store, customSensors, + enableDefaultSensors, }: SensorMarshalArgs) { - const useSensors: Sensor[] = [...defaultSensors, ...(customSensors || [])]; + const useSensors: Sensor[] = [ + ...(enableDefaultSensors ? defaultSensors : []), + ...(customSensors || []), + ]; + console.log('enableDefaultSensors', enableDefaultSensors); const lockAPI: LockAPI = useState(() => create())[0]; // We need to abort any capturing if there is no longer a drag diff --git a/test/unit/integration/drag-handle/app.jsx b/test/unit/integration/drag-handle/app.jsx index ac596b1539..235eabe5ea 100644 --- a/test/unit/integration/drag-handle/app.jsx +++ b/test/unit/integration/drag-handle/app.jsx @@ -18,9 +18,11 @@ export type Item = {| type Props = {| onDragStart?: Function, onDragEnd?: Function, - sensors?: Sensor[], items?: Item[], anotherChild?: Node, + + sensors?: Sensor[], + enableDefaultSensors?: boolean, |}; function noop() {} @@ -46,6 +48,7 @@ export default function App(props: Props) { onDragStart={onDragStart} onDragEnd={onDragEnd} __unstableSensors={sensors} + enableDefaultSensors={props.enableDefaultSensors} > {(droppableProvided: DroppableProvided) => ( diff --git a/test/unit/integration/drag-handle/shared-behaviours/controls.js b/test/unit/integration/drag-handle/shared-behaviours/controls.js index 6abc2dd67d..28fae2524a 100644 --- a/test/unit/integration/drag-handle/shared-behaviours/controls.js +++ b/test/unit/integration/drag-handle/shared-behaviours/controls.js @@ -23,6 +23,7 @@ function getTransitionEnd(): Event { cancelable: true, }); // cheating and adding property to event as TransitionEvent constructor does not exist + // $ExpectError - being amazing event.propertyName = 'transform'; return event; } diff --git a/test/unit/integration/drag-handle/shared-behaviours/disable-default-sensors.spec.js b/test/unit/integration/drag-handle/shared-behaviours/disable-default-sensors.spec.js new file mode 100644 index 0000000000..928011d3f3 --- /dev/null +++ b/test/unit/integration/drag-handle/shared-behaviours/disable-default-sensors.spec.js @@ -0,0 +1,16 @@ +// @flow +import React from 'react'; +import { render } from 'react-testing-library'; +import { isDragging } from '../util'; +import App from '../app'; +import { forEachSensor, type Control, simpleLift } from './controls'; + +forEachSensor((control: Control) => { + it('should be able to start a drag if default sensors is disabled', () => { + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + simpleLift(control, handle); + expect(isDragging(handle)).toBe(false); + }); +}); diff --git a/test/unit/integration/drag-handle/shared-behaviours/validate-controls.spec.js b/test/unit/integration/drag-handle/shared-behaviours/validate-controls.spec.js index fe0f10f0d7..351760544e 100644 --- a/test/unit/integration/drag-handle/shared-behaviours/validate-controls.spec.js +++ b/test/unit/integration/drag-handle/shared-behaviours/validate-controls.spec.js @@ -21,7 +21,6 @@ forEachSensor((control: Control) => { expect(isDragging(handle)).toBe(true); // drop - console.log('drop start'); control.drop(handle); expect(isDragging(handle)).toBe(false); }); From 371c000ea0b851545ed9c046053c308c26a484ea Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Mon, 27 May 2019 15:01:41 +1000 Subject: [PATCH 082/308] adding basic programmatic guide --- docs/sensors/programmatic.md | 99 ++++++++++++++++++++++++++++++++++++ src/types.js | 4 ++ 2 files changed, 103 insertions(+) diff --git a/docs/sensors/programmatic.md b/docs/sensors/programmatic.md index 3c12db8b24..3f2a532bf3 100644 --- a/docs/sensors/programmatic.md +++ b/docs/sensors/programmatic.md @@ -1 +1,100 @@ # Programmatic dragging 🎮 + +> WIP!! + +It is possible to drive an entire drag and drop experience programmatically. You can use this to: + +- Create drag and drop interactions from any input type you can think of +- Create beautiful scripted experiences + +The programmatic API is the same API that our [mouse], [keyboard], and [touch] sensor use. + +## How does it work? + +You create a `sensor` that has the ability to attempt to claim a **lock**. A **lock** allows exclusive control of dragging a single item within a ``. The **lock** has two phases: _pre drag_ and _dragging_. + +So here is how a `sensor` works: + +1. Try to get a lock when it wants to drag and item. A sensor might not be able to claim a lock for a variety of reasons. +2. If a lock is returned then there are a number of _pre drag_ actions available to you (`PreDragActions`). This allows you to claim a lock before starting a drag. This is important for things like [sloppy click detection](TODO) where a drag is only started after a sufficiently large movement. +3. A _pre drag_ lock can be upgraded to a _drag lock_, which contains a different set of API's (`DragActions`). This then allows you to move items around. + +A **lock** can be aborted at any time by the application, such as when an error occurs. If you try to perform actions on an aborted **lock** then it will not do anything. + +```js +function useMySensor(tryGetActionLock: TryGetActionLock) => void) { + const preDrag: ?PreDragActions = tryGetActionLock(); + // Could not get lock + if(!preDrag) { + return; + } + + preDrag.lift({ mode: 'SNAP' }); + + preDrag.moveDown(); + preDrag.moveDown(); + preDrag.moveDown(); + + preDrag.drop(); +} + +function App() { + return ( + {/*...*/} + ) +} +``` + +## | `sensors` + +This allows you to pass in an `array` of additional sensors you would like to use for the `DragDropContext`. + +```js +import useMyCoolSensor from './awesome'; + +{/*...*/}; +``` + +## | `enableDefaultSensors` + +By default all of the default sensors ([mouse], [keyboard], and [touch]) will be applied. They can work in conjuction with your own custom sensors. However, you are welcome to disable the default sensors + +```js +// disable default sensors +{/*...*/} +``` + +## Pre drag actions + +When you request a lock with `tryGetActionLock(...)` you _can_ be supplied with `PreDragAction`s. + +```js +type PreDragActions = {| + // is lock still active? + isActive: () => boolean, + // whether it has been indicated if force press should be respected + shouldRespectForcePress: () => boolean, + // upgrade lock + lift: (args: SensorLift) => DragActions, + // release the lock + abort: () => void, +|}; +``` + +## Drag actions + +> WIP + +```js +type DragActions = {| + isActive: () => boolean, + shouldRespectForcePress: () => boolean, + move: (point: Position) => void, + moveUp: () => void, + moveDown: () => void, + moveRight: () => void, + moveLeft: () => void, + drop: (args?: StopDragOptions) => void, + cancel: (args?: StopDragOptions) => void, +|}; +``` diff --git a/src/types.js b/src/types.js index 0971e29a6d..75e3eb93be 100644 --- a/src/types.js +++ b/src/types.js @@ -425,9 +425,13 @@ export type DragActions = {| |}; export type PreDragActions = {| + // is lock still active? isActive: () => boolean, + // whether it has been indicated if force press should be respected shouldRespectForcePress: () => boolean, + // upgrade lock lift: (args: SensorLift) => DragActions, + // release the lock abort: () => void, |}; From ff4d891321ef5a8819cc2d9ad2bfde981471e353 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Mon, 27 May 2019 15:03:26 +1000 Subject: [PATCH 083/308] fixing guide --- docs/sensors/programmatic.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/sensors/programmatic.md b/docs/sensors/programmatic.md index 3f2a532bf3..161e3b7b7b 100644 --- a/docs/sensors/programmatic.md +++ b/docs/sensors/programmatic.md @@ -29,13 +29,13 @@ function useMySensor(tryGetActionLock: TryGetActionLock) => void) { return; } - preDrag.lift({ mode: 'SNAP' }); + const drag: DragActions = preDrag.lift({ mode: 'SNAP' }); - preDrag.moveDown(); - preDrag.moveDown(); - preDrag.moveDown(); + drag.moveDown(); + drag.moveDown(); + drag.moveDown(); - preDrag.drop(); + drag.drop(); } function App() { From ae133f11cbea64422cb1d4ca5159ef1abb56314a Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Tue, 28 May 2019 11:10:41 +1000 Subject: [PATCH 084/308] content editable tests --- test/unit/integration/drag-handle/app.jsx | 43 +++--- .../shared-behaviours/contenteditable.spec.js | 144 ++++++++++++++++++ 2 files changed, 170 insertions(+), 17 deletions(-) create mode 100644 test/unit/integration/drag-handle/shared-behaviours/contenteditable.spec.js diff --git a/test/unit/integration/drag-handle/app.jsx b/test/unit/integration/drag-handle/app.jsx index 235eabe5ea..2f969ba46d 100644 --- a/test/unit/integration/drag-handle/app.jsx +++ b/test/unit/integration/drag-handle/app.jsx @@ -12,14 +12,33 @@ import { export type Item = {| id: string, - isEnabled: boolean, + // defaults to true + isEnabled?: boolean, + // defaults to false + canDragInteractiveElements?: boolean, |}; +const defaultItemRender = (item: Item) => ( + provided: DraggableProvided, + snapshot: DraggableStateSnapshot, +) => ( +
+ item: {item.id} +
+); + type Props = {| onDragStart?: Function, onDragEnd?: Function, items?: Item[], anotherChild?: Node, + renderItem?: typeof defaultItemRender, sensors?: Sensor[], enableDefaultSensors?: boolean, @@ -32,7 +51,6 @@ function getItems() { { length: 3 }, (v, k): Item => ({ id: `${k}`, - isEnabled: true, }), ); } @@ -42,6 +60,7 @@ export default function App(props: Props) { const onDragEnd = props.onDragStart || noop; const sensors: Sensor[] = props.sensors || []; const [items] = useState(() => props.items || getItems()); + const render = props.renderItem || defaultItemRender; return ( - {( - provided: DraggableProvided, - snapshot: DraggableStateSnapshot, - ) => ( -
- item: {item.id} -
- )} + {render(item)} ))} {droppableProvided.placeholder} diff --git a/test/unit/integration/drag-handle/shared-behaviours/contenteditable.spec.js b/test/unit/integration/drag-handle/shared-behaviours/contenteditable.spec.js new file mode 100644 index 0000000000..7781d7bd41 --- /dev/null +++ b/test/unit/integration/drag-handle/shared-behaviours/contenteditable.spec.js @@ -0,0 +1,144 @@ +// @flow +import React from 'react'; +import { render } from 'react-testing-library'; +import { forEachSensor, type Control, simpleLift } from './controls'; +import { isDragging } from '../util'; +import { + type DraggableProvided, + type DraggableStateSnapshot, +} from '../../../../../src'; +import App, { type Item } from '../app'; + +forEachSensor((control: Control) => { + beforeEach(() => { + // using content editable in particular ways causes react logging + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + afterEach(() => { + console.error.mockRestore(); + }); + + it('should block the drag if the drag handle is itself contenteditable', () => { + const renderItem = (item: Item) => ( + provided: DraggableProvided, + snapshot: DraggableStateSnapshot, + ) => ( +
+ ); + + const { getByTestId } = render(); + const handle: HTMLElement = getByTestId('0'); + + simpleLift(control, handle); + + expect(isDragging(handle)).toBe(false); + }); + + it('should block the drag if originated from a child contenteditable', () => { + const renderItem = (item: Item) => ( + provided: DraggableProvided, + snapshot: DraggableStateSnapshot, + ) => ( +
+
+
+ ); + + const { getByTestId } = render(); + const inner: HTMLElement = getByTestId('inner-0'); + + simpleLift(control, inner); + + expect(isDragging(inner)).toBe(false); + }); + + it('should block the drag if originated from a child of a child contenteditable', () => { + const renderItem = (item: Item) => ( + provided: DraggableProvided, + snapshot: DraggableStateSnapshot, + ) => ( +
+
+

hello there

+ Edit me! +
+
+ ); + + const { getByTestId } = render(); + const inner: HTMLElement = getByTestId('inner-0'); + + simpleLift(control, inner); + + expect(isDragging(inner)).toBe(false); + }); + + it('should not block if contenteditable is set to false', () => { + const renderItem = (item: Item) => ( + provided: DraggableProvided, + snapshot: DraggableStateSnapshot, + ) => ( +
+ ); + + const { getByTestId } = render(); + const handle: HTMLElement = getByTestId('0'); + + simpleLift(control, handle); + + expect(isDragging(handle)).toBe(true); + }); + + it('should not block a drag if dragging interactive elements is allowed', () => { + const items: Item[] = [{ id: '0', canDragInteractiveElements: true }]; + + const renderItem = (item: Item) => ( + provided: DraggableProvided, + snapshot: DraggableStateSnapshot, + ) => ( +
+ ); + + const { getByTestId } = render( + , + ); + const handle: HTMLElement = getByTestId('0'); + + simpleLift(control, handle); + + expect(isDragging(handle)).toBe(true); + }); +}); From 40a6f9c7a982f99b5fb82dddee6e918cd8bc0b98 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Tue, 28 May 2019 16:58:14 +1000 Subject: [PATCH 085/308] interactive element tests --- .../use-sensor-marshal/use-sensor-marshal.js | 1 - test/unit/integration/drag-handle/app.jsx | 13 +- .../interactive-elements.spec.js | 153 ++++++++++++++++++ 3 files changed, 163 insertions(+), 4 deletions(-) create mode 100644 test/unit/integration/drag-handle/shared-behaviours/interactive-elements.spec.js diff --git a/src/view/use-sensor-marshal/use-sensor-marshal.js b/src/view/use-sensor-marshal/use-sensor-marshal.js index d37f10dcdb..8e624d1134 100644 --- a/src/view/use-sensor-marshal/use-sensor-marshal.js +++ b/src/view/use-sensor-marshal/use-sensor-marshal.js @@ -227,7 +227,6 @@ function tryGetLock({ // block next click if requested if (options.shouldBlockNextClick) { - console.log('adding handler'); window.addEventListener('click', preventDefault, { // only blocking a single click once: true, diff --git a/test/unit/integration/drag-handle/app.jsx b/test/unit/integration/drag-handle/app.jsx index 2f969ba46d..32b5c5f9a0 100644 --- a/test/unit/integration/drag-handle/app.jsx +++ b/test/unit/integration/drag-handle/app.jsx @@ -18,7 +18,11 @@ export type Item = {| canDragInteractiveElements?: boolean, |}; -const defaultItemRender = (item: Item) => ( +type RenderItem = ( + item: Item, +) => (provided: DraggableProvided, snapshot: DraggableStateSnapshot) => Node; + +const defaultItemRender: RenderItem = (item: Item) => ( provided: DraggableProvided, snapshot: DraggableStateSnapshot, ) => ( @@ -38,7 +42,7 @@ type Props = {| onDragEnd?: Function, items?: Item[], anotherChild?: Node, - renderItem?: typeof defaultItemRender, + renderItem?: RenderItem, sensors?: Sensor[], enableDefaultSensors?: boolean, @@ -81,8 +85,11 @@ export default function App(props: Props) { draggableId={item.id} index={index} isDragDisabled={item.isEnabled === false} + // default to disabled = true disableInteractiveElementBlocking={ - item.canDragInteractiveElements === true + typeof item.canDragInteractiveElements === 'boolean' + ? item.canDragInteractiveElements + : true } > {render(item)} diff --git a/test/unit/integration/drag-handle/shared-behaviours/interactive-elements.spec.js b/test/unit/integration/drag-handle/shared-behaviours/interactive-elements.spec.js new file mode 100644 index 0000000000..b8b5b75ed8 --- /dev/null +++ b/test/unit/integration/drag-handle/shared-behaviours/interactive-elements.spec.js @@ -0,0 +1,153 @@ +// @flow +import React, { type Node } from 'react'; +import { render } from 'react-testing-library'; +import { forEachSensor, type Control, simpleLift } from './controls'; +import { isDragging } from '../util'; +import { + type DraggableProvided, + type DraggableStateSnapshot, +} from '../../../../../src'; +import App, { type Item } from '../app'; +import { interactiveTagNames } from '../../../../../src/view/use-sensor-marshal/is-handle-in-interactive-element'; + +const mixedCase = (obj: Object): string[] => [ + ...Object.keys(obj).map(s => s.toLowerCase()), + ...Object.keys(obj).map(s => s.toUpperCase()), +]; + +const forEachTagName = (fn: (tagName: string) => void) => + mixedCase(interactiveTagNames).forEach(fn); + +forEachSensor((control: Control) => { + beforeEach(() => { + // react will log a warning if using upper case + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + afterEach(() => { + console.error.mockRestore(); + }); + + it('should not drag if the handle is an interactive element', () => { + forEachTagName((tagName: string) => { + const renderItem = (item: Item) => ( + provided: DraggableProvided, + snapshot: DraggableStateSnapshot, + ) => { + const TagName = tagName; + return ( + + ); + }; + + const { unmount, getByTestId } = render(); + const handle: HTMLElement = getByTestId('0'); + + simpleLift(control, handle); + + expect(isDragging(handle)).toBe(false); + + unmount(); + }); + }); + + it('should allow dragging from an interactive handle if instructed', () => { + mixedCase(interactiveTagNames).forEach((tagName: string) => { + const items: Item[] = [{ id: '0', canDragInteractiveElements: true }]; + const renderItem = (item: Item) => ( + provided: DraggableProvided, + snapshot: DraggableStateSnapshot, + ) => { + const TagName = tagName; + return ( + + ); + }; + + const { unmount, getByTestId } = render( + , + ); + const handle: HTMLElement = getByTestId('0'); + + simpleLift(control, handle); + + expect(isDragging(handle)).toBe(true); + + unmount(); + }); + }); + + it('should not start a drag if the parent is interactive', () => { + forEachTagName((tagName: string) => { + const renderItem = (item: Item) => ( + provided: DraggableProvided, + snapshot: DraggableStateSnapshot, + ) => { + const TagName = tagName; + return ( +
+ +
+ ); + }; + + const { unmount, getByTestId } = render(); + const inner: HTMLElement = getByTestId('inner-0'); + + simpleLift(control, inner); + + expect(isDragging(inner)).toBe(false); + + unmount(); + }); + }); + + it('should allow dragging from with an interactive parent if instructed', () => { + forEachTagName((tagName: string) => { + const items: Item[] = [{ id: '0', canDragInteractiveElements: true }]; + const renderItem = (item: Item) => ( + provided: DraggableProvided, + snapshot: DraggableStateSnapshot, + ) => { + const TagName = tagName; + return ( +
+ +
+ ); + }; + + const { unmount, getByTestId } = render( + , + ); + const inner: HTMLElement = getByTestId('inner-0'); + + simpleLift(control, inner); + + expect(isDragging(inner)).toBe(true); + + unmount(); + }); + }); +}); From 0e494cf5f2dcd9711d17d2b54b44b1620a1413b4 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Tue, 28 May 2019 19:43:36 +1000 Subject: [PATCH 086/308] tests for interactive elements --- ...-element.js => is-target-in-interactive-element.js} | 6 +++--- .../use-sensor-marshal/sensors/use-keyboard-sensor.js | 5 ----- .../use-sensor-marshal/sensors/use-touch-sensor.js | 3 --- src/view/use-sensor-marshal/use-sensor-marshal.js | 5 ++--- test/unit/integration/drag-handle/app.jsx | 3 +-- .../shared-behaviours/contenteditable.spec.js | 10 ++++++---- .../shared-behaviours/interactive-elements.spec.js | 10 +++++++--- 7 files changed, 19 insertions(+), 23 deletions(-) rename src/view/use-sensor-marshal/{is-handle-in-interactive-element.js => is-target-in-interactive-element.js} (92%) diff --git a/src/view/use-sensor-marshal/is-handle-in-interactive-element.js b/src/view/use-sensor-marshal/is-target-in-interactive-element.js similarity index 92% rename from src/view/use-sensor-marshal/is-handle-in-interactive-element.js rename to src/view/use-sensor-marshal/is-target-in-interactive-element.js index ed8346424a..78faed6297 100644 --- a/src/view/use-sensor-marshal/is-handle-in-interactive-element.js +++ b/src/view/use-sensor-marshal/is-target-in-interactive-element.js @@ -51,9 +51,9 @@ function isAnInteractiveElement(parent: Element, current: ?Element) { return isAnInteractiveElement(parent, current.parentElement); } -export default function isHandleInInteractiveElement( +export default function isTargetInInteractiveElement( draggable: Element, - handle: Element, + target: Element, ): boolean { - return isAnInteractiveElement(draggable, handle); + return isAnInteractiveElement(draggable, target); } diff --git a/src/view/use-sensor-marshal/sensors/use-keyboard-sensor.js b/src/view/use-sensor-marshal/sensors/use-keyboard-sensor.js index 225effc158..98a55dbb47 100644 --- a/src/view/use-sensor-marshal/sensors/use-keyboard-sensor.js +++ b/src/view/use-sensor-marshal/sensors/use-keyboard-sensor.js @@ -149,13 +149,11 @@ export default function useKeyboardSensor( fn: function onKeyDown(event: KeyboardEvent) { // Event already used if (event.defaultPrevented) { - console.log('unable default prevented'); return; } // Need to start drag with a spacebar press if (event.keyCode !== keyCodes.space) { - console.log('wrong code to start'); return; } @@ -165,12 +163,9 @@ export default function useKeyboardSensor( // Cannot start capturing at this time if (!preDrag) { - console.log('unable to start'); return; } - console.log('starting drag'); - // we are consuming the event event.preventDefault(); let isCapturing: boolean = true; diff --git a/src/view/use-sensor-marshal/sensors/use-touch-sensor.js b/src/view/use-sensor-marshal/sensors/use-touch-sensor.js index c837fb67d6..65e0122873 100644 --- a/src/view/use-sensor-marshal/sensors/use-touch-sensor.js +++ b/src/view/use-sensor-marshal/sensors/use-touch-sensor.js @@ -249,7 +249,6 @@ export default function useMouseSensor( return; } - console.log('touches', event.touches); const touch: Touch = event.touches[0]; const { clientX, clientY } = touch; const point: Position = { @@ -301,11 +300,9 @@ export default function useMouseSensor( clearTimeout(current.longPressTimerId); } - console.log('STOPPing'); setPhase(idle); unbindEventsRef.current(); - console.log('listen for capture'); listenForCapture(); }, [listenForCapture, setPhase]); diff --git a/src/view/use-sensor-marshal/use-sensor-marshal.js b/src/view/use-sensor-marshal/use-sensor-marshal.js index 8e624d1134..80a1e9a62e 100644 --- a/src/view/use-sensor-marshal/use-sensor-marshal.js +++ b/src/view/use-sensor-marshal/use-sensor-marshal.js @@ -29,7 +29,7 @@ import useMouseSensor from './sensors/use-mouse-sensor'; import useKeyboardSensor from './sensors/use-keyboard-sensor'; import useTouchSensor from './sensors/use-touch-sensor'; import useValidateSensorHooks from './use-validate-sensor-hooks'; -import isHandleInInteractiveElement from './is-handle-in-interactive-element'; +import isTargetInInteractiveElement from './is-target-in-interactive-element'; import getDataFromDraggable from './get-data-from-draggable'; import getBorderBoxCenterPosition from '../get-border-box-center-position'; import { warning } from '../../dev-warning'; @@ -158,7 +158,7 @@ function tryGetLock({ // do not allow dragging from interactive elements if ( !canDragInteractiveElements && - isHandleInInteractiveElement(draggable, handle) + isTargetInInteractiveElement(draggable, target) ) { return null; } @@ -316,7 +316,6 @@ export default function useSensorMarshal({ ...(enableDefaultSensors ? defaultSensors : []), ...(customSensors || []), ]; - console.log('enableDefaultSensors', enableDefaultSensors); const lockAPI: LockAPI = useState(() => create())[0]; // We need to abort any capturing if there is no longer a drag diff --git a/test/unit/integration/drag-handle/app.jsx b/test/unit/integration/drag-handle/app.jsx index 32b5c5f9a0..5bf4459634 100644 --- a/test/unit/integration/drag-handle/app.jsx +++ b/test/unit/integration/drag-handle/app.jsx @@ -85,11 +85,10 @@ export default function App(props: Props) { draggableId={item.id} index={index} isDragDisabled={item.isEnabled === false} - // default to disabled = true disableInteractiveElementBlocking={ typeof item.canDragInteractiveElements === 'boolean' ? item.canDragInteractiveElements - : true + : false } > {render(item)} diff --git a/test/unit/integration/drag-handle/shared-behaviours/contenteditable.spec.js b/test/unit/integration/drag-handle/shared-behaviours/contenteditable.spec.js index 7781d7bd41..6ad462f97f 100644 --- a/test/unit/integration/drag-handle/shared-behaviours/contenteditable.spec.js +++ b/test/unit/integration/drag-handle/shared-behaviours/contenteditable.spec.js @@ -51,7 +51,7 @@ forEachSensor((control: Control) => { {...provided.dragHandleProps} ref={provided.innerRef} data-is-dragging={snapshot.isDragging} - data-testid={item.id} + data-testid={`handle-${item.id}`} >
@@ -59,10 +59,11 @@ forEachSensor((control: Control) => { const { getByTestId } = render(); const inner: HTMLElement = getByTestId('inner-0'); + const handle: HTMLElement = getByTestId('handle-0'); simpleLift(control, inner); - expect(isDragging(inner)).toBe(false); + expect(isDragging(handle)).toBe(false); }); it('should block the drag if originated from a child of a child contenteditable', () => { @@ -75,7 +76,7 @@ forEachSensor((control: Control) => { {...provided.dragHandleProps} ref={provided.innerRef} data-is-dragging={snapshot.isDragging} - data-testid={item.id} + data-testid={`handle-${item.id}`} >

hello there

@@ -86,10 +87,11 @@ forEachSensor((control: Control) => { const { getByTestId } = render(); const inner: HTMLElement = getByTestId('inner-0'); + const handle: HTMLElement = getByTestId('handle-0'); simpleLift(control, inner); - expect(isDragging(inner)).toBe(false); + expect(isDragging(handle)).toBe(false); }); it('should not block if contenteditable is set to false', () => { diff --git a/test/unit/integration/drag-handle/shared-behaviours/interactive-elements.spec.js b/test/unit/integration/drag-handle/shared-behaviours/interactive-elements.spec.js index b8b5b75ed8..713698a038 100644 --- a/test/unit/integration/drag-handle/shared-behaviours/interactive-elements.spec.js +++ b/test/unit/integration/drag-handle/shared-behaviours/interactive-elements.spec.js @@ -8,7 +8,7 @@ import { type DraggableStateSnapshot, } from '../../../../../src'; import App, { type Item } from '../app'; -import { interactiveTagNames } from '../../../../../src/view/use-sensor-marshal/is-handle-in-interactive-element'; +import { interactiveTagNames } from '../../../../../src/view/use-sensor-marshal/is-target-in-interactive-element'; const mixedCase = (obj: Object): string[] => [ ...Object.keys(obj).map(s => s.toLowerCase()), @@ -101,6 +101,7 @@ forEachSensor((control: Control) => { {...provided.dragHandleProps} ref={provided.innerRef} data-is-dragging={snapshot.isDragging} + data-testid={`handle-${item.id}`} >
@@ -109,10 +110,11 @@ forEachSensor((control: Control) => { const { unmount, getByTestId } = render(); const inner: HTMLElement = getByTestId('inner-0'); + const handle: HTMLElement = getByTestId('handle-0'); simpleLift(control, inner); - expect(isDragging(inner)).toBe(false); + expect(isDragging(handle)).toBe(false); unmount(); }); @@ -132,6 +134,7 @@ forEachSensor((control: Control) => { {...provided.dragHandleProps} ref={provided.innerRef} data-is-dragging={snapshot.isDragging} + data-testid={`handle-${item.id}`} >
@@ -141,11 +144,12 @@ forEachSensor((control: Control) => { const { unmount, getByTestId } = render( , ); + const handle: HTMLElement = getByTestId('handle-0'); const inner: HTMLElement = getByTestId('inner-0'); simpleLift(control, inner); - expect(isDragging(inner)).toBe(true); + expect(isDragging(handle)).toBe(true); unmount(); }); From 4860b71dd2775d4ddb06e1bd7ffd0ed66f2b020c Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Tue, 28 May 2019 20:44:03 +1000 Subject: [PATCH 087/308] nested drag handle test --- test/unit/integration/drag-handle/board.jsx | 79 +++++++++++++++++++ .../shared-behaviours/nested-handles.spec.js | 30 +++++++ 2 files changed, 109 insertions(+) create mode 100644 test/unit/integration/drag-handle/board.jsx create mode 100644 test/unit/integration/drag-handle/shared-behaviours/nested-handles.spec.js diff --git a/test/unit/integration/drag-handle/board.jsx b/test/unit/integration/drag-handle/board.jsx new file mode 100644 index 0000000000..0d8c6d6533 --- /dev/null +++ b/test/unit/integration/drag-handle/board.jsx @@ -0,0 +1,79 @@ +// @flow +import React from 'react'; +import { + DragDropContext, + Droppable, + Draggable, + type DroppableProvided, + type DraggableProvided, + type DraggableStateSnapshot, +} from '../../../../src'; +import { noop } from '../../../../src/empty'; + +type CardProps = {| + index: number, + cardId: string, +|}; +function Card(props: CardProps) { + return ( + + {(provided: DraggableProvided, snapshot: DraggableStateSnapshot) => ( +
+ )} + + ); +} + +type ColumnProps = {| + index: number, + columnId: string, +|}; + +function Column(props: ColumnProps) { + return ( + + {(provided: DraggableProvided, snapshot: DraggableStateSnapshot) => ( +
+ + {(droppableProvided: DroppableProvided) => ( +
+ + {droppableProvided.placeholder} +
+ )} +
+
+ )} +
+ ); +} + +export default function Board() { + return ( + + + {(provided: DroppableProvided) => ( +
+ + {provided.placeholder} +
+ )} +
+
+ ); +} diff --git a/test/unit/integration/drag-handle/shared-behaviours/nested-handles.spec.js b/test/unit/integration/drag-handle/shared-behaviours/nested-handles.spec.js new file mode 100644 index 0000000000..e5f2389905 --- /dev/null +++ b/test/unit/integration/drag-handle/shared-behaviours/nested-handles.spec.js @@ -0,0 +1,30 @@ +// @flow +import React from 'react'; +import { render } from 'react-testing-library'; +import { forEachSensor, type Control, simpleLift } from './controls'; +import { isDragging } from '../util'; +import Board from '../board'; + +forEachSensor((control: Control) => { + it('should not start a drag on a parent if a child drag handle has already received the event', () => { + const { getByTestId } = render(); + const cardHandle: HTMLElement = getByTestId('card-0'); + const columnHandle: HTMLElement = getByTestId('column-0'); + + simpleLift(control, cardHandle); + + expect(isDragging(cardHandle)).toBe(true); + expect(isDragging(columnHandle)).toBe(false); + }); + + it('should start a drag on a pare~nt the event is trigged on the parent', () => { + const { getByTestId } = render(); + const cardHandle: HTMLElement = getByTestId('card-0'); + const columnHandle: HTMLElement = getByTestId('column-0'); + + simpleLift(control, columnHandle); + + expect(isDragging(columnHandle)).toBe(true); + expect(isDragging(cardHandle)).toBe(false); + }); +}); From 029fd926cce2174b1b9b2376fe9d49f9337383b1 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Tue, 28 May 2019 20:51:25 +1000 Subject: [PATCH 088/308] fixing some examples --- stories/src/custom-drop/funny-drop.jsx | 2 +- stories/src/vertical-nested/quote-list.jsx | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/stories/src/custom-drop/funny-drop.jsx b/stories/src/custom-drop/funny-drop.jsx index 4ad5e9f38a..1d77cfb006 100644 --- a/stories/src/custom-drop/funny-drop.jsx +++ b/stories/src/custom-drop/funny-drop.jsx @@ -43,7 +43,7 @@ const getStyle = ( } const { moveTo, curve, duration } = dropping; const translate = `translate(${moveTo.x}px, ${moveTo.y}px)`; - const rotate = 'rotate(0.5turn)'; + const rotate = 'rotate(1turn)'; return { ...style, transform: `${translate} ${rotate}`, diff --git a/stories/src/vertical-nested/quote-list.jsx b/stories/src/vertical-nested/quote-list.jsx index ccd0babbce..39d4f126e9 100644 --- a/stories/src/vertical-nested/quote-list.jsx +++ b/stories/src/vertical-nested/quote-list.jsx @@ -86,6 +86,7 @@ export default class QuoteList extends Component<{ list: NestedQuoteList }> { ), )} + {dropProvided.placeholder} )} From 56cbcd4986ec11fbbee51a71863a54d0aa13c0ef Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Wed, 29 May 2019 08:32:49 +1000 Subject: [PATCH 089/308] new media entry --- docs/support/media.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/support/media.md b/docs/support/media.md index 237b3cd87f..f5e0684fef 100644 --- a/docs/support/media.md +++ b/docs/support/media.md @@ -63,6 +63,7 @@ This page contains a list of articles, blogs and newsletters that `react-beautif ## Articles, tutorials and blogs +- dev.to 7 most popular DEV posts [May 27th, 2019](https://dev.to/devteam/the-7-most-popular-dev-posts-from-the-past-week-2ice) - [React Drag and Drop - Multiple Horizontal Lists](https://www.youtube.com/watch?v=RI9kA09Egas) - [Building a Drag-and-Drop Game with react-beautiful-dnd](https://able.bio/drenther/building-a-drag-and-drop-game-with-react-beautiful-dnd--094r3g8) - [Let's Create a Trello Clone with React + Redux](https://www.youtube.com/watch?v=RDQGPs7StNA) From 15648799cf94dee7c684c4a05b3eef3474003fec Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Wed, 29 May 2019 08:32:57 +1000 Subject: [PATCH 090/308] svg dragging test --- .../no-dragging-svgs.spec.js | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 test/unit/integration/drag-handle/shared-behaviours/no-dragging-svgs.spec.js diff --git a/test/unit/integration/drag-handle/shared-behaviours/no-dragging-svgs.spec.js b/test/unit/integration/drag-handle/shared-behaviours/no-dragging-svgs.spec.js new file mode 100644 index 0000000000..23b8602694 --- /dev/null +++ b/test/unit/integration/drag-handle/shared-behaviours/no-dragging-svgs.spec.js @@ -0,0 +1,41 @@ +// @flow +import React from 'react'; +import { render } from 'react-testing-library'; +import { forEachSensor, type Control, simpleLift } from './controls'; +import { isDragging } from '../util'; +import { + type DraggableProvided, + type DraggableStateSnapshot, +} from '../../../../../src'; +import App, { type Item } from '../app'; + +forEachSensor((control: Control) => { + it('should not start a drag from an SVG', () => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + const renderItem = (item: Item) => ( + provided: DraggableProvided, + snapshot: DraggableStateSnapshot, + ) => ( +
+ +
+ ); + const { getByTestId } = render(); + const draggable = getByTestId('draggable-0'); + const handle = getByTestId('handle-0'); + expect(console.warn).toHaveBeenCalledTimes(0); + + simpleLift(control, handle); + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(isDragging(draggable)).toBe(false); + + console.warn.mockRestore(); + }); +}); From bddb7a7024f9fc14e0bf1f0a423a99ad8d7f5a93 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Wed, 29 May 2019 08:42:44 +1000 Subject: [PATCH 091/308] shared tests for blocking drag start --- .../mouse-sensor/start-dragging.spec.js | 44 ------------------- .../cannot-start-when-disabled.spec.js | 19 ++++++++ ...start-when-something-else-has-lock.spec.js | 26 +++++++++++ .../cannot-start-when-unmounted.spec.js | 19 ++++++++ 4 files changed, 64 insertions(+), 44 deletions(-) create mode 100644 test/unit/integration/drag-handle/shared-behaviours/cannot-start-when-disabled.spec.js create mode 100644 test/unit/integration/drag-handle/shared-behaviours/cannot-start-when-something-else-has-lock.spec.js create mode 100644 test/unit/integration/drag-handle/shared-behaviours/cannot-start-when-unmounted.spec.js diff --git a/test/unit/integration/drag-handle/mouse-sensor/start-dragging.spec.js b/test/unit/integration/drag-handle/mouse-sensor/start-dragging.spec.js index 66d347cc68..42c994824d 100644 --- a/test/unit/integration/drag-handle/mouse-sensor/start-dragging.spec.js +++ b/test/unit/integration/drag-handle/mouse-sensor/start-dragging.spec.js @@ -165,47 +165,3 @@ it('should not start a drag if a modifier key was used while pressing the mouse expect(isDragging(handle)).toBe(false); }); }); - -it('should not start a drag if another sensor is capturing', () => { - let tryCapture; - function greedy(tryStartCapture) { - tryCapture = tryStartCapture; - } - const { getByText } = render(); - const handle: HTMLElement = getByText('item: 0'); - - invariant(tryCapture, 'Expected function to be set'); - tryCapture(handle); - - // touch will now be capturing - // fireEvent.keyDown(handle, { keyCode: keyCodes.space }); - - // lift - simpleLift(handle); - - expect(isDragging(handle)).toBe(false); -}); - -it('should not start a drag if disabled', () => { - const items: Item[] = [{ id: '0', isEnabled: false }]; - - const { getByText } = render(); - const handle: HTMLElement = getByText('item: 0'); - - // lift - simpleLift(handle); - - // not lifting as is disabled - expect(isDragging(handle)).toBe(false); -}); - -it('should not allow starting after the handle is unmounted', () => { - const { getByText, unmount } = render(); - const handle: HTMLElement = getByText('item: 0'); - - unmount(); - - simpleLift(handle); - - expect(isDragging(handle)).toBe(false); -}); diff --git a/test/unit/integration/drag-handle/shared-behaviours/cannot-start-when-disabled.spec.js b/test/unit/integration/drag-handle/shared-behaviours/cannot-start-when-disabled.spec.js new file mode 100644 index 0000000000..f54b84186d --- /dev/null +++ b/test/unit/integration/drag-handle/shared-behaviours/cannot-start-when-disabled.spec.js @@ -0,0 +1,19 @@ +// @flow +import React from 'react'; +import { render } from 'react-testing-library'; +import { isDragging } from '../util'; +import App, { type Item } from '../app'; +import { forEachSensor, type Control, simpleLift } from './controls'; + +forEachSensor((control: Control) => { + it('should not start a drag if disabled', () => { + const items: Item[] = [{ id: '0', isEnabled: false }]; + + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + simpleLift(control, handle); + + expect(isDragging(handle)).toBe(false); + }); +}); diff --git a/test/unit/integration/drag-handle/shared-behaviours/cannot-start-when-something-else-has-lock.spec.js b/test/unit/integration/drag-handle/shared-behaviours/cannot-start-when-something-else-has-lock.spec.js new file mode 100644 index 0000000000..bd34f6eb21 --- /dev/null +++ b/test/unit/integration/drag-handle/shared-behaviours/cannot-start-when-something-else-has-lock.spec.js @@ -0,0 +1,26 @@ +// @flow +import invariant from 'tiny-invariant'; +import React from 'react'; +import { render } from 'react-testing-library'; +import { isDragging } from '../util'; +import App from '../app'; +import { forEachSensor, type Control, simpleLift } from './controls'; + +forEachSensor((control: Control) => { + it('should not start a drag if another sensor is capturing', () => { + let tryGetLock; + function greedy(tryStartCapture) { + tryGetLock = tryStartCapture; + } + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + invariant(tryGetLock, 'Expected function to be set'); + tryGetLock(handle); + + // won't be able to lift as the lock is already claimed + simpleLift(control, handle); + + expect(isDragging(handle)).toBe(false); + }); +}); diff --git a/test/unit/integration/drag-handle/shared-behaviours/cannot-start-when-unmounted.spec.js b/test/unit/integration/drag-handle/shared-behaviours/cannot-start-when-unmounted.spec.js new file mode 100644 index 0000000000..10b30dee16 --- /dev/null +++ b/test/unit/integration/drag-handle/shared-behaviours/cannot-start-when-unmounted.spec.js @@ -0,0 +1,19 @@ +// @flow +import React from 'react'; +import { render } from 'react-testing-library'; +import { isDragging } from '../util'; +import App from '../app'; +import { forEachSensor, type Control, simpleLift } from './controls'; + +forEachSensor((control: Control) => { + it('should not allow starting after the handle is unmounted', () => { + const { getByText, unmount } = render(); + const handle: HTMLElement = getByText('item: 0'); + + unmount(); + + simpleLift(control, handle); + + expect(isDragging(handle)).toBe(false); + }); +}); From 46410f66012fd458bc7f8dd9b013e3d7134d69e2 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Wed, 29 May 2019 11:37:57 +1000 Subject: [PATCH 092/308] moving control file around --- .../{shared-behaviours => }/controls.js | 6 +++--- .../mouse-sensor/cancel-while-pending.spec.js | 2 +- .../mouse-sensor/click-blocking.spec.js | 8 ++++---- ...prevent-standard-keys-while-dragging.spec.js | 4 ++-- .../mouse-sensor/start-dragging.spec.js | 6 +++--- .../drag-handle/mouse-sensor/util.js | 11 +++-------- .../sensor-marshal/click-blocking.spec.js | 17 ++++------------- .../shared-behaviours/abort-on-error.spec.js | 4 ++-- .../cancel-while-dragging.spec.js | 2 +- .../cannot-start-when-disabled.spec.js | 2 +- ...t-start-when-something-else-has-lock.spec.js | 2 +- .../cannot-start-when-unmounted.spec.js | 2 +- .../shared-behaviours/cleanup.spec.js | 2 +- .../shared-behaviours/contenteditable.spec.js | 2 +- .../disable-default-sensors.spec.js | 2 +- .../interactive-elements.spec.js | 2 +- .../shared-behaviours/nested-handles.spec.js | 2 +- .../shared-behaviours/no-dragging-svgs.spec.js | 2 +- ...arent-rendering-should-not-kill-drag.spec.js | 2 +- .../shared-behaviours/validate-controls.spec.js | 2 +- 20 files changed, 34 insertions(+), 48 deletions(-) rename test/unit/integration/drag-handle/{shared-behaviours => }/controls.js (91%) diff --git a/test/unit/integration/drag-handle/shared-behaviours/controls.js b/test/unit/integration/drag-handle/controls.js similarity index 91% rename from test/unit/integration/drag-handle/shared-behaviours/controls.js rename to test/unit/integration/drag-handle/controls.js index 28fae2524a..3fcf442f41 100644 --- a/test/unit/integration/drag-handle/shared-behaviours/controls.js +++ b/test/unit/integration/drag-handle/controls.js @@ -1,8 +1,8 @@ // @flow import { createEvent, fireEvent, act } from 'react-testing-library'; -import { sloppyClickThreshold } from '../../../../../src/view/use-sensor-marshal/sensors/use-mouse-sensor'; -import { timeForLongPress } from '../../../../../src/view/use-sensor-marshal/sensors/use-touch-sensor'; -import * as keyCodes from '../../../../../src/view/key-codes'; +import { sloppyClickThreshold } from '../../../../src/view/use-sensor-marshal/sensors/use-mouse-sensor'; +import { timeForLongPress } from '../../../../src/view/use-sensor-marshal/sensors/use-touch-sensor'; +import * as keyCodes from '../../../../src/view/key-codes'; export type Control = {| name: string, diff --git a/test/unit/integration/drag-handle/mouse-sensor/cancel-while-pending.spec.js b/test/unit/integration/drag-handle/mouse-sensor/cancel-while-pending.spec.js index 9ef2c38109..6adf39682d 100644 --- a/test/unit/integration/drag-handle/mouse-sensor/cancel-while-pending.spec.js +++ b/test/unit/integration/drag-handle/mouse-sensor/cancel-while-pending.spec.js @@ -2,7 +2,7 @@ import React from 'react'; import { createEvent, fireEvent, render } from 'react-testing-library'; import * as keyCodes from '../../../../../src/view/key-codes'; -import { sloppyClickThreshold } from '../../../../../src/view/use-sensor-marshal/sensors/util/is-sloppy-click-threshold-exceeded'; +import { sloppyClickThreshold } from '../../../../../src/view/use-sensor-marshal/sensors/use-mouse-sensor'; import App from '../app'; import { isDragging } from '../util'; import supportedEventName from '../../../../../src/view/use-sensor-marshal/sensors/util/supported-page-visibility-event-name'; diff --git a/test/unit/integration/drag-handle/mouse-sensor/click-blocking.spec.js b/test/unit/integration/drag-handle/mouse-sensor/click-blocking.spec.js index 1084517538..9a963cc696 100644 --- a/test/unit/integration/drag-handle/mouse-sensor/click-blocking.spec.js +++ b/test/unit/integration/drag-handle/mouse-sensor/click-blocking.spec.js @@ -2,10 +2,10 @@ import React from 'react'; import { createEvent, fireEvent, render } from 'react-testing-library'; import * as keyCodes from '../../../../../src/view/key-codes'; -import { sloppyClickThreshold } from '../../../../../src/view/use-sensor-marshal/sensors/util/is-sloppy-click-threshold-exceeded'; +import { sloppyClickThreshold } from '../../../../../src/view/use-sensor-marshal/sensors/use-mouse-sensor'; import App from '../app'; import { isDragging } from '../util'; -import { simpleLift } from './util'; +import { simpleLift, mouse } from '../controls'; it('should not prevent a subsequent click if aborting during a pending drag', () => { const { getByText } = render(); @@ -35,7 +35,7 @@ it('should prevent a subsequent click if cancelling a drag', () => { const { getByText } = render(); const handle: HTMLElement = getByText('item: 0'); - simpleLift(handle); + simpleLift(mouse, handle); expect(isDragging(handle)).toBe(true); // cancel @@ -51,7 +51,7 @@ it('should prevent a subsequent click if dropping a drag', () => { const { getByText } = render(); const handle: HTMLElement = getByText('item: 0'); - simpleLift(handle); + simpleLift(mouse, handle); expect(isDragging(handle)).toBe(true); // cancel diff --git a/test/unit/integration/drag-handle/mouse-sensor/prevent-standard-keys-while-dragging.spec.js b/test/unit/integration/drag-handle/mouse-sensor/prevent-standard-keys-while-dragging.spec.js index ae72cced61..4b5bbc57ea 100644 --- a/test/unit/integration/drag-handle/mouse-sensor/prevent-standard-keys-while-dragging.spec.js +++ b/test/unit/integration/drag-handle/mouse-sensor/prevent-standard-keys-while-dragging.spec.js @@ -4,13 +4,13 @@ import { createEvent, fireEvent, render } from 'react-testing-library'; import * as keyCodes from '../../../../../src/view/key-codes'; import App from '../app'; import { isDragging } from '../util'; -import { simpleLift } from './util'; +import { simpleLift, mouse } from '../controls'; it('should prevent enter or tab being pressed during a drag', () => { const { getByText } = render(); const handle: HTMLElement = getByText('item: 0'); - simpleLift(handle); + simpleLift(mouse, handle); expect(isDragging(handle)).toBe(true); [keyCodes.enter, keyCodes.tab].forEach((keyCode: number) => { diff --git a/test/unit/integration/drag-handle/mouse-sensor/start-dragging.spec.js b/test/unit/integration/drag-handle/mouse-sensor/start-dragging.spec.js index 42c994824d..059df068b3 100644 --- a/test/unit/integration/drag-handle/mouse-sensor/start-dragging.spec.js +++ b/test/unit/integration/drag-handle/mouse-sensor/start-dragging.spec.js @@ -3,10 +3,10 @@ import invariant from 'tiny-invariant'; import React from 'react'; import type { Position } from 'css-box-model'; import { render, fireEvent, createEvent } from 'react-testing-library'; -import { sloppyClickThreshold } from '../../../../../src/view/use-sensor-marshal/sensors/util/is-sloppy-click-threshold-exceeded'; +import { sloppyClickThreshold } from '../../../../../src/view/use-sensor-marshal/sensors/use-mouse-sensor'; import { isDragging } from '../util'; -import App, { type Item } from '../app'; -import { simpleLift, primaryButton } from './util'; +import App from '../app'; +import { primaryButton } from './util'; // blocking announcement messages jest.spyOn(console, 'warn').mockImplementation((message: string) => { diff --git a/test/unit/integration/drag-handle/mouse-sensor/util.js b/test/unit/integration/drag-handle/mouse-sensor/util.js index 72fa823a37..bb5afac310 100644 --- a/test/unit/integration/drag-handle/mouse-sensor/util.js +++ b/test/unit/integration/drag-handle/mouse-sensor/util.js @@ -1,13 +1,8 @@ // @flow -import { fireEvent } from 'react-testing-library'; -import { sloppyClickThreshold } from '../../../../../src/view/use-sensor-marshal/sensors/util/is-sloppy-click-threshold-exceeded'; +import { simpleLift, mouse } from '../controls'; export const primaryButton = 0; -export function simpleLift(handle: HTMLElement) { - fireEvent.mouseDown(handle); - fireEvent.mouseMove(handle, { - clientX: 0, - clientY: sloppyClickThreshold, - }); +export function simpleMouseLift(handle: HTMLElement) { + return simpleLift(mouse, handle); } diff --git a/test/unit/integration/drag-handle/sensor-marshal/click-blocking.spec.js b/test/unit/integration/drag-handle/sensor-marshal/click-blocking.spec.js index b81c2a4fcb..7217ccce0a 100644 --- a/test/unit/integration/drag-handle/sensor-marshal/click-blocking.spec.js +++ b/test/unit/integration/drag-handle/sensor-marshal/click-blocking.spec.js @@ -1,7 +1,7 @@ // @flow import invariant from 'tiny-invariant'; import React from 'react'; -import { render, fireEvent } from 'react-testing-library'; +import { render, fireEvent, createEvent } from 'react-testing-library'; import type { TryGetActionLock, Sensor, @@ -10,15 +10,6 @@ import type { } from '../../../../../src/types'; import App from '../app'; -function getClick(): MouseEvent { - return new MouseEvent('click', { - clientX: 0, - clientY: 0, - cancelable: true, - bubbles: true, - }); -} - it('should block a single click if requested', () => { let tryGetLock: TryGetActionLock; @@ -41,8 +32,8 @@ it('should block a single click if requested', () => { drag.drop({ shouldBlockNextClick: true }); // fire click - const first: MouseEvent = getClick(); - const second: MouseEvent = getClick(); + const first: MouseEvent = createEvent.click(handle); + const second: MouseEvent = createEvent.click(handle); fireEvent(handle, first); fireEvent(handle, second); @@ -73,7 +64,7 @@ it('should not block any clicks if not requested', () => { drag.drop({ shouldBlockNextClick: false }); // fire click - const first: MouseEvent = getClick(); + const first: MouseEvent = createEvent.click(handle); fireEvent(handle, first); // click not prevented diff --git a/test/unit/integration/drag-handle/shared-behaviours/abort-on-error.spec.js b/test/unit/integration/drag-handle/shared-behaviours/abort-on-error.spec.js index c7f7ff21f4..51bb383d27 100644 --- a/test/unit/integration/drag-handle/shared-behaviours/abort-on-error.spec.js +++ b/test/unit/integration/drag-handle/shared-behaviours/abort-on-error.spec.js @@ -1,11 +1,11 @@ // @flow import invariant from 'tiny-invariant'; -import React, { useEffect, useState, useRef } from 'react'; +import React, { useState, useRef } from 'react'; import { render, act } from 'react-testing-library'; import { isDragging, getOffset } from '../util'; import App from '../app'; import { noop } from '../../../../../src/empty'; -import { forEachSensor, simpleLift, type Control } from './controls'; +import { forEachSensor, simpleLift, type Control } from '../controls'; jest.spyOn(console, 'error').mockImplementation(noop); diff --git a/test/unit/integration/drag-handle/shared-behaviours/cancel-while-dragging.spec.js b/test/unit/integration/drag-handle/shared-behaviours/cancel-while-dragging.spec.js index 52bfafd0bc..65e376fe39 100644 --- a/test/unit/integration/drag-handle/shared-behaviours/cancel-while-dragging.spec.js +++ b/test/unit/integration/drag-handle/shared-behaviours/cancel-while-dragging.spec.js @@ -5,7 +5,7 @@ import * as keyCodes from '../../../../../src/view/key-codes'; import App from '../app'; import { isDragging } from '../util'; import supportedEventName from '../../../../../src/view/use-sensor-marshal/sensors/util/supported-page-visibility-event-name'; -import { forEachSensor, simpleLift, type Control } from './controls'; +import { forEachSensor, simpleLift, type Control } from '../controls'; forEachSensor((control: Control) => { it('should cancel when pressing escape', () => { diff --git a/test/unit/integration/drag-handle/shared-behaviours/cannot-start-when-disabled.spec.js b/test/unit/integration/drag-handle/shared-behaviours/cannot-start-when-disabled.spec.js index f54b84186d..0d9b3efcaa 100644 --- a/test/unit/integration/drag-handle/shared-behaviours/cannot-start-when-disabled.spec.js +++ b/test/unit/integration/drag-handle/shared-behaviours/cannot-start-when-disabled.spec.js @@ -3,7 +3,7 @@ import React from 'react'; import { render } from 'react-testing-library'; import { isDragging } from '../util'; import App, { type Item } from '../app'; -import { forEachSensor, type Control, simpleLift } from './controls'; +import { forEachSensor, type Control, simpleLift } from '../controls'; forEachSensor((control: Control) => { it('should not start a drag if disabled', () => { diff --git a/test/unit/integration/drag-handle/shared-behaviours/cannot-start-when-something-else-has-lock.spec.js b/test/unit/integration/drag-handle/shared-behaviours/cannot-start-when-something-else-has-lock.spec.js index bd34f6eb21..720652365e 100644 --- a/test/unit/integration/drag-handle/shared-behaviours/cannot-start-when-something-else-has-lock.spec.js +++ b/test/unit/integration/drag-handle/shared-behaviours/cannot-start-when-something-else-has-lock.spec.js @@ -4,7 +4,7 @@ import React from 'react'; import { render } from 'react-testing-library'; import { isDragging } from '../util'; import App from '../app'; -import { forEachSensor, type Control, simpleLift } from './controls'; +import { forEachSensor, type Control, simpleLift } from '../controls'; forEachSensor((control: Control) => { it('should not start a drag if another sensor is capturing', () => { diff --git a/test/unit/integration/drag-handle/shared-behaviours/cannot-start-when-unmounted.spec.js b/test/unit/integration/drag-handle/shared-behaviours/cannot-start-when-unmounted.spec.js index 10b30dee16..bee8f20f2f 100644 --- a/test/unit/integration/drag-handle/shared-behaviours/cannot-start-when-unmounted.spec.js +++ b/test/unit/integration/drag-handle/shared-behaviours/cannot-start-when-unmounted.spec.js @@ -3,7 +3,7 @@ import React from 'react'; import { render } from 'react-testing-library'; import { isDragging } from '../util'; import App from '../app'; -import { forEachSensor, type Control, simpleLift } from './controls'; +import { forEachSensor, type Control, simpleLift } from '../controls'; forEachSensor((control: Control) => { it('should not allow starting after the handle is unmounted', () => { diff --git a/test/unit/integration/drag-handle/shared-behaviours/cleanup.spec.js b/test/unit/integration/drag-handle/shared-behaviours/cleanup.spec.js index b97418aa84..61174ee7c7 100644 --- a/test/unit/integration/drag-handle/shared-behaviours/cleanup.spec.js +++ b/test/unit/integration/drag-handle/shared-behaviours/cleanup.spec.js @@ -3,7 +3,7 @@ import React from 'react'; import { render } from 'react-testing-library'; import { isDragging } from '../util'; import App from '../app'; -import { forEachSensor, simpleLift, type Control } from './controls'; +import { forEachSensor, simpleLift, type Control } from '../controls'; function getCallCount(myMock): number { return myMock.mock.calls.length; diff --git a/test/unit/integration/drag-handle/shared-behaviours/contenteditable.spec.js b/test/unit/integration/drag-handle/shared-behaviours/contenteditable.spec.js index 6ad462f97f..29aef94ba4 100644 --- a/test/unit/integration/drag-handle/shared-behaviours/contenteditable.spec.js +++ b/test/unit/integration/drag-handle/shared-behaviours/contenteditable.spec.js @@ -1,7 +1,7 @@ // @flow import React from 'react'; import { render } from 'react-testing-library'; -import { forEachSensor, type Control, simpleLift } from './controls'; +import { forEachSensor, type Control, simpleLift } from '../controls'; import { isDragging } from '../util'; import { type DraggableProvided, diff --git a/test/unit/integration/drag-handle/shared-behaviours/disable-default-sensors.spec.js b/test/unit/integration/drag-handle/shared-behaviours/disable-default-sensors.spec.js index 928011d3f3..1eb794ecf0 100644 --- a/test/unit/integration/drag-handle/shared-behaviours/disable-default-sensors.spec.js +++ b/test/unit/integration/drag-handle/shared-behaviours/disable-default-sensors.spec.js @@ -3,7 +3,7 @@ import React from 'react'; import { render } from 'react-testing-library'; import { isDragging } from '../util'; import App from '../app'; -import { forEachSensor, type Control, simpleLift } from './controls'; +import { forEachSensor, type Control, simpleLift } from '../controls'; forEachSensor((control: Control) => { it('should be able to start a drag if default sensors is disabled', () => { diff --git a/test/unit/integration/drag-handle/shared-behaviours/interactive-elements.spec.js b/test/unit/integration/drag-handle/shared-behaviours/interactive-elements.spec.js index 713698a038..d8a1ee1ee7 100644 --- a/test/unit/integration/drag-handle/shared-behaviours/interactive-elements.spec.js +++ b/test/unit/integration/drag-handle/shared-behaviours/interactive-elements.spec.js @@ -1,7 +1,7 @@ // @flow import React, { type Node } from 'react'; import { render } from 'react-testing-library'; -import { forEachSensor, type Control, simpleLift } from './controls'; +import { forEachSensor, type Control, simpleLift } from '../controls'; import { isDragging } from '../util'; import { type DraggableProvided, diff --git a/test/unit/integration/drag-handle/shared-behaviours/nested-handles.spec.js b/test/unit/integration/drag-handle/shared-behaviours/nested-handles.spec.js index e5f2389905..cc24129abc 100644 --- a/test/unit/integration/drag-handle/shared-behaviours/nested-handles.spec.js +++ b/test/unit/integration/drag-handle/shared-behaviours/nested-handles.spec.js @@ -1,7 +1,7 @@ // @flow import React from 'react'; import { render } from 'react-testing-library'; -import { forEachSensor, type Control, simpleLift } from './controls'; +import { forEachSensor, type Control, simpleLift } from '../controls'; import { isDragging } from '../util'; import Board from '../board'; diff --git a/test/unit/integration/drag-handle/shared-behaviours/no-dragging-svgs.spec.js b/test/unit/integration/drag-handle/shared-behaviours/no-dragging-svgs.spec.js index 23b8602694..c3fb48d0f4 100644 --- a/test/unit/integration/drag-handle/shared-behaviours/no-dragging-svgs.spec.js +++ b/test/unit/integration/drag-handle/shared-behaviours/no-dragging-svgs.spec.js @@ -1,7 +1,7 @@ // @flow import React from 'react'; import { render } from 'react-testing-library'; -import { forEachSensor, type Control, simpleLift } from './controls'; +import { forEachSensor, type Control, simpleLift } from '../controls'; import { isDragging } from '../util'; import { type DraggableProvided, diff --git a/test/unit/integration/drag-handle/shared-behaviours/parent-rendering-should-not-kill-drag.spec.js b/test/unit/integration/drag-handle/shared-behaviours/parent-rendering-should-not-kill-drag.spec.js index 2a18f5953a..c56003b9da 100644 --- a/test/unit/integration/drag-handle/shared-behaviours/parent-rendering-should-not-kill-drag.spec.js +++ b/test/unit/integration/drag-handle/shared-behaviours/parent-rendering-should-not-kill-drag.spec.js @@ -3,7 +3,7 @@ import React from 'react'; import { render } from 'react-testing-library'; import { isDragging } from '../util'; import App from '../app'; -import { forEachSensor, simpleLift, type Control } from './controls'; +import { forEachSensor, simpleLift, type Control } from '../controls'; forEachSensor((control: Control) => { it('should not abort a drag if a parent render occurs', () => { diff --git a/test/unit/integration/drag-handle/shared-behaviours/validate-controls.spec.js b/test/unit/integration/drag-handle/shared-behaviours/validate-controls.spec.js index 351760544e..87f55028c4 100644 --- a/test/unit/integration/drag-handle/shared-behaviours/validate-controls.spec.js +++ b/test/unit/integration/drag-handle/shared-behaviours/validate-controls.spec.js @@ -3,7 +3,7 @@ import React from 'react'; import { render } from 'react-testing-library'; import { isDragging } from '../util'; import App from '../app'; -import { forEachSensor, type Control } from './controls'; +import { forEachSensor, type Control } from '../controls'; forEachSensor((control: Control) => { it('should control the drag through the sensor', () => { From e799615baa42ffafef83e6ded078dc17bfc9a414 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Wed, 29 May 2019 16:34:33 +1000 Subject: [PATCH 093/308] more sensor tests --- .../use-sensor-marshal/use-sensor-marshal.js | 18 +- test/unit/integration/drag-handle/app.jsx | 9 +- test/unit/integration/drag-handle/controls.js | 2 +- .../prevent-keyboard-scroll.spec.js | 26 +++ ...event-standard-keys-while-dragging.spec.js | 21 +++ .../keyboard-sensor/starting-a-drag.spec.js | 17 ++ .../mouse-sensor/force-press.spec.js | 167 +++++++++++++++++- .../mouse-sensor/start-dragging.spec.js | 7 +- .../drag-handle/mouse-sensor/util.js | 8 - .../sensor-marshal/click-blocking.spec.js | 1 - .../cancel-while-dragging.spec.js | 12 +- .../validate-controls.spec.js | 15 +- 12 files changed, 278 insertions(+), 25 deletions(-) create mode 100644 test/unit/integration/drag-handle/keyboard-sensor/prevent-keyboard-scroll.spec.js create mode 100644 test/unit/integration/drag-handle/keyboard-sensor/prevent-standard-keys-while-dragging.spec.js create mode 100644 test/unit/integration/drag-handle/keyboard-sensor/starting-a-drag.spec.js delete mode 100644 test/unit/integration/drag-handle/mouse-sensor/util.js diff --git a/src/view/use-sensor-marshal/use-sensor-marshal.js b/src/view/use-sensor-marshal/use-sensor-marshal.js index 80a1e9a62e..0926168cb2 100644 --- a/src/view/use-sensor-marshal/use-sensor-marshal.js +++ b/src/view/use-sensor-marshal/use-sensor-marshal.js @@ -11,6 +11,7 @@ import type { StopDragOptions, PreDragActions, DragActions, + DraggableDimension, } from '../../types'; import create, { type Lock, type LockAPI } from './lock'; import type { Store, Action } from '../../state/store-types'; @@ -318,24 +319,29 @@ export default function useSensorMarshal({ ]; const lockAPI: LockAPI = useState(() => create())[0]; + const tryAbandonLock = useCallback( + function tryAbandonLock(previous: State, current: State) { + if (previous.isDragging && !current.isDragging) { + lockAPI.tryAbandon(); + } + }, + [lockAPI], + ); + // We need to abort any capturing if there is no longer a drag useLayoutEffect( function listenToStore() { let previous: State = store.getState(); const unsubscribe = store.subscribe(() => { const current: State = store.getState(); - - if (previous.isDragging && !current.isDragging) { - lockAPI.tryAbandon(); - } - + tryAbandonLock(previous, current); previous = current; }); // unsubscribe from store when unmounting return unsubscribe; }, - [lockAPI, store], + [lockAPI, store, tryAbandonLock], ); // abort any lock on unmount diff --git a/test/unit/integration/drag-handle/app.jsx b/test/unit/integration/drag-handle/app.jsx index 5bf4459634..a8128e13f3 100644 --- a/test/unit/integration/drag-handle/app.jsx +++ b/test/unit/integration/drag-handle/app.jsx @@ -16,6 +16,8 @@ export type Item = {| isEnabled?: boolean, // defaults to false canDragInteractiveElements?: boolean, + // defaults to false + shouldRespectForcePress?: boolean, |}; type RenderItem = ( @@ -61,7 +63,7 @@ function getItems() { export default function App(props: Props) { const onDragStart = props.onDragStart || noop; - const onDragEnd = props.onDragStart || noop; + const onDragEnd = props.onDragEnd || noop; const sensors: Sensor[] = props.sensors || []; const [items] = useState(() => props.items || getItems()); const render = props.renderItem || defaultItemRender; @@ -90,6 +92,11 @@ export default function App(props: Props) { ? item.canDragInteractiveElements : false } + shouldRespectForcePress={ + typeof item.shouldRespectForcePress === 'boolean' + ? item.shouldRespectForcePress + : false + } > {render(item)} diff --git a/test/unit/integration/drag-handle/controls.js b/test/unit/integration/drag-handle/controls.js index 3fcf442f41..51d2bdeb5a 100644 --- a/test/unit/integration/drag-handle/controls.js +++ b/test/unit/integration/drag-handle/controls.js @@ -1,5 +1,5 @@ // @flow -import { createEvent, fireEvent, act } from 'react-testing-library'; +import { fireEvent, act } from 'react-testing-library'; import { sloppyClickThreshold } from '../../../../src/view/use-sensor-marshal/sensors/use-mouse-sensor'; import { timeForLongPress } from '../../../../src/view/use-sensor-marshal/sensors/use-touch-sensor'; import * as keyCodes from '../../../../src/view/key-codes'; diff --git a/test/unit/integration/drag-handle/keyboard-sensor/prevent-keyboard-scroll.spec.js b/test/unit/integration/drag-handle/keyboard-sensor/prevent-keyboard-scroll.spec.js new file mode 100644 index 0000000000..06926679d4 --- /dev/null +++ b/test/unit/integration/drag-handle/keyboard-sensor/prevent-keyboard-scroll.spec.js @@ -0,0 +1,26 @@ +// @flow +import React from 'react'; +import { createEvent, fireEvent, render } from 'react-testing-library'; +import * as keyCodes from '../../../../../src/view/key-codes'; +import App from '../app'; +import { simpleLift, keyboard } from '../controls'; + +it('should prevent using keyboard keys that modify scroll', () => { + const keys: number[] = [ + keyCodes.pageUp, + keyCodes.pageDown, + keyCodes.home, + keyCodes.end, + ]; + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + simpleLift(keyboard, handle); + + keys.forEach((keyCode: number) => { + const event: Event = createEvent.keyDown(handle, { keyCode }); + fireEvent(handle, event); + + expect(event.defaultPrevented).toBe(true); + }); +}); diff --git a/test/unit/integration/drag-handle/keyboard-sensor/prevent-standard-keys-while-dragging.spec.js b/test/unit/integration/drag-handle/keyboard-sensor/prevent-standard-keys-while-dragging.spec.js new file mode 100644 index 0000000000..9f75503149 --- /dev/null +++ b/test/unit/integration/drag-handle/keyboard-sensor/prevent-standard-keys-while-dragging.spec.js @@ -0,0 +1,21 @@ +// @flow +import React from 'react'; +import { createEvent, fireEvent, render } from 'react-testing-library'; +import * as keyCodes from '../../../../../src/view/key-codes'; +import App from '../app'; +import { isDragging } from '../util'; +import { simpleLift, keyboard } from '../controls'; + +it('should prevent enter or tab being pressed during a drag', () => { + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + simpleLift(keyboard, handle); + expect(isDragging(handle)).toBe(true); + + [keyCodes.enter, keyCodes.tab].forEach((keyCode: number) => { + const event: Event = createEvent.keyDown(handle, { keyCode }); + fireEvent(handle, event); + expect(event.defaultPrevented).toBe(true); + }); +}); diff --git a/test/unit/integration/drag-handle/keyboard-sensor/starting-a-drag.spec.js b/test/unit/integration/drag-handle/keyboard-sensor/starting-a-drag.spec.js new file mode 100644 index 0000000000..39884aa013 --- /dev/null +++ b/test/unit/integration/drag-handle/keyboard-sensor/starting-a-drag.spec.js @@ -0,0 +1,17 @@ +// @flow +import React from 'react'; +import { render, createEvent, fireEvent } from 'react-testing-library'; +import App from '../app'; +import { isDragging } from '../util'; +import * as keyCodes from '../../../../../src/view/key-codes'; + +it('should prevent the default keyboard action when lifting', () => { + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + const event: Event = createEvent.keyDown(handle, { keyCode: keyCodes.space }); + fireEvent(handle, event); + + expect(isDragging(handle)).toBe(true); + expect(event.defaultPrevented).toBe(true); +}); diff --git a/test/unit/integration/drag-handle/mouse-sensor/force-press.spec.js b/test/unit/integration/drag-handle/mouse-sensor/force-press.spec.js index dd571b564c..992613c65c 100644 --- a/test/unit/integration/drag-handle/mouse-sensor/force-press.spec.js +++ b/test/unit/integration/drag-handle/mouse-sensor/force-press.spec.js @@ -1,2 +1,167 @@ // @flow -it('should do something'); +import React from 'react'; +import { render, fireEvent } from 'react-testing-library'; +import { mouse, simpleLift } from '../controls'; +import App, { type Item } from '../app'; +import { isDragging } from '../util'; + +const mouseForcePressThreshold = 2; +const standardForce = 1; + +// $ExpectError - non-standard MouseEvent property +const original = MouseEvent.WEBKIT_FORCE_AT_FORCE_MOUSE_DOWN; + +function setForceDownThreshold(value?: number) { + // $ExpectError - non-standard MouseEvent property + MouseEvent.WEBKIT_FORCE_AT_FORCE_MOUSE_DOWN = value; +} + +function getForceChangeEvent(value?: number) { + const event: Event = new Event('webkitmouseforcechanged', { + bubbles: true, + cancelable: true, + }); + // $ExpectError - being amazing + event.webkitForce = value; + return event; +} + +beforeEach(() => { + setForceDownThreshold(mouseForcePressThreshold); +}); + +afterAll(() => { + setForceDownThreshold(original); +}); + +it('should log a warning if a mouse force changed event is fired when there is no force value', () => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + setForceDownThreshold(mouseForcePressThreshold); + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + simpleLift(mouse, handle); + + expect(console.warn).not.toHaveBeenCalled(); + // not providing any force value + fireEvent(handle, getForceChangeEvent()); + expect(console.warn).toHaveBeenCalled(); + console.warn.mockRestore(); +}); + +it('should log a warning if a mouse force changed event is fired when there is no MouseEvent.WEBKIT_FORCE_AT_FORCE_MOUSE_DOWN global', () => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + // not setting force threshold + setForceDownThreshold(); + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + simpleLift(mouse, handle); + + expect(console.warn).not.toHaveBeenCalled(); + fireEvent(handle, getForceChangeEvent(standardForce)); + expect(console.warn).toHaveBeenCalled(); + console.warn.mockRestore(); +}); + +describe('force press is not respected', () => { + it('should not respect the force press by default', () => { + setForceDownThreshold(mouseForcePressThreshold); + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + mouse.preLift(handle); + const forcePress1: Event = getForceChangeEvent(standardForce); + fireEvent(handle, forcePress1); + + // opting out of default force press behaviour + expect(forcePress1.defaultPrevented).toBe(true); + + // able to continue the lift + mouse.lift(handle); + expect(isDragging(handle)).toBe(true); + + const forcePress2: Event = getForceChangeEvent(standardForce); + fireEvent(handle, forcePress2); + expect(forcePress2.defaultPrevented).toBe(true); + + // still dragging + expect(isDragging(handle)).toBe(true); + }); +}); + +describe('force press is respected', () => { + it('should not cancel a pending drag if not enough force is applied', () => { + setForceDownThreshold(mouseForcePressThreshold); + const items: Item[] = [{ id: '0', shouldRespectForcePress: true }]; + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + mouse.preLift(handle); + const forcePress: Event = getForceChangeEvent(standardForce); + fireEvent(handle, forcePress); + + // allow the force press event to occur + expect(forcePress.defaultPrevented).toBe(false); + + // complete the lift + mouse.lift(handle); + + expect(isDragging(handle)).toBe(true); + }); + + it('should cancel a pending drag if enough force is applied', () => { + setForceDownThreshold(mouseForcePressThreshold); + const items: Item[] = [{ id: '0', shouldRespectForcePress: true }]; + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + mouse.preLift(handle); + const forcePress: Event = getForceChangeEvent(mouseForcePressThreshold); + fireEvent(handle, forcePress); + + // allow the force press event to occur + expect(forcePress.defaultPrevented).toBe(false); + + // complete the lift + mouse.lift(handle); + + expect(isDragging(handle)).toBe(false); + }); + + it('should cancel a drag if enough force is applied', () => { + const onDragEnd = jest.fn(); + setForceDownThreshold(mouseForcePressThreshold); + const items: Item[] = [{ id: '0', shouldRespectForcePress: true }]; + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + simpleLift(mouse, handle); + + const forcePress: Event = getForceChangeEvent(mouseForcePressThreshold); + fireEvent(handle, forcePress); + + // allow the force press event to occur + expect(forcePress.defaultPrevented).toBe(false); + // drag cancelled + expect(isDragging(handle)).toBe(false); + expect(onDragEnd.mock.calls[0][0].reason).toBe('CANCEL'); + }); + + it('should not cancel a drag if not enough force is applied', () => { + setForceDownThreshold(mouseForcePressThreshold); + const items: Item[] = [{ id: '0', shouldRespectForcePress: true }]; + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + simpleLift(mouse, handle); + + const forcePress: Event = getForceChangeEvent(standardForce); + fireEvent(handle, forcePress); + + // allow the force press event to occur + expect(forcePress.defaultPrevented).toBe(false); + // drag not aborted + expect(isDragging(handle)).toBe(true); + }); +}); diff --git a/test/unit/integration/drag-handle/mouse-sensor/start-dragging.spec.js b/test/unit/integration/drag-handle/mouse-sensor/start-dragging.spec.js index 059df068b3..b601ad9217 100644 --- a/test/unit/integration/drag-handle/mouse-sensor/start-dragging.spec.js +++ b/test/unit/integration/drag-handle/mouse-sensor/start-dragging.spec.js @@ -3,11 +3,12 @@ import invariant from 'tiny-invariant'; import React from 'react'; import type { Position } from 'css-box-model'; import { render, fireEvent, createEvent } from 'react-testing-library'; -import { sloppyClickThreshold } from '../../../../../src/view/use-sensor-marshal/sensors/use-mouse-sensor'; +import { + sloppyClickThreshold, + primaryButton, +} from '../../../../../src/view/use-sensor-marshal/sensors/use-mouse-sensor'; import { isDragging } from '../util'; import App from '../app'; -import { primaryButton } from './util'; - // blocking announcement messages jest.spyOn(console, 'warn').mockImplementation((message: string) => { invariant( diff --git a/test/unit/integration/drag-handle/mouse-sensor/util.js b/test/unit/integration/drag-handle/mouse-sensor/util.js deleted file mode 100644 index bb5afac310..0000000000 --- a/test/unit/integration/drag-handle/mouse-sensor/util.js +++ /dev/null @@ -1,8 +0,0 @@ -// @flow -import { simpleLift, mouse } from '../controls'; - -export const primaryButton = 0; - -export function simpleMouseLift(handle: HTMLElement) { - return simpleLift(mouse, handle); -} diff --git a/test/unit/integration/drag-handle/sensor-marshal/click-blocking.spec.js b/test/unit/integration/drag-handle/sensor-marshal/click-blocking.spec.js index 7217ccce0a..228df1cf69 100644 --- a/test/unit/integration/drag-handle/sensor-marshal/click-blocking.spec.js +++ b/test/unit/integration/drag-handle/sensor-marshal/click-blocking.spec.js @@ -12,7 +12,6 @@ import App from '../app'; it('should block a single click if requested', () => { let tryGetLock: TryGetActionLock; - const a: Sensor = (tryStart: TryGetActionLock) => { tryGetLock = tryStart; }; diff --git a/test/unit/integration/drag-handle/shared-behaviours/cancel-while-dragging.spec.js b/test/unit/integration/drag-handle/shared-behaviours/cancel-while-dragging.spec.js index 65e376fe39..87d6822a71 100644 --- a/test/unit/integration/drag-handle/shared-behaviours/cancel-while-dragging.spec.js +++ b/test/unit/integration/drag-handle/shared-behaviours/cancel-while-dragging.spec.js @@ -9,7 +9,8 @@ import { forEachSensor, simpleLift, type Control } from '../controls'; forEachSensor((control: Control) => { it('should cancel when pressing escape', () => { - const { getByText } = render(); + const onDragEnd = jest.fn(); + const { getByText } = render(); const handle: HTMLElement = getByText('item: 0'); simpleLift(control, handle); @@ -26,10 +27,12 @@ forEachSensor((control: Control) => { expect(event.defaultPrevented).toBe(true); // drag ended expect(isDragging(handle)).toBe(false); + expect(onDragEnd.mock.calls[0][0].reason).toBe('CANCEL'); }); it('should cancel when window is resized', () => { - const { getByText } = render(); + const onDragEnd = jest.fn(); + const { getByText } = render(); const handle: HTMLElement = getByText('item: 0'); simpleLift(control, handle); @@ -47,10 +50,12 @@ forEachSensor((control: Control) => { expect(event.defaultPrevented).toBe(false); // drag ended expect(isDragging(handle)).toBe(false); + expect(onDragEnd.mock.calls[0][0].reason).toBe('CANCEL'); }); it('should cancel when there is a visibility change', () => { - const { getByText } = render(); + const onDragEnd = jest.fn(); + const { getByText } = render(); const handle: HTMLElement = getByText('item: 0'); simpleLift(control, handle); @@ -68,5 +73,6 @@ forEachSensor((control: Control) => { expect(event.defaultPrevented).toBe(false); // drag ended expect(isDragging(handle)).toBe(false); + expect(onDragEnd.mock.calls[0][0].reason).toBe('CANCEL'); }); }); diff --git a/test/unit/integration/drag-handle/shared-behaviours/validate-controls.spec.js b/test/unit/integration/drag-handle/shared-behaviours/validate-controls.spec.js index 87f55028c4..32b7844eec 100644 --- a/test/unit/integration/drag-handle/shared-behaviours/validate-controls.spec.js +++ b/test/unit/integration/drag-handle/shared-behaviours/validate-controls.spec.js @@ -7,7 +7,11 @@ import { forEachSensor, type Control } from '../controls'; forEachSensor((control: Control) => { it('should control the drag through the sensor', () => { - const { getByText } = render(); + const onDragStart = jest.fn(); + const onDragEnd = jest.fn(); + const { getByText } = render( + , + ); const handle: HTMLElement = getByText('item: 0'); control.preLift(handle); @@ -16,12 +20,21 @@ forEachSensor((control: Control) => { control.lift(handle); expect(isDragging(handle)).toBe(true); + // on drag start is async + jest.runOnlyPendingTimers(); + expect(onDragStart).toHaveBeenCalled(); + // move control.move(handle); expect(isDragging(handle)).toBe(true); // drop + expect(onDragEnd).not.toHaveBeenCalled(); + control.drop(handle); expect(isDragging(handle)).toBe(false); + + expect(onDragEnd).toHaveBeenCalled(); + expect(onDragEnd.mock.calls[0][0].reason).toBe('DROP'); }); }); From 1a8f32ce4eae1fe952002badb5a1a3a0392508c5 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Wed, 29 May 2019 17:08:43 +1000 Subject: [PATCH 094/308] onDragEnd tests --- .../prevent-keyboard-scroll.spec.js | 2 ++ .../mouse-sensor/click-blocking.spec.js | 15 ++++++++++++--- .../drag-handle/mouse-sensor/force-press.spec.js | 4 ++-- .../prevent-standard-keys-while-dragging.spec.js | 1 + .../cancel-while-dragging.spec.js | 4 ++-- test/unit/integration/drag-handle/util.js | 5 +++++ 6 files changed, 24 insertions(+), 7 deletions(-) diff --git a/test/unit/integration/drag-handle/keyboard-sensor/prevent-keyboard-scroll.spec.js b/test/unit/integration/drag-handle/keyboard-sensor/prevent-keyboard-scroll.spec.js index 06926679d4..445fd5dddb 100644 --- a/test/unit/integration/drag-handle/keyboard-sensor/prevent-keyboard-scroll.spec.js +++ b/test/unit/integration/drag-handle/keyboard-sensor/prevent-keyboard-scroll.spec.js @@ -4,6 +4,7 @@ import { createEvent, fireEvent, render } from 'react-testing-library'; import * as keyCodes from '../../../../../src/view/key-codes'; import App from '../app'; import { simpleLift, keyboard } from '../controls'; +import { isDragging } from '../util'; it('should prevent using keyboard keys that modify scroll', () => { const keys: number[] = [ @@ -22,5 +23,6 @@ it('should prevent using keyboard keys that modify scroll', () => { fireEvent(handle, event); expect(event.defaultPrevented).toBe(true); + expect(isDragging(handle)).toBe(true); }); }); diff --git a/test/unit/integration/drag-handle/mouse-sensor/click-blocking.spec.js b/test/unit/integration/drag-handle/mouse-sensor/click-blocking.spec.js index 9a963cc696..b4102a76d9 100644 --- a/test/unit/integration/drag-handle/mouse-sensor/click-blocking.spec.js +++ b/test/unit/integration/drag-handle/mouse-sensor/click-blocking.spec.js @@ -4,7 +4,7 @@ import { createEvent, fireEvent, render } from 'react-testing-library'; import * as keyCodes from '../../../../../src/view/key-codes'; import { sloppyClickThreshold } from '../../../../../src/view/use-sensor-marshal/sensors/use-mouse-sensor'; import App from '../app'; -import { isDragging } from '../util'; +import { isDragging, getDropReason } from '../util'; import { simpleLift, mouse } from '../controls'; it('should not prevent a subsequent click if aborting during a pending drag', () => { @@ -32,7 +32,8 @@ it('should not prevent a subsequent click if aborting during a pending drag', () }); it('should prevent a subsequent click if cancelling a drag', () => { - const { getByText } = render(); + const onDragEnd = jest.fn(); + const { getByText } = render(); const handle: HTMLElement = getByText('item: 0'); simpleLift(mouse, handle); @@ -41,6 +42,10 @@ it('should prevent a subsequent click if cancelling a drag', () => { // cancel fireEvent.keyDown(handle, { keyCode: keyCodes.escape }); + // drag cancelled + expect(getDropReason(onDragEnd)).toBe('CANCEL'); + expect(isDragging(handle)).toBe(false); + // click event prevented const click: Event = createEvent.click(handle); fireEvent(handle, click); @@ -48,7 +53,8 @@ it('should prevent a subsequent click if cancelling a drag', () => { }); it('should prevent a subsequent click if dropping a drag', () => { - const { getByText } = render(); + const onDragEnd = jest.fn(); + const { getByText } = render(); const handle: HTMLElement = getByText('item: 0'); simpleLift(mouse, handle); @@ -57,6 +63,9 @@ it('should prevent a subsequent click if dropping a drag', () => { // cancel fireEvent.mouseUp(handle); + expect(getDropReason(onDragEnd)).toBe('DROP'); + expect(isDragging(handle)).toBe(false); + // click event prevented const click: Event = createEvent.click(handle); fireEvent(handle, click); diff --git a/test/unit/integration/drag-handle/mouse-sensor/force-press.spec.js b/test/unit/integration/drag-handle/mouse-sensor/force-press.spec.js index 992613c65c..bfae6c1a86 100644 --- a/test/unit/integration/drag-handle/mouse-sensor/force-press.spec.js +++ b/test/unit/integration/drag-handle/mouse-sensor/force-press.spec.js @@ -3,7 +3,7 @@ import React from 'react'; import { render, fireEvent } from 'react-testing-library'; import { mouse, simpleLift } from '../controls'; import App, { type Item } from '../app'; -import { isDragging } from '../util'; +import { isDragging, getDropReason } from '../util'; const mouseForcePressThreshold = 2; const standardForce = 1; @@ -145,7 +145,7 @@ describe('force press is respected', () => { expect(forcePress.defaultPrevented).toBe(false); // drag cancelled expect(isDragging(handle)).toBe(false); - expect(onDragEnd.mock.calls[0][0].reason).toBe('CANCEL'); + expect(getDropReason(onDragEnd)).toBe('CANCEL'); }); it('should not cancel a drag if not enough force is applied', () => { diff --git a/test/unit/integration/drag-handle/mouse-sensor/prevent-standard-keys-while-dragging.spec.js b/test/unit/integration/drag-handle/mouse-sensor/prevent-standard-keys-while-dragging.spec.js index 4b5bbc57ea..f00388caf9 100644 --- a/test/unit/integration/drag-handle/mouse-sensor/prevent-standard-keys-while-dragging.spec.js +++ b/test/unit/integration/drag-handle/mouse-sensor/prevent-standard-keys-while-dragging.spec.js @@ -17,5 +17,6 @@ it('should prevent enter or tab being pressed during a drag', () => { const event: Event = createEvent.keyDown(handle, { keyCode }); fireEvent(handle, event); expect(event.defaultPrevented).toBe(true); + expect(isDragging(handle)).toBe(true); }); }); diff --git a/test/unit/integration/drag-handle/shared-behaviours/cancel-while-dragging.spec.js b/test/unit/integration/drag-handle/shared-behaviours/cancel-while-dragging.spec.js index 87d6822a71..1d44d9d2e0 100644 --- a/test/unit/integration/drag-handle/shared-behaviours/cancel-while-dragging.spec.js +++ b/test/unit/integration/drag-handle/shared-behaviours/cancel-while-dragging.spec.js @@ -3,7 +3,7 @@ import React from 'react'; import { createEvent, fireEvent, render } from 'react-testing-library'; import * as keyCodes from '../../../../../src/view/key-codes'; import App from '../app'; -import { isDragging } from '../util'; +import { isDragging, getDropReason } from '../util'; import supportedEventName from '../../../../../src/view/use-sensor-marshal/sensors/util/supported-page-visibility-event-name'; import { forEachSensor, simpleLift, type Control } from '../controls'; @@ -73,6 +73,6 @@ forEachSensor((control: Control) => { expect(event.defaultPrevented).toBe(false); // drag ended expect(isDragging(handle)).toBe(false); - expect(onDragEnd.mock.calls[0][0].reason).toBe('CANCEL'); + expect(getDropReason(onDragEnd)).toBe('CANCEL'); }); }); diff --git a/test/unit/integration/drag-handle/util.js b/test/unit/integration/drag-handle/util.js index c82736fd8e..cf661a981c 100644 --- a/test/unit/integration/drag-handle/util.js +++ b/test/unit/integration/drag-handle/util.js @@ -1,6 +1,7 @@ // @flow import invariant from 'tiny-invariant'; import type { Position } from 'css-box-model'; +import type { DropReason } from '../../../../src/types'; export function isDragging(el: HTMLElement): boolean { return el.getAttribute('data-is-dragging') === 'true'; @@ -28,3 +29,7 @@ export function getOffset(el: HTMLElement): Position { y: Number(result[2]), }; } + +export function getDropReason(onDragEnd: JestMockFn<*, *>): DropReason { + return onDragEnd.mock.calls[0][0].reason; +} From a9e218e0d45d8c8f019555beaa03e48136451826 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Wed, 29 May 2019 17:24:11 +1000 Subject: [PATCH 095/308] mroe robust testing --- .../move-to-next-index/index.js | 5 -- test/unit/integration/drag-handle/controls.js | 12 ++++ .../keyboard-sensor/cancel.spec.js | 4 ++ .../directional-movement.spec.js | 0 .../keyboard-sensor/no-click-blocking.spec.js | 2 + .../keyboard-sensor/stopping-a-drag.spec.js | 2 + .../validate-controls.spec.js | 66 ++++++++++++++----- 7 files changed, 68 insertions(+), 23 deletions(-) create mode 100644 test/unit/integration/drag-handle/keyboard-sensor/cancel.spec.js create mode 100644 test/unit/integration/drag-handle/keyboard-sensor/directional-movement.spec.js create mode 100644 test/unit/integration/drag-handle/keyboard-sensor/no-click-blocking.spec.js create mode 100644 test/unit/integration/drag-handle/keyboard-sensor/stopping-a-drag.spec.js diff --git a/src/state/move-in-direction/move-to-next-place/move-to-next-index/index.js b/src/state/move-in-direction/move-to-next-place/move-to-next-index/index.js index 10ff4c7b84..e257e37bcd 100644 --- a/src/state/move-in-direction/move-to-next-place/move-to-next-index/index.js +++ b/src/state/move-in-direction/move-to-next-place/move-to-next-index/index.js @@ -83,7 +83,6 @@ export default ({ } if (isMovingForward) { - console.log('remove closest'); return removeClosest(lastDisplaced); } @@ -101,10 +100,6 @@ export default ({ atProposedIndex, `Could not find item at proposed index ${proposedIndex}`, ); - console.log('add closest', { - proposedIndex, - item: atProposedIndex.descriptor, - }); return addClosest(atProposedIndex, lastDisplaced); })(); diff --git a/test/unit/integration/drag-handle/controls.js b/test/unit/integration/drag-handle/controls.js index 51d2bdeb5a..c1d77ffd8f 100644 --- a/test/unit/integration/drag-handle/controls.js +++ b/test/unit/integration/drag-handle/controls.js @@ -10,6 +10,7 @@ export type Control = {| lift: (handle: HTMLElement) => void, move: (handle: HTMLElement) => void, drop: (handle: HTMLElement) => void, + cancel: (handle: HTMLElement) => void, |}; export function simpleLift(control: Control, handle: HTMLElement) { @@ -50,6 +51,10 @@ export const mouse: Control = { fireEvent.mouseUp(handle); fireEvent(handle, getTransitionEnd()); }, + cancel: (handle: HTMLElement) => { + fireEvent.keyDown(handle, { keyCode: keyCodes.escape }); + fireEvent(handle, getTransitionEnd()); + }, }; export const keyboard: Control = { @@ -67,6 +72,9 @@ export const keyboard: Control = { fireEvent.keyDown(handle, { keyCode: keyCodes.space }); // no drop animation }, + cancel: (handle: HTMLElement) => { + fireEvent.keyDown(handle, { keyCode: keyCodes.escape }); + }, }; export const touch: Control = { @@ -93,6 +101,10 @@ export const touch: Control = { // allow for drop animation fireEvent(handle, getTransitionEnd()); }, + cancel: (handle: HTMLElement) => { + fireEvent.keyDown(handle, { keyCode: keyCodes.escape }); + fireEvent(handle, getTransitionEnd()); + }, }; export const controls: Control[] = [mouse, keyboard, touch]; diff --git a/test/unit/integration/drag-handle/keyboard-sensor/cancel.spec.js b/test/unit/integration/drag-handle/keyboard-sensor/cancel.spec.js new file mode 100644 index 0000000000..83f9303ff4 --- /dev/null +++ b/test/unit/integration/drag-handle/keyboard-sensor/cancel.spec.js @@ -0,0 +1,4 @@ +// @flow +it('should cancel the drag when the user presses escape and prevent default on the event', () => {}); + +it('should cancel when the user pushes any mouse button', () => {}); diff --git a/test/unit/integration/drag-handle/keyboard-sensor/directional-movement.spec.js b/test/unit/integration/drag-handle/keyboard-sensor/directional-movement.spec.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/unit/integration/drag-handle/keyboard-sensor/no-click-blocking.spec.js b/test/unit/integration/drag-handle/keyboard-sensor/no-click-blocking.spec.js new file mode 100644 index 0000000000..bae8426b4d --- /dev/null +++ b/test/unit/integration/drag-handle/keyboard-sensor/no-click-blocking.spec.js @@ -0,0 +1,2 @@ +// @flow +it('should not prevent any subsequent window click actions', () => {}); diff --git a/test/unit/integration/drag-handle/keyboard-sensor/stopping-a-drag.spec.js b/test/unit/integration/drag-handle/keyboard-sensor/stopping-a-drag.spec.js new file mode 100644 index 0000000000..4958c5046f --- /dev/null +++ b/test/unit/integration/drag-handle/keyboard-sensor/stopping-a-drag.spec.js @@ -0,0 +1,2 @@ +// @flow +it('should prevent default on the event', () => {}); diff --git a/test/unit/integration/drag-handle/shared-behaviours/validate-controls.spec.js b/test/unit/integration/drag-handle/shared-behaviours/validate-controls.spec.js index 32b7844eec..203a0c7fbf 100644 --- a/test/unit/integration/drag-handle/shared-behaviours/validate-controls.spec.js +++ b/test/unit/integration/drag-handle/shared-behaviours/validate-controls.spec.js @@ -1,9 +1,9 @@ // @flow import React from 'react'; import { render } from 'react-testing-library'; -import { isDragging } from '../util'; +import { isDragging, getDropReason } from '../util'; import App from '../app'; -import { forEachSensor, type Control } from '../controls'; +import { forEachSensor, type Control, cancel } from '../controls'; forEachSensor((control: Control) => { it('should control the drag through the sensor', () => { @@ -14,27 +14,57 @@ forEachSensor((control: Control) => { ); const handle: HTMLElement = getByText('item: 0'); - control.preLift(handle); - expect(isDragging(handle)).toBe(false); + // Drop + Array.from({ length: 4 }).forEach(() => { + control.preLift(handle); + expect(isDragging(handle)).toBe(false); - control.lift(handle); - expect(isDragging(handle)).toBe(true); + control.lift(handle); + expect(isDragging(handle)).toBe(true); - // on drag start is async - jest.runOnlyPendingTimers(); - expect(onDragStart).toHaveBeenCalled(); + // on drag start is async + jest.runOnlyPendingTimers(); + expect(onDragStart).toHaveBeenCalled(); - // move - control.move(handle); - expect(isDragging(handle)).toBe(true); + // move + control.move(handle); + expect(isDragging(handle)).toBe(true); - // drop - expect(onDragEnd).not.toHaveBeenCalled(); + // drop + expect(onDragEnd).not.toHaveBeenCalled(); - control.drop(handle); - expect(isDragging(handle)).toBe(false); + control.drop(handle); + expect(isDragging(handle)).toBe(false); - expect(onDragEnd).toHaveBeenCalled(); - expect(onDragEnd.mock.calls[0][0].reason).toBe('DROP'); + expect(onDragEnd).toHaveBeenCalledTimes(1); + expect(getDropReason(onDragEnd)).toBe('DROP'); + + onDragEnd.mockClear(); + }); + + // Cancel + Array.from({ length: 4 }).forEach(() => { + control.preLift(handle); + expect(isDragging(handle)).toBe(false); + + control.lift(handle); + expect(isDragging(handle)).toBe(true); + + // on drag start is async + jest.runOnlyPendingTimers(); + expect(onDragStart).toHaveBeenCalled(); + + // move + control.move(handle); + expect(isDragging(handle)).toBe(true); + + control.cancel(handle); + + expect(isDragging(handle)).toBe(false); + expect(onDragEnd).toHaveBeenCalledTimes(1); + expect(getDropReason(onDragEnd)).toBe('CANCEL'); + + onDragEnd.mockClear(); + }); }); }); From 396b64214d1df38d0b0d723faf02befa30ea2e49 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Wed, 29 May 2019 20:40:45 +1000 Subject: [PATCH 096/308] direction movement for handles --- src/index.js | 1 + test/unit/integration/drag-handle/app.jsx | 8 ++- .../directional-movement.spec.js | 72 +++++++++++++++++++ 3 files changed, 80 insertions(+), 1 deletion(-) diff --git a/src/index.js b/src/index.js index cfce1a531f..b05a75f27d 100644 --- a/src/index.js +++ b/src/index.js @@ -21,6 +21,7 @@ export type { DragStart, DragUpdate, DropResult, + Direction, ResponderProvided, Announce, DraggableLocation, diff --git a/test/unit/integration/drag-handle/app.jsx b/test/unit/integration/drag-handle/app.jsx index a8128e13f3..1feefbd034 100644 --- a/test/unit/integration/drag-handle/app.jsx +++ b/test/unit/integration/drag-handle/app.jsx @@ -8,6 +8,7 @@ import { type DraggableProvided, type DraggableStateSnapshot, type Sensor, + type Direction, } from '../../../../src'; export type Item = {| @@ -41,10 +42,12 @@ const defaultItemRender: RenderItem = (item: Item) => ( type Props = {| onDragStart?: Function, + onDragUpdate?: Function, onDragEnd?: Function, items?: Item[], anotherChild?: Node, renderItem?: RenderItem, + direction?: Direction, sensors?: Sensor[], enableDefaultSensors?: boolean, @@ -63,19 +66,22 @@ function getItems() { export default function App(props: Props) { const onDragStart = props.onDragStart || noop; + const onDragUpdate = props.onDragUpdate || noop; const onDragEnd = props.onDragEnd || noop; const sensors: Sensor[] = props.sensors || []; const [items] = useState(() => props.items || getItems()); const render = props.renderItem || defaultItemRender; + const direction: Direction = props.direction || 'vertical'; return ( - + {(droppableProvided: DroppableProvided) => (
{ + const onDragUpdate = jest.fn(); + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 1'); + + simpleLift(keyboard, handle); + + fireEvent.keyDown(handle, { keyCode: keyCodes.arrowUp }); + + // flush async responder + jest.runOnlyPendingTimers(); + expect(onDragUpdate).toHaveBeenCalled(); + expect(onDragUpdate.mock.calls[0][0].destination.index).toBe(0); +}); + +it('should move down when pressing the down arrow', () => { + const onDragUpdate = jest.fn(); + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + simpleLift(keyboard, handle); + + fireEvent.keyDown(handle, { keyCode: keyCodes.arrowDown }); + + // flush async responder + jest.runOnlyPendingTimers(); + expect(onDragUpdate).toHaveBeenCalled(); + expect(onDragUpdate.mock.calls[0][0].destination.index).toBe(1); +}); + +it('should move right when pressing the right arrow', () => { + const onDragUpdate = jest.fn(); + const { getByText } = render( + , + ); + const handle: HTMLElement = getByText('item: 0'); + + simpleLift(keyboard, handle); + + fireEvent.keyDown(handle, { keyCode: keyCodes.arrowRight }); + + // flush async responder + jest.runOnlyPendingTimers(); + expect(onDragUpdate).toHaveBeenCalled(); + expect(onDragUpdate.mock.calls[0][0].destination.index).toBe(1); +}); + +it('should move left when pressing the left arrow', () => { + const onDragUpdate = jest.fn(); + const { getByText } = render( + , + ); + const handle: HTMLElement = getByText('item: 1'); + + simpleLift(keyboard, handle); + + fireEvent.keyDown(handle, { keyCode: keyCodes.arrowLeft }); + + // flush async responder + jest.runOnlyPendingTimers(); + expect(onDragUpdate).toHaveBeenCalled(); + expect(onDragUpdate.mock.calls[0][0].destination.index).toBe(0); +}); From e195202bb8992e89ee01f599da047e2211c5a618 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Thu, 30 May 2019 07:09:26 +1000 Subject: [PATCH 097/308] more media --- docs/support/media.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/support/media.md b/docs/support/media.md index f5e0684fef..fdcc79694c 100644 --- a/docs/support/media.md +++ b/docs/support/media.md @@ -49,7 +49,9 @@ This page contains a list of articles, blogs and newsletters that `react-beautif - React Status [issue 114](https://react.statuscode.com/issues/114) - React Status [issue 129](https://react.statuscode.com/issues/129) - React Status [issue 134](https://react.statuscode.com/issues/134) +- React Status [issue 139](https://react.statuscode.com/issues/139) - Fullstack React [issue 72](http://newsletter.fullstackreact.com/issues/72) +- React Digest [issue 201](https://www.reactdigest.net/digests/201) - HashBang Weekly [issue 52](http://hashbangweekly.okgrow.com/2018/01/22/issue-52) - CSS Animation Weekly [issue 60](http://weekly.cssanimation.rocks/issues/css-animation-weekly-60-working-with-animations-skeleton-screens-and-rethinking-drag-and-drop-73446) - Codrops [collective 340](https://tympanus.net/codrops/collective/collective-340/) From ed885eb94bf5e98c058af340c055256c186decba Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Thu, 30 May 2019 19:30:04 +1000 Subject: [PATCH 098/308] tests for cancelling --- .../keyboard-sensor/stopping-a-drag.spec.js | 87 ++++++++++++++++++- .../mouse-sensor/stopping-a-drag.spec.js | 76 ++++++++++++++++ .../validate-controls.spec.js | 2 +- 3 files changed, 163 insertions(+), 2 deletions(-) create mode 100644 test/unit/integration/drag-handle/mouse-sensor/stopping-a-drag.spec.js diff --git a/test/unit/integration/drag-handle/keyboard-sensor/stopping-a-drag.spec.js b/test/unit/integration/drag-handle/keyboard-sensor/stopping-a-drag.spec.js index 4958c5046f..8c4bb1f7d3 100644 --- a/test/unit/integration/drag-handle/keyboard-sensor/stopping-a-drag.spec.js +++ b/test/unit/integration/drag-handle/keyboard-sensor/stopping-a-drag.spec.js @@ -1,2 +1,87 @@ // @flow -it('should prevent default on the event', () => {}); +// @flow +import React from 'react'; +import { render, createEvent, fireEvent } from 'react-testing-library'; +import App from '../app'; +import { getDropReason } from '../util'; +import * as keyCodes from '../../../../../src/view/key-codes'; +import { simpleLift, keyboard } from '../controls'; +import supportedEventName from '../../../../../src/view/use-sensor-marshal/sensors/util/supported-page-visibility-event-name'; + +it('should prevent default on the event that causes a drop', () => { + const onDragEnd = jest.fn(); + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + simpleLift(keyboard, handle); + + const event: Event = createEvent.keyDown(handle, { keyCode: keyCodes.space }); + fireEvent(handle, event); + + expect(event.defaultPrevented).toBe(true); + expect(getDropReason(onDragEnd)).toBe('DROP'); +}); + +it('should prevent default on an escape press', () => { + const onDragEnd = jest.fn(); + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + simpleLift(keyboard, handle); + + const event: Event = createEvent.keyDown(handle, { + keyCode: keyCodes.escape, + }); + fireEvent(handle, event); + + expect(event.defaultPrevented).toBe(true); + expect(getDropReason(onDragEnd)).toBe('CANCEL'); +}); + +it('should not prevent the default behaviour for an indirect cancel', () => { + [ + 'mousedown', + 'mouseup', + 'click', + 'touchstart', + 'resize', + 'wheel', + supportedEventName, + ].forEach((eventName: string) => { + const onDragEnd = jest.fn(); + const { getByText, unmount } = render(); + const handle: HTMLElement = getByText('item: 0'); + + simpleLift(keyboard, handle); + + const event: Event = new Event(eventName, { + bubbles: true, + cancelable: true, + target: handle, + }); + + fireEvent(handle, event); + + // not an explicit cancel + expect(event.defaultPrevented).toBe(false); + expect(getDropReason(onDragEnd)).toBe('CANCEL'); + + unmount(); + }); +}); + +it('should not prevent clicks after a drag', () => { + const onDragEnd = jest.fn(); + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + simpleLift(keyboard, handle); + keyboard.drop(handle); + + const event: Event = createEvent.click(handle); + fireEvent(handle, event); + + // click not blocked + expect(event.defaultPrevented).toBe(false); + expect(onDragEnd).toHaveBeenCalled(); +}); diff --git a/test/unit/integration/drag-handle/mouse-sensor/stopping-a-drag.spec.js b/test/unit/integration/drag-handle/mouse-sensor/stopping-a-drag.spec.js new file mode 100644 index 0000000000..bd3ec8ee13 --- /dev/null +++ b/test/unit/integration/drag-handle/mouse-sensor/stopping-a-drag.spec.js @@ -0,0 +1,76 @@ +// @flow +import React from 'react'; +import { render, createEvent, fireEvent } from 'react-testing-library'; +import App from '../app'; +import { getDropReason } from '../util'; +import * as keyCodes from '../../../../../src/view/key-codes'; +import { simpleLift, mouse } from '../controls'; +import supportedEventName from '../../../../../src/view/use-sensor-marshal/sensors/util/supported-page-visibility-event-name'; + +it('should prevent default on the event that causes a drop', () => { + const onDragEnd = jest.fn(); + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + simpleLift(mouse, handle); + + const event: Event = createEvent.mouseUp(handle); + fireEvent(handle, event); + + expect(event.defaultPrevented).toBe(true); + expect(getDropReason(onDragEnd)).toBe('DROP'); +}); + +it('should prevent default on an escape press', () => { + const onDragEnd = jest.fn(); + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + simpleLift(mouse, handle); + + const event: Event = createEvent.keyDown(handle, { + keyCode: keyCodes.escape, + }); + fireEvent(handle, event); + + expect(event.defaultPrevented).toBe(true); + expect(getDropReason(onDragEnd)).toBe('CANCEL'); +}); + +it('should not prevent the default behaviour for an indirect cancel', () => { + ['resize', supportedEventName].forEach((eventName: string) => { + const onDragEnd = jest.fn(); + const { getByText, unmount } = render(); + const handle: HTMLElement = getByText('item: 0'); + + simpleLift(mouse, handle); + + const event: Event = new Event(eventName, { + bubbles: true, + cancelable: true, + target: handle, + }); + + fireEvent(handle, event); + + // not an explicit cancel + expect(event.defaultPrevented).toBe(false); + expect(getDropReason(onDragEnd)).toBe('CANCEL'); + + unmount(); + }); +}); + +it('should cancel and prevent default on mousedown during a drag as it might be from a different button', () => { + const onDragEnd = jest.fn(); + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + simpleLift(mouse, handle); + + const event: Event = createEvent.mouseDown(handle); + fireEvent(handle, event); + + expect(event.defaultPrevented).toBe(true); + expect(getDropReason(onDragEnd)).toBe('CANCEL'); +}); diff --git a/test/unit/integration/drag-handle/shared-behaviours/validate-controls.spec.js b/test/unit/integration/drag-handle/shared-behaviours/validate-controls.spec.js index 203a0c7fbf..ec5f735ba2 100644 --- a/test/unit/integration/drag-handle/shared-behaviours/validate-controls.spec.js +++ b/test/unit/integration/drag-handle/shared-behaviours/validate-controls.spec.js @@ -3,7 +3,7 @@ import React from 'react'; import { render } from 'react-testing-library'; import { isDragging, getDropReason } from '../util'; import App from '../app'; -import { forEachSensor, type Control, cancel } from '../controls'; +import { forEachSensor, type Control } from '../controls'; forEachSensor((control: Control) => { it('should control the drag through the sensor', () => { From 58d3f35776144b25601f55631525714eefea9c28 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Fri, 31 May 2019 08:23:37 +1000 Subject: [PATCH 099/308] more tests. fixing unneeded layout effect shuffle for keyboad --- .../sensors/use-keyboard-sensor.js | 19 ++++++----- .../sensors/use-mouse-sensor.js | 17 ++++++---- .../sensors/use-touch-sensor.js | 29 +++++++++------- .../keyboard-sensor/cancel.spec.js | 4 --- .../directional-movement.spec.js | 34 ++++++++++++++++--- .../keyboard-sensor/no-click-blocking.spec.js | 2 -- .../keyboard-sensor/stopping-a-drag.spec.js | 1 - ...ng.spec.js => starting-a-dragging.spec.js} | 0 8 files changed, 66 insertions(+), 40 deletions(-) delete mode 100644 test/unit/integration/drag-handle/keyboard-sensor/cancel.spec.js delete mode 100644 test/unit/integration/drag-handle/keyboard-sensor/no-click-blocking.spec.js rename test/unit/integration/drag-handle/mouse-sensor/{start-dragging.spec.js => starting-a-dragging.spec.js} (100%) diff --git a/src/view/use-sensor-marshal/sensors/use-keyboard-sensor.js b/src/view/use-sensor-marshal/sensors/use-keyboard-sensor.js index 98a55dbb47..b11532b1e9 100644 --- a/src/view/use-sensor-marshal/sensors/use-keyboard-sensor.js +++ b/src/view/use-sensor-marshal/sensors/use-keyboard-sensor.js @@ -223,12 +223,15 @@ export default function useKeyboardSensor( [startCaptureBinding], ); - useLayoutEffect(() => { - listenForCapture(); - - // kill any pending window events when unmounting - return () => { - unbindEventsRef.current(); - }; - }); + useLayoutEffect( + function mount() { + listenForCapture(); + + // kill any pending window events when unmounting + return function unmount() { + unbindEventsRef.current(); + }; + }, + [listenForCapture], + ); } diff --git a/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js b/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js index 82336e9946..c7e369ddef 100644 --- a/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js +++ b/src/view/use-sensor-marshal/sensors/use-mouse-sensor.js @@ -344,12 +344,15 @@ export default function useMouseSensor( [bindCapturingEvents], ); - useLayoutEffect(() => { - listenForCapture(); + useLayoutEffect( + function mount() { + listenForCapture(); - // kill any pending window events when unmounting - return function unmount() { - unbindEventsRef.current(); - }; - }, [listenForCapture]); + // kill any pending window events when unmounting + return function unmount() { + unbindEventsRef.current(); + }; + }, + [listenForCapture], + ); } diff --git a/src/view/use-sensor-marshal/sensors/use-touch-sensor.js b/src/view/use-sensor-marshal/sensors/use-touch-sensor.js index 65e0122873..28bdab2796 100644 --- a/src/view/use-sensor-marshal/sensors/use-touch-sensor.js +++ b/src/view/use-sensor-marshal/sensors/use-touch-sensor.js @@ -393,19 +393,22 @@ export default function useMouseSensor( [bindCapturingEvents, getPhase, setPhase, startDragging], ); - useLayoutEffect(() => { - listenForCapture(); + useLayoutEffect( + function mount() { + listenForCapture(); - return function unmount() { - // remove any existing listeners - unbindEventsRef.current(); + return function unmount() { + // remove any existing listeners + unbindEventsRef.current(); - // need to kill any pending drag start timer - const phase: Phase = getPhase(); - if (phase.type === 'PENDING') { - clearTimeout(phase.longPressTimerId); - setPhase(idle); - } - }; - }, [getPhase, listenForCapture, setPhase]); + // need to kill any pending drag start timer + const phase: Phase = getPhase(); + if (phase.type === 'PENDING') { + clearTimeout(phase.longPressTimerId); + setPhase(idle); + } + }; + }, + [getPhase, listenForCapture, setPhase], + ); } diff --git a/test/unit/integration/drag-handle/keyboard-sensor/cancel.spec.js b/test/unit/integration/drag-handle/keyboard-sensor/cancel.spec.js deleted file mode 100644 index 83f9303ff4..0000000000 --- a/test/unit/integration/drag-handle/keyboard-sensor/cancel.spec.js +++ /dev/null @@ -1,4 +0,0 @@ -// @flow -it('should cancel the drag when the user presses escape and prevent default on the event', () => {}); - -it('should cancel when the user pushes any mouse button', () => {}); diff --git a/test/unit/integration/drag-handle/keyboard-sensor/directional-movement.spec.js b/test/unit/integration/drag-handle/keyboard-sensor/directional-movement.spec.js index 2728fcdcd2..2bfd62c22b 100644 --- a/test/unit/integration/drag-handle/keyboard-sensor/directional-movement.spec.js +++ b/test/unit/integration/drag-handle/keyboard-sensor/directional-movement.spec.js @@ -1,6 +1,6 @@ // @flow import React from 'react'; -import { fireEvent, render } from 'react-testing-library'; +import { fireEvent, render, createEvent } from 'react-testing-library'; import * as keyCodes from '../../../../../src/view/key-codes'; import App from '../app'; import { simpleLift, keyboard } from '../controls'; @@ -14,12 +14,18 @@ it('should move up when pressing the up arrow', () => { simpleLift(keyboard, handle); - fireEvent.keyDown(handle, { keyCode: keyCodes.arrowUp }); + const event: Event = createEvent.keyDown(handle, { + keyCode: keyCodes.arrowUp, + }); + fireEvent(handle, event); // flush async responder jest.runOnlyPendingTimers(); expect(onDragUpdate).toHaveBeenCalled(); expect(onDragUpdate.mock.calls[0][0].destination.index).toBe(0); + + // event consumed + expect(event.defaultPrevented).toBe(true); }); it('should move down when pressing the down arrow', () => { @@ -29,12 +35,18 @@ it('should move down when pressing the down arrow', () => { simpleLift(keyboard, handle); - fireEvent.keyDown(handle, { keyCode: keyCodes.arrowDown }); + const event: Event = createEvent.keyDown(handle, { + keyCode: keyCodes.arrowDown, + }); + fireEvent(handle, event); // flush async responder jest.runOnlyPendingTimers(); expect(onDragUpdate).toHaveBeenCalled(); expect(onDragUpdate.mock.calls[0][0].destination.index).toBe(1); + + // event consumed + expect(event.defaultPrevented).toBe(true); }); it('should move right when pressing the right arrow', () => { @@ -46,12 +58,18 @@ it('should move right when pressing the right arrow', () => { simpleLift(keyboard, handle); - fireEvent.keyDown(handle, { keyCode: keyCodes.arrowRight }); + const event: Event = createEvent.keyDown(handle, { + keyCode: keyCodes.arrowRight, + }); + fireEvent(handle, event); // flush async responder jest.runOnlyPendingTimers(); expect(onDragUpdate).toHaveBeenCalled(); expect(onDragUpdate.mock.calls[0][0].destination.index).toBe(1); + + // event consumed + expect(event.defaultPrevented).toBe(true); }); it('should move left when pressing the left arrow', () => { @@ -63,10 +81,16 @@ it('should move left when pressing the left arrow', () => { simpleLift(keyboard, handle); - fireEvent.keyDown(handle, { keyCode: keyCodes.arrowLeft }); + const event: Event = createEvent.keyDown(handle, { + keyCode: keyCodes.arrowLeft, + }); + fireEvent(handle, event); // flush async responder jest.runOnlyPendingTimers(); expect(onDragUpdate).toHaveBeenCalled(); expect(onDragUpdate.mock.calls[0][0].destination.index).toBe(0); + + // event consumed + expect(event.defaultPrevented).toBe(true); }); diff --git a/test/unit/integration/drag-handle/keyboard-sensor/no-click-blocking.spec.js b/test/unit/integration/drag-handle/keyboard-sensor/no-click-blocking.spec.js deleted file mode 100644 index bae8426b4d..0000000000 --- a/test/unit/integration/drag-handle/keyboard-sensor/no-click-blocking.spec.js +++ /dev/null @@ -1,2 +0,0 @@ -// @flow -it('should not prevent any subsequent window click actions', () => {}); diff --git a/test/unit/integration/drag-handle/keyboard-sensor/stopping-a-drag.spec.js b/test/unit/integration/drag-handle/keyboard-sensor/stopping-a-drag.spec.js index 8c4bb1f7d3..a85276b5fa 100644 --- a/test/unit/integration/drag-handle/keyboard-sensor/stopping-a-drag.spec.js +++ b/test/unit/integration/drag-handle/keyboard-sensor/stopping-a-drag.spec.js @@ -1,5 +1,4 @@ // @flow -// @flow import React from 'react'; import { render, createEvent, fireEvent } from 'react-testing-library'; import App from '../app'; diff --git a/test/unit/integration/drag-handle/mouse-sensor/start-dragging.spec.js b/test/unit/integration/drag-handle/mouse-sensor/starting-a-dragging.spec.js similarity index 100% rename from test/unit/integration/drag-handle/mouse-sensor/start-dragging.spec.js rename to test/unit/integration/drag-handle/mouse-sensor/starting-a-dragging.spec.js From ce4b4db4ae4638b2bb3c157fdb921358ac6b8308 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Fri, 31 May 2019 08:30:20 +1000 Subject: [PATCH 100/308] setting up empty touch specs --- .../keyboard-sensor/no-click-blocking.spec.js | 21 +++++++++++++++++++ .../keyboard-sensor/stopping-a-drag.spec.js | 16 -------------- .../validate-controls.spec.js | 13 +++++++++--- .../touch-sensor/cancel-while-pending.spec.js | 1 + .../touch-sensor/click-blocking.spec.js | 0 .../touch-sensor/force-press.spec.js | 0 .../touch-sensor/starting-a-drag.spec.js | 0 .../touch-sensor/stopping-a-drag.spec.js | 1 + 8 files changed, 33 insertions(+), 19 deletions(-) create mode 100644 test/unit/integration/drag-handle/keyboard-sensor/no-click-blocking.spec.js create mode 100644 test/unit/integration/drag-handle/touch-sensor/cancel-while-pending.spec.js create mode 100644 test/unit/integration/drag-handle/touch-sensor/click-blocking.spec.js create mode 100644 test/unit/integration/drag-handle/touch-sensor/force-press.spec.js create mode 100644 test/unit/integration/drag-handle/touch-sensor/starting-a-drag.spec.js create mode 100644 test/unit/integration/drag-handle/touch-sensor/stopping-a-drag.spec.js diff --git a/test/unit/integration/drag-handle/keyboard-sensor/no-click-blocking.spec.js b/test/unit/integration/drag-handle/keyboard-sensor/no-click-blocking.spec.js new file mode 100644 index 0000000000..93e4cd8de1 --- /dev/null +++ b/test/unit/integration/drag-handle/keyboard-sensor/no-click-blocking.spec.js @@ -0,0 +1,21 @@ +// @flow +import React from 'react'; +import { render, createEvent, fireEvent } from 'react-testing-library'; +import App from '../app'; +import { simpleLift, keyboard } from '../controls'; + +it('should not prevent clicks after a drag', () => { + const onDragEnd = jest.fn(); + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + simpleLift(keyboard, handle); + keyboard.drop(handle); + + const event: Event = createEvent.click(handle); + fireEvent(handle, event); + + // click not blocked + expect(event.defaultPrevented).toBe(false); + expect(onDragEnd).toHaveBeenCalled(); +}); diff --git a/test/unit/integration/drag-handle/keyboard-sensor/stopping-a-drag.spec.js b/test/unit/integration/drag-handle/keyboard-sensor/stopping-a-drag.spec.js index a85276b5fa..4991eaa30a 100644 --- a/test/unit/integration/drag-handle/keyboard-sensor/stopping-a-drag.spec.js +++ b/test/unit/integration/drag-handle/keyboard-sensor/stopping-a-drag.spec.js @@ -68,19 +68,3 @@ it('should not prevent the default behaviour for an indirect cancel', () => { unmount(); }); }); - -it('should not prevent clicks after a drag', () => { - const onDragEnd = jest.fn(); - const { getByText } = render(); - const handle: HTMLElement = getByText('item: 0'); - - simpleLift(keyboard, handle); - keyboard.drop(handle); - - const event: Event = createEvent.click(handle); - fireEvent(handle, event); - - // click not blocked - expect(event.defaultPrevented).toBe(false); - expect(onDragEnd).toHaveBeenCalled(); -}); diff --git a/test/unit/integration/drag-handle/shared-behaviours/validate-controls.spec.js b/test/unit/integration/drag-handle/shared-behaviours/validate-controls.spec.js index ec5f735ba2..57f28c1d68 100644 --- a/test/unit/integration/drag-handle/shared-behaviours/validate-controls.spec.js +++ b/test/unit/integration/drag-handle/shared-behaviours/validate-controls.spec.js @@ -6,7 +6,7 @@ import App from '../app'; import { forEachSensor, type Control } from '../controls'; forEachSensor((control: Control) => { - it('should control the drag through the sensor', () => { + it('should control a successful drag through the sensor', () => { const onDragStart = jest.fn(); const onDragEnd = jest.fn(); const { getByText } = render( @@ -14,7 +14,6 @@ forEachSensor((control: Control) => { ); const handle: HTMLElement = getByText('item: 0'); - // Drop Array.from({ length: 4 }).forEach(() => { control.preLift(handle); expect(isDragging(handle)).toBe(false); @@ -41,8 +40,16 @@ forEachSensor((control: Control) => { onDragEnd.mockClear(); }); + }); + + it('should control a cancel through the sensor', () => { + const onDragStart = jest.fn(); + const onDragEnd = jest.fn(); + const { getByText } = render( + , + ); + const handle: HTMLElement = getByText('item: 0'); - // Cancel Array.from({ length: 4 }).forEach(() => { control.preLift(handle); expect(isDragging(handle)).toBe(false); diff --git a/test/unit/integration/drag-handle/touch-sensor/cancel-while-pending.spec.js b/test/unit/integration/drag-handle/touch-sensor/cancel-while-pending.spec.js new file mode 100644 index 0000000000..46e7f7c045 --- /dev/null +++ b/test/unit/integration/drag-handle/touch-sensor/cancel-while-pending.spec.js @@ -0,0 +1 @@ +// @flow diff --git a/test/unit/integration/drag-handle/touch-sensor/click-blocking.spec.js b/test/unit/integration/drag-handle/touch-sensor/click-blocking.spec.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/unit/integration/drag-handle/touch-sensor/force-press.spec.js b/test/unit/integration/drag-handle/touch-sensor/force-press.spec.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/unit/integration/drag-handle/touch-sensor/starting-a-drag.spec.js b/test/unit/integration/drag-handle/touch-sensor/starting-a-drag.spec.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/unit/integration/drag-handle/touch-sensor/stopping-a-drag.spec.js b/test/unit/integration/drag-handle/touch-sensor/stopping-a-drag.spec.js new file mode 100644 index 0000000000..46e7f7c045 --- /dev/null +++ b/test/unit/integration/drag-handle/touch-sensor/stopping-a-drag.spec.js @@ -0,0 +1 @@ +// @flow From 82fca8e60c256948a44c7a11ef6d3c2a96d8e172 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Fri, 31 May 2019 14:53:09 +1000 Subject: [PATCH 101/308] more tests --- .../sensors/use-touch-sensor.js | 9 +- .../mouse-sensor/cancel-while-pending.spec.js | 78 ++++-------------- .../touch-sensor/cancel-while-pending.spec.js | 43 ++++++++++ .../touch-sensor/click-blocking.spec.js | 4 + .../touch-sensor/starting-a-drag.spec.js | 82 +++++++++++++++++++ 5 files changed, 151 insertions(+), 65 deletions(-) diff --git a/src/view/use-sensor-marshal/sensors/use-touch-sensor.js b/src/view/use-sensor-marshal/sensors/use-touch-sensor.js index 28bdab2796..45b5356490 100644 --- a/src/view/use-sensor-marshal/sensors/use-touch-sensor.js +++ b/src/view/use-sensor-marshal/sensors/use-touch-sensor.js @@ -107,6 +107,7 @@ function getTargetBindings({ completed, getPhase, }: GetBindingArgs): EventBinding[] { + console.log('binding target'); return [ { eventName: 'touchmove', @@ -145,6 +146,7 @@ function getTargetBindings({ const phase: Phase = getPhase(); // drag had not started yet - do not prevent the default action if (phase.type !== 'DRAGGING') { + console.log('TOUCH END'); cancel(); return; } @@ -264,6 +266,7 @@ export default function useMouseSensor( // unbind this event handler unbindEventsRef.current(); + console.log('STARTING PENDING DRAG'); // eslint-disable-next-line no-use-before-define startPendingDrag(actions, point, target); }, @@ -276,8 +279,8 @@ export default function useMouseSensor( const listenForCapture = useCallback( function listenForCapture() { const options: EventOptions = { - passive: false, capture: true, + passive: false, }; unbindEventsRef.current = bindEvents( @@ -319,6 +322,7 @@ export default function useMouseSensor( const bindCapturingEvents = useCallback( function bindCapturingEvents(target: HTMLElement) { + console.log('bind capturing events'); const options: EventOptions = { capture: true, passive: false }; const args: GetBindingArgs = { cancel, @@ -335,7 +339,7 @@ export default function useMouseSensor( const unbindTarget = bindEvents(target, getTargetBindings(args), options); const unbindWindow = bindEvents(window, getWindowBindings(args), options); - unbindEventsRef.current = function unbind() { + unbindEventsRef.current = function unbindAll() { unbindTarget(); unbindWindow(); }; @@ -351,6 +355,7 @@ export default function useMouseSensor( `Cannot start dragging from phase ${phase.type}`, ); + console.log('STARTING A DRAG'); const actions: DragActions = phase.actions.lift({ clientSelection: phase.point, mode: 'FLUID', diff --git a/test/unit/integration/drag-handle/mouse-sensor/cancel-while-pending.spec.js b/test/unit/integration/drag-handle/mouse-sensor/cancel-while-pending.spec.js index 6adf39682d..b6ee17d087 100644 --- a/test/unit/integration/drag-handle/mouse-sensor/cancel-while-pending.spec.js +++ b/test/unit/integration/drag-handle/mouse-sensor/cancel-while-pending.spec.js @@ -1,88 +1,40 @@ // @flow import React from 'react'; -import { createEvent, fireEvent, render } from 'react-testing-library'; -import * as keyCodes from '../../../../../src/view/key-codes'; +import { fireEvent, render } from 'react-testing-library'; import { sloppyClickThreshold } from '../../../../../src/view/use-sensor-marshal/sensors/use-mouse-sensor'; import App from '../app'; import { isDragging } from '../util'; import supportedEventName from '../../../../../src/view/use-sensor-marshal/sensors/util/supported-page-visibility-event-name'; +import { mouse } from '../controls'; -it(`should cancel a pending drag with keydown`, () => { - Object.keys(keyCodes).forEach((keyCode: string) => { +const events: string[] = ['keydown', 'resize', supportedEventName]; + +it(`should cancel a pending drag on events: [${events.join(', ')}]`, () => { + events.forEach((eventName: string) => { const { getByText, unmount } = render(); const handle: HTMLElement = getByText('item: 0'); - fireEvent.mouseDown(handle); + mouse.preLift(handle); - // abort - const event: KeyboardEvent = createEvent.keyDown(handle, { keyCode }); + const event: Event = new Event(eventName, { + bubbles: true, + cancelable: true, + }); fireEvent(handle, event); + // not an explicit cancel - so event not consumed + expect(event.defaultPrevented).toBe(false); + // would normally start - fireEvent.mouseMove(handle, { - clientX: 0, - clientY: sloppyClickThreshold, - }); + mouse.lift(handle); // drag not started expect(isDragging(handle)).toBe(false); - // default behaviour not prevented on keypress - expect(event.defaultPrevented).toBe(false); unmount(); }); }); -it('should cancel when resize is fired', () => { - const { getByText } = render(); - const handle: HTMLElement = getByText('item: 0'); - - fireEvent.mouseDown(handle); - - // abort - const event: Event = new Event('resize', { - bubbles: true, - cancelable: true, - }); - fireEvent(handle, event); - - // would normally start - fireEvent.mouseMove(handle, { - clientX: 0, - clientY: sloppyClickThreshold, - }); - - // drag not started - expect(isDragging(handle)).toBe(false); - // default behaviour not prevented on keypress - expect(event.defaultPrevented).toBe(false); -}); - -it('should abort when there is a visibility change', () => { - const { getByText } = render(); - const handle: HTMLElement = getByText('item: 0'); - - fireEvent.mouseDown(handle); - - // abort - const event: Event = new Event(supportedEventName, { - bubbles: true, - cancelable: true, - }); - fireEvent(handle, event); - - // would normally start - fireEvent.mouseMove(handle, { - clientX: 0, - clientY: sloppyClickThreshold, - }); - - // event not consumed as it is an indirect cancel - expect(event.defaultPrevented).toBe(false); - // drag not started - expect(isDragging(handle)).toBe(false); -}); - it('should abort when there is a window scroll', () => { const { getByText } = render(); const handle: HTMLElement = getByText('item: 0'); diff --git a/test/unit/integration/drag-handle/touch-sensor/cancel-while-pending.spec.js b/test/unit/integration/drag-handle/touch-sensor/cancel-while-pending.spec.js index 46e7f7c045..f49b6fcd9d 100644 --- a/test/unit/integration/drag-handle/touch-sensor/cancel-while-pending.spec.js +++ b/test/unit/integration/drag-handle/touch-sensor/cancel-while-pending.spec.js @@ -1 +1,44 @@ // @flow +import React from 'react'; +import { fireEvent, render, createEvent } from 'react-testing-library'; +import App from '../app'; +import { isDragging } from '../util'; +import supportedEventName from '../../../../../src/view/use-sensor-marshal/sensors/util/supported-page-visibility-event-name'; +import { touch } from '../controls'; + +jest.useFakeTimers(); + +const events: string[] = [ + 'orientationchange', + 'keydown', + 'resize', + supportedEventName, + // moving before a long press + 'touchmove', +]; + +it(`should cancel a pending drag on events: [${events.join(', ')}]`, () => { + events.forEach((eventName: string) => { + const { getByText, unmount } = render(); + const handle: HTMLElement = getByText('item: 0'); + + touch.preLift(handle); + + const event: Event = new Event(eventName, { + bubbles: true, + cancelable: true, + }); + fireEvent(handle, event); + + // not an explicit cancel - so event not consumed + expect(event.defaultPrevented).toBe(false); + + // would normally start + touch.lift(handle); + + // drag not started + expect(isDragging(handle)).toBe(false); + + unmount(); + }); +}); diff --git a/test/unit/integration/drag-handle/touch-sensor/click-blocking.spec.js b/test/unit/integration/drag-handle/touch-sensor/click-blocking.spec.js index e69de29bb2..ca4af182be 100644 --- a/test/unit/integration/drag-handle/touch-sensor/click-blocking.spec.js +++ b/test/unit/integration/drag-handle/touch-sensor/click-blocking.spec.js @@ -0,0 +1,4 @@ +// @flow +it('should block a click after a drag', () => {}); + +it('should not block a click after an aborted pending drag', () => {}); diff --git a/test/unit/integration/drag-handle/touch-sensor/starting-a-drag.spec.js b/test/unit/integration/drag-handle/touch-sensor/starting-a-drag.spec.js index e69de29bb2..24bb03e2b0 100644 --- a/test/unit/integration/drag-handle/touch-sensor/starting-a-drag.spec.js +++ b/test/unit/integration/drag-handle/touch-sensor/starting-a-drag.spec.js @@ -0,0 +1,82 @@ +// @flow +import React from 'react'; +import { createEvent, fireEvent, render } from 'react-testing-library'; +import App from '../app'; +import { isDragging } from '../util'; +import { timeForLongPress } from '../../../../../src/view/use-sensor-marshal/sensors/use-touch-sensor'; + +jest.useFakeTimers(); + +function getTouchStart(handle: HTMLElement): Event { + return createEvent.touchStart(handle, { + touches: [{ clientX: 0, clientY: 0 }], + }); +} + +it('should start dragging after a long press', () => { + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + const touchStart: Event = getTouchStart(handle); + + fireEvent(handle, touchStart); + // not calling event.preventDefault() to allow + // as many browser interactions as possible + expect(touchStart.defaultPrevented).toBe(false); + + // not dragging yet + expect(isDragging(handle)).toBe(false); + + // allow long press to run + jest.runOnlyPendingTimers(); + + // now dragging + expect(isDragging(handle)).toBe(true); +}); + +it('should not start dragging if finished before a long press', () => { + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + const touchStart: Event = getTouchStart(handle); + + fireEvent(handle, touchStart); + // not calling event.preventDefault() to allow + // as many browser interactions as possible + expect(touchStart.defaultPrevented).toBe(false); + + // not dragging yet + expect(isDragging(handle)).toBe(false); + + // allow long press to run + jest.advanceTimersByTime(timeForLongPress - 1); + + // not dragging yet + expect(isDragging(handle)).toBe(false); + + const touchEnd: Event = createEvent.touchEnd(handle); + fireEvent(handle, touchEnd); + + // not a direct cancel + expect(touchEnd.defaultPrevented).toBe(false); + + // flushing any timers + jest.runOnlyPendingTimers(); + + expect(isDragging(handle)).toBe(false); +}); + +it('should allow a false start', () => { + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + // a first attempt that is not successful + fireEvent(handle, getTouchStart(handle)); + jest.advanceTimersByTime(timeForLongPress - 1); + fireEvent.touchEnd(handle); + expect(isDragging(handle)).toBe(false); + + // Let's try again - this time we will wait + + fireEvent(handle, getTouchStart(handle)); + jest.advanceTimersByTime(timeForLongPress); + expect(isDragging(handle)).toBe(true); +}); From 3ecde86152ddf4db58398a16c44872a92d424770 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Fri, 31 May 2019 15:28:35 +1000 Subject: [PATCH 102/308] more touch tests --- .../sensors/use-touch-sensor.js | 5 - .../touch-sensor/cancel-while-pending.spec.js | 2 +- .../touch-sensor/click-blocking.spec.js | 34 ++++++- .../touch-sensor/force-press.spec.js | 98 +++++++++++++++++++ 4 files changed, 131 insertions(+), 8 deletions(-) diff --git a/src/view/use-sensor-marshal/sensors/use-touch-sensor.js b/src/view/use-sensor-marshal/sensors/use-touch-sensor.js index 45b5356490..d717af3987 100644 --- a/src/view/use-sensor-marshal/sensors/use-touch-sensor.js +++ b/src/view/use-sensor-marshal/sensors/use-touch-sensor.js @@ -107,7 +107,6 @@ function getTargetBindings({ completed, getPhase, }: GetBindingArgs): EventBinding[] { - console.log('binding target'); return [ { eventName: 'touchmove', @@ -146,7 +145,6 @@ function getTargetBindings({ const phase: Phase = getPhase(); // drag had not started yet - do not prevent the default action if (phase.type !== 'DRAGGING') { - console.log('TOUCH END'); cancel(); return; } @@ -266,7 +264,6 @@ export default function useMouseSensor( // unbind this event handler unbindEventsRef.current(); - console.log('STARTING PENDING DRAG'); // eslint-disable-next-line no-use-before-define startPendingDrag(actions, point, target); }, @@ -322,7 +319,6 @@ export default function useMouseSensor( const bindCapturingEvents = useCallback( function bindCapturingEvents(target: HTMLElement) { - console.log('bind capturing events'); const options: EventOptions = { capture: true, passive: false }; const args: GetBindingArgs = { cancel, @@ -355,7 +351,6 @@ export default function useMouseSensor( `Cannot start dragging from phase ${phase.type}`, ); - console.log('STARTING A DRAG'); const actions: DragActions = phase.actions.lift({ clientSelection: phase.point, mode: 'FLUID', diff --git a/test/unit/integration/drag-handle/touch-sensor/cancel-while-pending.spec.js b/test/unit/integration/drag-handle/touch-sensor/cancel-while-pending.spec.js index f49b6fcd9d..6b95df8e26 100644 --- a/test/unit/integration/drag-handle/touch-sensor/cancel-while-pending.spec.js +++ b/test/unit/integration/drag-handle/touch-sensor/cancel-while-pending.spec.js @@ -1,6 +1,6 @@ // @flow import React from 'react'; -import { fireEvent, render, createEvent } from 'react-testing-library'; +import { fireEvent, render } from 'react-testing-library'; import App from '../app'; import { isDragging } from '../util'; import supportedEventName from '../../../../../src/view/use-sensor-marshal/sensors/util/supported-page-visibility-event-name'; diff --git a/test/unit/integration/drag-handle/touch-sensor/click-blocking.spec.js b/test/unit/integration/drag-handle/touch-sensor/click-blocking.spec.js index ca4af182be..0e594c1b90 100644 --- a/test/unit/integration/drag-handle/touch-sensor/click-blocking.spec.js +++ b/test/unit/integration/drag-handle/touch-sensor/click-blocking.spec.js @@ -1,4 +1,34 @@ // @flow -it('should block a click after a drag', () => {}); +import React from 'react'; +import { fireEvent, render, createEvent } from 'react-testing-library'; +import App from '../app'; +import { touch, simpleLift } from '../controls'; -it('should not block a click after an aborted pending drag', () => {}); +it('should block a click after a drag', () => { + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + simpleLift(touch, handle); + touch.drop(handle); + + const click: Event = createEvent.click(handle); + fireEvent(handle, click); + + expect(click.defaultPrevented).toBe(true); +}); + +it('should not block a click after an aborted pending drag', () => { + const onDragStart = jest.fn(); + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + // aborted before getting to a drag + touch.preLift(handle); + touch.cancel(handle); + + const click: Event = createEvent.click(handle); + fireEvent(handle, click); + + expect(click.defaultPrevented).toBe(false); + expect(onDragStart).not.toHaveBeenCalled(); +}); diff --git a/test/unit/integration/drag-handle/touch-sensor/force-press.spec.js b/test/unit/integration/drag-handle/touch-sensor/force-press.spec.js index e69de29bb2..185f333f58 100644 --- a/test/unit/integration/drag-handle/touch-sensor/force-press.spec.js +++ b/test/unit/integration/drag-handle/touch-sensor/force-press.spec.js @@ -0,0 +1,98 @@ +// @flow +import React from 'react'; +import { fireEvent, render, createEvent } from 'react-testing-library'; +import App, { type Item } from '../app'; +import { touch, simpleLift } from '../controls'; +import { forcePressThreshold } from '../../../../../src/view/use-sensor-marshal/sensors/use-touch-sensor'; +import { isDragging } from '../util'; + +jest.useFakeTimers(); + +function getForceChange(force: number): Event { + const event: Event = new Event('touchforcechange', { + bubbles: true, + cancelable: true, + }); + // $FlowFixMe - being amazing + event.touches = [ + { + clientX: 0, + clientY: 0, + force, + }, + ]; + return event; +} + +describe('force press not respected (default)', () => { + it('should abort the force press when a force press is not respected', () => { + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + touch.preLift(handle); + + const first: Event = getForceChange(forcePressThreshold); + fireEvent(handle, first); + expect(first.defaultPrevented).toBe(true); + + touch.lift(handle); + + const second: Event = getForceChange(forcePressThreshold); + fireEvent(handle, second); + expect(second.defaultPrevented).toBe(true); + + // force presses did not abort the pending or actual drag + expect(isDragging(handle)).toBe(true); + }); +}); + +describe('force press respected', () => { + const items: Item[] = [{ id: '0', shouldRespectForcePress: true }]; + + it('should cancel a pending drag if a force press is registered', () => { + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + touch.preLift(handle); + + // indirect cancel so event is not consumed + const press: Event = getForceChange(forcePressThreshold); + fireEvent(handle, press); + expect(press.defaultPrevented).toBe(false); + + touch.lift(handle); + + expect(isDragging(handle)).toBe(false); + }); + + it('should cancel a drag if a force press is registered', () => { + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + simpleLift(touch, handle); + + // indirect cancel so event is not consumed + const press: Event = getForceChange(forcePressThreshold); + fireEvent(handle, press); + expect(press.defaultPrevented).toBe(false); + + // drag is no more + expect(isDragging(handle)).toBe(false); + }); + + it('should abort a force press if dragging and some movement has occurred', () => { + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + simpleLift(touch, handle); + + fireEvent.touchMove(handle, { touches: [{ clientX: 0, clientY: 0 }] }); + + // consuming event and not cancelling after movement + const press: Event = getForceChange(forcePressThreshold); + fireEvent(handle, press); + expect(press.defaultPrevented).toBe(true); + + expect(isDragging(handle)).toBe(true); + }); +}); From cafef4a6b54486850f3c00e7f17b19a5a01536a1 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Fri, 31 May 2019 15:35:32 +1000 Subject: [PATCH 103/308] context menu touch tests --- .../touch-sensor/context-menu-opt-out.spec.js | 35 ++++++++ .../touch-sensor/stopping-a-drag.spec.js | 82 +++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 test/unit/integration/drag-handle/touch-sensor/context-menu-opt-out.spec.js diff --git a/test/unit/integration/drag-handle/touch-sensor/context-menu-opt-out.spec.js b/test/unit/integration/drag-handle/touch-sensor/context-menu-opt-out.spec.js new file mode 100644 index 0000000000..f24870ae5a --- /dev/null +++ b/test/unit/integration/drag-handle/touch-sensor/context-menu-opt-out.spec.js @@ -0,0 +1,35 @@ +// @flow +import React from 'react'; +import { fireEvent, render } from 'react-testing-library'; +import App from '../app'; +import { touch } from '../controls'; +import { isDragging } from '../util'; + +jest.useFakeTimers(); + +it('should opt of a context menu', () => { + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + touch.preLift(handle); + + // prevented during a pending drag + const first: Event = new Event('contextmenu', { + bubbles: true, + cancelable: true, + }); + fireEvent(handle, first); + expect(first.defaultPrevented).toBe(true); + + touch.lift(handle); + + // prevented during a drag + const second: Event = new Event('contextmenu', { + bubbles: true, + cancelable: true, + }); + fireEvent(handle, second); + expect(second.defaultPrevented).toBe(true); + + expect(isDragging(handle)).toBe(true); +}); diff --git a/test/unit/integration/drag-handle/touch-sensor/stopping-a-drag.spec.js b/test/unit/integration/drag-handle/touch-sensor/stopping-a-drag.spec.js index 46e7f7c045..c4ac6a2be1 100644 --- a/test/unit/integration/drag-handle/touch-sensor/stopping-a-drag.spec.js +++ b/test/unit/integration/drag-handle/touch-sensor/stopping-a-drag.spec.js @@ -1 +1,83 @@ // @flow +import React from 'react'; +import { render, createEvent, fireEvent } from 'react-testing-library'; +import App from '../app'; +import { getDropReason } from '../util'; +import * as keyCodes from '../../../../../src/view/key-codes'; +import { simpleLift, touch } from '../controls'; +import supportedEventName from '../../../../../src/view/use-sensor-marshal/sensors/util/supported-page-visibility-event-name'; + +jest.useFakeTimers(); + +it('should prevent default on the event that causes a drop', () => { + const onDragEnd = jest.fn(); + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + simpleLift(touch, handle); + + const event: Event = createEvent.touchEnd(handle); + fireEvent(handle, event); + + expect(event.defaultPrevented).toBe(true); + expect(getDropReason(onDragEnd)).toBe('DROP'); +}); + +it('should prevent default on an escape press', () => { + const onDragEnd = jest.fn(); + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + simpleLift(touch, handle); + + const event: Event = createEvent.keyDown(handle, { + keyCode: keyCodes.escape, + }); + fireEvent(handle, event); + + expect(event.defaultPrevented).toBe(true); + expect(getDropReason(onDragEnd)).toBe('CANCEL'); +}); + +it('should prevent default on a touchcancel', () => { + const onDragEnd = jest.fn(); + const { getByText } = render(); + const handle: HTMLElement = getByText('item: 0'); + + simpleLift(touch, handle); + + const event: Event = new Event('touchcancel', { + bubbles: true, + cancelable: true, + }); + fireEvent(handle, event); + + expect(event.defaultPrevented).toBe(true); + expect(getDropReason(onDragEnd)).toBe('CANCEL'); +}); + +it('should not prevent the default behaviour for an indirect cancel', () => { + ['orientationchange', 'keydown', 'resize', supportedEventName].forEach( + (eventName: string) => { + const onDragEnd = jest.fn(); + const { getByText, unmount } = render(); + const handle: HTMLElement = getByText('item: 0'); + + simpleLift(touch, handle); + + const event: Event = new Event(eventName, { + bubbles: true, + cancelable: true, + target: handle, + }); + + fireEvent(handle, event); + + // not an explicit cancel + expect(event.defaultPrevented).toBe(false); + expect(getDropReason(onDragEnd)).toBe('CANCEL'); + + unmount(); + }, + ); +}); From b063920c44e84bdf6b11e2daf65c693477017b5c Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Fri, 31 May 2019 15:40:18 +1000 Subject: [PATCH 104/308] a little extra unmount safety --- ...ounted-while-pending-timer-running.spec.js | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 test/unit/integration/drag-handle/touch-sensor/unmounted-while-pending-timer-running.spec.js diff --git a/test/unit/integration/drag-handle/touch-sensor/unmounted-while-pending-timer-running.spec.js b/test/unit/integration/drag-handle/touch-sensor/unmounted-while-pending-timer-running.spec.js new file mode 100644 index 0000000000..9504ec9894 --- /dev/null +++ b/test/unit/integration/drag-handle/touch-sensor/unmounted-while-pending-timer-running.spec.js @@ -0,0 +1,25 @@ +// @flow +import React from 'react'; +import { render } from 'react-testing-library'; +import App from '../app'; +import { isDragging } from '../util'; +import { touch } from '../controls'; + +jest.useFakeTimers(); + +it('should cancel a pending drag when unmounted', () => { + jest.spyOn(console, 'warn'); + const { getByText, unmount } = render(); + const handle: HTMLElement = getByText('item: 0'); + + touch.preLift(handle); + + unmount(); + + // finish lift timer + jest.runOnlyPendingTimers(); + + expect(console.warn).not.toHaveBeenCalled(); + expect(isDragging(handle)).toBe(false); + console.warn.mockRestore(); +}); From e553b506d0c25fbfc1c41623db7b85cb0f2faec0 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Fri, 31 May 2019 15:59:39 +1000 Subject: [PATCH 105/308] adding missing fake timer setup --- .../integration/drag-handle/touch-sensor/click-blocking.spec.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/unit/integration/drag-handle/touch-sensor/click-blocking.spec.js b/test/unit/integration/drag-handle/touch-sensor/click-blocking.spec.js index 0e594c1b90..9be4a5868c 100644 --- a/test/unit/integration/drag-handle/touch-sensor/click-blocking.spec.js +++ b/test/unit/integration/drag-handle/touch-sensor/click-blocking.spec.js @@ -4,6 +4,8 @@ import { fireEvent, render, createEvent } from 'react-testing-library'; import App from '../app'; import { touch, simpleLift } from '../controls'; +jest.useFakeTimers(); + it('should block a click after a drag', () => { const { getByText } = render(); const handle: HTMLElement = getByText('item: 0'); From ca9a2278e3367b22918af00c0eac0d3bd7cfd0f7 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Fri, 31 May 2019 17:20:33 +1000 Subject: [PATCH 106/308] removing old drag handle --- src/index.js | 4 +- src/view/draggable/check-own-props.js | 16 - src/view/draggable/connected-draggable.js | 20 +- src/view/draggable/draggable-types.js | 40 +- src/view/draggable/draggable.jsx | 131 +- src/view/use-drag-handle/drag-handle-types.js | 64 - src/view/use-drag-handle/index.js | 2 - .../sensor/use-keyboard-sensor.js | 263 --- .../sensor/use-mouse-sensor.js | 372 ----- .../sensor/use-touch-sensor.js | 435 ----- src/view/use-drag-handle/use-drag-handle.js | 243 --- .../use-drag-handle/use-focus-retainer.js | 108 -- src/view/use-drag-handle/use-validation.js | 25 - src/view/use-drag-handle/util/bind-events.js | 34 - .../util/create-event-marshal.js | 32 - .../util/create-post-drag-event-preventer.js | 68 - .../use-drag-handle/util/create-scheduler.js | 40 - src/view/use-drag-handle/util/event-types.js | 12 - .../use-drag-handle/util/focus-retainer.js | 95 -- .../util/get-drag-handle-ref.js | 49 - .../is-sloppy-click-threshold-exceeded.js | 9 - .../util/prevent-standard-key-events.js | 19 - .../util/should-allow-dragging-from-target.js | 72 - .../supported-page-visibility-event-name.js | 29 - stories/src/multi-drag/task.jsx | 4 - .../responders-integration.spec.js | 2 +- test/unit/view/drag-handle/attributes.spec.js | 22 - ...start-when-something-else-dragging.spec.js | 30 - .../view/drag-handle/contenteditable.spec.js | 317 ---- .../disabled-while-capturing.spec.js | 128 -- test/unit/view/drag-handle/disabled.spec.js | 11 - .../view/drag-handle/focus-management.spec.js | 439 ------ .../drag-handle/interactive-elements.spec.js | 245 --- .../view/drag-handle/keyboard-sensor.spec.js | 698 -------- .../view/drag-handle/mouse-sensor.spec.js | 1404 ----------------- .../drag-handle/nested-drag-handles.spec.js | 106 -- .../page-visibility-changes.spec.js | 25 - .../view/drag-handle/throw-if-svg.spec.js | 45 - .../view/drag-handle/touch-sensor.spec.js | 932 ----------- .../unit/view/drag-handle/util/app-context.js | 12 - test/unit/view/drag-handle/util/callbacks.js | 67 - test/unit/view/drag-handle/util/controls.js | 130 -- test/unit/view/drag-handle/util/events.js | 68 - test/unit/view/drag-handle/util/wrappers.js | 90 -- .../view/drag-handle/window-bindings.spec.js | 144 -- 45 files changed, 32 insertions(+), 7069 deletions(-) delete mode 100644 src/view/draggable/check-own-props.js delete mode 100644 src/view/use-drag-handle/drag-handle-types.js delete mode 100644 src/view/use-drag-handle/index.js delete mode 100644 src/view/use-drag-handle/sensor/use-keyboard-sensor.js delete mode 100644 src/view/use-drag-handle/sensor/use-mouse-sensor.js delete mode 100644 src/view/use-drag-handle/sensor/use-touch-sensor.js delete mode 100644 src/view/use-drag-handle/use-drag-handle.js delete mode 100644 src/view/use-drag-handle/use-focus-retainer.js delete mode 100644 src/view/use-drag-handle/use-validation.js delete mode 100644 src/view/use-drag-handle/util/bind-events.js delete mode 100644 src/view/use-drag-handle/util/create-event-marshal.js delete mode 100644 src/view/use-drag-handle/util/create-post-drag-event-preventer.js delete mode 100644 src/view/use-drag-handle/util/create-scheduler.js delete mode 100644 src/view/use-drag-handle/util/event-types.js delete mode 100644 src/view/use-drag-handle/util/focus-retainer.js delete mode 100644 src/view/use-drag-handle/util/get-drag-handle-ref.js delete mode 100644 src/view/use-drag-handle/util/is-sloppy-click-threshold-exceeded.js delete mode 100644 src/view/use-drag-handle/util/prevent-standard-key-events.js delete mode 100644 src/view/use-drag-handle/util/should-allow-dragging-from-target.js delete mode 100644 src/view/use-drag-handle/util/supported-page-visibility-event-name.js delete mode 100644 test/unit/view/drag-handle/attributes.spec.js delete mode 100644 test/unit/view/drag-handle/block-drag-start-when-something-else-dragging.spec.js delete mode 100644 test/unit/view/drag-handle/contenteditable.spec.js delete mode 100644 test/unit/view/drag-handle/disabled-while-capturing.spec.js delete mode 100644 test/unit/view/drag-handle/disabled.spec.js delete mode 100644 test/unit/view/drag-handle/focus-management.spec.js delete mode 100644 test/unit/view/drag-handle/interactive-elements.spec.js delete mode 100644 test/unit/view/drag-handle/keyboard-sensor.spec.js delete mode 100644 test/unit/view/drag-handle/mouse-sensor.spec.js delete mode 100644 test/unit/view/drag-handle/nested-drag-handles.spec.js delete mode 100644 test/unit/view/drag-handle/page-visibility-changes.spec.js delete mode 100644 test/unit/view/drag-handle/throw-if-svg.spec.js delete mode 100644 test/unit/view/drag-handle/touch-sensor.spec.js delete mode 100644 test/unit/view/drag-handle/util/app-context.js delete mode 100644 test/unit/view/drag-handle/util/callbacks.js delete mode 100644 test/unit/view/drag-handle/util/controls.js delete mode 100644 test/unit/view/drag-handle/util/events.js delete mode 100644 test/unit/view/drag-handle/util/wrappers.js delete mode 100644 test/unit/view/drag-handle/window-bindings.spec.js diff --git a/src/index.js b/src/index.js index b05a75f27d..b1bf553394 100644 --- a/src/index.js +++ b/src/index.js @@ -43,12 +43,10 @@ export type { export type { Provided as DraggableProvided, StateSnapshot as DraggableStateSnapshot, + DragHandleProps, DropAnimation, DraggableProps, DraggableStyle, DraggingStyle, NotDraggingStyle, } from './view/draggable/draggable-types'; - -// DragHandle types -export type { DragHandleProps } from './view/use-drag-handle/drag-handle-types'; diff --git a/src/view/draggable/check-own-props.js b/src/view/draggable/check-own-props.js deleted file mode 100644 index d7aefa0c7d..0000000000 --- a/src/view/draggable/check-own-props.js +++ /dev/null @@ -1,16 +0,0 @@ -// @flow -import invariant from 'tiny-invariant'; -import type { Props } from './draggable-types'; - -export default (props: Props) => { - // Number.isInteger will be provided by @babel/runtime-corejs2 - invariant( - Number.isInteger(props.index), - 'Draggable requires an integer index prop', - ); - invariant(props.draggableId, 'Draggable requires a draggableId'); - invariant( - typeof props.isDragDisabled === 'boolean', - 'isDragDisabled must be a boolean', - ); -}; diff --git a/src/view/draggable/connected-draggable.js b/src/view/draggable/connected-draggable.js index 2cac9b7a9c..5cc6c15b94 100644 --- a/src/view/draggable/connected-draggable.js +++ b/src/view/draggable/connected-draggable.js @@ -8,17 +8,7 @@ import Draggable from './draggable'; import { origin } from '../../state/position'; import isStrictEqual from '../is-strict-equal'; import { curves, combine } from '../../animation'; -import { - lift as liftAction, - move as moveAction, - moveUp as moveUpAction, - moveDown as moveDownAction, - moveLeft as moveLeftAction, - moveRight as moveRightAction, - drop as dropAction, - dropAnimationFinished as dropAnimationFinishedAction, - moveByWindowScroll as moveByWindowScrollAction, -} from '../../state/action-creators'; +import { dropAnimationFinished as dropAnimationFinishedAction } from '../../state/action-creators'; import type { State, DraggableId, @@ -297,14 +287,6 @@ export const makeMapStateToProps = (): Selector => { }; const mapDispatchToProps: DispatchProps = { - lift: liftAction, - move: moveAction, - moveUp: moveUpAction, - moveDown: moveDownAction, - moveLeft: moveLeftAction, - moveRight: moveRightAction, - moveByWindowScroll: moveByWindowScrollAction, - drop: dropAction, dropAnimationFinished: dropAnimationFinishedAction, }; diff --git a/src/view/draggable/draggable-types.js b/src/view/draggable/draggable-types.js index b1de6f6ef0..e0fdb79d73 100644 --- a/src/view/draggable/draggable-types.js +++ b/src/view/draggable/draggable-types.js @@ -7,19 +7,9 @@ import type { DraggableDimension, State, MovementMode, + ContextId, } from '../../types'; -import { - lift, - move, - moveByWindowScroll, - moveUp, - moveDown, - moveRight, - moveLeft, - drop, - dropAnimationFinished, -} from '../../state/action-creators'; -import type { DragHandleProps } from '../use-drag-handle/drag-handle-types'; +import { dropAnimationFinished } from '../../state/action-creators'; export type DraggingStyle = {| position: 'fixed', @@ -69,6 +59,24 @@ export type DraggableProps = {| onTransitionEnd: ?(event: TransitionEvent) => void, |}; +export type DragHandleProps = {| + // what draggable the handle belongs to + 'data-rbd-drag-handle-draggable-id': DraggableId, + + // What DragDropContext the drag handle is in + 'data-rbd-drag-handle-context-id': ContextId, + + // Aria role (nicer screen reader text) + 'aria-roledescription': string, + + // Allow tabbing to this element + tabIndex: number, + + // Stop html5 drag and drop + draggable: boolean, + onDragStart: (event: DragEvent) => void, +|}; + export type Provided = {| draggableProps: DraggableProps, // will be null if the draggable is disabled @@ -97,14 +105,6 @@ export type StateSnapshot = {| |}; export type DispatchProps = {| - lift: typeof lift, - move: typeof move, - moveByWindowScroll: typeof moveByWindowScroll, - moveUp: typeof moveUp, - moveDown: typeof moveDown, - moveRight: typeof moveRight, - moveLeft: typeof moveLeft, - drop: typeof drop, dropAnimationFinished: typeof dropAnimationFinished, |}; diff --git a/src/view/draggable/draggable.jsx b/src/view/draggable/draggable.jsx index a76c7aadd7..154dbc7307 100644 --- a/src/view/draggable/draggable.jsx +++ b/src/view/draggable/draggable.jsx @@ -1,23 +1,16 @@ // @flow -import React, { useRef } from 'react'; -import { type Position } from 'css-box-model'; -import invariant from 'tiny-invariant'; +import { useRef } from 'react'; import { useMemo, useCallback } from 'use-memo-one'; import getStyle from './get-style'; -import type { - Args as DragHandleArgs, - Callbacks as DragHandleCallbacks, - DragHandleProps, -} from '../use-drag-handle/drag-handle-types'; -import type { MovementMode } from '../../types'; import useDraggableDimensionPublisher, { type Args as DimensionPublisherArgs, } from '../use-draggable-dimension-publisher/use-draggable-dimension-publisher'; -import * as timings from '../../debug/timings'; -import type { Props, Provided, DraggableStyle } from './draggable-types'; -import getWindowScroll from '../window/get-window-scroll'; -// import throwIfRefIsInvalid from '../throw-if-invalid-inner-ref'; -// import checkOwnProps from './check-own-props'; +import type { + Props, + Provided, + DraggableStyle, + DragHandleProps, +} from './draggable-types'; import AppContext, { type AppContextValue } from '../context/app-context'; import DroppableContext, { type DroppableContextValue, @@ -63,14 +56,6 @@ export default function Draggable(props: Props) { mapped, // dispatchProps - moveUp: moveUpAction, - move: moveAction, - drop: dropAction, - moveDown: moveDownAction, - moveRight: moveRightAction, - moveLeft: moveLeftAction, - moveByWindowScroll: moveByWindowScrollAction, - lift: liftAction, dropAnimationFinished: dropAnimationFinishedAction, } = props; const isEnabled: boolean = !isDragDisabled; @@ -91,88 +76,6 @@ export default function Draggable(props: Props) { useDraggableDimensionPublisher(forPublisher); } - // The Drag handle - - const onLift = useCallback( - (options: { clientSelection: Position, movementMode: MovementMode }) => { - timings.start('LIFT'); - const el: ?HTMLElement = ref.current; - invariant(el); - invariant(isEnabled, 'Cannot lift a Draggable when it is disabled'); - const { clientSelection, movementMode } = options; - - liftAction({ - id: draggableId, - clientSelection, - movementMode, - }); - timings.finish('LIFT'); - }, - [draggableId, isEnabled, liftAction], - ); - - const getShouldRespectForcePress = useCallback( - () => shouldRespectForcePress, - [shouldRespectForcePress], - ); - - const callbacks: DragHandleCallbacks = useMemo( - () => ({ - onLift, - onMove: (clientSelection: Position) => - moveAction({ client: clientSelection }), - onDrop: () => dropAction({ reason: 'DROP' }), - onCancel: () => dropAction({ reason: 'CANCEL' }), - onMoveUp: moveUpAction, - onMoveDown: moveDownAction, - onMoveRight: moveRightAction, - onMoveLeft: moveLeftAction, - onWindowScroll: () => - moveByWindowScrollAction({ - newScroll: getWindowScroll(), - }), - }), - [ - dropAction, - moveAction, - moveByWindowScrollAction, - moveDownAction, - moveLeftAction, - moveRightAction, - moveUpAction, - onLift, - ], - ); - - const isDragging: boolean = mapped.type === 'DRAGGING'; - const isDropAnimating: boolean = - mapped.type === 'DRAGGING' && Boolean(mapped.dropping); - - const dragHandleArgs: DragHandleArgs = useMemo( - () => ({ - draggableId, - isDragging, - isDropAnimating, - isEnabled, - callbacks, - getDraggableRef: getRef, - canDragInteractiveElements, - getShouldRespectForcePress, - }), - [ - callbacks, - canDragInteractiveElements, - draggableId, - getRef, - getShouldRespectForcePress, - isDragging, - isDropAnimating, - isEnabled, - ], - ); - - // const dragHandleProps: ?DragHandleProps = useDragHandle(dragHandleArgs); - const dragHandleProps: ?DragHandleProps = useMemo( () => isEnabled @@ -245,26 +148,10 @@ export default function Draggable(props: Props) { shouldRespectForcePress, ]); + const isDragging: boolean = mapped.type === 'DRAGGING'; + if (isDragging && usingCloneWhenDragging && !isClone) { return null; - // return ( - //
- // - // 🤘 - // - //
- // ); } return children(provided, mapped.snapshot); diff --git a/src/view/use-drag-handle/drag-handle-types.js b/src/view/use-drag-handle/drag-handle-types.js deleted file mode 100644 index 6c62482d40..0000000000 --- a/src/view/use-drag-handle/drag-handle-types.js +++ /dev/null @@ -1,64 +0,0 @@ -// @flow -import { type Position } from 'css-box-model'; -import type { MovementMode, DraggableId, ContextId } from '../../types'; - -export type Callbacks = {| - onLift: ({ - clientSelection: Position, - movementMode: MovementMode, - }) => mixed, - onMove: (point: Position) => mixed, - onWindowScroll: () => mixed, - onMoveUp: () => mixed, - onMoveDown: () => mixed, - onMoveRight: () => mixed, - onMoveLeft: () => mixed, - onDrop: () => mixed, - onCancel: () => mixed, -|}; - -export type DragHandleProps = {| - // If a consumer is using a portal then the item will lose focus - // when moving to the portal. This breaks keyboard dragging. - // To get around this we manually apply focus if needed when mounting - // onFocus: () => void, - // onBlur: () => void, - - // // Used to initiate dragging - // onMouseDown: (event: MouseEvent) => void, - // onKeyDown: (event: KeyboardEvent) => void, - // onTouchStart: (event: TouchEvent) => void, - - // what draggable the handle belongs to - 'data-rbd-drag-handle-draggable-id': DraggableId, - - // What DragDropContext the drag handle is in - 'data-rbd-drag-handle-context-id': ContextId, - - // Aria role (nicer screen reader text) - 'aria-roledescription': string, - - // Allow tabbing to this element - tabIndex: number, - - // Stop html5 drag and drop - draggable: boolean, - onDragStart: (event: DragEvent) => void, -|}; - -export type Args = {| - draggableId: DraggableId, - // callbacks provided by the draggable - callbacks: Callbacks, - isEnabled: boolean, - // whether the application thinks a drag is occurring - isDragging: boolean, - // whether the application thinks a drop is occurring - isDropAnimating: boolean, - // get the ref of the draggable - getDraggableRef: () => ?HTMLElement, - // whether interactive elements should be permitted to start a drag - canDragInteractiveElements: boolean, - // whether force press interactions should be respected - getShouldRespectForcePress: () => boolean, -|}; diff --git a/src/view/use-drag-handle/index.js b/src/view/use-drag-handle/index.js deleted file mode 100644 index 56db94fce8..0000000000 --- a/src/view/use-drag-handle/index.js +++ /dev/null @@ -1,2 +0,0 @@ -// @flow -export { default } from './use-drag-handle'; diff --git a/src/view/use-drag-handle/sensor/use-keyboard-sensor.js b/src/view/use-drag-handle/sensor/use-keyboard-sensor.js deleted file mode 100644 index ab1c14893d..0000000000 --- a/src/view/use-drag-handle/sensor/use-keyboard-sensor.js +++ /dev/null @@ -1,263 +0,0 @@ -// @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(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; -} diff --git a/src/view/use-drag-handle/sensor/use-mouse-sensor.js b/src/view/use-drag-handle/sensor/use-mouse-sensor.js deleted file mode 100644 index 419286730f..0000000000 --- a/src/view/use-drag-handle/sensor/use-mouse-sensor.js +++ /dev/null @@ -1,372 +0,0 @@ -// @flow -import type { Position } from 'css-box-model'; -import { useRef } from 'react'; -import invariant from 'tiny-invariant'; -import { useMemo, useCallback } from 'use-memo-one'; -import type { EventBinding } from '../util/event-types'; -import createEventMarshal, { - type EventMarshal, -} from '../util/create-event-marshal'; -import type { Callbacks } from '../drag-handle-types'; -import { bindEvents, unbindEvents } from '../util/bind-events'; -import createScheduler from '../util/create-scheduler'; -import { warning } from '../../../dev-warning'; -import * as keyCodes from '../../key-codes'; -import supportedPageVisibilityEventName from '../util/supported-page-visibility-event-name'; -import createPostDragEventPreventer, { - type EventPreventer, -} from '../util/create-post-drag-event-preventer'; -import isSloppyClickThresholdExceeded from '../util/is-sloppy-click-threshold-exceeded'; -import preventStandardKeyEvents from '../util/prevent-standard-key-events'; - -export type Args = {| - callbacks: Callbacks, - onCaptureStart: (abort: Function) => void, - onCaptureEnd: () => void, - getDraggableRef: () => ?HTMLElement, - getWindow: () => HTMLElement, - canStartCapturing: (event: Event) => boolean, - getShouldRespectForcePress: () => boolean, -|}; - -export type OnMouseDown = (event: MouseEvent) => void; - -// Custom event format for force press inputs -type MouseForceChangedEvent = MouseEvent & { - webkitForce?: number, -}; - -// https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button -const primaryButton: number = 0; -const noop = () => {}; - -// shared management of mousedown without needing to call preventDefault() -const mouseDownMarshal: EventMarshal = createEventMarshal(); - -export default function useMouseSensor(args: Args): OnMouseDown { - const { - canStartCapturing, - getWindow, - callbacks, - getShouldRespectForcePress, - onCaptureStart, - onCaptureEnd, - } = args; - const pendingRef = useRef(null); - const isDraggingRef = useRef(false); - const unbindWindowEventsRef = useRef<() => void>(noop); - const getIsCapturing = useCallback( - () => Boolean(pendingRef.current || isDraggingRef.current), - [], - ); - - const schedule = useMemo(() => { - invariant( - !getIsCapturing(), - 'Should not recreate scheduler while capturing', - ); - return createScheduler(callbacks); - }, [callbacks, getIsCapturing]); - - const postDragEventPreventer: EventPreventer = useMemo( - () => createPostDragEventPreventer(getWindow), - [getWindow], - ); - - const stop = useCallback(() => { - if (!getIsCapturing()) { - return; - } - - schedule.cancel(); - unbindWindowEventsRef.current(); - - const shouldBlockClick: boolean = isDraggingRef.current; - - mouseDownMarshal.reset(); - if (shouldBlockClick) { - postDragEventPreventer.preventNext(); - } - // resetting refs - pendingRef.current = null; - isDraggingRef.current = false; - - // releasing the capture - onCaptureEnd(); - }, [getIsCapturing, onCaptureEnd, postDragEventPreventer, schedule]); - - const cancel = useCallback(() => { - const wasDragging: boolean = isDraggingRef.current; - stop(); - - if (wasDragging) { - callbacks.onCancel(); - } - }, [callbacks, stop]); - - const startDragging = useCallback(() => { - invariant(!isDraggingRef.current, 'Cannot start a drag while dragging'); - const pending: ?Position = pendingRef.current; - invariant(pending, 'Cannot start a drag without a pending drag'); - - pendingRef.current = null; - isDraggingRef.current = true; - - callbacks.onLift({ - clientSelection: pending, - movementMode: 'FLUID', - }); - }, [callbacks]); - - const windowBindings: EventBinding[] = useMemo(() => { - invariant( - !getIsCapturing(), - 'Should not recreate window bindings while capturing', - ); - - const bindings: EventBinding[] = [ - { - eventName: 'mousemove', - fn: (event: MouseEvent) => { - const { button, clientX, clientY } = event; - if (button !== primaryButton) { - return; - } - - const point: Position = { - x: clientX, - y: clientY, - }; - - // Already dragging - if (isDraggingRef.current) { - // preventing default as we are using this event - event.preventDefault(); - schedule.move(point); - return; - } - - // There should be a pending drag at this point - const pending: ?Position = pendingRef.current; - - if (!pending) { - // this should be an impossible state - // we cannot use kill directly as it checks if there is a pending drag - stop(); - invariant( - false, - 'Expected there to be an active or pending drag when window mousemove event is received', - ); - } - - // threshold not yet exceeded - if (!isSloppyClickThresholdExceeded(pending, point)) { - return; - } - - // preventing default as we are using this event - event.preventDefault(); - startDragging(); - }, - }, - { - eventName: 'mouseup', - fn: (event: MouseEvent) => { - const wasDragging: boolean = isDraggingRef.current; - stop(); - - if (wasDragging) { - // preventing default as we are using this event - event.preventDefault(); - callbacks.onDrop(); - } - }, - }, - { - eventName: 'mousedown', - fn: (event: MouseEvent) => { - // this can happen during a drag when the user clicks a button - // other than the primary mouse button - if (isDraggingRef.current) { - event.preventDefault(); - } - - cancel(); - }, - }, - { - eventName: 'keydown', - fn: (event: KeyboardEvent) => { - // Abort if any keystrokes while a drag is pending - if (pendingRef.current) { - stop(); - return; - } - - // cancelling a drag - if (event.keyCode === keyCodes.escape) { - event.preventDefault(); - cancel(); - return; - } - - preventStandardKeyEvents(event); - }, - }, - { - eventName: 'resize', - fn: cancel, - }, - { - eventName: 'scroll', - // ## Passive: true - // Eventual consistency is fine because we use position: fixed on the item - // ## Capture: false - // 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 - // TODO: can result in awkward drop position - options: { passive: true, 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; - } - - // stop a pending drag - if (pendingRef.current) { - stop(); - return; - } - // getCallbacks().onWindowScroll(); - schedule.windowScrollMove(); - }, - }, - // Need to opt out of dragging if the user is a force press - // Only for safari which has decided to introduce its own custom way of doing things - // https://developer.apple.com/library/content/documentation/AppleApplications/Conceptual/SafariJSProgTopics/RespondingtoForceTouchEventsfromJavaScript.html - { - eventName: 'webkitmouseforcechanged', - fn: (event: MouseForceChangedEvent) => { - if ( - event.webkitForce == null || - (MouseEvent: any).WEBKIT_FORCE_AT_FORCE_MOUSE_DOWN == null - ) { - warning( - 'handling a mouse force changed event when it is not supported', - ); - return; - } - - const forcePressThreshold: number = (MouseEvent: any) - .WEBKIT_FORCE_AT_FORCE_MOUSE_DOWN; - const isForcePressing: boolean = - event.webkitForce >= forcePressThreshold; - - // New behaviour - if (!getShouldRespectForcePress()) { - event.preventDefault(); - return; - } - - if (isForcePressing) { - // it is considered a indirect cancel so we do not - // prevent default in any situation. - cancel(); - } - }, - }, - // Cancel on page visibility change - { - eventName: supportedPageVisibilityEventName, - fn: cancel, - }, - ]; - return bindings; - }, [ - getIsCapturing, - cancel, - startDragging, - schedule, - stop, - callbacks, - getWindow, - getShouldRespectForcePress, - ]); - - 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 startPendingDrag = useCallback( - (point: Position) => { - invariant(!pendingRef.current, 'Expected there to be no pending drag'); - pendingRef.current = point; - onCaptureStart(stop); - bindWindowEvents(); - }, - [bindWindowEvents, onCaptureStart, stop], - ); - - const onMouseDown = useCallback( - (event: MouseEvent) => { - if (mouseDownMarshal.isHandled()) { - return; - } - - invariant( - !getIsCapturing(), - 'Should not be able to perform a mouse down while a drag or pending drag is occurring', - ); - - // We do not need to prevent the event on a dropping draggable as - // the mouse down event will not fire due to pointer-events: none - // https://codesandbox.io/s/oxo0o775rz - if (!canStartCapturing(event)) { - return; - } - - // only starting a drag if dragging with the primary mouse button - if (event.button !== primaryButton) { - return; - } - - // Do not start a drag if any modifier key is pressed - if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) { - return; - } - - // Registering that this event has been handled. - // This is to prevent parent draggables using this event - // to start also. - // Ideally we would not use preventDefault() as we are not sure - // if this mouse down is part of a drag interaction - // Unfortunately we do to prevent the element obtaining focus (see below). - mouseDownMarshal.handle(); - - // Unfortunately we do need to prevent the drag handle from getting focus on mousedown. - // This goes against our policy on not blocking events before a drag has started. - // See [How we use dom events](/docs/guides/how-we-use-dom-events.md). - event.preventDefault(); - - const point: Position = { - x: event.clientX, - y: event.clientY, - }; - - startPendingDrag(point); - }, - [canStartCapturing, getIsCapturing, startPendingDrag], - ); - - return onMouseDown; -} diff --git a/src/view/use-drag-handle/sensor/use-touch-sensor.js b/src/view/use-drag-handle/sensor/use-touch-sensor.js deleted file mode 100644 index dfdff23510..0000000000 --- a/src/view/use-drag-handle/sensor/use-touch-sensor.js +++ /dev/null @@ -1,435 +0,0 @@ -// @flow -import type { Position } from 'css-box-model'; -import { useRef } from 'react'; -import invariant from 'tiny-invariant'; -import { useMemo, useCallback } from 'use-memo-one'; -import type { EventBinding } from '../util/event-types'; -import createEventMarshal, { - type EventMarshal, -} from '../util/create-event-marshal'; -import type { Callbacks } from '../drag-handle-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 createPostDragEventPreventer, { - type EventPreventer, -} from '../util/create-post-drag-event-preventer'; - -export type Args = {| - callbacks: Callbacks, - getDraggableRef: () => ?HTMLElement, - getWindow: () => HTMLElement, - canStartCapturing: (event: Event) => boolean, - getShouldRespectForcePress: () => boolean, - onCaptureStart: (abort: () => void) => void, - onCaptureEnd: () => void, -|}; -export type OnTouchStart = (event: TouchEvent) => void; - -type PendingDrag = {| - longPressTimerId: TimeoutID, - point: Position, -|}; - -type TouchWithForce = Touch & { - force: number, -}; - -type WebkitHack = {| - preventTouchMove: () => void, - releaseTouchMove: () => void, -|}; - -export const timeForLongPress: number = 150; -export const forcePressThreshold: number = 0.15; -const touchStartMarshal: EventMarshal = createEventMarshal(); -const noop = (): void => {}; - -// Webkit does not allow event.preventDefault() in dynamically added handlers -// So we add an always listening event handler to get around this :( -// webkit bug: https://bugs.webkit.org/show_bug.cgi?id=184250 -const webkitHack: WebkitHack = (() => { - const stub: WebkitHack = { - preventTouchMove: noop, - releaseTouchMove: noop, - }; - - // Do nothing when server side rendering - if (typeof window === 'undefined') { - return stub; - } - - // Device has no touch support - no point adding the touch listener - if (!('ontouchstart' in window)) { - return stub; - } - - // Not adding any user agent testing as everything pretends to be webkit - - let isBlocking: boolean = false; - - // Adding a persistent event handler - window.addEventListener( - 'touchmove', - (event: TouchEvent) => { - // We let the event go through as normal as nothing - // is blocking the touchmove - if (!isBlocking) { - return; - } - - // Our event handler would have worked correctly if the browser - // was not webkit based, or an older version of webkit. - if (event.defaultPrevented) { - return; - } - - // Okay, now we need to step in and fix things - event.preventDefault(); - - // Forcing this to be non-passive so we can get every touchmove - // Not activating in the capture phase like the dynamic touchmove we add. - // Technically it would not matter if we did this in the capture phase - }, - { passive: false, capture: false }, - ); - - const preventTouchMove = () => { - isBlocking = true; - }; - const releaseTouchMove = () => { - isBlocking = false; - }; - - return { preventTouchMove, releaseTouchMove }; -})(); - -export default function useTouchSensor(args: Args): OnTouchStart { - const { - callbacks, - getWindow, - canStartCapturing, - getShouldRespectForcePress, - onCaptureStart, - onCaptureEnd, - } = args; - const pendingRef = useRef(null); - const isDraggingRef = useRef(false); - const hasMovedRef = useRef(false); - const unbindWindowEventsRef = useRef<() => void>(noop); - const getIsCapturing = useCallback( - () => Boolean(pendingRef.current || isDraggingRef.current), - [], - ); - const postDragClickPreventer: EventPreventer = useMemo( - () => createPostDragEventPreventer(getWindow), - [getWindow], - ); - - const schedule = useMemo(() => { - invariant( - !getIsCapturing(), - 'Should not recreate scheduler while capturing', - ); - return createScheduler(callbacks); - }, [callbacks, getIsCapturing]); - - const stop = useCallback(() => { - if (!getIsCapturing()) { - return; - } - - schedule.cancel(); - unbindWindowEventsRef.current(); - touchStartMarshal.reset(); - webkitHack.releaseTouchMove(); - hasMovedRef.current = false; - onCaptureEnd(); - - // if dragging - prevent the next click - if (isDraggingRef.current) { - postDragClickPreventer.preventNext(); - isDraggingRef.current = false; - return; - } - - const pending: ?PendingDrag = pendingRef.current; - invariant(pending, 'Expected a pending drag'); - - clearTimeout(pending.longPressTimerId); - pendingRef.current = null; - }, [getIsCapturing, onCaptureEnd, postDragClickPreventer, schedule]); - - const cancel = useCallback(() => { - const wasDragging: boolean = isDraggingRef.current; - stop(); - - if (wasDragging) { - callbacks.onCancel(); - } - }, [callbacks, stop]); - - const windowBindings: EventBinding[] = useMemo(() => { - invariant( - !getIsCapturing(), - 'Should not recreate window bindings while capturing', - ); - - const bindings: EventBinding[] = [ - { - eventName: 'touchmove', - // Opting out of passive touchmove (default) so as to prevent scrolling while moving - // Not worried about performance as effect of move is throttled in requestAnimationFrame - // Using `capture: false` due to a recent horrible firefox bug: https://twitter.com/alexandereardon/status/1125904207184187393 - options: { passive: false, capture: false }, - fn: (event: TouchEvent) => { - // Drag has not yet started and we are waiting for a long press. - if (!isDraggingRef.current) { - stop(); - return; - } - - // At this point we are dragging - - if (!hasMovedRef.current) { - hasMovedRef.current = true; - } - - const { clientX, clientY } = event.touches[0]; - - const point: Position = { - x: clientX, - y: clientY, - }; - - // We need to prevent the default event in order to block native scrolling - // Also because we are using it as part of a drag we prevent the default action - // as a sign that we are using the event - event.preventDefault(); - schedule.move(point); - }, - }, - { - eventName: 'touchend', - fn: (event: TouchEvent) => { - // drag had not started yet - do not prevent the default action - if (!isDraggingRef.current) { - stop(); - return; - } - - // already dragging - this event is directly ending a drag - event.preventDefault(); - stop(); - callbacks.onDrop(); - }, - }, - { - eventName: 'touchcancel', - fn: (event: TouchEvent) => { - // drag had not started yet - do not prevent the default action - if (!isDraggingRef.current) { - stop(); - return; - } - - // already dragging - this event is directly ending a drag - event.preventDefault(); - cancel(); - }, - }, - // another touch start should not happen without a - // touchend or touchcancel. However, just being super safe - { - eventName: 'touchstart', - fn: cancel, - }, - // If the orientation of the device changes - kill the drag - // https://davidwalsh.name/orientation-change - { - eventName: 'orientationchange', - fn: cancel, - }, - // some devices fire resize if the orientation changes - { - eventName: 'resize', - fn: cancel, - }, - // ## Passive: true - // For scroll events we are okay with eventual consistency. - // Passive scroll listeners is the default behavior for mobile - // but we are being really clear here - // ## Capture: false - // 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 - { - eventName: 'scroll', - options: { passive: true, capture: false }, - fn: () => { - // stop a pending drag - if (pendingRef.current) { - stop(); - return; - } - schedule.windowScrollMove(); - }, - }, - // Long press can bring up a context menu - // need to opt out of this behavior - { - eventName: 'contextmenu', - fn: (event: Event) => { - // always opting out of context menu events - event.preventDefault(); - }, - }, - // On some devices it is possible to have a touch interface with a keyboard. - // On any keyboard event we cancel a touch drag - { - eventName: 'keydown', - fn: (event: KeyboardEvent) => { - if (!isDraggingRef.current) { - cancel(); - return; - } - - // direct cancel: we are preventing the default action - // indirect cancel: we are not preventing the default action - - // escape is a direct cancel - if (event.keyCode === keyCodes.escape) { - event.preventDefault(); - } - cancel(); - }, - }, - // Need to opt out of dragging if the user is a force press - // Only for webkit which has decided to introduce its own custom way of doing things - // https://developer.apple.com/library/content/documentation/AppleApplications/Conceptual/SafariJSProgTopics/RespondingtoForceTouchEventsfromJavaScript.html - { - eventName: 'touchforcechange', - fn: (event: TouchEvent) => { - // Not respecting force touches - prevent the event - if (!getShouldRespectForcePress()) { - event.preventDefault(); - return; - } - - // A force push action will no longer fire after a touchmove - if (hasMovedRef.current) { - // This is being super safe. While this situation should not occur we - // are still expressing that we want to opt out of force pressing - event.preventDefault(); - return; - } - - // A drag could be pending or has already started but no movement has occurred - - const touch: TouchWithForce = (event.touches[0]: any); - - if (touch.force >= forcePressThreshold) { - // this is an indirect cancel so we do not preventDefault - // we also want to allow the force press to occur - cancel(); - } - }, - }, - // Cancel on page visibility change - { - eventName: supportedPageVisibilityEventName, - fn: cancel, - }, - ]; - return bindings; - }, [ - callbacks, - cancel, - getIsCapturing, - getShouldRespectForcePress, - schedule, - stop, - ]); - - 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(() => { - const pending: ?PendingDrag = pendingRef.current; - invariant(pending, 'Cannot start a drag without a pending drag'); - - isDraggingRef.current = true; - pendingRef.current = null; - hasMovedRef.current = false; - - callbacks.onLift({ - clientSelection: pending.point, - movementMode: 'FLUID', - }); - }, [callbacks]); - - const startPendingDrag = useCallback( - (event: TouchEvent) => { - invariant(!pendingRef.current, 'Expected there to be no pending drag'); - const touch: Touch = event.touches[0]; - const { clientX, clientY } = touch; - const point: Position = { - x: clientX, - y: clientY, - }; - const longPressTimerId: TimeoutID = setTimeout( - startDragging, - timeForLongPress, - ); - - const pending: PendingDrag = { - point, - longPressTimerId, - }; - - pendingRef.current = pending; - onCaptureStart(stop); - bindWindowEvents(); - }, - [bindWindowEvents, onCaptureStart, startDragging, stop], - ); - - const onTouchStart = (event: TouchEvent) => { - if (touchStartMarshal.isHandled()) { - return; - } - - invariant( - !getIsCapturing(), - 'Should not be able to perform a touch start while a drag or pending drag is occurring', - ); - - // We do not need to prevent the event on a dropping draggable as - // the touchstart event will not fire due to pointer-events: none - // https://codesandbox.io/s/oxo0o775rz - if (!canStartCapturing(event)) { - return; - } - - // We need to stop parents from responding to this event - which may cause a double lift - // We also need to NOT call event.preventDefault() so as to maintain as much standard - // browser interactions as possible. - // This includes navigation on anchors which we want to preserve - touchStartMarshal.handle(); - - // A webkit only hack to prevent touch move events - webkitHack.preventTouchMove(); - startPendingDrag(event); - }; - - return onTouchStart; -} diff --git a/src/view/use-drag-handle/use-drag-handle.js b/src/view/use-drag-handle/use-drag-handle.js deleted file mode 100644 index de7829539b..0000000000 --- a/src/view/use-drag-handle/use-drag-handle.js +++ /dev/null @@ -1,243 +0,0 @@ -// @flow -import invariant from 'tiny-invariant'; -import { useRef } from 'react'; -import { useMemo, useCallback } from 'use-memo-one'; -import type { Args, DragHandleProps } from './drag-handle-types'; -import getWindowFromEl from '../window/get-window-from-el'; -import useRequiredContext from '../use-required-context'; -import AppContext, { type AppContextValue } from '../context/app-context'; -import shouldAllowDraggingFromTarget from './util/should-allow-dragging-from-target'; -import useKeyboardSensor, { - type Args as KeyboardSensorArgs, -} from './sensor/use-keyboard-sensor'; -import useTouchSensor, { - type Args as TouchSensorArgs, -} from './sensor/use-touch-sensor'; -import usePreviousRef from '../use-previous-ref'; -import { warning } from '../../dev-warning'; -import useValidation from './use-validation'; -// import useFocusRetainer from './use-focus-retainer'; -import useLayoutEffect from '../use-isomorphic-layout-effect'; -import getDragHandleRef from './util/get-drag-handle-ref'; -import useMouseSensor, { - type Args as MouseSensorArgs, -} from './sensor/use-mouse-sensor'; - -function preventHtml5Dnd(event: DragEvent) { - event.preventDefault(); -} - -type Capturing = {| - abort: () => void, -|}; - -export default function useDragHandle(args: Args): ?DragHandleProps { - // Capturing - const capturingRef = useRef(null); - const onCaptureStart = useCallback((abort: () => void) => { - invariant( - !capturingRef.current, - 'Cannot start capturing while something else is', - ); - capturingRef.current = { - abort, - }; - }, []); - const onCaptureEnd = useCallback(() => { - invariant( - capturingRef.current, - 'Cannot stop capturing while nothing is capturing', - ); - capturingRef.current = null; - }, []); - const abortCapture = useCallback(() => { - invariant(capturingRef.current, 'Cannot abort capture when there is none'); - capturingRef.current.abort(); - }, []); - - const { canLift, contextId, focus }: AppContextValue = useRequiredContext( - AppContext, - ); - const { - isDragging, - isEnabled, - draggableId, - callbacks, - getDraggableRef, - getShouldRespectForcePress, - canDragInteractiveElements, - } = args; - const lastArgsRef = usePreviousRef(args); - - useValidation({ isEnabled, getDraggableRef }); - - const getWindow = useCallback( - (): HTMLElement => getWindowFromEl(getDraggableRef()), - [getDraggableRef], - ); - - const canStartCapturing = useCallback( - (event: Event) => { - // Cannot lift when disabled - if (!isEnabled) { - return false; - } - // Something on this element might be capturing. - // A drag might not have started yet - // We want to prevent anything else from capturing - if (capturingRef.current) { - return false; - } - - // Do not drag if anything else in the system is dragging - if (!canLift(draggableId)) { - return false; - } - - // Check if we are dragging an interactive element - return shouldAllowDraggingFromTarget(event, canDragInteractiveElements); - }, - [canDragInteractiveElements, canLift, draggableId, isEnabled], - ); - - // const { onBlur, onFocus } = useFocusRetainer(args); - const mouseArgs: MouseSensorArgs = useMemo( - () => ({ - callbacks, - getDraggableRef, - getWindow, - canStartCapturing, - getShouldRespectForcePress, - onCaptureStart, - onCaptureEnd, - }), - [ - callbacks, - getDraggableRef, - getWindow, - canStartCapturing, - getShouldRespectForcePress, - onCaptureStart, - onCaptureEnd, - ], - ); - const onMouseDown = useMouseSensor(mouseArgs); - - const keyboardArgs: KeyboardSensorArgs = useMemo( - () => ({ - callbacks, - getDraggableRef, - getWindow, - canStartCapturing, - onCaptureStart, - onCaptureEnd, - }), - [ - callbacks, - canStartCapturing, - getDraggableRef, - getWindow, - onCaptureEnd, - onCaptureStart, - ], - ); - const onKeyDown = useKeyboardSensor(keyboardArgs); - - const touchArgs: TouchSensorArgs = useMemo( - () => ({ - callbacks, - getDraggableRef, - getWindow, - canStartCapturing, - getShouldRespectForcePress, - onCaptureStart, - onCaptureEnd, - }), - [ - callbacks, - getDraggableRef, - getWindow, - canStartCapturing, - getShouldRespectForcePress, - onCaptureStart, - onCaptureEnd, - ], - ); - const onTouchStart = useTouchSensor(touchArgs); - - // aborting on unmount - - useLayoutEffect(() => { - // only when unmounting - return () => { - if (!capturingRef.current) { - return; - } - abortCapture(); - - if (lastArgsRef.current.isDragging) { - // eslint-disable-next-line react-hooks/exhaustive-deps - lastArgsRef.current.callbacks.onCancel(); - } - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // No longer enabled but still capturing: need to abort and cancel if needed - if (!isEnabled && capturingRef.current) { - abortCapture(); - if (lastArgsRef.current.isDragging) { - warning( - 'You have disabled dragging on a Draggable while it was dragging. The drag has been cancelled', - ); - callbacks.onCancel(); - } - } - - const focusOnHandle = useCallback(() => { - const draggableRef: ?HTMLElement = getDraggableRef(); - if (!draggableRef) { - warning(`Cannot find draggable ref from drag handle`); - return; - } - getDragHandleRef(draggableRef).focus(); - }, [getDraggableRef]); - - useLayoutEffect(() => { - const unregister = focus.register(draggableId, focusOnHandle); - return unregister; - }, [draggableId, focus, focusOnHandle]); - - // Handle aborting - // No longer dragging but still capturing: need to abort - // Using a layout effect to ensure that there is a flip from isDragging => !isDragging - // When there is a pending drag !isDragging will always be true - useLayoutEffect(() => { - if (!isDragging && capturingRef.current) { - abortCapture(); - } - }, [abortCapture, isDragging]); - - const props: ?DragHandleProps = useMemo(() => { - if (!isEnabled) { - return null; - } - return { - // onMouseDown, - // onKeyDown: () => {}, - // onTouchStart, - // onFocus, - // onBlur, - tabIndex: 0, - 'data-rbd-drag-handle-draggable-id': draggableId, - 'data-rbd-drag-handle-context-id': contextId, - // English default. Consumers are welcome to add their own start instruction - 'aria-roledescription': 'Draggable item. Press space bar to lift', - // Opting out of html5 drag and drops - draggable: false, - onDragStart: preventHtml5Dnd, - }; - }, [contextId, draggableId, isEnabled]); - - return props; -} diff --git a/src/view/use-drag-handle/use-focus-retainer.js b/src/view/use-drag-handle/use-focus-retainer.js deleted file mode 100644 index 08f5c4b0a1..0000000000 --- a/src/view/use-drag-handle/use-focus-retainer.js +++ /dev/null @@ -1,108 +0,0 @@ -// @flow -import invariant from 'tiny-invariant'; -import { useRef } from 'react'; -import { useCallback } from 'use-memo-one'; -import type { Args } from './drag-handle-types'; -import usePrevious from '../use-previous-ref'; -import focusRetainer from './util/focus-retainer'; -import getDragHandleRef from './util/get-drag-handle-ref'; -import useLayoutEffect from '../use-isomorphic-layout-effect'; - -export type Result = {| - onBlur: () => void, - onFocus: () => void, -|}; - -function noop() {} - -export default function useFocusRetainer(args: Args): Result { - const isFocusedRef = useRef(false); - const lastArgsRef = usePrevious(args); - const { getDraggableRef } = args; - - const onFocus = useCallback(() => { - isFocusedRef.current = true; - }, []); - const onBlur = useCallback(() => { - isFocusedRef.current = false; - }, []); - - // This effect handles: - // - giving focus on mount - // - registering focus on unmount - useLayoutEffect(() => { - // mounting: try to restore focus - const first: Args = lastArgsRef.current; - if (!first.isEnabled) { - return noop; - } - const draggable: ?HTMLElement = getDraggableRef(); - invariant(draggable, 'Drag handle could not obtain draggable ref'); - - const dragHandle: HTMLElement = getDragHandleRef(draggable); - - focusRetainer.tryRestoreFocus(first.draggableId, dragHandle); - - // unmounting: try to retain focus - return () => { - // eslint-disable-next-line react-hooks/exhaustive-deps - const last: Args = lastArgsRef.current; - const shouldRetainFocus = ((): boolean => { - // will not restore if not enabled - if (!last.isEnabled) { - return false; - } - // not focused - if (!isFocusedRef.current) { - return false; - } - - // a drag is finishing - return last.isDragging || last.isDropAnimating; - })(); - - if (shouldRetainFocus) { - focusRetainer.retain(last.draggableId); - } - }; - }, [getDraggableRef, lastArgsRef]); - - // will always be null on the first render as nothing has mounted yet - const lastDraggableRef = useRef(null); - - // This effect restores focus to an element when a - // ref changes while a component is still mounted. - // This can happen when a drag handle is moved into a portal - useLayoutEffect(() => { - // this can happen on the first mount - no draggable ref is set - // this effect is not handling initial mounting - if (!lastDraggableRef.current) { - return; - } - - const draggableRef: ?HTMLElement = getDraggableRef(); - - // Cannot focus on nothing - if (!draggableRef) { - return; - } - - // no change in ref - if (draggableRef === lastDraggableRef.current) { - return; - } - - // ref has changed - let's do this - if (isFocusedRef.current && lastArgsRef.current.isEnabled) { - getDragHandleRef(draggableRef).focus(); - } - - // Doing our own should run check - }); - - useLayoutEffect(() => { - lastDraggableRef.current = getDraggableRef(); - }); - - return { onBlur, onFocus }; -} diff --git a/src/view/use-drag-handle/use-validation.js b/src/view/use-drag-handle/use-validation.js deleted file mode 100644 index 6f40de5a4c..0000000000 --- a/src/view/use-drag-handle/use-validation.js +++ /dev/null @@ -1,25 +0,0 @@ -// @flow -import { useEffect } from 'react'; -import invariant from 'tiny-invariant'; -import getDragHandleRef from './util/get-drag-handle-ref'; - -type Args = {| - isEnabled: boolean, - getDraggableRef: () => ?HTMLElement, -|}; - -export default function useValidation({ isEnabled, getDraggableRef }: Args) { - // validate ref on mount - useEffect(() => { - // wrapping entire block for better minification - if (process.env.NODE_ENV !== 'production') { - // There will be no drag handle ref when disabled - if (!isEnabled) { - return; - } - const draggableRef: ?HTMLElement = getDraggableRef(); - invariant(draggableRef, 'Drag handle was unable to find draggable ref'); - getDragHandleRef(draggableRef); - } - }, [getDraggableRef, isEnabled]); -} diff --git a/src/view/use-drag-handle/util/bind-events.js b/src/view/use-drag-handle/util/bind-events.js deleted file mode 100644 index 6530914aae..0000000000 --- a/src/view/use-drag-handle/util/bind-events.js +++ /dev/null @@ -1,34 +0,0 @@ -// @flow -import type { EventBinding, EventOptions } from './event-types'; - -const getOptions = ( - shared?: EventOptions, - fromBinding: ?EventOptions, -): EventOptions => ({ - ...shared, - ...fromBinding, -}); - -export const bindEvents = ( - el: HTMLElement, - bindings: EventBinding[], - sharedOptions?: EventOptions, -) => { - bindings.forEach((binding: EventBinding) => { - const options: Object = getOptions(sharedOptions, binding.options); - - el.addEventListener(binding.eventName, binding.fn, options); - }); -}; - -export const unbindEvents = ( - el: HTMLElement, - bindings: EventBinding[], - sharedOptions?: EventOptions, -) => { - bindings.forEach((binding: EventBinding) => { - const options: Object = getOptions(sharedOptions, binding.options); - - el.removeEventListener(binding.eventName, binding.fn, options); - }); -}; diff --git a/src/view/use-drag-handle/util/create-event-marshal.js b/src/view/use-drag-handle/util/create-event-marshal.js deleted file mode 100644 index eee13c6811..0000000000 --- a/src/view/use-drag-handle/util/create-event-marshal.js +++ /dev/null @@ -1,32 +0,0 @@ -// @flow -import invariant from 'tiny-invariant'; - -export type EventMarshal = {| - handle: () => void, - isHandled: () => boolean, - reset: () => void, -|}; - -export default (): EventMarshal => { - let isMouseDownHandled: boolean = false; - - const handle = (): void => { - invariant( - !isMouseDownHandled, - 'Cannot handle mouse down as it is already handled', - ); - isMouseDownHandled = true; - }; - - const isHandled = (): boolean => isMouseDownHandled; - - const reset = (): void => { - isMouseDownHandled = false; - }; - - return { - handle, - isHandled, - reset, - }; -}; diff --git a/src/view/use-drag-handle/util/create-post-drag-event-preventer.js b/src/view/use-drag-handle/util/create-post-drag-event-preventer.js deleted file mode 100644 index 361f239751..0000000000 --- a/src/view/use-drag-handle/util/create-post-drag-event-preventer.js +++ /dev/null @@ -1,68 +0,0 @@ -// @flow -/* eslint-disable no-use-before-define */ -import type { EventBinding, EventOptions } from './event-types'; -import { bindEvents, unbindEvents } from './bind-events'; - -type GetWindowFn = () => HTMLElement; - -export type EventPreventer = {| - preventNext: () => void, - abort: () => void, -|}; - -const sharedOptions: EventOptions = { capture: true }; - -export default (getWindow: GetWindowFn): EventPreventer => { - let isBound: boolean = false; - - const bind = () => { - if (isBound) { - return; - } - isBound = true; - bindEvents(getWindow(), pointerEvents, sharedOptions); - }; - - const unbind = () => { - if (!isBound) { - return; - } - isBound = false; - unbindEvents(getWindow(), pointerEvents, sharedOptions); - }; - - const pointerEvents: EventBinding[] = [ - { - eventName: 'click', - fn: (event: MouseEvent) => { - event.preventDefault(); - unbind(); - }, - }, - // These events signal that the click prevention is no longer needed - { - eventName: 'mousedown', - // a new mouse interaction is starting: we can unbind - fn: unbind, - }, - { - eventName: 'touchstart', - fn: unbind, - }, - ]; - - const preventNext = (): void => { - if (isBound) { - unbind(); - } - - bind(); - }; - - const preventer: EventPreventer = { - preventNext, - abort: unbind, - }; - - return preventer; -}; diff --git a/src/view/use-drag-handle/util/create-scheduler.js b/src/view/use-drag-handle/util/create-scheduler.js deleted file mode 100644 index 1b2d671947..0000000000 --- a/src/view/use-drag-handle/util/create-scheduler.js +++ /dev/null @@ -1,40 +0,0 @@ -// @flow -import { type Position } from 'css-box-model'; -import memoizeOne from 'memoize-one'; -import rafSchd from 'raf-schd'; -import type { Callbacks } from '../drag-handle-types'; - -export default (callbacks: Callbacks) => { - const memoizedMove = memoizeOne((x: number, y: number) => { - const point: Position = { x, y }; - callbacks.onMove(point); - }); - - const move = rafSchd((point: Position) => memoizedMove(point.x, point.y)); - const moveUp = rafSchd(callbacks.onMoveUp); - const moveDown = rafSchd(callbacks.onMoveDown); - const moveRight = rafSchd(callbacks.onMoveRight); - const moveLeft = rafSchd(callbacks.onMoveLeft); - const windowScrollMove = rafSchd(callbacks.onWindowScroll); - - const cancel = () => { - // cancel all of the next animation frames - - move.cancel(); - moveUp.cancel(); - moveDown.cancel(); - moveRight.cancel(); - moveLeft.cancel(); - windowScrollMove.cancel(); - }; - - return { - move, - moveUp, - moveDown, - moveRight, - moveLeft, - windowScrollMove, - cancel, - }; -}; diff --git a/src/view/use-drag-handle/util/event-types.js b/src/view/use-drag-handle/util/event-types.js deleted file mode 100644 index 42e68d31c7..0000000000 --- a/src/view/use-drag-handle/util/event-types.js +++ /dev/null @@ -1,12 +0,0 @@ -// @flow - -export type EventOptions = {| - passive?: boolean, - capture?: boolean, -|}; - -export type EventBinding = {| - eventName: string, - fn: Function, - options?: EventOptions, -|}; diff --git a/src/view/use-drag-handle/util/focus-retainer.js b/src/view/use-drag-handle/util/focus-retainer.js deleted file mode 100644 index 2df6465a38..0000000000 --- a/src/view/use-drag-handle/util/focus-retainer.js +++ /dev/null @@ -1,95 +0,0 @@ -// @flow -import getDragHandleRef from './get-drag-handle-ref'; -import { warning } from '../../../dev-warning'; -import type { DraggableId } from '../../../types'; - -type FocusRetainer = {| - retain: (draggableId: DraggableId) => void, - tryRestoreFocus: ( - draggableId: DraggableId, - draggableRef: HTMLElement, - ) => void, -|}; - -// our shared state -let retainingFocusFor: ?DraggableId = null; - -// Using capture: true as focus events do not bubble -// Additionally doing this prevents us from intercepting the initial -// focus event as it does not bubble up to this listener -const listenerOptions = { capture: true }; - -// If we focus on -const clearRetentionOnFocusChange = (() => { - let isBound: boolean = false; - - const bind = () => { - if (isBound) { - return; - } - - isBound = true; - - // eslint-disable-next-line no-use-before-define - window.addEventListener('focus', onWindowFocusChange, listenerOptions); - }; - - const unbind = () => { - if (!isBound) { - return; - } - - isBound = false; - // eslint-disable-next-line no-use-before-define - window.removeEventListener('focus', onWindowFocusChange, listenerOptions); - }; - - // focusin will fire after the focus event fires on the element - const onWindowFocusChange = () => { - // unbinding self after single use - unbind(); - retainingFocusFor = null; - }; - - const result = () => bind(); - result.cancel = () => unbind(); - - return result; -})(); - -const retain = (id: DraggableId) => { - retainingFocusFor = id; - clearRetentionOnFocusChange(); -}; - -const tryRestoreFocus = (id: DraggableId, draggableRef: HTMLElement) => { - // Not needing to retain focus - if (!retainingFocusFor) { - return; - } - // Not needing to retain focus for this draggable - if (id !== retainingFocusFor) { - return; - } - - // We are about to force focus onto a drag handle - - retainingFocusFor = null; - // no need to clear it - we are already clearing it - clearRetentionOnFocusChange.cancel(); - - const dragHandleRef: ?HTMLElement = getDragHandleRef(draggableRef); - - if (!dragHandleRef) { - warning('Could not find drag handle in the DOM to focus on it'); - return; - } - dragHandleRef.focus(); -}; - -const retainer: FocusRetainer = { - retain, - tryRestoreFocus, -}; - -export default retainer; diff --git a/src/view/use-drag-handle/util/get-drag-handle-ref.js b/src/view/use-drag-handle/util/get-drag-handle-ref.js deleted file mode 100644 index 0c89e4f0c7..0000000000 --- a/src/view/use-drag-handle/util/get-drag-handle-ref.js +++ /dev/null @@ -1,49 +0,0 @@ -// @flow -import invariant from 'tiny-invariant'; -import { dragHandle } from '../../data-attributes'; -import isSvgElement from '../../is-type-of-element/is-svg-element'; -import isHtmlElement from '../../is-type-of-element/is-html-element'; - -const selector: string = `[${dragHandle.contextId}]`; - -const throwIfSVG = (el: mixed) => { - invariant( - !isSvgElement(el), - `A drag handle cannot be an SVGElement: it has inconsistent focus support. - - More information: https://github.com/atlassian/react-beautiful-dnd/blob/master/docs/guides/dragging-svgs.md`, - ); -}; - -// If called when the component is disabled then the data -// attribute will not be present -const getDragHandleRef = (draggableRef: HTMLElement): HTMLElement => { - if (draggableRef.hasAttribute(dragHandle.contextId)) { - throwIfSVG(draggableRef); - return draggableRef; - } - - // find the first nested drag handle - // querySelector will return the first match on a breadth first search which is what we want - // Search will fail when the drag handle is disabled - // https://codepen.io/alexreardon/pen/erOqyZ - const el: ?HTMLElement = draggableRef.querySelector(selector); - - throwIfSVG(draggableRef); - - invariant( - el, - ` - Cannot find drag handle element inside of Draggable. - Please be sure to apply the {...provided.dragHandleProps} to your Draggable - - More information: https://github.com/atlassian/react-beautiful-dnd/blob/master/docs/api/draggable.md - `, - ); - - invariant(isHtmlElement(el), 'A drag handle must be a HTMLElement'); - - return el; -}; - -export default getDragHandleRef; diff --git a/src/view/use-drag-handle/util/is-sloppy-click-threshold-exceeded.js b/src/view/use-drag-handle/util/is-sloppy-click-threshold-exceeded.js deleted file mode 100644 index e73f3f2789..0000000000 --- a/src/view/use-drag-handle/util/is-sloppy-click-threshold-exceeded.js +++ /dev/null @@ -1,9 +0,0 @@ -// @flow -import { type Position } from 'css-box-model'; -// The amount of pixels that need to move before we consider the movement -// a drag rather than a click. -export const sloppyClickThreshold: number = 5; - -export default (original: Position, current: Position): boolean => - Math.abs(current.x - original.x) >= sloppyClickThreshold || - Math.abs(current.y - original.y) >= sloppyClickThreshold; diff --git a/src/view/use-drag-handle/util/prevent-standard-key-events.js b/src/view/use-drag-handle/util/prevent-standard-key-events.js deleted file mode 100644 index c4f30a6576..0000000000 --- a/src/view/use-drag-handle/util/prevent-standard-key-events.js +++ /dev/null @@ -1,19 +0,0 @@ -// @flow -import * as keyCodes from '../../key-codes'; - -type KeyMap = { - [key: number]: true, -}; - -const preventedKeys: KeyMap = { - // submission - [keyCodes.enter]: true, - // tabbing - [keyCodes.tab]: true, -}; - -export default (event: KeyboardEvent) => { - if (preventedKeys[event.keyCode]) { - event.preventDefault(); - } -}; diff --git a/src/view/use-drag-handle/util/should-allow-dragging-from-target.js b/src/view/use-drag-handle/util/should-allow-dragging-from-target.js deleted file mode 100644 index fe43adce04..0000000000 --- a/src/view/use-drag-handle/util/should-allow-dragging-from-target.js +++ /dev/null @@ -1,72 +0,0 @@ -// @flow -import isElement from '../../is-type-of-element/is-element'; - -export type TagNameMap = { - [tagName: string]: true, -}; - -export const interactiveTagNames: TagNameMap = { - input: true, - button: true, - textarea: true, - select: true, - option: true, - optgroup: true, - video: true, - audio: true, -}; - -const isAnInteractiveElement = ( - parent: Element, - current: ?Element, -): boolean => { - if (current == null) { - return false; - } - - // Most interactive elements cannot have children. However, some can such as 'button'. - // See 'Permitted content' on https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button - // Rather than having two different functions we can consolidate our checks into this single - // function to keep things simple. - // There is no harm checking if the parent has an interactive tag name even if it cannot have - // any children. We need to perform this loop anyway to check for the contenteditable attribute - const hasAnInteractiveTag: boolean = Boolean( - interactiveTagNames[current.tagName.toLowerCase()], - ); - - if (hasAnInteractiveTag) { - return true; - } - - // contenteditable="true" or contenteditable="" are valid ways - // of creating a contenteditable container - // https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/contenteditable - const attribute: ?string = current.getAttribute('contenteditable'); - if (attribute === 'true' || attribute === '') { - return true; - } - - // nothing more can be done and no results found - if (current === parent) { - return false; - } - - // recursion to check parent - return isAnInteractiveElement(parent, current.parentElement); -}; - -export default (event: Event, canDragInteractiveElements: boolean): boolean => { - // Allowing drag with all element types - if (canDragInteractiveElements) { - return true; - } - - const { target, currentTarget } = event; - - // Technically target and currentTarget are EventTarget's and do not have to be elements - if (!isElement(target) || !isElement(currentTarget)) { - return true; - } - - return !isAnInteractiveElement(currentTarget, target); -}; diff --git a/src/view/use-drag-handle/util/supported-page-visibility-event-name.js b/src/view/use-drag-handle/util/supported-page-visibility-event-name.js deleted file mode 100644 index 970536a8d3..0000000000 --- a/src/view/use-drag-handle/util/supported-page-visibility-event-name.js +++ /dev/null @@ -1,29 +0,0 @@ -// @flow -import { find } from '../../../native-with-fallback'; - -const supportedEventName: string = ((): string => { - const base: string = 'visibilitychange'; - - // Server side rendering - if (typeof document === 'undefined') { - return base; - } - - // See https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API - const candidates: string[] = [ - base, - `ms${base}`, - `webkit${base}`, - `moz${base}`, - `o${base}`, - ]; - - const supported: ?string = find( - candidates, - (eventName: string): boolean => `on${eventName}` in document, - ); - - return supported || base; -})(); - -export default supportedEventName; diff --git a/stories/src/multi-drag/task.jsx b/stories/src/multi-drag/task.jsx index 73ca96e1dd..e7d6ec40fb 100644 --- a/stories/src/multi-drag/task.jsx +++ b/stories/src/multi-drag/task.jsx @@ -108,10 +108,6 @@ export default class Task extends Component { provided: DraggableProvided, snapshot: DraggableStateSnapshot, ) => { - if (provided.dragHandleProps) { - provided.dragHandleProps.onKeyDown(event); - } - if (event.defaultPrevented) { return; } diff --git a/test/unit/integration/responders-integration.spec.js b/test/unit/integration/responders-integration.spec.js index f017ce010e..c69a441de5 100644 --- a/test/unit/integration/responders-integration.spec.js +++ b/test/unit/integration/responders-integration.spec.js @@ -4,7 +4,7 @@ import React from 'react'; import { mount } from 'enzyme'; import { getRect, type Rect, type Position } from 'css-box-model'; import { DragDropContext, Draggable, Droppable } from '../../../src'; -import { sloppyClickThreshold } from '../../../src/view/use-drag-handle/util/is-sloppy-click-threshold-exceeded'; +import { sloppyClickThreshold } from '../../../src/view/use-sensor-marshal/sensors/use-mouse-sensor'; import { dispatchWindowMouseEvent, dispatchWindowKeyDownEvent, diff --git a/test/unit/view/drag-handle/attributes.spec.js b/test/unit/view/drag-handle/attributes.spec.js deleted file mode 100644 index 69f22b6938..0000000000 --- a/test/unit/view/drag-handle/attributes.spec.js +++ /dev/null @@ -1,22 +0,0 @@ -// @flow -import { getWrapper, Child } from './util/wrappers'; -import { getStubCallbacks } from './util/callbacks'; -import basicContext from './util/app-context'; - -it('should apply the style context to a data-attribute', () => { - expect( - getWrapper(getStubCallbacks()) - .find(Child) - .getDOMNode() - .getAttribute('data-react-beautiful-dnd-drag-handle'), - ).toEqual(basicContext.style); -}); - -it('should apply a default aria roledescription containing lift instructions', () => { - expect( - getWrapper(getStubCallbacks()) - .find(Child) - .getDOMNode() - .getAttribute('aria-roledescription'), - ).toEqual('Draggable item. Press space bar to lift'); -}); diff --git a/test/unit/view/drag-handle/block-drag-start-when-something-else-dragging.spec.js b/test/unit/view/drag-handle/block-drag-start-when-something-else-dragging.spec.js deleted file mode 100644 index 982644f582..0000000000 --- a/test/unit/view/drag-handle/block-drag-start-when-something-else-dragging.spec.js +++ /dev/null @@ -1,30 +0,0 @@ -// @flow -import { forEach, type Control } from './util/controls'; -import { getStubCallbacks, callbacksCalled } from './util/callbacks'; -import { getWrapper } from './util/wrappers'; -import type { AppContextValue } from '../../../../src/view/context/app-context'; -import basicContext from './util/app-context'; - -forEach((control: Control) => { - it('should not start a drag if something else is already dragging in the system', () => { - // faking a 'false' response - const canLift = jest.fn().mockImplementation(() => false); - const customContext: AppContextValue = { - ...basicContext, - canLift, - }; - - const callbacks = getStubCallbacks(); - const wrapper = getWrapper(callbacks, customContext); - control.preLift(wrapper); - control.lift(wrapper); - control.drop(wrapper); - - expect( - callbacksCalled(callbacks)({ - onLift: 0, - }), - ).toBe(true); - expect(canLift).toHaveBeenCalledWith('my-draggable'); - }); -}); diff --git a/test/unit/view/drag-handle/contenteditable.spec.js b/test/unit/view/drag-handle/contenteditable.spec.js deleted file mode 100644 index 0ff1952b6a..0000000000 --- a/test/unit/view/drag-handle/contenteditable.spec.js +++ /dev/null @@ -1,317 +0,0 @@ -// @flow -import React from 'react'; -import { mount } from 'enzyme'; -import { forEach, type Control } from './util/controls'; -import type { DragHandleProps } from '../../../../src/view/use-drag-handle/drag-handle-types'; -import { WithDragHandle } from './util/wrappers'; -import createRef from '../../../utils/create-ref'; -import { - getStubCallbacks, - callbacksCalled, - whereAnyCallbacksCalled, -} from './util/callbacks'; -import basicContext from './util/app-context'; -import AppContext from '../../../../src/view/context/app-context'; - -const draggableId = 'draggable'; - -forEach((control: Control) => { - beforeEach(() => { - // using content editable in particular ways causes react logging - jest.spyOn(console, 'error').mockImplementation(() => {}); - }); - afterEach(() => { - console.error.mockRestore(); - }); - describe('interactive interactions are blocked', () => { - it('should block the drag if the drag handle is itself contenteditable', () => { - const callbacks = getStubCallbacks(); - const ref = createRef(); - const wrapper = mount( - - true} - > - {(dragHandleProps: ?DragHandleProps) => ( -
- )} - - , - ); - const target = wrapper.getDOMNode(); - const options = { - target, - }; - - control.preLift(wrapper, options); - control.lift(wrapper, options); - control.drop(wrapper); - - expect( - callbacksCalled(callbacks)({ - onLift: 0, - }), - ).toBe(true); - - wrapper.unmount(); - }); - - it('should block the drag if originated from a child contenteditable', () => { - const customCallbacks = getStubCallbacks(); - const ref = createRef(); - const customWrapper = mount( - - true} - > - {(dragHandleProps: ?DragHandleProps) => ( -
-
-
- )} - - , - ); - const target = customWrapper.getDOMNode().querySelector('.editable'); - if (!target) { - throw new Error('could not find editable element'); - } - const options = { - target, - }; - - control.preLift(customWrapper, options); - control.lift(customWrapper, options); - control.drop(customWrapper); - - expect(whereAnyCallbacksCalled(customCallbacks)).toBe(false); - - customWrapper.unmount(); - }); - - it('should block the drag if originated from a child of a child contenteditable', () => { - const customCallbacks = getStubCallbacks(); - const ref = createRef(); - const customWrapper = mount( - - true} - > - {(dragHandleProps: ?DragHandleProps) => ( -
-
-

hello there

- Edit me! -
-
- )} -
-
, - ); - const target = customWrapper.getDOMNode().querySelector('.target'); - if (!target) { - throw new Error('could not find the target'); - } - const options = { - target, - }; - - control.preLift(customWrapper, options); - control.lift(customWrapper, options); - control.drop(customWrapper); - - expect( - callbacksCalled(customCallbacks)({ - onLift: 0, - }), - ).toBe(true); - - customWrapper.unmount(); - }); - - it('should not block if contenteditable is set to false', () => { - const customCallbacks = getStubCallbacks(); - const ref = createRef(); - const customWrapper = mount( - - true} - > - {(dragHandleProps: ?DragHandleProps) => ( -
-
-

hello there

- Edit me! -
-
- )} -
-
, - ); - const target = customWrapper.getDOMNode().querySelector('.target'); - if (!target) { - throw new Error('could not find the target'); - } - const options = { - target, - }; - - control.preLift(customWrapper, options); - control.lift(customWrapper, options); - - expect( - callbacksCalled(customCallbacks)({ - onLift: 1, - }), - ).toBe(true); - - customWrapper.unmount(); - }); - }); - - describe('interactive interactions are not blocked', () => { - it('should not block the drag if the drag handle is contenteditable', () => { - const customCallbacks = getStubCallbacks(); - const ref = createRef(); - const customWrapper = mount( - - true} - // stating that we can drag - canDragInteractiveElements - > - {(dragHandleProps: ?DragHandleProps) => ( -
-
-
- )} - - , - ); - const target = customWrapper.getDOMNode().querySelector('.editable'); - if (!target) { - throw new Error('could not find editable element'); - } - const options = { - target, - }; - - control.preLift(customWrapper, options); - control.lift(customWrapper, options); - - expect( - callbacksCalled(customCallbacks)({ - onLift: 1, - }), - ).toBe(true); - - customWrapper.unmount(); - }); - - it('should not block the drag if originated from a child contenteditable', () => { - const customCallbacks = getStubCallbacks(); - const ref = createRef(); - const customWrapper = mount( - - true} - // stating that we can drag - canDragInteractiveElements - > - {(dragHandleProps: ?DragHandleProps) => ( -
-
-

hello there

- Edit me! -
-
- )} -
-
, - ); - const target = customWrapper.getDOMNode().querySelector('.target'); - if (!target) { - throw new Error('could not find the target'); - } - const options = { - target, - }; - - control.preLift(customWrapper, options); - control.lift(customWrapper, options); - - expect( - callbacksCalled(customCallbacks)({ - onLift: 1, - }), - ).toBe(true); - - customWrapper.unmount(); - }); - }); -}); diff --git a/test/unit/view/drag-handle/disabled-while-capturing.spec.js b/test/unit/view/drag-handle/disabled-while-capturing.spec.js deleted file mode 100644 index 0ccfcb849f..0000000000 --- a/test/unit/view/drag-handle/disabled-while-capturing.spec.js +++ /dev/null @@ -1,128 +0,0 @@ -// @flow -import type { ReactWrapper } from 'enzyme'; -import { forEach, type Control } from './util/controls'; -import { getWrapper } from './util/wrappers'; -import { getStubCallbacks, callbacksCalled } from './util/callbacks'; -import type { Callbacks } from '../../../../src/view/use-drag-handle/drag-handle-types'; - -const expectMidDragDisabledWarning = (fn: Function) => { - // arrange - jest.spyOn(console, 'warn').mockImplementation(() => {}); - - // act - fn(); - - // assert - expect(console.warn).toHaveBeenCalled(); - - // cleanup - console.warn.mockRestore(); -}; - -forEach((control: Control) => { - it('should abort a pending drag', () => { - // not relevant for control - if (!control.hasPreLift) { - expect(true).toBeTruthy(); - return; - } - - const callbacks: Callbacks = getStubCallbacks(); - const wrapper: ReactWrapper<*> = getWrapper(callbacks); - - control.preLift(wrapper); - - wrapper.setProps({ isEnabled: false }); - - control.lift(wrapper); - expect( - callbacksCalled(callbacks)({ - onLift: 0, - }), - ).toBe(true); - }); - - it('should cancel an existing drag', () => { - const callbacks: Callbacks = getStubCallbacks(); - const wrapper: ReactWrapper<*> = getWrapper(callbacks); - - control.preLift(wrapper); - control.lift(wrapper); - - expect( - callbacksCalled(callbacks)({ - onLift: 1, - }), - ).toBe(true); - - expectMidDragDisabledWarning(() => { - wrapper.setProps({ isEnabled: false }); - }); - - expect( - callbacksCalled(callbacks)({ - onLift: 1, - onCancel: 1, - }), - ).toBe(true); - }); - - it('should stop publishing movements', () => { - const callbacks: Callbacks = getStubCallbacks(); - const wrapper: ReactWrapper<*> = getWrapper(callbacks); - - control.preLift(wrapper); - control.lift(wrapper); - - expectMidDragDisabledWarning(() => { - wrapper.setProps({ isEnabled: false }); - }); - - expect( - callbacksCalled(callbacks)({ - onLift: 1, - onCancel: 1, - }), - ).toBe(true); - - control.move(wrapper); - - expect( - callbacksCalled(callbacks)({ - onLift: 1, - onCancel: 1, - }), - ).toBe(true); - }); - - it('should allow subsequent drags', () => { - const callbacks: Callbacks = getStubCallbacks(); - const wrapper: ReactWrapper<*> = getWrapper(callbacks); - - control.preLift(wrapper); - control.lift(wrapper); - - expectMidDragDisabledWarning(() => { - wrapper.setProps({ isEnabled: false }); - }); - - expect( - callbacksCalled(callbacks)({ - onLift: 1, - onCancel: 1, - }), - ).toBe(true); - - wrapper.setProps({ isEnabled: true }); - - control.preLift(wrapper); - control.lift(wrapper); - - expect( - callbacksCalled(callbacks)({ - onLift: 2, - onCancel: 1, - }), - ).toBe(true); - }); -}); diff --git a/test/unit/view/drag-handle/disabled.spec.js b/test/unit/view/drag-handle/disabled.spec.js deleted file mode 100644 index 18af4622c7..0000000000 --- a/test/unit/view/drag-handle/disabled.spec.js +++ /dev/null @@ -1,11 +0,0 @@ -// @flow -import type { ReactWrapper } from 'enzyme'; -import { getWrapper, Child } from './util/wrappers'; -import { getStubCallbacks } from './util/callbacks'; - -it('should not pass any handleProps to the child', () => { - const wrapper: ReactWrapper<*> = getWrapper(getStubCallbacks()); - wrapper.setProps({ isEnabled: false }); - - expect(wrapper.find(Child).props().dragHandleProps).toBe(null); -}); diff --git a/test/unit/view/drag-handle/focus-management.spec.js b/test/unit/view/drag-handle/focus-management.spec.js deleted file mode 100644 index b1dacbce37..0000000000 --- a/test/unit/view/drag-handle/focus-management.spec.js +++ /dev/null @@ -1,439 +0,0 @@ -// @flow -import React, { type Node } from 'react'; -import invariant from 'tiny-invariant'; -import ReactDOM from 'react-dom'; -import { Simulate } from 'react-dom/test-utils'; -import { mount } from 'enzyme'; -import type { ReactWrapper } from 'enzyme'; -import type { DragHandleProps } from '../../../../src/view/use-drag-handle/drag-handle-types'; -import { getStubCallbacks } from './util/callbacks'; -import { WithDragHandle } from './util/wrappers'; -import { getMarshalStub } from '../../../utils/dimension-marshal'; -import forceUpdate from '../../../utils/force-update'; -import AppContext, { - type AppContextValue, -} from '../../../../src/view/context/app-context'; -import createRef from '../../../utils/create-ref'; - -const body: ?HTMLElement = document.body; -invariant(body, 'Cannot find body'); - -const appContext: AppContextValue = { - marshal: getMarshalStub(), - style: 'fake style context', - canLift: () => true, - isMovementAllowed: () => true, -}; - -describe('Portal usage (ref changing while mounted)', () => { - type ChildProps = {| - dragHandleProps: ?DragHandleProps, - usePortal: boolean, - setRef: (ref: ?HTMLElement) => void, - |}; - class Child extends React.Component { - // eslint-disable-next-line react/sort-comp - portal: ?HTMLElement; - - componentDidMount() { - this.portal = document.createElement('div'); - body.appendChild(this.portal); - } - - componentWillUnmount() { - if (!this.portal) { - return; - } - body.removeChild(this.portal); - this.portal = null; - } - - render() { - const child: Node = ( -
- Drag me! -
- ); - - if (this.portal && this.props.usePortal) { - return ReactDOM.createPortal(child, this.portal); - } - - return child; - } - } - class WithParentRefAndPortalChild extends React.Component<{ - usePortal: boolean, - }> { - // eslint-disable-next-line react/sort-comp - ref: ?HTMLElement; - - setRef = (ref: ?HTMLElement) => { - this.ref = ref; - }; - - render() { - return ( - - this.ref} - canDragInteractiveElements={false} - getShouldRespectForcePress={() => true} - > - {(dragHandleProps: ?DragHandleProps) => ( - - )} - - - ); - } - } - - it('should not try to retain focus if the ref has not changed', () => { - const wrapper = mount(); - - const original: HTMLElement = wrapper.find('.drag-handle').getDOMNode(); - expect(original).not.toBe(document.activeElement); - - // giving it focus - original.focus(); - wrapper.simulate('focus'); - expect(original).toBe(document.activeElement); - jest.spyOn(original, 'focus'); - - // force render to run focus detection tests - forceUpdate(wrapper); - - // element still has focus - expect(original).toBe(document.activeElement); - // should not manually call focus on element if there is no change - expect(original.focus).not.toHaveBeenCalled(); - - original.focus.mockRestore(); - }); - - it('should retain focus if draggable ref is changing and had focus', () => { - const wrapper = mount(); - - const original: HTMLElement = wrapper.find('.drag-handle').getDOMNode(); - expect(original).not.toBe(document.activeElement); - - // giving it focus - original.focus(); - wrapper.simulate('focus'); - expect(original).toBe(document.activeElement); - - // forcing change of parent ref by moving into a portal - wrapper.setProps({ - usePortal: true, - }); - - const inPortal: HTMLElement = wrapper.find('.drag-handle').getDOMNode(); - expect(inPortal).toBe(document.activeElement); - expect(inPortal).not.toBe(original); - expect(original).not.toBe(document.activeElement); - }); - - it('should not retain focus if draggable ref is changing and did not have focus', () => { - const wrapper = mount(); - - const original: HTMLElement = wrapper.getDOMNode(); - expect(original).not.toBe(document.activeElement); - - // forcing change of parent ref by moving into a portal - wrapper.setProps({ - usePortal: true, - }); - - const inPortal: HTMLElement = wrapper.getDOMNode(); - expect(inPortal).not.toBe(document.activeElement); - expect(inPortal).not.toBe(original); - expect(original).not.toBe(document.activeElement); - }); -}); - -describe('Focus retention moving between lists (focus retention between mounts)', () => { - type WithParentRefProps = {| - draggableId?: string, - isDragging?: boolean, - isDropAnimating?: boolean, - |}; - class WithParentRef extends React.Component { - // eslint-disable-next-line react/sort-comp - ref: ?HTMLElement; - - static defaultProps = { - draggableId: 'draggable', - isDragging: false, - isDropAnimating: false, - }; - - setRef = (ref: ?HTMLElement) => { - this.ref = ref; - }; - - render() { - return ( - - this.ref} - canDragInteractiveElements={false} - getShouldRespectForcePress={() => true} - > - {(dragHandleProps: ?DragHandleProps) => ( -
- Drag me! -
- )} -
-
- ); - } - } - - it('should maintain focus if unmounting while dragging', () => { - const first: ReactWrapper<*> = mount(); - const original: HTMLElement = first.getDOMNode(); - expect(original).not.toBe(document.activeElement); - - // get focus - original.focus(); - first.find('.drag-handle').simulate('focus'); - expect(original).toBe(document.activeElement); - - first.unmount(); - - const second: ReactWrapper<*> = mount(); - const latest: HTMLElement = second.getDOMNode(); - expect(latest).toBe(document.activeElement); - // validation - expect(latest).not.toBe(original); - expect(original).not.toBe(document.activeElement); - - // cleanup - second.unmount(); - }); - - it('should maintain focus if unmounting while drop animating', () => { - const first: ReactWrapper<*> = mount(); - const original: HTMLElement = first.getDOMNode(); - expect(original).not.toBe(document.activeElement); - - // get focus - original.focus(); - first.find('.drag-handle').simulate('focus'); - expect(original).toBe(document.activeElement); - - first.unmount(); - - const second: ReactWrapper<*> = mount(); - const latest: HTMLElement = second.getDOMNode(); - expect(latest).toBe(document.activeElement); - // validation - expect(latest).not.toBe(original); - expect(original).not.toBe(document.activeElement); - - // cleanup - second.unmount(); - }); - - // This interaction has nothing to do with us! - it('should not maintain focus if the item was not dragging or drop animating', () => { - const first: ReactWrapper<*> = mount( - , - ); - const original: HTMLElement = first.getDOMNode(); - expect(original).not.toBe(document.activeElement); - - // get focus - original.focus(); - first.find('.drag-handle').simulate('focus'); - expect(original).toBe(document.activeElement); - - first.unmount(); - - // will not get focus as it was not previously dragging or drop animating - const second: ReactWrapper<*> = mount(); - const latest: HTMLElement = second.getDOMNode(); - expect(latest).not.toBe(document.activeElement); - // validation - expect(latest).not.toBe(original); - expect(original).not.toBe(document.activeElement); - }); - - it('should not give focus to something that was not previously focused', () => { - const first: ReactWrapper<*> = mount(); - const original: HTMLElement = first.getDOMNode(); - - expect(original).not.toBe(document.activeElement); - first.unmount(); - - const second: ReactWrapper<*> = mount(); - const latest: HTMLElement = second.getDOMNode(); - expect(latest).not.toBe(document.activeElement); - // validation - expect(latest).not.toBe(original); - expect(original).not.toBe(document.activeElement); - - // cleanup - second.unmount(); - }); - - it('should maintain focus if another component is mounted before the focused component', () => { - const first: ReactWrapper<*> = mount( - , - ); - const original: HTMLElement = first.getDOMNode(); - expect(original).not.toBe(document.activeElement); - - // get focus - original.focus(); - first.find('.drag-handle').simulate('focus'); - expect(original).toBe(document.activeElement); - - // unmounting the first - first.unmount(); - - // mounting something with a different id - const other: ReactWrapper<*> = mount(); - expect(other.getDOMNode()).not.toBe(document.activeElement); - - // mounting something with the same id as the first - const second: ReactWrapper<*> = mount( - , - ); - const latest: HTMLElement = second.getDOMNode(); - expect(latest).toBe(document.activeElement); - - // cleanup - other.unmount(); - second.unmount(); - }); - - it('should only maintain focus once', () => { - const first: ReactWrapper<*> = mount(); - const original: HTMLElement = first.getDOMNode(); - expect(original).not.toBe(document.activeElement); - - // get focus - original.focus(); - first.find('.drag-handle').simulate('focus'); - expect(original).toBe(document.activeElement); - - first.unmount(); - - // obtaining focus on first remount - const second: ReactWrapper<*> = mount(); - const latest: HTMLElement = second.getDOMNode(); - expect(latest).toBe(document.activeElement); - // validation - expect(latest).not.toBe(original); - expect(original).not.toBe(document.activeElement); - - second.unmount(); - - // should not obtain focus on the second remount - const third: ReactWrapper<*> = mount(); - expect(third.getDOMNode()).not.toBe(document.activeElement); - - // cleanup - third.unmount(); - }); - - it('should not give focus if something else on the page has been focused on after unmount', () => { - // eslint-disable-next-line react/button-has-type - const button: HTMLElement = document.createElement('button'); - body.appendChild(button); - const first: ReactWrapper<*> = mount(); - const original: HTMLElement = first.getDOMNode(); - expect(original).not.toBe(document.activeElement); - - // get focus - original.focus(); - first.find('.drag-handle').simulate('focus'); - expect(original).toBe(document.activeElement); - - first.unmount(); - - // a button is focused on - button.focus(); - expect(button).toBe(document.activeElement); - - // remount should now not claim focus - const second: ReactWrapper<*> = mount(); - expect(second.getDOMNode()).not.toBe(document.activeElement); - // focus maintained on button - expect(button).toBe(document.activeElement); - }); - - it('should not steal focus from an element with auto focus on mount', () => { - function App() { - const ref = createRef(); - return ( - - true} - > - {(dragHandleProps: ?DragHandleProps) => ( -
- Drag me! - {/* autoFocus attribute does give focus, but does not trigger onFocus callback */} -