Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Maintain focus when moving between lists #449

Merged
merged 17 commits into from
Apr 19, 2018
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 4 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -692,15 +692,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
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We now do this out of the box


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 +1018,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
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This explains the rationale for the change

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not convinced that this information is useful for the user. I would prefer to see this as a code comment in the context of the logic.

Copy link
Collaborator Author

@alexreardon alexreardon Apr 18, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that we are controlling the focus I would like to call it out. I have found that calling behaviour out leads to the least confusion (and issues raised). For example: how we use dom events

Maintaining focus across list movements is a feature of the library that it is not native to react or the dom

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We call out other pieces of information like this through the docs

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To me it seems like this change makes the focus behave more intuitively. What do you think about moving this section into the "how we use dom events" page? If you think this is important enough to have in the README I am happy to leave it as is.


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.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s/loose/lose

##### 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
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rather than repeating these everywhere i pulled them out

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`;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flow question, do these need to be typed as string or can the type be inferred from the assignment since they are constants?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i generally type every variable even if it can be inferred

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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Would it be better to name this isDropping to be consistent with the isDragging prop?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps! We currently use this naming in multiple places so i i'll leave it for now

// the direction of the current droppable
direction: ?Direction,
// get the ref of the draggable
Expand Down
80 changes: 68 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,8 @@
import { Component } from 'react';
import PropTypes from 'prop-types';
import memoizeOne from 'memoize-one';
import getWindowFromRef from '../get-window-from-ref';
import getDragHandleRef from './util/get-drag-handle-ref';
import type {
Props,
DragHandleProps,
Expand All @@ -16,6 +18,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 +38,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 +51,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 +79,33 @@ 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;
}

focusRetainer.tryRestoreFocus(this.props.draggableId, getDragHandleRef(draggableRef));
}

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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s/looses/loses

// https://github.com/facebook/react/issues/12454
if (ref && this.isFocused) {
getDragHandleRef(ref).focus();
}
}

const isCapturing: boolean = this.isAnySensorCapturing();

if (!isCapturing) {
Expand Down Expand Up @@ -122,6 +143,41 @@ 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 = (() => {
// 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 +233,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
81 changes: 81 additions & 0 deletions src/view/drag-handle/util/focus-retainer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// @flow
import getDragHandleRef from './get-drag-handle-ref';
import type { DraggableId } from '../../../types';

type FocusRetainer = {|
retain: (draggableId: DraggableId) => void,
tryRestoreFocus: (draggableId: DraggableId, draggableRef: HTMLElement) => void,
|}

// our shared state
let retainingFocusFor: ?DraggableId = null;

// If we focus on
const clearRetentionOnFocusChange = (() => {
let isBound: boolean = false;

const bind = () => {
if (isBound) {
return;
}

isBound = true;
// Using capture: true as focus events do not bubble
// Additionally doing this prevents us from intercepting the initial
// focus event as it does not bubble up to this listener
// eslint-disable-next-line no-use-before-define
window.addEventListener('focus', onWindowFocusChange, { capture: true });
};

const unbind = () => {
if (!isBound) {
return;
}

isBound = false;
// eslint-disable-next-line no-use-before-define
window.removeEventListener('focus', onWindowFocusChange, { capture: true });
};

// focusin will fire after the focus event fires on the element
const onWindowFocusChange = () => {
// unbinding self after single use
unbind();
retainingFocusFor = null;
};

const result = () => bind();
result.cancel = () => unbind();

return result;
})();

const retain = (id: DraggableId) => {
retainingFocusFor = id;
clearRetentionOnFocusChange();
};

const tryRestoreFocus = (id: DraggableId, draggableRef: HTMLElement) => {
// Not needing to retain focus
if (!retainingFocusFor) {
return;
}
// Not needing to retain focus for this draggable
if (id !== retainingFocusFor) {
return;
}

// We are about to force force onto a drag handle
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s/force force/force focus/?


retainingFocusFor = null;
// no need to clear it - we are already clearing it
clearRetentionOnFocusChange.cancel();
getDragHandleRef(draggableRef).focus();
};

const retainer: FocusRetainer = {
retain,
tryRestoreFocus,
};

export default retainer;
22 changes: 22 additions & 0 deletions src/view/drag-handle/util/get-drag-handle-ref.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// @flow
import invariant from 'tiny-invariant';
import { dragHandle } from '../../data-attributes';

const selector: string = `[${dragHandle}]`;

const getDragHandleRef = (draggableRef: HTMLElement): HTMLElement => {
if (draggableRef.hasAttribute(dragHandle)) {
return draggableRef;
}

// find the first nested drag handle
// querySelector will return the first match on a breadth first search which is what we want
// https://codepen.io/alexreardon/pen/erOqyZ
const el: ?HTMLElement = draggableRef.querySelector(selector);

invariant(el, 'Could not find draggable ref');

return el;
};

export default getDragHandleRef;
Loading