Skip to content

Commit 68a7d57

Browse files
authored
Support onAction and drag and drop in listbox (#3421)
1 parent b2aaa7a commit 68a7d57

File tree

19 files changed

+712
-56
lines changed

19 files changed

+712
-56
lines changed

packages/@react-aria/dnd/intl/en-US.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,13 @@
22
"dragItem": "Drag {itemText}",
33
"dragSelectedItems": "Drag {count, plural, one {# selected item} other {# selected items}}",
44
"dragDescriptionKeyboard": "Press Enter to start dragging.",
5+
"dragDescriptionKeyboardAlt": "Press Alt + Enter to start dragging.",
56
"dragDescriptionTouch": "Double tap to start dragging.",
67
"dragDescriptionVirtual": "Click to start dragging.",
8+
"dragDescriptionLongPress": "Long press to start dragging.",
9+
"dragSelectedKeyboard": "Press Enter to drag {count, plural, one {# selected item} other {# selected items}}.",
10+
"dragSelectedKeyboardAlt": "Press Alt + Enter to drag {count, plural, one {# selected item} other {# selected items}}.",
11+
"dragSelectedLongPress": "Long press to drag {count, plural, one {# selected item} other {# selected items}}.",
712
"dragStartedKeyboard": "Started dragging. Press Tab to navigate to a drop target, then press Enter to drop, or press Escape to cancel.",
813
"dragStartedTouch": "Started dragging. Navigate to a drop target, then double tap to drop.",
914
"dragStartedVirtual": "Started dragging. Navigate to a drop target, then click or press Enter to drop.",

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

Lines changed: 76 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ export interface DragOptions {
2828
onDragEnd?: (e: DragEndEvent) => void,
2929
getItems: () => DragItem[],
3030
preview?: RefObject<DragPreviewRenderer>,
31-
getAllowedDropOperations?: () => DropOperation[]
31+
getAllowedDropOperations?: () => DropOperation[],
32+
hasDragButton?: boolean
3233
}
3334

3435
export interface DragResult {
@@ -53,6 +54,7 @@ const MESSAGES = {
5354
};
5455

5556
export function useDrag(options: DragOptions): DragResult {
57+
let {hasDragButton} = options;
5658
let stringFormatter = useLocalizedStringFormatter(intlMessages);
5759
let state = useRef({
5860
options,
@@ -62,12 +64,21 @@ export function useDrag(options: DragOptions): DragResult {
6264
state.options = options;
6365
let [isDragging, setDragging] = useState(false);
6466
let {addGlobalListener, removeAllGlobalListeners} = useGlobalListeners();
67+
let modalityOnPointerDown = useRef<string>(null);
6568

6669
let onDragStart = (e: DragEvent) => {
6770
if (e.defaultPrevented) {
6871
return;
6972
}
7073

74+
// If this drag was initiated by a mobile screen reader (e.g. VoiceOver or TalkBack), enter virtual dragging mode.
75+
if (modalityOnPointerDown.current === 'virtual') {
76+
e.preventDefault();
77+
startDragging(e.target as HTMLElement);
78+
modalityOnPointerDown.current = null;
79+
return;
80+
}
81+
7182
if (typeof options.onDragStart === 'function') {
7283
options.onDragStart({
7384
type: 'dragstart',
@@ -171,8 +182,12 @@ export function useDrag(options: DragOptions): DragResult {
171182
return;
172183
}
173184

185+
startDragging(e.target as HTMLElement);
186+
};
187+
188+
let startDragging = (target: HTMLElement) => {
174189
if (typeof state.options.onDragStart === 'function') {
175-
let rect = (e.target as HTMLElement).getBoundingClientRect();
190+
let rect = target.getBoundingClientRect();
176191
state.options.onDragStart({
177192
type: 'dragstart',
178193
x: rect.x + (rect.width / 2),
@@ -181,7 +196,7 @@ export function useDrag(options: DragOptions): DragResult {
181196
}
182197

183198
DragManager.beginDragging({
184-
element: e.target as HTMLElement,
199+
element: target,
185200
items: state.options.getItems(),
186201
allowedDropOperations: typeof state.options.getAllowedDropOperations === 'function'
187202
? state.options.getAllowedDropOperations()
@@ -198,12 +213,67 @@ export function useDrag(options: DragOptions): DragResult {
198213
};
199214

200215
let modality = useDragModality();
201-
let descriptionProps = useDescription(
202-
stringFormatter.format(!isDragging ? MESSAGES[modality].start : MESSAGES[modality].end)
203-
);
216+
let message: string;
217+
if (!isDragging) {
218+
if (modality === 'touch' && !hasDragButton) {
219+
message = 'dragDescriptionLongPress';
220+
} else {
221+
message = MESSAGES[modality].start;
222+
}
223+
} else {
224+
message = MESSAGES[modality].end;
225+
}
226+
227+
let descriptionProps = useDescription(stringFormatter.format(message));
228+
229+
let interactions: HTMLAttributes<HTMLElement>;
230+
if (!hasDragButton) {
231+
// If there's no separate button to trigger accessible drag and drop mode,
232+
// then add event handlers to the draggable element itself to start dragging.
233+
// For keyboard, we use the Enter key in a capturing listener to prevent other
234+
// events such as selection from also occurring. We attempt to infer whether a
235+
// pointer event (e.g. long press) came from a touch screen reader, and then initiate
236+
// dragging in the native onDragStart listener above.
237+
238+
interactions = {
239+
...descriptionProps,
240+
onPointerDown({nativeEvent: e}) {
241+
// Try to detect virtual drags.
242+
if (e.width < 1 && e.height < 1) {
243+
// iOS VoiceOver.
244+
modalityOnPointerDown.current = 'virtual';
245+
} else {
246+
let rect = (e.target as HTMLElement).getBoundingClientRect();
247+
let centerX = rect.width / 2;
248+
let centerY = rect.height / 2;
249+
250+
if (Math.abs(e.offsetX - centerX) < 0.5 && Math.abs(e.offsetY - centerY) < 0.5) {
251+
// Android TalkBack.
252+
modalityOnPointerDown.current = 'virtual';
253+
} else {
254+
modalityOnPointerDown.current = e.pointerType;
255+
}
256+
}
257+
},
258+
onKeyDownCapture(e) {
259+
if (e.target === e.currentTarget && e.key === 'Enter') {
260+
e.preventDefault();
261+
e.stopPropagation();
262+
}
263+
},
264+
onKeyUpCapture(e) {
265+
if (e.target === e.currentTarget && e.key === 'Enter') {
266+
e.preventDefault();
267+
e.stopPropagation();
268+
startDragging(e.target as HTMLElement);
269+
}
270+
}
271+
};
272+
}
204273

205274
return {
206275
dragProps: {
276+
...interactions,
207277
draggable: 'true',
208278
onDragStart,
209279
onDrag,

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

Lines changed: 84 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,26 +15,58 @@ import {DraggableCollectionState} from '@react-stately/dnd';
1515
import {HTMLAttributes, Key} from 'react';
1616
// @ts-ignore
1717
import intlMessages from '../intl/*.json';
18+
import {useDescription} from '@react-aria/utils';
1819
import {useDrag} from './useDrag';
20+
import {useDragModality} from './utils';
1921
import {useLocalizedStringFormatter} from '@react-aria/i18n';
2022

2123
export interface DraggableItemProps {
22-
key: Key
24+
/** The key of the draggable item within the collection. */
25+
key: Key,
26+
/**
27+
* Whether the item has an explicit focusable drag affordance to initiate accessible drag and drop mode.
28+
* If true, the dragProps will omit these event handlers, and they will be applied to dragButtonProps instead.
29+
*/
30+
hasDragButton?: boolean,
31+
/**
32+
* Whether the item has a primary action (e.g. Enter key or long press) that would
33+
* conflict with initiating accessible drag and drop. If true, the Alt key must be held to
34+
* start dragging with a keyboard, and long press is disabled until selection mode is entered.
35+
* This should be passed from the associated collection item hook (e.g. useOption, useGridListItem, etc.).
36+
*/
37+
hasAction?: boolean
2338
}
2439

2540
export interface DraggableItemResult {
2641
dragProps: HTMLAttributes<HTMLElement>,
2742
dragButtonProps: AriaButtonProps
2843
}
2944

45+
const MESSAGES = {
46+
keyboard: {
47+
selected: 'dragSelectedKeyboard',
48+
notSelected: 'dragDescriptionKeyboard'
49+
},
50+
touch: {
51+
selected: 'dragSelectedLongPress',
52+
notSelected: 'dragDescriptionLongPress'
53+
},
54+
virtual: {
55+
selected: 'dragDescriptionVirtual',
56+
notSelected: 'dragDescriptionVirtual'
57+
}
58+
};
59+
3060
export function useDraggableItem(props: DraggableItemProps, state: DraggableCollectionState): DraggableItemResult {
3161
let stringFormatter = useLocalizedStringFormatter(intlMessages);
62+
let isDisabled = state.selectionManager.isDisabled(props.key);
3263
let {dragProps, dragButtonProps} = useDrag({
3364
getItems() {
3465
return state.getItems(props.key);
3566
},
3667
preview: state.preview,
3768
getAllowedDropOperations: state.getAllowedDropOperations,
69+
hasDragButton: props.hasDragButton,
3870
onDragStart(e) {
3971
state.startDrag(props.key, e);
4072
},
@@ -48,19 +80,63 @@ export function useDraggableItem(props: DraggableItemProps, state: DraggableColl
4880

4981
let item = state.collection.getItem(props.key);
5082
let numKeysForDrag = state.getKeysForDrag(props.key).size;
51-
let isSelected = state.selectionManager.isSelected(props.key);
52-
let message: string;
53-
if (isSelected && numKeysForDrag > 1) {
54-
message = stringFormatter.format('dragSelectedItems', {count: numKeysForDrag});
83+
let isSelected = numKeysForDrag > 1 && state.selectionManager.isSelected(props.key);
84+
let dragButtonLabel: string;
85+
let description: string;
86+
87+
// Override description to include selected item count.
88+
let modality = useDragModality();
89+
if (!props.hasDragButton) {
90+
let msg = MESSAGES[modality][isSelected ? 'selected' : 'notSelected'];
91+
if (props.hasAction && modality === 'keyboard') {
92+
msg += 'Alt';
93+
}
94+
95+
if (isSelected) {
96+
description = stringFormatter.format(msg, {count: numKeysForDrag});
97+
} else {
98+
description = stringFormatter.format(msg);
99+
}
55100
} else {
56-
message = stringFormatter.format('dragItem', {itemText: item?.textValue ?? ''});
101+
if (isSelected) {
102+
dragButtonLabel = stringFormatter.format('dragSelectedItems', {count: numKeysForDrag});
103+
} else {
104+
dragButtonLabel = stringFormatter.format('dragItem', {itemText: item?.textValue ?? ''});
105+
}
106+
}
107+
108+
let descriptionProps = useDescription(description);
109+
if (description) {
110+
Object.assign(dragProps, descriptionProps);
111+
}
112+
113+
if (!props.hasDragButton && props.hasAction) {
114+
let {onKeyDownCapture, onKeyUpCapture} = dragProps;
115+
if (modality === 'touch') {
116+
// Remove long press description if an action is present, because in that case long pressing selects the item.
117+
delete dragProps['aria-describedby'];
118+
}
119+
120+
// Require Alt key if there is a conflicting action.
121+
dragProps.onKeyDownCapture = e => {
122+
if (e.altKey) {
123+
onKeyDownCapture(e);
124+
}
125+
};
126+
127+
dragProps.onKeyUpCapture = e => {
128+
if (e.altKey) {
129+
onKeyUpCapture(e);
130+
}
131+
};
57132
}
58133

59134
return {
60-
dragProps,
135+
dragProps: isDisabled ? {} : dragProps,
61136
dragButtonProps: {
62137
...dragButtonProps,
63-
'aria-label': message
138+
isDisabled,
139+
'aria-label': dragButtonLabel
64140
}
65141
};
66142
}

packages/@react-aria/dnd/stories/DraggableCollection.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ function DraggableCollectionItem({item, state, dragState}) {
153153
shouldSelectOnPressUp: true
154154
}, state, cellRef);
155155

156-
let {dragProps, dragButtonProps} = useDraggableItem({key: item.key}, dragState);
156+
let {dragProps, dragButtonProps} = useDraggableItem({key: item.key, hasDragButton: true}, dragState);
157157

158158
let buttonRef = React.useRef();
159159
let {buttonProps} = useButton({
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import {classNames} from '@react-spectrum/utils';
2+
import dndStyles from './dnd.css';
3+
import {mergeProps} from '@react-aria/utils';
4+
import React from 'react';
5+
import {useDraggableCollectionState} from '@react-stately/dnd';
6+
import {useDraggableItem} from '@react-aria/dnd';
7+
import {useFocusRing} from '@react-aria/focus';
8+
import {useListBox, useOption} from '@react-aria/listbox';
9+
import {useListState} from '@react-stately/list';
10+
11+
export function DraggableListBox(props) {
12+
let state = useListState(props);
13+
let ref = React.useRef();
14+
let {listBoxProps} = useListBox(
15+
{
16+
...props,
17+
shouldSelectOnPressUp: true
18+
},
19+
state,
20+
ref
21+
);
22+
23+
let dragState = useDraggableCollectionState({
24+
...props,
25+
collection: state.collection,
26+
selectionManager: state.selectionManager,
27+
getItems: props.getItems || ((keys) => (
28+
[...keys].map((key) => {
29+
let item = state.collection.getItem(key);
30+
31+
return {
32+
'text/plain': item.textValue
33+
};
34+
})
35+
))
36+
});
37+
38+
return (
39+
<ul {...listBoxProps} ref={ref} className={dndStyles['draggable-listbox']}>
40+
{[...state.collection].map((item) => (
41+
<Option
42+
key={item.key}
43+
item={item}
44+
state={state}
45+
dragState={dragState} />
46+
))}
47+
</ul>
48+
);
49+
}
50+
51+
function Option({item, state, dragState}) {
52+
let ref = React.useRef();
53+
let {optionProps, isPressed, hasAction} = useOption(
54+
{key: item.key},
55+
state,
56+
ref
57+
);
58+
let {isFocusVisible, focusProps} = useFocusRing();
59+
60+
let {dragProps} = useDraggableItem({
61+
key: item.key,
62+
hasAction
63+
}, dragState);
64+
65+
return (
66+
<li
67+
{...mergeProps(dragProps, optionProps, focusProps)}
68+
ref={ref}
69+
className={classNames(dndStyles, 'option', {'focus-visible': isFocusVisible, 'pressed': isPressed})}>
70+
{item.rendered}
71+
</li>
72+
);
73+
}

packages/@react-aria/dnd/stories/Reorderable.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ function CollectionItem({item, state, dragState, dropState}) {
223223
shouldSelectOnPressUp: true
224224
}, state, cellRef);
225225

226-
let {dragProps, dragButtonProps} = useDraggableItem({key: item.key}, dragState);
226+
let {dragProps, dragButtonProps} = useDraggableItem({key: item.key, hasDragButton: true}, dragState);
227227

228228
let dragButtonRef = React.useRef();
229229
let {buttonProps} = useButton({
@@ -239,10 +239,10 @@ function CollectionItem({item, state, dragState, dropState}) {
239239
let id = useId();
240240

241241
return (
242-
<div {...rowProps} ref={rowRef} style={{outline: 'none'}} aria-labelledby={id}>
242+
<div {...mergeProps(rowProps, dragProps)} ref={rowRef} style={{outline: 'none'}} aria-labelledby={id}>
243243
<FocusRing focusRingClass={classNames(dndStyles, 'focus-ring')}>
244244
<div
245-
{...mergeProps(gridCellProps, dragProps)}
245+
{...gridCellProps}
246246
aria-labelledby={id}
247247
ref={cellRef}
248248
className={classNames(dndStyles, 'draggable', 'droppable', {

0 commit comments

Comments
 (0)