Skip to content

Commit

Permalink
Maintain focus when moving between lists (#449)
Browse files Browse the repository at this point in the history
  • Loading branch information
alexreardon authored Apr 19, 2018
1 parent 78c30a0 commit f966b6f
Show file tree
Hide file tree
Showing 40 changed files with 718 additions and 244 deletions.
22 changes: 11 additions & 11 deletions .size-snapshot.json
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
{
"dist/react-beautiful-dnd.js": {
"bundled": 399839,
"minified": 149239,
"gzipped": 42623
"bundled": 402551,
"minified": 150440,
"gzipped": 42949
},
"dist/react-beautiful-dnd.min.js": {
"bundled": 358155,
"minified": 132319,
"gzipped": 37652
"bundled": 360867,
"minified": 133520,
"gzipped": 37980
},
"dist/react-beautiful-dnd.esm.js": {
"bundled": 176656,
"minified": 88917,
"gzipped": 22577,
"bundled": 179282,
"minified": 90441,
"gzipped": 22922,
"treeshaked": {
"rollup": 77471,
"webpack": 79091
"rollup": 78671,
"webpack": 80293
}
}
}
15 changes: 4 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,6 @@ You can check out all the features that will be landing soon [on our issue page]

There are a lot of libraries out there that allow for drag and drop interactions within React. Most notable of these is the amazing [`react-dnd`](https://github.com/react-dnd/react-dnd). It does an incredible job at providing a great set of drag and drop primitives which work especially well with the [wildly inconsistent](https://www.quirksmode.org/blog/archives/2009/09/the_html5_drag.html) html5 drag and drop feature. **`react-beautiful-dnd` is a higher level abstraction specifically built for vertical and horizontal lists**. Within that subset of functionality `react-beautiful-dnd` offers a powerful, natural and beautiful drag and drop experience. However, it does not provide the breadth of functionality offered by react-dnd. So this library might not be for you depending on what your use case is.

`react-beautiful-dnd` [uses `position: fixed` to position the dragging element](#positioning-ownership). In some layouts, this might break how the element is rendered. One example is a `<table>`-based layout which will lose column widths for dragged `<tr>`s. Follow [#103](https://github.com/atlassian/react-beautiful-dnd/issues/103) for updates on support for this use case.

## Driving philosophy: physicality

The core design idea of `react-beautiful-dnd` is physicality: we want users to feel like they are moving physical objects around
Expand Down Expand Up @@ -692,15 +690,6 @@ Here are a few poor user experiences that can occur if you change things *during
- If you remove the node that the user is dragging, then the drag will instantly end
- If you change the dimension of the dragging node, then other things will not move out of the way at the correct time.
#### Force focus after a transition between lists
When an item is moved from one list to a different list, it loses browser focus if it had it. This is because `React` creates a new node in this situation. It will not lose focus if transitioned within the same list. The dragging item will always have had browser focus if it is dragging with a keyboard. It is highly recommended that you give the item (which is now in a different list) focus again. You can see an example of how to do this in our stories. Here is an example of how you could do it:
- `onDragEnd`: move the item into the new list and record the id of the item that has moved
- When rendering the reordered list, pass down a prop which will tell the newly moved item to obtain focus
- In the `componentDidMount` lifecycle call back check if the item needs to gain focus based on its props (such as an `autoFocus` prop)
- If focus is required - call `.focus` on the node. You can obtain the node by using `ReactDOM.findDOMNode` or monkey patching the `provided.innerRef` callback.
### `onDragStart` and `onDragEnd` pairing
We try very hard to ensure that each `onDragStart` event is paired with a single `onDragEnd` event. However, there maybe a rogue situation where this is not the case. If that occurs - it is a bug. Currently there is no mechanism to tell the library to cancel a current drag externally.
Expand Down Expand Up @@ -1027,6 +1016,10 @@ It is a contract of this library that it owns the positioning logic of the dragg
To get around this you can use [`React.Portal`](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](/guides/using-a-portal.md) explaining the performance problem in more detail and how you can set up your own `React.Portal` if you want to.
##### Focus retention when moving between lists
When moving a `Draggable` from one list to another the default browser behaviour is for the *drag handle* element to loose 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 give a *drag handle* focus as it mounts if it had browser focus when it unmounted and nothing else has obtained browser focus.
##### Extending `DraggableProps.style`
If you are using inline styles you are welcome to extend the `DraggableProps.style` object. You are also welcome to apply the `DraggableProps.style` object using inline styles and use your own styling solution for the component itself - such as [styled-components](https://github.com/styled-components/styled-components).
Expand Down
5 changes: 5 additions & 0 deletions src/view/data-attributes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// @flow
export const prefix: string = 'data-react-beautiful-dnd';
export const dragHandle: string = `${prefix}-drag-handle`;
export const draggable: string = `${prefix}-draggable`;
export const droppable: string = `${prefix}-droppable`;
4 changes: 2 additions & 2 deletions src/view/drag-handle/drag-handle-types.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ import type {
} from '../../types';

export type Callbacks = {|
onFocus: () => void,
onBlur: () => void,
onLift: ({ client: Position, autoScrollMode: AutoScrollMode }) => void,
onMove: (point: Position) => void,
onWindowScroll: () => void,
Expand Down Expand Up @@ -54,6 +52,8 @@ export type Props = {|
isEnabled: boolean,
// whether the application thinks a drag is occurring
isDragging: boolean,
// whether the application thinks a drop is occurring
isDropAnimating: boolean,
// the direction of the current droppable
direction: ?Direction,
// get the ref of the draggable
Expand Down
105 changes: 93 additions & 12 deletions src/view/drag-handle/drag-handle.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
import { Component } from 'react';
import PropTypes from 'prop-types';
import memoizeOne from 'memoize-one';
import invariant from 'tiny-invariant';
import getWindowFromRef from '../get-window-from-ref';
import getDragHandleRef from './util/get-drag-handle-ref';
import type {
Props,
DragHandleProps,
Expand All @@ -16,6 +19,7 @@ import type {
DraggableId,
} from '../../types';
import { styleContextKey, canLiftContextKey } 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';
Expand All @@ -35,6 +39,8 @@ export default class DragHandle extends Component<Props> {
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
Expand All @@ -46,9 +52,12 @@ export default class DragHandle extends Component<Props> {
constructor(props: Props, context: Object) {
super(props, context);

const getWindow = (): HTMLElement => getWindowFromRef(this.props.getDraggableRef());

const args: CreateSensorArgs = {
callbacks: this.props.callbacks,
getDraggableRef: this.props.getDraggableRef,
getWindow,
canStartCapturing: this.canStartCapturing,
};

Expand All @@ -71,20 +80,53 @@ export default class DragHandle extends Component<Props> {
this.canLift = context[canLiftContextKey];
}

componentWillUnmount() {
this.sensors.forEach((sensor: Sensor) => {
// kill the current drag and fire a cancel event if
const wasDragging = sensor.isDragging();
componentDidMount() {
const draggableRef: ?HTMLElement = this.props.getDraggableRef();

sensor.unmount();
// cancel if drag was occurring
if (wasDragging) {
this.props.callbacks.onCancel();
}
});
// storing a reference for later
this.lastDraggableRef = draggableRef;

if (!draggableRef) {
console.error('Cannot get draggable ref from drag handle');
return;
}

// drag handle ref will not be available when not enabled
if (!this.props.isEnabled) {
return;
}

const dragHandleRef: ?HTMLElement = getDragHandleRef(draggableRef);
invariant(dragHandleRef, 'DragHandle could not find drag handle element');

focusRetainer.tryRestoreFocus(this.props.draggableId, dragHandleRef);
}

componentDidUpdate(prevProps: Props) {
const ref: ?HTMLElement = this.props.getDraggableRef();
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 looses focus
// https://github.com/facebook/react/issues/12454

// No need to focus
if (!ref || !this.isFocused) {
return;
}

// No drag handle ref will be available to focus on
if (!this.props.isEnabled) {
return;
}

const dragHandleRef: ?HTMLElement = getDragHandleRef(ref);
invariant(dragHandleRef, 'DragHandle could not find drag handle element');

dragHandleRef.focus();
}

const isCapturing: boolean = this.isAnySensorCapturing();

if (!isCapturing) {
Expand Down Expand Up @@ -122,6 +164,45 @@ export default class DragHandle extends Component<Props> {
}
}

componentWillUnmount() {
this.sensors.forEach((sensor: Sensor) => {
// kill the current drag and fire a cancel event if
const wasDragging = 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 mouse sensor deal with it
if (this.mouseSensor.isCapturing()) {
Expand Down Expand Up @@ -177,8 +258,8 @@ export default class DragHandle extends Component<Props> {
onMouseDown: this.onMouseDown,
onKeyDown: this.onKeyDown,
onTouchStart: this.onTouchStart,
onFocus: this.props.callbacks.onFocus,
onBlur: this.props.callbacks.onBlur,
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
Expand Down
4 changes: 1 addition & 3 deletions src/view/drag-handle/sensor/create-keyboard-sensor.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import createScheduler from '../util/create-scheduler';
import preventStandardKeyEvents from '../util/prevent-standard-key-events';
import * as keyCodes from '../../key-codes';
import getWindowFromRef from '../../get-window-from-ref';
import getCenterPosition from '../../get-center-position';
import { bindEvents, unbindEvents } from '../util/bind-events';
import supportedPageVisibilityEventName from '../util/supported-page-visibility-event-name';
Expand Down Expand Up @@ -38,6 +37,7 @@ const noop = () => { };

export default ({
callbacks,
getWindow,
getDraggableRef,
canStartCapturing,
}: CreateSensorArgs): KeyboardSensor => {
Expand All @@ -47,8 +47,6 @@ export default ({
const setState = (newState: State): void => {
state = newState;
};
const getWindow = (): HTMLElement => getWindowFromRef(getDraggableRef());

const startDragging = (fn?: Function = noop) => {
setState({
isDragging: true,
Expand Down
4 changes: 1 addition & 3 deletions src/view/drag-handle/sensor/create-mouse-sensor.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
/* eslint-disable no-use-before-define */
import createScheduler from '../util/create-scheduler';
import isSloppyClickThresholdExceeded from '../util/is-sloppy-click-threshold-exceeded';
import getWindowFromRef from '../../get-window-from-ref';
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';
Expand Down Expand Up @@ -34,7 +33,7 @@ const mouseDownMarshal: EventMarshal = createEventMarshal();

export default ({
callbacks,
getDraggableRef,
getWindow,
canStartCapturing,
}: CreateSensorArgs): MouseSensor => {
let state: State = {
Expand All @@ -47,7 +46,6 @@ export default ({
const isDragging = (): boolean => state.isDragging;
const isCapturing = (): boolean => Boolean(state.pending || state.isDragging);
const schedule = createScheduler(callbacks);
const getWindow = (): HTMLElement => getWindowFromRef(getDraggableRef());
const postDragEventPreventer: EventPreventer = createPostDragEventPreventer(getWindow);

const startDragging = (fn?: Function = noop) => {
Expand Down
4 changes: 1 addition & 3 deletions src/view/drag-handle/sensor/create-touch-sensor.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
// @flow
/* eslint-disable no-use-before-define */
import createScheduler from '../util/create-scheduler';
import getWindowFromRef from '../../get-window-from-ref';
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';
Expand Down Expand Up @@ -98,12 +97,11 @@ const initial: State = {

export default ({
callbacks,
getDraggableRef,
getWindow,
canStartCapturing,
}: CreateSensorArgs): TouchSensor => {
let state: State = initial;

const getWindow = (): HTMLElement => getWindowFromRef(getDraggableRef());
const setState = (partial: Object): void => {
state = {
...state,
Expand Down
1 change: 1 addition & 0 deletions src/view/drag-handle/sensor/sensor-types.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type SensorBase = {|
export type CreateSensorArgs = {|
callbacks: Callbacks,
getDraggableRef: () => ?HTMLElement,
getWindow: () => HTMLElement,
canStartCapturing: (event: Event) => boolean,
|}

Expand Down
Loading

0 comments on commit f966b6f

Please sign in to comment.