diff --git a/.circleci/config.yml b/.circleci/config.yml index 48a782c848..e9bf0739ac 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -2,7 +2,7 @@ version: 2 jobs: install: docker: - - image: circleci/node:10.15.3-browsers + - image: circleci/node:10.16.3-browsers working_directory: ~/repo steps: # Fetch Code @@ -11,9 +11,9 @@ jobs: - restore_cache: keys: # Restore cached node_modules - - v7-dependencies-{{ checksum "yarn.lock" }} + - v11-dependencies-{{ checksum "yarn.lock" }} # fallback to using the latest cache if no exact match is found - - v7-dependencies- + - v11-dependencies- - run: name: Add CI global modules @@ -43,17 +43,17 @@ jobs: - save_cache: paths: - node_modules - key: v7-dependencies-{{ checksum "yarn.lock" }} + key: v11-dependencies-{{ checksum "yarn.lock" }} validate: docker: - - image: circleci/node:10.15.3-browsers + - image: circleci/node:10.16.3-browsers working_directory: ~/repo steps: - checkout - restore_cache: keys: - - v7-dependencies-{{ checksum "yarn.lock" }} + - v11-dependencies-{{ checksum "yarn.lock" }} - run: # PR's from forks cannot use the dependency cache for performance reasons @@ -65,14 +65,14 @@ jobs: command: yarn validate test-unit: docker: - - image: circleci/node:10.15.3-browsers + - image: circleci/node:10.16.3-browsers working_directory: ~/repo steps: - checkout - restore_cache: keys: - - v7-dependencies-{{ checksum "yarn.lock" }} + - v11-dependencies-{{ checksum "yarn.lock" }} - run: # PR's from forks cannot use the dependency cache for performance reasons @@ -89,14 +89,14 @@ jobs: path: test-reports/junit test-bundle: docker: - - image: circleci/node:10.15.3-browsers + - image: circleci/node:10.16.3-browsers working_directory: ~/repo steps: - checkout - restore_cache: keys: - - v7-dependencies-{{ checksum "yarn.lock" }} + - v11-dependencies-{{ checksum "yarn.lock" }} # PR's from forks cannot use the dependency cache for performance reasons - run: @@ -108,16 +108,16 @@ jobs: command: yarn run bundle-size:check test-browser: docker: - # Single Docker container with Node 8 and Cypress dependencies + # Single Docker container with Node 10 and Cypress dependencies # https://github.com/cypress-io/circleci-orb/blob/master/src/orb.yml - - image: cypress/base:8 + - image: cypress/base:10.16.0 working_directory: ~/repo steps: - checkout - restore_cache: keys: - - v7-dependencies-{{ checksum "yarn.lock" }} + - v11-dependencies-{{ checksum "yarn.lock" }} # PR's from forks cannot use the dependency cache for performance reasons - run: @@ -133,6 +133,31 @@ jobs: path: cypress/videos - store_artifacts: path: cypress/screenshots + test-a11y: + docker: + - image: circleci/node:10.16.3-browsers + working_directory: ~/repo + steps: + - checkout + + - restore_cache: + keys: + - v11-dependencies-{{ checksum "yarn.lock" }} + + # PR's from forks cannot use the dependency cache for performance reasons + - run: + name: 'Forked PR dependency install' + command: yarn + + - run: + name: Accessibility Audit + command: node browser-test-harness.js yarn test:accessibility + + - store_artifacts: + path: test-reports/lighthouse + + - store_test_results: + path: test-reports/lighthouse workflows: version: 2 build: @@ -150,3 +175,6 @@ workflows: - test-browser: requires: - install + - test-a11y: + requires: + - install diff --git a/.eslintrc.js b/.eslintrc.js index 92632eb9d0..a84f845e8b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -52,9 +52,18 @@ module.exports = { // Cannot reassign function parameters but allowing modification 'no-param-reassign': ['error', { props: false }], + // Named exports are kewl + 'import/prefer-default-export': 'off', + + // Don't tell me what to do! + 'max-classes-per-file': 'off', + // Allowing ++ on numbers 'no-plusplus': 'off', + // Always enforcing the use of curly braces for if statements + 'curly': ['error', 'all'], + 'no-restricted-syntax': [ // Nicer booleans #1 // Disabling the use of !! to cast to boolean @@ -82,6 +91,25 @@ module.exports = { message: 'Must use `useLayoutEffect` as the name of the import from `*use-isomorphic-layout-effect` to leverage `eslint-plugin-react-hooks`', }, + + // No Array.from as it pulls in a large amount of babel helpers + { + selector: 'MemberExpression[object.name="Array"][property.name="from"]', + message: + 'Not allowing using of Array.from to save kbs. Please use native-with-fallback/from', + }, + + // No usage of `tiny-invariant`. Must use our own invariant for error flow + { + selector: 'ImportDeclaration[source.value="tiny-invariant"]', + message: 'Please use our own invariant function (src/invariant.js) to ensure correct error flow' + }, + + // Must use invariant to throw + { + selector: 'ThrowStatement', + message: 'Please use invariant (src/invariant.js) for throwing. This is to ensure correct error flows' + } ], // Allowing Math.pow rather than forcing `**` @@ -162,6 +190,17 @@ module.exports = { // Enforce rules of hooks 'react-hooks/rules-of-hooks': 'error', // Second argument to hook functions - 'react-hooks/exhaustive-deps': 'warn', + 'react-hooks/exhaustive-deps': 'error', + + 'react/jsx-props-no-spreading': 'off', + + // using is fine + 'react/jsx-fragments': 'off', + + // all good to declare static class members in the class + 'react/static-property-placement': 'off', + + // don't need to initialize state in a constructor + 'react/state-in-constructor': 'off', }, }; diff --git a/.flowconfig b/.flowconfig index 43eb1b40c9..9cb26e664b 100644 --- a/.flowconfig +++ b/.flowconfig @@ -2,6 +2,10 @@ # Issue with atlaskit/theme typing .*/node_modules/@atlaskit/theme +[ignore] +# Creating lots of invalid files +.*/node_modules/jsonlint-mod/.* + [libs] ./flow-typed/custom/ diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md index e73c745528..a018e30d36 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.md +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -16,7 +16,7 @@ https://github.com/atlassian/react-beautiful-dnd/blob/master/docs/guides/common- In development builds we log warnings to the console for common setup issues. Please have a look to see if it can give you information in overcoming your issue ## Are you new to rbd? -If you are new to `react-beautiful-dnd` we recommend taking at look at our getting started course: https://egghead.io/courses/beautiful-and-accessible-drag-and-drop-with-react-beautiful-dnd +If you are new to `react-beautiful-dnd` we recommend taking at look at our getting started course: https://egghead.io/courses/beautiful-and-accessible-drag-and-drop-with-react-beautiful-dnd?af=2jc3e4 It will give you a good base understanding of how everything fits together. This can often be the best help in overcoming your issue. diff --git a/.gitignore b/.gitignore index 6761f35121..f3932ff6f5 100644 --- a/.gitignore +++ b/.gitignore @@ -20,8 +20,9 @@ coverage/ # test reports test-reports/ -# test videos +# test outputs cypress/videos/ +cypress/screenshots/ # storybook .storybook.out diff --git a/.nvmrc b/.nvmrc index d16c3a34a3..f228857c9f 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -10.15.3 \ No newline at end of file +10.16.3 \ No newline at end of file diff --git a/.size-snapshot.json b/.size-snapshot.json index b7cac82f6e..a88b82fecc 100644 --- a/.size-snapshot.json +++ b/.size-snapshot.json @@ -1,25 +1,25 @@ { "dist/react-beautiful-dnd.js": { - "bundled": 392081, - "minified": 147079, - "gzipped": 41340 + "bundled": 380407, + "minified": 139714, + "gzipped": 42033 }, "dist/react-beautiful-dnd.min.js": { - "bundled": 323864, - "minified": 116189, - "gzipped": 33372 + "bundled": 322021, + "minified": 115167, + "gzipped": 34123 }, "dist/react-beautiful-dnd.esm.js": { - "bundled": 238384, - "minified": 123773, - "gzipped": 31477, + "bundled": 241645, + "minified": 126072, + "gzipped": 32900, "treeshaked": { "rollup": { - "code": 29975, - "import_statements": 793 + "code": 21306, + "import_statements": 788 }, "webpack": { - "code": 33907 + "code": 24963 } } } diff --git a/.storybook/.babelrc b/.storybook/.babelrc index 309c47e728..c019ede44a 100644 --- a/.storybook/.babelrc +++ b/.storybook/.babelrc @@ -2,9 +2,11 @@ "presets": [ "@babel/react", "@babel/flow", - ["@babel/env", { "modules": false, "loose": true }] + ["@babel/env", { "modules": false, "loose": true }], + "@emotion/babel-preset-css-prop" ], "plugins": [ + "emotion", ["@babel/proposal-class-properties", { "loose": true }], ["@babel/proposal-object-rest-spread", { "loose": true }] ], diff --git a/.storybook/config.js b/.storybook/config.js index 83bb3686ed..8e8bfe14ea 100644 --- a/.storybook/config.js +++ b/.storybook/config.js @@ -39,7 +39,18 @@ const table = Object.prototype.hasOwnProperty.call(console, 'table') ? console.table : console.log; -console.log('environment'); +// Generated by: http://patorjk.com/software/taag/#p=display&f=ANSI%20Shadow&t=rbd +console.log(`%c +██████╗ ██████╗ ██████╗ +██╔══██╗██╔══██╗██╔══██╗ +██████╔╝██████╔╝██║ ██║ +██╔══██╗██╔══██╗██║ ██║ +██║ ██║██████╔╝██████╔╝ +╚═╝ ╚═╝╚═════╝ ╚═════╝ + +%cBeautiful and accessible drag and drop +`, `color: ${colors.G200}; font-size: 1.2em; font-weight: bold;`, `color: ${colors.P200}; font-size: 1.2em; font-weight: bold;`); + table([ ['react-beautiful-dnd version', version], ['react version', React.version], diff --git a/.storybook/decorator/global-styles.jsx b/.storybook/decorator/global-styles.jsx index 2f3a661320..401d05b98f 100644 --- a/.storybook/decorator/global-styles.jsx +++ b/.storybook/decorator/global-styles.jsx @@ -5,7 +5,6 @@ import { colors } from '@atlaskit/theme'; import { grid } from '../../stories/src/constants'; const GlobalStyles = styled.div` - background-color: ${colors.N0}; min-height: 100vh; color: ${colors.N900}; `; diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html new file mode 100644 index 0000000000..33e32d5582 --- /dev/null +++ b/.storybook/preview-head.html @@ -0,0 +1,9 @@ + + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fb9e74aa55..ea1938851b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,7 +40,7 @@ The online courses listed are no free - feel free to seek out alternatives if yo This is a `React` project so getting familiar with `React` is a must! - [`react`](https://facebook.github.io/react/) -- [An intro to using React](https://egghead.io/courses/start-using-react-to-build-web-applications) +- [An intro to using React](https://egghead.io/courses/start-using-react-to-build-web-applications?af=2jc3e4) #### `Redux` @@ -57,8 +57,8 @@ This project uses `redux` for its state management. If you have not used `redux` We test our application very thoroughly. Changes will not be accepted without tests - [`jest`](https://facebook.github.io/jest/): We use the jest test runner. It is worth getting familiar with it -- [Test JavaScript with Jest](https://egghead.io/lessons/javascript-test-javascript-with-jest) -- [React Testing Cookbook](https://egghead.io/courses/react-testing-cookbook) +- [Test JavaScript with Jest](https://egghead.io/lessons/javascript-test-javascript-with-jest?af=2jc3e4) +- [React Testing Cookbook](https://egghead.io/courses/react-testing-cookbook?af=2jc3e4) #### Performance @@ -75,7 +75,7 @@ Performance is **critical** to this project. Please get familiar with React perf This codebase is typed with [`flow`](https://flow.org/). Changes will not be merged without correct flow typing. If you are not sure about a particular use case let flow break the build and it can be discussed in the pull request. - [`flow`](https://flow.org/en/docs/getting-started/): the `flow` docs are great -- [Up and Running with Facebook Flow for Typed JavaScript](https://egghead.io/lessons/javascript-up-and-running-with-facebook-flow-for-typed-javascript): a small primer for running flow +- [Up and Running with Facebook Flow for Typed JavaScript](https://egghead.io/lessons/javascript-up-and-running-with-facebook-flow-for-typed-javascript?af=2jc3e4): a small primer for running flow ### Drag and drop problem space diff --git a/README.md b/README.md index e0a29a5d55..c595cf24a1 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@

react beautiful dnd logo

-

react-beautiful-dnd

+

react-beautiful-dnd (rbd)

@@ -28,15 +28,16 @@ ## Get started 👩‍🏫 -We have created [a free course on `egghead.io` 🥚](https://egghead.io/courses/beautiful-and-accessible-drag-and-drop-with-react-beautiful-dnd) to help you get started with `react-beautiful-dnd` as quickly as possible. +We have created [a free course on `egghead.io` 🥚](https://egghead.io/courses/beautiful-and-accessible-drag-and-drop-with-react-beautiful-dnd?af=2jc3e4) to help you get started with `react-beautiful-dnd` as quickly as possible. -[![course-logo](https://user-images.githubusercontent.com/2182637/43372837-8c72d3f8-93e8-11e8-9d92-a82adde7718f.png)](https://egghead.io/courses/beautiful-and-accessible-drag-and-drop-with-react-beautiful-dnd) +[![course-logo](https://user-images.githubusercontent.com/2182637/43372837-8c72d3f8-93e8-11e8-9d92-a82adde7718f.png)](https://egghead.io/courses/beautiful-and-accessible-drag-and-drop-with-react-beautiful-dnd?af=2jc3e4) ## Currently supported feature set ✅ - Vertical lists ↕ - Horizontal lists ↔ - Movement between lists (▤ ↔ ▤) +- [Virtual list support 👾](/docs/patterns/virtual-lists.md) - unlocking 10,000 items @ 60fps - [Combining items](/docs/guides/combining.md) - Mouse 🐭, keyboard 🎹♿️ and touch 👉📱 (mobile, tablet and so on) support - [Multi drag support](/docs/patterns/multi-drag.md) @@ -48,7 +49,9 @@ We have created [a free course on `egghead.io` 🥚](https://egghead.io/courses/ - Compatible with semantic `` reordering - [table pattern](/docs/patterns/tables.md) - [Auto scrolling](/docs/guides/auto-scrolling.md) - automatically scroll containers and the window as required during a drag (even with keyboard 🔥) - Custom drag handles - you can drag a whole item by just a part of it -- Compatible with [`ReactDOM.createPortal`](https://reactjs.org/docs/portals.html) - [portal pattern](/docs/patterns/using-a-portal.md) +- Able to move the dragging item to another element while dragging (clone, portal) - [Reparenting your ``](/docs/guides/reparenting.md) +- [Create scripted drag and drop experiences 🎮](/docs/sensors/sensor-api.md) +- Allows extensions to support for [any input type you like 🕹](/docs/sensors/sensor-api.md) - 🌲 Tree support through the [`@atlaskit/tree`](https://atlaskit.atlassian.com/packages/core/tree) package - A `` list can be a scroll container (without a scrollable parent) or be the child of a scroll container (that also does not have a scrollable parent) - Independent nested lists - a list can be a child of another list, but you cannot drag items from the parent list into a child list @@ -72,7 +75,7 @@ There are a lot of libraries out there that allow for drag and drop interactions - [Installation](/docs/about/installation.md) - [Examples and samples](/docs/about/examples.md) -- [Get started](https://egghead.io/courses/beautiful-and-accessible-drag-and-drop-with-react-beautiful-dnd) +- [Get started](https://egghead.io/courses/beautiful-and-accessible-drag-and-drop-with-react-beautiful-dnd?af=2jc3e4) - [Design principles](/docs/about/design-principles.md) - [Animations](/docs/about/animations.md) - [Accessibility](/docs/about/accessibility.md) @@ -85,6 +88,7 @@ There are a lot of libraries out there that allow for drag and drop interactions - [Mouse dragging 🐭](/docs/sensors/mouse.md) - [Touch dragging 👉📱](/docs/sensors/touch.md) - [Keyboard dragging 🎹♿️](/docs/sensors/keyboard.md) +- [Create your own sensor](/docs/sensors/sensor-api.md) (allows for any input type as well as scripted experiences) ### API 🏋️‍ @@ -101,24 +105,28 @@ There are a lot of libraries out there that allow for drag and drop interactions - [Combining ``s](/docs/guides/combining.md) - [Common setup issues](/docs/guides/common-setup-issues.md) - [Using `innerRef`](/docs/guides/using-inner-ref.md) -- [Developer warnings and how to disable them](/docs/guides/developer-warnings.md) +- [Setup problem detection and error recovery](/docs/guides/setup-problem-detection-and-error-recovery.md) - [Rules for `draggableId` and `droppableId`s](/docs/guides/identifiers.md) +- [Browser focus retention](/docs/guides/browser-focus.md) - [Customising or skipping the drop animation](/docs/guides/drop-animation.md) - [Auto scrolling](/docs/guides/auto-scrolling.md) - [Controlling the screen reader](/docs/guides/screen-reader.md) - [Use the html5 `doctype`](/docs/guides/doctype.md) - [`TypeScript` and `flow`](/docs/guides/types.md) - [Dragging ``s](/docs/guides/dragging-svgs.md) +- [Avoiding image flickering](/docs/guides/avoiding-image-flickering.md) - [Non-visible preset styles](/docs/guides/preset-styles.md) - [How we detect scroll containers](/docs/guides/how-we-detect-scroll-containers.md) - [How we use dom events](/docs/guides/how-we-use-dom-events.md) - _Useful if you need to build on top of `react-beautiful-dnd`_ -- [Adding ``s during a drag](/docs/guides/changes-while-dragging.md) - _⚠️ Advanced_ +- [Adding ``s during a drag (11.x behaviour)](/docs/guides/changes-while-dragging.md) - _⚠️ Advanced_ +- [Setting up Content Security Policy](/docs/guides/content-security-policy.md) ### Patterns 👷‍ +- [Virtual lists 👾](/docs/patterns/virtual-lists.md) - [Multi drag](/docs/patterns/multi-drag.md) - [Tables](/docs/patterns/tables.md) -- [Using a portal (`ReactDOM.createPortal`)](/docs/patterns/using-a-portal.md) +- [Reparenting a ``](/docs/guides/reparenting.md) - _Using our cloning API or your own portal_ ### Support 👩‍⚕️ diff --git a/a11y-audit-parse.js b/a11y-audit-parse.js new file mode 100644 index 0000000000..f7b4fda25c --- /dev/null +++ b/a11y-audit-parse.js @@ -0,0 +1,31 @@ +/* eslint-disable flowtype/require-valid-file-annotation */ +/* eslint-disable import/no-unresolved */ +/* eslint-disable global-require */ +/* eslint-disable no-console */ +try { + // I disabled flow for this file because of this line. + // Sometimes the file exists, sometimes it doesn't. + // It depends on if you have run the accessibility test or not. + // Given this conditional I thought it best to simply disable flow for the file + const a11yReport = require('./test-reports/lighthouse/a11y.report.json'); + const a11yScore = a11yReport.categories.accessibility.score; + const a11yScoreFormatted = `${a11yScore ? a11yScore * 100 : 0}%`; + + console.log('*************************'); + console.log('Lighthouse accessibility score: ', a11yScoreFormatted); + console.log('*************************'); + + if (a11yScore === 1) { + // success! + process.exit(0); + } else { + // fail build + console.log( + '\nNOTE: Lighthouse accessibility audit score must be 100% to pass this build step.\n\n', + ); + process.exit(1); + } +} catch (e) { + console.error(e); + process.exit(1); +} diff --git a/babel.config.js b/babel.config.js index 8e9193cdcd..050fc64e40 100644 --- a/babel.config.js +++ b/babel.config.js @@ -2,7 +2,6 @@ module.exports = { presets: ['@babel/react', '@babel/flow', ['@babel/env', { loose: true }]], plugins: [ ['@babel/proposal-class-properties', { loose: true }], - 'emotion', // used for stripping out the `invariant` messages in production builds 'dev-expression', ], diff --git a/browser-test-harness.js b/browser-test-harness.js index d9342fe819..36d37bb4ef 100644 --- a/browser-test-harness.js +++ b/browser-test-harness.js @@ -2,23 +2,43 @@ const childProcess = require('child_process'); const path = require('path'); const waitPort = require('wait-port'); +const ports = require('./server-ports'); const storybook = childProcess.spawn(process.execPath, [ path.join('node_modules', '.bin', 'start-storybook'), '-p', - '9002', + `${ports.storybook}`, +]); + +const cspServer = childProcess.spawn(process.execPath, [ + path.join('csp-server', 'start.sh'), + `${ports.cspServer}`, ]); process.on('exit', () => { storybook.kill(); + cspServer.kill(); }); -waitPort({ - host: 'localhost', - port: 9002, - timeout: 60000, -}) +Promise.all([ + waitPort({ + host: 'localhost', + port: ports.storybook, + timeout: 60000, + }), + waitPort({ + host: 'localhost', + port: ports.cspServer, + timeout: 60000, + }), +]) .then(() => { + if (!process.argv[2]) { + // eslint-disable-next-line no-console + console.warn('Started servers but no command supplied to run after'); + process.exit(); + } + const child = childProcess.spawn(process.argv[2], process.argv.slice(3), { stdio: 'inherit', }); @@ -29,9 +49,12 @@ waitPort({ process.exit(code); }); }) - .catch(() => { + .catch(error => { + // eslint-disable-next-line no-console + console.error('Unable to spin up standalone servers'); // eslint-disable-next-line no-console - console.error('Storybook did not start in time'); + console.error(error); storybook.kill(); + cspServer.kill(); process.exit(1); }); diff --git a/csp-server/.eslintrc.js b/csp-server/.eslintrc.js new file mode 100644 index 0000000000..8ec816711e --- /dev/null +++ b/csp-server/.eslintrc.js @@ -0,0 +1,14 @@ +module.exports = { + rules: { + // allowing console.warn / console.error + // 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', + + // Allowing Array.from + 'no-restricted-syntax': 'off', + }, +}; diff --git a/csp-server/app.jsx b/csp-server/app.jsx new file mode 100644 index 0000000000..601eb6a00a --- /dev/null +++ b/csp-server/app.jsx @@ -0,0 +1,96 @@ +// disabling flowtype to keep this example super simple +// It matches +/* eslint-disable flowtype/require-valid-file-annotation */ + +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { DragDropContext, Droppable, Draggable } from '../src'; + +// fake data generator +const getItems = count => + Array.from({ length: count }, (v, k) => k).map(k => ({ + id: `item-${k}`, + content: `item ${k}`, + })); + +// a little function to help us with reordering the result +const reorder = (list, startIndex, endIndex) => { + const result = Array.from(list); + const [removed] = result.splice(startIndex, 1); + result.splice(endIndex, 0, removed); + + return result; +}; + +export default class App extends Component { + constructor(props, context) { + super(props, context); + this.state = { + items: getItems(10), + cspErrors: [], + }; + this.onDragEnd = this.onDragEnd.bind(this); + } + + componentDidMount() { + document.addEventListener('securitypolicyviolation', e => { + this.setState(state => { + return { cspErrors: [...state.cspErrors, e] }; + }); + }); + } + + onDragEnd(result) { + // dropped outside the list + if (!result.destination) { + return; + } + + const items = reorder( + this.state.items, + result.source.index, + result.destination.index, + ); + + this.setState({ + items, + }); + } + + // Normally you would want to split things out into separate components. + // But in this example everything is just done in one place for simplicity + render() { + return ( + +

+ Content-Security-Policy error count:{' '} + {this.state.cspErrors.length} +

+ + {droppableProvided => ( +
+ {this.state.items.map((item, index) => ( + + {draggableProvided => ( +
+ {item.content} +
+ )} +
+ ))} + {droppableProvided.placeholder} +
+ )} +
+ + ); + } +} + +App.propTypes = { + nonce: PropTypes.string, +}; diff --git a/csp-server/client.js b/csp-server/client.js new file mode 100644 index 0000000000..3dcc87bd81 --- /dev/null +++ b/csp-server/client.js @@ -0,0 +1,19 @@ +// @flow +/* eslint-env browser */ + +import React from 'react'; +import { hydrate } from 'react-dom'; +import Sample from './app'; + +const root: Element | null = document.getElementById('root'); + +let nonce = null; + +const cspEl = document.getElementById('csp-nonce'); +if (cspEl) { + nonce = cspEl.getAttribute('content'); +} + +if (root) { + hydrate(, root); +} diff --git a/csp-server/main.js b/csp-server/main.js new file mode 100644 index 0000000000..ae1666098a --- /dev/null +++ b/csp-server/main.js @@ -0,0 +1,24 @@ +/* eslint-disable id-length */ +// @flow + +const webpack = require('webpack'); +const requireFromString = require('require-from-string'); +const MemoryFS = require('memory-fs'); +const path = require('path'); +const config = require('./webpack.config'); +const ports = require('../server-ports'); + +const fs = new MemoryFS(); +const compiler = webpack(config); + +// $ExpectError +compiler.outputFileSystem = fs; +// $ExpectError +const outputPath = compiler.compilers.find(cfg => cfg.name === 'client') + .outputPath; + +compiler.run(() => { + const content = fs.readFileSync(path.resolve(outputPath, 'server.js')); + const server = requireFromString(content.toString()).default; + server(process.argv[2] || ports.cspServer, outputPath, fs); +}); diff --git a/csp-server/server.js b/csp-server/server.js new file mode 100644 index 0000000000..523b2a2a7b --- /dev/null +++ b/csp-server/server.js @@ -0,0 +1,67 @@ +// @flow +import express from 'express'; +import React from 'react'; +import { renderToString } from 'react-dom/server'; +import { resolve } from 'path'; +import App from './app'; +import { resetServerContext } from '../src'; + +let count = 0; +function getNonce(): string { + return `ThisShouldBeACryptographicallySecurePseudoRandomNumber-${count++}`; +} + +function renderHtml(policy?: string, nonce?: string) { + resetServerContext(); + let meta = ''; + if (nonce) { + meta += ``; + } + if (policy) { + meta += ``; + } + return `${meta}
${renderToString( + , + )}
`; +} + +export default (port: string, outputPath: string, fs: any) => { + const server = express(); + + server.get('/client.js', (req, res) => { + res.header('content-type', 'text/javascript'); + res.end(fs.readFileSync(resolve(outputPath, 'client.js'))); + }); + + function render(res: any, policy?: string, nonce?: string) { + if (policy) { + res.header('Content-Security-Policy', policy); + } + res.header('content-type', 'text/html'); + res.end(renderHtml(policy, nonce)); + } + + server.get('/', (req, res) => { + render(res); + }); + + server.get('/unsafe-inline', (req, res) => { + render(res, "style-src 'unsafe-inline'"); + }); + + server.get('/nonce', (req, res) => { + const nonce = getNonce(); + render(res, `style-src 'nonce-${nonce}'`, nonce); + }); + + server.get('/wrong-nonce', (req, res) => { + const nonce = getNonce(); + const wrongNonce = getNonce(); + render(res, `style-src 'nonce-${nonce}'`, wrongNonce); + }); + + server.listen(port, () => { + // eslint-disable-next-line no-console + console.log('csp server listening on port', port); + }); +}; diff --git a/csp-server/start.sh b/csp-server/start.sh new file mode 100755 index 0000000000..04814c2e59 --- /dev/null +++ b/csp-server/start.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env node + +require('./main'); diff --git a/csp-server/webpack.config.js b/csp-server/webpack.config.js new file mode 100644 index 0000000000..35acbcf8a7 --- /dev/null +++ b/csp-server/webpack.config.js @@ -0,0 +1,59 @@ +// @flow +const path = require('path'); + +const common = { + context: path.resolve(__dirname, '..'), + mode: 'development', + entry: path.resolve(__dirname, 'main.js'), + target: 'web', + output: { + filename: 'client.js', + path: path.resolve(__dirname, 'dist'), + }, + resolve: { + extensions: ['.ts', '.tsx', '.js', '.jsx', '.mjs'], + }, + module: { + rules: [ + { + test: /\.(png|jpg|gif)$/i, + use: [ + { + loader: 'url-loader', + }, + ], + }, + { + test: /jsx?$/, + exclude: [/node_modules/], + use: [ + { + loader: 'babel-loader', + }, + ], + }, + ], + }, + externals: [ + { + express: 'express', + fs: 'fs', + 'convert-source-map': 'convert-source-map', + }, + ], +}; + +module.exports = [ + { ...common, entry: path.resolve(__dirname, 'client.js'), name: 'client' }, + { + ...common, + entry: path.resolve(__dirname, 'server.js'), + name: 'server', + target: 'node', + output: { + filename: 'server.js', + path: path.resolve(__dirname, 'dist'), + libraryTarget: 'commonjs2', + }, + }, +]; diff --git a/cypress/.eslintrc.js b/cypress/.eslintrc.js index da63b18525..8e22872bda 100644 --- a/cypress/.eslintrc.js +++ b/cypress/.eslintrc.js @@ -4,4 +4,8 @@ module.exports = { 'cypress/globals': true, }, extends: ['plugin:cypress/recommended'], + rules: { + // Allowing Array.from + 'no-restricted-syntax': 'off', + } }; diff --git a/cypress/integration/content-security-policy.spec.js b/cypress/integration/content-security-policy.spec.js new file mode 100644 index 0000000000..077fbd1140 --- /dev/null +++ b/cypress/integration/content-security-policy.spec.js @@ -0,0 +1,60 @@ +// @flow +import * as keyCodes from '../../src/view/key-codes'; +import { timings } from '../../src/animation'; +import { getHandleSelector } from './util'; +import ports from '../../server-ports'; + +function commonTest(url: string, cspTest: string) { + cy.visit(url); + + cy.get(getHandleSelector()) + .eq(0) + .as('first') + .should('contain', 'item 0'); + cy.get(getHandleSelector()) + .eq(1) + .should('contain', 'item 1'); + + // reorder operation + cy.get('@first') + .focus() + .trigger('keydown', { keyCode: keyCodes.space }) + // need to re-query for a clone + .get('@first') + .trigger('keydown', { keyCode: keyCodes.arrowDown, force: true }) + // finishing before the movement time is fine - but this looks nice + .wait(timings.outOfTheWay * 1000) + .trigger('keydown', { keyCode: keyCodes.space, force: true }); + + // order now 2, 1 + // note: not using get aliases as they where returning incorrect results + cy.get(getHandleSelector()) + .eq(0) + .should('contain', 'item 1'); + + cy.get(getHandleSelector()) + .eq(1) + .should('contain', 'item 0'); + + // element should maintain focus post drag + cy.focused().should('contain', 'item 0'); + + cy.get('#cspErrors').should(cspTest, '0'); +} + +describe('content security policy', () => { + it('should reorder a list without a nonce', () => { + commonTest(`http://localhost:${ports.cspServer}`, 'contain'); + }); + + it('should reorder a list with a nonce', () => { + commonTest(`http://localhost:${ports.cspServer}/nonce`, 'contain'); + }); + + it('should reorder a list with a wrong nonce', () => { + commonTest( + `http://localhost:${ports.cspServer}/wrong-nonce`, + 'not.contain', + ); + }); +}); diff --git a/cypress/integration/focus.spec.js b/cypress/integration/focus.spec.js new file mode 100644 index 0000000000..a525f97d67 --- /dev/null +++ b/cypress/integration/focus.spec.js @@ -0,0 +1,148 @@ +// @flow +import * as keyCodes from '../../src/view/key-codes'; +import { getHandleSelector, getDraggableSelector } from './util'; + +describe('focus', () => { + it('should not steal focus if not already focused when lifting', () => { + cy.visit('/iframe.html?id=board--dragging-a-clone'); + // focusing on another handle + cy.get(getHandleSelector('1')).focus(); + cy.focused().should('contain', 'id:1'); + + cy.get(getHandleSelector('2')) + .as('id:2') + .trigger('mousedown', { button: 0 }) + .trigger('mousemove', { + button: 0, + clientX: 200, + clientY: 300, + force: true, + }); + + // asserting id:2 is now dragging + cy.get(getHandleSelector('2')).should( + 'have.attr', + 'data-is-dragging', + 'true', + ); + + // focus not stolen + cy.focused().should('contain', 'id:1'); + + cy.get(getHandleSelector('2')) + .trigger('mouseup', { force: true }) + // clone will be unmounting during drop + .should('not.exist'); + + // getting post clone handle + cy.get(getHandleSelector('2')).should( + 'have.attr', + 'data-is-dragging', + 'false', + ); + + // focus not stolen + cy.focused().should('contain', 'id:1'); + }); + + it('should maintain focus if dragging a clone', () => { + cy.visit('/iframe.html?id=board--dragging-a-clone'); + // focusing on another handle + cy.get(getHandleSelector('2')).focus(); + cy.focused().should('contain', 'id:2'); + + cy.get(getHandleSelector('2')).trigger('keydown', { + keyCode: keyCodes.space, + }); + + // asserting id:2 is now dragging + cy.get(getHandleSelector('2')).should( + 'have.attr', + 'data-is-dragging', + 'true', + ); + + // focus maintained + cy.focused().should('contain', 'id:2'); + + cy.get(getHandleSelector('2')) + .trigger('keydown', { keyCode: keyCodes.arrowRight, force: true }) + .trigger('keydown', { keyCode: keyCodes.space, force: true }) + // clone will be unmounting during drop + .should('not.exist'); + + // getting post clone handle + cy.get(getHandleSelector('2')) + // no longer dragging + .should('have.attr', 'data-is-dragging', 'false') + // is in the second column (normally would loose focus moving between lists) + .closest(getDraggableSelector('BMO')); + + // focus maintained + cy.focused().should('contain', 'id:2'); + }); + + it('should give focus to a combine target', () => { + cy.visit('/iframe.html?id=board--with-combining-and-cloning'); + cy.get(getHandleSelector('2')).focus(); + cy.focused().should('contain', 'id:2'); + + cy.get(getHandleSelector('2')).trigger('keydown', { + keyCode: keyCodes.space, + }); + + // asserting id:2 is now dragging + cy.get(getHandleSelector('2')).should( + 'have.attr', + 'data-is-dragging', + 'true', + ); + + // focus maintained + cy.focused().should('contain', 'id:2'); + + cy.get(getHandleSelector('2')) + .trigger('keydown', { keyCode: keyCodes.arrowRight, force: true }) + // combining with item:1 + .trigger('keydown', { keyCode: keyCodes.arrowUp, force: true }) + // dropping + .trigger('keydown', { keyCode: keyCodes.space, force: true }) + // clone will be unmounting during drop + .should('not.exist'); + + // focus giving to item:1 the combine target + cy.focused().should('contain', 'id:1'); + }); + + it('should not give focus to a combine target if source did not have focus at start of drag', () => { + cy.visit('/iframe.html?id=board--with-combining-and-cloning'); + // focusing on something unrelated to the drag + cy.get(getHandleSelector('3')).focus(); + + cy.get(getHandleSelector('2')).trigger('keydown', { + keyCode: keyCodes.space, + }); + + // asserting id:2 is now dragging + cy.get(getHandleSelector('2')).should( + 'have.attr', + 'data-is-dragging', + 'true', + ); + + // focus not stolen + cy.focused().should('contain', 'id:3'); + + cy.get(getHandleSelector('2')) + .trigger('keydown', { keyCode: keyCodes.arrowRight, force: true }) + // combining with item:1 + .trigger('keydown', { keyCode: keyCodes.arrowUp, force: true }) + // dropping + .trigger('keydown', { keyCode: keyCodes.space, force: true }) + // clone will be unmounting during drop + .should('not.exist'); + + // focus not given to the combine target + cy.focused().should('contain', 'id:3'); + }); +}); diff --git a/cypress/integration/move-between-lists.spec.js b/cypress/integration/move-between-lists.spec.js index 9571355359..0f176b7f3d 100644 --- a/cypress/integration/move-between-lists.spec.js +++ b/cypress/integration/move-between-lists.spec.js @@ -1,38 +1,41 @@ // @flow import * as keyCodes from '../../src/view/key-codes'; import { timings } from '../../src/animation'; +import { getDroppableSelector, getHandleSelector } from './util'; -beforeEach(() => { - cy.visit('/iframe.html?id=board--simple'); -}); +describe('move between lists', () => { + beforeEach(() => { + cy.visit('/iframe.html?id=board--simple'); + }); -it('should move between lists', () => { - // first list has item with id:2 - cy.get('[data-react-beautiful-dnd-droppable]') - .eq(1) - .as('first-list') - .should('contain', 'id:2'); + it('should move between lists', () => { + // first list has item with id:2 + cy.get(getDroppableSelector()) + .eq(1) + .as('first-list') + .should('contain', 'id:2'); - // second list does not have item with id:2 - cy.get('[data-react-beautiful-dnd-droppable]') - .eq(2) - .as('second-list') - .should('not.contain', 'id:2'); + // second list does not have item with id:2 + cy.get(getDroppableSelector()) + .eq(2) + .as('second-list') + .should('not.contain', 'id:2'); - cy.get('@first-list') - .find('[data-react-beautiful-dnd-drag-handle]') - .first() - .should('contain', 'id:2') - .focus() - .trigger('keydown', { keyCode: keyCodes.space }) - .trigger('keydown', { keyCode: keyCodes.arrowRight, force: true }) - // finishing before the movement time is fine - but this looks nice - .wait(timings.outOfTheWay * 1000) - .trigger('keydown', { keyCode: keyCodes.space, force: true }); + cy.get('@first-list') + .find(getHandleSelector()) + .first() + .should('contain', 'id:2') + .focus() + .trigger('keydown', { keyCode: keyCodes.space }) + .trigger('keydown', { keyCode: keyCodes.arrowRight, force: true }) + // finishing before the movement time is fine - but this looks nice + .wait(timings.outOfTheWay * 1000) + .trigger('keydown', { keyCode: keyCodes.space, force: true }); - // no longer in the first list - cy.get('@first-list').should('not.contain', 'id:2'); + // no longer in the first list + cy.get('@first-list').should('not.contain', 'id:2'); - // now in the second list - cy.get('@second-list').should('contain', 'id:2'); + // now in the second list + cy.get('@second-list').should('contain', 'id:2'); + }); }); diff --git a/cypress/integration/reorder-lists.spec.js b/cypress/integration/reorder-lists.spec.js index bfe2017fd0..df85e51eef 100644 --- a/cypress/integration/reorder-lists.spec.js +++ b/cypress/integration/reorder-lists.spec.js @@ -1,40 +1,43 @@ // @flow import * as keyCodes from '../../src/view/key-codes'; import { timings } from '../../src/animation'; +import { getHandleSelector } from './util'; -beforeEach(() => { - cy.visit('/iframe.html?id=board--simple'); -}); +describe('reorder lists', () => { + beforeEach(() => { + cy.visit('/iframe.html?id=board--simple'); + }); -it('should reorder lists', () => { - // order: Jake, BMO - cy.get('h4') - .eq(0) - .as('first') - .should('have.text', 'Jake'); + it('should reorder lists', () => { + // order: Jake, BMO + cy.get('h4') + .eq(0) + .as('first') + .should('contain', 'Jake'); - cy.get('h4') - .eq(1) - .should('have.text', 'BMO'); + cy.get('h4') + .eq(1) + .should('contain', 'BMO'); - // reorder operation - cy.get('@first') - .closest('[data-react-beautiful-dnd-drag-handle]') - .focus() - .trigger('keydown', { keyCode: keyCodes.space }) - .trigger('keydown', { keyCode: keyCodes.arrowRight, force: true }) - // finishing before the movement time is fine - but this looks nice - .wait(timings.outOfTheWay * 1000) - .trigger('keydown', { keyCode: keyCodes.space, force: true }); + // reorder operation + cy.get('@first') + .closest(getHandleSelector()) + .focus() + .trigger('keydown', { keyCode: keyCodes.space }) + .trigger('keydown', { keyCode: keyCodes.arrowRight, force: true }) + // finishing before the movement time is fine - but this looks nice + .wait(timings.outOfTheWay * 1000) + .trigger('keydown', { keyCode: keyCodes.space, force: true }); - // order now 2, 1 - // note: not using get aliases as they where returning incorrect results - cy.get('h4') - .eq(0) - .should('have.text', 'BMO'); + // order now 2, 1 + // note: not using get aliases as they where returning incorrect results + cy.get('h4') + .eq(0) + .should('contain', 'BMO'); - // index of the drag handle has changed - cy.get('h4') - .eq(1) - .should('have.text', 'Jake'); + // index of the drag handle has changed + cy.get('h4') + .eq(1) + .should('contain', 'Jake'); + }); }); diff --git a/cypress/integration/reorder-virtual.spec.js b/cypress/integration/reorder-virtual.spec.js new file mode 100644 index 0000000000..16fa238ff3 --- /dev/null +++ b/cypress/integration/reorder-virtual.spec.js @@ -0,0 +1,57 @@ +// @flow +import * as keyCodes from '../../src/view/key-codes'; +import { timings } from '../../src/animation'; +import { getHandleSelector } from './util'; + +describe('reorder: virtual', () => { + beforeEach(() => { + cy.visit('/iframe.html?id=virtual-react-window--list'); + }); + + it('should reorder within a list', () => { + const movements: number = 12; + + cy.get(getHandleSelector()) + .first() + .as('item'); + + cy.get('@item') + .invoke('attr', 'data-testid') + .as('item-id'); + + cy.get('@item') + .invoke('attr', 'data-index') + .as('item-index') + .should('equal', '0'); + + // lift + cy.get('@item') + .focus() + .trigger('keydown', { keyCode: keyCodes.space }) + // need to re-query for a clone + .get('@item'); + + cy.wrap(Array.from({ length: movements })).each(() => { + cy.get('@item') + .trigger('keydown', { keyCode: keyCodes.arrowDown, force: true }) + // finishing before the movement time is fine - but this looks nice + // waiting longer than we should (timings.outOfTheWay * 1000) as electron is being strange + .wait(timings.outOfTheWay * 1000 * 2); + }); + + // drop + cy.get('@item').trigger('keydown', { + keyCode: keyCodes.space, + force: true, + }); + + // This is setting up a chain of commands and this test will not wait + // for a 'promise' to resolve. Linting is getting confused by .then + // eslint-disable-next-line jest/valid-expect-in-promise + cy.get('@item-id').then(id => { + cy.get(getHandleSelector(id)) + .invoke('attr', 'data-index') + .should('equal', `${movements}`); + }); + }); +}); diff --git a/cypress/integration/reorder.spec.js b/cypress/integration/reorder.spec.js index 6e3f7dba4d..1bc120d838 100644 --- a/cypress/integration/reorder.spec.js +++ b/cypress/integration/reorder.spec.js @@ -1,37 +1,45 @@ // @flow import * as keyCodes from '../../src/view/key-codes'; import { timings } from '../../src/animation'; +import { getHandleSelector } from './util'; -beforeEach(() => { - cy.visit('/iframe.html?id=single-vertical-list--basic'); -}); +describe('reorder', () => { + beforeEach(() => { + cy.visit('/iframe.html?id=single-vertical-list--basic'); + }); + + it('should reorder within a list', () => { + // order: 1, 2 + cy.get(getHandleSelector()) + .eq(0) + .as('first') + .should('contain', 'id:1'); + cy.get(getHandleSelector()) + .eq(1) + .should('contain', 'id:2'); -it('should reorder a list', () => { - // order: 1, 2 - cy.get('[data-react-beautiful-dnd-drag-handle]') - .eq(0) - .as('first') - .should('contain', 'id:1'); - cy.get('[data-react-beautiful-dnd-drag-handle]') - .eq(1) - .should('contain', 'id:2'); + // reorder operation + cy.get('@first') + .focus() + .trigger('keydown', { keyCode: keyCodes.space }) + // need to re-query for a clone + .get('@first') + .trigger('keydown', { keyCode: keyCodes.arrowDown, force: true }) + // finishing before the movement time is fine - but this looks nice + .wait(timings.outOfTheWay * 1000) + .trigger('keydown', { keyCode: keyCodes.space, force: true }); - // reorder operation - cy.get('@first') - .focus() - .trigger('keydown', { keyCode: keyCodes.space }) - .trigger('keydown', { keyCode: keyCodes.arrowDown, force: true }) - // finishing before the movement time is fine - but this looks nice - .wait(timings.outOfTheWay * 1000) - .trigger('keydown', { keyCode: keyCodes.space, force: true }); + // order now 2, 1 + // note: not using get aliases as they where returning incorrect results + cy.get(getHandleSelector()) + .eq(0) + .should('contain', 'id:2'); - // order now 2, 1 - // note: not using get aliases as they where returning incorrect results - cy.get('[data-react-beautiful-dnd-drag-handle]') - .eq(0) - .should('contain', 'id:2'); + cy.get(getHandleSelector()) + .eq(1) + .should('contain', 'id:1'); - cy.get('[data-react-beautiful-dnd-drag-handle]') - .eq(1) - .should('contain', 'id:1'); + // element should maintain focus post drag + cy.focused().should('contain', 'id:1'); + }); }); diff --git a/cypress/integration/util.js b/cypress/integration/util.js new file mode 100644 index 0000000000..3f4e9e8d54 --- /dev/null +++ b/cypress/integration/util.js @@ -0,0 +1,23 @@ +// @flow +import * as dataAttr from '../../src/view/data-attributes'; + +export function getDroppableSelector(droppableId?: string) { + if (droppableId) { + return `[${dataAttr.droppable.id}="${droppableId}"]`; + } + return `[${dataAttr.droppable.id}]`; +} + +export function getHandleSelector(draggableId?: string) { + if (draggableId) { + return `[${dataAttr.dragHandle.draggableId}="${draggableId}"]`; + } + return `[${dataAttr.dragHandle.draggableId}]`; +} + +export function getDraggableSelector(draggableId?: string) { + if (draggableId) { + return `[${dataAttr.draggable.id}="${draggableId}"]`; + } + return `[${dataAttr.draggable.id}]`; +} diff --git a/docs/about/accessibility.md b/docs/about/accessibility.md index 52c08499cc..4732dc6bf3 100644 --- a/docs/about/accessibility.md +++ b/docs/about/accessibility.md @@ -8,10 +8,8 @@ Traditionally drag and drop interactions have been exclusively a mouse or touch - [Keyboard multi drag support](/docs/patterns/multi-drag.md) - Keyboard [auto scrolling](/docs/guides/auto-scrolling.md) - Fantastic [screen reader support](/docs/guides/screen-reader.md) - _We ship with english messaging out of the box 📦_ -- Correct use of `aria-*` attributes for [lift announcements](/docs/guides/screen-reader.md) -- Ensure a dragging item maintains focus if started dragging with focus - [more info](/docs/api/draggable.md) -- Ensure a dragging item maintains focus when dropping into a new list to allow drags to be chained together - [more info](/docs/api/draggable.md) -- Ensure a dragging item maintains focus moving into a [portal](/docs/patterns/using-a-portal.md) +- Smart management of [browser focus](/docs/guides/browser-focus.md) +- A [Google lighthouse](https://developers.google.com/web/tools/lighthouse) automated build to ensure perfect accessibility scores (at least according to [Google](https://developers.google.com/web/tools/lighthouse/v3/scoring#a11y)) ![screen-reader-text](https://user-images.githubusercontent.com/2182637/36571009-d326d82a-1888-11e8-9a1d-e44f8b969c2f.gif) diff --git a/docs/about/installation.md b/docs/about/installation.md index 264ab693b4..d59f4a0dc1 100644 --- a/docs/about/installation.md +++ b/docs/about/installation.md @@ -27,7 +27,7 @@ import { DragDropContext } from 'react-beautiful-dnd'; In order to use `react-beautiful-dnd` you will probably want to have a `React` environment set up. - [Add react to a website](https://reactjs.org/docs/add-react-to-a-website.html) - official `React` docs -- [Setup a react environment with `create-react-app`](https://egghead.io/lessons/react-set-up-a-react-environment-with-create-react-app) - from our [free getting started course](https://egghead.io/courses/beautiful-and-accessible-drag-and-drop-with-react-beautiful-dnd) +- [Setup a react environment with `create-react-app`](https://egghead.io/lessons/react-set-up-a-react-environment-with-create-react-app?af=2jc3e4) - from our [free getting started course](https://egghead.io/courses/beautiful-and-accessible-drag-and-drop-with-react-beautiful-dnd?af=2jc3e4) ## Distribution bundle diff --git a/docs/api/drag-drop-context.md b/docs/api/drag-drop-context.md index b7e679d3e6..cd2f2ad66d 100644 --- a/docs/api/drag-drop-context.md +++ b/docs/api/drag-drop-context.md @@ -1,6 +1,6 @@ # `` -In order to use drag and drop, you need to have the part of your `React` tree that you want to be able to use drag and drop in wrapped in a ``. It is advised to just wrap your entire application in a ``. Having nested ``'s is _not_ supported. You will be able to achieve your desired conditional dragging and dropping using the props of `` and ``. You can think of `` as having a similar purpose to the [react-redux Provider component](https://react-redux.js.org/api/provider) +In order to use drag and drop, you need to have the part of your `React` tree that you want to be able to use drag and drop in wrapped in a ``. It is advised to just wrap your entire application in a ``. Having nested ``'s is _not_ supported. You will be able to achieve your desired conditional dragging and dropping using the props of `` and ``. You can think of `` as having a similar purpose to the [react-redux Provider component](https://react-redux.js.org/api/provider). A content-security-protection nonce attribute is added to the injected style tags if provided. ## Props @@ -18,10 +18,24 @@ import type { Node } from 'react'; type Props = {| ...Responders, - children: ?Node, + // We do not technically need any children for this component + children: Node | null, + // Read out by screen readers when focusing on a drag handle + liftInstruction?: string, + // Used for strict content security policies + nonce?: string, + // Used for custom sensors + sensors?: Sensor[], + enableDefaultSensors?: ?boolean, |}; ``` +- `liftInstruction`: What is read out to screen reader users when a *drag handle* is given browser focus. See our [screen reader guide](/docs/guides/screen-reader.md) +- `nonce`: Used for strict content security policy setups. See our [content security policy guide](/docs/guides/content-security-policy.md) +- `sensors`: Used to pass in your own `sensor`s for a ``. See our [sensor api documentation](/docs/sensors/sensor-api.md) +- `enableDefaultSensors`: Whether or not the default sensors ([mouse](/docs/sensors/mouse.md), [keyboard](/docs/sensors/keyboard.md), and [touch](/docs/sensors/touch.md)) are enabled. See our [sensor api documentation](/docs/sensors/sensor-api.md) + + > See our [type guide](/docs/guides/types.md) for more details ## Basic usage @@ -104,4 +118,8 @@ Responders are top level application events that you can use to perform your own [Please see our Responders guide](/docs/guides/responders.md) for detailed information about responders ❤️ +## `liftInstruction` + +This is text used as the screen reader lift instruction for *drag-handle*s. We will use our default english message if no liftInstruction is provided. See our [screen reader guide](/docs/guides/screen-reader.md) + [← Back to documentation](/README.md#documentation-) diff --git a/docs/api/draggable.md b/docs/api/draggable.md index b75de9b42d..4e06bfea9b 100644 --- a/docs/api/draggable.md +++ b/docs/api/draggable.md @@ -29,7 +29,7 @@ type Props = {| // required draggableId: DraggableId, index: number, - children: (DraggableProvided, DraggableStateSnapshot) => Node, + children: DraggableChildrenFn, // optional isDragDisabled: ?boolean, disableInteractiveElementBlocking: ?boolean, @@ -42,7 +42,16 @@ type Props = {| > `react-beautiful-dnd` will throw an error if a required prop is not provided - `draggableId`: A _required_ `DraggableId(string)`. See our [identifiers guide](/docs/guides/identifiers.md) for more information. -- `index`: A _required_ `number` that matches the order of the `` in the ``. It is simply the index of the `` in the list. The `index` needs to be unique within a `` but does not need to be unique between `Droppables`. The `index`s in a list must start from `0` and be consecutive. `[0, 1, 2]` and not `[1, 2, 8]`. Typically the `index` value will simply be the `index` provided by a `Array.prototype.map` function: +- `index`: A _required_ `number` that matches the order of the `` in the ``. It is simply the index of the `` in the list. + +`index` rule: + +- Must be unique within a `` (no duplicates) +- Must be consecutive. `[0, 1, 2]` and not `[1, 2, 8]` + +Indexes do not need to start from `0` (this is often the case in [virtual lists](/docs/patterns/virtual-lists.md)). In development mode we will log warnings to the `console` if any of these rules are violated. See [Setup problem detection and error recovery](/docs/guides/setup-problem-detection-and-error-recovery.md) + +Typically the `index` value will simply be the `index` provided by a `Array.prototype.map` function: ```js { @@ -86,7 +95,11 @@ The `React` children of a `` must be a function that returns a `Rea ``` -The function is provided with two arguments: +```js +type DraggableChildrenFn = (DraggableProvided, DraggableStateSnapshot, DraggableRubric) => Node +``` + +The function is provided with three arguments: ### 1. provided: (DraggableProvided) @@ -127,7 +140,10 @@ export type DraggableProps = {| // inline style style: ?DraggableStyle, // used for shared global styles - 'data-react-beautiful-dnd-draggable': string, + 'data-rbd-draggable-context-id': string, + 'data-rbd-draggable-id': string, + // used to know when a transition ends + onTransitionEnd: ?(event: TransitionEvent) => void, |}; ``` @@ -193,7 +209,7 @@ It is a contract of this library that it owns the positioning logic of the dragg `react-beautiful-dnd` uses `position: fixed` to position the dragging element. This is quite robust and allows for you to have `position: relative | absolute | fixed` parents. However, unfortunately `position:fixed` is [impacted by `transform`](http://meyerweb.com/eric/thoughts/2011/09/12/un-fixing-fixed-elements-with-css-transforms/) (such as `transform: rotate(10deg);`). This means that if you have a `transform: *` on one of the parents of a `` then the positioning logic will be incorrect while dragging. Lame! For most consumers this will not be an issue. -To get around this you can use [`ReactDOM.createPortal`](https://reactjs.org/docs/portals.html). We do not enable this functionality by default as it has performance problems. We have a [using a portal guide](/docs/patterns/using-a-portal.md) explaining the performance problem in more detail and how you can set up your own `ReactDOM.createPortal` if you want to. +To get around this you can [reparent your ](/docs/guides/reparenting.md). We do not enable this functionality by default as it has performance problems. #### Force press @@ -217,14 +233,9 @@ If the user force presses on the element before they have moved the element (eve Any force press action will cancel an existing or pending drag -#### Focus retention when moving between lists - -When moving a `` from one list to another the default browser behaviour is for the _drag handle_ element to lose focus. This is because the old element is being destroyed and a new one is being created. The loss of focus is not good when dragging with a keyboard as the user is then unable to continue to interact with the element. To improve this user experience we automatically give a _drag handle_ focus when: +#### Focus retention -- It was unmounted at the end of a drag -- It had focus -- It is enabled when mounted -- No other elements have gained browser focus before the drag handle has mounted +See [our focus guide](/docs/guides/browser-focus.md) #### Extending `DraggableProps.style` @@ -311,14 +322,19 @@ It is an assumption that ``s are _visible siblings_ of one another. ```js type DragHandleProps = {| - onFocus: () => void, - onBlur: () => void, - onMouseDown: (event: MouseEvent) => void, - onKeyDown: (event: KeyboardEvent) => void, - onTouchStart: (event: TouchEvent) => void, - 'data-react-beautiful-dnd-drag-handle': string, - 'aria-roledescription': string, + // what draggable the handle belongs to + 'data-rbd-drag-handle-draggable-id': DraggableId, + + // What DragDropContext the drag handle is in + 'data-rbd-drag-handle-context-id': ContextId, + + // Id of hidden element that contains the lift instruction (nicer screen reader text) + 'aria-labelledby': ElementId, + + // Allow tabbing to this element tabIndex: number, + + // Stop html5 drag and drop draggable: boolean, onDragStart: (event: DragEvent) => void, |}; @@ -355,42 +371,6 @@ Controlling a whole draggable by just a part of it ``` -#### `dragHandleProps` monkey patching - -You can override some of the `dragHandleProps` props with your own behavior if you need to. - -```js -const myOnMouseDown = event => console.log('mouse down on', event.target); - - - {(provided, snapshot) => { - const onMouseDown = (() => { - // dragHandleProps might be null - if (!provided.dragHandleProps) { - return onMouseDown; - } - - // creating a new onMouseDown function that calls myOnMouseDown as well as the drag handle one. - return event => { - provided.dragHandleProps.onMouseDown(event); - myOnMouseDown(event); - }; - })(); - - return ( -
- Drag me! -
- ); - }} -
; -``` - ### 2. Snapshot: (DraggableStateSnapshot) ```js @@ -444,6 +424,18 @@ The `children` function is also provided with a small amount of state relating t
``` +### 3. rubric: (DraggableRubric) + +```js +type DraggableRubric = {| + draggableId: DraggableId, + type: TypeId, + source: DraggableLocation, +|}; +``` + +`rubric` represents all of the information associated with a ``. `rubric` is helpful for looking up the data associated with your `` when it is not available in the current scope. This is useful when using the ` | renderClone` API. The `rubric` is the same lookup information that is provided to the [`Responder`s](/docs/guides/responders.md). + ## Adding an `onClick` handler to a `` or a _drag handle_ You are welcome to add your own `onClick` handler to a `` or a _drag handle_ (which might be the same element). `onClick` events handlers will always be called if a click occurred. If we are preventing the click, then the `event.defaultPrevented` property will be set to `true`. We prevent click events from occurring when the user was dragging an item. See [sloppy clicks and click prevention](/docs/sensors/mouse.md#sloppy-clicks-and-click-prevention-) for more information. diff --git a/docs/api/droppable.md b/docs/api/droppable.md index 00a615dca4..b7358c4375 100644 --- a/docs/api/droppable.md +++ b/docs/api/droppable.md @@ -29,12 +29,18 @@ type Props = {| droppableId: DroppableId, // optional type?: TypeId, + mode?: DroppableMode, isDropDisabled?: boolean, isCombineEnabled?: boolean, direction?: Direction, ignoreContainerClipping?: boolean, - children: (Provided, StateSnapshot) => Node, + renderClone?: DraggableChildrenFn, + getContainerForClone?: () => HTMLElement, + children: (DroppableProvided, DroppableStateSnapshot) => Node, |}; + +type DroppableMode = 'standard' | 'virtual'; +type Direction = 'horizontal' | 'vertical'; ``` ### Required props @@ -50,6 +56,9 @@ type Props = {| - `isCombineEnabled`: A flag to control whether or not _all_ the `Draggables` in the list will be able to be **combined** with. It will default to `false`. - `direction`: The direction in which items flow in this droppable. Options are `vertical` (default) and `horizontal`. - `ignoreContainerClipping`: When a `` is inside a scrollable container its area is constrained so that you can only drop on the part of the `` that you can see. Setting this prop opts out of this behavior, allowing you to drop anywhere on a `` even if it's visually hidden by a scrollable parent. The default behavior is suitable for most cases so odds are you'll never need to use this prop, but it can be useful if you've got very long ``s inside a short scroll container. Keep in mind that it might cause some unexpected behavior if you have multiple ``s inside scroll containers on the same page. +- `mode`: `standard` (default) or `virtual`. Used to designate a list as a virtual list. See our [virtual lists pattern](/docs/patterns/virtual-lists.md) +- `renderClone`: used to render a clone (replacement) of the dragging `` while a drag is occurring. See our [reparenting guide](/docs/guides/reparenting.md) for usage details. **A clone must be used for [virtual lists](/docs/patterns/virtual-lists.md).** You can use a clone without using virtual lists +- `getContainerForClone`: a function that returns the containing element (parent element) for a clone during a drag. See our [reparenting guide](/docs/guides/reparenting.md). ## Children function @@ -68,15 +77,19 @@ The function is provided with two arguments: ### 1. provided: (DroppableProvided) ```js +import type { Node } from 'react'; + type DroppableProvided = {| innerRef: (?HTMLElement) => void, droppableProps: DroppableProps, - placeholder: ?ReactElement, + placeholder: ?Node, |}; type DroppableProps = {| // used for shared global styles - 'data-react-beautiful-dnd-droppable': string, + 'data-rbd-droppable-context-id': ContextId, + // Used to lookup. Currently not used for drag and drop lifecycle + 'data-rbd-droppable-id': DroppableId, |}; ``` @@ -85,7 +98,7 @@ type DroppableProps = {| > For more information on using `innerRef` see our [using `innerRef` guide](/docs/guides/using-inner-ref.md) - `provided.placeholder`: This is used to create space in the `` as needed during a drag. This space is needed when a user is dragging over a list that is not the home list. Please be sure to put the placeholder inside of the component for which you have provided the ref. We need to increase the size of the `` itself. -- `provided.droppableProps (DroppableProps)`: This is an Object that contains properties that need to be applied to a Droppable element. It needs to be applied to the same element that you apply `provided.innerRef` to. It currently contains a `data` attribute that we use to control some non-visible css. +- `provided.droppableProps (DroppableProps)`: This is an Object that contains properties that need to be applied to a Droppable element. It needs to be applied to the same element that you apply `provided.innerRef` to. It currently contains `data` attributes that we use for styling and lookups. ```js @@ -109,6 +122,10 @@ type DroppableStateSnapshot = {| // What is the id of the draggable that is dragging from this list? // Useful for styling the home list when not being dragged over draggingFromThisWith: ?DraggableId, + // Whether or not the placeholder is actively being used. + // This is useful information when working with virtual lists + // (See our virtual list pattern) + isUsingPlaceholder: boolean, |}; ``` @@ -196,7 +213,7 @@ const getBackgroundColor = (snapshot: DroppableStateSnapshot): string => { ## Recommended `` performance optimisation -> 📺 This optimisation is covered in a [free lesson of our getting started course](https://egghead.io/lessons/react-optimize-performance-in-react-beautiful-dnd-with-shouldcomponentupdate-and-purecomponent) +> 📺 This optimisation is covered in a [free lesson of our getting started course](https://egghead.io/lessons/react-optimize-performance-in-react-beautiful-dnd-with-shouldcomponentupdate-and-purecomponent?af=2jc3e4) When a user drags over, or stops dragging over, a `` we re-render the `` with an updated `DroppableStateSnapshot > isDraggingOver` value. This is useful for styling the ``. However, by default this will cause a render of all of the children of the `` - which might be 100's of ``s! This can result in a noticeable frame rate drop. To avoid this problem we recommend that you create a component that is the child of a `` whose responsibility it is to avoid rendering children if it is not required. diff --git a/docs/guides/avoiding-image-flickering.md b/docs/guides/avoiding-image-flickering.md new file mode 100644 index 0000000000..b938c10473 --- /dev/null +++ b/docs/guides/avoiding-image-flickering.md @@ -0,0 +1,51 @@ +# Avoiding image flickering + +> Often all you need to do is close your browsers dev tools! See 'HTTP cache headers below' + +If your `` has a image inside of it, then you might notice that sometimes it flashes sometimes. Why is that? + +Some behaviours will cause a `` to be **recreated**. This is where the original DOM element is destroyed, and a new DOM element is created. When the new DOM element is inserted into the DOM then the browser will try to load the image from scratch. Image flashing is caused by the gap between the new element being inserted into the DOM and the image being loaded. + +These are the actions that can cause a `` to be recreated: + +- [Reparenting](/docs/guides/reparenting.md) a `` (using the cloning api, or using your own portal) +- Moving `` into a new list. React will not shift the original element. It will recreate one. + +## How can you prevent image flickering? + +The big idea is you want to allow the browser to load the image **instantly** when it is recreated. + +Here are some ways you can do that: + +### HTTP cache headers + +> When you open devtools, it can disable HTTP caching. So simply closing devtools might make your image flickering go away! 🤘 + +Generally speaking, a browser will not request an image that it already has cached and it will load instantly. You can use the [HTTP cache headers](https://devcenter.heroku.com/articles/increasing-application-performance-with-http-cache-headers) to tell a browser that an image can be cached. Ultimately the browser can decide to re-request the image if it wants to, but that would be an edge case. + +We put together an [example on Glitch](https://glitch.com/~image-flickering) that shows off the impact of using HTTP cache headers. + +### Inline your images + +[Base64 encode](https://stackoverflow.com/questions/201479/what-is-base-64-encoding-used-for) your images and use that as the source. That way there is no need to talk to the server to get the source. + +```diff +- + ++ +``` + +You can use the [webpack `url-loader`](https://github.com/webpack-contrib/url-loader) to help. + +#### Drawbacks of this approach + +- If the same image is used in multiple places, they all need to be downloaded independently +- The browser cannot defer image loading + +You will want to keep your image sizes fairly small. + +### Really anything else! + +The big idea is that you don't want to be calling the server to refetch an image that has already been fetched. So anything you can use to do client side caching of images is fine. You could even use [service workers](https://developers.google.com/web/ilt/pwa/caching-files-with-service-worker) if you want! + +[← Back to documentation](/README.md#documentation-) diff --git a/docs/guides/browser-focus.md b/docs/guides/browser-focus.md new file mode 100644 index 0000000000..638a262fb7 --- /dev/null +++ b/docs/guides/browser-focus.md @@ -0,0 +1,29 @@ +# Browser focus + +> "You got to focus on what's real, man" - [Jake from Adventure time](https://www.youtube.com/watch?v=TFGz6Qvg1CE) + +`react-beautiful-dnd` includes logic to maintain browser focus for _drag handles_. This especially important for [keyboard dragging](/docs/sensors/keyboard.md) which requires the dragging item to be focused. + +## Terminology reminder 📖 + +A `` has a _drag handle_. A _drag handle_ is the part of the `` that controls the dragging of the whole ``. A _drag handle_ can be the same element as the `` + +## Drag handle not focused at drag start + +If the _drag handle_ is not focused when a drag starts then **focus is not given** to the dragging item. This is a mirror of the native HTML5 drag and drop behaviour which does not give focus to an item just because it is dragging. You are welcome to call `HTMLElement.focus()` when a drag starts to give it focus, but that is up to you. + +## Drag handle is focused at drag start + +If a _drag handle_ has browser focus when a drag starts then `rbd` will try to give focus to the _drag handle_ during a drag and just after a drag ends. + +Here is what is done: + +- Give focus to a _drag handle_ with a matching `DraggableId` after the drag starts. This might be a different element to the original _drag handle_ if you are [reparenting your ``](/docs/guides/reparenting.md). +- Give focus to a _drag handle_ with a matching `DraggableId` after the drag ends. Sometimes the original _drag handle_ element is lost during a drag, such as when [reparenting your ``](/docs/guides/reparenting.md), or when moving a `` from one list to another as `React` will recreate the element. +- If [combining](/docs/guides/combining.md) then focus is given to the combine target after a drag ends. This allows keyboard users to continue to engage with the application without needing to get the focus back to where they where the last interaction was + +## Browser testing + +Fun fact: we test this behaviour using [`cypress.io`](http://cypress.io) to ensure that focus management behaves as we expect + +[← Back to documentation](/README.md#documentation-) diff --git a/docs/guides/changes-while-dragging.md b/docs/guides/changes-while-dragging.md index 3b561e8e50..1b818d3955 100644 --- a/docs/guides/changes-while-dragging.md +++ b/docs/guides/changes-while-dragging.md @@ -1,7 +1,8 @@ # Changes while dragging -> ⚠️ This is fairly advanced behavior -> 👶 This feature is still quite young. The circumstances that we support are fairly limited. We wanted to get it out there for people to play with +> For virtual list support see our [virtual list pattern](/docs/patterns/virtual-lists.md) + +> ❌ **This behaviour is only supported in `11.x`**. We do plan on supporting this type of behaviour again in a future `minor` release. We needed to cut this existing behaviour order to get `12.x` across the line. Going forward, tree behaviour will be supported on the latest version. We know this sucks, but we thought it better to move things forward. `react-beautiful-dnd` supports the addition and removal of ``s during a drag. diff --git a/docs/guides/common-setup-issues.md b/docs/guides/common-setup-issues.md index 5c4073c005..f5321bb4b0 100644 --- a/docs/guides/common-setup-issues.md +++ b/docs/guides/common-setup-issues.md @@ -24,9 +24,10 @@ More information: [identifiers guide](/docs/guides/identifiers.md) Rules: -- Must start from `0` +- Must be unique within a `` (no duplicates) - Must be consecutive. `[0, 1, 2]` and not `[1, 2, 8]` -- Must be unique with a `` (no duplicates) + +Indexes do not need to start from `0` (this is often the case in [virtual lists](/docs/patterns/virtual-lists.md)) [More information](/docs/api/draggable.md#draggable-props) @@ -67,6 +68,10 @@ return items.map((item, index) => ( We recommend you set a `min-height` or `min-width` on a `` to ensure that there is a visible drop target when a list is empty -We go over this in our [Get started with `react-beautiful-dnd` course](https://egghead.io/lessons/react-move-items-between-columns-with-react-beautiful-dnd-using-ondragend) +We go over this in our [Get started with `react-beautiful-dnd` course](https://egghead.io/lessons/react-move-items-between-columns-with-react-beautiful-dnd-using-ondragend?af=2jc3e4) + +## Image flickering in a `` + +See our [avoiding image flickering guide](/docs/guides/avoiding-image-flickering.md) [← Back to documentation](/README.md#documentation-) diff --git a/docs/guides/content-security-policy.md b/docs/guides/content-security-policy.md new file mode 100644 index 0000000000..8cd55a7305 --- /dev/null +++ b/docs/guides/content-security-policy.md @@ -0,0 +1,37 @@ +# Content Security Policy + +> This page is to help you get around the CSP error: "Refused to apply inline style because it violates the following Content Security Policy directive: "style-src 'self'"." +> A huge thankyou to [@Zweder](https://github.com/Zweder) for driving this effort + +Content Security Policy (CSP) is a way of controlling where a browser can download assets from, as well as what those assets are allowed to do. + +Background reading on CSP + +- [Google guide](https://developer.chrome.com/extensions/contentSecurityPolicy) +- [MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) +- [Helmetjs guide](https://helmetjs.github.io/docs/csp/) + +`react-beautiful-dnd` creates a `