Skip to content

Commit 1be5939

Browse files
devongovettMichael JordanLFDanLudannify
authored
Handle dragging with NVDA/JAWS in browse mode (#3524)
* Handle dragging with NVDA/JAWS in browse mode * Don't allow other click events on standalone draggable elements * Update packages/@react-aria/dnd/src/useDraggableItem.ts Co-authored-by: Michael Jordan <[email protected]> * fix lint error after merge from main * Fix merge * Update isVirtualEvent.ts * Move drop on Enter to key up * Update useDrag.ts * Fix test Co-authored-by: Michael Jordan <[email protected]> Co-authored-by: Daniel Lu <[email protected]> Co-authored-by: Danni <[email protected]>
1 parent b10ddaa commit 1be5939

File tree

10 files changed

+110
-100
lines changed

10 files changed

+110
-100
lines changed

packages/@react-aria/dnd/src/DragManager.ts

Lines changed: 17 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {ariaHideOutside} from '@react-aria/overlays';
1515
import {DragEndEvent, DragItem, DropActivateEvent, DropEnterEvent, DropEvent, DropExitEvent, DropItem, DropOperation, DropTarget as DroppableCollectionTarget, FocusableElement} from '@react-types/shared';
1616
import {flushSync} from 'react-dom';
1717
import {getDragModality, getTypes} from './utils';
18+
import {isVirtualClick, isVirtualPointerEvent} from '@react-aria/utils';
1819
import type {LocalizedStringFormatter} from '@internationalized/string';
1920
import {useEffect, useState} from 'react';
2021

@@ -135,7 +136,6 @@ const CANCELED_EVENTS = [
135136
'touchstart',
136137
'touchmove',
137138
'touchend',
138-
'keyup',
139139
'focusin',
140140
'focusout'
141141
];
@@ -169,6 +169,7 @@ class DragSession {
169169
this.stringFormatter = stringFormatter;
170170

171171
this.onKeyDown = this.onKeyDown.bind(this);
172+
this.onKeyUp = this.onKeyUp.bind(this);
172173
this.onFocus = this.onFocus.bind(this);
173174
this.onBlur = this.onBlur.bind(this);
174175
this.onClick = this.onClick.bind(this);
@@ -179,6 +180,7 @@ class DragSession {
179180

180181
setup() {
181182
document.addEventListener('keydown', this.onKeyDown, true);
183+
document.addEventListener('keyup', this.onKeyUp, true);
182184
window.addEventListener('focus', this.onFocus, true);
183185
window.addEventListener('blur', this.onBlur, true);
184186
document.addEventListener('click', this.onClick, true);
@@ -198,6 +200,7 @@ class DragSession {
198200

199201
teardown() {
200202
document.removeEventListener('keydown', this.onKeyDown, true);
203+
document.removeEventListener('keyup', this.onKeyUp, true);
201204
window.removeEventListener('focus', this.onFocus, true);
202205
window.removeEventListener('blur', this.onBlur, true);
203206
document.removeEventListener('click', this.onClick, true);
@@ -219,15 +222,6 @@ class DragSession {
219222
return;
220223
}
221224

222-
if (e.key === 'Enter') {
223-
if (e.altKey) {
224-
this.activate();
225-
} else {
226-
this.drop();
227-
}
228-
return;
229-
}
230-
231225
if (e.key === 'Tab' && !(e.metaKey || e.altKey || e.ctrlKey)) {
232226
if (e.shiftKey) {
233227
this.previous();
@@ -241,6 +235,18 @@ class DragSession {
241235
}
242236
}
243237

238+
onKeyUp(e: KeyboardEvent) {
239+
this.cancelEvent(e);
240+
241+
if (e.key === 'Enter') {
242+
if (e.altKey) {
243+
this.activate();
244+
} else {
245+
this.drop();
246+
}
247+
}
248+
}
249+
244250
onFocus(e: FocusEvent) {
245251
// Prevent focus events, except to the original drag target.
246252
if (e.target !== this.dragTarget.element) {
@@ -284,7 +290,7 @@ class DragSession {
284290

285291
onClick(e: MouseEvent) {
286292
this.cancelEvent(e);
287-
if (e.detail === 0 || this.isVirtualClick) {
293+
if (isVirtualClick(e) || this.isVirtualClick) {
288294
if (e.target === this.dragTarget.element) {
289295
this.cancel();
290296
return;
@@ -585,19 +591,3 @@ function findValidDropTargets(options: DragTarget) {
585591
return true;
586592
});
587593
}
588-
589-
function isVirtualPointerEvent(event: PointerEvent) {
590-
// If the pointer size is zero, then we assume it's from a screen reader.
591-
// Android TalkBack double tap will sometimes return a event with width and height of 1
592-
// and pointerType === 'mouse' so we need to check for a specific combination of event attributes.
593-
// Cannot use "event.pressure === 0" as the sole check due to Safari pointer events always returning pressure === 0
594-
// instead of .5, see https://bugs.webkit.org/show_bug.cgi?id=206216
595-
return (
596-
(event.width === 0 && event.height === 0) ||
597-
(event.width === 1 &&
598-
event.height === 1 &&
599-
event.pressure === 0 &&
600-
event.detail === 0
601-
)
602-
);
603-
}

packages/@react-aria/dnd/src/useDrag.ts

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {DROP_EFFECT_TO_DROP_OPERATION, DROP_OPERATION, EFFECT_ALLOWED} from './c
1818
import {globalDropEffect, setGlobalAllowedDropOperations, setGlobalDropEffect, useDragModality, writeToDataTransfer} from './utils';
1919
// @ts-ignore
2020
import intlMessages from '../intl/*.json';
21-
import {useDescription, useGlobalListeners, useLayoutEffect} from '@react-aria/utils';
21+
import {isVirtualClick, isVirtualPointerEvent, useDescription, useGlobalListeners, useLayoutEffect} from '@react-aria/utils';
2222
import {useLocalizedStringFormatter} from '@react-aria/i18n';
2323

2424
export interface DragOptions {
@@ -248,16 +248,7 @@ export function useDrag(options: DragOptions): DragResult {
248248
};
249249

250250
let modality = useDragModality();
251-
let message: string;
252-
if (!isDraggingRef.current) {
253-
if (modality === 'touch' && !hasDragButton) {
254-
message = 'dragDescriptionLongPress';
255-
} else {
256-
message = MESSAGES[modality].start;
257-
}
258-
} else {
259-
message = MESSAGES[modality].end;
260-
}
251+
let message = !isDraggingRef.current ? MESSAGES[modality].start : MESSAGES[modality].end;
261252

262253
let descriptionProps = useDescription(stringFormatter.format(message));
263254

@@ -273,7 +264,9 @@ export function useDrag(options: DragOptions): DragResult {
273264
interactions = {
274265
...descriptionProps,
275266
onPointerDown(e) {
276-
// Try to detect virtual drags.
267+
modalityOnPointerDown.current = isVirtualPointerEvent(e.nativeEvent) ? 'virtual' : e.pointerType;
268+
269+
// Try to detect virtual drag passthrough gestures.
277270
if (e.width < 1 && e.height < 1) {
278271
// iOS VoiceOver.
279272
modalityOnPointerDown.current = 'virtual';
@@ -284,7 +277,7 @@ export function useDrag(options: DragOptions): DragResult {
284277
let centerX = rect.width / 2;
285278
let centerY = rect.height / 2;
286279

287-
if (Math.abs(offsetX - centerX) < 0.5 && Math.abs(offsetY - centerY) < 0.5) {
280+
if (Math.abs(offsetX - centerX) <= 0.5 && Math.abs(offsetY - centerY) <= 0.5) {
288281
// Android TalkBack.
289282
modalityOnPointerDown.current = 'virtual';
290283
} else {
@@ -304,6 +297,14 @@ export function useDrag(options: DragOptions): DragResult {
304297
e.stopPropagation();
305298
startDragging(e.target as HTMLElement);
306299
}
300+
},
301+
onClick(e) {
302+
// Handle NVDA/JAWS in browse mode, and touch screen readers. In this case, no keyboard events are fired.
303+
if (isVirtualClick(e.nativeEvent) || modalityOnPointerDown.current === 'virtual') {
304+
e.preventDefault();
305+
e.stopPropagation();
306+
startDragging(e.target as HTMLElement);
307+
}
307308
}
308309
};
309310
}

packages/@react-aria/dnd/src/useDraggableItem.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ export function useDraggableItem(props: DraggableItemProps, state: DraggableColl
9292

9393
// Override description to include selected item count.
9494
let modality = useDragModality();
95-
if (!props.hasDragButton) {
95+
if (!props.hasDragButton && state.selectionManager.selectionMode !== 'none') {
9696
let msg = MESSAGES[modality][isSelected ? 'selected' : 'notSelected'];
9797
if (props.hasAction && modality === 'keyboard') {
9898
msg += 'Alt';
@@ -103,6 +103,10 @@ export function useDraggableItem(props: DraggableItemProps, state: DraggableColl
103103
} else {
104104
description = stringFormatter.format(msg);
105105
}
106+
107+
// Remove the onClick handler from useDrag. Long pressing will be required on touch devices,
108+
// and NVDA/JAWS are always in forms mode within collection components.
109+
delete dragProps.onClick;
106110
} else {
107111
if (isSelected) {
108112
dragButtonLabel = stringFormatter.format('dragSelectedItems', {count: numKeysForDrag});

packages/@react-aria/dnd/test/dnd.test.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1484,9 +1484,10 @@ describe('useDrag and useDrop', function () {
14841484
});
14851485

14861486
it('should cancel the drag when pressing Enter on the original drag target', () => {
1487+
let onDragStart = jest.fn();
14871488
let onDragEnd = jest.fn();
14881489
let tree = render(<>
1489-
<Draggable onDragEnd={onDragEnd} />
1490+
<Draggable onDragStart={onDragStart} onDragEnd={onDragEnd} />
14901491
<Droppable />
14911492
</>);
14921493

@@ -1500,15 +1501,18 @@ describe('useDrag and useDrop', function () {
15001501
fireEvent.keyUp(draggable, {key: 'Enter'});
15011502
act(() => jest.runAllTimers());
15021503
expect(document.activeElement).toBe(droppable);
1504+
expect(onDragStart).toHaveBeenCalledTimes(1);
15031505

15041506
userEvent.tab();
15051507
expect(document.activeElement).toBe(draggable);
15061508

15071509
fireEvent.keyDown(draggable, {key: 'Enter'});
15081510
fireEvent.keyUp(draggable, {key: 'Enter'});
1511+
act(() => jest.runAllTimers());
15091512
expect(document.activeElement).toBe(draggable);
15101513

15111514
expect(onDragEnd).toHaveBeenCalledTimes(1);
1515+
expect(onDragStart).toHaveBeenCalledTimes(1);
15121516
});
15131517

15141518
it('should handle when a drop target is removed', () => {
@@ -2023,6 +2027,7 @@ describe('useDrag and useDrop', function () {
20232027
let droppable = tree.getByText('Drop here');
20242028
let droppable2 = tree.getByText('Drop here 2');
20252029

2030+
act(() => draggable.focus());
20262031
fireEvent.focus(draggable);
20272032
expect(draggable).toHaveAttribute('aria-describedby');
20282033
expect(document.getElementById(draggable.getAttribute('aria-describedby'))).toHaveTextContent('Click to start dragging');
@@ -2421,7 +2426,7 @@ describe('useDrag and useDrop', function () {
24212426

24222427
let draggable = tree.getByText('Drag me');
24232428

2424-
fireEvent.focus(draggable);
2429+
act(() => draggable.focus());
24252430
fireEvent.click(draggable);
24262431
act(() => jest.runAllTimers());
24272432

@@ -2474,6 +2479,7 @@ describe('useDrag and useDrop', function () {
24742479
let draggable = tree.getByText('Drag me');
24752480
let droppable = tree.getByText('Drop here');
24762481

2482+
act(() => draggable.focus());
24772483
fireEvent.focus(draggable);
24782484
expect(draggable).toHaveAttribute('aria-describedby');
24792485
expect(document.getElementById(draggable.getAttribute('aria-describedby'))).toHaveTextContent('Double tap to start dragging');

packages/@react-aria/dnd/test/examples.js

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,7 @@ import {useDrag, useDrop} from '../';
1818

1919
export function Draggable(props) {
2020
let preview = useRef(null);
21-
let {dragProps, dragButtonProps, isDragging} = useDrag({
22-
hasDragButton: true,
21+
let {dragProps, isDragging} = useDrag({
2322
getItems() {
2423
return [{
2524
'text/plain': 'hello world'
@@ -29,14 +28,12 @@ export function Draggable(props) {
2928
...props
3029
});
3130

32-
let ref = React.useRef();
33-
let {buttonProps} = useButton({...dragButtonProps, elementType: 'div'}, ref);
34-
3531
return (
3632
<>
3733
<div
38-
ref={ref}
39-
{...mergeProps(dragProps, buttonProps)}
34+
{...dragProps}
35+
role="button"
36+
tabIndex={0}
4037
data-dragging={isDragging}>
4138
{props.children || 'Drag me'}
4239
</div>

packages/@react-aria/interactions/src/useFocusVisible.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,7 @@
1515
// NOTICE file in the root directory of this source tree.
1616
// See https://github.com/facebook/react/tree/cc7c1aece46a6b69b41958d731e0fd27c94bfc6c/packages/react-interactions
1717

18-
import {isMac} from '@react-aria/utils';
19-
import {isVirtualClick} from './utils';
18+
import {isMac, isVirtualClick} from '@react-aria/utils';
2019
import {useEffect, useState} from 'react';
2120

2221
export type Modality = 'keyboard' | 'pointer' | 'virtual';

packages/@react-aria/interactions/src/usePress.ts

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,7 @@
1717

1818
import {disableTextSelection, restoreTextSelection} from './textSelection';
1919
import {DOMAttributes, FocusableElement, PointerType, PressEvents} from '@react-types/shared';
20-
import {focusWithoutScrolling, mergeProps, useGlobalListeners, useSyncRef} from '@react-aria/utils';
21-
import {isVirtualClick} from './utils';
20+
import {focusWithoutScrolling, isVirtualClick, isVirtualPointerEvent, mergeProps, useGlobalListeners, useSyncRef} from '@react-aria/utils';
2221
import {PressResponderContext} from './context';
2322
import {RefObject, useContext, useEffect, useMemo, useRef, useState} from 'react';
2423

@@ -803,21 +802,3 @@ function isValidInputKey(target: HTMLInputElement, key: string) {
803802
? key === ' '
804803
: nonTextInputTypes.has(target.type);
805804
}
806-
807-
function isVirtualPointerEvent(event: PointerEvent) {
808-
// If the pointer size is zero, then we assume it's from a screen reader.
809-
// Android TalkBack double tap will sometimes return a event with width and height of 1
810-
// and pointerType === 'mouse' so we need to check for a specific combination of event attributes.
811-
// Cannot use "event.pressure === 0" as the sole check due to Safari pointer events always returning pressure === 0
812-
// instead of .5, see https://bugs.webkit.org/show_bug.cgi?id=206216. event.pointerType === 'mouse' is to distingush
813-
// Talkback double tap from Windows Firefox touch screen press
814-
return (
815-
(event.width === 0 && event.height === 0) ||
816-
(event.width === 1 &&
817-
event.height === 1 &&
818-
event.pressure === 0 &&
819-
event.detail === 0 &&
820-
event.pointerType === 'mouse'
821-
)
822-
);
823-
}

packages/@react-aria/interactions/src/utils.ts

Lines changed: 1 addition & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -10,35 +10,8 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {isAndroid, useLayoutEffect} from '@react-aria/utils';
1413
import {FocusEvent as ReactFocusEvent, useCallback, useRef} from 'react';
15-
16-
// Original licensing for the following method can be found in the
17-
// NOTICE file in the root directory of this source tree.
18-
// See https://github.com/facebook/react/blob/3c713d513195a53788b3f8bb4b70279d68b15bcc/packages/react-interactions/events/src/dom/shared/index.js#L74-L87
19-
20-
// Keyboards, Assistive Technologies, and element.click() all produce a "virtual"
21-
// click event. This is a method of inferring such clicks. Every browser except
22-
// IE 11 only sets a zero value of "detail" for click events that are "virtual".
23-
// However, IE 11 uses a zero value for all click events. For IE 11 we rely on
24-
// the quirk that it produces click events that are of type PointerEvent, and
25-
// where only the "virtual" click lacks a pointerType field.
26-
27-
export function isVirtualClick(event: MouseEvent | PointerEvent): boolean {
28-
// JAWS/NVDA with Firefox.
29-
if ((event as any).mozInputSource === 0 && event.isTrusted) {
30-
return true;
31-
}
32-
33-
// Android TalkBack's detail value varies depending on the event listener providing the event so we have specific logic here instead
34-
// If pointerType is defined, event is from a click listener. For events from mousedown listener, detail === 0 is a sufficient check
35-
// to detect TalkBack virtual clicks.
36-
if (isAndroid() && (event as PointerEvent).pointerType) {
37-
return event.type === 'click' && event.buttons === 1;
38-
}
39-
40-
return event.detail === 0 && !(event as PointerEvent).pointerType;
41-
}
14+
import {useLayoutEffect} from '@react-aria/utils';
4215

4316
export class SyntheticFocusEvent implements ReactFocusEvent {
4417
nativeEvent: FocusEvent;

packages/@react-aria/utils/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,4 @@ export {useEvent} from './useEvent';
3333
export {useValueEffect} from './useValueEffect';
3434
export {scrollIntoView} from './scrollIntoView';
3535
export {clamp, snapValueToStep} from '@react-stately/utils';
36+
export {isVirtualClick, isVirtualPointerEvent} from './isVirtualEvent';

0 commit comments

Comments
 (0)