From 7e1cbdfb5250e83cd6e44753bf8005fe9f9a490f Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Thu, 4 Apr 2019 11:25:30 +1100 Subject: [PATCH] =?UTF-8?q?Hooks=20=F0=9F=8E=A3=20(#1208)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #871 --- .eslintrc.js | 14 + .size-snapshot.json | 24 +- .../integration/move-between-lists.spec.js | 2 +- cypress/integration/reorder-lists.spec.js | 2 +- cypress/integration/reorder.spec.js | 4 +- jest.config.js | 1 + package.json | 10 +- rollup.config.js | 15 +- src/debug/middleware/log.js | 1 + src/index.js | 8 +- src/state/action-creators.js | 12 +- src/state/create-store.js | 18 +- .../dimension-marshal-types.js | 5 - .../dimension-marshal/dimension-marshal.js | 26 -- src/state/middleware/auto-scroll.js | 14 +- .../middleware/dimension-marshal-stopper.js | 7 +- src/state/middleware/lift.js | 4 +- src/state/middleware/style.js | 2 +- src/state/reducer.js | 5 +- src/view/animate-in-out/animate-in-out.jsx | 4 + src/view/announcer/announcer-types.js | 9 - src/view/check-is-valid-inner-ref.js | 15 + src/view/context-keys.js | 11 - src/view/context/app-context.js | 13 + src/view/context/droppable-context.js | 10 + src/view/context/store-context.js | 5 + src/view/drag-drop-context/app.jsx | 174 +++++++ .../drag-drop-context/drag-drop-context.jsx | 195 +------- .../use-startup-validation.js | 17 + src/view/drag-handle/drag-handle.jsx | 268 ----------- src/view/drag-handle/index.js | 2 - .../sensor/create-keyboard-sensor.js | 232 ---------- .../drag-handle/sensor/create-mouse-sensor.js | 342 -------------- .../drag-handle/sensor/create-touch-sensor.js | 431 ----------------- .../draggable-dimension-publisher.jsx | 150 ------ .../draggable-dimension-publisher/index.js | 2 - src/view/draggable/connected-draggable.js | 9 +- src/view/draggable/draggable-types.js | 2 +- src/view/draggable/draggable.jsx | 367 ++++++++------- src/view/draggable/use-validation.js | 32 ++ .../droppable-dimension-publisher.jsx | 352 -------------- .../droppable-dimension-publisher/index.js | 2 - src/view/droppable/check-own-props.js | 19 - src/view/droppable/connected-droppable.js | 15 +- src/view/droppable/droppable.jsx | 294 +++++------- src/view/droppable/use-validation.js | 62 +++ src/view/error-boundary/error-boundary.jsx | 21 +- src/view/placeholder/placeholder.jsx | 247 +++++----- src/view/style-marshal/style-marshal.js | 97 ---- src/view/use-announcer/index.js | 2 + .../use-announcer.js} | 80 ++-- .../drag-handle-types.js | 6 +- src/view/use-drag-handle/index.js | 2 + .../sensor/sensor-types.js | 4 +- .../sensor/use-keyboard-sensor.js | 263 +++++++++++ .../sensor/use-mouse-sensor.js | 372 +++++++++++++++ .../sensor/use-touch-sensor.js | 434 ++++++++++++++++++ src/view/use-drag-handle/use-drag-handle.js | 236 ++++++++++ .../use-drag-handle/use-focus-retainer.js | 94 ++++ src/view/use-drag-handle/use-validation.js | 16 + .../util/bind-events.js | 0 .../util/create-event-marshal.js | 0 .../util/create-post-drag-event-preventer.js | 0 .../util/create-scheduler.js | 0 .../util/event-types.js | 0 .../util/focus-retainer.js | 0 .../util/get-drag-handle-ref.js | 0 .../is-sloppy-click-threshold-exceeded.js | 0 .../util/prevent-standard-key-events.js | 0 .../util/should-allow-dragging-from-target.js | 5 +- .../supported-page-visibility-event-name.js | 0 .../get-dimension.js | 44 ++ .../index.js | 2 + .../use-draggable-dimension-publisher.js | 78 ++++ .../check-for-nested-scroll-container.js | 0 .../get-closest-scrollable.js | 0 .../get-dimension.js | 0 .../get-env.js | 0 .../get-listener-options.js | 12 + .../get-scroll.js | 0 .../index.js | 2 + .../is-in-fixed-container.js | 0 .../use-droppable-dimension-publisher.js | 279 +++++++++++ .../without-placeholder.js | 18 + src/view/use-isomorphic-layout-effect.js | 13 + src/view/use-previous-ref.js | 16 + src/view/use-required-context.js | 9 + .../get-styles.js | 4 +- src/view/use-style-marshal/index.js | 2 + .../style-marshal-types.js | 2 - .../use-style-marshal/use-style-marshal.js | 126 +++++ stories/11-portal.stories.js | 2 +- stories/src/portal/portal-app.jsx | 1 + test/.eslintrc.js | 3 + .../drop-dev-warnings-for-prod.spec.js | 12 +- .../responders-integration.spec.js | 60 ++- .../server-rendering.spec.js.snap | 2 +- .../client-hydration.spec.js | 12 + .../server-rendering.spec.js | 22 + .../unit/state/middleware/auto-scroll.spec.js | 6 +- .../dimension-marshal-stopper.spec.js | 10 +- test/unit/state/middleware/lift.spec.js | 24 +- test/unit/state/middleware/style.spec.js | 4 +- test/unit/view/announcer.spec.js | 143 +++--- .../child-render-behaviour.spec.js | 46 +- .../child-render-behaviour.spec.js | 35 +- .../dimension-marshal/initial-publish.spec.js | 4 +- test/unit/view/drag-drop-context/app.jsx | 21 - .../clashing-with-consumers-redux.spec.js | 107 +++++ .../reset-server-context.spec.js | 42 +- .../store-management.spec.js | 154 ------- .../view/drag-drop-context/unmount.spec.js | 9 +- test/unit/view/drag-handle/attributes.spec.js | 5 +- ...start-when-something-else-dragging.spec.js | 37 +- .../view/drag-handle/contenteditable.spec.js | 262 ++++++----- .../disabled-while-capturing.spec.js | 2 +- .../view/drag-handle/focus-management.spec.js | 181 ++++---- .../drag-handle/interactive-elements.spec.js | 8 +- .../view/drag-handle/keyboard-sensor.spec.js | 16 +- .../view/drag-handle/mouse-sensor.spec.js | 36 +- .../drag-handle/nested-drag-handles.spec.js | 88 ++-- .../view/drag-handle/throw-if-svg.spec.js | 47 +- .../view/drag-handle/touch-sensor.spec.js | 42 +- .../unit/view/drag-handle/util/app-context.js | 12 + .../view/drag-handle/util/basic-context.js | 9 - test/unit/view/drag-handle/util/callbacks.js | 2 +- test/unit/view/drag-handle/util/controls.js | 41 +- test/unit/view/drag-handle/util/wrappers.js | 77 ++-- .../view/drag-handle/window-bindings.spec.js | 9 +- .../draggable/drag-handle-connection.spec.js | 230 +--------- test/unit/view/draggable/mounting.spec.js | 10 +- test/unit/view/draggable/util/mount.js | 72 ++- .../forced-scroll.spec.js | 45 +- .../is-combined-enabled-change.spec.js | 36 +- .../is-element-scrollable.spec.js | 2 +- .../is-enabled-change.spec.js | 36 +- .../publishing.spec.js | 98 ++-- .../recollection.spec.js | 38 +- .../registration.spec.js | 78 ++-- .../scroll-watching.spec.js | 123 +++-- .../util/shared.js | 252 +++++----- .../home-list-placeholder-cleanup.spec.js | 24 +- test/unit/view/droppable/placeholder.spec.js | 15 +- .../update-max-window-scroll.spec.js | 7 +- test/unit/view/droppable/util/mount.js | 64 ++- .../view/placeholder/animated-mount.spec.js | 52 ++- test/unit/view/placeholder/on-close.spec.js | 6 +- .../placeholder/on-transition-end.spec.js | 9 +- .../util/placeholder-with-class.js | 12 + .../view/style-marshal/get-styles.spec.js | 2 +- .../view/style-marshal/style-marshal.spec.js | 204 ++++---- ...use-draggable-dimension-publisher.spec.js} | 142 +++--- test/utils/create-ref.js | 12 + test/utils/dimension-marshal.js | 1 - test/utils/get-context-options.js | 130 ------ test/utils/pass-through-props.jsx | 12 + yarn.lock | 32 +- 157 files changed, 4635 insertions(+), 4396 deletions(-) delete mode 100644 src/view/announcer/announcer-types.js create mode 100644 src/view/check-is-valid-inner-ref.js delete mode 100644 src/view/context-keys.js create mode 100644 src/view/context/app-context.js create mode 100644 src/view/context/droppable-context.js create mode 100644 src/view/context/store-context.js create mode 100644 src/view/drag-drop-context/app.jsx create mode 100644 src/view/drag-drop-context/use-startup-validation.js delete mode 100644 src/view/drag-handle/drag-handle.jsx delete mode 100644 src/view/drag-handle/index.js delete mode 100644 src/view/drag-handle/sensor/create-keyboard-sensor.js delete mode 100644 src/view/drag-handle/sensor/create-mouse-sensor.js delete mode 100644 src/view/drag-handle/sensor/create-touch-sensor.js delete mode 100644 src/view/draggable-dimension-publisher/draggable-dimension-publisher.jsx delete mode 100644 src/view/draggable-dimension-publisher/index.js create mode 100644 src/view/draggable/use-validation.js delete mode 100644 src/view/droppable-dimension-publisher/droppable-dimension-publisher.jsx delete mode 100644 src/view/droppable-dimension-publisher/index.js delete mode 100644 src/view/droppable/check-own-props.js create mode 100644 src/view/droppable/use-validation.js delete mode 100644 src/view/style-marshal/style-marshal.js create mode 100644 src/view/use-announcer/index.js rename src/view/{announcer/announcer.js => use-announcer/use-announcer.js} (60%) rename src/view/{drag-handle => use-drag-handle}/drag-handle-types.js (94%) create mode 100644 src/view/use-drag-handle/index.js rename src/view/{drag-handle => use-drag-handle}/sensor/sensor-types.js (90%) create mode 100644 src/view/use-drag-handle/sensor/use-keyboard-sensor.js create mode 100644 src/view/use-drag-handle/sensor/use-mouse-sensor.js create mode 100644 src/view/use-drag-handle/sensor/use-touch-sensor.js create mode 100644 src/view/use-drag-handle/use-drag-handle.js create mode 100644 src/view/use-drag-handle/use-focus-retainer.js create mode 100644 src/view/use-drag-handle/use-validation.js rename src/view/{drag-handle => use-drag-handle}/util/bind-events.js (100%) rename src/view/{drag-handle => use-drag-handle}/util/create-event-marshal.js (100%) rename src/view/{drag-handle => use-drag-handle}/util/create-post-drag-event-preventer.js (100%) rename src/view/{drag-handle => use-drag-handle}/util/create-scheduler.js (100%) rename src/view/{drag-handle => use-drag-handle}/util/event-types.js (100%) rename src/view/{drag-handle => use-drag-handle}/util/focus-retainer.js (100%) rename src/view/{drag-handle => use-drag-handle}/util/get-drag-handle-ref.js (100%) rename src/view/{drag-handle => use-drag-handle}/util/is-sloppy-click-threshold-exceeded.js (100%) rename src/view/{drag-handle => use-drag-handle}/util/prevent-standard-key-events.js (100%) rename src/view/{drag-handle => use-drag-handle}/util/should-allow-dragging-from-target.js (92%) rename src/view/{drag-handle => use-drag-handle}/util/supported-page-visibility-event-name.js (100%) create mode 100644 src/view/use-draggable-dimension-publisher/get-dimension.js create mode 100644 src/view/use-draggable-dimension-publisher/index.js create mode 100644 src/view/use-draggable-dimension-publisher/use-draggable-dimension-publisher.js rename src/view/{droppable-dimension-publisher => use-droppable-dimension-publisher}/check-for-nested-scroll-container.js (100%) rename src/view/{droppable-dimension-publisher => use-droppable-dimension-publisher}/get-closest-scrollable.js (100%) rename src/view/{droppable-dimension-publisher => use-droppable-dimension-publisher}/get-dimension.js (100%) rename src/view/{droppable-dimension-publisher => use-droppable-dimension-publisher}/get-env.js (100%) create mode 100644 src/view/use-droppable-dimension-publisher/get-listener-options.js rename src/view/{droppable-dimension-publisher => use-droppable-dimension-publisher}/get-scroll.js (100%) create mode 100644 src/view/use-droppable-dimension-publisher/index.js rename src/view/{droppable-dimension-publisher => use-droppable-dimension-publisher}/is-in-fixed-container.js (100%) create mode 100644 src/view/use-droppable-dimension-publisher/use-droppable-dimension-publisher.js create mode 100644 src/view/use-droppable-dimension-publisher/without-placeholder.js create mode 100644 src/view/use-isomorphic-layout-effect.js create mode 100644 src/view/use-previous-ref.js create mode 100644 src/view/use-required-context.js rename src/view/{style-marshal => use-style-marshal}/get-styles.js (97%) create mode 100644 src/view/use-style-marshal/index.js rename src/view/{style-marshal => use-style-marshal}/style-marshal-types.js (82%) create mode 100644 src/view/use-style-marshal/use-style-marshal.js delete mode 100644 test/unit/view/drag-drop-context/app.jsx create mode 100644 test/unit/view/drag-drop-context/clashing-with-consumers-redux.spec.js delete mode 100644 test/unit/view/drag-drop-context/store-management.spec.js create mode 100644 test/unit/view/drag-handle/util/app-context.js delete mode 100644 test/unit/view/drag-handle/util/basic-context.js create mode 100644 test/unit/view/placeholder/util/placeholder-with-class.js rename test/unit/view/{draggable-dimension-publisher.spec.js => use-draggable-dimension-publisher.spec.js} (72%) create mode 100644 test/utils/create-ref.js delete mode 100644 test/utils/get-context-options.js create mode 100644 test/utils/pass-through-props.jsx diff --git a/.eslintrc.js b/.eslintrc.js index 092fc9541c..9e1296c79d 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -64,6 +64,20 @@ module.exports = { }, ], + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + name: 'react', + importNames: ['useMemo', 'useCallback'], + message: + 'useMemo and useCallback are subject to cache busting. Please use useMemoOne and useCallbackOne', + }, + ], + }, + ], + // Allowing jsx in files with any file extension (old components have jsx but not the extension) 'react/jsx-filename-extension': 'off', diff --git a/.size-snapshot.json b/.size-snapshot.json index 227fc07505..cb6057e8e2 100644 --- a/.size-snapshot.json +++ b/.size-snapshot.json @@ -1,25 +1,25 @@ { "dist/react-beautiful-dnd.js": { - "bundled": 357308, - "minified": 138768, - "gzipped": 40712 + "bundled": 392153, + "minified": 146738, + "gzipped": 41191 }, "dist/react-beautiful-dnd.min.js": { - "bundled": 303784, - "minified": 113686, - "gzipped": 32910 + "bundled": 324706, + "minified": 116369, + "gzipped": 33451 }, "dist/react-beautiful-dnd.esm.js": { - "bundled": 237952, - "minified": 125718, - "gzipped": 31356, + "bundled": 237886, + "minified": 123357, + "gzipped": 31310, "treeshaked": { "rollup": { - "code": 85891, - "import_statements": 832 + "code": 30267, + "import_statements": 799 }, "webpack": { - "code": 88596 + "code": 34399 } } } diff --git a/cypress/integration/move-between-lists.spec.js b/cypress/integration/move-between-lists.spec.js index b5971bb034..9571355359 100644 --- a/cypress/integration/move-between-lists.spec.js +++ b/cypress/integration/move-between-lists.spec.js @@ -3,7 +3,7 @@ import * as keyCodes from '../../src/view/key-codes'; import { timings } from '../../src/animation'; beforeEach(() => { - cy.visit('/iframe.html?selectedKind=board&selectedStory=simple'); + cy.visit('/iframe.html?id=board--simple'); }); it('should move between lists', () => { diff --git a/cypress/integration/reorder-lists.spec.js b/cypress/integration/reorder-lists.spec.js index 7e27edd128..bfe2017fd0 100644 --- a/cypress/integration/reorder-lists.spec.js +++ b/cypress/integration/reorder-lists.spec.js @@ -3,7 +3,7 @@ import * as keyCodes from '../../src/view/key-codes'; import { timings } from '../../src/animation'; beforeEach(() => { - cy.visit('/iframe.html?selectedKind=board&selectedStory=simple'); + cy.visit('/iframe.html?id=board--simple'); }); it('should reorder lists', () => { diff --git a/cypress/integration/reorder.spec.js b/cypress/integration/reorder.spec.js index 372b02b282..6e3f7dba4d 100644 --- a/cypress/integration/reorder.spec.js +++ b/cypress/integration/reorder.spec.js @@ -3,9 +3,7 @@ import * as keyCodes from '../../src/view/key-codes'; import { timings } from '../../src/animation'; beforeEach(() => { - cy.visit( - '/iframe.html?selectedKind=single%20vertical%20list&selectedStory=basic', - ); + cy.visit('/iframe.html?id=single-vertical-list--basic'); }); it('should reorder a list', () => { diff --git a/jest.config.js b/jest.config.js index d1fe8423c8..33eeb2a32a 100644 --- a/jest.config.js +++ b/jest.config.js @@ -14,4 +14,5 @@ module.exports = { 'jest-watch-typeahead/filename', 'jest-watch-typeahead/testname', ], + verbose: true, }; diff --git a/package.json b/package.json index 98c577aa93..c99ae2ff67 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-beautiful-dnd", - "version": "10.1.1", + "version": "11.0.0-beta", "description": "Beautiful and accessible drag and drop for lists with React", "author": "Alex Reardon ", "keywords": [ @@ -57,11 +57,11 @@ "@babel/runtime-corejs2": "^7.4.2", "css-box-model": "^1.1.1", "memoize-one": "^5.0.1", - "prop-types": "^15.6.1", "raf-schd": "^4.0.0", - "react-redux": "^5.0.7", + "react-redux": "7.0.0-beta.0", "redux": "^4.0.1", - "tiny-invariant": "^1.0.4" + "tiny-invariant": "^1.0.4", + "use-memo-one": "^1.0.0" }, "devDependencies": { "@atlaskit/css-reset": "^3.0.6", @@ -134,7 +134,7 @@ "webpack": "^4.29.6" }, "peerDependencies": { - "react": "^16.3.1" + "react": "^16.8.0" }, "license": "Apache-2.0", "jest-junit": { diff --git a/rollup.config.js b/rollup.config.js index d7b59f0a1a..56e215e822 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -33,10 +33,13 @@ const snapshotArgs = const commonjsArgs = { include: 'node_modules/**', - // needed for react-is via react-redux v5.1 + // needed for react-is via react-redux // https://stackoverflow.com/questions/50080893/rollup-error-isvalidelementtype-is-not-exported-by-node-modules-react-is-inde/50098540 namedExports: { - 'node_modules/react-is/index.js': ['isValidElementType'], + 'node_modules/react-redux/node_modules/react-is/index.js': [ + 'isValidElementType', + 'isContextConsumer', + ], }, }; @@ -50,10 +53,10 @@ export default [ file: 'dist/react-beautiful-dnd.js', format: 'umd', name: 'ReactBeautifulDnd', - globals: { react: 'React' }, + globals: { react: 'React', 'react-dom': 'ReactDOM' }, }, // Only deep dependency required is React - external: ['react'], + external: ['react', 'react-dom'], plugins: [ json(), babel(getBabelOptions({ useESModules: true })), @@ -71,10 +74,10 @@ export default [ file: 'dist/react-beautiful-dnd.min.js', format: 'umd', name: 'ReactBeautifulDnd', - globals: { react: 'React' }, + globals: { react: 'React', 'react-dom': 'ReactDOM' }, }, // Only deep dependency required is React - external: ['react'], + external: ['react', 'react-dom'], plugins: [ json(), babel(getBabelOptions({ useESModules: true })), diff --git a/src/debug/middleware/log.js b/src/debug/middleware/log.js index 2c9aa69118..b187df0fbf 100644 --- a/src/debug/middleware/log.js +++ b/src/debug/middleware/log.js @@ -6,6 +6,7 @@ export default (store: Store) => (next: Action => mixed) => ( action: Action, ): any => { 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/index.js b/src/index.js index b103d1b7fe..30d1f6a912 100644 --- a/src/index.js +++ b/src/index.js @@ -30,14 +30,14 @@ export type { OnDragEndResponder, } from './types'; -// Droppable +// Droppable types export type { Provided as DroppableProvided, StateSnapshot as DroppableStateSnapshot, DroppableProps, } from './view/droppable/droppable-types'; -// Draggable +// Draggable types export type { Provided as DraggableProvided, StateSnapshot as DraggableStateSnapshot, @@ -48,5 +48,5 @@ export type { NotDraggingStyle, } from './view/draggable/draggable-types'; -// DragHandle -export type { DragHandleProps } from './view/drag-handle/drag-handle-types'; +// DragHandle types +export type { DragHandleProps } from './view/use-drag-handle/drag-handle-types'; diff --git a/src/state/action-creators.js b/src/state/action-creators.js index fcdb4d529b..e5e7d19437 100644 --- a/src/state/action-creators.js +++ b/src/state/action-creators.js @@ -229,14 +229,20 @@ export const moveLeft = (): MoveLeftAction => ({ payload: null, }); +type CleanActionArgs = {| + shouldFlush: boolean, +|}; + type CleanAction = {| type: 'CLEAN', - payload: null, + payload: CleanActionArgs, |}; -export const clean = (): CleanAction => ({ +export const clean = ( + args?: CleanActionArgs = { shouldFlush: false }, +): CleanAction => ({ type: 'CLEAN', - payload: null, + payload: args, }); export type AnimateDropArgs = {| diff --git a/src/state/create-store.js b/src/state/create-store.js index 6555251da6..231c831db8 100644 --- a/src/state/create-store.js +++ b/src/state/create-store.js @@ -11,7 +11,7 @@ import dimensionMarshalStopper from './middleware/dimension-marshal-stopper'; import autoScroll from './middleware/auto-scroll'; import pendingDrop from './middleware/pending-drop'; import type { DimensionMarshal } from './dimension-marshal/dimension-marshal-types'; -import type { StyleMarshal } from '../view/style-marshal/style-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'; import type { Store } from './store-types'; @@ -27,19 +27,19 @@ const composeEnhancers = : compose; type Args = {| - getDimensionMarshal: () => DimensionMarshal, + dimensionMarshal: DimensionMarshal, styleMarshal: StyleMarshal, getResponders: () => Responders, announce: Announce, - getScroller: () => AutoScroller, + autoScroller: AutoScroller, |}; export default ({ - getDimensionMarshal, + dimensionMarshal, styleMarshal, getResponders, announce, - getScroller, + autoScroller, }: Args): Store => createStore( reducer, @@ -50,7 +50,7 @@ export default ({ // > uncomment to use // debugging logger // require('../debug/middleware/log').default, - // user timing api + // // user timing api // require('../debug/middleware/user-timing').default, // debugging timer // require('../debug/middleware/action-timing').default, @@ -71,14 +71,14 @@ export default ({ // when moving into a phase where collection is no longer needed. // We need to stop the marshal before responders fire as responders can cause // dimension registration changes in response to reordering - dimensionMarshalStopper(getDimensionMarshal), + dimensionMarshalStopper(dimensionMarshal), // Fire application responders in response to drag changes - lift(getDimensionMarshal), + lift(dimensionMarshal), drop, // When a drop animation finishes - fire a drop complete dropAnimationFinish, pendingDrop, - autoScroll(getScroller), + autoScroll(autoScroller), // Fire responders for consumers (after update to store) responders(getResponders, announce), ), diff --git a/src/state/dimension-marshal/dimension-marshal-types.js b/src/state/dimension-marshal/dimension-marshal-types.js index 4e08767d22..2df5ce7471 100644 --- a/src/state/dimension-marshal/dimension-marshal-types.js +++ b/src/state/dimension-marshal/dimension-marshal-types.js @@ -90,11 +90,6 @@ export type DimensionMarshal = {| descriptor: DroppableDescriptor, callbacks: DroppableCallbacks, ) => void, - updateDroppable: ( - previous: DroppableDescriptor, - descriptor: DroppableDescriptor, - callbacks: DroppableCallbacks, - ) => void, // it is possible for a droppable to change whether it is enabled during a drag updateDroppableIsEnabled: (id: DroppableId, isEnabled: boolean) => void, // it is also possible to update whether combining is enabled diff --git a/src/state/dimension-marshal/dimension-marshal.js b/src/state/dimension-marshal/dimension-marshal.js index 85e47d0ce7..e749000684 100644 --- a/src/state/dimension-marshal/dimension-marshal.js +++ b/src/state/dimension-marshal/dimension-marshal.js @@ -164,31 +164,6 @@ export default (callbacks: Callbacks) => { invariant(!collection, 'Cannot add a Droppable during a drag'); }; - const updateDroppable = ( - previous: DroppableDescriptor, - descriptor: DroppableDescriptor, - droppableCallbacks: DroppableCallbacks, - ) => { - invariant( - entries.droppables[previous.id], - 'Cannot update droppable registration as no previous registration was found', - ); - - // The id might have changed, so we are removing the old entry - delete entries.droppables[previous.id]; - - const entry: DroppableEntry = { - descriptor, - callbacks: droppableCallbacks, - }; - entries.droppables[descriptor.id] = entry; - - invariant( - !collection, - 'You are not able to update the id or type of a droppable during a drag', - ); - }; - const unregisterDroppable = (descriptor: DroppableDescriptor) => { const entry: ?DroppableEntry = entries.droppables[descriptor.id]; @@ -328,7 +303,6 @@ export default (callbacks: Callbacks) => { updateDraggable, unregisterDraggable, registerDroppable, - updateDroppable, unregisterDroppable, // droppable changes diff --git a/src/state/middleware/auto-scroll.js b/src/state/middleware/auto-scroll.js index 048f2848b4..57123c1cc4 100644 --- a/src/state/middleware/auto-scroll.js +++ b/src/state/middleware/auto-scroll.js @@ -12,17 +12,17 @@ const shouldEnd = (action: Action): boolean => const shouldCancelPending = (action: Action): boolean => action.type === 'COLLECTION_STARTING'; -export default (getScroller: () => AutoScroller) => ( - store: MiddlewareStore, -) => (next: Dispatch) => (action: Action): any => { +export default (autoScroller: AutoScroller) => (store: MiddlewareStore) => ( + next: Dispatch, +) => (action: Action): any => { if (shouldEnd(action)) { - getScroller().stop(); + autoScroller.stop(); next(action); return; } if (shouldCancelPending(action)) { - getScroller().cancelPending(); + autoScroller.cancelPending(); next(action); return; } @@ -35,12 +35,12 @@ export default (getScroller: () => AutoScroller) => ( state.phase === 'DRAGGING', 'Expected phase to be DRAGGING after INITIAL_PUBLISH', ); - getScroller().start(state); + autoScroller.start(state); return; } // auto scroll happens in response to state changes // releasing all actions to the reducer first next(action); - getScroller().scroll(store.getState()); + autoScroller.scroll(store.getState()); }; diff --git a/src/state/middleware/dimension-marshal-stopper.js b/src/state/middleware/dimension-marshal-stopper.js index d2a39e9f87..1f6bc0a4f8 100644 --- a/src/state/middleware/dimension-marshal-stopper.js +++ b/src/state/middleware/dimension-marshal-stopper.js @@ -2,9 +2,9 @@ import type { Action, Dispatch } from '../store-types'; import type { DimensionMarshal } from '../dimension-marshal/dimension-marshal-types'; -export default (getMarshal: () => DimensionMarshal) => () => ( - next: Dispatch, -) => (action: Action): any => { +export default (marshal: DimensionMarshal) => () => (next: Dispatch) => ( + action: Action, +): any => { // Not stopping a collection on a 'DROP' as we want a collection to continue if ( // drag is finished @@ -13,7 +13,6 @@ export default (getMarshal: () => DimensionMarshal) => () => ( // no longer accepting changes once the drop has started action.type === 'DROP_ANIMATE' ) { - const marshal: DimensionMarshal = getMarshal(); marshal.stopPublishing(); } diff --git a/src/state/middleware/lift.js b/src/state/middleware/lift.js index 8db6baff3f..d9f3909036 100644 --- a/src/state/middleware/lift.js +++ b/src/state/middleware/lift.js @@ -5,7 +5,7 @@ import type { DimensionMarshal } from '../dimension-marshal/dimension-marshal-ty import type { State, ScrollOptions, LiftRequest } from '../../types'; import type { MiddlewareStore, Action, Dispatch } from '../store-types'; -export default (getMarshal: () => DimensionMarshal) => ({ +export default (marshal: DimensionMarshal) => ({ getState, dispatch, }: MiddlewareStore) => (next: Dispatch) => (action: Action): any => { @@ -13,8 +13,6 @@ export default (getMarshal: () => DimensionMarshal) => ({ next(action); return; } - - const marshal: DimensionMarshal = getMarshal(); const { id, clientSelection, movementMode } = action.payload; const initial: State = getState(); diff --git a/src/state/middleware/style.js b/src/state/middleware/style.js index 97c82aa3cf..2b9820cdd4 100644 --- a/src/state/middleware/style.js +++ b/src/state/middleware/style.js @@ -1,6 +1,6 @@ // @flow import type { Action, Dispatch } from '../store-types'; -import type { StyleMarshal } from '../../view/style-marshal/style-marshal-types'; +import type { StyleMarshal } from '../../view/use-style-marshal/style-marshal-types'; export default (marshal: StyleMarshal) => () => (next: Dispatch) => ( action: Action, diff --git a/src/state/reducer.js b/src/state/reducer.js index 0efb37e637..1c21f7416c 100644 --- a/src/state/reducer.js +++ b/src/state/reducer.js @@ -60,7 +60,10 @@ const idle: IdleState = { phase: 'IDLE', completed: null, shouldFlush: false }; export default (state: State = idle, action: Action): State => { if (action.type === 'CLEAN') { - return idle; + return { + ...idle, + shouldFlush: action.payload.shouldFlush, + }; } if (action.type === 'INITIAL_PUBLISH') { diff --git a/src/view/animate-in-out/animate-in-out.jsx b/src/view/animate-in-out/animate-in-out.jsx index 8c60a4a218..cb05e2eab1 100644 --- a/src/view/animate-in-out/animate-in-out.jsx +++ b/src/view/animate-in-out/animate-in-out.jsx @@ -20,6 +20,10 @@ type State = {| animate: InOutAnimationMode, |}; +// Using a class here rather than hooks because +// getDerivedStateFromProps results in far less renders. +// Using hooks to implement this was quite messy and resulted in lots of additional renders + export default class AnimateInOut extends React.PureComponent { state: State = { isVisible: Boolean(this.props.on), diff --git a/src/view/announcer/announcer-types.js b/src/view/announcer/announcer-types.js deleted file mode 100644 index 5f23f05208..0000000000 --- a/src/view/announcer/announcer-types.js +++ /dev/null @@ -1,9 +0,0 @@ -// @flow -import type { Announce } from '../../types'; - -export type Announcer = {| - announce: Announce, - id: string, - mount: () => void, - unmount: () => void, -|}; diff --git a/src/view/check-is-valid-inner-ref.js b/src/view/check-is-valid-inner-ref.js new file mode 100644 index 0000000000..ac54133dde --- /dev/null +++ b/src/view/check-is-valid-inner-ref.js @@ -0,0 +1,15 @@ +// @flow +import invariant from 'tiny-invariant'; +import isHtmlElement from './is-type-of-element/is-html-element'; + +export default function checkIsValidInnerRef(el: ?HTMLElement) { + invariant( + el && isHtmlElement(el), + ` + provided.innerRef has not been provided with a HTMLElement. + + You can find a guide on using the innerRef callback functions at: + https://github.com/atlassian/react-beautiful-dnd/blob/master/docs/guides/using-inner-ref.md + `, + ); +} diff --git a/src/view/context-keys.js b/src/view/context-keys.js deleted file mode 100644 index e5091d964f..0000000000 --- a/src/view/context-keys.js +++ /dev/null @@ -1,11 +0,0 @@ -// @flow -const prefix = (key: string): string => - `private-react-beautiful-dnd-key-do-not-use-${key}`; - -export const storeKey: string = prefix('store'); -export const droppableIdKey: string = prefix('droppable-id'); -export const droppableTypeKey: string = prefix('droppable-type'); -export const dimensionMarshalKey: string = prefix('dimension-marshal'); -export const styleKey: string = prefix('style'); -export const canLiftKey: string = prefix('can-lift'); -export const isMovementAllowedKey: string = prefix('is-movement-allowed'); diff --git a/src/view/context/app-context.js b/src/view/context/app-context.js new file mode 100644 index 0000000000..45dcf6dc7a --- /dev/null +++ b/src/view/context/app-context.js @@ -0,0 +1,13 @@ +// @flow +import React from 'react'; +import type { DraggableId } from '../../types'; +import type { DimensionMarshal } from '../../state/dimension-marshal/dimension-marshal-types'; + +export type AppContextValue = {| + marshal: DimensionMarshal, + style: string, + canLift: (id: DraggableId) => boolean, + isMovementAllowed: () => boolean, +|}; + +export default React.createContext(null); diff --git a/src/view/context/droppable-context.js b/src/view/context/droppable-context.js new file mode 100644 index 0000000000..a8e27227af --- /dev/null +++ b/src/view/context/droppable-context.js @@ -0,0 +1,10 @@ +// @flow +import React from 'react'; +import type { DroppableId, TypeId } from '../../types'; + +export type DroppableContextValue = {| + droppableId: DroppableId, + type: TypeId, +|}; + +export default React.createContext(null); diff --git a/src/view/context/store-context.js b/src/view/context/store-context.js new file mode 100644 index 0000000000..fc63ab137e --- /dev/null +++ b/src/view/context/store-context.js @@ -0,0 +1,5 @@ +// @flow +import React from 'react'; +import type { Store } from '../../state/store-types'; + +export default React.createContext(null); diff --git a/src/view/drag-drop-context/app.jsx b/src/view/drag-drop-context/app.jsx new file mode 100644 index 0000000000..d7f62bc83d --- /dev/null +++ b/src/view/drag-drop-context/app.jsx @@ -0,0 +1,174 @@ +// @flow +import React, { useEffect, useRef, type Node } from 'react'; +import { bindActionCreators } from 'redux'; +import { Provider } from 'react-redux'; +import { useMemoOne, useCallbackOne } from 'use-memo-one'; +import createStore from '../../state/create-store'; +import createDimensionMarshal from '../../state/dimension-marshal/dimension-marshal'; +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 type { AutoScroller } from '../../state/auto-scroller/auto-scroller-types'; +import type { StyleMarshal } from '../use-style-marshal/style-marshal-types'; +import type { + DimensionMarshal, + Callbacks as DimensionMarshalCallbacks, +} from '../../state/dimension-marshal/dimension-marshal-types'; +import type { DraggableId, State, Responders, Announce } from '../../types'; +import type { Store, Action } from '../../state/store-types'; +import StoreContext from '../context/store-context'; +import { + clean, + move, + publishWhileDragging, + updateDroppableScroll, + updateDroppableIsEnabled, + updateDroppableIsCombineEnabled, + collectionStarting, +} from '../../state/action-creators'; +import isMovementAllowed from '../../state/is-movement-allowed'; +import useAnnouncer from '../use-announcer'; +import AppContext, { type AppContextValue } from '../context/app-context'; +import useStartupValidation from './use-startup-validation'; +import useIsomorphicLayoutEffect from '../use-isomorphic-layout-effect'; +import usePrevious from '../use-previous-ref'; + +type Props = {| + ...Responders, + uniqueId: number, + setOnError: (onError: Function) => void, + // we do not technically need any children for this component + children: Node | null, +|}; + +const createResponders = (props: Props): Responders => ({ + onBeforeDragStart: props.onBeforeDragStart, + onDragStart: props.onDragStart, + onDragEnd: props.onDragEnd, + onDragUpdate: props.onDragUpdate, +}); + +export default function App(props: Props) { + const { uniqueId, setOnError } = props; + // flow does not support MutableRefObject + // let storeRef: MutableRefObject; + let storeRef; + + useStartupValidation(); + + // lazy collection of responders using a ref - update on ever render + const lastPropsRef = usePrevious(props); + + const getResponders: () => Responders = useCallbackOne(() => { + return createResponders(lastPropsRef.current); + }, []); + + const announce: Announce = useAnnouncer(uniqueId); + const styleMarshal: StyleMarshal = useStyleMarshal(uniqueId); + + const lazyDispatch: Action => void = useCallbackOne( + (action: Action): void => { + storeRef.current.dispatch(action); + }, + [], + ); + + const callbacks: DimensionMarshalCallbacks = useMemoOne( + () => + bindActionCreators( + { + publishWhileDragging, + updateDroppableScroll, + updateDroppableIsEnabled, + updateDroppableIsCombineEnabled, + collectionStarting, + }, + // $FlowFixMe - not sure why this is wrong + lazyDispatch, + ), + [], + ); + const dimensionMarshal: DimensionMarshal = useMemoOne( + () => createDimensionMarshal(callbacks), + [], + ); + + const autoScroller: AutoScroller = useMemoOne( + () => + createAutoScroller({ + scrollWindow, + scrollDroppable: dimensionMarshal.scrollDroppable, + ...bindActionCreators( + { + move, + }, + // $FlowFixMe - not sure why this is wrong + lazyDispatch, + ), + }), + [], + ); + + const store: Store = useMemoOne( + () => + createStore({ + dimensionMarshal, + styleMarshal, + announce, + autoScroller, + getResponders, + }), + [], + ); + + storeRef = useRef(store); + + const getCanLift = useCallbackOne( + (id: DraggableId) => canStartDrag(storeRef.current.getState(), id), + [], + ); + + const getIsMovementAllowed = useCallbackOne( + () => isMovementAllowed(storeRef.current.getState()), + [], + ); + + const appContext: AppContextValue = useMemoOne( + () => ({ + marshal: dimensionMarshal, + style: styleMarshal.styleContext, + canLift: getCanLift, + isMovementAllowed: getIsMovementAllowed, + }), + [], + ); + + const tryResetStore = useCallbackOne(() => { + const state: State = storeRef.current.getState(); + if (state.phase !== 'IDLE') { + store.dispatch(clean({ shouldFlush: true })); + } + }, []); + + useIsomorphicLayoutEffect(() => { + setOnError(tryResetStore); + }, [setOnError, tryResetStore]); + + useIsomorphicLayoutEffect(() => { + tryResetStore(); + }, [tryResetStore]); + + // Clean store when unmounting + useEffect(() => { + return tryResetStore; + }, [tryResetStore]); + + return ( + + + {props.children} + + + ); +} diff --git a/src/view/drag-drop-context/drag-drop-context.jsx b/src/view/drag-drop-context/drag-drop-context.jsx index ec0a60f32d..4957a08457 100644 --- a/src/view/drag-drop-context/drag-drop-context.jsx +++ b/src/view/drag-drop-context/drag-drop-context.jsx @@ -1,47 +1,9 @@ // @flow import React, { type Node } from 'react'; -import { bindActionCreators } from 'redux'; -import invariant from 'tiny-invariant'; -import PropTypes from 'prop-types'; -import createStore from '../../state/create-store'; -import createDimensionMarshal from '../../state/dimension-marshal/dimension-marshal'; -import createStyleMarshal, { - resetStyleContext, -} from '../style-marshal/style-marshal'; -import canStartDrag from '../../state/can-start-drag'; -import scrollWindow from '../window/scroll-window'; -import createAnnouncer from '../announcer/announcer'; -import createAutoScroller from '../../state/auto-scroller'; -import type { Announcer } from '../announcer/announcer-types'; -import type { AutoScroller } from '../../state/auto-scroller/auto-scroller-types'; -import type { StyleMarshal } from '../style-marshal/style-marshal-types'; -import type { - DimensionMarshal, - Callbacks as DimensionMarshalCallbacks, -} from '../../state/dimension-marshal/dimension-marshal-types'; -import type { DraggableId, State, Responders } from '../../types'; -import type { Store } from '../../state/store-types'; -import { - storeKey, - dimensionMarshalKey, - styleKey, - canLiftKey, - isMovementAllowedKey, -} from '../context-keys'; -import { - clean, - move, - publishWhileDragging, - updateDroppableScroll, - updateDroppableIsEnabled, - updateDroppableIsCombineEnabled, - collectionStarting, -} from '../../state/action-creators'; -import { peerDependencies } from '../../../package.json'; -import checkReactVersion from './check-react-version'; -import checkDoctype from './check-doctype'; -import isMovementAllowed from '../../state/is-movement-allowed'; +import { useMemoOne } from 'use-memo-one'; +import type { Responders } from '../../types'; import ErrorBoundary from '../error-boundary'; +import App from './app'; type Props = {| ...Responders, @@ -49,140 +11,25 @@ type Props = {| children: Node | null, |}; -type Context = { - [string]: Store, -}; +let instanceCount: number = 0; // Reset any context that gets persisted across server side renders -export const resetServerContext = () => { - resetStyleContext(); -}; - -export default class DragDropContext extends React.Component { - /* eslint-disable react/sort-comp */ - store: Store; - dimensionMarshal: DimensionMarshal; - styleMarshal: StyleMarshal; - autoScroller: AutoScroller; - announcer: Announcer; - unsubscribe: Function; - - constructor(props: Props, context: mixed) { - super(props, context); - - // A little setup check for dev - if (process.env.NODE_ENV !== 'production') { - invariant( - typeof props.onDragEnd === 'function', - 'A DragDropContext requires an onDragEnd function to perform reordering logic', - ); - } - - this.announcer = createAnnouncer(); - - // create the style marshal - this.styleMarshal = createStyleMarshal(); - - this.store = createStore({ - // Lazy reference to dimension marshal get around circular dependency - getDimensionMarshal: (): DimensionMarshal => this.dimensionMarshal, - styleMarshal: this.styleMarshal, - // This is a function as users are allowed to change their responder functions - // at any time - getResponders: (): Responders => ({ - onBeforeDragStart: this.props.onBeforeDragStart, - onDragStart: this.props.onDragStart, - onDragEnd: this.props.onDragEnd, - onDragUpdate: this.props.onDragUpdate, - }), - announce: this.announcer.announce, - getScroller: () => this.autoScroller, - }); - const callbacks: DimensionMarshalCallbacks = bindActionCreators( - { - publishWhileDragging, - updateDroppableScroll, - updateDroppableIsEnabled, - updateDroppableIsCombineEnabled, - collectionStarting, - }, - this.store.dispatch, - ); - this.dimensionMarshal = createDimensionMarshal(callbacks); - - this.autoScroller = createAutoScroller({ - scrollWindow, - scrollDroppable: this.dimensionMarshal.scrollDroppable, - ...bindActionCreators( - { - move, - }, - this.store.dispatch, - ), - }); - } - // Need to declare childContextTypes without flow - // https://github.com/brigand/babel-plugin-flow-react-proptypes/issues/22 - static childContextTypes = { - [storeKey]: PropTypes.shape({ - dispatch: PropTypes.func.isRequired, - subscribe: PropTypes.func.isRequired, - getState: PropTypes.func.isRequired, - }).isRequired, - [dimensionMarshalKey]: PropTypes.object.isRequired, - [styleKey]: PropTypes.string.isRequired, - [canLiftKey]: PropTypes.func.isRequired, - [isMovementAllowedKey]: PropTypes.func.isRequired, - }; - - getChildContext(): Context { - return { - [storeKey]: this.store, - [dimensionMarshalKey]: this.dimensionMarshal, - [styleKey]: this.styleMarshal.styleContext, - [canLiftKey]: this.canLift, - [isMovementAllowedKey]: this.getIsMovementAllowed, - }; - } - - // Providing function on the context for drag handles to use to - // let them know if they can start a drag or not. This is done - // rather than mapping a prop onto the drag handle so that we - // do not need to re-render a connected drag handle in order to - // pull this state off. It would cause a re-render of all items - // on drag start which is too expensive. - // This is useful when the user - canLift = (id: DraggableId) => canStartDrag(this.store.getState(), id); - getIsMovementAllowed = () => isMovementAllowed(this.store.getState()); - - componentDidMount() { - this.styleMarshal.mount(); - this.announcer.mount(); - - if (process.env.NODE_ENV !== 'production') { - checkReactVersion(peerDependencies.react, React.version); - checkDoctype(document); - } - } - - componentWillUnmount() { - this.tryResetStore(); - this.styleMarshal.unmount(); - this.announcer.unmount(); - } - - tryResetStore = () => { - const state: State = this.store.getState(); - if (state.phase !== 'IDLE') { - this.store.dispatch(clean()); - } - }; +export function resetServerContext() { + instanceCount = 0; +} - render() { - return ( - - {this.props.children} - - ); - } +export default function DragDropContext(props: Props) { + const uniqueId: number = useMemoOne(() => 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/drag-drop-context/use-startup-validation.js b/src/view/drag-drop-context/use-startup-validation.js new file mode 100644 index 0000000000..863cc20901 --- /dev/null +++ b/src/view/drag-drop-context/use-startup-validation.js @@ -0,0 +1,17 @@ +// @flow +import React, { useEffect } from 'react'; +import { peerDependencies } from '../../../package.json'; +import checkReactVersion from './check-react-version'; +import checkDoctype from './check-doctype'; + +export default function useStartupValidation() { + // Only need to run these when mounting + useEffect(() => { + if (process.env.NODE_ENV === 'production') { + return; + } + + checkReactVersion(peerDependencies.react, React.version); + checkDoctype(document); + }, []); +} diff --git a/src/view/drag-handle/drag-handle.jsx b/src/view/drag-handle/drag-handle.jsx deleted file mode 100644 index 05ea0e5d1b..0000000000 --- a/src/view/drag-handle/drag-handle.jsx +++ /dev/null @@ -1,268 +0,0 @@ -// @flow -import { Component } from 'react'; -import PropTypes from 'prop-types'; -import memoizeOne from 'memoize-one'; -import invariant from 'tiny-invariant'; -import getWindowFromEl from '../window/get-window-from-el'; -import getDragHandleRef from './util/get-drag-handle-ref'; -import type { Props, DragHandleProps } from './drag-handle-types'; -import type { - MouseSensor, - KeyboardSensor, - TouchSensor, - CreateSensorArgs, -} from './sensor/sensor-types'; -import type { DraggableId } from '../../types'; -import { styleKey, canLiftKey } from '../context-keys'; -import focusRetainer from './util/focus-retainer'; -import shouldAllowDraggingFromTarget from './util/should-allow-dragging-from-target'; -import createMouseSensor from './sensor/create-mouse-sensor'; -import createKeyboardSensor from './sensor/create-keyboard-sensor'; -import createTouchSensor from './sensor/create-touch-sensor'; -import { warning } from '../../dev-warning'; - -const preventHtml5Dnd = (event: DragEvent) => { - event.preventDefault(); -}; - -type Sensor = MouseSensor | KeyboardSensor | TouchSensor; - -export default class DragHandle extends Component { - /* eslint-disable react/sort-comp */ - mouseSensor: MouseSensor; - keyboardSensor: KeyboardSensor; - touchSensor: TouchSensor; - sensors: Sensor[]; - styleContext: string; - canLift: (id: DraggableId) => boolean; - isFocused: boolean = false; - lastDraggableRef: ?HTMLElement; - - // Need to declare contextTypes without flow - // https://github.com/brigand/babel-plugin-flow-react-proptypes/issues/22 - static contextTypes = { - [styleKey]: PropTypes.string.isRequired, - [canLiftKey]: PropTypes.func.isRequired, - }; - - constructor(props: Props, context: Object) { - super(props, context); - - const getWindow = (): HTMLElement => - getWindowFromEl(this.props.getDraggableRef()); - - const args: CreateSensorArgs = { - callbacks: this.props.callbacks, - getDraggableRef: this.props.getDraggableRef, - getWindow, - canStartCapturing: this.canStartCapturing, - getShouldRespectForceTouch: this.props.getShouldRespectForceTouch, - }; - - this.mouseSensor = createMouseSensor(args); - this.keyboardSensor = createKeyboardSensor(args); - this.touchSensor = createTouchSensor(args); - this.sensors = [this.mouseSensor, this.keyboardSensor, this.touchSensor]; - this.styleContext = context[styleKey]; - - // The canLift function is read directly off the context - // and will communicate with the store. This is done to avoid - // needing to query a property from the store and re-render this component - // with that value. By putting it as a function on the context we are able - // to avoid re-rendering to pass this information while still allowing - // drag-handles to obtain this state if they need it. - this.canLift = context[canLiftKey]; - } - - componentDidMount() { - const draggableRef: ?HTMLElement = this.props.getDraggableRef(); - - // storing a reference for later - this.lastDraggableRef = draggableRef; - - invariant(draggableRef, 'Cannot get draggable ref from drag handle'); - - // drag handle ref will not be available when not enabled - if (!this.props.isEnabled) { - return; - } - - const dragHandleRef: HTMLElement = getDragHandleRef(draggableRef); - - focusRetainer.tryRestoreFocus(this.props.draggableId, dragHandleRef); - } - - componentDidUpdate(prevProps: Props) { - const ref: ?HTMLElement = this.props.getDraggableRef(); - - // 1. focus on element if required - if (ref !== this.lastDraggableRef) { - this.lastDraggableRef = ref; - - // After a ref change we might need to manually force focus onto the ref. - // When moving something into or out of a portal the element loses focus - // https://github.com/facebook/react/issues/12454 - - if (ref && this.isFocused && this.props.isEnabled) { - getDragHandleRef(ref).focus(); - } - } - - // 2. should we kill the any capturing? - - const isCapturing: boolean = this.isAnySensorCapturing(); - - // not capturing was happening - so we dont need to do anything - if (!isCapturing) { - return; - } - - const isBeingDisabled: boolean = - prevProps.isEnabled && !this.props.isEnabled; - - if (isBeingDisabled) { - this.sensors.forEach((sensor: Sensor) => { - if (!sensor.isCapturing()) { - return; - } - const wasDragging: boolean = sensor.isDragging(); - sensor.kill(); - - // It is fine for a draggable to be disabled while a drag is pending - if (wasDragging) { - warning( - 'You have disabled dragging on a Draggable while it was dragging. The drag has been cancelled', - ); - this.props.callbacks.onCancel(); - } - }); - } - - // Drag has stopped due to somewhere else in the system - const isDragAborted: boolean = - prevProps.isDragging && !this.props.isDragging; - if (isDragAborted) { - // We need to unbind the handlers - this.sensors.forEach((sensor: Sensor) => { - if (sensor.isCapturing()) { - sensor.kill(); - // not firing any cancel event as the drag is already over - } - }); - } - } - - componentWillUnmount() { - this.sensors.forEach((sensor: Sensor) => { - // kill the current drag and fire a cancel event if - const wasDragging: boolean = sensor.isDragging(); - - sensor.unmount(); - // cancel if drag was occurring - if (wasDragging) { - this.props.callbacks.onCancel(); - } - }); - - const shouldRetainFocus: boolean = (() => { - if (!this.props.isEnabled) { - return false; - } - - // not already focused - if (!this.isFocused) { - return false; - } - - // a drag is finishing - return this.props.isDragging || this.props.isDropAnimating; - })(); - - if (shouldRetainFocus) { - focusRetainer.retain(this.props.draggableId); - } - } - - onFocus = () => { - this.isFocused = true; - }; - - onBlur = () => { - this.isFocused = false; - }; - - onKeyDown = (event: KeyboardEvent) => { - // let the other sensors deal with it - if (this.mouseSensor.isCapturing() || this.touchSensor.isCapturing()) { - return; - } - - this.keyboardSensor.onKeyDown(event); - }; - - onMouseDown = (event: MouseEvent) => { - // let the other sensors deal with it - if (this.keyboardSensor.isCapturing() || this.mouseSensor.isCapturing()) { - return; - } - - this.mouseSensor.onMouseDown(event); - }; - - onTouchStart = (event: TouchEvent) => { - // let the keyboard sensor deal with it - if (this.mouseSensor.isCapturing() || this.keyboardSensor.isCapturing()) { - return; - } - - this.touchSensor.onTouchStart(event); - }; - - canStartCapturing = (event: Event) => { - // this might be before a drag has started - isolated to this element - if (this.isAnySensorCapturing()) { - return false; - } - - // this will check if anything else in the system is dragging - if (!this.canLift(this.props.draggableId)) { - return false; - } - - // check if we are dragging an interactive element - return shouldAllowDraggingFromTarget(event, this.props); - }; - - isAnySensorCapturing = (): boolean => - this.sensors.some((sensor: Sensor) => sensor.isCapturing()); - - getProvided = memoizeOne( - (isEnabled: boolean): ?DragHandleProps => { - if (!isEnabled) { - return null; - } - - const provided: DragHandleProps = { - onMouseDown: this.onMouseDown, - onKeyDown: this.onKeyDown, - onTouchStart: this.onTouchStart, - onFocus: this.onFocus, - onBlur: this.onBlur, - tabIndex: 0, - 'data-react-beautiful-dnd-drag-handle': this.styleContext, - // English default. Consumers are welcome to add their own start instruction - 'aria-roledescription': 'Draggable item. Press space bar to lift', - draggable: false, - onDragStart: preventHtml5Dnd, - }; - - return provided; - }, - ); - - render() { - const { children, isEnabled } = this.props; - - return children(this.getProvided(isEnabled)); - } -} diff --git a/src/view/drag-handle/index.js b/src/view/drag-handle/index.js deleted file mode 100644 index 9fd0d90bc8..0000000000 --- a/src/view/drag-handle/index.js +++ /dev/null @@ -1,2 +0,0 @@ -// @flow -export { default } from './drag-handle'; diff --git a/src/view/drag-handle/sensor/create-keyboard-sensor.js b/src/view/drag-handle/sensor/create-keyboard-sensor.js deleted file mode 100644 index ac90fff403..0000000000 --- a/src/view/drag-handle/sensor/create-keyboard-sensor.js +++ /dev/null @@ -1,232 +0,0 @@ -// @flow -/* eslint-disable no-use-before-define */ -import invariant from 'tiny-invariant'; -import { type Position } from 'css-box-model'; -import createScheduler from '../util/create-scheduler'; -import preventStandardKeyEvents from '../util/prevent-standard-key-events'; -import * as keyCodes from '../../key-codes'; -import getBorderBoxCenterPosition from '../../get-border-box-center-position'; -import { bindEvents, unbindEvents } from '../util/bind-events'; -import supportedPageVisibilityEventName from '../util/supported-page-visibility-event-name'; -import type { EventBinding } from '../util/event-types'; -import type { KeyboardSensor, CreateSensorArgs } from './sensor-types'; - -type State = {| - isDragging: boolean, -|}; - -type KeyMap = { - [key: number]: true, -}; - -const scrollJumpKeys: KeyMap = { - [keyCodes.pageDown]: true, - [keyCodes.pageUp]: true, - [keyCodes.home]: true, - [keyCodes.end]: true, -}; - -const noop = () => {}; - -export default ({ - callbacks, - getWindow, - getDraggableRef, - canStartCapturing, -}: CreateSensorArgs): KeyboardSensor => { - let state: State = { - isDragging: false, - }; - const setState = (newState: State): void => { - state = newState; - }; - const startDragging = (fn?: Function = noop) => { - setState({ - isDragging: true, - }); - bindWindowEvents(); - fn(); - }; - const stopDragging = (postDragFn?: Function = noop) => { - schedule.cancel(); - unbindWindowEvents(); - setState({ isDragging: false }); - postDragFn(); - }; - const kill = () => { - if (state.isDragging) { - stopDragging(); - } - }; - const cancel = () => { - stopDragging(callbacks.onCancel); - }; - const isDragging = (): boolean => state.isDragging; - const schedule = createScheduler(callbacks); - - const onKeyDown = (event: KeyboardEvent) => { - // not yet dragging - if (!isDragging()) { - // 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; - } - - const ref: ?HTMLElement = getDraggableRef(); - - invariant(ref, 'Cannot start a keyboard drag without a draggable ref'); - - // using center position as selection - const center: Position = getBorderBoxCenterPosition(ref); - - // we are using this event for part of the drag - event.preventDefault(); - startDragging(() => - callbacks.onLift({ - clientSelection: center, - movementMode: 'SNAP', - }), - ); - return; - } - - // 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(); - stopDragging(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); - }; - - const windowBindings: EventBinding[] = [ - // 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, - }, - ]; - - const bindWindowEvents = () => { - bindEvents(getWindow(), windowBindings, { capture: true }); - }; - - const unbindWindowEvents = () => { - unbindEvents(getWindow(), windowBindings, { capture: true }); - }; - - const sensor: KeyboardSensor = { - onKeyDown, - kill, - isDragging, - // a drag starts instantly so capturing is the same as dragging - isCapturing: isDragging, - // no additional cleanup needed other then what it is kill - unmount: kill, - }; - - return sensor; -}; diff --git a/src/view/drag-handle/sensor/create-mouse-sensor.js b/src/view/drag-handle/sensor/create-mouse-sensor.js deleted file mode 100644 index b3f7c3da9f..0000000000 --- a/src/view/drag-handle/sensor/create-mouse-sensor.js +++ /dev/null @@ -1,342 +0,0 @@ -// @flow -/* eslint-disable no-use-before-define */ -import invariant from 'tiny-invariant'; -import { type Position } from 'css-box-model'; -import createScheduler from '../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 createPostDragEventPreventer, { - type EventPreventer, -} from '../util/create-post-drag-event-preventer'; -import { bindEvents, unbindEvents } from '../util/bind-events'; -import createEventMarshal, { - type EventMarshal, -} from '../util/create-event-marshal'; -import supportedPageVisibilityEventName from '../util/supported-page-visibility-event-name'; -import type { EventBinding } from '../util/event-types'; -import type { MouseSensor, CreateSensorArgs } from './sensor-types'; -import { warning } from '../../../dev-warning'; - -// Custom event format for force press inputs -type MouseForceChangedEvent = MouseEvent & { - webkitForce?: number, -}; - -type State = {| - isDragging: boolean, - pending: ?Position, -|}; - -// 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 ({ - callbacks, - getWindow, - canStartCapturing, - getShouldRespectForceTouch, -}: CreateSensorArgs): MouseSensor => { - let state: State = { - isDragging: false, - pending: null, - }; - const setState = (newState: State): void => { - state = newState; - }; - const isDragging = (): boolean => state.isDragging; - const isCapturing = (): boolean => Boolean(state.pending || state.isDragging); - const schedule = createScheduler(callbacks); - const postDragEventPreventer: EventPreventer = createPostDragEventPreventer( - getWindow, - ); - - const startDragging = (fn?: Function = noop) => { - setState({ - pending: null, - isDragging: true, - }); - fn(); - }; - const stopDragging = ( - fn?: Function = noop, - shouldBlockClick?: boolean = true, - ) => { - schedule.cancel(); - unbindWindowEvents(); - mouseDownMarshal.reset(); - if (shouldBlockClick) { - postDragEventPreventer.preventNext(); - } - setState({ - isDragging: false, - pending: null, - }); - fn(); - }; - const startPendingDrag = (point: Position) => { - setState({ pending: point, isDragging: false }); - bindWindowEvents(); - }; - const stopPendingDrag = () => { - stopDragging(noop, false); - }; - - const kill = (fn?: Function = noop) => { - if (state.pending) { - stopPendingDrag(); - return; - } - if (state.isDragging) { - stopDragging(fn); - } - }; - - const unmount = (): void => { - kill(); - postDragEventPreventer.abort(); - }; - - const cancel = () => { - kill(callbacks.onCancel); - }; - - const windowBindings: 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 (state.isDragging) { - // preventing default as we are using this event - event.preventDefault(); - schedule.move(point); - return; - } - - // There should be a pending drag at this point - - if (!state.pending) { - // this should be an impossible state - // we cannot use kill directly as it checks if there is a pending drag - stopPendingDrag(); - invariant( - false, - 'Expected there to be an active or pending drag when window mousemove event is received', - ); - } - - // threshold not yet exceeded - if (!isSloppyClickThresholdExceeded(state.pending, point)) { - return; - } - - // preventing default as we are using this event - event.preventDefault(); - startDragging(() => - callbacks.onLift({ - clientSelection: point, - movementMode: 'FLUID', - }), - ); - }, - }, - { - eventName: 'mouseup', - fn: (event: MouseEvent) => { - if (state.pending) { - stopPendingDrag(); - return; - } - - // preventing default as we are using this event - event.preventDefault(); - stopDragging(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 (state.isDragging) { - event.preventDefault(); - } - - stopDragging(callbacks.onCancel); - }, - }, - { - eventName: 'keydown', - fn: (event: KeyboardEvent) => { - // firing a keyboard event before the drag has started - // treat this as an indirect cancel - if (!state.isDragging) { - cancel(); - 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 (state.pending) { - stopPendingDrag(); - return; - } - // callbacks.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; - - // force press is not being respected - // opt out of default browser behaviour and continue the drag - if (!getShouldRespectForceTouch()) { - 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, - }, - ]; - - const bindWindowEvents = () => { - const win: HTMLElement = getWindow(); - bindEvents(win, windowBindings, { capture: true }); - }; - - const unbindWindowEvents = () => { - const win: HTMLElement = getWindow(); - unbindEvents(win, windowBindings, { capture: true }); - }; - - const onMouseDown = (event: MouseEvent): void => { - if (mouseDownMarshal.isHandled()) { - return; - } - - invariant( - !isCapturing(), - '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); - }; - - const sensor: MouseSensor = { - onMouseDown, - kill, - isCapturing, - isDragging, - unmount, - }; - - return sensor; -}; diff --git a/src/view/drag-handle/sensor/create-touch-sensor.js b/src/view/drag-handle/sensor/create-touch-sensor.js deleted file mode 100644 index 7a5b318903..0000000000 --- a/src/view/drag-handle/sensor/create-touch-sensor.js +++ /dev/null @@ -1,431 +0,0 @@ -// @flow -/* eslint-disable no-use-before-define */ -import invariant from 'tiny-invariant'; -import { type Position } from 'css-box-model'; -import type { EventBinding } from '../util/event-types'; -import type { TouchSensor, CreateSensorArgs } from './sensor-types'; -import createScheduler from '../util/create-scheduler'; -import createPostDragEventPreventer, { - type EventPreventer, -} from '../util/create-post-drag-event-preventer'; -import createEventMarshal, { - type EventMarshal, -} from '../util/create-event-marshal'; -import { bindEvents, unbindEvents } from '../util/bind-events'; -import * as keyCodes from '../../key-codes'; -import supportedPageVisibilityEventName from '../util/supported-page-visibility-event-name'; - -type State = { - isDragging: boolean, - hasMoved: boolean, - longPressTimerId: ?TimeoutID, - pending: ?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 }; -})(); - -const initial: State = { - isDragging: false, - pending: null, - hasMoved: false, - longPressTimerId: null, -}; - -export default ({ - callbacks, - getWindow, - canStartCapturing, - getShouldRespectForceTouch, -}: CreateSensorArgs): TouchSensor => { - let state: State = initial; - - const setState = (partial: Object): void => { - state = { - ...state, - ...partial, - }; - }; - const isDragging = (): boolean => state.isDragging; - const isCapturing = (): boolean => - Boolean(state.pending || state.isDragging || state.longPressTimerId); - const schedule = createScheduler(callbacks); - const postDragEventPreventer: EventPreventer = createPostDragEventPreventer( - getWindow, - ); - - const startDragging = () => { - const pending: ?Position = state.pending; - - if (!pending) { - // this should be an impossible state - // cannot use kill() as it will not unbind when there is no pending - stopPendingDrag(); - invariant(false, 'cannot start a touch drag without a pending position'); - } - - setState({ - isDragging: true, - // has not moved from original position yet - hasMoved: false, - // no longer relevant - pending: null, - longPressTimerId: null, - }); - - callbacks.onLift({ - clientSelection: pending, - movementMode: 'FLUID', - }); - }; - const stopDragging = (fn?: Function = noop) => { - schedule.cancel(); - touchStartMarshal.reset(); - webkitHack.releaseTouchMove(); - unbindWindowEvents(); - postDragEventPreventer.preventNext(); - setState(initial); - fn(); - }; - - const startPendingDrag = (event: TouchEvent) => { - const touch: Touch = event.touches[0]; - const { clientX, clientY } = touch; - const point: Position = { - x: clientX, - y: clientY, - }; - - const longPressTimerId: TimeoutID = setTimeout( - startDragging, - timeForLongPress, - ); - - setState({ - longPressTimerId, - pending: point, - isDragging: false, - hasMoved: false, - }); - bindWindowEvents(); - }; - - const stopPendingDrag = () => { - if (state.longPressTimerId) { - clearTimeout(state.longPressTimerId); - } - schedule.cancel(); - touchStartMarshal.reset(); - webkitHack.releaseTouchMove(); - unbindWindowEvents(); - - setState(initial); - }; - - const kill = (fn?: Function = noop) => { - if (state.pending) { - stopPendingDrag(); - return; - } - if (state.isDragging) { - stopDragging(fn); - } - }; - - const unmount = () => { - kill(); - postDragEventPreventer.abort(); - }; - - const cancel = () => { - kill(callbacks.onCancel); - }; - - const windowBindings: 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 - options: { passive: false }, - fn: (event: TouchEvent) => { - // Drag has not yet started and we are waiting for a long press. - if (!state.isDragging) { - stopPendingDrag(); - return; - } - - // At this point we are dragging - - if (!state.hasMoved) { - setState({ - 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(); - schedule.move(point); - }, - }, - { - eventName: 'touchend', - fn: (event: TouchEvent) => { - // drag had not started yet - do not prevent the default action - if (!state.isDragging) { - stopPendingDrag(); - return; - } - - // already dragging - this event is directly ending a drag - event.preventDefault(); - stopDragging(callbacks.onDrop); - }, - }, - { - eventName: 'touchcancel', - fn: (event: TouchEvent) => { - // drag had not started yet - do not prevent the default action - if (!state.isDragging) { - stopPendingDrag(); - return; - } - - // already dragging - this event is directly ending a drag - event.preventDefault(); - stopDragging(callbacks.onCancel); - }, - }, - // 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 (state.pending) { - stopPendingDrag(); - 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 (!state.isDragging) { - 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) => { - if (!state.isDragging && !state.pending) { - return; - } - - // A force push action will no longer fire after a touchmove - if (state.hasMoved) { - // 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 - - // Not respecting force press - prevent the event - if (!getShouldRespectForceTouch()) { - event.preventDefault(); - return; - } - - 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, - }, - ]; - - const bindWindowEvents = () => { - bindEvents(getWindow(), windowBindings, { capture: true }); - }; - - const unbindWindowEvents = () => { - unbindEvents(getWindow(), windowBindings, { capture: true }); - }; - - // entry point - const onTouchStart = (event: TouchEvent) => { - if (touchStartMarshal.isHandled()) { - return; - } - - invariant( - !isCapturing(), - '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); - }; - - const sensor: TouchSensor = { - onTouchStart, - kill, - isCapturing, - isDragging, - unmount, - }; - - return sensor; -}; diff --git a/src/view/draggable-dimension-publisher/draggable-dimension-publisher.jsx b/src/view/draggable-dimension-publisher/draggable-dimension-publisher.jsx deleted file mode 100644 index 2db8e33f59..0000000000 --- a/src/view/draggable-dimension-publisher/draggable-dimension-publisher.jsx +++ /dev/null @@ -1,150 +0,0 @@ -// @flow -import { - calculateBox, - withScroll, - type BoxModel, - type Position, -} from 'css-box-model'; -import memoizeOne from 'memoize-one'; -import PropTypes from 'prop-types'; -import { Component, type Node } from 'react'; -import invariant from 'tiny-invariant'; -import { dimensionMarshalKey } from '../context-keys'; -import { origin } from '../../state/position'; -import type { - DraggableDescriptor, - DraggableDimension, - Placeholder, - DraggableId, - DroppableId, - TypeId, -} from '../../types'; -import type { DimensionMarshal } from '../../state/dimension-marshal/dimension-marshal-types'; - -type Props = {| - draggableId: DraggableId, - droppableId: DroppableId, - type: TypeId, - index: number, - getDraggableRef: () => ?HTMLElement, - children: Node, -|}; - -export default class DraggableDimensionPublisher extends Component { - /* eslint-disable react/sort-comp */ - static contextTypes = { - [dimensionMarshalKey]: PropTypes.object.isRequired, - }; - - publishedDescriptor: ?DraggableDescriptor = null; - - componentDidMount() { - this.publish(); - } - - componentDidUpdate() { - this.publish(); - } - - componentWillUnmount() { - this.unpublish(); - } - - getMemoizedDescriptor = memoizeOne( - ( - id: DraggableId, - index: number, - droppableId: DroppableId, - type: TypeId, - ): DraggableDescriptor => ({ - id, - index, - droppableId, - type, - }), - ); - - publish = () => { - const marshal: DimensionMarshal = this.context[dimensionMarshalKey]; - const descriptor: DraggableDescriptor = this.getMemoizedDescriptor( - this.props.draggableId, - this.props.index, - this.props.droppableId, - this.props.type, - ); - - if (!this.publishedDescriptor) { - marshal.registerDraggable(descriptor, this.getDimension); - this.publishedDescriptor = descriptor; - return; - } - - // No changes to the descriptor - if (descriptor === this.publishedDescriptor) { - return; - } - - marshal.updateDraggable( - this.publishedDescriptor, - descriptor, - this.getDimension, - ); - this.publishedDescriptor = descriptor; - }; - - unpublish = () => { - invariant( - this.publishedDescriptor, - 'Cannot unpublish descriptor when none is published', - ); - - // Using the previously published id to unpublish. This is to guard - // against the case where the id dynamically changes. This is not - // supported during a drag - but it is good to guard against. - const marshal: DimensionMarshal = this.context[dimensionMarshalKey]; - marshal.unregisterDraggable(this.publishedDescriptor); - this.publishedDescriptor = null; - }; - - getDimension = (windowScroll?: Position = origin): DraggableDimension => { - const targetRef: ?HTMLElement = this.props.getDraggableRef(); - const descriptor: ?DraggableDescriptor = this.publishedDescriptor; - - invariant( - targetRef, - 'DraggableDimensionPublisher cannot calculate a dimension when not attached to the DOM', - ); - invariant(descriptor, 'Cannot get dimension for unpublished draggable'); - - const computedStyles: CSSStyleDeclaration = window.getComputedStyle( - targetRef, - ); - const borderBox: ClientRect = targetRef.getBoundingClientRect(); - const client: BoxModel = calculateBox(borderBox, computedStyles); - const page: BoxModel = withScroll(client, windowScroll); - - const placeholder: Placeholder = { - client, - tagName: targetRef.tagName.toLowerCase(), - display: computedStyles.display, - }; - const displaceBy: Position = { - x: client.marginBox.width, - y: client.marginBox.height, - }; - - const dimension: DraggableDimension = { - descriptor, - placeholder, - displaceBy, - client, - page, - }; - - return dimension; - }; - - render() { - return this.props.children; - } -} diff --git a/src/view/draggable-dimension-publisher/index.js b/src/view/draggable-dimension-publisher/index.js deleted file mode 100644 index 0f07a791a4..0000000000 --- a/src/view/draggable-dimension-publisher/index.js +++ /dev/null @@ -1,2 +0,0 @@ -// @flow -export { default } from './draggable-dimension-publisher'; diff --git a/src/view/draggable/connected-draggable.js b/src/view/draggable/connected-draggable.js index 5d3f8fc204..58eff66086 100644 --- a/src/view/draggable/connected-draggable.js +++ b/src/view/draggable/connected-draggable.js @@ -5,7 +5,6 @@ import { Component } from 'react'; import memoizeOne from 'memoize-one'; import { connect } from 'react-redux'; import Draggable from './draggable'; -import { storeKey } from '../context-keys'; import { origin } from '../../state/position'; import isStrictEqual from '../is-strict-equal'; import { curves, combine } from '../../animation'; @@ -44,6 +43,7 @@ import type { DropAnimation, } from './draggable-types'; import whatIsDraggedOver from '../../state/droppable/what-is-dragged-over'; +import StoreContext from '../context/store-context'; import whatIsDraggedOverFromResult from '../../state/droppable/what-is-dragged-over-from-result'; const getCombineWithFromResult = (result: DropResult): ?DraggableId => { @@ -328,6 +328,7 @@ class DraggableType extends Component { // Leaning heavily on the default shallow equality checking // that `connect` provides. // It avoids needing to do it own within `Draggable` +// $ExpectError - incorrect flowtype for react-redux version const ConnectedDraggable: typeof DraggableType = (connect( // returning a function so each component can do its own memoization makeMapStateToProps, @@ -336,10 +337,8 @@ const ConnectedDraggable: typeof DraggableType = (connect( null, // options { - // Using our own store key. - // This allows consumers to also use redux - // Note: the default store key is 'store' - storeKey, + // Using our own context for the store to avoid clashing with consumers + context: StoreContext, // Default value, but being really clear pure: true, // When pure, compares the result of mapStateToProps to its previous value. diff --git a/src/view/draggable/draggable-types.js b/src/view/draggable/draggable-types.js index 1e13807bf2..7a05e8f565 100644 --- a/src/view/draggable/draggable-types.js +++ b/src/view/draggable/draggable-types.js @@ -19,7 +19,7 @@ import { drop, dropAnimationFinished, } from '../../state/action-creators'; -import type { DragHandleProps } from '../drag-handle/drag-handle-types'; +import type { DragHandleProps } from '../use-drag-handle/drag-handle-types'; export type DraggingStyle = {| position: 'fixed', diff --git a/src/view/draggable/draggable.jsx b/src/view/draggable/draggable.jsx index ee7edfac35..d8dfec1ca2 100644 --- a/src/view/draggable/draggable.jsx +++ b/src/view/draggable/draggable.jsx @@ -1,203 +1,198 @@ // @flow -import React, { type Node } from 'react'; +import { useRef } from 'react'; import { type Position } from 'css-box-model'; -import PropTypes from 'prop-types'; -import memoizeOne from 'memoize-one'; import invariant from 'tiny-invariant'; -import type { DroppableId, MovementMode, TypeId } from '../../types'; -import DraggableDimensionPublisher from '../draggable-dimension-publisher'; -import DragHandle from '../drag-handle'; +import { useMemoOne, useCallbackOne } from 'use-memo-one'; +import getStyle from './get-style'; +import useDragHandle from '../use-drag-handle/use-drag-handle'; import type { - DragHandleProps, + Args as DragHandleArgs, Callbacks as DragHandleCallbacks, -} from '../drag-handle/drag-handle-types'; -import { droppableIdKey, styleKey, droppableTypeKey } from '../context-keys'; + 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, - MappedProps, -} from './draggable-types'; -import getStyle from './get-style'; +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'; - -export default class Draggable extends React.Component { - /* eslint-disable react/sort-comp */ - callbacks: DragHandleCallbacks; - styleContext: string; - ref: ?HTMLElement = null; - - // Need to declare contextTypes without flow - // https://github.com/brigand/babel-plugin-flow-react-proptypes/issues/22 - static contextTypes = { - [droppableIdKey]: PropTypes.string.isRequired, - [droppableTypeKey]: PropTypes.string.isRequired, - [styleKey]: PropTypes.string.isRequired, - }; - - constructor(props: Props, context: Object) { - super(props, context); - - const callbacks: DragHandleCallbacks = { - onLift: this.onLift, +// import throwIfRefIsInvalid from '../throw-if-invalid-inner-ref'; +// import checkOwnProps from './check-own-props'; +import AppContext, { type AppContextValue } from '../context/app-context'; +import useRequiredContext from '../use-required-context'; +import useValidation from './use-validation'; + +export default function Draggable(props: Props) { + // reference to DOM node + const ref = useRef(null); + const setRef = useCallbackOne((el: ?HTMLElement) => { + ref.current = el; + }, []); + const getRef = useCallbackOne((): ?HTMLElement => ref.current, []); + + // context + const appContext: AppContextValue = useRequiredContext(AppContext); + + // Validating props and innerRef + useValidation(props, getRef); + + // props + const { + // ownProps + children, + draggableId, + isDragDisabled, + shouldRespectForceTouch, + disableInteractiveElementBlocking: canDragInteractiveElements, + index, + + // mapProps + mapped, + + // dispatchProps + moveUp: moveUpAction, + move: moveAction, + drop: dropAction, + moveDown: moveDownAction, + moveRight: moveRightAction, + moveLeft: moveLeftAction, + moveByWindowScroll: moveByWindowScrollAction, + lift: liftAction, + dropAnimationFinished: dropAnimationFinishedAction, + } = props; + + // The dimension publisher: talks to the marshal + const forPublisher: DimensionPublisherArgs = useMemoOne( + () => ({ + draggableId, + index, + getDraggableRef: getRef, + }), + [draggableId, getRef, index], + ); + useDraggableDimensionPublisher(forPublisher); + + // The Drag handle + + const onLift = useCallbackOne( + (options: { clientSelection: Position, movementMode: MovementMode }) => { + timings.start('LIFT'); + const el: ?HTMLElement = ref.current; + invariant(el); + invariant(!isDragDisabled, 'Cannot lift a Draggable when it is disabled'); + const { clientSelection, movementMode } = options; + + liftAction({ + id: draggableId, + clientSelection, + movementMode, + }); + timings.finish('LIFT'); + }, + [draggableId, isDragDisabled, liftAction], + ); + + const getShouldRespectForceTouch = useCallbackOne( + () => shouldRespectForceTouch, + [shouldRespectForceTouch], + ); + + const callbacks: DragHandleCallbacks = useMemoOne( + () => ({ + onLift, onMove: (clientSelection: Position) => - props.move({ client: clientSelection }), - onDrop: () => props.drop({ reason: 'DROP' }), - onCancel: () => props.drop({ reason: 'CANCEL' }), - onMoveUp: props.moveUp, - onMoveDown: props.moveDown, - onMoveRight: props.moveRight, - onMoveLeft: props.moveLeft, + moveAction({ client: clientSelection }), + onDrop: () => dropAction({ reason: 'DROP' }), + onCancel: () => dropAction({ reason: 'CANCEL' }), + onMoveUp: moveUpAction, + onMoveDown: moveDownAction, + onMoveRight: moveRightAction, + onMoveLeft: moveLeftAction, onWindowScroll: () => - props.moveByWindowScroll({ + moveByWindowScrollAction({ newScroll: getWindowScroll(), }), - }; - - this.callbacks = callbacks; - this.styleContext = context[styleKey]; - - // Only running this check on creation. - // Could run it on updates, but I don't think that would be needed - // as it is designed to prevent setup issues - if (process.env.NODE_ENV !== 'production') { - checkOwnProps(props); - } - } - - componentWillUnmount() { - // releasing reference to ref for cleanup - this.ref = null; - } - - onMoveEnd = (event: TransitionEvent) => { - const mapped: MappedProps = this.props.mapped; - const isDropping: boolean = - mapped.type === 'DRAGGING' && Boolean(mapped.dropping); - - if (!isDropping) { - return; - } - - // There might be other properties on the element that are - // being transitioned. We do not want those to end a drop animation! - if (event.propertyName !== 'transform') { - return; - } - - this.props.dropAnimationFinished(); - }; - - onLift = (options: { - clientSelection: Position, - movementMode: MovementMode, - }) => { - timings.start('LIFT'); - const ref: ?HTMLElement = this.ref; - invariant(ref); - invariant( - !this.props.isDragDisabled, - 'Cannot lift a Draggable when it is disabled', - ); - const { clientSelection, movementMode } = options; - const { lift, draggableId } = this.props; - - lift({ - id: draggableId, - clientSelection, - movementMode, - }); - timings.finish('LIFT'); - }; - - // React can call ref callback twice for every render - // if using an arrow function - setRef = (ref: ?HTMLElement) => { - if (ref === null) { - return; - } - - if (ref === this.ref) { - return; - } - - // At this point the ref has been changed or initially populated - - this.ref = ref; - throwIfRefIsInvalid(ref); - }; - - getDraggableRef = (): ?HTMLElement => this.ref; - getShouldRespectForceTouch = (): boolean => - this.props.shouldRespectForceTouch; - - getProvided = memoizeOne( - (mapped: MappedProps, dragHandleProps: ?DragHandleProps): Provided => { - const style: DraggableStyle = getStyle(mapped); - const onTransitionEnd = - mapped.type === 'DRAGGING' && Boolean(mapped.dropping) - ? this.onMoveEnd - : null; - - const result: Provided = { - innerRef: this.setRef, - draggableProps: { - 'data-react-beautiful-dnd-draggable': this.styleContext, - style, - onTransitionEnd, - }, - dragHandleProps, - }; - - return result; - }, + }), + [ + dropAction, + moveAction, + moveByWindowScrollAction, + moveDownAction, + moveLeftAction, + moveRightAction, + moveUpAction, + onLift, + ], ); - renderChildren = (dragHandleProps: ?DragHandleProps): Node | null => { - const { children, mapped } = this.props; - return children(this.getProvided(mapped, dragHandleProps), mapped.snapshot); - }; + const isDragging: boolean = mapped.type === 'DRAGGING'; + const isDropAnimating: boolean = + mapped.type === 'DRAGGING' && Boolean(mapped.dropping); - render() { - const { + const dragHandleArgs: DragHandleArgs = useMemoOne( + () => ({ draggableId, - index, - mapped, + isDragging, + isDropAnimating, + isEnabled: !isDragDisabled, + callbacks, + getDraggableRef: getRef, + canDragInteractiveElements, + getShouldRespectForceTouch, + }), + [ + callbacks, + canDragInteractiveElements, + draggableId, + getRef, + getShouldRespectForceTouch, isDragDisabled, - disableInteractiveElementBlocking, - } = this.props; - const droppableId: DroppableId = this.context[droppableIdKey]; - const type: TypeId = this.context[droppableTypeKey]; - const isDragging: boolean = mapped.type === 'DRAGGING'; - const isDropAnimating: boolean = - mapped.type === 'DRAGGING' && Boolean(mapped.dropping); - - return ( - - - {this.renderChildren} - - - ); - } + isDragging, + isDropAnimating, + ], + ); + + const dragHandleProps: ?DragHandleProps = useDragHandle(dragHandleArgs); + + const onMoveEnd = useCallbackOne( + (event: TransitionEvent) => { + if (mapped.type !== 'DRAGGING') { + return; + } + + if (!mapped.dropping) { + return; + } + + // There might be other properties on the element that are + // being transitioned. We do not want those to end a drop animation! + if (event.propertyName !== 'transform') { + return; + } + + dropAnimationFinishedAction(); + }, + [dropAnimationFinishedAction, mapped], + ); + + const provided: Provided = useMemoOne(() => { + const style: DraggableStyle = getStyle(mapped); + const onTransitionEnd = + mapped.type === 'DRAGGING' && mapped.dropping ? onMoveEnd : null; + + const result: Provided = { + innerRef: setRef, + draggableProps: { + 'data-react-beautiful-dnd-draggable': appContext.style, + style, + onTransitionEnd, + }, + dragHandleProps, + }; + + return result; + }, [appContext.style, dragHandleProps, mapped, onMoveEnd, setRef]); + + return children(provided, mapped.snapshot); } diff --git a/src/view/draggable/use-validation.js b/src/view/draggable/use-validation.js new file mode 100644 index 0000000000..06cfcf321a --- /dev/null +++ b/src/view/draggable/use-validation.js @@ -0,0 +1,32 @@ +// @flow +import { useEffect } from 'react'; +import invariant from 'tiny-invariant'; +import type { Props } from './draggable-types'; +import checkIsValidInnerRef from '../check-is-valid-inner-ref'; + +function checkOwnProps(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', + ); +} + +export default function useValidation( + props: Props, + getRef: () => ?HTMLElement, +) { + // running after every update in development + useEffect(() => { + // wrapping entire block for better minification + if (process.env.NODE_ENV !== 'production') { + checkOwnProps(props); + checkIsValidInnerRef(getRef()); + } + }); +} diff --git a/src/view/droppable-dimension-publisher/droppable-dimension-publisher.jsx b/src/view/droppable-dimension-publisher/droppable-dimension-publisher.jsx deleted file mode 100644 index 36b61f2b2d..0000000000 --- a/src/view/droppable-dimension-publisher/droppable-dimension-publisher.jsx +++ /dev/null @@ -1,352 +0,0 @@ -// @flow -import React, { type Node } from 'react'; -import PropTypes from 'prop-types'; -import memoizeOne from 'memoize-one'; -import invariant from 'tiny-invariant'; -import { type Position } from 'css-box-model'; -import rafSchedule from 'raf-schd'; -import checkForNestedScrollContainers from './check-for-nested-scroll-container'; -import { dimensionMarshalKey } from '../context-keys'; -import { origin } from '../../state/position'; -import getScroll from './get-scroll'; -import type { - DimensionMarshal, - DroppableCallbacks, - RecollectDroppableOptions, -} from '../../state/dimension-marshal/dimension-marshal-types'; -import getEnv, { type Env } from './get-env'; -import type { - DroppableId, - TypeId, - DroppableDimension, - DroppableDescriptor, - Direction, - ScrollOptions, -} from '../../types'; -import getDimension from './get-dimension'; -import { warning } from '../../dev-warning'; - -type Props = {| - droppableId: DroppableId, - type: TypeId, - direction: Direction, - isDropDisabled: boolean, - isCombineEnabled: boolean, - ignoreContainerClipping: boolean, - getPlaceholderRef: () => ?HTMLElement, - getDroppableRef: () => ?HTMLElement, - children: Node, -|}; - -type WhileDragging = {| - ref: HTMLElement, - descriptor: DroppableDescriptor, - env: Env, - scrollOptions: ScrollOptions, -|}; - -const getClosestScrollable = (dragging: ?WhileDragging): ?Element => - (dragging && dragging.env.closestScrollable) || null; - -const immediate = { - passive: false, -}; -const delayed = { - passive: true, -}; - -const getListenerOptions = (options: ScrollOptions) => - options.shouldPublishImmediately ? immediate : delayed; - -const withoutPlaceholder = ( - placeholder: ?HTMLElement, - fn: () => DroppableDimension, -): DroppableDimension => { - if (!placeholder) { - return fn(); - } - - const last: string = placeholder.style.display; - placeholder.style.display = 'none'; - const result: DroppableDimension = fn(); - placeholder.style.display = last; - - return result; -}; - -export default class DroppableDimensionPublisher extends React.Component { - /* eslint-disable react/sort-comp */ - dragging: ?WhileDragging; - callbacks: DroppableCallbacks; - publishedDescriptor: ?DroppableDescriptor = null; - - constructor(props: Props, context: mixed) { - super(props, context); - const callbacks: DroppableCallbacks = { - getDimensionAndWatchScroll: this.getDimensionAndWatchScroll, - recollect: this.recollect, - dragStopped: this.dragStopped, - scroll: this.scroll, - }; - this.callbacks = callbacks; - } - - static contextTypes = { - [dimensionMarshalKey]: PropTypes.object.isRequired, - }; - - getClosestScroll = (): Position => { - const dragging: ?WhileDragging = this.dragging; - if (!dragging || !dragging.env.closestScrollable) { - return origin; - } - - return getScroll(dragging.env.closestScrollable); - }; - - memoizedUpdateScroll = memoizeOne((x: number, y: number) => { - invariant( - this.publishedDescriptor, - 'Cannot update scroll on unpublished droppable', - ); - - const newScroll: Position = { x, y }; - const marshal: DimensionMarshal = this.context[dimensionMarshalKey]; - marshal.updateDroppableScroll(this.publishedDescriptor.id, newScroll); - }); - - updateScroll = () => { - const scroll: Position = this.getClosestScroll(); - this.memoizedUpdateScroll(scroll.x, scroll.y); - }; - - scheduleScrollUpdate = rafSchedule(this.updateScroll); - - onClosestScroll = () => { - const dragging: ?WhileDragging = this.dragging; - const closest: ?Element = getClosestScrollable(this.dragging); - - invariant( - dragging && closest, - 'Could not find scroll options while scrolling', - ); - const options: ScrollOptions = dragging.scrollOptions; - if (options.shouldPublishImmediately) { - this.updateScroll(); - return; - } - this.scheduleScrollUpdate(); - }; - - scroll = (change: Position) => { - const closest: ?Element = getClosestScrollable(this.dragging); - invariant(closest, 'Cannot scroll a droppable with no closest scrollable'); - closest.scrollTop += change.y; - closest.scrollLeft += change.x; - }; - - dragStopped = () => { - const dragging: ?WhileDragging = this.dragging; - invariant(dragging, 'Cannot stop drag when no active drag'); - const closest: ?Element = getClosestScrollable(dragging); - - // goodbye old friend - this.dragging = null; - - if (!closest) { - return; - } - - // unwatch scroll - this.scheduleScrollUpdate.cancel(); - closest.removeEventListener( - 'scroll', - this.onClosestScroll, - getListenerOptions(dragging.scrollOptions), - ); - }; - - componentDidMount() { - this.publish(); - - // Note: not calling `marshal.updateDroppableIsEnabled()` - // If the dimension marshal needs to get the dimension immediately - // then it will get the enabled state of the dimension at that point - } - - componentDidUpdate(prevProps: Props) { - // Update the descriptor if needed - this.publish(); - - // Do not need to update the marshal if no drag is occurring - if (!this.dragging) { - return; - } - - // Need to update the marshal if an enabled state is changing - - const isDisabledChanged: boolean = - this.props.isDropDisabled !== prevProps.isDropDisabled; - const isCombineChanged: boolean = - this.props.isCombineEnabled !== prevProps.isCombineEnabled; - - if (!isDisabledChanged && !isCombineChanged) { - return; - } - - const marshal: DimensionMarshal = this.context[dimensionMarshalKey]; - - if (isDisabledChanged) { - marshal.updateDroppableIsEnabled( - this.props.droppableId, - !this.props.isDropDisabled, - ); - } - - if (isCombineChanged) { - marshal.updateDroppableIsCombineEnabled( - this.props.droppableId, - this.props.isCombineEnabled, - ); - } - } - - componentWillUnmount() { - if (this.dragging) { - warning('unmounting droppable while a drag is occurring'); - this.dragStopped(); - } - - this.unpublish(); - } - - getMemoizedDescriptor = memoizeOne( - (id: DroppableId, type: TypeId): DroppableDescriptor => ({ - id, - type, - }), - ); - - publish = () => { - const marshal: DimensionMarshal = this.context[dimensionMarshalKey]; - const descriptor: DroppableDescriptor = this.getMemoizedDescriptor( - this.props.droppableId, - this.props.type, - ); - - if (!this.publishedDescriptor) { - marshal.registerDroppable(descriptor, this.callbacks); - this.publishedDescriptor = descriptor; - return; - } - - // already published - and no changes - if (this.publishedDescriptor === descriptor) { - return; - } - - // already published and there has been changes - marshal.updateDroppable( - this.publishedDescriptor, - descriptor, - this.callbacks, - ); - this.publishedDescriptor = descriptor; - }; - - unpublish = () => { - invariant( - this.publishedDescriptor, - 'Cannot unpublish descriptor when none is published', - ); - - // Using the previously published id to unpublish. This is to guard - // against the case where the id dynamically changes. This is not - // supported during a drag - but it is good to guard against. - const marshal: DimensionMarshal = this.context[dimensionMarshalKey]; - marshal.unregisterDroppable(this.publishedDescriptor); - this.publishedDescriptor = null; - }; - - // Used when Draggables are added or removed from a Droppable during a drag - recollect = (options: RecollectDroppableOptions): DroppableDimension => { - const dragging: ?WhileDragging = this.dragging; - const closest: ?Element = getClosestScrollable(dragging); - invariant( - dragging && closest, - 'Can only recollect Droppable client for Droppables that have a scroll container', - ); - - const execute = (): DroppableDimension => - getDimension({ - ref: dragging.ref, - descriptor: dragging.descriptor, - env: dragging.env, - windowScroll: origin, - direction: this.props.direction, - isDropDisabled: this.props.isDropDisabled, - isCombineEnabled: this.props.isCombineEnabled, - shouldClipSubject: !this.props.ignoreContainerClipping, - }); - - if (!options.withoutPlaceholder) { - return execute(); - } - - return withoutPlaceholder(this.props.getPlaceholderRef(), execute); - }; - - getDimensionAndWatchScroll = ( - windowScroll: Position, - options: ScrollOptions, - ): DroppableDimension => { - invariant( - !this.dragging, - 'Cannot collect a droppable while a drag is occurring', - ); - const descriptor: ?DroppableDescriptor = this.publishedDescriptor; - invariant(descriptor, 'Cannot get dimension for unpublished droppable'); - const ref: ?HTMLElement = this.props.getDroppableRef(); - invariant(ref, 'Cannot collect without a droppable ref'); - const env: Env = getEnv(ref); - - const dragging: WhileDragging = { - ref, - descriptor, - env, - scrollOptions: options, - }; - this.dragging = dragging; - - const dimension: DroppableDimension = getDimension({ - ref, - descriptor, - env, - windowScroll, - direction: this.props.direction, - isDropDisabled: this.props.isDropDisabled, - isCombineEnabled: this.props.isCombineEnabled, - shouldClipSubject: !this.props.ignoreContainerClipping, - }); - - if (env.closestScrollable) { - // bind scroll listener - - env.closestScrollable.addEventListener( - 'scroll', - this.onClosestScroll, - getListenerOptions(dragging.scrollOptions), - ); - // print a debug warning if using an unsupported nested scroll container setup - if (process.env.NODE_ENV !== 'production') { - checkForNestedScrollContainers(env.closestScrollable); - } - } - - return dimension; - }; - - render() { - return this.props.children; - } -} diff --git a/src/view/droppable-dimension-publisher/index.js b/src/view/droppable-dimension-publisher/index.js deleted file mode 100644 index 6195e3a83a..0000000000 --- a/src/view/droppable-dimension-publisher/index.js +++ /dev/null @@ -1,2 +0,0 @@ -// @flow -export { default } from './droppable-dimension-publisher'; diff --git a/src/view/droppable/check-own-props.js b/src/view/droppable/check-own-props.js deleted file mode 100644 index 16d0830e2c..0000000000 --- a/src/view/droppable/check-own-props.js +++ /dev/null @@ -1,19 +0,0 @@ -// @flow -import invariant from 'tiny-invariant'; -import type { Props } from './droppable-types'; - -export default (props: Props) => { - invariant(props.droppableId, 'A Droppable requires a droppableId prop'); - invariant( - typeof props.isDropDisabled === 'boolean', - 'isDropDisabled must be a boolean', - ); - invariant( - typeof props.isCombineEnabled === 'boolean', - 'isCombineEnabled must be a boolean', - ); - invariant( - typeof props.ignoreContainerClipping === 'boolean', - 'ignoreContainerClipping must be a boolean', - ); -}; diff --git a/src/view/droppable/connected-droppable.js b/src/view/droppable/connected-droppable.js index c02fde74d7..b7b1c4c00b 100644 --- a/src/view/droppable/connected-droppable.js +++ b/src/view/droppable/connected-droppable.js @@ -21,11 +21,11 @@ import type { DispatchProps, StateSnapshot, } from './droppable-types'; -import { storeKey } from '../context-keys'; import Droppable from './droppable'; import isStrictEqual from '../is-strict-equal'; 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'; const isMatchingType = (type: TypeId, critical: Critical): boolean => @@ -157,6 +157,12 @@ export const makeMapStateToProps = (): Selector => { ); } + // An error occurred and we need to clear everything + // TODO: validate and add test + if (state.phase === 'IDLE' && !state.completed && state.shouldFlush) { + return idleWithoutAnimation; + } + if (state.phase === 'IDLE' && state.completed) { const completed: CompletedDrag = state.completed; if (!isMatchingType(type, completed.critical)) { @@ -214,6 +220,7 @@ class DroppableType extends Component { // Leaning heavily on the default shallow equality checking // that `connect` provides. // It avoids needing to do it own within `Droppable` +// $ExpectError - incorrect flowtype for react-redux version const ConnectedDroppable: typeof DroppableType = (connect( // returning a function so each component can do its own memoization makeMapStateToProps, @@ -222,10 +229,8 @@ const ConnectedDroppable: typeof DroppableType = (connect( // mergeProps - using default null, { - // Using our own store key. - // This allows consumers to also use redux - // Note: the default store key is 'store' - storeKey, + // Ensuring our context does not clash with consumers + context: StoreContext, // pure: true is default value, but being really clear pure: true, // When pure, compares the result of mapStateToProps to its previous value. diff --git a/src/view/droppable/droppable.jsx b/src/view/droppable/droppable.jsx index 4d1dde7974..a6e16db7fc 100644 --- a/src/view/droppable/droppable.jsx +++ b/src/view/droppable/droppable.jsx @@ -1,191 +1,129 @@ // @flow -import React, { type Node } from 'react'; -import PropTypes from 'prop-types'; -import DroppableDimensionPublisher from '../droppable-dimension-publisher'; +import invariant from 'tiny-invariant'; +import { useMemoOne, useCallbackOne } from 'use-memo-one'; +import React, { useRef, useContext, type Node } from 'react'; import type { Props, Provided } from './droppable-types'; -import type { DroppableId, TypeId } from '../../types'; +import useDroppableDimensionPublisher from '../use-droppable-dimension-publisher'; import Placeholder from '../placeholder'; -import throwIfRefIsInvalid from '../throw-if-invalid-inner-ref'; -import { - droppableIdKey, - droppableTypeKey, - styleKey, - isMovementAllowedKey, -} from '../context-keys'; -import { warning } from '../../dev-warning'; -import checkOwnProps from './check-own-props'; +import AppContext, { type AppContextValue } from '../context/app-context'; +import DroppableContext, { + type DroppableContextValue, +} from '../context/droppable-context'; +// 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 AnimateInOut, { type AnimateProvided, } from '../animate-in-out/animate-in-out'; -import getMaxWindowScroll from '../window/get-max-window-scroll'; - -type Context = { - [string]: DroppableId | TypeId, -}; - -export default class Droppable extends React.Component { - /* eslint-disable react/sort-comp */ - styleContext: string; - ref: ?HTMLElement = null; - placeholderRef: ?HTMLElement = null; - - // Need to declare childContextTypes without flow - static contextTypes = { - [styleKey]: PropTypes.string.isRequired, - [isMovementAllowedKey]: PropTypes.func.isRequired, - }; - - constructor(props: Props, context: Object) { - super(props, context); - - this.styleContext = context[styleKey]; - - // a little run time check to avoid an easy to catch setup issues - if (process.env.NODE_ENV !== 'production') { - checkOwnProps(props); - } - } - - // Need to declare childContextTypes without flow - // https://github.com/brigand/babel-plugin-flow-react-proptypes/issues/22 - static childContextTypes = { - [droppableIdKey]: PropTypes.string.isRequired, - [droppableTypeKey]: PropTypes.string.isRequired, - }; - getChildContext(): Context { - const value: Context = { - [droppableIdKey]: this.props.droppableId, - [droppableTypeKey]: this.props.type, - }; - return value; - } - - componentDidMount() { - throwIfRefIsInvalid(this.ref); - this.warnIfPlaceholderNotMounted(); - } - - componentDidUpdate() { - this.warnIfPlaceholderNotMounted(); - } - - componentWillUnmount() { - // allowing garbage collection - this.ref = null; - this.placeholderRef = null; - } - - warnIfPlaceholderNotMounted() { - if (process.env.NODE_ENV === 'production') { - return; - } - - if (!this.props.placeholder) { - return; - } - - if (this.placeholderRef) { - return; - } - - warning(` - Droppable setup issue [droppableId: "${this.props.droppableId}"]: - DroppableProvided > placeholder could not be found. - - Please be sure to add the {provided.placeholder} React Node as a child of your Droppable. - More information: https://github.com/atlassian/react-beautiful-dnd/blob/master/docs/api/droppable.md - `); - } - - /* eslint-enable */ - - setPlaceholderRef = (ref: ?HTMLElement) => { - this.placeholderRef = ref; - }; - - getPlaceholderRef = () => this.placeholderRef; - - // React calls ref callback twice for every render - // https://github.com/facebook/react/pull/8333/files - setRef = (ref: ?HTMLElement) => { - if (ref === null) { - return; - } - - if (ref === this.ref) { - return; - } - - this.ref = ref; - throwIfRefIsInvalid(ref); - }; - - getDroppableRef = (): ?HTMLElement => this.ref; - - onPlaceholderTransitionEnd = () => { - const isMovementAllowed: boolean = this.context[isMovementAllowedKey](); +export default function Droppable(props: Props) { + const appContext: ?AppContextValue = useContext(AppContext); + invariant(appContext, 'Could not find app context'); + const { style: styleContext, isMovementAllowed } = appContext; + const droppableRef = useRef(null); + const placeholderRef = useRef(null); + + // Note: Running validation at the end as it uses some placeholder things + + const { + // own props + children, + droppableId, + type, + direction, + ignoreContainerClipping, + isDropDisabled, + isCombineEnabled, + // map props + snapshot, + // dispatch props + updateViewportMaxScroll, + } = props; + + const getDroppableRef = useCallbackOne( + (): ?HTMLElement => droppableRef.current, + [], + ); + const getPlaceholderRef = useCallbackOne( + (): ?HTMLElement => placeholderRef.current, + [], + ); + const setDroppableRef = useCallbackOne((value: ?HTMLElement) => { + droppableRef.current = value; + }, []); + const setPlaceholderRef = useCallbackOne((value: ?HTMLElement) => { + placeholderRef.current = value; + }, []); + + const onPlaceholderTransitionEnd = useCallbackOne(() => { // A placeholder change can impact the window's max scroll - if (isMovementAllowed) { - this.props.updateViewportMaxScroll({ maxScroll: getMaxWindowScroll() }); + if (isMovementAllowed()) { + updateViewportMaxScroll({ maxScroll: getMaxWindowScroll() }); } - }; - - getPlaceholder(): Node { - // Placeholder > onClose / onTransitionEnd - // might not fire in the case of very fast toggling - return ( - - {({ onClose, data, animate }: AnimateProvided) => ( - - )} - - ); - } - - render() { - const { - // ownProps - children, - direction, - type, - droppableId, - isDropDisabled, - isCombineEnabled, - ignoreContainerClipping, - // mapProps - snapshot, - } = this.props; - const provided: Provided = { - innerRef: this.setRef, - placeholder: this.getPlaceholder(), + }, [isMovementAllowed, updateViewportMaxScroll]); + + useDroppableDimensionPublisher({ + droppableId, + type, + direction, + isDropDisabled, + isCombineEnabled, + ignoreContainerClipping, + getDroppableRef, + getPlaceholderRef, + }); + + // const instruction: ?AnimateProvided = useAnimateInOut({ + // on: props.placeholder, + // shouldAnimate: props.shouldAnimatePlaceholder, + // }); + + const placeholder: Node = ( + + {({ onClose, data, animate }: AnimateProvided) => ( + + )} + + ); + + const provided: Provided = useMemoOne( + (): Provided => ({ + innerRef: setDroppableRef, + placeholder, droppableProps: { - 'data-react-beautiful-dnd-droppable': this.styleContext, + 'data-react-beautiful-dnd-droppable': styleContext, }, - }; + }), + [placeholder, setDroppableRef, styleContext], + ); - return ( - - {children(provided, snapshot)} - - ); - } + const droppableContext: ?DroppableContextValue = useMemoOne( + () => ({ + droppableId, + type, + }), + [droppableId, type], + ); + + useValidation({ + props, + getDroppableRef: () => droppableRef.current, + getPlaceholderRef: () => placeholderRef.current, + }); + + return ( + + {children(provided, snapshot)} + + ); } diff --git a/src/view/droppable/use-validation.js b/src/view/droppable/use-validation.js new file mode 100644 index 0000000000..63299971ea --- /dev/null +++ b/src/view/droppable/use-validation.js @@ -0,0 +1,62 @@ +// @flow +import { useEffect } from 'react'; +import invariant from 'tiny-invariant'; +import type { Props } from './droppable-types'; +import { warning } from '../../dev-warning'; +import checkIsValidInnerRef from '../check-is-valid-inner-ref'; + +function checkOwnProps(props: Props) { + invariant(props.droppableId, 'A Droppable requires a droppableId prop'); + invariant( + typeof props.isDropDisabled === 'boolean', + 'isDropDisabled must be a boolean', + ); + invariant( + typeof props.isCombineEnabled === 'boolean', + 'isCombineEnabled must be a boolean', + ); + invariant( + typeof props.ignoreContainerClipping === 'boolean', + 'ignoreContainerClipping must be a boolean', + ); +} + +function checkPlaceholderRef(props: Props, placeholderEl: ?HTMLElement) { + if (!props.placeholder) { + return; + } + + if (placeholderEl) { + return; + } + + warning(` + Droppable setup issue [droppableId: "${props.droppableId}"]: + DroppableProvided > placeholder could not be found. + + Please be sure to add the {provided.placeholder} React Node as a child of your Droppable. + More information: https://github.com/atlassian/react-beautiful-dnd/blob/master/docs/api/droppable.md + `); +} + +type Args = {| + props: Props, + getDroppableRef: () => ?HTMLElement, + getPlaceholderRef: () => ?HTMLElement, +|}; + +export default function useValidation({ + props, + getDroppableRef, + getPlaceholderRef, +}: Args) { + // Running on every update + useEffect(() => { + // wrapping entire block for better minification + if (process.env.NODE_ENV !== 'production') { + checkOwnProps(props); + checkIsValidInnerRef(getDroppableRef()); + checkPlaceholderRef(props, getPlaceholderRef()); + } + }); +} diff --git a/src/view/error-boundary/error-boundary.jsx b/src/view/error-boundary/error-boundary.jsx index 92c42b52ef..07eaf38812 100644 --- a/src/view/error-boundary/error-boundary.jsx +++ b/src/view/error-boundary/error-boundary.jsx @@ -1,10 +1,9 @@ // @flow import React, { type Node } from 'react'; -import { getFormattedMessage } from '../../dev-warning'; +import { getFormattedMessage, warning } from '../../dev-warning'; type Props = {| - onError: () => void, - children: Node | null, + children: (setOnError: Function) => Node, |}; function printFatalError(error: Error) { @@ -27,6 +26,9 @@ function printFatalError(error: Error) { } export default class ErrorBoundary extends React.Component { + // eslint-disable-next-line react/sort-comp + recover: ?() => void; + componentDidMount() { window.addEventListener('error', this.onFatalError); } @@ -34,9 +36,18 @@ export default class ErrorBoundary extends React.Component { window.removeEventListener('error', this.onFatalError); } + setOnError = (onError: () => void) => { + this.recover = onError; + }; + onFatalError = (error: Error) => { printFatalError(error); - this.props.onError(); + + if (this.recover) { + this.recover(); + } else { + warning('Could not find recovering function'); + } // If the failure was due to an invariant failure - then we handle the error if (error.message.indexOf('Invariant failed') !== -1) { @@ -53,6 +64,6 @@ export default class ErrorBoundary extends React.Component { } render() { - return this.props.children; + return this.props.children(this.setOnError); } } diff --git a/src/view/placeholder/placeholder.jsx b/src/view/placeholder/placeholder.jsx index 7f0ca6cc80..84889aac99 100644 --- a/src/view/placeholder/placeholder.jsx +++ b/src/view/placeholder/placeholder.jsx @@ -1,5 +1,6 @@ // @flow -import React, { PureComponent } from 'react'; +import React, { useState, useRef, useEffect, type Node } from 'react'; +import { useCallbackOne } from 'use-memo-one'; import type { Spacing } from 'css-box-model'; import type { Placeholder as PlaceholderType, @@ -8,6 +9,8 @@ import type { import { transitions } from '../../animation'; import { noSpacing } from '../../state/spacing'; +function noop() {} + export type PlaceholderStyle = {| display: string, boxSizing: 'border-box', @@ -22,12 +25,13 @@ export type PlaceholderStyle = {| pointerEvents: 'none', transition: string, |}; -type Props = {| +export type Props = {| placeholder: PlaceholderType, animate: InOutAnimationMode, onClose: () => void, innerRef?: () => ?HTMLElement, onTransitionEnd: () => void, + styleContext: string, |}; type Size = {| @@ -37,9 +41,10 @@ type Size = {| margin: Spacing, |}; -type State = {| +type HelperArgs = {| isAnimatingOpenOnMount: boolean, - // useEmpty: boolean, + placeholder: PlaceholderType, + animate: InOutAnimationMode, |}; const empty: Size = { @@ -48,129 +53,145 @@ const empty: Size = { margin: noSpacing, }; -export default class Placeholder extends PureComponent { - mountTimerId: ?TimeoutID = null; +const getSize = ({ + isAnimatingOpenOnMount, + placeholder, + animate, +}: HelperArgs): Size => { + if (isAnimatingOpenOnMount) { + return empty; + } + + if (animate === 'close') { + return empty; + } - state: State = { - isAnimatingOpenOnMount: this.props.animate === 'open', + return { + height: placeholder.client.borderBox.height, + width: placeholder.client.borderBox.width, + margin: placeholder.client.margin, }; +}; - // called before render() on initial mount and updates - static getDerivedStateFromProps(props: Props, state: State): State { - // An animated open is no longer relevant. - if (state.isAnimatingOpenOnMount && props.animate !== 'open') { - return { - isAnimatingOpenOnMount: false, - }; - } +const getStyle = ({ + isAnimatingOpenOnMount, + placeholder, + animate, +}: HelperArgs): PlaceholderStyle => { + const size: Size = getSize({ isAnimatingOpenOnMount, placeholder, animate }); + + return { + display: placeholder.display, + // ## Recreating the box model + // We created the borderBox and then apply the margins directly + // this is to maintain any margin collapsing behaviour + + // creating borderBox + // background: 'green', + boxSizing: 'border-box', + width: size.width, + height: size.height, + // creating marginBox + marginTop: size.margin.top, + marginRight: size.margin.right, + marginBottom: size.margin.bottom, + marginLeft: size.margin.left, + + // ## Avoiding collapsing + // Avoiding the collapsing or growing of this element when pushed by flex child siblings. + // We have already taken a snapshot the current dimensions we do not want this element + // to recalculate its dimensions + // It is okay for these properties to be applied on elements that are not flex children + flexShrink: '0', + flexGrow: '0', + // Just a little performance optimisation: avoiding the browser needing + // to worry about pointer events for this element + pointerEvents: 'none', + + // Animate the placeholder size and margin + transition: transitions.placeholder, + }; +}; - return state; - } +function Placeholder(props: Props): Node { + const animateOpenTimerRef = useRef(null); - componentDidMount() { - if (!this.state.isAnimatingOpenOnMount) { + const tryClearAnimateOpenTimer = useCallbackOne(() => { + if (!animateOpenTimerRef.current) { return; } - - // Ensuring there is one browser update with an empty size - // .setState in componentDidMount will cause two react renders - // but only a single browser update - // https://reactjs.org/docs/react-component.html#componentdidmount - this.mountTimerId = setTimeout(() => { - this.mountTimerId = null; - - if (this.state.isAnimatingOpenOnMount) { - this.setState({ - isAnimatingOpenOnMount: false, - }); - } - }); - } - - componentWillUnmount() { - if (!this.mountTimerId) { - return; + clearTimeout(animateOpenTimerRef.current); + animateOpenTimerRef.current = null; + }, []); + + const { animate, onTransitionEnd, onClose, styleContext } = props; + const [isAnimatingOpenOnMount, setIsAnimatingOpenOnMount] = useState( + props.animate === 'open', + ); + + // Will run after a render is flushed + // Still need to wait a timeout to ensure that the + // update is completely applied to the DOM + useEffect(() => { + // No need to do anything + if (!isAnimatingOpenOnMount) { + return noop; } - clearTimeout(this.mountTimerId); - this.mountTimerId = null; - } - onTransitionEnd = (event: TransitionEvent) => { - // We transition height, width and margin - // each of those transitions will independently call this callback - // Because they all have the same duration we can just respond to one of them - // 'height' was chosen for no particular reason :D - if (event.propertyName !== 'height') { - return; + // might need to clear the timer + if (animate !== 'open') { + tryClearAnimateOpenTimer(); + setIsAnimatingOpenOnMount(false); + return noop; } - this.props.onTransitionEnd(); - - if (this.props.animate === 'close') { - this.props.onClose(); + // timer already pending + if (animateOpenTimerRef.current) { + return noop; } - }; - getSize(): Size { - if (this.state.isAnimatingOpenOnMount) { - return empty; - } + animateOpenTimerRef.current = setTimeout(() => { + animateOpenTimerRef.current = null; + setIsAnimatingOpenOnMount(false); + }); - if (this.props.animate === 'close') { - return empty; - } + // clear the timer if needed + return tryClearAnimateOpenTimer; + }, [animate, isAnimatingOpenOnMount, tryClearAnimateOpenTimer]); + + const onSizeChangeEnd = useCallbackOne( + (event: TransitionEvent) => { + // We transition height, width and margin + // each of those transitions will independently call this callback + // Because they all have the same duration we can just respond to one of them + // 'height' was chosen for no particular reason :D + if (event.propertyName !== 'height') { + return; + } - const placeholder: PlaceholderType = this.props.placeholder; - return { - height: placeholder.client.borderBox.height, - width: placeholder.client.borderBox.width, - margin: placeholder.client.margin, - }; - } + onTransitionEnd(); - render() { - const placeholder: PlaceholderType = this.props.placeholder; - const size: Size = this.getSize(); - const { display, tagName } = placeholder; - - // The goal of the placeholder is to take up the same amount of space - // as the original draggable - const style: PlaceholderStyle = { - display, - // ## Recreating the box model - // We created the borderBox and then apply the margins directly - // this is to maintain any margin collapsing behaviour - - // creating borderBox - // background: 'green', - boxSizing: 'border-box', - width: size.width, - height: size.height, - // creating marginBox - marginTop: size.margin.top, - marginRight: size.margin.right, - marginBottom: size.margin.bottom, - marginLeft: size.margin.left, - - // ## Avoiding collapsing - // Avoiding the collapsing or growing of this element when pushed by flex child siblings. - // We have already taken a snapshot the current dimensions we do not want this element - // to recalculate its dimensions - // It is okay for these properties to be applied on elements that are not flex children - flexShrink: '0', - flexGrow: '0', - // Just a little performance optimisation: avoiding the browser needing - // to worry about pointer events for this element - pointerEvents: 'none', - - // Animate the placeholder size and margin - transition: transitions.placeholder, - }; - - return React.createElement(tagName, { - style, - onTransitionEnd: this.onTransitionEnd, - ref: this.props.innerRef, - }); - } + if (animate === 'close') { + onClose(); + } + }, + [animate, onClose, onTransitionEnd], + ); + + const style: PlaceholderStyle = getStyle({ + isAnimatingOpenOnMount, + animate: props.animate, + placeholder: props.placeholder, + }); + + return React.createElement(props.placeholder.tagName, { + style, + 'data-react-beautiful-dnd-placeholder': styleContext, + onTransitionEnd: onSizeChangeEnd, + ref: props.innerRef, + }); } + +export default React.memo(Placeholder); +// enzyme does not work well with memo, so exporting the non-memo version +export const WithoutMemo = Placeholder; diff --git a/src/view/style-marshal/style-marshal.js b/src/view/style-marshal/style-marshal.js deleted file mode 100644 index 7c521280a9..0000000000 --- a/src/view/style-marshal/style-marshal.js +++ /dev/null @@ -1,97 +0,0 @@ -// @flow -import memoizeOne from 'memoize-one'; -import invariant from 'tiny-invariant'; -import getStyles, { type Styles } from './get-styles'; -import { prefix } from '../data-attributes'; -import type { StyleMarshal } from './style-marshal-types'; -import type { DropReason } from '../../types'; - -let count: number = 0; - -// Required for server side rendering as count is persisted across requests -export const resetStyleContext = () => { - count = 0; -}; - -const getHead = (): HTMLHeadElement => { - const head: ?HTMLHeadElement = document.querySelector('head'); - invariant(head, 'Cannot find the head to append a style to'); - return head; -}; - -const createStyleEl = (): HTMLStyleElement => { - const el: HTMLStyleElement = document.createElement('style'); - el.type = 'text/css'; - return el; -}; - -export default () => { - const context: string = `${count++}`; - const styles: Styles = getStyles(context); - let always: ?HTMLStyleElement = null; - let dynamic: ?HTMLStyleElement = null; - - // using memoizeOne as a way of not updating the innerHTML - // unless there is a new value required - const setStyle = memoizeOne((el: ?HTMLStyleElement, proposed: string) => { - invariant(el, 'Cannot set style of style tag if not mounted'); - // This technique works with ie11+ so no need for a nasty fallback as seen here: - // https://stackoverflow.com/a/22050778/1374236 - el.innerHTML = proposed; - }); - - // exposing this as a seperate step so that it works nicely with - // server side rendering - const mount = () => { - invariant(!always && !dynamic, 'Style marshal already mounted'); - - always = createStyleEl(); - dynamic = createStyleEl(); - // for easy identification - always.setAttribute(`${prefix}-always`, context); - dynamic.setAttribute(`${prefix}-dynamic`, context); - - // add style tags to head - getHead().appendChild(always); - getHead().appendChild(dynamic); - - // set initial style - setStyle(always, styles.always); - setStyle(dynamic, styles.resting); - }; - - const dragging = () => setStyle(dynamic, styles.dragging); - const dropping = (reason: DropReason) => { - if (reason === 'DROP') { - setStyle(dynamic, styles.dropAnimating); - return; - } - setStyle(dynamic, styles.userCancel); - }; - const resting = () => setStyle(dynamic, styles.resting); - - const unmount = (): void => { - invariant( - always && dynamic, - 'Cannot unmount style marshal as it is already unmounted', - ); - - // Remove from head - getHead().removeChild(always); - getHead().removeChild(dynamic); - // Unset - always = null; - dynamic = null; - }; - - const marshal: StyleMarshal = { - dragging, - dropping, - resting, - styleContext: context, - mount, - unmount, - }; - - return marshal; -}; diff --git a/src/view/use-announcer/index.js b/src/view/use-announcer/index.js new file mode 100644 index 0000000000..ecb54f8a50 --- /dev/null +++ b/src/view/use-announcer/index.js @@ -0,0 +1,2 @@ +// @flow +export { default } from './use-announcer'; diff --git a/src/view/announcer/announcer.js b/src/view/use-announcer/use-announcer.js similarity index 60% rename from src/view/announcer/announcer.js rename to src/view/use-announcer/use-announcer.js index 73079a4963..798b3e562b 100644 --- a/src/view/announcer/announcer.js +++ b/src/view/use-announcer/use-announcer.js @@ -1,12 +1,11 @@ // @flow +import { useRef, useEffect } from 'react'; import invariant from 'tiny-invariant'; +import { useMemoOne, useCallbackOne } from 'use-memo-one'; import type { Announce } from '../../types'; -import type { Announcer } from './announcer-types'; import { warning } from '../../dev-warning'; import getBodyElement from '../get-body-element'; -let count: number = 0; - // https://allyjs.io/tutorials/hiding-elements.html // Element is visually hidden but is readable by screen readers const visuallyHidden: Object = { @@ -22,32 +21,19 @@ const visuallyHidden: Object = { 'clip-path': 'inset(100%)', }; -export default (): Announcer => { - const id: string = `react-beautiful-dnd-announcement-${count++}`; - let el: ?HTMLElement = null; - - const announce: Announce = (message: string): void => { - if (el) { - el.textContent = message; - return; - } - - warning(` - A screen reader message was trying to be announced but it was unable to do so. - This can occur if you unmount your in your onDragEnd. - Consider calling provided.announce() before the unmount so that the instruction will - not be lost for users relying on a screen reader. +export const getId = (uniqueId: number): string => + `react-beautiful-dnd-announcement-${uniqueId}`; - Message not passed to screen reader: +export default function useAnnouncer(uniqueId: number): Announce { + const id: string = useMemoOne(() => getId(uniqueId), [uniqueId]); + const ref = useRef(null); - "${message}" - `); - }; + useEffect(() => { + invariant(!ref.current, 'Announcement node already mounted'); - const mount = () => { - invariant(!el, 'Announcer already mounted'); + const el: HTMLElement = document.createElement('div'); + ref.current = el; - el = document.createElement('div'); // identifier el.id = id; @@ -64,23 +50,35 @@ export default (): Announcer => { // Add to body getBodyElement().appendChild(el); - }; - const unmount = () => { - invariant(el, 'Will not unmount announcer as it is already unmounted'); + return () => { + const toBeRemoved: ?HTMLElement = ref.current; + invariant(toBeRemoved, 'Cannot unmount announcement node'); - // Remove from body - getBodyElement().removeChild(el); - // Unset - el = null; - }; + // Remove from body + getBodyElement().removeChild(toBeRemoved); + ref.current = null; + }; + }, [id]); - const announcer: Announcer = { - announce, - id, - mount, - unmount, - }; + const announce: Announce = useCallbackOne((message: string): void => { + const el: ?HTMLElement = ref.current; + if (el) { + el.textContent = message; + return; + } - return announcer; -}; + warning(` + A screen reader message was trying to be announced but it was unable to do so. + This can occur if you unmount your in your onDragEnd. + Consider calling provided.announce() before the unmount so that the instruction will + not be lost for users relying on a screen reader. + + Message not passed to screen reader: + + "${message}" + `); + }, []); + + return announce; +} diff --git a/src/view/drag-handle/drag-handle-types.js b/src/view/use-drag-handle/drag-handle-types.js similarity index 94% rename from src/view/drag-handle/drag-handle-types.js rename to src/view/use-drag-handle/drag-handle-types.js index b769ec9d31..8bf62be46c 100644 --- a/src/view/drag-handle/drag-handle-types.js +++ b/src/view/use-drag-handle/drag-handle-types.js @@ -1,13 +1,12 @@ // @flow import { type Position } from 'css-box-model'; -import { type Node } from 'react'; import type { MovementMode, DraggableId } from '../../types'; export type Callbacks = {| onLift: ({ clientSelection: Position, movementMode: MovementMode, - }) => void, + }) => mixed, onMove: (point: Position) => mixed, onWindowScroll: () => mixed, onMoveUp: () => mixed, @@ -44,7 +43,7 @@ export type DragHandleProps = {| onDragStart: (event: DragEvent) => void, |}; -export type Props = {| +export type Args = {| draggableId: DraggableId, // callbacks provided by the draggable callbacks: Callbacks, @@ -59,5 +58,4 @@ export type Props = {| canDragInteractiveElements: boolean, // whether force touch interactions should be respected getShouldRespectForceTouch: () => boolean, - children: (?DragHandleProps) => Node, |}; diff --git a/src/view/use-drag-handle/index.js b/src/view/use-drag-handle/index.js new file mode 100644 index 0000000000..56db94fce8 --- /dev/null +++ b/src/view/use-drag-handle/index.js @@ -0,0 +1,2 @@ +// @flow +export { default } from './use-drag-handle'; diff --git a/src/view/drag-handle/sensor/sensor-types.js b/src/view/use-drag-handle/sensor/sensor-types.js similarity index 90% rename from src/view/drag-handle/sensor/sensor-types.js rename to src/view/use-drag-handle/sensor/sensor-types.js index a66a6a6e63..c66fe8fc35 100644 --- a/src/view/drag-handle/sensor/sensor-types.js +++ b/src/view/use-drag-handle/sensor/sensor-types.js @@ -14,7 +14,7 @@ type SensorBase = {| |}; export type CreateSensorArgs = {| - callbacks: Callbacks, + getCallbacks: () => Callbacks, getDraggableRef: () => ?HTMLElement, getWindow: () => HTMLElement, canStartCapturing: (event: Event) => boolean, @@ -35,3 +35,5 @@ export type TouchSensor = {| ...SensorBase, onTouchStart: (event: TouchEvent) => void, |}; + +export type Sensor = MouseSensor | KeyboardSensor | TouchSensor; diff --git a/src/view/use-drag-handle/sensor/use-keyboard-sensor.js b/src/view/use-drag-handle/sensor/use-keyboard-sensor.js new file mode 100644 index 0000000000..41f2c59786 --- /dev/null +++ b/src/view/use-drag-handle/sensor/use-keyboard-sensor.js @@ -0,0 +1,263 @@ +// @flow +import type { Position } from 'css-box-model'; +import { useRef } from 'react'; +import { useMemoOne, useCallbackOne } 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 = useCallbackOne(() => isDraggingRef.current, []); + + const schedule = useMemoOne(() => { + invariant( + !getIsDragging(), + 'Should not recreate scheduler while capturing', + ); + return createScheduler(callbacks); + }, [callbacks, getIsDragging]); + + const stop = useCallbackOne(() => { + if (!getIsDragging()) { + return; + } + + schedule.cancel(); + unbindWindowEventsRef.current(); + isDraggingRef.current = false; + onCaptureEnd(); + }, [getIsDragging, onCaptureEnd, schedule]); + + const cancel = useCallbackOne(() => { + const wasDragging: boolean = isDraggingRef.current; + stop(); + + if (wasDragging) { + callbacks.onCancel(); + } + }, [callbacks, stop]); + + const windowBindings: EventBinding[] = useMemoOne(() => { + 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 = useCallbackOne(() => { + 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 = useCallbackOne(() => { + 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 = useCallbackOne( + (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 new file mode 100644 index 0000000000..dfc8ecbc15 --- /dev/null +++ b/src/view/use-drag-handle/sensor/use-mouse-sensor.js @@ -0,0 +1,372 @@ +// @flow +import type { Position } from 'css-box-model'; +import { useRef } from 'react'; +import invariant from 'tiny-invariant'; +import { useMemoOne, useCallbackOne } 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, + getShouldRespectForceTouch: () => 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, + getShouldRespectForceTouch, + onCaptureStart, + onCaptureEnd, + } = args; + const pendingRef = useRef(null); + const isDraggingRef = useRef(false); + const unbindWindowEventsRef = useRef<() => void>(noop); + const getIsCapturing = useCallbackOne( + () => Boolean(pendingRef.current || isDraggingRef.current), + [], + ); + + const schedule = useMemoOne(() => { + invariant( + !getIsCapturing(), + 'Should not recreate scheduler while capturing', + ); + return createScheduler(callbacks); + }, [callbacks, getIsCapturing]); + + const postDragEventPreventer: EventPreventer = useMemoOne( + () => createPostDragEventPreventer(getWindow), + [getWindow], + ); + + const stop = useCallbackOne(() => { + 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 = useCallbackOne(() => { + const wasDragging: boolean = isDraggingRef.current; + stop(); + + if (wasDragging) { + callbacks.onCancel(); + } + }, [callbacks, stop]); + + const startDragging = useCallbackOne(() => { + 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[] = useMemoOne(() => { + 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 (!getShouldRespectForceTouch()) { + 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, + getShouldRespectForceTouch, + ]); + + const bindWindowEvents = useCallbackOne(() => { + 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 = useCallbackOne( + (point: Position) => { + invariant(!pendingRef.current, 'Expected there to be no pending drag'); + pendingRef.current = point; + onCaptureStart(stop); + bindWindowEvents(); + }, + [bindWindowEvents, onCaptureStart, stop], + ); + + const onMouseDown = useCallbackOne( + (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 new file mode 100644 index 0000000000..22306c442d --- /dev/null +++ b/src/view/use-drag-handle/sensor/use-touch-sensor.js @@ -0,0 +1,434 @@ +// @flow +import type { Position } from 'css-box-model'; +import { useRef } from 'react'; +import invariant from 'tiny-invariant'; +import { useMemoOne, useCallbackOne } 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, + getShouldRespectForceTouch: () => 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, + getShouldRespectForceTouch, + onCaptureStart, + onCaptureEnd, + } = args; + const pendingRef = useRef(null); + const isDraggingRef = useRef(false); + const hasMovedRef = useRef(false); + const unbindWindowEventsRef = useRef<() => void>(noop); + const getIsCapturing = useCallbackOne( + () => Boolean(pendingRef.current || isDraggingRef.current), + [], + ); + const postDragClickPreventer: EventPreventer = useMemoOne( + () => createPostDragEventPreventer(getWindow), + [getWindow], + ); + + const schedule = useMemoOne(() => { + invariant( + !getIsCapturing(), + 'Should not recreate scheduler while capturing', + ); + return createScheduler(callbacks); + }, [callbacks, getIsCapturing]); + + const stop = useCallbackOne(() => { + 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 = useCallbackOne(() => { + const wasDragging: boolean = isDraggingRef.current; + stop(); + + if (wasDragging) { + callbacks.onCancel(); + } + }, [callbacks, stop]); + + const windowBindings: EventBinding[] = useMemoOne(() => { + 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 + options: { passive: 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) => { + // 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 + + // Not respecting force touches - prevent the event + if (!getShouldRespectForceTouch()) { + event.preventDefault(); + return; + } + + 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, + getShouldRespectForceTouch, + schedule, + stop, + ]); + + const bindWindowEvents = useCallbackOne(() => { + 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 = useCallbackOne(() => { + 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 = useCallbackOne( + (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 new file mode 100644 index 0000000000..37f05462ef --- /dev/null +++ b/src/view/use-drag-handle/use-drag-handle.js @@ -0,0 +1,236 @@ +// @flow +import invariant from 'tiny-invariant'; +import { useRef } from 'react'; +import { useMemoOne, useCallbackOne } 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 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, +} 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 useIsomorphicLayoutEffect from '../use-isomorphic-layout-effect'; + +function preventHtml5Dnd(event: DragEvent) { + event.preventDefault(); +} + +type Capturing = {| + abort: () => void, +|}; + +export default function useDragHandle(args: Args): ?DragHandleProps { + // Capturing + const capturingRef = useRef(null); + const onCaptureStart = useCallbackOne((abort: () => void) => { + invariant( + !capturingRef.current, + 'Cannot start capturing while something else is', + ); + capturingRef.current = { + abort, + }; + }, []); + const onCaptureEnd = useCallbackOne(() => { + invariant( + capturingRef.current, + 'Cannot stop capturing while nothing is capturing', + ); + capturingRef.current = null; + }, []); + const abortCapture = useCallbackOne(() => { + invariant(capturingRef.current, 'Cannot abort capture when there is none'); + capturingRef.current.abort(); + }, []); + + const { canLift, style: styleContext }: AppContextValue = useRequiredContext( + AppContext, + ); + const { + isDragging, + isEnabled, + draggableId, + callbacks, + getDraggableRef, + getShouldRespectForceTouch, + canDragInteractiveElements, + } = args; + const lastArgsRef = usePreviousRef(args); + + useValidation(getDraggableRef); + + const getWindow = useCallbackOne( + (): HTMLElement => getWindowFromEl(getDraggableRef()), + [getDraggableRef], + ); + + const canStartCapturing = useCallbackOne( + (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 = useMemoOne( + () => ({ + callbacks, + getDraggableRef, + getWindow, + canStartCapturing, + onCaptureStart, + onCaptureEnd, + getShouldRespectForceTouch, + }), + [ + callbacks, + getDraggableRef, + getWindow, + canStartCapturing, + onCaptureStart, + onCaptureEnd, + getShouldRespectForceTouch, + ], + ); + const onMouseDown = useMouseSensor(mouseArgs); + + const keyboardArgs: KeyboardSensorArgs = useMemoOne( + () => ({ + callbacks, + getDraggableRef, + getWindow, + canStartCapturing, + onCaptureStart, + onCaptureEnd, + }), + [ + callbacks, + canStartCapturing, + getDraggableRef, + getWindow, + onCaptureEnd, + onCaptureStart, + ], + ); + const onKeyDown = useKeyboardSensor(keyboardArgs); + + const touchArgs: TouchSensorArgs = useMemoOne( + () => ({ + callbacks, + getDraggableRef, + getWindow, + canStartCapturing, + getShouldRespectForceTouch, + onCaptureStart, + onCaptureEnd, + }), + [ + callbacks, + getDraggableRef, + getWindow, + canStartCapturing, + getShouldRespectForceTouch, + onCaptureStart, + onCaptureEnd, + ], + ); + const onTouchStart = useTouchSensor(touchArgs); + + // aborting on unmount + + useIsomorphicLayoutEffect(() => { + // 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(); + } + } + + // 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 + useIsomorphicLayoutEffect(() => { + if (!isDragging && capturingRef.current) { + abortCapture(); + } + }, [abortCapture, isDragging]); + + const props: ?DragHandleProps = useMemoOne(() => { + if (!isEnabled) { + return null; + } + return { + onMouseDown, + onKeyDown, + onTouchStart, + onFocus, + onBlur, + tabIndex: 0, + 'data-react-beautiful-dnd-drag-handle': styleContext, + // 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, + }; + }, [ + isEnabled, + onBlur, + onFocus, + onKeyDown, + onMouseDown, + onTouchStart, + styleContext, + ]); + + return props; +} diff --git a/src/view/use-drag-handle/use-focus-retainer.js b/src/view/use-drag-handle/use-focus-retainer.js new file mode 100644 index 0000000000..c40c5de944 --- /dev/null +++ b/src/view/use-drag-handle/use-focus-retainer.js @@ -0,0 +1,94 @@ +// @flow +import invariant from 'tiny-invariant'; +import { useRef } from 'react'; +import { useCallbackOne } 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 useIsomorphicLayoutEffect 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 = useCallbackOne(() => { + isFocusedRef.current = true; + }, []); + const onBlur = useCallbackOne(() => { + isFocusedRef.current = false; + }, []); + + useIsomorphicLayoutEffect(() => { + // 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 () => { + 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]); + + const lastDraggableRef = useRef(getDraggableRef()); + + useIsomorphicLayoutEffect(() => { + const draggableRef: ?HTMLElement = getDraggableRef(); + + // Cannot focus on nothing + if (!draggableRef) { + return; + } + + // no change in ref + if (draggableRef === lastArgsRef.current.draggableId) { + return; + } + + // ref has changed - let's do this + if (isFocusedRef.current && lastArgsRef.current.isEnabled) { + getDragHandleRef(draggableRef).focus(); + } + + // Doing our own should run check + }); + + useIsomorphicLayoutEffect(() => { + 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 new file mode 100644 index 0000000000..0454eb45d6 --- /dev/null +++ b/src/view/use-drag-handle/use-validation.js @@ -0,0 +1,16 @@ +// @flow +import { useEffect } from 'react'; +import invariant from 'tiny-invariant'; +import getDragHandleRef from './util/get-drag-handle-ref'; + +export default function useValidation(getDraggableRef: () => ?HTMLElement) { + // validate ref on mount + useEffect(() => { + // wrapping entire block for better minification + if (process.env.NODE_ENV !== 'production') { + const draggableRef: ?HTMLElement = getDraggableRef(); + invariant(draggableRef, 'Drag handle was unable to find draggable ref'); + getDragHandleRef(draggableRef); + } + }, [getDraggableRef]); +} diff --git a/src/view/drag-handle/util/bind-events.js b/src/view/use-drag-handle/util/bind-events.js similarity index 100% rename from src/view/drag-handle/util/bind-events.js rename to src/view/use-drag-handle/util/bind-events.js diff --git a/src/view/drag-handle/util/create-event-marshal.js b/src/view/use-drag-handle/util/create-event-marshal.js similarity index 100% rename from src/view/drag-handle/util/create-event-marshal.js rename to src/view/use-drag-handle/util/create-event-marshal.js diff --git a/src/view/drag-handle/util/create-post-drag-event-preventer.js b/src/view/use-drag-handle/util/create-post-drag-event-preventer.js similarity index 100% rename from src/view/drag-handle/util/create-post-drag-event-preventer.js rename to src/view/use-drag-handle/util/create-post-drag-event-preventer.js diff --git a/src/view/drag-handle/util/create-scheduler.js b/src/view/use-drag-handle/util/create-scheduler.js similarity index 100% rename from src/view/drag-handle/util/create-scheduler.js rename to src/view/use-drag-handle/util/create-scheduler.js diff --git a/src/view/drag-handle/util/event-types.js b/src/view/use-drag-handle/util/event-types.js similarity index 100% rename from src/view/drag-handle/util/event-types.js rename to src/view/use-drag-handle/util/event-types.js diff --git a/src/view/drag-handle/util/focus-retainer.js b/src/view/use-drag-handle/util/focus-retainer.js similarity index 100% rename from src/view/drag-handle/util/focus-retainer.js rename to src/view/use-drag-handle/util/focus-retainer.js diff --git a/src/view/drag-handle/util/get-drag-handle-ref.js b/src/view/use-drag-handle/util/get-drag-handle-ref.js similarity index 100% rename from src/view/drag-handle/util/get-drag-handle-ref.js rename to src/view/use-drag-handle/util/get-drag-handle-ref.js diff --git a/src/view/drag-handle/util/is-sloppy-click-threshold-exceeded.js b/src/view/use-drag-handle/util/is-sloppy-click-threshold-exceeded.js similarity index 100% rename from src/view/drag-handle/util/is-sloppy-click-threshold-exceeded.js rename to src/view/use-drag-handle/util/is-sloppy-click-threshold-exceeded.js diff --git a/src/view/drag-handle/util/prevent-standard-key-events.js b/src/view/use-drag-handle/util/prevent-standard-key-events.js similarity index 100% rename from src/view/drag-handle/util/prevent-standard-key-events.js rename to src/view/use-drag-handle/util/prevent-standard-key-events.js diff --git a/src/view/drag-handle/util/should-allow-dragging-from-target.js b/src/view/use-drag-handle/util/should-allow-dragging-from-target.js similarity index 92% rename from src/view/drag-handle/util/should-allow-dragging-from-target.js rename to src/view/use-drag-handle/util/should-allow-dragging-from-target.js index d1b2141eb4..fe43adce04 100644 --- a/src/view/drag-handle/util/should-allow-dragging-from-target.js +++ b/src/view/use-drag-handle/util/should-allow-dragging-from-target.js @@ -1,5 +1,4 @@ // @flow -import type { Props } from '../drag-handle-types'; import isElement from '../../is-type-of-element/is-element'; export type TagNameMap = { @@ -56,9 +55,9 @@ const isAnInteractiveElement = ( return isAnInteractiveElement(parent, current.parentElement); }; -export default (event: Event, props: Props): boolean => { +export default (event: Event, canDragInteractiveElements: boolean): boolean => { // Allowing drag with all element types - if (props.canDragInteractiveElements) { + if (canDragInteractiveElements) { return true; } diff --git a/src/view/drag-handle/util/supported-page-visibility-event-name.js b/src/view/use-drag-handle/util/supported-page-visibility-event-name.js similarity index 100% rename from src/view/drag-handle/util/supported-page-visibility-event-name.js rename to src/view/use-drag-handle/util/supported-page-visibility-event-name.js diff --git a/src/view/use-draggable-dimension-publisher/get-dimension.js b/src/view/use-draggable-dimension-publisher/get-dimension.js new file mode 100644 index 0000000000..b589b95045 --- /dev/null +++ b/src/view/use-draggable-dimension-publisher/get-dimension.js @@ -0,0 +1,44 @@ +// @flow +import { + type BoxModel, + type Position, + calculateBox, + withScroll, +} from 'css-box-model'; +import type { + DraggableDescriptor, + DraggableDimension, + Placeholder, +} from '../../types'; +import { origin } from '../../state/position'; + +export default function getDimension( + descriptor: DraggableDescriptor, + el: HTMLElement, + windowScroll?: Position = origin, +): DraggableDimension { + const computedStyles: CSSStyleDeclaration = window.getComputedStyle(el); + const borderBox: ClientRect = el.getBoundingClientRect(); + const client: BoxModel = calculateBox(borderBox, computedStyles); + const page: BoxModel = withScroll(client, windowScroll); + + const placeholder: Placeholder = { + client, + tagName: el.tagName.toLowerCase(), + display: computedStyles.display, + }; + const displaceBy: Position = { + x: client.marginBox.width, + y: client.marginBox.height, + }; + + const dimension: DraggableDimension = { + descriptor, + placeholder, + displaceBy, + client, + page, + }; + + return dimension; +} diff --git a/src/view/use-draggable-dimension-publisher/index.js b/src/view/use-draggable-dimension-publisher/index.js new file mode 100644 index 0000000000..04c9114c8b --- /dev/null +++ b/src/view/use-draggable-dimension-publisher/index.js @@ -0,0 +1,2 @@ +// @flow +export { default } from './use-draggable-dimension-publisher'; 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 new file mode 100644 index 0000000000..9e56812429 --- /dev/null +++ b/src/view/use-draggable-dimension-publisher/use-draggable-dimension-publisher.js @@ -0,0 +1,78 @@ +// @flow +import { useRef } from 'react'; +import { type Position } from 'css-box-model'; +import invariant from 'tiny-invariant'; +import { useMemoOne, useCallbackOne } from 'use-memo-one'; +import type { + DraggableDescriptor, + DraggableDimension, + DraggableId, +} from '../../types'; +import type { DimensionMarshal } from '../../state/dimension-marshal/dimension-marshal-types'; +import useRequiredContext from '../use-required-context'; +import AppContext, { type AppContextValue } from '../context/app-context'; +import getDimension from './get-dimension'; +import DroppableContext, { + type DroppableContextValue, +} from '../context/droppable-context'; +import useIsomorphicLayoutEffect from '../use-isomorphic-layout-effect'; + +export type Args = {| + draggableId: DraggableId, + index: number, + getDraggableRef: () => ?HTMLElement, +|}; + +export default function useDraggableDimensionPublisher(args: Args) { + const { draggableId, index, getDraggableRef } = args; + // App context + const appContext: AppContextValue = useRequiredContext(AppContext); + const marshal: DimensionMarshal = appContext.marshal; + + // Droppable context + const droppableContext: DroppableContextValue = useRequiredContext( + DroppableContext, + ); + const { droppableId, type } = droppableContext; + + const descriptor: DraggableDescriptor = useMemoOne(() => { + const result = { + id: draggableId, + droppableId, + type, + index, + }; + return result; + }, [draggableId, droppableId, index, type]); + + const publishedDescriptorRef = useRef(descriptor); + + const makeDimension = useCallbackOne( + (windowScroll?: Position): DraggableDimension => { + const latest: DraggableDescriptor = publishedDescriptorRef.current; + const el: ?HTMLElement = getDraggableRef(); + invariant(el, 'Cannot get dimension when no ref is set'); + return getDimension(latest, el, windowScroll); + }, + [getDraggableRef], + ); + + // handle mounting / unmounting + useIsomorphicLayoutEffect(() => { + marshal.registerDraggable(publishedDescriptorRef.current, makeDimension); + return () => marshal.unregisterDraggable(publishedDescriptorRef.current); + }, [makeDimension, marshal]); + + // handle updates to descriptor + useIsomorphicLayoutEffect(() => { + // this will happen when mounting + if (publishedDescriptorRef.current === descriptor) { + return; + } + + const previous: DraggableDescriptor = publishedDescriptorRef.current; + publishedDescriptorRef.current = descriptor; + + marshal.updateDraggable(previous, descriptor, makeDimension); + }, [descriptor, makeDimension, marshal]); +} diff --git a/src/view/droppable-dimension-publisher/check-for-nested-scroll-container.js b/src/view/use-droppable-dimension-publisher/check-for-nested-scroll-container.js similarity index 100% rename from src/view/droppable-dimension-publisher/check-for-nested-scroll-container.js rename to src/view/use-droppable-dimension-publisher/check-for-nested-scroll-container.js diff --git a/src/view/droppable-dimension-publisher/get-closest-scrollable.js b/src/view/use-droppable-dimension-publisher/get-closest-scrollable.js similarity index 100% rename from src/view/droppable-dimension-publisher/get-closest-scrollable.js rename to src/view/use-droppable-dimension-publisher/get-closest-scrollable.js diff --git a/src/view/droppable-dimension-publisher/get-dimension.js b/src/view/use-droppable-dimension-publisher/get-dimension.js similarity index 100% rename from src/view/droppable-dimension-publisher/get-dimension.js rename to src/view/use-droppable-dimension-publisher/get-dimension.js diff --git a/src/view/droppable-dimension-publisher/get-env.js b/src/view/use-droppable-dimension-publisher/get-env.js similarity index 100% rename from src/view/droppable-dimension-publisher/get-env.js rename to src/view/use-droppable-dimension-publisher/get-env.js diff --git a/src/view/use-droppable-dimension-publisher/get-listener-options.js b/src/view/use-droppable-dimension-publisher/get-listener-options.js new file mode 100644 index 0000000000..dcaadf66bc --- /dev/null +++ b/src/view/use-droppable-dimension-publisher/get-listener-options.js @@ -0,0 +1,12 @@ +// @flow +import type { ScrollOptions } from '../../types'; + +const immediate = { + passive: false, +}; +const delayed = { + passive: true, +}; + +export default (options: ScrollOptions) => + options.shouldPublishImmediately ? immediate : delayed; diff --git a/src/view/droppable-dimension-publisher/get-scroll.js b/src/view/use-droppable-dimension-publisher/get-scroll.js similarity index 100% rename from src/view/droppable-dimension-publisher/get-scroll.js rename to src/view/use-droppable-dimension-publisher/get-scroll.js diff --git a/src/view/use-droppable-dimension-publisher/index.js b/src/view/use-droppable-dimension-publisher/index.js new file mode 100644 index 0000000000..21a4c1fa25 --- /dev/null +++ b/src/view/use-droppable-dimension-publisher/index.js @@ -0,0 +1,2 @@ +// @flow +export { default } from './use-droppable-dimension-publisher'; diff --git a/src/view/droppable-dimension-publisher/is-in-fixed-container.js b/src/view/use-droppable-dimension-publisher/is-in-fixed-container.js similarity index 100% rename from src/view/droppable-dimension-publisher/is-in-fixed-container.js rename to src/view/use-droppable-dimension-publisher/is-in-fixed-container.js diff --git a/src/view/use-droppable-dimension-publisher/use-droppable-dimension-publisher.js b/src/view/use-droppable-dimension-publisher/use-droppable-dimension-publisher.js new file mode 100644 index 0000000000..1b8d4c0bb6 --- /dev/null +++ b/src/view/use-droppable-dimension-publisher/use-droppable-dimension-publisher.js @@ -0,0 +1,279 @@ +// @flow +import { useRef } from 'react'; +import invariant from 'tiny-invariant'; +import { type Position } from 'css-box-model'; +import rafSchedule from 'raf-schd'; +import { useMemoOne, useCallbackOne } from 'use-memo-one'; +import memoizeOne from 'memoize-one'; +import checkForNestedScrollContainers from './check-for-nested-scroll-container'; +import { origin } from '../../state/position'; +import getScroll from './get-scroll'; +import type { + DimensionMarshal, + DroppableCallbacks, + RecollectDroppableOptions, +} from '../../state/dimension-marshal/dimension-marshal-types'; +import getEnv, { type Env } from './get-env'; +import type { + DroppableId, + TypeId, + DroppableDimension, + DroppableDescriptor, + Direction, + ScrollOptions, +} from '../../types'; +import getDimension from './get-dimension'; +import AppContext, { type AppContextValue } from '../context/app-context'; +import withoutPlaceholder from './without-placeholder'; +import { warning } from '../../dev-warning'; +import getListenerOptions from './get-listener-options'; +import useRequiredContext from '../use-required-context'; +import usePreviousRef from '../use-previous-ref'; +import useIsomorphicLayoutEffect from '../use-isomorphic-layout-effect'; + +type Props = {| + droppableId: DroppableId, + type: TypeId, + direction: Direction, + isDropDisabled: boolean, + isCombineEnabled: boolean, + ignoreContainerClipping: boolean, + getPlaceholderRef: () => ?HTMLElement, + getDroppableRef: () => ?HTMLElement, +|}; + +type WhileDragging = {| + ref: HTMLElement, + descriptor: DroppableDescriptor, + env: Env, + scrollOptions: ScrollOptions, +|}; + +const getClosestScrollableFromDrag = (dragging: ?WhileDragging): ?Element => + (dragging && dragging.env.closestScrollable) || null; + +export default function useDroppableDimensionPublisher(args: Props) { + const whileDraggingRef = useRef(null); + const appContext: AppContextValue = useRequiredContext(AppContext); + const marshal: DimensionMarshal = appContext.marshal; + const previousRef: { current: Props } = usePreviousRef(args); + const descriptor: DroppableDescriptor = useMemoOne((): DroppableDescriptor => { + return { + id: args.droppableId, + type: args.type, + }; + }, [args.droppableId, args.type]); + const publishedDescriptorRef = useRef(descriptor); + + const memoizedUpdateScroll = useMemoOne( + () => + memoizeOne((x: number, y: number) => { + invariant( + whileDraggingRef.current, + 'Can only update scroll when dragging', + ); + const scroll: Position = { x, y }; + marshal.updateDroppableScroll(descriptor.id, scroll); + }), + [descriptor.id, marshal], + ); + + const getClosestScroll = useCallbackOne((): Position => { + const dragging: ?WhileDragging = whileDraggingRef.current; + if (!dragging || !dragging.env.closestScrollable) { + return origin; + } + + return getScroll(dragging.env.closestScrollable); + }, []); + + const updateScroll = useCallbackOne(() => { + const scroll: Position = getClosestScroll(); + memoizedUpdateScroll(scroll.x, scroll.y); + }, [getClosestScroll, memoizedUpdateScroll]); + + const scheduleScrollUpdate = useMemoOne(() => rafSchedule(updateScroll), [ + updateScroll, + ]); + + const onClosestScroll = useCallbackOne(() => { + const dragging: ?WhileDragging = whileDraggingRef.current; + const closest: ?Element = getClosestScrollableFromDrag(dragging); + + invariant( + dragging && closest, + 'Could not find scroll options while scrolling', + ); + const options: ScrollOptions = dragging.scrollOptions; + if (options.shouldPublishImmediately) { + updateScroll(); + return; + } + scheduleScrollUpdate(); + }, [scheduleScrollUpdate, updateScroll]); + + const getDimensionAndWatchScroll = useCallbackOne( + (windowScroll: Position, options: ScrollOptions) => { + invariant( + !whileDraggingRef.current, + 'Cannot collect a droppable while a drag is occurring', + ); + const previous: Props = previousRef.current; + const ref: ?HTMLElement = previous.getDroppableRef(); + invariant(ref, 'Cannot collect without a droppable ref'); + const env: Env = getEnv(ref); + + const dragging: WhileDragging = { + ref, + descriptor, + env, + scrollOptions: options, + }; + // side effect + whileDraggingRef.current = dragging; + + const dimension: DroppableDimension = getDimension({ + ref, + descriptor, + env, + windowScroll, + direction: previous.direction, + isDropDisabled: previous.isDropDisabled, + isCombineEnabled: previous.isCombineEnabled, + shouldClipSubject: !previous.ignoreContainerClipping, + }); + + if (env.closestScrollable) { + // bind scroll listener + + env.closestScrollable.addEventListener( + 'scroll', + onClosestScroll, + getListenerOptions(dragging.scrollOptions), + ); + // print a debug warning if using an unsupported nested scroll container setup + if (process.env.NODE_ENV !== 'production') { + checkForNestedScrollContainers(env.closestScrollable); + } + } + + return dimension; + }, + [descriptor, onClosestScroll, previousRef], + ); + const recollect = useCallbackOne( + (options: RecollectDroppableOptions): DroppableDimension => { + const dragging: ?WhileDragging = whileDraggingRef.current; + const closest: ?Element = getClosestScrollableFromDrag(dragging); + invariant( + dragging && closest, + 'Can only recollect Droppable client for Droppables that have a scroll container', + ); + + const previous: Props = previousRef.current; + + const execute = (): DroppableDimension => + getDimension({ + ref: dragging.ref, + descriptor: dragging.descriptor, + env: dragging.env, + windowScroll: origin, + direction: previous.direction, + isDropDisabled: previous.isDropDisabled, + isCombineEnabled: previous.isCombineEnabled, + shouldClipSubject: !previous.ignoreContainerClipping, + }); + + if (!options.withoutPlaceholder) { + return execute(); + } + + return withoutPlaceholder(previous.getPlaceholderRef(), execute); + }, + [previousRef], + ); + const dragStopped = useCallbackOne(() => { + const dragging: ?WhileDragging = whileDraggingRef.current; + invariant(dragging, 'Cannot stop drag when no active drag'); + const closest: ?Element = getClosestScrollableFromDrag(dragging); + + // goodbye old friend + whileDraggingRef.current = null; + + if (!closest) { + return; + } + + // unwatch scroll + scheduleScrollUpdate.cancel(); + closest.removeEventListener( + 'scroll', + onClosestScroll, + getListenerOptions(dragging.scrollOptions), + ); + }, [onClosestScroll, scheduleScrollUpdate]); + + const scroll = useCallbackOne((change: Position) => { + // arrange + const dragging: ?WhileDragging = whileDraggingRef.current; + invariant(dragging, 'Cannot scroll when there is no drag'); + const closest: ?Element = getClosestScrollableFromDrag(dragging); + invariant(closest, 'Cannot scroll a droppable with no closest scrollable'); + + // act + closest.scrollTop += change.y; + closest.scrollLeft += change.x; + }, []); + + const callbacks: DroppableCallbacks = useMemoOne(() => { + return { + getDimensionAndWatchScroll, + recollect, + dragStopped, + scroll, + }; + }, [dragStopped, getDimensionAndWatchScroll, recollect, scroll]); + + // Register with the marshal and let it know of: + // - any descriptor changes + // - when it unmounts + useIsomorphicLayoutEffect(() => { + publishedDescriptorRef.current = descriptor; + marshal.registerDroppable(descriptor, callbacks); + + return () => { + if (whileDraggingRef.current) { + warning( + 'Unsupported: changing the droppableId or type of a Droppable during a drag', + ); + dragStopped(); + } + + marshal.unregisterDroppable(descriptor); + }; + }, [callbacks, descriptor, dragStopped, marshal]); + + // update is enabled with the marshal + // only need to update when there is a drag + useIsomorphicLayoutEffect(() => { + if (!whileDraggingRef.current) { + return; + } + marshal.updateDroppableIsEnabled( + publishedDescriptorRef.current.id, + !args.isDropDisabled, + ); + }, [args.isDropDisabled, marshal]); + + // update is combine enabled with the marshal + // only need to update when there is a drag + useIsomorphicLayoutEffect(() => { + if (!whileDraggingRef.current) { + return; + } + marshal.updateDroppableIsCombineEnabled( + publishedDescriptorRef.current.id, + args.isCombineEnabled, + ); + }, [args.isCombineEnabled, marshal]); +} diff --git a/src/view/use-droppable-dimension-publisher/without-placeholder.js b/src/view/use-droppable-dimension-publisher/without-placeholder.js new file mode 100644 index 0000000000..40d4fe8abe --- /dev/null +++ b/src/view/use-droppable-dimension-publisher/without-placeholder.js @@ -0,0 +1,18 @@ +// @flow +import type { DroppableDimension } from '../../types'; + +export default function withoutPlaceholder( + placeholder: ?HTMLElement, + fn: () => DroppableDimension, +): DroppableDimension { + if (!placeholder) { + return fn(); + } + + const last: string = placeholder.style.display; + placeholder.style.display = 'none'; + const result: DroppableDimension = fn(); + placeholder.style.display = last; + + return result; +} diff --git a/src/view/use-isomorphic-layout-effect.js b/src/view/use-isomorphic-layout-effect.js new file mode 100644 index 0000000000..ef32b241ec --- /dev/null +++ b/src/view/use-isomorphic-layout-effect.js @@ -0,0 +1,13 @@ +// @flow +import { useLayoutEffect, useEffect } from 'react'; + +// https://github.com/reduxjs/react-redux/blob/v7-beta/src/components/connectAdvanced.js#L35 +// React currently throws a warning when using useLayoutEffect on the server. +// To get around it, we can conditionally useEffect on the server (no-op) and +// useLayoutEffect in the browser. We need useLayoutEffect because we want +// `connect` to perform sync updates to a ref to save the latest props after +// a render is actually committed to the DOM. +const useIsomorphicLayoutEffect = + typeof window !== 'undefined' ? useLayoutEffect : useEffect; + +export default useIsomorphicLayoutEffect; diff --git a/src/view/use-previous-ref.js b/src/view/use-previous-ref.js new file mode 100644 index 0000000000..4289e5517d --- /dev/null +++ b/src/view/use-previous-ref.js @@ -0,0 +1,16 @@ +// @flow +import { useRef, useEffect } from 'react'; + +// Should return MutableRefObject but I cannot import this type from 'react'; +// $ExpectError - MutableRefObject I want you +export default function usePrevious(current: T): MutableRefObject { + const ref = useRef(current); + + // will be updated on the next render + useEffect(() => { + ref.current = current; + }); + + // return the existing current (pre render) + return ref; +} diff --git a/src/view/use-required-context.js b/src/view/use-required-context.js new file mode 100644 index 0000000000..989ee24c85 --- /dev/null +++ b/src/view/use-required-context.js @@ -0,0 +1,9 @@ +// @flow +import { useContext, type Context as ContextType } from 'react'; +import invariant from 'tiny-invariant'; + +export default function useRequiredContext(Context: ContextType): T { + const result: ?T = useContext(Context); + invariant(result, 'Could not find required context'); + return result; +} diff --git a/src/view/style-marshal/get-styles.js b/src/view/use-style-marshal/get-styles.js similarity index 97% rename from src/view/style-marshal/get-styles.js rename to src/view/use-style-marshal/get-styles.js index a61b12697a..c7157125d2 100644 --- a/src/view/style-marshal/get-styles.js +++ b/src/view/use-style-marshal/get-styles.js @@ -40,8 +40,8 @@ const getStyles = (rules: Rule[], property: string): string => const noPointerEvents: string = 'pointer-events: none;'; -export default (styleContext: string): Styles => { - const getSelector = makeGetSelector(styleContext); +export default (uniqueContext: string): Styles => { + const getSelector = makeGetSelector(uniqueContext); // ## Drag handle styles diff --git a/src/view/use-style-marshal/index.js b/src/view/use-style-marshal/index.js new file mode 100644 index 0000000000..8eda849ff9 --- /dev/null +++ b/src/view/use-style-marshal/index.js @@ -0,0 +1,2 @@ +// @flow +export { default } from './use-style-marshal'; diff --git a/src/view/style-marshal/style-marshal-types.js b/src/view/use-style-marshal/style-marshal-types.js similarity index 82% rename from src/view/style-marshal/style-marshal-types.js rename to src/view/use-style-marshal/style-marshal-types.js index 18d255d3e0..16e95c0372 100644 --- a/src/view/style-marshal/style-marshal-types.js +++ b/src/view/use-style-marshal/style-marshal-types.js @@ -6,6 +6,4 @@ export type StyleMarshal = {| dropping: (reason: DropReason) => void, resting: () => void, styleContext: string, - unmount: () => void, - mount: () => void, |}; diff --git a/src/view/use-style-marshal/use-style-marshal.js b/src/view/use-style-marshal/use-style-marshal.js new file mode 100644 index 0000000000..068af5c098 --- /dev/null +++ b/src/view/use-style-marshal/use-style-marshal.js @@ -0,0 +1,126 @@ +// @flow +import { useRef } from 'react'; +import memoizeOne from 'memoize-one'; +import { useMemoOne, useCallbackOne } from 'use-memo-one'; +import invariant from 'tiny-invariant'; +import type { StyleMarshal } from './style-marshal-types'; +import type { DropReason } from '../../types'; +import getStyles, { type Styles } from './get-styles'; +import { prefix } from '../data-attributes'; +import useIsomorphicLayoutEffect from '../use-isomorphic-layout-effect'; + +const getHead = (): HTMLHeadElement => { + const head: ?HTMLHeadElement = document.querySelector('head'); + invariant(head, 'Cannot find the head to append a style to'); + return head; +}; + +const createStyleEl = (): HTMLStyleElement => { + const el: HTMLStyleElement = document.createElement('style'); + el.type = 'text/css'; + return el; +}; + +export default function useStyleMarshal(uniqueId: number) { + const uniqueContext: string = useMemoOne(() => `${uniqueId}`, [uniqueId]); + const styles: Styles = useMemoOne(() => getStyles(uniqueContext), [ + uniqueContext, + ]); + const alwaysRef = useRef(null); + const dynamicRef = useRef(null); + + const setDynamicStyle = useCallbackOne( + // Using memoizeOne to prevent frequent updates to textContext + memoizeOne((proposed: string) => { + const el: ?HTMLStyleElement = dynamicRef.current; + invariant(el, 'Cannot set dynamic style element if it is not set'); + el.textContent = proposed; + }), + [], + ); + + const setAlwaysStyle = useCallbackOne((proposed: string) => { + const el: ?HTMLStyleElement = alwaysRef.current; + invariant(el, 'Cannot set dynamic style element if it is not set'); + el.textContent = proposed; + }, []); + + // using layout effect as programatic dragging might start straight away (such as for cypress) + useIsomorphicLayoutEffect(() => { + invariant( + !alwaysRef.current && !dynamicRef.current, + 'style elements already mounted', + ); + + const always: HTMLStyleElement = createStyleEl(); + const dynamic: HTMLStyleElement = createStyleEl(); + + // store their refs + alwaysRef.current = always; + dynamicRef.current = dynamic; + + // for easy identification + always.setAttribute(`${prefix}-always`, uniqueContext); + dynamic.setAttribute(`${prefix}-dynamic`, uniqueContext); + + // add style tags to head + getHead().appendChild(always); + getHead().appendChild(dynamic); + + // set initial style + setAlwaysStyle(styles.always); + setDynamicStyle(styles.resting); + + return () => { + const remove = ref => { + const current: ?HTMLStyleElement = ref.current; + invariant(current, 'Cannot unmount ref as it is not set'); + getHead().removeChild(current); + ref.current = null; + }; + + remove(alwaysRef); + remove(dynamicRef); + }; + }, [ + setAlwaysStyle, + setDynamicStyle, + styles.always, + styles.resting, + uniqueContext, + ]); + + const dragging = useCallbackOne(() => setDynamicStyle(styles.dragging), [ + setDynamicStyle, + styles.dragging, + ]); + const dropping = useCallbackOne( + (reason: DropReason) => { + if (reason === 'DROP') { + setDynamicStyle(styles.dropAnimating); + return; + } + setDynamicStyle(styles.userCancel); + }, + [setDynamicStyle, styles.dropAnimating, styles.userCancel], + ); + const resting = useCallbackOne(() => { + // Can be called defensively + if (!dynamicRef.current) { + return; + } + setDynamicStyle(styles.resting); + }, [setDynamicStyle, styles.resting]); + + const marshal: StyleMarshal = useMemoOne( + () => ({ + dragging, + dropping, + resting, + styleContext: uniqueContext, + }), + [dragging, dropping, resting, uniqueContext], + ); + + return marshal; +} diff --git a/stories/11-portal.stories.js b/stories/11-portal.stories.js index a76b4e62ec..a3bc17f108 100644 --- a/stories/11-portal.stories.js +++ b/stories/11-portal.stories.js @@ -5,5 +5,5 @@ import PortalApp from './src/portal/portal-app'; import { quotes } from './src/data'; storiesOf('Portals', module).add('Using your own portal', () => ( - + )); diff --git a/stories/src/portal/portal-app.jsx b/stories/src/portal/portal-app.jsx index 77e52bab2c..4521da46ed 100644 --- a/stories/src/portal/portal-app.jsx +++ b/stories/src/portal/portal-app.jsx @@ -149,6 +149,7 @@ export default class PortalApp extends Component { )} ))} + {droppableProvided.placeholder} )} diff --git a/test/.eslintrc.js b/test/.eslintrc.js index 0d2ee38ba9..41c47472db 100644 --- a/test/.eslintrc.js +++ b/test/.eslintrc.js @@ -4,5 +4,8 @@ module.exports = { // this is because we often mock console.warn and console.error and adding this rul // avoids needing to constantly be opting out of the rule 'no-console': ['error', { allow: ['warn', 'error'] }], + + // allowing useMemo and useCallback in tests + 'no-restricted-imports': 'off', }, }; diff --git a/test/unit/integration/drop-dev-warnings-for-prod.spec.js b/test/unit/integration/drop-dev-warnings-for-prod.spec.js index f7c61793ae..b494e77d3b 100644 --- a/test/unit/integration/drop-dev-warnings-for-prod.spec.js +++ b/test/unit/integration/drop-dev-warnings-for-prod.spec.js @@ -25,10 +25,14 @@ const getCode = async ({ mode }): Promise => { resolve({ extensions }), commonjs({ include: 'node_modules/**', - // needed for react-is via react-redux v5.1 + // needed for react-is via react-redux // https://stackoverflow.com/questions/50080893/rollup-error-isvalidelementtype-is-not-exported-by-node-modules-react-is-inde/50098540 namedExports: { - 'node_modules/react-is/index.js': ['isValidElementType'], + 'node_modules/react-redux/node_modules/react-is/index.js': [ + 'isValidElementType', + 'isContextConsumer', + ], + 'node_modules/react-dom/index.js': ['unstable_batchedUpdates'], }, }), ]; @@ -39,13 +43,13 @@ const getCode = async ({ mode }): Promise => { const inputOptions = { input: './src/index.js', - external: ['react'], + external: ['react', 'react-dom'], plugins, }; const outputOptions = { format: 'umd', name: 'ReactBeautifulDnd', - globals: { react: 'React' }, + globals: { react: 'React', 'react-dom': 'ReactDOM' }, }; const bundle = await rollup(inputOptions); const result = await bundle.generate(outputOptions); diff --git a/test/unit/integration/responders-integration.spec.js b/test/unit/integration/responders-integration.spec.js index d74b291e6f..f017ce010e 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/drag-handle/util/is-sloppy-click-threshold-exceeded'; +import { sloppyClickThreshold } from '../../../src/view/use-drag-handle/util/is-sloppy-click-threshold-exceeded'; import { dispatchWindowMouseEvent, dispatchWindowKeyDownEvent, @@ -49,14 +49,21 @@ describe('responders integration', () => { const getMountedApp = () => { // Both list and item will have the same dimensions - jest - .spyOn(Element.prototype, 'getBoundingClientRect') - .mockImplementation(() => borderBox); - // Stubbing out totally - not including margins in this - jest - .spyOn(window, 'getComputedStyle') - .mockImplementation(() => getComputedSpacing({})); + const setRefDimensions = (ref: ?HTMLElement) => { + if (!ref) { + return; + } + + jest + .spyOn(ref, 'getBoundingClientRect') + .mockImplementation(() => borderBox); + + // Stubbing out totally - not including margins in this + jest + .spyOn(window, 'getComputedStyle') + .mockImplementation(() => getComputedSpacing({})); + }; return mount( { {(droppableProvided: DroppableProvided) => (
{ + setRefDimensions(ref); + droppableProvided.innerRef(ref); + }} {...droppableProvided.droppableProps} >

Droppable

@@ -76,7 +86,10 @@ describe('responders integration', () => { {(draggableProvided: DraggableProvided) => (
{ + setRefDimensions(ref); + draggableProvided.innerRef(ref); + }} {...draggableProvided.draggableProps} {...draggableProvided.dragHandleProps} > @@ -143,25 +156,38 @@ describe('responders integration', () => { const move = () => { windowMouseMove({ x: dragMove.x, - y: dragMove.y + sloppyClickThreshold + 1, + y: dragMove.y, }); - // movements are scheduled with setTimeout + // movements are scheduled in an animation frame + requestAnimationFrame.step(); + // responder updates are scheduled with setTimeout jest.runOnlyPendingTimers(); }; - const waitForReturnToHome = () => { - // cheating - wrapper.find(Draggable).simulate('transitionEnd'); + const tryFlushDropAnimation = () => { + // could not get this right just using window events + const props = wrapper + .find('[data-react-beautiful-dnd-draggable]') + .first() + .props(); + + if (props.onTransitionEnd) { + props.onTransitionEnd({ propertyName: 'transform' }); + } }; const stop = () => { windowMouseUp(); - waitForReturnToHome(); + // tell enzyme the onTransitionEnd prop has chan`ged + wrapper.update(); + tryFlushDropAnimation(); }; const cancel = () => { cancelWithKeyboard(); - waitForReturnToHome(); + // tell enzyme the onTransitionEnd prop has changed + wrapper.update(); + tryFlushDropAnimation(); }; const perform = () => { diff --git a/test/unit/integration/server-side-rendering/__snapshots__/server-rendering.spec.js.snap b/test/unit/integration/server-side-rendering/__snapshots__/server-rendering.spec.js.snap index dca7fb9796..87c17c0604 100644 --- a/test/unit/integration/server-side-rendering/__snapshots__/server-rendering.spec.js.snap +++ b/test/unit/integration/server-side-rendering/__snapshots__/server-rendering.spec.js.snap @@ -1,5 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`should support rendering to a string 1`] = `"
Drag me!
"`; +exports[`should support rendering to a string 1`] = `"
Drag me!
"`; exports[`should support rendering to static markup 1`] = `"
Drag me!
"`; diff --git a/test/unit/integration/server-side-rendering/client-hydration.spec.js b/test/unit/integration/server-side-rendering/client-hydration.spec.js index 9eb97f6812..94d0ea37ac 100644 --- a/test/unit/integration/server-side-rendering/client-hydration.spec.js +++ b/test/unit/integration/server-side-rendering/client-hydration.spec.js @@ -19,8 +19,20 @@ invariant( it('should support hydrating a server side rendered application', () => { // would be done server side + // we need to mock out the warnings caused by useLayoutEffect + // This will not happen on the client as the string is rendered + // on the server + jest.spyOn(console, 'error').mockImplementation(() => {}); + const serverHTML: string = ReactDOMServer.renderToString(); + console.error.mock.calls.forEach(call => { + expect( + call[0].includes('Warning: useLayoutEffect does nothing on the server'), + ).toBe(true); + }); + console.error.mockRestore(); + // would be done client side // would have a fresh server context on the client resetServerContext(); diff --git a/test/unit/integration/server-side-rendering/server-rendering.spec.js b/test/unit/integration/server-side-rendering/server-rendering.spec.js index 6781864653..fd66e1935b 100644 --- a/test/unit/integration/server-side-rendering/server-rendering.spec.js +++ b/test/unit/integration/server-side-rendering/server-rendering.spec.js @@ -8,11 +8,30 @@ import invariant from 'tiny-invariant'; import { resetServerContext } from '../../../../src'; import App from './app'; +const consoleFunctions: string[] = ['warn', 'error', 'log']; + beforeEach(() => { // Reset server context between tests to prevent state being shared between them resetServerContext(); + consoleFunctions.forEach((name: string) => { + jest.spyOn(console, name); + }); +}); + +afterEach(() => { + consoleFunctions.forEach((name: string) => { + // eslint-disable-next-line no-console + console[name].mockRestore(); + }); }); +const expectConsoleNotCalled = () => { + consoleFunctions.forEach((name: string) => { + // eslint-disable-next-line no-console + expect(console[name]).not.toHaveBeenCalled(); + }); +}; + // Checking that the browser globals are not available in this test file invariant( typeof window === 'undefined' && typeof document === 'undefined', @@ -24,6 +43,7 @@ it('should support rendering to a string', () => { expect(result).toEqual(expect.any(String)); expect(result).toMatchSnapshot(); + expectConsoleNotCalled(); }); it('should support rendering to static markup', () => { @@ -31,6 +51,7 @@ it('should support rendering to static markup', () => { expect(result).toEqual(expect.any(String)); expect(result).toMatchSnapshot(); + expectConsoleNotCalled(); }); it('should render identical content when resetting context between renders', () => { @@ -41,4 +62,5 @@ it('should render identical content when resetting context between renders', () resetServerContext(); const nextRenderAfterReset = renderToString(); expect(firstRender).toEqual(nextRenderAfterReset); + expectConsoleNotCalled(); }); diff --git a/test/unit/state/middleware/auto-scroll.spec.js b/test/unit/state/middleware/auto-scroll.spec.js index cc46b6f0ef..f56cf6e848 100644 --- a/test/unit/state/middleware/auto-scroll.spec.js +++ b/test/unit/state/middleware/auto-scroll.spec.js @@ -38,7 +38,7 @@ const getScrollerStub = (): AutoScroller => ({ shouldCancelPending.forEach((action: Action) => { it(`should cancel a pending scroll when a ${action.type} is fired`, () => { const scroller: AutoScroller = getScrollerStub(); - const store: Store = createStore(middleware(() => scroller)); + const store: Store = createStore(middleware(scroller)); store.dispatch(initialPublish(initialPublishArgs)); expect(store.getState().phase).toBe('DRAGGING'); @@ -53,7 +53,7 @@ shouldCancelPending.forEach((action: Action) => { shouldStop.forEach((action: Action) => { it(`should stop the auto scroller when a ${action.type} is fired`, () => { const scroller: AutoScroller = getScrollerStub(); - const store: Store = createStore(middleware(() => scroller)); + const store: Store = createStore(middleware(scroller)); store.dispatch(initialPublish(initialPublishArgs)); expect(store.getState().phase).toBe('DRAGGING'); @@ -66,7 +66,7 @@ shouldStop.forEach((action: Action) => { it('should fire a scroll when there is an update', () => { const scroller: AutoScroller = getScrollerStub(); - const store: Store = createStore(middleware(() => scroller)); + const store: Store = createStore(middleware(scroller)); store.dispatch(initialPublish(initialPublishArgs)); expect(scroller.start).toHaveBeenCalledWith(store.getState()); diff --git a/test/unit/state/middleware/dimension-marshal-stopper.spec.js b/test/unit/state/middleware/dimension-marshal-stopper.spec.js index e5ee6a5c9f..a56c5489bb 100644 --- a/test/unit/state/middleware/dimension-marshal-stopper.spec.js +++ b/test/unit/state/middleware/dimension-marshal-stopper.spec.js @@ -28,9 +28,7 @@ const getMarshal = (stopPublishing: Function): DimensionMarshal => { it('should stop a collection if a drag is aborted', () => { const stopPublishing = jest.fn(); - const store: Store = createStore( - middleware(() => getMarshal(stopPublishing)), - ); + const store: Store = createStore(middleware(getMarshal(stopPublishing))); store.dispatch(initialPublish(initialPublishArgs)); @@ -42,7 +40,7 @@ it('should stop a collection if a drag is aborted', () => { it('should not stop a collection if a drop is pending', () => { const stopPublishing = jest.fn(); const store: Store = createStore( - middleware(() => getMarshal(stopPublishing)), + middleware(getMarshal(stopPublishing)), // will convert the drop into a drop pending dropMiddleware, ); @@ -62,7 +60,7 @@ it('should not stop a collection if a drop is pending', () => { it('should stop a collection if a drag is complete', () => { const stopPublishing = jest.fn(); const store: Store = createStore( - middleware(() => getMarshal(stopPublishing)), + middleware(getMarshal(stopPublishing)), // will convert the drop into a drop pending dropMiddleware, ); @@ -80,7 +78,7 @@ it('should stop a collection if a drag is complete', () => { it('should stop a collection if a drop animation starts', () => { const stopPublishing = jest.fn(); const store: Store = createStore( - middleware(() => getMarshal(stopPublishing)), + middleware(getMarshal(stopPublishing)), // will convert the drop into a drop pending dropMiddleware, ); diff --git a/test/unit/state/middleware/lift.spec.js b/test/unit/state/middleware/lift.spec.js index 1bbf1f9fc9..d5dc87d017 100644 --- a/test/unit/state/middleware/lift.spec.js +++ b/test/unit/state/middleware/lift.spec.js @@ -1,6 +1,6 @@ // @flow import type { CompletedDrag } from '../../../../src/types'; -import type { Store } from '../../../../src/state/store-types'; +import type { Action, Store } from '../../../../src/state/store-types'; import type { DimensionMarshal } from '../../../../src/state/dimension-marshal/dimension-marshal-types'; import middleware from '../../../../src/state/middleware/lift'; import createStore from './util/create-store'; @@ -23,8 +23,8 @@ import { getCompletedArgs, } from '../../../utils/preset-action-args'; -const getMarshal = (store: Store): DimensionMarshal => { - const marshal: DimensionMarshal = getDimensionMarshal(store.dispatch); +const getMarshal = (dispatch: Action => void): DimensionMarshal => { + const marshal: DimensionMarshal = getDimensionMarshal(dispatch); populateMarshal(marshal); return marshal; @@ -45,7 +45,11 @@ it('should throw if a drag cannot be started when a lift action occurs', () => { const mock = jest.fn(); const store: Store = createStore( passThrough(mock), - middleware(() => getMarshal(store)), + middleware( + getMarshal((action: Action) => { + store.dispatch(action); + }), + ), ); // first lift is all good @@ -61,7 +65,11 @@ it('should flush any animating drops', () => { const mock = jest.fn(); const store: Store = createStore( passThrough(mock), - middleware(() => getMarshal(store)), + middleware( + getMarshal((action: Action) => { + store.dispatch(action); + }), + ), ); // start a drag @@ -95,7 +103,11 @@ it('should publish the initial dimensions when lifting', () => { const mock = jest.fn(); const store: Store = createStore( passThrough(mock), - middleware(() => getMarshal(store)), + middleware( + getMarshal((action: Action) => { + store.dispatch(action); + }), + ), ); // first lift is preparing diff --git a/test/unit/state/middleware/style.spec.js b/test/unit/state/middleware/style.spec.js index 577bbfad62..42e0b2a301 100644 --- a/test/unit/state/middleware/style.spec.js +++ b/test/unit/state/middleware/style.spec.js @@ -1,6 +1,6 @@ // @flow import middleware from '../../../../src/state/middleware/style'; -import type { StyleMarshal } from '../../../../src/view/style-marshal/style-marshal-types'; +import type { StyleMarshal } from '../../../../src/view/use-style-marshal/style-marshal-types'; import type { DropReason } from '../../../../src/types'; import type { Store } from '../../../../src/state/store-types'; import createStore from './util/create-store'; @@ -20,8 +20,6 @@ const getMarshalStub = (): StyleMarshal => ({ dragging: jest.fn(), dropping: jest.fn(), resting: jest.fn(), - mount: jest.fn(), - unmount: jest.fn(), styleContext: 'why hello there', }); diff --git a/test/unit/view/announcer.spec.js b/test/unit/view/announcer.spec.js index 82c810a7bc..6720c3e1c8 100644 --- a/test/unit/view/announcer.spec.js +++ b/test/unit/view/announcer.spec.js @@ -1,94 +1,81 @@ // @flow -import createAnnouncer from '../../../src/view/announcer/announcer'; -import type { Announcer } from '../../../src/view/announcer/announcer-types'; - -describe('mounting', () => { - it('should not create a dom node before mount is called', () => { - const announcer: Announcer = createAnnouncer(); - - const el: ?HTMLElement = document.getElementById(announcer.id); - - expect(el).not.toBeTruthy(); - }); - - it('should create a new element when mounting', () => { - const announcer: Announcer = createAnnouncer(); - - announcer.mount(); - const el: ?HTMLElement = document.getElementById(announcer.id); - - expect(el).toBeInstanceOf(HTMLElement); - }); - - it('should throw if attempting to double mount', () => { - const announcer: Announcer = createAnnouncer(); - - announcer.mount(); - - expect(() => announcer.mount()).toThrow(); - }); - - it('should apply the appropriate aria attributes and non visibility styles', () => { - const announcer: Announcer = createAnnouncer(); - - announcer.mount(); - const el: HTMLElement = (document.getElementById(announcer.id): any); - - expect(el.getAttribute('aria-live')).toBe('assertive'); - expect(el.getAttribute('role')).toBe('log'); - expect(el.getAttribute('aria-atomic')).toBe('true'); - - // not checking all the styles - just enough to know we are doing something - expect(el.style.overflow).toBe('hidden'); - }); +import React, { type Node } from 'react'; +import invariant from 'tiny-invariant'; +import { mount } from 'enzyme'; +import type { Announce } from '../../../src/types'; +import useAnnouncer from '../../../src/view/use-announcer'; +import { getId } from '../../../src/view/use-announcer/use-announcer'; + +type Props = {| + uniqueId: number, + children: (announce: Announce) => Node, +|}; + +function WithAnnouncer(props: Props) { + const announce: Announce = useAnnouncer(props.uniqueId); + return props.children(announce); +} + +const getAnnounce = (myMock): Announce => myMock.mock.calls[0][0]; +const getMock = () => jest.fn().mockImplementation(() => null); +const getElement = (uniqueId: number): ?HTMLElement => + document.getElementById(getId(uniqueId)); + +it('should create a new element when mounting', () => { + const wrapper = mount( + {getMock()}, + ); + + const el: ?HTMLElement = getElement(5); + + expect(el).toBeTruthy(); + + wrapper.unmount(); }); -describe('unmounting', () => { - it('should remove the element when unmounting', () => { - const announcer: Announcer = createAnnouncer(); +it('should apply the appropriate aria attributes and non visibility styles', () => { + const wrapper = mount( + {getMock()}, + ); - announcer.mount(); - announcer.unmount(); - const el: ?HTMLElement = document.getElementById(announcer.id); + const el: ?HTMLElement = getElement(5); + invariant(el, 'Could not find announcer'); - expect(el).not.toBeTruthy(); - }); + expect(el.getAttribute('aria-live')).toBe('assertive'); + expect(el.getAttribute('role')).toBe('log'); + expect(el.getAttribute('aria-atomic')).toBe('true'); - it('should throw if attempting to unmount before mounting', () => { - const announcer: Announcer = createAnnouncer(); + // not checking all the styles - just enough to know we are doing something + expect(el.style.overflow).toBe('hidden'); - expect(() => announcer.unmount()).toThrow(); - }); - - it('should throw if unmounting after an unmount', () => { - const announcer: Announcer = createAnnouncer(); - - announcer.mount(); - announcer.unmount(); - - expect(() => announcer.unmount()).toThrow(); - }); + wrapper.unmount(); }); -describe('announcing', () => { - it('should warn if not mounted', () => { - jest.spyOn(console, 'warn').mockImplementation(() => {}); - const announcer: Announcer = createAnnouncer(); +it('should remove the element when unmounting', () => { + const wrapper = mount( + {getMock()}, + ); - announcer.announce('test'); + wrapper.unmount(); - expect(console.warn).toHaveBeenCalled(); + const el: ?HTMLElement = getElement(5); + expect(el).not.toBeTruthy(); +}); - console.warn.mockRestore(); - }); +it('should set the text content of the announcement element', () => { + // arrange + const mock = getMock(); + const wrapper = mount({mock}); + const el: ?HTMLElement = getElement(6); + invariant(el, 'Could not find announcer'); - it('should set the text content of the announcement element', () => { - const announcer: Announcer = createAnnouncer(); - announcer.mount(); - const el: HTMLElement = (document.getElementById(announcer.id): any); + // act + const announce: Announce = getAnnounce(mock); + announce('test'); - announcer.announce('test'); + // assert + expect(el.textContent).toBe('test'); - expect(el.textContent).toBe('test'); - }); + // cleanup + wrapper.unmount(); }); diff --git a/test/unit/view/connected-draggable/child-render-behaviour.spec.js b/test/unit/view/connected-draggable/child-render-behaviour.spec.js index f6ff79e94b..a92df60c5e 100644 --- a/test/unit/view/connected-draggable/child-render-behaviour.spec.js +++ b/test/unit/view/connected-draggable/child-render-behaviour.spec.js @@ -1,37 +1,29 @@ // @flow import React, { Component } from 'react'; import { mount } from 'enzyme'; -import { - combine, - withStore, - withDroppableId, - withDroppableType, - withDimensionMarshal, - withStyleContext, - withCanLift, -} from '../../../utils/get-context-options'; import type { DimensionMarshal } from '../../../../src/state/dimension-marshal/dimension-marshal-types'; import { getMarshalStub, getDroppableCallbacks, } from '../../../utils/dimension-marshal'; +import { DragDropContext } from '../../../../src'; import { getPreset } from '../../../utils/dimension'; import forceUpdate from '../../../utils/force-update'; import Draggable from '../../../../src/view/draggable/connected-draggable'; import type { Provided } from '../../../../src/view/draggable/draggable-types'; +import DroppableContext, { + type DroppableContextValue, +} from '../../../../src/view/context/droppable-context'; const preset = getPreset(); // creating our own marshal so we can publish a droppable // so that the draggable can publish itself const marshal: DimensionMarshal = getMarshalStub(); -const options: Object = combine( - withStore(), - withDroppableId(preset.home.descriptor.id), - withDroppableType(preset.home.descriptor.type), - withDimensionMarshal(marshal), - withStyleContext(), - withCanLift(), -); + +const droppableContext: DroppableContextValue = { + type: preset.home.descriptor.type, + droppableId: preset.home.descriptor.id, +}; // registering a fake droppable so that when a draggable // registers itself the marshal can find its parent @@ -58,11 +50,15 @@ class Person extends Component<{ name: string, provided: Provided }> { class App extends Component<{ currentUser: string }> { render() { return ( - - {(dragProvided: Provided) => ( - - )} - + {}}> + + + {(dragProvided: Provided) => ( + + )} + + + ); } } @@ -76,7 +72,7 @@ afterEach(() => { }); it('should render the child function when the parent renders', () => { - const wrapper = mount(, options); + const wrapper = mount(); expect(Person.prototype.render).toHaveBeenCalledTimes(1); expect(wrapper.find(Person).props().name).toBe('Jake'); @@ -85,7 +81,7 @@ it('should render the child function when the parent renders', () => { }); it('should render the child function when the parent re-renders', () => { - const wrapper = mount(, options); + const wrapper = mount(); forceUpdate(wrapper); @@ -96,7 +92,7 @@ it('should render the child function when the parent re-renders', () => { }); it('should render the child function when the parents props changes that cause a re-render', () => { - const wrapper = mount(, options); + const wrapper = mount(); wrapper.setProps({ currentUser: 'Finn', diff --git a/test/unit/view/connected-droppable/child-render-behaviour.spec.js b/test/unit/view/connected-droppable/child-render-behaviour.spec.js index 6a59637d6c..ac79b63f9e 100644 --- a/test/unit/view/connected-droppable/child-render-behaviour.spec.js +++ b/test/unit/view/connected-droppable/child-render-behaviour.spec.js @@ -1,16 +1,10 @@ // @flow import React, { Component } from 'react'; import { mount } from 'enzyme'; -import { - withStore, - combine, - withDimensionMarshal, - withStyleContext, - withIsMovementAllowed, -} from '../../../utils/get-context-options'; +import type { Provided } from '../../../../src/view/droppable/droppable-types'; import Droppable from '../../../../src/view/droppable/connected-droppable'; import forceUpdate from '../../../utils/force-update'; -import type { Provided } from '../../../../src/view/droppable/droppable-types'; +import { DragDropContext } from '../../../../src'; class Person extends Component<{ name: string, provided: Provided }> { render() { @@ -26,22 +20,17 @@ class Person extends Component<{ name: string, provided: Provided }> { class App extends Component<{ currentUser: string }> { render() { return ( - - {(provided: Provided) => ( - - )} - + {}}> + + {(provided: Provided) => ( + + )} + + ); } } -const contextOptions = combine( - withStore(), - withDimensionMarshal(), - withStyleContext(), - withIsMovementAllowed(), -); - beforeEach(() => { jest.spyOn(Person.prototype, 'render'); }); @@ -51,7 +40,7 @@ afterEach(() => { }); it('should render the child function when the parent renders', () => { - const wrapper = mount(, contextOptions); + const wrapper = mount(); expect(Person.prototype.render).toHaveBeenCalledTimes(1); expect(wrapper.find(Person).props().name).toBe('Jake'); @@ -60,7 +49,7 @@ it('should render the child function when the parent renders', () => { }); it('should render the child function when the parent re-renders', () => { - const wrapper = mount(, contextOptions); + const wrapper = mount(); forceUpdate(wrapper); @@ -71,7 +60,7 @@ it('should render the child function when the parent re-renders', () => { }); it('should render the child function when the parents props changes that cause a re-render', () => { - const wrapper = mount(, contextOptions); + const wrapper = mount(); wrapper.setProps({ currentUser: 'Finn', diff --git a/test/unit/view/dimension-marshal/initial-publish.spec.js b/test/unit/view/dimension-marshal/initial-publish.spec.js index 4c16ec10cb..b7842b22ed 100644 --- a/test/unit/view/dimension-marshal/initial-publish.spec.js +++ b/test/unit/view/dimension-marshal/initial-publish.spec.js @@ -186,8 +186,8 @@ it('should publish droppables that have been updated (id change)', () => { id: 'some new id', }, }; - marshal.updateDroppable( - preset.home.descriptor, + marshal.unregisterDroppable(preset.home.descriptor); + marshal.registerDroppable( updatedHome.descriptor, getDroppableCallbacks(updatedHome), ); diff --git a/test/unit/view/drag-drop-context/app.jsx b/test/unit/view/drag-drop-context/app.jsx deleted file mode 100644 index 3eb9fa3b4a..0000000000 --- a/test/unit/view/drag-drop-context/app.jsx +++ /dev/null @@ -1,21 +0,0 @@ -// @flow -import React from 'react'; -import PropTypes from 'prop-types'; -import { storeKey, canLiftKey } from '../../../../src/view/context-keys'; - -export default class App extends React.Component<*> { - // Part of reacts api is to use flow types for this. - // Sadly cannot use flow - static contextTypes = { - [storeKey]: PropTypes.shape({ - dispatch: PropTypes.func.isRequired, - subscribe: PropTypes.func.isRequired, - getState: PropTypes.func.isRequired, - }).isRequired, - [canLiftKey]: PropTypes.func.isRequired, - }; - - render() { - return
Hi there
; - } -} diff --git a/test/unit/view/drag-drop-context/clashing-with-consumers-redux.spec.js b/test/unit/view/drag-drop-context/clashing-with-consumers-redux.spec.js new file mode 100644 index 0000000000..dc0b10c5f9 --- /dev/null +++ b/test/unit/view/drag-drop-context/clashing-with-consumers-redux.spec.js @@ -0,0 +1,107 @@ +// @flow +/* eslint-disable react/no-multi-comp */ +import React, { Component } from 'react'; +import { mount } from 'enzyme'; +import { Provider, connect } from 'react-redux'; +import { createStore } from 'redux'; +import { Droppable, Draggable, DragDropContext } from '../../../../src'; +import type { DraggableProvided, DroppableProvided } from '../../../../src'; +// Imported as wildcard so we can mock `resetStyleContext` using spyOn + +type AppState = {| + foo: string, +|}; +const original: AppState = { + foo: 'bar', +}; +// super boring reducer that always returns the same thing +const reducer = (state: AppState = original) => state; +const store = createStore(reducer); + +class Unconnected extends Component { + render() { + return
{this.props.foo}
; + } +} + +function mapStateToProps(state: AppState): AppState { + return state; +} + +const Connected = connect(mapStateToProps)(Unconnected); + +it('should avoid clashes with parent redux applications', () => { + class Container extends Component<*> { + render() { + return ( + + {}}> + + {(droppableProvided: DroppableProvided) => ( +
+ + {(draggableProvided: DraggableProvided) => ( +
+ {/* $FlowFixMe - not sure why this requires foo */} + +
+ )} +
+ {droppableProvided.placeholder} +
+ )} +
+
+
+ ); + } + } + const wrapper = mount(); + + expect(wrapper.find(Container).text()).toBe(original.foo); +}); + +it('should avoid clashes with child redux applications', () => { + class Container extends Component<*> { + render() { + return ( + {}}> + + {(droppableProvided: DroppableProvided) => ( +
+ + {(draggableProvided: DraggableProvided) => ( +
+ + {/* $FlowFixMe - not sure why this requires foo */} + + +
+ )} +
+ {droppableProvided.placeholder} +
+ )} +
+
+ ); + } + } + const wrapper = mount(); + + expect(wrapper.find(Container).text()).toBe(original.foo); +}); diff --git a/test/unit/view/drag-drop-context/reset-server-context.spec.js b/test/unit/view/drag-drop-context/reset-server-context.spec.js index 18f2f1b1f7..ecd21338c4 100644 --- a/test/unit/view/drag-drop-context/reset-server-context.spec.js +++ b/test/unit/view/drag-drop-context/reset-server-context.spec.js @@ -1,12 +1,44 @@ // @flow +import React from 'react'; +import { mount, type ReactWrapper } from 'enzyme'; +import DragDropContext from '../../../../src/view/drag-drop-context'; import { resetServerContext } from '../../../../src'; -import * as StyleMarshal from '../../../../src/view/style-marshal/style-marshal'; +import * as attributes from '../../../../src/view/data-attributes'; + +const doesStyleElementExist = (uniqueId: number): boolean => + Boolean( + document.querySelector(`[${attributes.prefix}-always="${uniqueId}"]`), + ); it('should reset the style marshal context', () => { - const spy = jest.spyOn(StyleMarshal, 'resetStyleContext'); - expect(spy).not.toHaveBeenCalled(); + expect(doesStyleElementExist(1)).toBe(false); + + const wrapper1: ReactWrapper<*> = mount( + {}}>{null}, + ); + expect(doesStyleElementExist(0)).toBe(true); + + const wrapper2: ReactWrapper<*> = mount( + {}}>{null}, + ); + expect(doesStyleElementExist(1)).toBe(true); + + // not created yet + expect(doesStyleElementExist(2)).toBe(false); + + // clearing away the old wrappers + wrapper1.unmount(); + wrapper2.unmount(); resetServerContext(); - expect(spy).toHaveBeenCalledTimes(1); - spy.mockRestore(); + // a new wrapper after the reset + const wrapper3: ReactWrapper<*> = mount( + {}}>{null}, + ); + + // now only '0' exists + expect(doesStyleElementExist(0)).toBe(true); + expect(doesStyleElementExist(1)).toBe(false); + + wrapper3.unmount(); }); diff --git a/test/unit/view/drag-drop-context/store-management.spec.js b/test/unit/view/drag-drop-context/store-management.spec.js deleted file mode 100644 index b841409e1a..0000000000 --- a/test/unit/view/drag-drop-context/store-management.spec.js +++ /dev/null @@ -1,154 +0,0 @@ -// @flow -/* eslint-disable react/no-multi-comp */ -import React, { Component } from 'react'; -import TestUtils from 'react-dom/test-utils'; -import { mount } from 'enzyme'; -import { Provider, connect } from 'react-redux'; -import { createStore } from 'redux'; -import { Droppable, Draggable, DragDropContext } from '../../../../src'; -import type { DraggableProvided, DroppableProvided } from '../../../../src'; -import { storeKey, canLiftKey } from '../../../../src/view/context-keys'; -import App from './app'; -// Imported as wildcard so we can mock `resetStyleContext` using spyOn - -it('should put a store on the context', () => { - // using react test utils to allow access to nested contexts - const tree = TestUtils.renderIntoDocument( - {}}> - - , - ); - - const app = TestUtils.findRenderedComponentWithType(tree, App); - - if (!app) { - throw new Error('Invalid test setup'); - } - - expect(app.context[storeKey]).toHaveProperty('dispatch'); - expect(app.context[storeKey].dispatch).toBeInstanceOf(Function); - expect(app.context[storeKey]).toHaveProperty('getState'); - expect(app.context[storeKey].getState).toBeInstanceOf(Function); - expect(app.context[storeKey]).toHaveProperty('subscribe'); - expect(app.context[storeKey].subscribe).toBeInstanceOf(Function); -}); - -describe('can start drag', () => { - // behavior of this function is tested in can-start-drag.spec.js - it('should put a can lift function on the context', () => { - // using react test utils to allow access to nested contexts - const tree = TestUtils.renderIntoDocument( - {}}> - - , - ); - - const app = TestUtils.findRenderedComponentWithType(tree, App); - - if (!app) { - throw new Error('Invalid test setup'); - } - - expect(app.context[canLiftKey]).toBeInstanceOf(Function); - }); -}); - -describe('Playing with other redux apps', () => { - type AppState = {| - foo: string, - |}; - const original: AppState = { - foo: 'bar', - }; - // super boring reducer that always returns the same thing - const reducer = (state: AppState = original) => state; - const store = createStore(reducer); - - class Unconnected extends Component { - render() { - return
{this.props.foo}
; - } - } - - function mapStateToProps(state: AppState): AppState { - return state; - } - - const Connected = connect(mapStateToProps)(Unconnected); - - it('should avoid clashes with parent redux applications', () => { - class Container extends Component<*> { - render() { - return ( - - {}}> - - {(droppableProvided: DroppableProvided) => ( -
- - {(draggableProvided: DraggableProvided) => ( -
- {/* $FlowFixMe - not sure why this requires foo */} - -
- )} -
- {droppableProvided.placeholder} -
- )} -
-
-
- ); - } - } - const wrapper = mount(); - - expect(wrapper.find(Container).text()).toBe(original.foo); - }); - - it('should avoid clashes with child redux applications', () => { - class Container extends Component<*> { - render() { - return ( - {}}> - - {(droppableProvided: DroppableProvided) => ( -
- - {(draggableProvided: DraggableProvided) => ( -
- - {/* $FlowFixMe - not sure why this requires foo */} - - -
- )} -
- {droppableProvided.placeholder} -
- )} -
-
- ); - } - } - const wrapper = mount(); - - expect(wrapper.find(Container).text()).toBe(original.foo); - }); -}); diff --git a/test/unit/view/drag-drop-context/unmount.spec.js b/test/unit/view/drag-drop-context/unmount.spec.js index 9da074c207..a7304567d5 100644 --- a/test/unit/view/drag-drop-context/unmount.spec.js +++ b/test/unit/view/drag-drop-context/unmount.spec.js @@ -1,14 +1,11 @@ // @flow import React from 'react'; import { mount } from 'enzyme'; -import App from './app'; import DragDropContext from '../../../../src/view/drag-drop-context'; it('should not throw when unmounting', () => { const wrapper = mount( - {}}> - - , + {}}>{null}, ); expect(() => wrapper.unmount()).not.toThrow(); @@ -19,9 +16,7 @@ it('should clean up any window event handlers', () => { jest.spyOn(window, 'removeEventListener'); const wrapper = mount( - {}}> - - , + {}}>{null}, ); wrapper.unmount(); diff --git a/test/unit/view/drag-handle/attributes.spec.js b/test/unit/view/drag-handle/attributes.spec.js index 03225866e1..69f22b6938 100644 --- a/test/unit/view/drag-handle/attributes.spec.js +++ b/test/unit/view/drag-handle/attributes.spec.js @@ -1,8 +1,7 @@ // @flow import { getWrapper, Child } from './util/wrappers'; import { getStubCallbacks } from './util/callbacks'; -import basicContext from './util/basic-context'; -import { styleKey } from '../../../../src/view/context-keys'; +import basicContext from './util/app-context'; it('should apply the style context to a data-attribute', () => { expect( @@ -10,7 +9,7 @@ it('should apply the style context to a data-attribute', () => { .find(Child) .getDOMNode() .getAttribute('data-react-beautiful-dnd-drag-handle'), - ).toEqual(basicContext[styleKey]); + ).toEqual(basicContext.style); }); it('should apply a default aria roledescription containing lift instructions', () => { 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 index 5c3a10428e..982644f582 100644 --- 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 @@ -1,48 +1,27 @@ // @flow -import React from 'react'; -import { mount } from 'enzyme'; -import DragHandle from '../../../../src/view/drag-handle/drag-handle'; -import type { DragHandleProps } from '../../../../src/view/drag-handle/drag-handle-types'; import { forEach, type Control } from './util/controls'; import { getStubCallbacks, callbacksCalled } from './util/callbacks'; -import basicContext from './util/basic-context'; -import { Child, createRef } from './util/wrappers'; -import { canLiftKey } from '../../../../src/view/context-keys'; +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', () => { - const ref = createRef(); // faking a 'false' response const canLift = jest.fn().mockImplementation(() => false); - const customContext = { + const customContext: AppContextValue = { ...basicContext, - [canLiftKey]: canLift, + canLift, }; - const customCallbacks = getStubCallbacks(); - const wrapper = mount( - true} - canDragInteractiveElements={false} - > - {(dragHandleProps: ?DragHandleProps) => ( - - )} - , - { context: customContext }, - ); + const callbacks = getStubCallbacks(); + const wrapper = getWrapper(callbacks, customContext); control.preLift(wrapper); control.lift(wrapper); control.drop(wrapper); expect( - callbacksCalled(customCallbacks)({ + callbacksCalled(callbacks)({ onLift: 0, }), ).toBe(true); diff --git a/test/unit/view/drag-handle/contenteditable.spec.js b/test/unit/view/drag-handle/contenteditable.spec.js index 1fd984e167..fddeaa2104 100644 --- a/test/unit/view/drag-handle/contenteditable.spec.js +++ b/test/unit/view/drag-handle/contenteditable.spec.js @@ -2,15 +2,16 @@ import React from 'react'; import { mount } from 'enzyme'; import { forEach, type Control } from './util/controls'; -import { createRef } from './util/wrappers'; +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 DragHandle from '../../../../src/view/drag-handle/drag-handle'; -import basicContext from './util/basic-context'; -import type { DragHandleProps } from '../../../../src/view/drag-handle/drag-handle-types'; +import basicContext from './util/app-context'; +import AppContext from '../../../../src/view/context/app-context'; const draggableId = 'draggable'; @@ -27,21 +28,27 @@ forEach((control: Control) => { const callbacks = getStubCallbacks(); const ref = createRef(); const wrapper = mount( - true} - > - {(dragHandleProps: ?DragHandleProps) => ( -
- )} - , - { context: basicContext }, + + true} + > + {(dragHandleProps: ?DragHandleProps) => ( +
+ )} + + , ); const target = wrapper.getDOMNode(); const options = { @@ -65,23 +72,28 @@ forEach((control: Control) => { const customCallbacks = getStubCallbacks(); const ref = createRef(); const customWrapper = mount( - true} - > - {(dragHandleProps: ?DragHandleProps) => ( -
-
-
- )} - , - { context: basicContext }, + + true} + > + {(dragHandleProps: ?DragHandleProps) => ( +
+
+
+ )} + + , ); const target = customWrapper.getDOMNode().querySelector('.editable'); if (!target) { @@ -104,26 +116,31 @@ forEach((control: Control) => { const customCallbacks = getStubCallbacks(); const ref = createRef(); const customWrapper = mount( - true} - > - {(dragHandleProps: ?DragHandleProps) => ( -
-
-

hello there

- Edit me! + + true} + > + {(dragHandleProps: ?DragHandleProps) => ( +
+
+

hello there

+ Edit me! +
-
- )} - , - { context: basicContext }, + )} + + , ); const target = customWrapper.getDOMNode().querySelector('.target'); if (!target) { @@ -150,26 +167,31 @@ forEach((control: Control) => { const customCallbacks = getStubCallbacks(); const ref = createRef(); const customWrapper = mount( - true} - > - {(dragHandleProps: ?DragHandleProps) => ( -
-
-

hello there

- Edit me! + + true} + > + {(dragHandleProps: ?DragHandleProps) => ( +
+
+

hello there

+ Edit me! +
-
- )} - , - { context: basicContext }, + )} + + , ); const target = customWrapper.getDOMNode().querySelector('.target'); if (!target) { @@ -181,12 +203,10 @@ forEach((control: Control) => { control.preLift(customWrapper, options); control.lift(customWrapper, options); - control.drop(customWrapper); expect( callbacksCalled(customCallbacks)({ onLift: 1, - onDrop: 1, }), ).toBe(true); @@ -199,24 +219,29 @@ forEach((control: Control) => { const customCallbacks = getStubCallbacks(); const ref = createRef(); const customWrapper = mount( - true} - // stating that we can drag - canDragInteractiveElements - > - {(dragHandleProps: ?DragHandleProps) => ( -
-
-
- )} - , - { context: basicContext }, + + true} + // stating that we can drag + canDragInteractiveElements + > + {(dragHandleProps: ?DragHandleProps) => ( +
+
+
+ )} + + , ); const target = customWrapper.getDOMNode().querySelector('.editable'); if (!target) { @@ -228,12 +253,10 @@ forEach((control: Control) => { control.preLift(customWrapper, options); control.lift(customWrapper, options); - control.drop(customWrapper); expect( callbacksCalled(customCallbacks)({ onLift: 1, - onDrop: 1, }), ).toBe(true); @@ -244,27 +267,32 @@ forEach((control: Control) => { const customCallbacks = getStubCallbacks(); const ref = createRef(); const customWrapper = mount( - true} - // stating that we can drag - canDragInteractiveElements - > - {(dragHandleProps: ?DragHandleProps) => ( -
-
-

hello there

- Edit me! + + true} + // stating that we can drag + canDragInteractiveElements + > + {(dragHandleProps: ?DragHandleProps) => ( +
+
+

hello there

+ Edit me! +
-
- )} - , - { context: basicContext }, + )} + + , ); const target = customWrapper.getDOMNode().querySelector('.target'); if (!target) { @@ -276,12 +304,10 @@ forEach((control: Control) => { control.preLift(customWrapper, options); control.lift(customWrapper, options); - control.drop(customWrapper); expect( callbacksCalled(customCallbacks)({ onLift: 1, - onDrop: 1, }), ).toBe(true); diff --git a/test/unit/view/drag-handle/disabled-while-capturing.spec.js b/test/unit/view/drag-handle/disabled-while-capturing.spec.js index 473883a5f0..0ccfcb849f 100644 --- a/test/unit/view/drag-handle/disabled-while-capturing.spec.js +++ b/test/unit/view/drag-handle/disabled-while-capturing.spec.js @@ -3,7 +3,7 @@ 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/drag-handle/drag-handle-types'; +import type { Callbacks } from '../../../../src/view/use-drag-handle/drag-handle-types'; const expectMidDragDisabledWarning = (fn: Function) => { // arrange diff --git a/test/unit/view/drag-handle/focus-management.spec.js b/test/unit/view/drag-handle/focus-management.spec.js index 26d09028d1..0c003cae2a 100644 --- a/test/unit/view/drag-handle/focus-management.spec.js +++ b/test/unit/view/drag-handle/focus-management.spec.js @@ -1,30 +1,26 @@ // @flow import React, { type Node } from 'react'; +import invariant from 'tiny-invariant'; import ReactDOM from 'react-dom'; -import PropTypes from 'prop-types'; import { mount } from 'enzyme'; import type { ReactWrapper } from 'enzyme'; -import DragHandle from '../../../../src/view/drag-handle'; -import { styleKey, canLiftKey } from '../../../../src/view/context-keys'; -import type { DragHandleProps } from '../../../../src/view/drag-handle/drag-handle-types'; +import type { DragHandleProps } from '../../../../src/view/use-drag-handle/drag-handle-types'; import { getStubCallbacks } from './util/callbacks'; - -const options = { - context: { - [styleKey]: 'hello', - [canLiftKey]: () => true, - }, - childContextTypes: { - [styleKey]: PropTypes.string.isRequired, - [canLiftKey]: PropTypes.func.isRequired, - }, -}; +import { WithDragHandle } from './util/wrappers'; +import { getMarshalStub } from '../../../utils/dimension-marshal'; +import AppContext, { + type AppContextValue, +} from '../../../../src/view/context/app-context'; const body: ?HTMLElement = document.body; +invariant(body, 'Cannot find body'); -if (!body) { - throw new Error('document.body not found'); -} +const appContext: AppContextValue = { + marshal: getMarshalStub(), + style: 'fake style context', + canLift: () => true, + isMovementAllowed: () => true, +}; describe('Portal usage (ref changing while mounted)', () => { type ChildProps = {| @@ -51,7 +47,11 @@ describe('Portal usage (ref changing while mounted)', () => { render() { const child: Node = ( -
+
Drag me!
); @@ -75,35 +75,34 @@ describe('Portal usage (ref changing while mounted)', () => { render() { return ( - this.ref} - canDragInteractiveElements={false} - getShouldRespectForceTouch={() => true} - > - {(dragHandleProps: ?DragHandleProps) => ( - - )} - + + this.ref} + canDragInteractiveElements={false} + getShouldRespectForceTouch={() => true} + > + {(dragHandleProps: ?DragHandleProps) => ( + + )} + + ); } } it('should retain focus if draggable ref is changing and had focus', () => { - const wrapper = mount( - , - options, - ); + const wrapper = mount(); - const original: HTMLElement = wrapper.getDOMNode(); + const original: HTMLElement = wrapper.find('.drag-handle').getDOMNode(); expect(original).not.toBe(document.activeElement); // giving it focus @@ -116,17 +115,14 @@ describe('Portal usage (ref changing while mounted)', () => { usePortal: true, }); - const inPortal: HTMLElement = wrapper.getDOMNode(); + 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( - , - options, - ); + const wrapper = mount(); const original: HTMLElement = wrapper.getDOMNode(); expect(original).not.toBe(document.activeElement); @@ -165,39 +161,45 @@ describe('Focus retention moving between lists (focus retention between mounts)' render() { return ( - this.ref} - canDragInteractiveElements={false} - getShouldRespectForceTouch={() => true} - > - {(dragHandleProps: ?DragHandleProps) => ( -
- Drag me! -
- )} -
+ + this.ref} + canDragInteractiveElements={false} + getShouldRespectForceTouch={() => true} + > + {(dragHandleProps: ?DragHandleProps) => ( +
+ Drag me! +
+ )} +
+
); } } it('should maintain focus if unmounting while dragging', () => { - const first: ReactWrapper<*> = mount(, options); + const first: ReactWrapper<*> = mount(); const original: HTMLElement = first.getDOMNode(); expect(original).not.toBe(document.activeElement); // get focus original.focus(); - first.find(DragHandle).simulate('focus'); + first.find('.drag-handle').simulate('focus'); expect(original).toBe(document.activeElement); first.unmount(); - const second: ReactWrapper<*> = mount(, options); + const second: ReactWrapper<*> = mount(); const latest: HTMLElement = second.getDOMNode(); expect(latest).toBe(document.activeElement); // validation @@ -209,21 +211,18 @@ describe('Focus retention moving between lists (focus retention between mounts)' }); it('should maintain focus if unmounting while drop animating', () => { - const first: ReactWrapper<*> = mount( - , - options, - ); + const first: ReactWrapper<*> = mount(); const original: HTMLElement = first.getDOMNode(); expect(original).not.toBe(document.activeElement); // get focus original.focus(); - first.find(DragHandle).simulate('focus'); + first.find('.drag-handle').simulate('focus'); expect(original).toBe(document.activeElement); first.unmount(); - const second: ReactWrapper<*> = mount(, options); + const second: ReactWrapper<*> = mount(); const latest: HTMLElement = second.getDOMNode(); expect(latest).toBe(document.activeElement); // validation @@ -238,20 +237,19 @@ describe('Focus retention moving between lists (focus retention between mounts)' it('should not maintain focus if the item was not dragging or drop animating', () => { const first: ReactWrapper<*> = mount( , - options, ); const original: HTMLElement = first.getDOMNode(); expect(original).not.toBe(document.activeElement); // get focus original.focus(); - first.find(DragHandle).simulate('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(, options); + const second: ReactWrapper<*> = mount(); const latest: HTMLElement = second.getDOMNode(); expect(latest).not.toBe(document.activeElement); // validation @@ -260,13 +258,13 @@ describe('Focus retention moving between lists (focus retention between mounts)' }); it('should not give focus to something that was not previously focused', () => { - const first: ReactWrapper<*> = mount(, options); + const first: ReactWrapper<*> = mount(); const original: HTMLElement = first.getDOMNode(); expect(original).not.toBe(document.activeElement); first.unmount(); - const second: ReactWrapper<*> = mount(, options); + const second: ReactWrapper<*> = mount(); const latest: HTMLElement = second.getDOMNode(); expect(latest).not.toBe(document.activeElement); // validation @@ -280,30 +278,25 @@ describe('Focus retention moving between lists (focus retention between mounts)' it('should maintain focus if another component is mounted before the focused component', () => { const first: ReactWrapper<*> = mount( , - options, ); const original: HTMLElement = first.getDOMNode(); expect(original).not.toBe(document.activeElement); // get focus original.focus(); - first.find(DragHandle).simulate('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( - , - options, - ); + const other: ReactWrapper<*> = mount(); expect(other.getDOMNode()).not.toBe(document.activeElement); // mounting something with the same id as the first const second: ReactWrapper<*> = mount( , - options, ); const latest: HTMLElement = second.getDOMNode(); expect(latest).toBe(document.activeElement); @@ -314,22 +307,19 @@ describe('Focus retention moving between lists (focus retention between mounts)' }); it('should only maintain focus once', () => { - const first: ReactWrapper<*> = mount( - , - options, - ); + const first: ReactWrapper<*> = mount(); const original: HTMLElement = first.getDOMNode(); expect(original).not.toBe(document.activeElement); // get focus original.focus(); - first.find(DragHandle).simulate('focus'); + first.find('.drag-handle').simulate('focus'); expect(original).toBe(document.activeElement); first.unmount(); // obtaining focus on first remount - const second: ReactWrapper<*> = mount(, options); + const second: ReactWrapper<*> = mount(); const latest: HTMLElement = second.getDOMNode(); expect(latest).toBe(document.activeElement); // validation @@ -339,7 +329,7 @@ describe('Focus retention moving between lists (focus retention between mounts)' second.unmount(); // should not obtain focus on the second remount - const third: ReactWrapper<*> = mount(, options); + const third: ReactWrapper<*> = mount(); expect(third.getDOMNode()).not.toBe(document.activeElement); // cleanup @@ -350,16 +340,13 @@ describe('Focus retention moving between lists (focus retention between mounts)' // eslint-disable-next-line react/button-has-type const button: HTMLElement = document.createElement('button'); body.appendChild(button); - const first: ReactWrapper<*> = mount( - , - options, - ); + const first: ReactWrapper<*> = mount(); const original: HTMLElement = first.getDOMNode(); expect(original).not.toBe(document.activeElement); // get focus original.focus(); - first.find(DragHandle).simulate('focus'); + first.find('.drag-handle').simulate('focus'); expect(original).toBe(document.activeElement); first.unmount(); @@ -369,7 +356,7 @@ describe('Focus retention moving between lists (focus retention between mounts)' expect(button).toBe(document.activeElement); // remount should now not claim focus - const second: ReactWrapper<*> = mount(, options); + const second: ReactWrapper<*> = mount(); expect(second.getDOMNode()).not.toBe(document.activeElement); // focus maintained on button expect(button).toBe(document.activeElement); diff --git a/test/unit/view/drag-handle/interactive-elements.spec.js b/test/unit/view/drag-handle/interactive-elements.spec.js index 0a127f6053..53db24e272 100644 --- a/test/unit/view/drag-handle/interactive-elements.spec.js +++ b/test/unit/view/drag-handle/interactive-elements.spec.js @@ -7,9 +7,9 @@ import { resetCallbacks, } from './util/callbacks'; import { getWrapper } from './util/wrappers'; -import { interactiveTagNames } from '../../../../src/view/drag-handle/util/should-allow-dragging-from-target'; -import type { Callbacks } from '../../../../src/view/drag-handle/drag-handle-types'; -import type { TagNameMap } from '../../../../src/view/drag-handle/util/should-allow-dragging-from-target'; +import { interactiveTagNames } from '../../../../src/view/use-drag-handle/util/should-allow-dragging-from-target'; +import type { Callbacks } from '../../../../src/view/use-drag-handle/drag-handle-types'; +import type { TagNameMap } from '../../../../src/view/use-drag-handle/util/should-allow-dragging-from-target'; const mixedCase = (map: TagNameMap): string[] => [ ...Object.keys(map).map((tagName: string) => tagName.toLowerCase()), @@ -122,7 +122,6 @@ forEach((control: Control) => { control.preLift(wrapper, options); control.lift(wrapper, options); - control.drop(wrapper); expect( callbacksCalled(callbacks)({ @@ -149,7 +148,6 @@ forEach((control: Control) => { control.preLift(wrapper, options); control.lift(wrapper, options); - control.drop(wrapper); expect( callbacksCalled(callbacks)({ diff --git a/test/unit/view/drag-handle/keyboard-sensor.spec.js b/test/unit/view/drag-handle/keyboard-sensor.spec.js index 790090aef2..105a0991c5 100644 --- a/test/unit/view/drag-handle/keyboard-sensor.spec.js +++ b/test/unit/view/drag-handle/keyboard-sensor.spec.js @@ -1,7 +1,6 @@ // @flow import { getRect, type Position } from 'css-box-model'; import { type ReactWrapper } from 'enzyme'; -import { canLiftKey, styleKey } from '../../../../src/view/context-keys'; import * as keyCodes from '../../../../src/view/key-codes'; import { withKeyboard } from '../../../utils/user-input-util'; import { @@ -30,7 +29,9 @@ import { windowMouseMove, } from './util/events'; import { getWrapper } from './util/wrappers'; -import type { Callbacks } from '../../../../src/view/drag-handle/drag-handle-types'; +import type { Callbacks } from '../../../../src/view/use-drag-handle/drag-handle-types'; +import type { AppContextValue } from '../../../../src/view/context/app-context'; +import basicContext from './util/app-context'; const origin: Position = { x: 0, y: 0 }; @@ -131,9 +132,9 @@ describe('initiation', () => { it('should not lift if the state does not currently allow lifting', () => { const customCallbacks: Callbacks = getStubCallbacks(); - const customContext = { - [styleKey]: 'hello', - [canLiftKey]: () => false, + const customContext: AppContextValue = { + ...basicContext, + canLift: () => false, }; const customWrapper = getWrapper(customCallbacks, customContext); const mock: MockEvent = createMockEvent(); @@ -519,6 +520,7 @@ describe('cancel', () => { describe('disabled mid drag', () => { it('should cancel the current drag', () => { pressSpacebar(wrapper); + wrapper.setProps({ isDragging: true }); wrapper.setProps({ isEnabled: false, @@ -535,6 +537,8 @@ describe('disabled mid drag', () => { it('should drop any pending movements', () => { // lift pressSpacebar(wrapper); + wrapper.setProps({ isDragging: true }); + wrapper.setProps({ isDragging: true }); expect(callbacks.onLift).toHaveBeenCalledTimes(1); pressArrowUp(wrapper); @@ -566,6 +570,7 @@ describe('disabled mid drag', () => { it('should stop preventing default action on events', () => { // setup pressSpacebar(wrapper); + wrapper.setProps({ isDragging: true }); wrapper.setProps({ isEnabled: false, }); @@ -633,6 +638,7 @@ describe('cancelled elsewhere in the app mid drag', () => { it('should call the onCancel prop if unmounted mid drag', () => { pressSpacebar(wrapper); + wrapper.setProps({ isDragging: true }); wrapper.unmount(); diff --git a/test/unit/view/drag-handle/mouse-sensor.spec.js b/test/unit/view/drag-handle/mouse-sensor.spec.js index 2cd03f86b4..0c86556386 100644 --- a/test/unit/view/drag-handle/mouse-sensor.spec.js +++ b/test/unit/view/drag-handle/mouse-sensor.spec.js @@ -1,8 +1,7 @@ // @flow import { type Position } from 'css-box-model'; import { type ReactWrapper } from 'enzyme'; -import { canLiftKey, styleKey } from '../../../../src/view/context-keys'; -import { sloppyClickThreshold } from '../../../../src/view/drag-handle/util/is-sloppy-click-threshold-exceeded'; +import { sloppyClickThreshold } from '../../../../src/view/use-drag-handle/util/is-sloppy-click-threshold-exceeded'; import * as keyCodes from '../../../../src/view/key-codes'; import getWindowScroll from '../../../../src/view/window/get-window-scroll'; import setWindowScroll from '../../../utils/set-window-scroll'; @@ -37,7 +36,10 @@ import { windowTab, } from './util/events'; import { getWrapper } from './util/wrappers'; -import type { Callbacks } from '../../../../src/view/drag-handle/drag-handle-types'; +import type { Callbacks } from '../../../../src/view/use-drag-handle/drag-handle-types'; +import type { AppContextValue } from '../../../../src/view/context/app-context'; +import basicContext from './util/app-context'; +import forceUpdate from '../../../utils/force-update'; const origin: Position = { x: 0, y: 0 }; @@ -83,7 +85,7 @@ describe('initiation', () => { windowMouseMove(point); expect(customCallbacks.onLift).toHaveBeenCalledWith({ - clientSelection: point, + clientSelection: origin, movementMode: 'FLUID', }); @@ -202,9 +204,9 @@ describe('initiation', () => { it('should not start a drag if the state says that a drag cannot start', () => { const customCallbacks: Callbacks = getStubCallbacks(); - const customContext = { - [styleKey]: 'hello', - [canLiftKey]: () => false, + const customContext: AppContextValue = { + ...basicContext, + canLift: () => false, }; const customWrapper = getWrapper(customCallbacks, customContext); const mock: MockEvent = createMockEvent(); @@ -1004,6 +1006,7 @@ describe('disabled mid drag', () => { // lift mouseDown(wrapper); windowMouseMove({ x: 0, y: sloppyClickThreshold }); + wrapper.setProps({ isDragging: true }); expect(callbacksCalled(callbacks)({ onLift: 1 })).toBe(true); @@ -1024,6 +1027,7 @@ describe('disabled mid drag', () => { // lift mouseDown(wrapper); windowMouseMove({ x: 0, y: sloppyClickThreshold }); + wrapper.setProps({ isDragging: true }); // move windowMouseMove({ x: 0, y: sloppyClickThreshold + 1 }); requestAnimationFrame.step(); @@ -1050,6 +1054,7 @@ describe('disabled mid drag', () => { // lift mouseDown(wrapper); windowMouseMove({ x: 0, y: sloppyClickThreshold + 1 }); + wrapper.setProps({ isDragging: true }); // move windowMouseMove({ x: 0, y: sloppyClickThreshold + 1 }); requestAnimationFrame.step(); @@ -1086,6 +1091,22 @@ describe('disabled mid drag', () => { }); describe('cancelled elsewhere in the app mid drag', () => { + it('should not abort a drag if a render occurs during a pending drag', () => { + // lift + mouseDown(wrapper); + forceUpdate(wrapper); + + windowMouseMove({ x: 0, y: sloppyClickThreshold }); + + expect( + callbacksCalled(callbacks)({ + onLift: 1, + onMove: 0, + onCancel: 0, + }), + ).toBe(true); + }); + it('should end a current drag without firing the onCancel callback', () => { // lift mouseDown(wrapper); @@ -1133,6 +1154,7 @@ describe('unmounted mid drag', () => { beforeEach(() => { mouseDown(wrapper); windowMouseMove({ x: 0, y: sloppyClickThreshold }); + wrapper.setProps({ isDragging: true }); wrapper.unmount(); }); diff --git a/test/unit/view/drag-handle/nested-drag-handles.spec.js b/test/unit/view/drag-handle/nested-drag-handles.spec.js index 36186ab098..cf801f1695 100644 --- a/test/unit/view/drag-handle/nested-drag-handles.spec.js +++ b/test/unit/view/drag-handle/nested-drag-handles.spec.js @@ -2,14 +2,15 @@ import React from 'react'; import { mount, type ReactWrapper } from 'enzyme'; import { forEach, type Control } from './util/controls'; -import DragHandle from '../../../../src/view/drag-handle/drag-handle'; import { getStubCallbacks } from './util/callbacks'; -import basicContext from './util/basic-context'; +import basicContext from './util/app-context'; import type { Callbacks, DragHandleProps, -} from '../../../../src/view/drag-handle/drag-handle-types'; -import { createRef, Child } from './util/wrappers'; +} from '../../../../src/view/use-drag-handle/drag-handle-types'; +import { Child, WithDragHandle } from './util/wrappers'; +import createRef from '../../../utils/create-ref'; +import AppContext from '../../../../src/view/context/app-context'; const getNestedWrapper = ( parentCallbacks: Callbacks, @@ -19,46 +20,47 @@ const getNestedWrapper = ( const inner = createRef(); return mount( - true} - canDragInteractiveElements={false} - > - {(parentProps: ?DragHandleProps) => ( - - true} + + true} + canDragInteractiveElements={false} + > + {(parentProps: ?DragHandleProps) => ( + - {(childProps: ?DragHandleProps) => ( - - Child! - - )} - - - )} - , - { context: basicContext }, + true} + > + {(childProps: ?DragHandleProps) => ( + + Child! + + )} + + + )} + + , ); }; diff --git a/test/unit/view/drag-handle/throw-if-svg.spec.js b/test/unit/view/drag-handle/throw-if-svg.spec.js index 2f3ffe7af3..6e32876f18 100644 --- a/test/unit/view/drag-handle/throw-if-svg.spec.js +++ b/test/unit/view/drag-handle/throw-if-svg.spec.js @@ -1,16 +1,12 @@ // @flow import React from 'react'; import { mount } from 'enzyme'; -import DragHandle from '../../../../src/view/drag-handle/drag-handle'; -import type { DragHandleProps } from '../../../../src/view/drag-handle/drag-handle-types'; -import { styleKey, canLiftKey } from '../../../../src/view/context-keys'; +import type { DragHandleProps } from '../../../../src/view/use-drag-handle/drag-handle-types'; import { getStubCallbacks } from './util/callbacks'; -import { createRef } from './util/wrappers'; - -const basicContext = { - [styleKey]: 'hello', - [canLiftKey]: () => true, -}; +import { WithDragHandle } from './util/wrappers'; +import createRef from '../../../utils/create-ref'; +import AppContext from '../../../../src/view/context/app-context'; +import basicContext from './util/app-context'; beforeEach(() => { jest.spyOn(console, 'error').mockImplementation(() => {}); @@ -25,22 +21,23 @@ it('should throw if a help SVG message if the drag handle is a SVG', () => { const ref = createRef(); return mount( - true} - > - {(dragHandleProps: ?DragHandleProps) => ( - // $ExpectError - this fails the flow check! Success! - - )} - , - { context: basicContext }, + + true} + > + {(dragHandleProps: ?DragHandleProps) => ( + // $ExpectError - this fails the flow check! Success! + + )} + + , ); }; diff --git a/test/unit/view/drag-handle/touch-sensor.spec.js b/test/unit/view/drag-handle/touch-sensor.spec.js index da03b37975..2175987e5f 100644 --- a/test/unit/view/drag-handle/touch-sensor.spec.js +++ b/test/unit/view/drag-handle/touch-sensor.spec.js @@ -1,14 +1,13 @@ // @flow import { type Position } from 'css-box-model'; import { type ReactWrapper } from 'enzyme'; -import { canLiftKey, styleKey } from '../../../../src/view/context-keys'; import * as keyCodes from '../../../../src/view/key-codes'; import getWindowScroll from '../../../../src/view/window/get-window-scroll'; import setWindowScroll from '../../../utils/set-window-scroll'; import { timeForLongPress, forcePressThreshold, -} from '../../../../src/view/drag-handle/sensor/create-touch-sensor'; +} from '../../../../src/view/use-drag-handle/sensor/use-touch-sensor'; import { dispatchWindowEvent, dispatchWindowKeyDownEvent, @@ -27,7 +26,10 @@ import { windowTouchStart, } from './util/events'; import { getWrapper } from './util/wrappers'; -import type { Callbacks } from '../../../../src/view/drag-handle/drag-handle-types'; +import type { Callbacks } from '../../../../src/view/use-drag-handle/drag-handle-types'; +import type { AppContextValue } from '../../../../src/view/context/app-context'; +import basicContext from './util/app-context'; +import forceUpdate from '../../../utils/force-update'; const origin: Position = { x: 0, y: 0 }; let callbacks: Callbacks; @@ -57,6 +59,7 @@ afterEach(() => { const start = () => { touchStart(wrapper, origin); jest.runTimersToTime(timeForLongPress); + wrapper.setProps({ isDragging: true }); }; const end = () => windowTouchEnd(); const move = (point?: Position = { x: 5, y: 20 }) => { @@ -90,9 +93,9 @@ describe('initiation', () => { it('should not start a drag if the application state does not allow it', () => { const customCallbacks: Callbacks = getStubCallbacks(); - const customContext = { - [styleKey]: 'hello', - [canLiftKey]: () => false, + const customContext: AppContextValue = { + ...basicContext, + canLift: () => false, }; const customWrapper = getWrapper(customCallbacks, customContext); const mock: MockEvent = createMockEvent(); @@ -463,6 +466,33 @@ describe('disabling a draggable during a drag', () => { }); describe('cancelled elsewhere in the app', () => { + it('should not abort a drag if a render occurs during a pending drag', () => { + // killing other wrapper + wrapper.unmount(); + + // lift + const customCallbacks = getStubCallbacks(); + const customWrapper = getWrapper(customCallbacks); + // pending drag started + touchStart(customWrapper, origin); + + // render should not kill a drag start + forceUpdate(customWrapper); + + // should still start a drag + jest.runTimersToTime(timeForLongPress); + + expect( + callbacksCalled(customCallbacks)({ + onLift: 1, + onMove: 0, + onCancel: 0, + }), + ).toBe(true); + + customWrapper.unmount(); + }); + it('should end the drag without firing the onCancel callback', () => { wrapper.setProps({ isDragging: true, diff --git a/test/unit/view/drag-handle/util/app-context.js b/test/unit/view/drag-handle/util/app-context.js new file mode 100644 index 0000000000..b527568b2b --- /dev/null +++ b/test/unit/view/drag-handle/util/app-context.js @@ -0,0 +1,12 @@ +// @flow +import { getMarshalStub } from '../../../../utils/dimension-marshal'; +import { type AppContextValue } from '../../../../../src/view/context/app-context'; + +const value: AppContextValue = { + marshal: getMarshalStub(), + style: '1', + canLift: () => true, + isMovementAllowed: () => true, +}; + +export default value; diff --git a/test/unit/view/drag-handle/util/basic-context.js b/test/unit/view/drag-handle/util/basic-context.js deleted file mode 100644 index 9d0610aea6..0000000000 --- a/test/unit/view/drag-handle/util/basic-context.js +++ /dev/null @@ -1,9 +0,0 @@ -// @flow -import { styleKey, canLiftKey } from '../../../../../src/view/context-keys'; - -const basicContext = { - [styleKey]: 'hello', - [canLiftKey]: () => true, -}; - -export default basicContext; diff --git a/test/unit/view/drag-handle/util/callbacks.js b/test/unit/view/drag-handle/util/callbacks.js index e0e1959714..f1f5d48b9f 100644 --- a/test/unit/view/drag-handle/util/callbacks.js +++ b/test/unit/view/drag-handle/util/callbacks.js @@ -1,5 +1,5 @@ // @flow -import type { Callbacks } from '../../../../../src/view/drag-handle/drag-handle-types'; +import type { Callbacks } from '../../../../../src/view/use-drag-handle/drag-handle-types'; export const getStubCallbacks = (): Callbacks => ({ onLift: jest.fn(), diff --git a/test/unit/view/drag-handle/util/controls.js b/test/unit/view/drag-handle/util/controls.js index 92c85eeca4..d86c7e4a3a 100644 --- a/test/unit/view/drag-handle/util/controls.js +++ b/test/unit/view/drag-handle/util/controls.js @@ -1,7 +1,7 @@ // @flow import type { ReactWrapper } from 'enzyme'; -import { sloppyClickThreshold } from '../../../../../src/view/drag-handle/util/is-sloppy-click-threshold-exceeded'; -import { timeForLongPress } from '../../../../../src/view/drag-handle/sensor/create-touch-sensor'; +import { sloppyClickThreshold } from '../../../../../src/view/use-drag-handle/util/is-sloppy-click-threshold-exceeded'; +import { timeForLongPress } from '../../../../../src/view/use-drag-handle/sensor/use-touch-sensor'; import { primaryButton, touchStart, @@ -26,18 +26,21 @@ export type Control = {| cleanup: () => void, |}; +// using the class rather than the attribute as the attribute will not be present when disabled +const getDragHandle = (wrapper: ReactWrapper<*>) => + // using div. as it can return a component with the classname prop + // using .first in case there is nested handles + wrapper.find('div.drag-handle').first(); + const trySetIsDragging = (wrapper: ReactWrapper<*>) => { - // potentially not looking at the root wrapper - if (!wrapper.props().callbacks) { - return; - } + // sometimes we are dragging a wrapper that is not the root. + // this will throw an error - // lift was not successful - this can happen when not allowed to lift - if (!wrapper.props().callbacks.onLift.mock.calls.length) { - return; + try { + wrapper.setProps({ isDragging: true }); + } catch (e) { + // ignoring error } - // would be set during a drag - wrapper.setProps({ isDragging: true }); }; export const touch: Control = { @@ -45,7 +48,7 @@ export const touch: Control = { hasPostDragClickBlocking: true, hasPreLift: true, preLift: (wrapper: ReactWrapper<*>, options?: Object = {}) => - touchStart(wrapper, { x: 0, y: 0 }, 0, options), + touchStart(getDragHandle(wrapper), { x: 0, y: 0 }, 0, options), lift: (wrapper: ReactWrapper<*>) => { jest.runTimersToTime(timeForLongPress); trySetIsDragging(wrapper); @@ -68,16 +71,16 @@ export const keyboard: Control = { // no pre lift required preLift: () => {}, lift: (wrap: ReactWrapper<*>, options?: Object = {}) => { - pressSpacebar(wrap, options); + pressSpacebar(getDragHandle(wrap), options); trySetIsDragging(wrap); }, move: (wrap: ReactWrapper<*>) => { - pressArrowDown(wrap); + pressArrowDown(getDragHandle(wrap)); }, drop: (wrap: ReactWrapper<*>) => { // only want to fire the event if dragging - otherwise it might start a drag if (wrap.props().isDragging) { - pressSpacebar(wrap); + pressSpacebar(getDragHandle(wrap)); } }, // no cleanup required @@ -88,8 +91,9 @@ export const mouse: Control = { name: 'mouse', hasPostDragClickBlocking: true, hasPreLift: true, - preLift: (wrap: ReactWrapper<*>, options?: Object = {}) => - mouseDown(wrap, { x: 0, y: 0 }, primaryButton, options), + preLift: (wrap: ReactWrapper<*>, options?: Object = {}) => { + mouseDown(getDragHandle(wrap), { x: 0, y: 0 }, primaryButton, options); + }, lift: (wrap: ReactWrapper<*>) => { windowMouseMove({ x: 0, y: sloppyClickThreshold }); trySetIsDragging(wrap); @@ -105,7 +109,8 @@ export const mouse: Control = { }, }; -export const controls: Control[] = [mouse, keyboard, touch]; +// export const controls: Control[] = [mouse, keyboard, touch]; +export const controls: Control[] = [keyboard]; export const forEach = (fn: (control: Control) => void) => { controls.forEach((control: Control) => { diff --git a/test/unit/view/drag-handle/util/wrappers.js b/test/unit/view/drag-handle/util/wrappers.js index 824363e580..0b65f6775d 100644 --- a/test/unit/view/drag-handle/util/wrappers.js +++ b/test/unit/view/drag-handle/util/wrappers.js @@ -1,12 +1,17 @@ // @flow import React, { type Node } from 'react'; import { mount, type ReactWrapper } from 'enzyme'; -import DragHandle from '../../../../../src/view/drag-handle/drag-handle'; +import useDragHandle from '../../../../../src/view/use-drag-handle'; import type { + Args, Callbacks, DragHandleProps, -} from '../../../../../src/view/drag-handle/drag-handle-types'; -import basicContext from './basic-context'; +} from '../../../../../src/view/use-drag-handle/drag-handle-types'; +import basicContext from './app-context'; +import AppContext, { + type AppContextValue, +} from '../../../../../src/view/context/app-context'; +import createRef from '../../../../utils/create-ref'; type ChildProps = {| dragHandleProps: ?DragHandleProps, @@ -21,7 +26,7 @@ export class Child extends React.Component {
Drag me! {this.props.children} @@ -30,40 +35,56 @@ export class Child extends React.Component { } } -export const createRef = () => { - let ref: ?HTMLElement = null; - - const setRef = (supplied: ?HTMLElement) => { - ref = supplied; - }; +type WithDragHandleProps = {| + ...Args, + children: (value: ?DragHandleProps) => Node | null, +|}; - const getRef = (): ?HTMLElement => ref; +export function WithDragHandle(props: WithDragHandleProps) { + // strip the children prop out + const { children, ...args } = props; + const result: ?DragHandleProps = useDragHandle(args); + return props.children(result); +} - return { ref, setRef, getRef }; -}; +export class PassThrough extends React.Component<*> { + render() { + const { children, ...rest } = this.props; + return children(rest); + } +} export const getWrapper = ( callbacks: Callbacks, - context?: Object = basicContext, + appContext?: AppContextValue = basicContext, shouldRespectForceTouch?: boolean = true, ): ReactWrapper<*> => { const ref = createRef(); + // stopping this from creating a new reference and breaking the memoization during a drag + const getShouldRespectForceTouch = () => shouldRespectForceTouch; + return mount( - shouldRespectForceTouch} - > - {(dragHandleProps: ?DragHandleProps) => ( - + + {(outer: any) => ( + + + {(dragHandleProps: ?DragHandleProps) => ( + + )} + + )} - , - { context }, + , ); }; diff --git a/test/unit/view/drag-handle/window-bindings.spec.js b/test/unit/view/drag-handle/window-bindings.spec.js index 96619da1f3..b589620c92 100644 --- a/test/unit/view/drag-handle/window-bindings.spec.js +++ b/test/unit/view/drag-handle/window-bindings.spec.js @@ -1,6 +1,6 @@ // @flow import type { ReactWrapper } from 'enzyme'; -import type { Callbacks } from '../../../../src/view/drag-handle/drag-handle-types'; +import type { Callbacks } from '../../../../src/view/use-drag-handle/drag-handle-types'; import { forEach, type Control } from './util/controls'; import { getWrapper } from './util/wrappers'; import { getStubCallbacks } from './util/callbacks'; @@ -101,6 +101,13 @@ forEach((control: Control) => { // unmounting while dragging wrapper.unmount(); + if (control.hasPostDragClickBlocking) { + // cleanup is still bound + expect(getAddCount()).toBeGreaterThan(getRemoveCount()); + // cleanup performed + control.cleanup(); + } + expect(getAddCount()).toBe(getRemoveCount()); }); diff --git a/test/unit/view/draggable/drag-handle-connection.spec.js b/test/unit/view/draggable/drag-handle-connection.spec.js index 6e212f6fc6..0be499ffb6 100644 --- a/test/unit/view/draggable/drag-handle-connection.spec.js +++ b/test/unit/view/draggable/drag-handle-connection.spec.js @@ -1,25 +1,16 @@ // @flow import React from 'react'; -import { type Position } from 'css-box-model'; import type { ReactWrapper } from 'enzyme'; import type { DispatchProps, Provided, } from '../../../../src/view/draggable/draggable-types'; -import { - draggable, - getDispatchPropsStub, - atRestMapProps, - disabledOwnProps, - whileDragging, -} from './util/get-props'; +import { getDispatchPropsStub } from './util/get-props'; import type { Viewport } from '../../../../src/types'; -import { origin } from '../../../../src/state/position'; import { getPreset } from '../../../utils/dimension'; import { setViewport } from '../../../utils/viewport'; import mount from './util/mount'; import Item from './util/item'; -import DragHandle from '../../../../src/view/drag-handle'; import { withKeyboard } from '../../../utils/user-input-util'; import * as keyCodes from '../../../../src/view/key-codes'; @@ -102,222 +93,3 @@ describe('drag handle not the same element as draggable', () => { expect(dispatchProps.lift).not.toHaveBeenCalled(); }); }); - -describe('handling drag handle events', () => { - describe('onLift', () => { - it('should throw if lifted when dragging is not enabled', () => { - const customWrapper = mount({ - ownProps: disabledOwnProps, - mapProps: atRestMapProps, - }); - - expect(() => { - customWrapper - .find(DragHandle) - .props() - .callbacks.onLift({ - clientSelection: origin, - movementMode: 'SNAP', - }); - }).toThrow(); - }); - - it('should throw if lifted when not attached to the dom', () => { - const customWrapper = mount(); - customWrapper.unmount(); - - expect(() => { - customWrapper - .find(DragHandle) - .props() - .callbacks.onLift({ - clientSelection: origin, - movementMode: 'SNAP', - }); - }).toThrow(); - }); - - it('should lift if permitted', () => { - const dispatchProps = getDispatchPropsStub(); - const wrapper = mount({ - dispatchProps, - }); - - wrapper - .find(DragHandle) - .props() - .callbacks.onLift({ - clientSelection: origin, - movementMode: 'SNAP', - }); - - expect(dispatchProps.lift).toHaveBeenCalledWith({ - id: draggable.id, - clientSelection: origin, - movementMode: 'SNAP', - }); - }); - - describe('onMove', () => { - it('should consider any mouse movement for the client coordinates', () => { - const selection: Position = { - x: 10, - y: 50, - }; - - const dispatchProps = getDispatchPropsStub(); - const wrapper = mount({ - mapProps: whileDragging, - dispatchProps, - }); - - wrapper - .find(DragHandle) - .props() - .callbacks.onMove(selection); - - expect(dispatchProps.move).toHaveBeenCalledWith({ - client: selection, - }); - }); - }); - - describe('onDrop', () => { - it('should trigger drop', () => { - const dispatchProps = getDispatchPropsStub(); - const wrapper = mount({ - dispatchProps, - }); - - wrapper - .find(DragHandle) - .props() - .callbacks.onDrop(); - - expect(dispatchProps.drop).toHaveBeenCalled(); - }); - }); - - describe('onMoveUp', () => { - it('should call the move up action', () => { - const dispatchProps = getDispatchPropsStub(); - const wrapper = mount({ - mapProps: whileDragging, - dispatchProps, - }); - - wrapper - .find(DragHandle) - .props() - .callbacks.onMoveUp(); - - expect(dispatchProps.moveUp).toHaveBeenCalled(); - }); - }); - - describe('onMoveDown', () => { - it('should call the move down action', () => { - const dispatchProps = getDispatchPropsStub(); - const wrapper = mount({ - mapProps: whileDragging, - dispatchProps, - }); - - wrapper - .find(DragHandle) - .props() - .callbacks.onMoveDown(); - - expect(dispatchProps.moveDown).toHaveBeenCalled(); - }); - }); - - describe('onMoveLeft', () => { - it('should call the cross axis move forward action', () => { - const dispatchProps = getDispatchPropsStub(); - const wrapper = mount({ - mapProps: whileDragging, - dispatchProps, - }); - - wrapper - .find(DragHandle) - .props() - .callbacks.onMoveLeft(); - - expect(dispatchProps.moveLeft).toHaveBeenCalled(); - }); - }); - - describe('onMoveRight', () => { - it('should call the move cross axis backwards action', () => { - const dispatchProps = getDispatchPropsStub(); - const wrapper = mount({ - mapProps: whileDragging, - dispatchProps, - }); - - wrapper - .find(DragHandle) - .props() - .callbacks.onMoveRight(); - - expect(dispatchProps.moveRight).toHaveBeenCalled(); - }); - }); - - describe('onCancel', () => { - it('should call the drop dispatch prop', () => { - const dispatchProps = getDispatchPropsStub(); - const wrapper = mount({ - mapProps: whileDragging, - dispatchProps, - }); - - wrapper - .find(DragHandle) - .props() - .callbacks.onCancel(); - - expect(dispatchProps.drop).toHaveBeenCalledWith({ - reason: 'CANCEL', - }); - }); - - it('should allow the action even if dragging is disabled', () => { - const dispatchProps = getDispatchPropsStub(); - const wrapper = mount({ - ownProps: disabledOwnProps, - mapProps: whileDragging, - dispatchProps, - }); - - wrapper - .find(DragHandle) - .props() - .callbacks.onCancel(); - - expect(dispatchProps.drop).toHaveBeenCalled(); - }); - }); - - describe('onWindowScroll', () => { - it('should call the moveByWindowScroll action', () => { - const dispatchProps = getDispatchPropsStub(); - const wrapper = mount({ - mapProps: whileDragging, - dispatchProps, - }); - - wrapper - .find(DragHandle) - .props() - .callbacks.onWindowScroll(); - - expect(dispatchProps.moveByWindowScroll).toHaveBeenCalledWith({ - newScroll: viewport.scroll.current, - }); - }); - }); - }); -}); diff --git a/test/unit/view/draggable/mounting.spec.js b/test/unit/view/draggable/mounting.spec.js index 8d10140e9a..31c71f585d 100644 --- a/test/unit/view/draggable/mounting.spec.js +++ b/test/unit/view/draggable/mounting.spec.js @@ -1,8 +1,6 @@ // @flow import type { ReactWrapper } from 'enzyme'; -import type { StyleMarshal } from '../../../../src/view/style-marshal/style-marshal-types'; import type { Provided } from '../../../../src/view/draggable/draggable-types'; -import createStyleMarshal from '../../../../src/view/style-marshal/style-marshal'; import mount from './util/mount'; import getStubber from './util/get-stubber'; import getLastCall from './util/get-last-call'; @@ -20,16 +18,14 @@ it('should not create any wrapping elements', () => { it('should attach a data attribute for global styling', () => { const myMock = jest.fn(); const Stubber = getStubber(myMock); - const styleMarshal: StyleMarshal = createStyleMarshal(); + const styleContext: string = 'this is a cool style context'; mount({ mapProps: atRestMapProps, WrappedComponent: Stubber, - styleMarshal, + styleContext, }); const provided: Provided = getLastCall(myMock)[0].provided; - expect(provided.draggableProps[attributes.draggable]).toEqual( - styleMarshal.styleContext, - ); + expect(provided.draggableProps[attributes.draggable]).toEqual(styleContext); }); diff --git a/test/unit/view/draggable/util/mount.js b/test/unit/view/draggable/util/mount.js index d7c71b287c..1dfc1f3412 100644 --- a/test/unit/view/draggable/util/mount.js +++ b/test/unit/view/draggable/util/mount.js @@ -1,23 +1,14 @@ // @flow -import React from 'react'; +import React, { type Node } from 'react'; import { mount, type ReactWrapper } from 'enzyme'; import type { + Props, OwnProps, MapProps, DispatchProps, Provided, StateSnapshot, } from '../../../../../src/view/draggable/draggable-types'; -import type { StyleMarshal } from '../../../../../src/view/style-marshal/style-marshal-types'; -import { - combine, - withStore, - withDroppableId, - withStyleContext, - withDimensionMarshal, - withCanLift, - withDroppableType, -} from '../../../../utils/get-context-options'; import { atRestMapProps, getDispatchPropsStub, @@ -26,36 +17,67 @@ import { } from './get-props'; import Item from './item'; import Draggable from '../../../../../src/view/draggable/draggable'; +import AppContext, { + type AppContextValue, +} from '../../../../../src/view/context/app-context'; +import DroppableContext, { + type DroppableContextValue, +} from '../../../../../src/view/context/droppable-context'; +import { getMarshalStub } from '../../../../utils/dimension-marshal'; type MountConnected = {| ownProps?: OwnProps, mapProps?: MapProps, dispatchProps?: DispatchProps, WrappedComponent?: any, - styleMarshal?: StyleMarshal, + styleContext?: string, +|}; + +type PassThroughProps = {| + ...Props, + children: (props: Props) => Node, |}; +export class PassThrough extends React.Component { + render() { + const { children, ...rest } = this.props; + // $FlowFixMe - incorrectly typed child function + return this.props.children(rest); + } +} export default ({ ownProps = defaultOwnProps, mapProps = atRestMapProps, dispatchProps = getDispatchPropsStub(), WrappedComponent = Item, - styleMarshal, + styleContext = 'fake-style-context', }: MountConnected = {}): ReactWrapper<*> => { + const droppableContext: DroppableContextValue = { + droppableId: droppable.id, + type: droppable.type, + }; + + const appContext: AppContextValue = { + marshal: getMarshalStub(), + style: styleContext, + canLift: () => true, + isMovementAllowed: () => true, + }; + // Using PassThrough so that you can do .setProps on the root const wrapper: ReactWrapper<*> = mount( - - {(provided: Provided, snapshot: StateSnapshot) => ( - + + {(props: Props) => ( + + + + {(provided: Provided, snapshot: StateSnapshot) => ( + + )} + + + )} - , - combine( - withStore(), - withDroppableId(droppable.id), - withDroppableType(droppable.type), - withStyleContext(styleMarshal), - withDimensionMarshal(), - withCanLift(), - ), + , ); return wrapper; diff --git a/test/unit/view/droppable-dimension-publisher/forced-scroll.spec.js b/test/unit/view/droppable-dimension-publisher/forced-scroll.spec.js index d05c64ae07..dd61532b31 100644 --- a/test/unit/view/droppable-dimension-publisher/forced-scroll.spec.js +++ b/test/unit/view/droppable-dimension-publisher/forced-scroll.spec.js @@ -7,7 +7,6 @@ import type { DroppableCallbacks, } from '../../../../src/state/dimension-marshal/dimension-marshal-types'; import { getMarshalStub } from '../../../utils/dimension-marshal'; -import { withDimensionMarshal } from '../../../utils/get-context-options'; import { setViewport } from '../../../utils/viewport'; import { App, @@ -17,6 +16,7 @@ import { preset, scheduled, ScrollableItem, + WithAppContext, } from './util/shared'; import tryCleanPrototypeStubs from '../../../utils/try-clean-prototype-stubs'; @@ -30,12 +30,14 @@ it('should throw if the droppable has no closest scrollable', () => { const marshal: DimensionMarshal = getMarshalStub(); // no scroll parent const wrapper = mount( - , - withDimensionMarshal(marshal), + + , + , ); - const droppable: ?HTMLElement = wrapper.instance().getRef(); + const droppable: ?HTMLElement = wrapper.find('.droppable').getDOMNode(); invariant(droppable); - const parent: HTMLElement = wrapper.getDOMNode(); + const parent: ?HTMLElement = wrapper.find('.scroll-parent').getDOMNode(); + invariant(parent); jest .spyOn(droppable, 'getBoundingClientRect') .mockImplementation(() => smallFrameClient.borderBox); @@ -67,12 +69,15 @@ it('should throw if the droppable has no closest scrollable', () => { describe('there is a closest scrollable', () => { it('should update the scroll of the closest scrollable', () => { const marshal: DimensionMarshal = getMarshalStub(); - const wrapper = mount(, withDimensionMarshal(marshal)); - const container: HTMLElement = wrapper.getDOMNode(); - - if (!container.classList.contains('scroll-container')) { - throw new Error('incorrect dom node collected'); - } + const wrapper = mount( + + + , + ); + const container: ?HTMLElement = wrapper + .find('.scroll-container') + .getDOMNode(); + invariant(container); expect(container.scrollTop).toBe(0); expect(container.scrollLeft).toBe(0); @@ -91,14 +96,16 @@ describe('there is a closest scrollable', () => { it('should throw if asked to scoll while scroll is not currently being watched', () => { const marshal: DimensionMarshal = getMarshalStub(); - const wrapper = mount(, withDimensionMarshal(marshal)); - - const container: HTMLElement = wrapper.getDOMNode(); - - if (!container.classList.contains('scroll-container')) { - throw new Error('incorrect dom node collected'); - } - + const wrapper = mount( + + + , + ); + + const container: ?HTMLElement = wrapper + .find('.scroll-container') + .getDOMNode(); + invariant(container); expect(container.scrollTop).toBe(0); expect(container.scrollLeft).toBe(0); diff --git a/test/unit/view/droppable-dimension-publisher/is-combined-enabled-change.spec.js b/test/unit/view/droppable-dimension-publisher/is-combined-enabled-change.spec.js index 5ac40cb3c6..2ea67e33f1 100644 --- a/test/unit/view/droppable-dimension-publisher/is-combined-enabled-change.spec.js +++ b/test/unit/view/droppable-dimension-publisher/is-combined-enabled-change.spec.js @@ -6,18 +6,28 @@ import type { DroppableCallbacks, } from '../../../../src/state/dimension-marshal/dimension-marshal-types'; import { getMarshalStub } from '../../../utils/dimension-marshal'; -import { withDimensionMarshal } from '../../../utils/get-context-options'; import { setViewport } from '../../../utils/viewport'; -import { preset, scheduled, ScrollableItem } from './util/shared'; +import { + preset, + scheduled, + ScrollableItem, + WithAppContext, +} from './util/shared'; import forceUpdate from '../../../utils/force-update'; +import PassThroughProps from '../../../utils/pass-through-props'; setViewport(preset.viewport); it('should publish updates to the enabled state when dragging', () => { const marshal: DimensionMarshal = getMarshalStub(); const wrapper = mount( - , - withDimensionMarshal(marshal), + + {extra => ( + + + + )} + , ); // not called yet expect(marshal.updateDroppableIsCombineEnabled).not.toHaveBeenCalled(); @@ -51,8 +61,13 @@ it('should publish updates to the enabled state when dragging', () => { it('should not publish updates to the enabled state when there is no drag', () => { const marshal: DimensionMarshal = getMarshalStub(); const wrapper = mount( - , - withDimensionMarshal(marshal), + + {extra => ( + + , + + )} + , ); // not called yet @@ -70,8 +85,13 @@ it('should not publish updates to the enabled state when there is no drag', () = it('should not publish updates when there is no change', () => { const marshal: DimensionMarshal = getMarshalStub(); const wrapper = mount( - , - withDimensionMarshal(marshal), + + {extra => ( + + , + + )} + , ); // not called yet diff --git a/test/unit/view/droppable-dimension-publisher/is-element-scrollable.spec.js b/test/unit/view/droppable-dimension-publisher/is-element-scrollable.spec.js index a4d1ef79f7..f45225cd9c 100644 --- a/test/unit/view/droppable-dimension-publisher/is-element-scrollable.spec.js +++ b/test/unit/view/droppable-dimension-publisher/is-element-scrollable.spec.js @@ -1,6 +1,6 @@ // @flow import invariant from 'tiny-invariant'; -import getClosestScrollable from '../../../../src/view/droppable-dimension-publisher/get-closest-scrollable'; +import getClosestScrollable from '../../../../src/view/use-droppable-dimension-publisher/get-closest-scrollable'; it('should return true if an element has overflow:auto or overflow:scroll', () => { ['overflowY', 'overflowX'].forEach((overflow: string) => { diff --git a/test/unit/view/droppable-dimension-publisher/is-enabled-change.spec.js b/test/unit/view/droppable-dimension-publisher/is-enabled-change.spec.js index 4757419e5c..0d6298368f 100644 --- a/test/unit/view/droppable-dimension-publisher/is-enabled-change.spec.js +++ b/test/unit/view/droppable-dimension-publisher/is-enabled-change.spec.js @@ -6,18 +6,28 @@ import type { DroppableCallbacks, } from '../../../../src/state/dimension-marshal/dimension-marshal-types'; import { getMarshalStub } from '../../../utils/dimension-marshal'; -import { withDimensionMarshal } from '../../../utils/get-context-options'; import { setViewport } from '../../../utils/viewport'; -import { preset, scheduled, ScrollableItem } from './util/shared'; +import { + preset, + scheduled, + ScrollableItem, + WithAppContext, +} from './util/shared'; import forceUpdate from '../../../utils/force-update'; +import PassThroughProps from '../../../utils/pass-through-props'; setViewport(preset.viewport); it('should publish updates to the enabled state when dragging', () => { const marshal: DimensionMarshal = getMarshalStub(); const wrapper = mount( - , - withDimensionMarshal(marshal), + + {extra => ( + + + + )} + , ); // not called yet expect(marshal.updateDroppableIsEnabled).not.toHaveBeenCalled(); @@ -41,8 +51,13 @@ it('should publish updates to the enabled state when dragging', () => { it('should not publish updates to the enabled state when there is no drag', () => { const marshal: DimensionMarshal = getMarshalStub(); const wrapper = mount( - , - withDimensionMarshal(marshal), + + {extra => ( + + + + )} + , ); // not called yet @@ -60,8 +75,13 @@ it('should not publish updates to the enabled state when there is no drag', () = it('should not publish updates when there is no change', () => { const marshal: DimensionMarshal = getMarshalStub(); const wrapper = mount( - , - withDimensionMarshal(marshal), + + {extra => ( + + + + )} + , ); // not called yet diff --git a/test/unit/view/droppable-dimension-publisher/publishing.spec.js b/test/unit/view/droppable-dimension-publisher/publishing.spec.js index 2040a8f812..f86a611b52 100644 --- a/test/unit/view/droppable-dimension-publisher/publishing.spec.js +++ b/test/unit/view/droppable-dimension-publisher/publishing.spec.js @@ -12,11 +12,11 @@ import { negate } from '../../../../src/state/position'; import { offsetByPosition } from '../../../../src/state/spacing'; import { getDroppableDimension } from '../../../utils/dimension'; import { getMarshalStub } from '../../../utils/dimension-marshal'; -import { withDimensionMarshal } from '../../../utils/get-context-options'; import setWindowScroll from '../../../utils/set-window-scroll'; import { App, ScrollableItem, + WithAppContext, scheduled, immediate, preset, @@ -52,14 +52,15 @@ it('should publish the dimensions of the target', () => { windowScroll: { x: 0, y: 0 }, }); const wrapper: ReactWrapper<*> = mount( - , - withDimensionMarshal(marshal), + + + , ); - const el: ?HTMLElement = wrapper.instance().getRef(); + const el: ?HTMLElement = wrapper.getDOMNode(); invariant(el); jest .spyOn(el, 'getBoundingClientRect') @@ -101,14 +102,15 @@ it('should consider the window scroll when calculating dimensions', () => { }); const wrapper: ReactWrapper<*> = mount( - , - withDimensionMarshal(marshal), + + + , ); - const el: ?HTMLElement = wrapper.instance().getRef(); + const el: ?HTMLElement = wrapper.getDOMNode(); invariant(el); jest .spyOn(el, 'getBoundingClientRect') @@ -138,10 +140,11 @@ describe('no closest scrollable', () => { }); const marshal: DimensionMarshal = getMarshalStub(); const wrapper = mount( - , - withDimensionMarshal(marshal), + + + , ); - const el: ?HTMLElement = wrapper.instance().getRef(); + const el: ?HTMLElement = wrapper.find('.droppable').getDOMNode(); invariant(el); jest .spyOn(el, 'getBoundingClientRect') @@ -194,10 +197,11 @@ describe('droppable is scrollable', () => { const marshal: DimensionMarshal = getMarshalStub(); // both the droppable and the parent are scrollable const wrapper = mount( - , - withDimensionMarshal(marshal), + + + , ); - const el: ?HTMLElement = wrapper.instance().getRef(); + const el: ?HTMLElement = wrapper.find('.droppable').getDOMNode(); invariant(el); // returning smaller border box as this is what occurs when the element is scrollable jest @@ -257,10 +261,11 @@ describe('droppable is scrollable', () => { const marshal: DimensionMarshal = getMarshalStub(); // both the droppable and the parent are scrollable const wrapper = mount( - , - withDimensionMarshal(marshal), + + + , ); - const el: ?HTMLElement = wrapper.instance().getRef(); + const el: ?HTMLElement = wrapper.find('.droppable').getDOMNode(); invariant(el); // returning smaller border box as this is what occurs when the element is scrollable jest @@ -314,15 +319,16 @@ describe('parent of droppable is scrollable', () => { }); const marshal: DimensionMarshal = getMarshalStub(); const wrapper = mount( - , - withDimensionMarshal(marshal), + + + , ); - const droppable: ?HTMLElement = wrapper.instance().getRef(); + const droppable: ?HTMLElement = wrapper.find('.droppable').getDOMNode(); invariant(droppable); jest .spyOn(droppable, 'getBoundingClientRect') .mockImplementation(() => bigClient.borderBox); - const parent: HTMLElement = wrapper.getDOMNode(); + const parent: HTMLElement = wrapper.find('.scroll-parent').getDOMNode(); jest .spyOn(parent, 'getBoundingClientRect') .mockImplementation(() => smallFrameClient.borderBox); @@ -370,12 +376,13 @@ describe('both droppable and parent is scrollable', () => { }); const marshal: DimensionMarshal = getMarshalStub(); const wrapper = mount( - , - withDimensionMarshal(marshal), + + , + , ); - const droppable: ?HTMLElement = wrapper.instance().getRef(); + const droppable: ?HTMLElement = wrapper.find('.droppable').getDOMNode(); invariant(droppable); - const parent: HTMLElement = wrapper.getDOMNode(); + const parent: HTMLElement = wrapper.find('.scroll-parent').getDOMNode(); jest .spyOn(droppable, 'getBoundingClientRect') .mockImplementation(() => smallFrameClient.borderBox); @@ -413,12 +420,14 @@ it('should capture the initial scroll of the closest scrollable', () => { const frameScroll: Position = { x: 10, y: 20 }; const marshal: DimensionMarshal = getMarshalStub(); const wrapper = mount( - , - withDimensionMarshal(marshal), + + , + , ); - const droppable: ?HTMLElement = wrapper.instance().getRef(); + const droppable: ?HTMLElement = wrapper.find('.droppable').getDOMNode(); invariant(droppable); - const parent: HTMLElement = wrapper.getDOMNode(); + const parent: HTMLElement = wrapper.find('.scroll-parent').getDOMNode(); + invariant(parent); // manually setting the scroll of the parent node parent.scrollTop = frameScroll.y; parent.scrollLeft = frameScroll.x; @@ -471,16 +480,17 @@ it('should indicate if subject clipping is permitted based on the ignoreContaine // in this case the parent of the droppable is the closest scrollable const marshal: DimensionMarshal = getMarshalStub(); const wrapper = mount( - , - withDimensionMarshal(marshal), + + + , ); - const droppable: ?HTMLElement = wrapper.instance().getRef(); + const droppable: ?HTMLElement = wrapper.find('.droppable').getDOMNode(); invariant(droppable); - const parent: HTMLElement = wrapper.getDOMNode(); + const parent: HTMLElement = wrapper.find('.scroll-parent').getDOMNode(); const scrollSize: ScrollSize = { scrollWidth: bigClient.paddingBox.width, scrollHeight: bigClient.paddingBox.height, diff --git a/test/unit/view/droppable-dimension-publisher/recollection.spec.js b/test/unit/view/droppable-dimension-publisher/recollection.spec.js index f0a7473694..0c5cb193f9 100644 --- a/test/unit/view/droppable-dimension-publisher/recollection.spec.js +++ b/test/unit/view/droppable-dimension-publisher/recollection.spec.js @@ -9,7 +9,6 @@ import type { import type { DroppableDimension } from '../../../../src/types'; import { getDroppableDimension } from '../../../utils/dimension'; import { getMarshalStub } from '../../../utils/dimension-marshal'; -import { withDimensionMarshal } from '../../../utils/get-context-options'; import tryCleanPrototypeStubs from '../../../utils/try-clean-prototype-stubs'; import { setViewport } from '../../../utils/viewport'; import { @@ -22,6 +21,7 @@ import { padding, preset, smallFrameClient, + WithAppContext, } from './util/shared'; beforeEach(() => { @@ -61,10 +61,11 @@ it('should recollect a dimension if requested', () => { const marshal: DimensionMarshal = getMarshalStub(); // both the droppable and the parent are scrollable const wrapper = mount( - , - withDimensionMarshal(marshal), + + + , ); - const el: ?HTMLElement = wrapper.instance().getRef(); + const el: ?HTMLElement = wrapper.find('.droppable').getDOMNode(); invariant(el); // returning smaller border box as this is what occurs when the element is scrollable jest @@ -102,12 +103,13 @@ it('should hide any placeholder when recollecting dimensions if requested', () = const marshal: DimensionMarshal = getMarshalStub(); // both the droppable and the parent are scrollable const wrapper = mount( - , - withDimensionMarshal(marshal), + + + , ); - const el: ?HTMLElement = wrapper.instance().getRef(); - const placeholderEl: ?HTMLElement = wrapper.instance().getPlaceholderRef(); + const el: ?HTMLElement = wrapper.find('.droppable').getDOMNode(); invariant(el); + const placeholderEl: ?HTMLElement = wrapper.find('.placeholder').getDOMNode(); invariant(placeholderEl); // returning smaller border box as this is what occurs when the element is scrollable jest @@ -142,12 +144,13 @@ it('should not hide any placeholder when recollecting dimensions if requested', const marshal: DimensionMarshal = getMarshalStub(); // both the droppable and the parent are scrollable const wrapper = mount( - , - withDimensionMarshal(marshal), + + + , ); - const el: ?HTMLElement = wrapper.instance().getRef(); - const placeholderEl: ?HTMLElement = wrapper.instance().getPlaceholderRef(); + const el: ?HTMLElement = wrapper.find('.droppable').getDOMNode(); invariant(el); + const placeholderEl: ?HTMLElement = wrapper.find('.placeholder').getDOMNode(); invariant(placeholderEl); // returning smaller border box as this is what occurs when the element is scrollable jest @@ -179,8 +182,9 @@ it('should throw if there is no drag occurring when a recollection is requested' const marshal: DimensionMarshal = getMarshalStub(); // both the droppable and the parent are scrollable mount( - , - withDimensionMarshal(marshal), + + + , ); const callbacks: DroppableCallbacks = @@ -192,7 +196,11 @@ it('should throw if there is no drag occurring when a recollection is requested' it('should throw if there if recollecting from droppable that is not a scroll container', () => { const marshal: DimensionMarshal = getMarshalStub(); // both the droppable and the parent are scrollable - mount(, withDimensionMarshal(marshal)); + mount( + + + , + ); const callbacks: DroppableCallbacks = marshal.registerDroppable.mock.calls[0][1]; diff --git a/test/unit/view/droppable-dimension-publisher/registration.spec.js b/test/unit/view/droppable-dimension-publisher/registration.spec.js index cb396a747f..a863146d80 100644 --- a/test/unit/view/droppable-dimension-publisher/registration.spec.js +++ b/test/unit/view/droppable-dimension-publisher/registration.spec.js @@ -2,24 +2,24 @@ /* eslint-disable react/no-multi-comp */ import { mount } from 'enzyme'; import React from 'react'; -import type { - DimensionMarshal, - DroppableCallbacks, -} from '../../../../src/state/dimension-marshal/dimension-marshal-types'; -import type { DroppableDimension } from '../../../../src/types'; -import getWindowScroll from '../../../../src/view/window/get-window-scroll'; +import type { DimensionMarshal } from '../../../../src/state/dimension-marshal/dimension-marshal-types'; +import type { DroppableDescriptor } from '../../../../src/types'; import { getMarshalStub } from '../../../utils/dimension-marshal'; import forceUpdate from '../../../utils/force-update'; -import { withDimensionMarshal } from '../../../utils/get-context-options'; -import { preset, scheduled, ScrollableItem } from './util/shared'; +import { preset, ScrollableItem, WithAppContext } from './util/shared'; import { setViewport } from '../../../utils/viewport'; +import PassThroughProps from '../../../utils/pass-through-props'; setViewport(preset.viewport); it('should register itself when mounting', () => { const marshal: DimensionMarshal = getMarshalStub(); - mount(, withDimensionMarshal(marshal)); + mount( + + + , + ); expect(marshal.registerDroppable).toHaveBeenCalledTimes(1); expect(marshal.registerDroppable.mock.calls[0][0]).toEqual( @@ -30,7 +30,11 @@ it('should register itself when mounting', () => { it('should unregister itself when unmounting', () => { const marshal: DimensionMarshal = getMarshalStub(); - const wrapper = mount(, withDimensionMarshal(marshal)); + const wrapper = mount( + + + , + ); expect(marshal.registerDroppable).toHaveBeenCalled(); expect(marshal.unregisterDroppable).not.toHaveBeenCalled(); @@ -44,49 +48,53 @@ it('should unregister itself when unmounting', () => { it('should update its registration when a descriptor property changes', () => { const marshal: DimensionMarshal = getMarshalStub(); - const wrapper = mount(, withDimensionMarshal(marshal)); + const wrapper = mount( + + {extra => ( + + + + )} + , + ); // asserting shape of original publish expect(marshal.registerDroppable.mock.calls[0][0]).toEqual( preset.home.descriptor, ); - const original: DroppableDimension = marshal.registerDroppable.mock.calls[0][1].getDimensionAndWatchScroll( - getWindowScroll(), - scheduled, - ); + marshal.registerDroppable.mockClear(); // updating the index wrapper.setProps({ droppableId: 'some-new-id', }); - const updated: DroppableDimension = { - ...original, - descriptor: { - ...original.descriptor, - id: 'some-new-id', - }, + const updated: DroppableDescriptor = { + ...preset.home.descriptor, + id: 'some-new-id', }; - expect(marshal.updateDroppable).toHaveBeenCalledTimes(1); - expect(marshal.updateDroppable).toHaveBeenCalledWith( + + // old descriptor removed + expect(marshal.unregisterDroppable).toHaveBeenCalledTimes(1); + expect(marshal.unregisterDroppable).toHaveBeenCalledWith( preset.home.descriptor, - updated.descriptor, - // Droppable callbacks - expect.any(Object), ); - // should now return a dimension with the correct descriptor - const callbacks: DroppableCallbacks = - marshal.updateDroppable.mock.calls[0][2]; - callbacks.dragStopped(); - expect( - callbacks.getDimensionAndWatchScroll(preset.windowScroll, scheduled), - ).toEqual(updated); + + // new descriptor added + expect(marshal.registerDroppable.mock.calls[0][0]).toEqual(updated); }); it('should not update its registration when a descriptor property does not change on an update', () => { const marshal: DimensionMarshal = getMarshalStub(); - const wrapper = mount(, withDimensionMarshal(marshal)); + const wrapper = mount( + + + , + ); expect(marshal.registerDroppable).toHaveBeenCalledTimes(1); + expect(marshal.unregisterDroppable).not.toHaveBeenCalled(); + marshal.registerDroppable.mockClear(); forceUpdate(wrapper); - expect(marshal.updateDroppable).not.toHaveBeenCalled(); + expect(marshal.unregisterDroppable).not.toHaveBeenCalled(); + expect(marshal.registerDroppable).not.toHaveBeenCalled(); }); diff --git a/test/unit/view/droppable-dimension-publisher/scroll-watching.spec.js b/test/unit/view/droppable-dimension-publisher/scroll-watching.spec.js index 4b4e7e5fa7..ddf99ceb05 100644 --- a/test/unit/view/droppable-dimension-publisher/scroll-watching.spec.js +++ b/test/unit/view/droppable-dimension-publisher/scroll-watching.spec.js @@ -1,5 +1,6 @@ // @flow import { mount } from 'enzyme'; +import invariant from 'tiny-invariant'; import React from 'react'; import { type Position } from 'css-box-model'; import type { @@ -7,9 +8,14 @@ import type { DroppableCallbacks, } from '../../../../src/state/dimension-marshal/dimension-marshal-types'; import { getMarshalStub } from '../../../utils/dimension-marshal'; -import { withDimensionMarshal } from '../../../utils/get-context-options'; import { setViewport } from '../../../utils/viewport'; -import { immediate, preset, scheduled, ScrollableItem } from './util/shared'; +import { + immediate, + preset, + scheduled, + ScrollableItem, + WithAppContext, +} from './util/shared'; const scroll = (el: HTMLElement, target: Position) => { el.scrollTop = target.y; @@ -22,12 +28,15 @@ setViewport(preset.viewport); describe('should immediately publish updates', () => { it('should immediately publish the scroll offset of the closest scrollable', () => { const marshal: DimensionMarshal = getMarshalStub(); - const wrapper = mount(, withDimensionMarshal(marshal)); - const container: HTMLElement = wrapper.getDOMNode(); - - if (!container.classList.contains('scroll-container')) { - throw new Error('incorrect dom node collected'); - } + const wrapper = mount( + + + , + ); + const container: ?HTMLElement = wrapper + .find('.scroll-container') + .getDOMNode(); + invariant(container); // tell the droppable to watch for scrolling const callbacks: DroppableCallbacks = @@ -46,8 +55,15 @@ describe('should immediately publish updates', () => { it('should not fire a scroll if the value has not changed since the previous call', () => { // this can happen if you scroll backward and forward super quick const marshal: DimensionMarshal = getMarshalStub(); - const wrapper = mount(, withDimensionMarshal(marshal)); - const container: HTMLElement = wrapper.getDOMNode(); + const wrapper = mount( + + + , + ); + const container: ?HTMLElement = wrapper + .find('.scroll-container') + .getDOMNode(); + invariant(container); // tell the droppable to watch for scrolling const callbacks: DroppableCallbacks = marshal.registerDroppable.mock.calls[0][1]; @@ -80,12 +96,15 @@ describe('should immediately publish updates', () => { describe('should schedule publish updates', () => { it('should publish the scroll offset of the closest scrollable', () => { const marshal: DimensionMarshal = getMarshalStub(); - const wrapper = mount(, withDimensionMarshal(marshal)); - const container: HTMLElement = wrapper.getDOMNode(); - - if (!container.classList.contains('scroll-container')) { - throw new Error('incorrect dom node collected'); - } + const wrapper = mount( + + + , + ); + const container: ?HTMLElement = wrapper + .find('.scroll-container') + .getDOMNode(); + invariant(container); // tell the droppable to watch for scrolling const callbacks: DroppableCallbacks = @@ -105,8 +124,15 @@ describe('should schedule publish updates', () => { it('should throttle multiple scrolls into a animation frame', () => { const marshal: DimensionMarshal = getMarshalStub(); - const wrapper = mount(, withDimensionMarshal(marshal)); - const container: HTMLElement = wrapper.getDOMNode(); + const wrapper = mount( + + + , + ); + const container: ?HTMLElement = wrapper + .find('.scroll-container') + .getDOMNode(); + invariant(container); // tell the droppable to watch for scrolling const callbacks: DroppableCallbacks = marshal.registerDroppable.mock.calls[0][1]; @@ -136,8 +162,15 @@ describe('should schedule publish updates', () => { it('should not fire a scroll if the value has not changed since the previous frame', () => { // this can happen if you scroll backward and forward super quick const marshal: DimensionMarshal = getMarshalStub(); - const wrapper = mount(, withDimensionMarshal(marshal)); - const container: HTMLElement = wrapper.getDOMNode(); + const wrapper = mount( + + + , + ); + const container: ?HTMLElement = wrapper + .find('.scroll-container') + .getDOMNode(); + invariant(container); // tell the droppable to watch for scrolling const callbacks: DroppableCallbacks = marshal.registerDroppable.mock.calls[0][1]; @@ -169,8 +202,15 @@ describe('should schedule publish updates', () => { it('should not publish a scroll update after requested not to update while an animation frame is occurring', () => { const marshal: DimensionMarshal = getMarshalStub(); - const wrapper = mount(, withDimensionMarshal(marshal)); - const container: HTMLElement = wrapper.getDOMNode(); + const wrapper = mount( + + + , + ); + const container: ?HTMLElement = wrapper + .find('.scroll-container') + .getDOMNode(); + invariant(container); // tell the droppable to watch for scrolling const callbacks: DroppableCallbacks = marshal.registerDroppable.mock.calls[0][1]; @@ -200,8 +240,15 @@ describe('should schedule publish updates', () => { it('should stop watching scroll when no longer required to publish', () => { // this can happen if you scroll backward and forward super quick const marshal: DimensionMarshal = getMarshalStub(); - const wrapper = mount(, withDimensionMarshal(marshal)); - const container: HTMLElement = wrapper.getDOMNode(); + const wrapper = mount( + + + , + ); + const container: ?HTMLElement = wrapper + .find('.scroll-container') + .getDOMNode(); + invariant(container); // tell the droppable to watch for scrolling const callbacks: DroppableCallbacks = marshal.registerDroppable.mock.calls[0][1]; @@ -224,8 +271,15 @@ it('should stop watching scroll when no longer required to publish', () => { it('should stop watching for scroll events when the component is unmounted', () => { jest.spyOn(console, 'warn').mockImplementation(() => {}); const marshal: DimensionMarshal = getMarshalStub(); - const wrapper = mount(, withDimensionMarshal(marshal)); - const container: HTMLElement = wrapper.getDOMNode(); + const wrapper = mount( + + + , + ); + const container: ?HTMLElement = wrapper + .find('.scroll-container') + .getDOMNode(); + invariant(container); // tell the droppable to watch for scrolling const callbacks: DroppableCallbacks = marshal.registerDroppable.mock.calls[0][1]; @@ -247,7 +301,11 @@ it('should stop watching for scroll events when the component is unmounted', () it('should throw an error if asked to watch a scroll when already listening for scroll changes', () => { const marshal: DimensionMarshal = getMarshalStub(); - const wrapper = mount(, withDimensionMarshal(marshal)); + const wrapper = mount( + + + , + ); // tell the droppable to watch for scrolling const callbacks: DroppableCallbacks = marshal.registerDroppable.mock.calls[0][1]; @@ -266,8 +324,15 @@ it('should throw an error if asked to watch a scroll when already listening for // if this is not the case then it will break in IE11 it('should add and remove events with the same event options', () => { const marshal: DimensionMarshal = getMarshalStub(); - const wrapper = mount(, withDimensionMarshal(marshal)); - const container: HTMLElement = wrapper.getDOMNode(); + const wrapper = mount( + + + , + ); + const container: ?HTMLElement = wrapper + .find('.scroll-container') + .getDOMNode(); + invariant(container); jest.spyOn(container, 'addEventListener'); jest.spyOn(container, 'removeEventListener'); diff --git a/test/unit/view/droppable-dimension-publisher/util/shared.js b/test/unit/view/droppable-dimension-publisher/util/shared.js index 546fbcba3e..e98f64384e 100644 --- a/test/unit/view/droppable-dimension-publisher/util/shared.js +++ b/test/unit/view/droppable-dimension-publisher/util/shared.js @@ -1,15 +1,20 @@ // @flow /* eslint-disable react/no-multi-comp */ import { createBox, type Spacing, type BoxModel } from 'css-box-model'; -import React, { Component } from 'react'; -import DroppableDimensionPublisher from '../../../../../src/view/droppable-dimension-publisher/droppable-dimension-publisher'; +import React, { useMemo, type Node } from 'react'; +import useDroppableDimensionPublisher from '../../../../../src/view/use-droppable-dimension-publisher/use-droppable-dimension-publisher'; import { getComputedSpacing, getPreset } from '../../../../utils/dimension'; +import { type DimensionMarshal } from '../../../../../src/state/dimension-marshal/dimension-marshal-types'; import type { ScrollOptions, DroppableId, DroppableDescriptor, TypeId, } from '../../../../../src/types'; +import createRef from '../../../../utils/create-ref'; +import AppContext, { + type AppContextValue, +} from '../../../../../src/view/context/app-context'; export const scheduled: ScrollOptions = { shouldPublishImmediately: false, @@ -66,150 +71,133 @@ const withSpacing = getComputedSpacing({ padding, margin, border }); export const descriptor: DroppableDescriptor = preset.home.descriptor; +type WithAppContextProps = {| + marshal: DimensionMarshal, + children: Node, +|}; + +export function WithAppContext(props: WithAppContextProps) { + const context: AppContextValue = useMemo( + () => ({ + marshal: props.marshal, + style: 'fake', + canLift: () => true, + isMovementAllowed: () => true, + }), + [props.marshal], + ); + + return ( + {props.children} + ); +} + type ScrollableItemProps = {| - // scrollable item prop (default: false) - isScrollable: boolean, - isDropDisabled: boolean, - isCombineEnabled: boolean, - droppableId: DroppableId, - type: TypeId, + type?: TypeId, + isScrollable?: boolean, + isDropDisabled?: boolean, + isCombineEnabled?: boolean, + droppableId?: DroppableId, |}; -export class ScrollableItem extends React.Component { - static defaultProps = { - isScrollable: true, - type: descriptor.type, +export function ScrollableItem(props: ScrollableItemProps) { + const droppableRef = createRef(); + const placeholderRef = createRef(); + // originally tests where made with this as the default + const isScrollable: boolean = props.isScrollable !== false; + + useDroppableDimensionPublisher({ + droppableId: props.droppableId || descriptor.id, + type: props.type || descriptor.type, + direction: preset.home.axis.direction, + isDropDisabled: props.isDropDisabled || false, + ignoreContainerClipping: false, + getDroppableRef: droppableRef.getRef, + getPlaceholderRef: placeholderRef.getRef, + isCombineEnabled: props.isCombineEnabled || false, + }); + + return ( +
+ hi +
+
+ ); +} + +type AppProps = {| + droppableIsScrollable?: boolean, + parentIsScrollable?: boolean, + ignoreContainerClipping?: boolean, + showPlaceholder?: boolean, +|}; + +export function App(props: AppProps) { + const droppableRef = createRef(); + const placeholderRef = createRef(); + + const { + droppableIsScrollable = false, + parentIsScrollable = false, + ignoreContainerClipping = false, + showPlaceholder = false, + } = props; + + useDroppableDimensionPublisher({ droppableId: descriptor.id, + direction: 'vertical', isDropDisabled: false, isCombineEnabled: false, - }; - /* eslint-disable react/sort-comp */ - ref: ?HTMLElement; - placeholderRef: ?HTMLElement; - - setRef = (ref: ?HTMLElement) => { - this.ref = ref; - }; - - setPlaceholderRef = (ref: ?HTMLElement) => { - this.placeholderRef = ref; - }; - - getRef = (): ?HTMLElement => this.ref; - getPlaceholderRef = (): ?HTMLElement => this.placeholderRef; - - render() { - return ( - + type: descriptor.type, + ignoreContainerClipping, + getDroppableRef: droppableRef.getRef, + getPlaceholderRef: placeholderRef.getRef, + }); + + return ( +
+
- hi -
-
- - ); - } -} - -type AppProps = {| - droppableIsScrollable: boolean, - parentIsScrollable: boolean, - ignoreContainerClipping: boolean, - showPlaceholder: boolean, -|}; - -export class App extends Component { - ref: ?HTMLElement; - placeholderRef: ?HTMLElement; - - static defaultProps = { - ignoreContainerClipping: false, - droppableIsScrollable: false, - parentIsScrollable: false, - showPlaceholder: false, - }; - - setRef = (ref: ?HTMLElement) => { - this.ref = ref; - }; - - setPlaceholderRef = (ref: ?HTMLElement) => { - this.placeholderRef = ref; - }; - - getRef = (): ?HTMLElement => this.ref; - getPlaceholderRef = (): ?HTMLElement => this.placeholderRef; - - render() { - const { - droppableIsScrollable, - parentIsScrollable, - ignoreContainerClipping, - } = this.props; - return ( -
-
-
- -
hello world
- {this.props.showPlaceholder ? ( -
- ) : null} - -
+
hello world
+ {showPlaceholder ? ( +
+ ) : null}
- ); - } +
+ ); } diff --git a/test/unit/view/droppable/home-list-placeholder-cleanup.spec.js b/test/unit/view/droppable/home-list-placeholder-cleanup.spec.js index e8bc3d2017..513443b59b 100644 --- a/test/unit/view/droppable/home-list-placeholder-cleanup.spec.js +++ b/test/unit/view/droppable/home-list-placeholder-cleanup.spec.js @@ -1,4 +1,5 @@ // @flow +import { act } from 'react-dom/test-utils'; import type { ReactWrapper } from 'enzyme'; import mount from './util/mount'; import { @@ -7,8 +8,6 @@ import { homeAtRest, homePostDropAnimation, } from './util/get-props'; -import Placeholder from '../../../../src/view/placeholder'; -import AnimateInOut from '../../../../src/view/animate-in-out/animate-in-out'; it('should not display a placeholder after a flushed drag end in the home list', () => { // dropping @@ -17,14 +16,14 @@ it('should not display a placeholder after a flushed drag end in the home list', mapProps: isNotOverHome, }); - expect(wrapper.find(Placeholder)).toHaveLength(1); + expect(wrapper.find('Placeholder')).toHaveLength(1); wrapper.setProps({ ...homeAtRest, }); wrapper.update(); - expect(wrapper.find(Placeholder)).toHaveLength(0); + expect(wrapper.find('Placeholder')).toHaveLength(0); }); it('should animate a placeholder closed in a home list after a drag', () => { @@ -34,26 +33,27 @@ it('should animate a placeholder closed in a home list after a drag', () => { mapProps: isNotOverHome, }); - expect(wrapper.find(Placeholder)).toHaveLength(1); + expect(wrapper.find('Placeholder')).toHaveLength(1); wrapper.setProps({ ...homePostDropAnimation, }); wrapper.update(); - expect(wrapper.find(Placeholder)).toHaveLength(1); - expect(wrapper.find(AnimateInOut).props().shouldAnimate).toBe(true); + expect(wrapper.find('Placeholder')).toHaveLength(1); expect(homePostDropAnimation.shouldAnimatePlaceholder).toBe(true); // finishing the animation - wrapper - .find(Placeholder) - .props() - .onClose(); + act(() => { + wrapper + .find('Placeholder') + .props() + .onClose(); + }); // let the wrapper know the react tree has changed wrapper.update(); // placeholder is now gone - expect(wrapper.find(Placeholder)).toHaveLength(0); + expect(wrapper.find('Placeholder')).toHaveLength(0); }); diff --git a/test/unit/view/droppable/placeholder.spec.js b/test/unit/view/droppable/placeholder.spec.js index 5fb6a2ba14..b077ddfbe1 100644 --- a/test/unit/view/droppable/placeholder.spec.js +++ b/test/unit/view/droppable/placeholder.spec.js @@ -10,7 +10,6 @@ import { homeAtRest, isNotOverForeign, } from './util/get-props'; -import Placeholder from '../../../../src/view/placeholder'; describe('home list', () => { it('should not render a placeholder when not dragging', () => { @@ -19,7 +18,7 @@ describe('home list', () => { mapProps: homeAtRest, }); - expect(wrapper.find(Placeholder)).toHaveLength(0); + expect(wrapper.find('Placeholder')).toHaveLength(0); }); it('should render a placeholder when dragging over', () => { @@ -28,7 +27,7 @@ describe('home list', () => { mapProps: isOverHome, }); - expect(wrapper.find(Placeholder)).toHaveLength(1); + expect(wrapper.find('Placeholder')).toHaveLength(1); }); it('should render a placeholder when dragging over nothing', () => { @@ -37,7 +36,7 @@ describe('home list', () => { mapProps: isNotOverHome, }); - expect(wrapper.find(Placeholder)).toHaveLength(1); + expect(wrapper.find('Placeholder')).toHaveLength(1); }); it('should render a placeholder when dragging over a foreign list', () => { @@ -46,7 +45,7 @@ describe('home list', () => { mapProps: isOverForeign, }); - expect(wrapper.find(Placeholder)).toHaveLength(1); + expect(wrapper.find('Placeholder')).toHaveLength(1); }); }); @@ -57,7 +56,7 @@ describe('foreign', () => { mapProps: homeAtRest, }); - expect(wrapper.find(Placeholder)).toHaveLength(0); + expect(wrapper.find('Placeholder')).toHaveLength(0); }); it('should render a placeholder when dragging over', () => { @@ -66,7 +65,7 @@ describe('foreign', () => { mapProps: isOverForeign, }); - expect(wrapper.find(Placeholder)).toHaveLength(1); + expect(wrapper.find('Placeholder')).toHaveLength(1); }); it('should not render a placeholder when over nothing', () => { @@ -75,6 +74,6 @@ describe('foreign', () => { mapProps: isNotOverForeign, }); - expect(wrapper.find(Placeholder)).toHaveLength(0); + expect(wrapper.find('Placeholder')).toHaveLength(0); }); }); diff --git a/test/unit/view/droppable/update-max-window-scroll.spec.js b/test/unit/view/droppable/update-max-window-scroll.spec.js index c3a9eefff6..14a7ac08b9 100644 --- a/test/unit/view/droppable/update-max-window-scroll.spec.js +++ b/test/unit/view/droppable/update-max-window-scroll.spec.js @@ -3,7 +3,6 @@ import type { ReactWrapper } from 'enzyme'; import mount from './util/mount'; import { homeOwnProps, isOverHome, isNotOverHome } from './util/get-props'; import type { DispatchProps } from '../../../../src/view/droppable/droppable-types'; -import Placeholder from '../../../../src/view/placeholder'; import getMaxWindowScroll from '../../../../src/view/window/get-max-window-scroll'; it('should update when a placeholder animation finishes', () => { @@ -18,7 +17,7 @@ it('should update when a placeholder animation finishes', () => { }); wrapper - .find(Placeholder) + .find('Placeholder') .props() .onTransitionEnd(); @@ -39,7 +38,7 @@ it('should update when a placeholder finishes and the list is not dragged over', }); wrapper - .find(Placeholder) + .find('Placeholder') .props() .onTransitionEnd(); @@ -61,7 +60,7 @@ it('should not update when dropping', () => { }); wrapper - .find(Placeholder) + .find('Placeholder') .props() .onTransitionEnd(); diff --git a/test/unit/view/droppable/util/mount.js b/test/unit/view/droppable/util/mount.js index a7636ca912..cce8364170 100644 --- a/test/unit/view/droppable/util/mount.js +++ b/test/unit/view/droppable/util/mount.js @@ -1,5 +1,5 @@ // @flow -import React from 'react'; +import React, { useMemo } from 'react'; import { mount, type ReactWrapper } from 'enzyme'; import type { MapProps, @@ -14,14 +14,11 @@ import { homeAtRest, dispatchProps as defaultDispatchProps, } from './get-props'; -import { - withStore, - combine, - withDimensionMarshal, - withStyleContext, - withIsMovementAllowed, -} from '../../../../utils/get-context-options'; import getStubber from './get-stubber'; +import { getMarshalStub } from '../../../../utils/dimension-marshal'; +import AppContext, { + type AppContextValue, +} from '../../../../../src/view/context/app-context'; type MountArgs = {| WrappedComponent?: any, @@ -31,23 +28,50 @@ type MountArgs = {| isMovementAllowed?: () => boolean, |}; +type AppProps = {| + ...OwnProps, + ...MapProps, + ...DispatchProps, + isMovementAllowed: () => boolean, + WrappedComponent: any, +|}; + +function App(props: AppProps) { + const { WrappedComponent, isMovementAllowed, ...rest } = props; + const context: AppContextValue = useMemo( + () => ({ + marshal: getMarshalStub(), + style: '1', + canLift: () => true, + isMovementAllowed, + }), + [isMovementAllowed], + ); + + return ( + + + {(provided: Provided, snapshot: StateSnapshot) => ( + + )} + + + ); +} + export default ({ WrappedComponent = getStubber(), ownProps = homeOwnProps, mapProps = homeAtRest, dispatchProps = defaultDispatchProps, - isMovementAllowed, + isMovementAllowed = () => true, }: MountArgs = {}): ReactWrapper<*> => mount( - - {(provided: Provided, snapshot: StateSnapshot) => ( - - )} - , - combine( - withStore(), - withDimensionMarshal(), - withStyleContext(), - withIsMovementAllowed(isMovementAllowed), - ), + , ); diff --git a/test/unit/view/placeholder/animated-mount.spec.js b/test/unit/view/placeholder/animated-mount.spec.js index 0656ec9c2d..ed766388ad 100644 --- a/test/unit/view/placeholder/animated-mount.spec.js +++ b/test/unit/view/placeholder/animated-mount.spec.js @@ -1,13 +1,32 @@ // @flow import React from 'react'; import { mount, type ReactWrapper } from 'enzyme'; +import { act } from 'react-dom/test-utils'; +import Placeholder from './util/placeholder-with-class'; import type { PlaceholderStyle } from '../../../../src/view/placeholder/placeholder-types'; -import Placeholder from '../../../../src/view/placeholder'; import { expectIsEmpty, expectIsFull } from './util/expect'; import { placeholder } from './util/data'; import getPlaceholderStyle from './util/get-placeholder-style'; +import * as attributes from '../../../../src/view/data-attributes'; jest.useFakeTimers(); +const styleContext: string = 'hello-there'; + +let spy; + +beforeEach(() => { + spy = jest.spyOn(React, 'createElement'); +}); + +afterEach(() => { + spy.mockRestore(); +}); + +const getCreatePlaceholderCalls = () => { + return spy.mock.calls.filter(call => { + return call[1] && call[1][attributes.placeholder] === styleContext; + }); +}; it('should animate a mount', () => { const wrapper: ReactWrapper<*> = mount( @@ -16,13 +35,22 @@ it('should animate a mount', () => { placeholder={placeholder} onClose={jest.fn()} onTransitionEnd={jest.fn()} + styleContext={styleContext} />, ); + + expect(getCreatePlaceholderCalls().length).toBe(1); + + // first call had an empty size const onMount: PlaceholderStyle = getPlaceholderStyle(wrapper); expectIsEmpty(onMount); - jest.runOnlyPendingTimers(); - // let enzyme know that the react tree has changed due to the set state + // Will trigger a .setState + act(() => { + jest.runOnlyPendingTimers(); + }); + + // tell enzyme that something has changed wrapper.update(); const postMount: PlaceholderStyle = getPlaceholderStyle(wrapper); @@ -30,25 +58,28 @@ it('should animate a mount', () => { }); it('should not animate a mount if interrupted', () => { - jest.spyOn(Placeholder.prototype, 'render'); - const wrapper: ReactWrapper<*> = mount( , ); const onMount: PlaceholderStyle = getPlaceholderStyle(wrapper); expectIsEmpty(onMount); - expect(Placeholder.prototype.render).toHaveBeenCalledTimes(1); + + expect(getCreatePlaceholderCalls()).toHaveLength(1); // interrupting animation wrapper.setProps({ animate: 'none', }); - expect(Placeholder.prototype.render).toHaveBeenCalledTimes(2); + // render 1: normal + // render 2: useEffect calling setState + // render 3: result of setState + expect(getCreatePlaceholderCalls()).toHaveLength(3); // no timers are run // let enzyme know that the react tree has changed due to the set state @@ -58,13 +89,11 @@ it('should not animate a mount if interrupted', () => { expectIsFull(postMount); // validation - no further updates - Placeholder.prototype.render.mockClear(); + spy.mockClear(); jest.runOnlyPendingTimers(); wrapper.update(); expectIsFull(getPlaceholderStyle(wrapper)); - expect(Placeholder.prototype.render).not.toHaveBeenCalled(); - - Placeholder.prototype.render.mockRestore(); + expect(getCreatePlaceholderCalls()).toHaveLength(0); }); it('should not animate in if unmounted', () => { @@ -76,6 +105,7 @@ it('should not animate in if unmounted', () => { placeholder={placeholder} onClose={jest.fn()} onTransitionEnd={jest.fn()} + styleContext={styleContext} />, ); expectIsEmpty(getPlaceholderStyle(wrapper)); diff --git a/test/unit/view/placeholder/on-close.spec.js b/test/unit/view/placeholder/on-close.spec.js index 134faf87a5..d7ba06e7e6 100644 --- a/test/unit/view/placeholder/on-close.spec.js +++ b/test/unit/view/placeholder/on-close.spec.js @@ -1,12 +1,12 @@ // @flow import React from 'react'; import { mount, type ReactWrapper } from 'enzyme'; -import Placeholder from '../../../../src/view/placeholder'; +import Placeholder from './util/placeholder-with-class'; import { expectIsFull } from './util/expect'; import getPlaceholderStyle from './util/get-placeholder-style'; import { placeholder } from './util/data'; -jest.useFakeTimers(); +const styleContext: string = 'yolo'; it('should only fire a single onClose event', () => { const onClose = jest.fn(); @@ -17,6 +17,7 @@ it('should only fire a single onClose event', () => { placeholder={placeholder} onClose={onClose} onTransitionEnd={jest.fn()} + styleContext={styleContext} />, ); expectIsFull(getPlaceholderStyle(wrapper)); @@ -57,6 +58,7 @@ it('should not fire an onClose if not closing when a transitionend occurs', () = placeholder={placeholder} onClose={onClose} onTransitionEnd={jest.fn()} + styleContext={styleContext} />, ); const assert = () => { diff --git a/test/unit/view/placeholder/on-transition-end.spec.js b/test/unit/view/placeholder/on-transition-end.spec.js index 3a56da8add..3135ec114f 100644 --- a/test/unit/view/placeholder/on-transition-end.spec.js +++ b/test/unit/view/placeholder/on-transition-end.spec.js @@ -1,7 +1,8 @@ // @flow import React from 'react'; import { mount, type ReactWrapper } from 'enzyme'; -import Placeholder from '../../../../src/view/placeholder'; +import { act } from 'react-dom/test-utils'; +import Placeholder from './util/placeholder-with-class'; import { expectIsFull } from './util/expect'; import getPlaceholderStyle from './util/get-placeholder-style'; import { placeholder } from './util/data'; @@ -18,9 +19,13 @@ it('should only fire a single transitionend event a single time when transitioni placeholder={placeholder} onClose={onClose} onTransitionEnd={onTransitionEnd} + styleContext="hey" />, ); - jest.runOnlyPendingTimers(); + // finish the animate open timer + act(() => { + jest.runOnlyPendingTimers(); + }); // let enzyme know that the react tree has changed due to the set state wrapper.update(); expectIsFull(getPlaceholderStyle(wrapper)); diff --git a/test/unit/view/placeholder/util/placeholder-with-class.js b/test/unit/view/placeholder/util/placeholder-with-class.js new file mode 100644 index 0000000000..2c75c22d49 --- /dev/null +++ b/test/unit/view/placeholder/util/placeholder-with-class.js @@ -0,0 +1,12 @@ +// @flow +import React from 'react'; +import { WithoutMemo } from '../../../../../src/view/placeholder/placeholder'; +import type { Props } from '../../../../../src/view/placeholder/placeholder'; + +// enzyme does not work well with memo, so exporting the non-memo version +// Using PureComponent to match behaviour of React.memo +export default class PlaceholderWithClass extends React.PureComponent { + render() { + return ; + } +} diff --git a/test/unit/view/style-marshal/get-styles.spec.js b/test/unit/view/style-marshal/get-styles.spec.js index 40c4260d2d..4b7e965001 100644 --- a/test/unit/view/style-marshal/get-styles.spec.js +++ b/test/unit/view/style-marshal/get-styles.spec.js @@ -2,7 +2,7 @@ import stylelint from 'stylelint'; import getStyles, { type Styles, -} from '../../../../src/view/style-marshal/get-styles'; +} from '../../../../src/view/use-style-marshal/get-styles'; const styles: Styles = getStyles('hey'); diff --git a/test/unit/view/style-marshal/style-marshal.spec.js b/test/unit/view/style-marshal/style-marshal.spec.js index ce32a26728..464423b408 100644 --- a/test/unit/view/style-marshal/style-marshal.spec.js +++ b/test/unit/view/style-marshal/style-marshal.spec.js @@ -1,181 +1,177 @@ // @flow -import createStyleMarshal, { - resetStyleContext, -} from '../../../../src/view/style-marshal/style-marshal'; +import React, { type Node } from 'react'; +import { mount, type ReactWrapper } from 'enzyme'; +import useStyleMarshal from '../../../../src/view/use-style-marshal'; import getStyles, { type Styles, -} from '../../../../src/view/style-marshal/get-styles'; +} from '../../../../src/view/use-style-marshal/get-styles'; +import type { StyleMarshal } from '../../../../src/view/use-style-marshal/style-marshal-types'; import { prefix } from '../../../../src/view/data-attributes'; -import type { StyleMarshal } from '../../../../src/view/style-marshal/style-marshal-types'; -const getDynamicStyleTagSelector = (context: string) => - `style[${prefix}-dynamic="${context}"]`; +const getMarshal = (myMock): StyleMarshal => myMock.mock.calls[0][0]; +const getMock = () => jest.fn().mockImplementation(() => null); -const getAlwaysStyleTagSelector = (context: string) => - `style[${prefix}-always="${context}"]`; +type Props = {| + uniqueId: number, + children: (marshal: StyleMarshal) => Node, +|}; -const getStyleFromTag = (context: string): string => { - const selector: string = getDynamicStyleTagSelector(context); +function WithMarshal(props: Props) { + const marshal: StyleMarshal = useStyleMarshal(props.uniqueId); + return props.children(marshal); +} + +const getDynamicStyleTagSelector = (uniqueId: number) => + `style[${prefix}-dynamic="${uniqueId}"]`; + +const getAlwaysStyleTagSelector = (uniqueId: number) => + `style[${prefix}-always="${uniqueId}"]`; + +const getDynamicStyleFromTag = (uniqueId: number): string => { + const selector: string = getDynamicStyleTagSelector(uniqueId); const el: HTMLStyleElement = (document.querySelector(selector): any); return el.innerHTML; }; -let marshal: StyleMarshal; -let styles: Styles; -beforeEach(() => { - resetStyleContext(); - marshal = createStyleMarshal(); - styles = getStyles(marshal.styleContext); -}); - -afterEach(() => { - try { - marshal.unmount(); - } catch (e) { - // already unmounted - } -}); +const getAlwaysStyleFromTag = (uniqueId: number): string => { + const selector: string = getAlwaysStyleTagSelector(uniqueId); + const el: HTMLStyleElement = (document.querySelector(selector): any); + return el.innerHTML; +}; it('should not mount style tags until mounted', () => { - const dynamicSelector: string = getDynamicStyleTagSelector( - marshal.styleContext, - ); - const alwaysSelector: string = getAlwaysStyleTagSelector( - marshal.styleContext, - ); + const uniqueId: number = 1; + const dynamicSelector: string = getDynamicStyleTagSelector(uniqueId); + const alwaysSelector: string = getAlwaysStyleTagSelector(uniqueId); // initially there is no style tag expect(document.querySelector(dynamicSelector)).toBeFalsy(); expect(document.querySelector(alwaysSelector)).toBeFalsy(); // now mounting - marshal.mount(); + const wrapper: ReactWrapper<*> = mount( + {getMock()}, + ); + + // elements should now exist expect(document.querySelector(alwaysSelector)).toBeInstanceOf( HTMLStyleElement, ); expect(document.querySelector(dynamicSelector)).toBeInstanceOf( HTMLStyleElement, ); -}); - -it('should throw if mounting after already mounting', () => { - marshal.mount(); - expect(() => marshal.mount()).toThrow(); + wrapper.unmount(); }); -it('should apply the resting styles by default', () => { - marshal.mount(); - const active: string = getStyleFromTag(marshal.styleContext); - - expect(active).toEqual(styles.resting); -}); +it('should apply the resting dyanmic styles by default', () => { + const uniqueId: number = 2; + const wrapper: ReactWrapper<*> = mount( + {getMock()}, + ); -it('should apply the always styles when mounted', () => { - marshal.mount(); + const active: string = getDynamicStyleFromTag(uniqueId); + expect(active).toEqual(getStyles(`${uniqueId}`).resting); - const selector: string = getAlwaysStyleTagSelector(marshal.styleContext); - const el: HTMLStyleElement = (document.querySelector(selector): any); - - expect(el.innerHTML).toEqual(styles.always); + wrapper.unmount(); }); -it('should apply the resting styles when asked', () => { - marshal.mount(); +it('should apply the resting always styles by default', () => { + const uniqueId: number = 2; + const wrapper: ReactWrapper<*> = mount( + {getMock()}, + ); - marshal.resting(); - const active: string = getStyleFromTag(marshal.styleContext); + const always: string = getAlwaysStyleFromTag(uniqueId); + expect(always).toEqual(getStyles(`${uniqueId}`).always); - expect(active).toEqual(styles.resting); + wrapper.unmount(); }); it('should apply the dragging styles when asked', () => { - marshal.mount(); + const uniqueId: number = 2; + const mock = getMock(); + const wrapper: ReactWrapper<*> = mount( + {mock}, + ); + const marshal: StyleMarshal = getMarshal(mock); marshal.dragging(); - const active: string = getStyleFromTag(marshal.styleContext); - expect(active).toEqual(styles.dragging); + const active: string = getDynamicStyleFromTag(uniqueId); + expect(active).toEqual(getStyles(`${uniqueId}`).dragging); + + wrapper.unmount(); }); it('should apply the drop animating styles when asked', () => { - marshal.mount(); + const uniqueId: number = 2; + const mock = getMock(); + const wrapper: ReactWrapper<*> = mount( + {mock}, + ); + const marshal: StyleMarshal = getMarshal(mock); marshal.dropping('DROP'); - const active: string = getStyleFromTag(marshal.styleContext); + const active: string = getDynamicStyleFromTag(uniqueId); + expect(active).toEqual(getStyles(`${uniqueId}`).dropAnimating); - expect(active).toEqual(styles.dropAnimating); + wrapper.unmount(); }); it('should apply the user cancel styles when asked', () => { - marshal.mount(); + const uniqueId: number = 2; + const mock = getMock(); + const wrapper: ReactWrapper<*> = mount( + {mock}, + ); + const marshal: StyleMarshal = getMarshal(mock); marshal.dropping('CANCEL'); - const active: string = getStyleFromTag(marshal.styleContext); + const active: string = getDynamicStyleFromTag(uniqueId); + expect(active).toEqual(getStyles(`${uniqueId}`).userCancel); - expect(active).toEqual(styles.userCancel); + wrapper.unmount(); }); it('should remove the style tag from the head when unmounting', () => { - marshal.mount(); - const selector1: string = getDynamicStyleTagSelector(marshal.styleContext); - const selector2: string = getAlwaysStyleTagSelector(marshal.styleContext); + const uniqueId: number = 2; + const wrapper: ReactWrapper<*> = mount( + {getMock()}, + ); + const selector1: string = getDynamicStyleTagSelector(uniqueId); + const selector2: string = getAlwaysStyleTagSelector(uniqueId); // the style tag exists expect(document.querySelector(selector1)).toBeTruthy(); expect(document.querySelector(selector2)).toBeTruthy(); // now unmounted - marshal.unmount(); + wrapper.unmount(); expect(document.querySelector(selector1)).not.toBeTruthy(); expect(document.querySelector(selector2)).not.toBeTruthy(); }); -it('should log an error if attempting to apply styles after unmounted', () => { - marshal.mount(); - const selector: string = getDynamicStyleTagSelector(marshal.styleContext); - // grabbing the element before unmount - const el: HTMLElement = (document.querySelector(selector): any); - - // asserting it has the base styles - expect(el.innerHTML).toEqual(styles.resting); - - marshal.unmount(); - - expect(() => marshal.dragging()).toThrow(); -}); - it('should allow subsequent updates', () => { - marshal.mount(); + const uniqueId: number = 10; + const styles: Styles = getStyles(`${uniqueId}`); + const mock = getMock(); + const wrapper: ReactWrapper<*> = mount( + {mock}, + ); + const marshal: StyleMarshal = getMarshal(mock); Array.from({ length: 4 }).forEach(() => { marshal.resting(); - expect(getStyleFromTag(marshal.styleContext)).toEqual(styles.resting); + expect(getDynamicStyleFromTag(uniqueId)).toEqual(styles.resting); marshal.dragging(); - expect(getStyleFromTag(marshal.styleContext)).toEqual(styles.dragging); + expect(getDynamicStyleFromTag(uniqueId)).toEqual(styles.dragging); marshal.dropping('DROP'); - expect(getStyleFromTag(marshal.styleContext)).toEqual(styles.dropAnimating); + expect(getDynamicStyleFromTag(uniqueId)).toEqual(styles.dropAnimating); }); -}); -describe('resetStyleContext', () => { - it('should reset the style context counter for subsequent marshals', () => { - // initial marshal - marshal.mount(); - // initial style context - expect(marshal.styleContext).toBe('0'); - - // creating second marshal - const marshalBeforeReset = createStyleMarshal(); - expect(marshalBeforeReset.styleContext).toBe('1'); - - resetStyleContext(); - - // creating third marshal after reset - const marshalAfterReset = createStyleMarshal(); - expect(marshalAfterReset.styleContext).toBe('0'); - }); + wrapper.unmount(); }); diff --git a/test/unit/view/draggable-dimension-publisher.spec.js b/test/unit/view/use-draggable-dimension-publisher.spec.js similarity index 72% rename from test/unit/view/draggable-dimension-publisher.spec.js rename to test/unit/view/use-draggable-dimension-publisher.spec.js index 2730fcef96..7fcfcc62bc 100644 --- a/test/unit/view/draggable-dimension-publisher.spec.js +++ b/test/unit/view/use-draggable-dimension-publisher.spec.js @@ -1,9 +1,9 @@ // @flow -import React, { Component } from 'react'; +import React, { useRef, useCallback } from 'react'; import invariant from 'tiny-invariant'; import { type Spacing, type Rect } from 'css-box-model'; import { mount, type ReactWrapper } from 'enzyme'; -import DraggableDimensionPublisher from '../../../src/view/draggable-dimension-publisher/draggable-dimension-publisher'; +import useDraggableDimensionPublisher from '../../../src/view/use-draggable-dimension-publisher'; import { getPreset, getDraggableDimension, @@ -13,7 +13,6 @@ import type { DimensionMarshal, GetDraggableDimensionFn, } from '../../../src/state/dimension-marshal/dimension-marshal-types'; -import { withDimensionMarshal } from '../../utils/get-context-options'; import forceUpdate from '../../utils/force-update'; import tryCleanPrototypeStubs from '../../utils/try-clean-prototype-stubs'; import { getMarshalStub } from '../../utils/dimension-marshal'; @@ -22,39 +21,73 @@ import type { DraggableDimension, DraggableDescriptor, } from '../../../src/types'; +import AppContext, { + type AppContextValue, +} from '../../../src/view/context/app-context'; +import DroppableContext, { + type DroppableContextValue, +} from '../../../src/view/context/droppable-context'; const preset = getPreset(); const noComputedSpacing = getComputedSpacing({}); -type Props = {| +type ItemProps = {| + index: number, + draggableId: DraggableId, +|}; + +type AppProps = {| + marshal: DimensionMarshal, index?: number, draggableId?: DraggableId, + Component?: any, |}; -class Item extends Component { - /* eslint-disable react/sort-comp */ +function Item(props: ItemProps) { + const ref = useRef(null); + const setRef = useCallback((value: ?HTMLElement) => { + ref.current = value; + }, []); + const getRef = useCallback((): ?HTMLElement => ref.current, []); + + useDraggableDimensionPublisher({ + draggableId: props.draggableId, + index: props.index, + getDraggableRef: getRef, + }); + + return
hi
; +} - ref: ?HTMLElement; +function App({ + marshal, + draggableId = preset.inHome1.descriptor.id, + index = preset.inHome1.descriptor.index, + Component = Item, +}: AppProps) { + const appContext: AppContextValue = { + marshal, + style: '1', + canLift: () => true, + isMovementAllowed: () => true, + }; + const droppableContext: DroppableContextValue = { + type: preset.inHome1.descriptor.type, + droppableId: preset.inHome1.descriptor.droppableId, + }; - setRef = (ref: ?HTMLElement) => { - this.ref = ref; + const itemProps: ItemProps = { + draggableId, + index, }; - getRef = (): ?HTMLElement => this.ref; - - render() { - return ( - -
hi
-
- ); - } + return ( + + + + + + ); } beforeEach(() => { @@ -72,7 +105,7 @@ describe('dimension registration', () => { it('should register itself when mounting', () => { const marshal: DimensionMarshal = getMarshalStub(); - mount(, withDimensionMarshal(marshal)); + mount(); expect(marshal.registerDraggable).toHaveBeenCalledTimes(1); expect(marshal.registerDraggable.mock.calls[0][0]).toEqual( @@ -83,7 +116,7 @@ describe('dimension registration', () => { it('should unregister itself when unmounting', () => { const marshal: DimensionMarshal = getMarshalStub(); - const wrapper = mount(, withDimensionMarshal(marshal)); + const wrapper = mount(); expect(marshal.registerDraggable).toHaveBeenCalled(); expect(marshal.unregisterDraggable).not.toHaveBeenCalled(); @@ -97,11 +130,13 @@ describe('dimension registration', () => { it('should update its registration when a descriptor property changes', () => { const marshal: DimensionMarshal = getMarshalStub(); - const wrapper = mount(, withDimensionMarshal(marshal)); + const wrapper = mount(); // asserting shape of original publish expect(marshal.registerDraggable.mock.calls[0][0]).toEqual( preset.inHome1.descriptor, ); + marshal.registerDraggable.mockClear(); + marshal.registerDroppable.mockClear(); // updating the index wrapper.setProps({ @@ -111,17 +146,22 @@ describe('dimension registration', () => { ...preset.inHome1.descriptor, index: 1000, }; + + // Descriptor updated expect(marshal.updateDraggable).toHaveBeenCalledWith( preset.inHome1.descriptor, newDescriptor, expect.any(Function), ); + // Nothing else changed + expect(marshal.registerDraggable).not.toHaveBeenCalled(); + expect(marshal.unregisterDraggable).not.toHaveBeenCalled(); }); it('should not update its registration when a descriptor property does not change on an update', () => { const marshal: DimensionMarshal = getMarshalStub(); - const wrapper = mount(, withDimensionMarshal(marshal)); + const wrapper = mount(); expect(marshal.registerDraggable).toHaveBeenCalledTimes(1); forceUpdate(wrapper); @@ -133,7 +173,7 @@ describe('dimension publishing', () => { // we are doing this rather than spying on the prototype. // Sometimes setRef was being provided with an element that did not have the mocked prototype :| const setBoundingClientRect = (wrapper: ReactWrapper<*>, borderBox: Rect) => { - const ref: ?HTMLElement = wrapper.instance().getRef(); + const ref: ?HTMLElement = wrapper.getDOMNode(); invariant(ref); // $FlowFixMe - normally a read only thing. Muhaha @@ -162,11 +202,11 @@ describe('dimension publishing', () => { const marshal: DimensionMarshal = getMarshalStub(); const wrapper: ReactWrapper<*> = mount( - , - withDimensionMarshal(marshal), ); setBoundingClientRect(wrapper, expected.client.borderBox); @@ -208,11 +248,11 @@ describe('dimension publishing', () => { const marshal: DimensionMarshal = getMarshalStub(); const wrapper: ReactWrapper<*> = mount( - , - withDimensionMarshal(marshal), ); setBoundingClientRect(wrapper, expected.client.borderBox); @@ -248,11 +288,11 @@ describe('dimension publishing', () => { .mockImplementation(() => noComputedSpacing); const wrapper: ReactWrapper<*> = mount( - , - withDimensionMarshal(marshal), ); setBoundingClientRect(wrapper, expected.client.borderBox); @@ -267,35 +307,27 @@ describe('dimension publishing', () => { }); it('should throw an error if no ref is provided when attempting to get a dimension', () => { - class NoRefItem extends Component<*> { - render() { - return ( - undefined} - > -
hi
-
- ); - } + function NoRefItem(props: ItemProps) { + const ref = useRef(null); + const getRef = useCallback((): ?HTMLElement => ref.current, []); + + useDraggableDimensionPublisher({ + draggableId: props.draggableId, + index: props.index, + getDraggableRef: getRef, + }); + + return
hi
; } const marshal: DimensionMarshal = getMarshalStub(); - const wrapper: ReactWrapper<*> = mount( - , - withDimensionMarshal(marshal), + , ); - // pull the get dimension function out const getDimension: GetDraggableDimensionFn = marshal.registerDraggable.mock.calls[0][1]; - // when we call the get dimension function without a ref things will explode expect(getDimension).toThrow(); - wrapper.unmount(); }); }); diff --git a/test/utils/create-ref.js b/test/utils/create-ref.js new file mode 100644 index 0000000000..95e0c6ff0c --- /dev/null +++ b/test/utils/create-ref.js @@ -0,0 +1,12 @@ +// @flow +export default function createRef() { + let ref: ?HTMLElement = null; + + const setRef = (supplied: ?HTMLElement) => { + ref = supplied; + }; + + const getRef = (): ?HTMLElement => ref; + + return { ref, setRef, getRef }; +} diff --git a/test/utils/dimension-marshal.js b/test/utils/dimension-marshal.js index afb4a2334e..8f50f0bef2 100644 --- a/test/utils/dimension-marshal.js +++ b/test/utils/dimension-marshal.js @@ -48,7 +48,6 @@ export const getMarshalStub = (): DimensionMarshal => ({ updateDraggable: jest.fn(), unregisterDraggable: jest.fn(), registerDroppable: jest.fn(), - updateDroppable: jest.fn(), unregisterDroppable: jest.fn(), updateDroppableScroll: jest.fn(), updateDroppableIsEnabled: jest.fn(), diff --git a/test/utils/get-context-options.js b/test/utils/get-context-options.js deleted file mode 100644 index b6b6f4a464..0000000000 --- a/test/utils/get-context-options.js +++ /dev/null @@ -1,130 +0,0 @@ -// @flow -import PropTypes from 'prop-types'; -import { - storeKey, - droppableIdKey, - dimensionMarshalKey, - styleKey, - canLiftKey, - droppableTypeKey, - isMovementAllowedKey, -} from '../../src/view/context-keys'; -import createStore from '../../src/state/create-store'; -import { getMarshalStub } from './dimension-marshal'; -import type { DroppableId, TypeId } from '../../src/types'; -import type { DimensionMarshal } from '../../src/state/dimension-marshal/dimension-marshal-types'; -import type { StyleMarshal } from '../../src/view/style-marshal/style-marshal-types'; -import type { AutoScroller } from '../../src/state/auto-scroller/auto-scroller-types'; - -// Not using this store - just putting it on the context -// For any connected components that need it (eg DimensionPublisher) -export const withStore = () => ({ - context: { - // Each consumer will get their own store - [storeKey]: createStore({ - getDimensionMarshal: () => getMarshalStub(), - styleMarshal: { - dragging: jest.fn(), - dropping: jest.fn(), - resting: jest.fn(), - styleContext: 'fake-style-context', - unmount: jest.fn(), - mount: jest.fn(), - }, - getResponders: () => ({ - onDragEnd: () => {}, - }), - announce: () => {}, - getScroller: (): AutoScroller => ({ - start: jest.fn(), - stop: jest.fn(), - cancelPending: jest.fn(), - scroll: jest.fn(), - }), - }), - }, - childContextTypes: { - [storeKey]: PropTypes.shape({ - dispatch: PropTypes.func.isRequired, - subscribe: PropTypes.func.isRequired, - getState: PropTypes.func.isRequired, - }).isRequired, - }, -}); - -export const withDroppableId = (droppableId: DroppableId): Object => ({ - context: { - [droppableIdKey]: droppableId, - }, - childContextTypes: { - [droppableIdKey]: PropTypes.string.isRequired, - }, -}); - -export const withDroppableType = (type: TypeId): Object => ({ - context: { - [droppableTypeKey]: type, - }, - childContextTypes: { - [droppableTypeKey]: PropTypes.string.isRequired, - }, -}); - -export const withStyleContext = (marshal?: StyleMarshal): Object => ({ - context: { - [styleKey]: marshal ? marshal.styleContext : 'fake-style-context', - }, - childContextTypes: { - [styleKey]: PropTypes.string.isRequired, - }, -}); - -export const withCanLift = (): Object => ({ - context: { - [canLiftKey]: () => true, - }, - childContextTypes: { - [canLiftKey]: PropTypes.func.isRequired, - }, -}); - -export const withDimensionMarshal = (marshal?: DimensionMarshal): Object => ({ - context: { - [dimensionMarshalKey]: marshal || getMarshalStub(), - }, - childContextTypes: { - [dimensionMarshalKey]: PropTypes.object.isRequired, - }, -}); - -export const withIsMovementAllowed = ( - getIsMovementAllowed?: () => boolean = () => false, -) => ({ - context: { - [isMovementAllowedKey]: getIsMovementAllowed, - }, - childContextTypes: { - [isMovementAllowedKey]: PropTypes.func.isRequired, - }, -}); - -const base: Object = { - context: {}, - childContextTypes: {}, -}; - -// returning type Object because that is what enzyme wants -export const combine = (...args: Object[]): Object => - args.reduce( - (previous: Object, current: Object): Object => ({ - context: { - ...previous.context, - ...current.context, - }, - childContextTypes: { - ...previous.childContextTypes, - ...current.childContextTypes, - }, - }), - base, - ); diff --git a/test/utils/pass-through-props.jsx b/test/utils/pass-through-props.jsx new file mode 100644 index 0000000000..b05c86b05c --- /dev/null +++ b/test/utils/pass-through-props.jsx @@ -0,0 +1,12 @@ +// @flow +import { type Node } from 'react'; + +type Props = {| + ...T, + children: (value: T) => Node, +|}; + +export default function PassThroughProps(props: Props<*>) { + const { children, ...rest } = props; + return children(rest); +} diff --git a/yarn.lock b/yarn.lock index 8421833d04..91cd8bde43 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1150,7 +1150,7 @@ dependencies: regenerator-runtime "^0.12.0" -"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.2.0", "@babel/runtime@^7.3.4": +"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.2.0", "@babel/runtime@^7.3.1", "@babel/runtime@^7.3.4": version "7.3.4" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.3.4.tgz#73d12ba819e365fcf7fd152aed56d6df97d21c83" integrity sha512-IvfvnMdSaLBateu0jfsYIpZTxAc2cKEXEMiezGGN75QcBcecDUKd3PgLAncT0oOgxKy8dd8hrJKj9MfzgfZd6g== @@ -6177,7 +6177,7 @@ hoist-non-react-statics@^2.3.1, hoist-non-react-statics@^2.5.0: resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz#c5903cf409c0dfd908f388e619d86b9c1174cb47" integrity sha512-rqcy4pJo55FTTLWt+bU8ukscqHeE/e9KWvsOW2b/a3afxQZhwkQdT1rPPCJ0rYXdj4vNcasY8zHTH+jF/qStxw== -hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0: +hoist-non-react-statics@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.0.tgz#b09178f0122184fb95acf525daaecb4d8f45958b" integrity sha512-0XsbTXxgiaCDYDIWFcwkmerZPSwywfUqYmwT4jzewKTQSWoE6FCMoUVOeBJWK3E/CrWbxRG3m5GzY4lnIwGRBA== @@ -9714,12 +9714,12 @@ react-inspector@^2.3.0, react-inspector@^2.3.1: is-dom "^1.0.9" prop-types "^15.6.1" -react-is@^16.3.1, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.3: +react-is@^16.3.1, react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.3: version "16.8.3" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.8.3.tgz#4ad8b029c2a718fc0cfc746c8d4e1b7221e5387d" integrity sha512-Y4rC1ZJmsxxkkPuMLwvKvlL1Zfpbcu+Bf4ZigkHup3v9EfdYhAlWAaVyA19olXq2o2mGn0w+dFKvk3pVVlYcIA== -react-is@^16.8.4: +react-is@^16.8.2, react-is@^16.8.4: version "16.8.4" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.8.4.tgz#90f336a68c3a29a096a3d648ab80e87ec61482a2" integrity sha512-PVadd+WaUDOAciICm/J1waJaSvgq+4rHE/K70j0PFqKhkTBsPv/82UGQJNXAngz1fOQLLxI6z1sEDmJDQhCTAA== @@ -9764,18 +9764,17 @@ react-popper@^1.3.3: typed-styles "^0.0.7" warning "^4.0.2" -react-redux@^5.0.7: - version "5.1.1" - resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-5.1.1.tgz#88e368682c7fa80e34e055cd7ac56f5936b0f52f" - integrity sha512-LE7Ned+cv5qe7tMV5BPYkGQ5Lpg8gzgItK07c67yHvJ8t0iaD9kPFPAli/mYkiyJYrs2pJgExR2ZgsGqlrOApg== +react-redux@7.0.0-beta.0: + version "7.0.0-beta.0" + resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.0.0-beta.0.tgz#46ea289a0b0cf18f864b251a1d2bff4f110b71e0" + integrity sha512-vQSWFBeSxXm0RPuUqZMcYyN9CPapFs3cJltgUHQe943joTgcpC/nZAmiBMWLmte6BE7xYeE66diO2Z/87kWarw== dependencies: - "@babel/runtime" "^7.1.2" - hoist-non-react-statics "^3.1.0" + "@babel/runtime" "^7.3.1" + hoist-non-react-statics "^3.3.0" invariant "^2.2.4" - loose-envify "^1.1.0" - prop-types "^15.6.1" - react-is "^16.6.0" - react-lifecycles-compat "^3.0.0" + loose-envify "^1.4.0" + prop-types "^15.7.2" + react-is "^16.8.2" react-resize-detector@^3.2.1: version "3.4.0" @@ -11859,6 +11858,11 @@ url@0.11.0, url@^0.11.0: punycode "1.3.2" querystring "0.2.0" +use-memo-one@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/use-memo-one/-/use-memo-one-1.0.0.tgz#725a24878dfa3e87d0c99a755c06a2da0f6fa272" + integrity sha512-ITeeQU5bmuFASUiScPnQ55YRXJ3bLOvfLN6Q49ZvtEiTOToQj2hF8BzSPRN6YAoI4l3gbQhLd6iHYQl9jMyqEg== + use@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"